diff --git a/BACKLOG.md b/BACKLOG.md index 08cea65..d00e0b1 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -1 +1 @@ -# All backlog items implemented ✅ +# All backlog items completed ✅ diff --git a/README.md b/README.md index 753da1c..2d29cc0 100644 --- a/README.md +++ b/README.md @@ -85,4 +85,6 @@ Access with a Gemini client like Lagrange at `gemini://yourdomain.com/`. ## Testing -Run `cargo test` for unit tests. Fix warnings before commits. +Run `cargo test` for the full test suite, which includes integration tests that require Python 3. + +**Note**: Integration tests use Python 3 for Gemini protocol validation. If Python 3 is not available, integration tests will be skipped automatically. diff --git a/dist/INSTALL.md b/dist/INSTALL.md index 8d5caa3..6ac6ffd 100644 --- a/dist/INSTALL.md +++ b/dist/INSTALL.md @@ -200,6 +200,41 @@ See `config.toml` for all available options. Key settings: - `max_concurrent_requests`: Connection limit - `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 ```bash diff --git a/dist/pollux.service b/dist/pollux.service index a05eb16..84e7a5c 100644 --- a/dist/pollux.service +++ b/dist/pollux.service @@ -6,7 +6,6 @@ Wants=network.target [Service] Type=simple ExecStart=/usr/local/bin/pollux -ExecReload=/bin/kill -HUP $MAINPID Restart=on-failure RestartSec=5 User=gemini @@ -15,6 +14,8 @@ NoNewPrivileges=yes ProtectHome=yes ProtectSystem=strict 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: # - /etc/letsencrypt/live/example.com for Let's Encrypt certs # - /var/www/example.com for your content root diff --git a/src/main.rs b/src/main.rs index 5ea2c67..929700b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -48,27 +48,105 @@ async fn main() { // Load config let config_path = args.config.as_deref().unwrap_or("/etc/pollux/config.toml"); - let config = config::load_config(config_path).unwrap_or(config::Config { - root: None, - cert: None, - key: None, - bind_host: None, - hostname: None, - port: None, - log_level: None, - max_concurrent_requests: None, - }); + // Check if config file exists + if !std::path::Path::new(&config_path).exists() { + eprintln!("Error: Config file '{}' not found", config_path); + eprintln!("Create the config file with required fields:"); + eprintln!(" root = \"/path/to/gemini/content\""); + eprintln!(" cert = \"/path/to/certificate.pem\""); + eprintln!(" key = \"/path/to/private-key.pem\""); + eprintln!(" bind_host = \"0.0.0.0\""); + eprintln!(" hostname = \"your.domain.com\""); + std::process::exit(1); + } - // Initialize logging + // Load and parse config + 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"); init_logging(log_level); - // Load configuration from file only - let root = config.root.expect("root is required"); - let cert_path = config.cert.expect("cert is required"); - let key_path = config.key.expect("key is required"); + // Extract validated config values + let root = config.root.unwrap(); + let cert_path = config.cert.unwrap(); + let key_path = config.key.unwrap(); let bind_host = config.bind_host.unwrap_or_else(|| "0.0.0.0".to_string()); - let hostname = config.hostname.expect("hostname is required"); + let hostname = config.hostname.unwrap(); let port = config.port.unwrap_or(1965); // Validate max concurrent requests @@ -107,7 +185,7 @@ async fn main() { let acceptor = TlsAcceptor::from(Arc::new(config)); let listener = TcpListener::bind(format!("{}:{}", bind_host, port)).await.unwrap(); - + // Print startup information print_startup_info(&bind_host, port, &root, &cert_path, &key_path, Some(log_level), max_concurrent_requests); @@ -116,7 +194,7 @@ async fn main() { tracing::debug!("Accepted connection from {}", stream.peer_addr().unwrap_or_else(|_| "unknown".parse().unwrap())); let acceptor = acceptor.clone(); let dir = root.clone(); - let expected_hostname = hostname.clone(); // Use configured hostname + let expected_hostname = hostname.clone(); let max_concurrent = max_concurrent_requests; let test_delay = test_processing_delay; tokio::spawn(async move { diff --git a/tests/common.rs b/tests/common.rs new file mode 100644 index 0000000..9ddde09 --- /dev/null +++ b/tests/common.rs @@ -0,0 +1,37 @@ +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"); +} \ No newline at end of file diff --git a/tests/config_validation.rs b/tests/config_validation.rs new file mode 100644 index 0000000..9a3c951 --- /dev/null +++ b/tests/config_validation.rs @@ -0,0 +1,122 @@ +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(); +} \ No newline at end of file diff --git a/tests/gemini_test_client.py b/tests/gemini_test_client.py new file mode 100755 index 0000000..351715f --- /dev/null +++ b/tests/gemini_test_client.py @@ -0,0 +1,71 @@ +#!/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 ", 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() \ No newline at end of file diff --git a/tests/rate_limiting.rs b/tests/rate_limiting.rs new file mode 100644 index 0000000..afb2547 --- /dev/null +++ b/tests/rate_limiting.rs @@ -0,0 +1,65 @@ +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); +} \ No newline at end of file