Compare commits

..

No commits in common. "bde61818203cb69faee93c7ebcd8a01adea076be" and "ea8083fe1f5f3178712aad13ee87b37d6f5b3786" have entirely different histories.

9 changed files with 21 additions and 432 deletions

View file

@ -1 +1 @@
# All backlog items completed ✅ # All backlog items implemented ✅

View file

@ -85,6 +85,4 @@ Access with a Gemini client like Lagrange at `gemini://yourdomain.com/`.
## Testing ## Testing
Run `cargo test` for the full test suite, which includes integration tests that require Python 3. Run `cargo test` for unit tests. Fix warnings before commits.
**Note**: Integration tests use Python 3 for Gemini protocol validation. If Python 3 is not available, integration tests will be skipped automatically.

35
dist/INSTALL.md vendored
View file

@ -200,41 +200,6 @@ See `config.toml` for all available options. Key settings:
- `max_concurrent_requests`: Connection limit - `max_concurrent_requests`: Connection limit
- `log_level`: Logging verbosity - `log_level`: Logging verbosity
## Certificate Management
The server uses standard systemd restart for certificate updates. Restart time is less than 1 second.
### Let's Encrypt Integration
For automatic certificate renewal with certbot:
```bash
# Create post-renewal hook
sudo tee /etc/letsencrypt/renewal-hooks/post/restart-pollux.sh > /dev/null << 'EOF'
#!/bin/bash
# Restart Pollux after Let's Encrypt certificate renewal
systemctl restart pollux
logger -t certbot-pollux-restart "Restarted pollux after certificate renewal"
EOF
# Make it executable
sudo chmod +x /etc/letsencrypt/renewal-hooks/post/restart-pollux.sh
# Test the hook
sudo /etc/letsencrypt/renewal-hooks/post/restart-pollux.sh
```
### Manual Certificate Update
```bash
# Restart server to load new certificates
sudo systemctl restart pollux
# Check restart in logs
sudo journalctl -u pollux -f
```
## Upgrading ## Upgrading
```bash ```bash

3
dist/pollux.service vendored
View file

@ -6,6 +6,7 @@ Wants=network.target
[Service] [Service]
Type=simple Type=simple
ExecStart=/usr/local/bin/pollux ExecStart=/usr/local/bin/pollux
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
User=gemini User=gemini
@ -14,8 +15,6 @@ NoNewPrivileges=yes
ProtectHome=yes ProtectHome=yes
ProtectSystem=strict ProtectSystem=strict
ReadOnlyPaths=/etc/pollux /etc/letsencrypt/live/example.com /var/www/example.com ReadOnlyPaths=/etc/pollux /etc/letsencrypt/live/example.com /var/www/example.com
# NOTE: Adjust /etc/letsencrypt/live/example.com and /var/www/example.com to match your config
# The server needs read access to config, certificates, and content files
# NOTE: Adjust paths to match your config: # NOTE: Adjust paths to match your config:
# - /etc/letsencrypt/live/example.com for Let's Encrypt certs # - /etc/letsencrypt/live/example.com for Let's Encrypt certs
# - /var/www/example.com for your content root # - /var/www/example.com for your content root

View file

