diff --git a/BACKLOG.md b/BACKLOG.md index d00e0b1..08cea65 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -1 +1 @@ -# All backlog items completed ✅ +# All backlog items implemented ✅ diff --git a/README.md b/README.md index 2d29cc0..753da1c 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,4 @@ Access with a Gemini client like Lagrange at `gemini://yourdomain.com/`. ## Testing -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. +Run `cargo test` for unit tests. Fix warnings before commits. diff --git a/dist/INSTALL.md b/dist/INSTALL.md index 6ac6ffd..8d5caa3 100644 --- a/dist/INSTALL.md +++ b/dist/INSTALL.md @@ -200,41 +200,6 @@ 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 84e7a5c..a05eb16 100644 --- a/dist/pollux.service +++ b/dist/pollux.service @@ -6,6 +6,7 @@ Wants=network.target [Service] Type=simple ExecStart=/usr/local/bin/pollux +ExecReload=/bin/kill -HUP $MAINPID Restart=on-failure RestartSec=5 User=gemini @@ -14,8 +15,6 @@ 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 929700b..5ea2c67 100644 --- a/src/main.rs +++ b/src/main.rs @@ -48,105 +48,27 @@ async fn main() { // Load config let config_path = args.config.as_deref().unwrap_or("/etc/pollux/config.toml"); - // 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); - } + 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, + }); - // 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 + // Initialize logging let log_level = config.log_level.as_deref().unwrap_or("info"); init_logging(log_level); - // Extract validated config values - let root = config.root.unwrap(); - let cert_path = config.cert.unwrap(); - let key_path = config.key.unwrap(); + // 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"); 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); // Validate max concurrent requests @@ -185,7 +107,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); @@ -194,7 +116,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(); + let expected_hostname = hostname.clone(); // Use configured hostname 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 deleted file mode 100644 index 9ddde09..0000000 --- a/tests/common.rs +++ /dev/null @@ -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"); -} \ No newline at end of file diff --git a/tests/config_validation.rs b/tests/config_validation.rs deleted file mode 100644 index 9a3c951..0000000 --- a/tests/config_validation.rs +++ /dev/null @@ -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(); -} \ No newline at end of file diff --git a/tests/gemini_test_client.py b/tests/gemini_test_client.py deleted file mode 100755 index 351715f..0000000 --- a/tests/gemini_test_client.py +++ /dev/null @@ -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 ", 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 deleted file mode 100644 index afb2547..0000000 --- a/tests/rate_limiting.rs +++ /dev/null @@ -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); -} \ No newline at end of file