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
|
## 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
|
- `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
3
dist/pollux.service
vendored
|
|
@ -6,7 +6,6 @@ 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
|
||||||
|
|
@ -15,6 +14,8 @@ 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
|
||||||
|
|
|
||||||
114
src/main.rs
114
src/main.rs
|
|
@ -48,27 +48,105 @@ 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");
|
||||||
let config = config::load_config(config_path).unwrap_or(config::Config {
|
// Check if config file exists
|
||||||
root: None,
|
if !std::path::Path::new(&config_path).exists() {
|
||||||
cert: None,
|
eprintln!("Error: Config file '{}' not found", config_path);
|
||||||
key: None,
|
eprintln!("Create the config file with required fields:");
|
||||||
bind_host: None,
|
eprintln!(" root = \"/path/to/gemini/content\"");
|
||||||
hostname: None,
|
eprintln!(" cert = \"/path/to/certificate.pem\"");
|
||||||
port: None,
|
eprintln!(" key = \"/path/to/private-key.pem\"");
|
||||||
log_level: None,
|
eprintln!(" bind_host = \"0.0.0.0\"");
|
||||||
max_concurrent_requests: None,
|
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");
|
let log_level = config.log_level.as_deref().unwrap_or("info");
|
||||||
init_logging(log_level);
|
init_logging(log_level);
|
||||||
|
|
||||||
// Load configuration from file only
|
// Extract validated config values
|
||||||
let root = config.root.expect("root is required");
|
let root = config.root.unwrap();
|
||||||
let cert_path = config.cert.expect("cert is required");
|
let cert_path = config.cert.unwrap();
|
||||||
let key_path = config.key.expect("key is required");
|
let key_path = config.key.unwrap();
|
||||||
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.expect("hostname is required");
|
let hostname = config.hostname.unwrap();
|
||||||
let port = config.port.unwrap_or(1965);
|
let port = config.port.unwrap_or(1965);
|
||||||
|
|
||||||
// Validate max concurrent requests
|
// Validate max concurrent requests
|
||||||
|
|
@ -107,7 +185,7 @@ async fn main() {
|
||||||
let acceptor = TlsAcceptor::from(Arc::new(config));
|
let acceptor = TlsAcceptor::from(Arc::new(config));
|
||||||
|
|
||||||
let listener = TcpListener::bind(format!("{}:{}", bind_host, port)).await.unwrap();
|
let listener = TcpListener::bind(format!("{}:{}", bind_host, port)).await.unwrap();
|
||||||
|
|
||||||
// Print startup information
|
// Print startup information
|
||||||
print_startup_info(&bind_host, port, &root, &cert_path, &key_path, Some(log_level), max_concurrent_requests);
|
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()));
|
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(); // Use configured hostname
|
let expected_hostname = hostname.clone();
|
||||||
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 {
|
||||||
|
|
|
||||||
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