@ -48,105 +48,27 @@ async fn main() {
// Load config // Load config
let config_path = args.config.as_deref().unwrap_or("/etc/pollux/config.toml"); let config_path = args.config.as_deref().unwrap_or("/etc/pollux/config.toml");
// Check if config file exists let config = config::load_config(config_path).unwrap_or(config::Config {
if !std::path::Path::new(&config_path).exists() { root: None,
eprintln!("Error: Config file '{}' not found", config_path); cert: None,
eprintln!("Create the config file with required fields:"); key: None,
eprintln!(" root = \"/path/to/gemini/content\""); bind_host: None,
eprintln!(" cert = \"/path/to/certificate.pem\""); hostname: None,
eprintln!(" key = \"/path/to/private-key.pem\""); port: None,
eprintln!(" bind_host = \"0.0.0.0\""); log_level: None,
eprintln!(" hostname = \"your.domain.com\""); max_concurrent_requests: None,
std::process::exit(1); });
}
// Load and parse config // Initialize logging
let config = match config::load_config(config_path) {
Ok(config) => config,
Err(e) => {
eprintln!("Error: Failed to parse config file '{}': {}", config_path, e);
eprintln!("Check the TOML syntax and ensure all values are properly quoted.");
std::process::exit(1);
}
};
// Validate required fields
if config.root.is_none() {
eprintln!("Error: 'root' field is required in config file");
eprintln!("Add: root = \"/path/to/gemini/content\"");
std::process::exit(1);
}
if config.cert.is_none() {
eprintln!("Error: 'cert' field is required in config file");
eprintln!("Add: cert = \"/path/to/certificate.pem\"");
std::process::exit(1);
}
if config.key.is_none() {
eprintln!("Error: 'key' field is required in config file");
eprintln!("Add: key = \"/path/to/private-key.pem\"");
std::process::exit(1);
}
if config.hostname.is_none() {
eprintln!("Error: 'hostname' field is required in config file");
eprintln!("Add: hostname = \"your.domain.com\"");
std::process::exit(1);
}
// Validate filesystem
let root_path = std::path::Path::new(config.root.as_ref().unwrap());
if !root_path.exists() {
eprintln!("Error: Root directory '{}' does not exist", config.root.as_ref().unwrap());
eprintln!("Create the directory and add your Gemini files (.gmi, .txt, images)");
std::process::exit(1);
}
if !root_path.is_dir() {
eprintln!("Error: Root path '{}' is not a directory", config.root.as_ref().unwrap());
eprintln!("The 'root' field must point to a directory containing your content");
std::process::exit(1);
}
if let Err(e) = std::fs::read_dir(root_path) {
eprintln!("Error: Cannot read root directory '{}': {}", config.root.as_ref().unwrap(), e);
eprintln!("Ensure the directory exists and the server user has read permission");
std::process::exit(1);
}
let cert_path = std::path::Path::new(config.cert.as_ref().unwrap());
if !cert_path.exists() {
eprintln!("Error: Certificate file '{}' does not exist", config.cert.as_ref().unwrap());
eprintln!("Generate or obtain TLS certificates for your domain");
std::process::exit(1);
}
if let Err(e) = std::fs::File::open(cert_path) {
eprintln!("Error: Cannot read certificate file '{}': {}", config.cert.as_ref().unwrap(), e);
eprintln!("Ensure the file exists and the server user has read permission");
std::process::exit(1);
}
let key_path = std::path::Path::new(config.key.as_ref().unwrap());
if !key_path.exists() {
eprintln!("Error: Private key file '{}' does not exist", config.key.as_ref().unwrap());
eprintln!("Generate or obtain TLS private key for your domain");
std::process::exit(1);
}
if let Err(e) = std::fs::File::open(key_path) {
eprintln!("Error: Cannot read private key file '{}': {}", config.key.as_ref().unwrap(), e);
eprintln!("Ensure the file exists and the server user has read permission");
std::process::exit(1);
}
// Initialize logging after config validation
let log_level = config.log_level.as_deref().unwrap_or("info"); let log_level = config.log_level.as_deref().unwrap_or("info");
init_logging(log_level); init_logging(log_level);
// Extract validated config values // Load configuration from file only
let root = config.root.unwrap(); let root = config.root.expect("root is required");
let cert_path = config.cert.unwrap(); let cert_path = config.cert.expect("cert is required");
let key_path = config.key.unwrap(); let key_path = config.key.expect("key is required");
let bind_host = config.bind_host.unwrap_or_else(|| "0.0.0.0".to_string()); let bind_host = config.bind_host.unwrap_or_else(|| "0.0.0.0".to_string());
let hostname = config.hostname.unwrap(); let hostname = config.hostname.expect("hostname is required");
let port = config.port.unwrap_or(1965); let port = config.port.unwrap_or(1965);
// Validate max concurrent requests // Validate max concurrent requests
@ -194,7 +116,7 @@ async fn main() {
tracing::debug!("Accepted connection from {}", stream.peer_addr().unwrap_or_else(|_| "unknown".parse().unwrap())); tracing::debug!("Accepted connection from {}", stream.peer_addr().unwrap_or_else(|_| "unknown".parse().unwrap()));
let acceptor = acceptor.clone(); let acceptor = acceptor.clone();
let dir = root.clone(); let dir = root.clone();
let expected_hostname = hostname.clone(); let expected_hostname = hostname.clone(); // Use configured hostname
let max_concurrent = max_concurrent_requests; let max_concurrent = max_concurrent_requests;
let test_delay = test_processing_delay; let test_delay = test_processing_delay;
tokio::spawn(async move { tokio::spawn(async move {

View file

@ -1,37 +0,0 @@
use std::path::Path;
use tempfile::TempDir;
pub fn setup_test_environment() -> TempDir {
let temp_dir = TempDir::new().unwrap();
let content_path = temp_dir.path().join("content");
// Create content directory and file
std::fs::create_dir(&content_path).unwrap();
std::fs::write(content_path.join("test.gmi"), "# Test Gemini content\n").unwrap();
// Generate test certificates
generate_test_certificates(temp_dir.path());
temp_dir
}
fn generate_test_certificates(temp_dir: &Path) {
use std::process::Command;
let cert_path = temp_dir.join("cert.pem");
let key_path = temp_dir.join("key.pem");
let status = Command::new("openssl")
.args(&[
"req", "-x509", "-newkey", "rsa:2048",
"-keyout", &key_path.to_string_lossy(),
"-out", &cert_path.to_string_lossy(),
"-days", "1",
"-nodes",
"-subj", "/CN=localhost"
])
.status()
.unwrap();
assert!(status.success(), "Failed to generate test certificates");
}

View file

@ -1,122 +0,0 @@
mod common;
use std::process::Command;
#[test]
fn test_missing_config_file() {
let output = Command::new(env!("CARGO_BIN_EXE_pollux"))
.arg("--config")
.arg("nonexistent.toml")
.output()
.unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(stderr.contains("Config file 'nonexistent.toml' not found"));
assert!(stderr.contains("Create the config file with required fields"));
}
#[test]
fn test_missing_hostname() {
let temp_dir = common::setup_test_environment();
let config_path = temp_dir.path().join("config.toml");
let config_content = format!(r#"
root = "{}"
cert = "{}"
key = "{}"
bind_host = "127.0.0.1"
"#, temp_dir.path().join("content").display(), temp_dir.path().join("cert.pem").display(), temp_dir.path().join("key.pem").display());
std::fs::write(&config_path, config_content).unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_pollux"))
.arg("--config")
.arg(&config_path)
.output()
.unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(stderr.contains("'hostname' field is required"));
assert!(stderr.contains("Add: hostname = \"your.domain.com\""));
}
#[test]
fn test_nonexistent_root_directory() {
let temp_dir = common::setup_test_environment();
let config_path = temp_dir.path().join("config.toml");
let config_content = format!(r#"
root = "/definitely/does/not/exist"
cert = "{}"
key = "{}"
hostname = "example.com"
bind_host = "127.0.0.1"
"#, temp_dir.path().join("cert.pem").display(), temp_dir.path().join("key.pem").display());
std::fs::write(&config_path, config_content).unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_pollux"))
.arg("--config")
.arg(&config_path)
.output()
.unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(stderr.contains("Error: Root directory '/definitely/does/not/exist' does not exist"));
assert!(stderr.contains("Create the directory and add your Gemini files (.gmi, .txt, images)"));
}
#[test]
fn test_missing_certificate_file() {
let temp_dir = common::setup_test_environment();
let config_path = temp_dir.path().join("config.toml");
let config_content = format!(r#"
root = "{}"
cert = "/nonexistent/cert.pem"
key = "{}"
hostname = "example.com"
bind_host = "127.0.0.1"
"#, temp_dir.path().join("content").display(), temp_dir.path().join("key.pem").display());
std::fs::write(&config_path, config_content).unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_pollux"))
.arg("--config")
.arg(&config_path)
.output()
.unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(stderr.contains("Error: Certificate file '/nonexistent/cert.pem' does not exist"));
assert!(stderr.contains("Generate or obtain TLS certificates for your domain"));
}
#[test]
fn test_valid_config_startup() {
let temp_dir = common::setup_test_environment();
let port = 1967 + (std::process::id() % 1000) as u16;
let config_path = temp_dir.path().join("config.toml");
let config_content = format!(r#"
root = "{}"
cert = "{}"
key = "{}"
hostname = "localhost"
bind_host = "127.0.0.1"
port = {}
"#, temp_dir.path().join("content").display(), temp_dir.path().join("cert.pem").display(), temp_dir.path().join("key.pem").display(), port);
std::fs::write(&config_path, config_content).unwrap();
let mut server_process = Command::new(env!("CARGO_BIN_EXE_pollux"))
.arg("--config")
.arg(&config_path)
.spawn()
.unwrap();
// Wait for server to start
std::thread::sleep(std::time::Duration::from_millis(500));
// Check server is still running (didn't exit with error)
assert!(server_process.try_wait().unwrap().is_none(), "Server should still be running with valid config");
// Kill server
server_process.kill().unwrap();
}

View file

@ -1,71 +0,0 @@
#!/usr/bin/env python3
"""
Simple Gemini Test Client
Makes a single Gemini request and prints the status line.
Used by integration tests for rate limiting validation.
Usage: python3 tests/gemini_test_client.py gemini://host:port/path
"""
import sys
import socket
import ssl
def main():
if len(sys.argv) != 2:
print("Usage: python3 gemini_test_client.py <gemini-url>", file=sys.stderr)
sys.exit(1)
url = sys.argv[1]
# Parse URL (basic parsing)
if not url.startswith('gemini://'):
print("Error: URL must start with gemini://", file=sys.stderr)
sys.exit(1)
url_parts = url[9:].split('/', 1) # Remove gemini://
host_port = url_parts[0]
path = '/' + url_parts[1] if len(url_parts) > 1 else '/'
if ':' in host_port:
host, port = host_port.rsplit(':', 1)
port = int(port)
else:
host = host_port
port = 1965
try:
# Create SSL connection
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
sock = socket.create_connection((host, port), timeout=5.0)
ssl_sock = context.wrap_socket(sock, server_hostname=host)
# Send request
request = f"{url}\r\n"
ssl_sock.send(request.encode('utf-8'))
# Read response header
response = b''
while b'\r\n' not in response and len(response) < 1024:
data = ssl_sock.recv(1)
if not data:
break
response += data
ssl_sock.close()
if response:
status_line = response.decode('utf-8', errors='ignore').split('\r\n')[0]
print(status_line)
else:
print("Error: No response")
except Exception as e:
print(f"Error: {e}")
if __name__ == '__main__':
main()

View file

@ -1,65 +0,0 @@
mod common;
#[test]
fn test_rate_limiting_with_concurrent_requests() {
let temp_dir = common::setup_test_environment();
let port = 1967 + (std::process::id() % 1000) as u16;
// Create config with rate limiting enabled
let config_path = temp_dir.path().join("config.toml");
let config_content = format!(r#"
root = "{}"
cert = "{}"
key = "{}"
hostname = "localhost"
bind_host = "127.0.0.1"
port = {}
max_concurrent_requests = 1
"#, temp_dir.path().join("content").display(), temp_dir.path().join("cert.pem").display(), temp_dir.path().join("key.pem").display(), port);
std::fs::write(&config_path, config_content).unwrap();
// Start server binary with test delay to simulate processing time
let mut server_process = std::process::Command::new(env!("CARGO_BIN_EXE_pollux"))
.arg("--config")
.arg(&config_path)
.arg("--test-processing-delay")
.arg("1") // 1 second delay per request
.spawn()
.expect("Failed to start server");
// Wait for server to start
std::thread::sleep(std::time::Duration::from_millis(500));
// Spawn 5 concurrent client processes
let mut handles = vec![];
for _ in 0..5 {
let url = format!("gemini://localhost:{}/test.gmi", port);
let handle = std::thread::spawn(move || {
std::process::Command::new("python3")
.arg("tests/gemini_test_client.py")
.arg(url)
.output()
});
handles.push(handle);
}
// Collect results
let mut results = vec![];
for handle in handles {
let output = handle.join().unwrap().unwrap();
let status = String::from_utf8(output.stdout).unwrap();
results.push(status.trim().to_string());
}
// Kill server
let _ = server_process.kill();
// Analyze results
let success_count = results.iter().filter(|r| r.starts_with("20")).count();
let rate_limited_count = results.iter().filter(|r| r.starts_with("41")).count();
// Validation
assert!(success_count >= 1, "At least 1 request should succeed, got results: {:?}", results);
assert!(rate_limited_count >= 1, "At least 1 request should be rate limited, got results: {:?}", results);
assert_eq!(success_count + rate_limited_count, 5, "All requests should get valid responses, got results: {:?}", results);
}