Compare commits
10 commits
ea8083fe1f
...
bde6181820
| Author | SHA1 | Date | |
|---|---|---|---|
| bde6181820 | |||
| 01bcda10d0 | |||
| 3e490d85ef | |||
| ad84bf187d | |||
| 1ef0f97ebf | |||
| 4b4651384c | |||
| 13acdd9bcb | |||
| b13c46806c | |||
| b9380483d2 | |||
| caf9d0984f |
9 changed files with 432 additions and 21 deletions
|
|
@ -1 +1 @@
|
|||
# All backlog items implemented ✅
|
||||
# All backlog items completed ✅
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
35
dist/INSTALL.md
vendored
35
dist/INSTALL.md
vendored
|
|
@ -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
|
||||
|
|
|
|||
3
dist/pollux.service
vendored
3
dist/pollux.service
vendored
|
|
@ -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
|
||||
|
|
|
|||
112
src/main.rs
112
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
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
37
tests/common.rs
Normal file
37
tests/common.rs
Normal file
|
|
@ -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");
|
||||
}
|
||||
122
tests/config_validation.rs
Normal file
122
tests/config_validation.rs
Normal file
|
|
@ -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();
|
||||
}
|
||||
71
tests/gemini_test_client.py
Executable file
71
tests/gemini_test_client.py
Executable file
|
|
@ -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 <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()
|
||||
65
tests/rate_limiting.rs
Normal file
65
tests/rate_limiting.rs
Normal file
|
|
@ -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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue