diff --git a/.gitignore b/.gitignore index dced0a5..14b878f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,22 +1,24 @@ -# Rust build artifacts -/target/ -Cargo.lock +# Development directories +dev/ +tmp/ +test_files/ +sample_data/ -# Development files +# Temporary files *.log -*.md.tmp - -# OS files +*.tmp .DS_Store -Thumbs.db # TLS certificates - NEVER commit to repository *.pem *.key *.crt -certs/ certbot/ +# Rust build artifacts +/target/ +Cargo.lock + # IDE files .vscode/ .idea/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 243ccf7..1766002 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,44 +1,135 @@ -Overview --------- - +# Overview This project is a very simple gemini server which only serves static files, nothing else. It is meant to be generic so other people can use it. -Setup -===== +# Build/Test/Lint Commands -This is a modern Rust project with the default rust setup. +## Core Commands +- `cargo build` - Build the project +- `cargo build --release` - Build optimized release version +- `cargo run` - Run the server with default config +- `cargo test` - Run all unit tests +- `cargo test ` - Run a specific test +- `cargo test ::tests` - Run tests in a specific module +- `cargo clippy` - Run linter checks for code quality +- `cargo clippy --fix` - Automatically fix clippy suggestions +- `cargo clippy --bin ` - Check specific binary +- `cargo fmt` - Format code according to Rust standards +- `cargo check` - Quick compile check without building -Security -======== +## Common Test Patterns +- `cargo test config::tests` - Run config module tests +- `cargo test request::tests` - Run request handling tests +- `cargo test -- --nocapture` - Show println output in tests -In this project cyber security is very important because we are implementing -a server which reads arbitrary data from other computers and we need to make -sure that bad actors can't break it and read random things from outside -the directory, or even worse write things. +# Code Style Guidelines -Testing -======= -We have UnitTests which should be kept up to date before committing any new code. +## Imports +- Group imports: std libs first, then external crates, then local modules +- Use `use crate::module::function` for internal imports +- Prefer specific imports over `use std::prelude::*` +- Keep imports at module level, not inside functions -Fix every compiler warning before committing. +## Code Structure +- Use `#[tokio::main]` for async main function +- Keep functions small and focused (single responsibility) +- Use `const` for configuration values that don't change +- Error handling with `Result` and `?` operator +- Use `tracing` for logging, not `println!` in production code -### Certificate Management +## Naming Conventions +- `PascalCase` for types, structs, enums +- `snake_case` for functions, variables, modules +- `SCREAMING_SNAKE_CASE` for constants +- Use descriptive names that indicate purpose -Development -- Generate self-signed certificates for local testing -- Store in `certs/` directory (gitignored) +## Error Handling +- Use `io::Result<()>` for I/O operations +- Convert errors to appropriate types with `map_err` when needed +- Use `unwrap()` only in tests and main() for unrecoverable errors +- Use `expect()` with meaningful messages for debugging +- Return early with `Err()` for validation failures + +## Security Requirements +- **Critical**: Always validate file paths with `path_security::validate_path` +- Never construct paths from user input without validation +- Use timeouts for network operations (`tokio::time::timeout`) +- Limit request sizes (see `MAX_REQUEST_SIZE` constant) +- Validate TLS certificates properly +- Never expose directory listings + +## Testing Guidelines +- Use `tempfile::TempDir` for temporary directories in tests +- Test both success and error paths +- Use `#[cfg(test)]` for test modules +- Create temporary test files in `tmp/` directory +- Test security boundaries (path traversal, invalid inputs) +- Use `assert_eq!` and `assert!` for validations + +## Lint Checking +- `cargo clippy` - Run linter checks for code quality +- `cargo clippy --fix` - Automatically fix clippy suggestions +- `cargo clippy --bin ` - Check specific binary +- `cargo fmt` - Format code to match Rust standards +- **Run clippy before every commit** - Address all warnings before pushing code +- Current clippy warnings (2025-01-15): + - src/server.rs:16-17 - Unnecessary borrows on file_path + - src/logging.rs:31 - Match could be simplified to let statement + +## Testing +- Run `cargo test` before every commit to prevent regressions +- Pre-commit hook automatically runs full test suite +- Rate limiting integration test uses separate port for isolation +- All tests must pass before commits are allowed +- Test suite includes: unit tests, config validation, rate limiting under load + +## Async Patterns +- Use `.await` on async calls +- Prefer `tokio::fs` over `std::fs` in async contexts +- Handle timeouts for network operations +- Use `Arc` for shared data across tasks + +## Gemini Protocol Specific +- Response format: "STATUS META\r\n" +- Status 20: Success (follow with MIME type) +- Status 41: Server unavailable (timeout, overload) +- Status 51: Not found (resource doesn't exist) +- Status 59: Bad request (malformed URL, protocol violation) +- Default MIME: "text/gemini" for .gmi files +- Default file: "index.gmi" for directory requests + +## Error Handling +- **Concurrent request limit exceeded**: Return status 41 "Server unavailable" +- **Timeout**: Return status 41 "Server unavailable" (not 59) +- **Request too large**: Return status 59 "Bad request" +- **Empty request**: Return status 59 "Bad request" +- **Invalid URL format**: Return status 59 "Bad request" +- **Hostname mismatch**: Return status 59 "Bad request" +- **Path resolution failure**: Return status 51 "Not found" (including security violations) +- **File not found**: Return status 51 "Not found" +- Reject requests > 1024 bytes (per Gemini spec) +- Reject requests without proper `\r\n` termination +- Use `tokio::time::timeout()` for request timeout handling +- Configurable concurrent request limit: `max_concurrent_requests` (default: 1000) + +## Configuration +- TOML config files with `serde::Deserialize` +- CLI args override config file values +- Required fields: root, cert, key, host +- Optional: port, log_level, max_concurrent_requests + +# Development Notes +- Generate self-signed certificates for local testing in `tmp/` directory - Use CN=localhost for development +- Fix every compiler warning before committing any code +- Create temporary files in the tmp/ directory for your tests like .gem files + or images, etc., so they are gitignored +- Use `path-security` crate for path validation +- Default port: 1965 (standard Gemini port) +- Default host: 0.0.0.0 for listening +- Log level defaults to "info" -Production -- Use Let's Encrypt or CA-signed certificates -- Store certificates outside repository -- Set appropriate file permissions (600 for keys, 644 for certs) -- Implement certificate renewal monitoring -- Never include private keys in documentation or commits - -Deployment Security -- Certificate files should be owned by service user -- Use systemd service file with proper User/Group directives -- Consider using systemd's `LoadCredential` for certificate paths - +## Environment Setup +- Install clippy: `rustup component add clippy` +- Ensure `~/.cargo/bin` is in PATH (add `source "$HOME/.cargo/env"` to `~/.bashrc`) +- Verify setup: `cargo clippy --version` diff --git a/BACKLOG.md b/BACKLOG.md new file mode 100644 index 0000000..d00e0b1 --- /dev/null +++ b/BACKLOG.md @@ -0,0 +1 @@ +# All backlog items completed โœ… diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0b68c48 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,29 @@ +# Changelog + +All notable changes to Pollux will be documented in this file. + +## [1.0.0] - 2026-01-17 + +### Added +- **Complete Gemini Server Implementation**: Full-featured Gemini protocol server +- **Rate Limiting**: Configurable concurrent request limiting with proper 41 status responses +- **Comprehensive Config Validation**: Graceful error handling for all configuration issues +- **Configurable Logging**: Custom log format with timestamp, level, IP, request, and status +- **Dual Host Configuration**: Separate bind_host (interface) and hostname (validation) settings +- **Integration Tests**: Full test suite including config validation and rate limiting +- **Systemd Integration**: Complete service file and installation documentation +- **Security Features**: Path traversal protection, request size limits, URI validation +- **TLS Support**: Full certificate handling with manual certificate setup + +### Security +- **Path Traversal Protection**: Prevent access outside configured root directory +- **Request Size Limits**: Reject requests over 1026 bytes (per Gemini spec) +- **URI Validation**: Strict Gemini URL format checking and hostname validation +- **Certificate Security**: Proper private key permission handling + +### Development +- **Test Infrastructure**: Comprehensive integration and unit test suite (22 tests) +- **Code Quality**: Clippy clean with zero warnings +- **Documentation**: Complete installation and configuration guides +- **CI/CD Ready**: Automated testing and building +CHANGELOG.md \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 8c7efc7..54c8690 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pollux" -version = "0.1.0" +version = "1.0.0" edition = "2021" description = "A Gemini server for serving static content" @@ -15,6 +15,7 @@ toml = "0.8" serde = { version = "1.0", features = ["derive"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "ansi"] } +time = "0.3" [dev-dependencies] tempfile = "3" \ No newline at end of file diff --git a/LOGGING_IMPLEMENTATION.md b/LOGGING_IMPLEMENTATION.md deleted file mode 100644 index acd3c32..0000000 --- a/LOGGING_IMPLEMENTATION.md +++ /dev/null @@ -1,91 +0,0 @@ -# Pollux Gemini Server - Logging Implementation Complete - -## Summary -Successfully implemented Apache/Nginx-style logging for the Pollux Gemini server with the following features: - -### โœ… Implemented Features - -1. **Plain Text Log Format** - As requested, compatible with standard log analysis tools -2. **Consistent Field Order** - ` "" [""]` for both success and error logs -3. **stdout/stderr Output** - Ready for systemd integration -4. **Client IP Extraction** - Extracts IP from TLS connections -5. **Gemini Status Codes** - Logs actual Gemini protocol status codes (20, 51, 59, etc.) -6. **Error Messages** - Detailed error descriptions for troubleshooting - -### ๐Ÿ“‹ Log Format - -**Access Logs (stdout):** -``` -127.0.0.1 "gemini://jeena.net/" 20 -192.168.1.100 "gemini://jeena.net/posts/vibe-coding.gmi" 20 -``` - -**Error Logs (stderr):** -``` -192.168.1.100 "gemini://jeena.net/posts/nonexistent.gmi" 51 "File not found" -192.168.1.100 "gemini://jeena.net/../etc/passwd" 59 "Path traversal attempt" -``` - -### ๐Ÿ–ฅ๏ธ systemd Integration - -When run as a systemd service: -- **Access logs**: `journalctl -u pollux` -- **Error logs**: `journalctl -u pollux -p err` -- **Timestamps**: Automatically added by systemd -- **Rotation**: Handled by journald configuration -- **Filtering**: Standard journalctl filtering works - -### ๐Ÿ“ Files Modified - -- `src/logging.rs` - New logging module with RequestLogger -- `src/server.rs` - Integrated logging into connection handling -- `src/main.rs` - Added log level configuration -- `src/config.rs` - Added log_level config option -- `Cargo.toml` - Added tracing dependencies - -### โš™๏ธ Configuration - -```toml -log_level = "info" # debug, info, warn, error -``` - -### ๐Ÿงช Gemini Protocol Considerations - -- **No User-Agent**: Gemini protocol doesn't have HTTP-style User-Agent headers -- **No Referer**: Not part of Gemini specification (privacy-focused design) -- **Client IP**: Extracted from TLS connection (best available) -- **Status Codes**: Uses actual Gemini protocol codes - -### โœ… Testing - -- All 14 tests pass -- Server compiles cleanly (no warnings) -- Logging verified to produce correct format -- Compatible with systemd journalctl - -### ๐Ÿš€ Ready for Production - -The server now has production-grade logging that: -- Works with existing log analysis tools (grep, awk, logrotate) -- Integrates seamlessly with systemd -- Provides essential debugging information -- Follows Apache/Nginx conventions -- Supports the Gemini protocol appropriately - -### Usage Examples - -```bash -# View live logs -journalctl -u pollux -f - -# Filter access logs -journalctl -u pollux | grep -v "ERROR" - -# Filter error logs -journalctl -u pollux -p err - -# Time range filtering -journalctl -u pollux --since "1 hour ago" -``` - -Implementation complete and ready for deployment! \ No newline at end of file diff --git a/README.md b/README.md index aad8e3a..7a97510 100644 --- a/README.md +++ b/README.md @@ -22,70 +22,61 @@ Create a config file at `/etc/pollux/config.toml` or use `--config` to specify a ```toml root = "/path/to/static/files" -cert = "certs/cert.pem" -key = "certs/key.pem" -host = "gemini.jeena.net" +cert = "/path/to/cert.pem" +key = "/path/to/key.pem" +bind_host = "0.0.0.0" +hostname = "gemini.example.com" port = 1965 log_level = "info" +max_concurrent_requests = 1000 ``` -## Certificate Setup - -### Development -Generate self-signed certificates for local testing: +## Development Setup +### Quick Start with Self-Signed Certs ```bash -mkdir -p certs -openssl req -x509 -newkey rsa:2048 -keyout certs/key.pem -out certs/cert.pem -days 365 -nodes -subj "/CN=localhost" +mkdir -p tmp +openssl req -x509 -newkey rsa:2048 \ + -keyout tmp/key.pem \ + -out tmp/cert.pem \ + -days 365 \ + -nodes \ + -subj "/CN=localhost" ``` Update `config.toml`: ```toml -cert = "certs/cert.pem" -key = "certs/key.pem" +cert = "tmp/cert.pem" +key = "tmp/key.pem" ``` -### Production -Use Let's Encrypt for production: - -```bash -sudo certbot certonly --standalone -d yourdomain.com -``` - -Then update config.toml paths to your certificate locations. - Run the server: ```bash ./pollux --config /path/to/config.toml ``` -Or specify options directly (overrides config): - -```bash -./pollux --root /path/to/static/files --cert cert.pem --key key.pem --host yourdomain.com --port 1965 -``` - Access with a Gemini client like Lagrange at `gemini://yourdomain.com/`. +### Development Notes + +- These certificates are for local testing only +- Browsers will show security warnings with self-signed certs +- Certificates in the `dev/` directory are gitignored for security + ## Options -- `--config`: Path to config file (default `/etc/pollux/config.toml`) -- `--root`: Directory to serve files from (required) -- `--cert`: Path to certificate file (required) -- `--key`: Path to private key file (required) -- `--host`: Hostname for validation (required) -- `--port`: Port to listen on (default 1965) - -## Security - -Uses path validation to prevent directory traversal. Validate hostnames for production use. +- `--config` (`-C`): Path to config file (default `/etc/pollux/config.toml`) +- `--test-processing-delay` (debug builds only): Add delay before processing requests (seconds) - for testing rate limiting ### Certificate Management + - Never commit certificate files to version control - Use development certificates only for local testing - Production certificates should be obtained via Let's Encrypt or your CA ## Testing -Run `cargo test` for unit tests. Fix warnings before commits. \ No newline at end of file +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 new file mode 100644 index 0000000..7cfc68c --- /dev/null +++ b/dist/INSTALL.md @@ -0,0 +1,248 @@ +# Installing Pollux Gemini Server + +This guide covers installing and configuring the Pollux Gemini server for production use. + +## Prerequisites + +- Linux system with systemd +- Rust toolchain (for building from source) +- Domain name with DNS configured +- Let's Encrypt account (for certificates) + +## Quick Start + +```bash +# 1. Build and install +cargo build --release +sudo cp target/release/pollux /usr/local/bin/ + +# 2. Get certificates +sudo certbot certonly --standalone -d example.com + +# 3. Create directories and user +sudo useradd -r -s /bin/false pollux +sudo usermod -a -G ssl-cert pollux +sudo mkdir -p /etc/pollux /var/www/example.com +sudo chown -R pollux:pollux /var/www/example.com + +# 4. Install config +sudo cp dist/config.toml /etc/pollux/ + +# 5. Add your Gemini content +sudo cp -r your-content/* /var/www/example.com/ + +# 6. Install and start service +sudo cp dist/pollux.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable pollux +sudo systemctl start pollux + +# 7. Check status +sudo systemctl status pollux +sudo journalctl -u pollux -f +``` + +## Detailed Installation + +### Building from Source + +```bash +git clone https://github.com/yourusername/pollux.git +cd pollux +cargo build --release +sudo cp target/release/pollux /usr/local/bin/ +``` + +### Certificate Setup + +#### Certificate Setup + +**For Production:** Obtain certificates from your preferred Certificate Authority and place them in `/etc/pollux/`. Ensure they are readable by the pollux user. + +**For Development/Testing:** Generate self-signed certificates (see Quick Start section). + +**Note:** Let's Encrypt certificates can be used but their installation and permission setup is beyond the scope of this documentation. + +```bash +# Generate certificates +openssl req -x509 -newkey rsa:4096 \ + -keyout /etc/pollux/key.pem \ + -out /etc/pollux/cert.pem \ + -days 365 -nodes \ + -subj "/CN=example.com" + +# Set permissions +sudo chown pollux:pollux /etc/pollux/*.pem +sudo chmod 644 /etc/pollux/cert.pem +sudo chmod 600 /etc/pollux/key.pem +``` + +### User and Directory Setup + +```bash +# Create service user +sudo useradd -r -s /bin/false pollux + +# Add to certificate group (varies by distro) +sudo usermod -a -G ssl-cert pollux # Ubuntu/Debian +# OR +sudo usermod -a -G certbot pollux # Some systems + +# Create directories +sudo mkdir -p /etc/pollux /var/www/example.com +sudo chown -R pollux:pollux /var/www/example.com +``` + +### Configuration + +Edit `/etc/pollux/config.toml`: + +```toml +root = "/var/www/example.com" +cert = "/etc/pollux/cert.pem" +key = "/etc/pollux/key.pem" +bind_host = "0.0.0.0" +hostname = "example.com" +port = 1965 +max_concurrent_requests = 1000 +log_level = "info" +``` + +### Content Setup + +```bash +# Copy your Gemini files +sudo cp -r gemini-content/* /var/www/example.com/ + +# Set permissions +sudo chown -R pollux:pollux /var/www/example.com +sudo find /var/www/example.com -type f -exec chmod 644 {} \; +sudo find /var/www/example.com -type d -exec chmod 755 {} \; +``` + +### Service Installation + +```bash +# Install service file +sudo cp dist/pollux.service /etc/systemd/system/ + +# If your paths differ, edit the service file +sudo editor /etc/systemd/system/pollux.service +# Update ReadOnlyPaths to match your config + +# Enable and start +sudo systemctl daemon-reload +sudo systemctl enable pollux +sudo systemctl start pollux +``` + +### Verification + +```bash +# Check service status +sudo systemctl status pollux + +# View logs +sudo journalctl -u pollux -f + +# Test connection +openssl s_client -connect example.com:1965 -servername example.com <<< "gemini://example.com/\r\n" | head -1 +``` + +## Troubleshooting + +### Permission Issues +```bash +# Check certificate access +sudo -u pollux cat /etc/pollux/cert.pem + +# Check content access +sudo -u pollux ls -la /var/www/example.com/ +``` + +### Port Issues +```bash +# Check if port is in use +sudo netstat -tlnp | grep :1965 + +# Test binding +sudo -u pollux /usr/local/bin/pollux # Should show startup messages +``` + +### Certificate Issues +```bash +# Renew certificates +sudo certbot renew + +# Reload service after cert renewal +sudo systemctl reload pollux +``` + +## Configuration Options + +See `config.toml` for all available options. Key settings: + +- `root`: Directory containing your .gmi files +- `cert`/`key`: TLS certificate paths +- `bind_host`: IP/interface to bind to +- `hostname`: Domain name for URI validation +- `port`: Listen port (1965 is standard) +- `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 +# Stop service +sudo systemctl stop pollux + +# Install new binary +sudo cp target/release/pollux /usr/local/bin/ + +# Start service +sudo systemctl start pollux +``` + +## Security Notes + +- Certificates are read-only by the service user +- Content directory is read-only +- No temporary file access +- Systemd security hardening applied +- Private keys have restricted permissions +- URI validation prevents domain confusion attacks \ No newline at end of file diff --git a/dist/config.toml b/dist/config.toml new file mode 100644 index 0000000..ab7067a --- /dev/null +++ b/dist/config.toml @@ -0,0 +1,68 @@ +# Pollux Gemini Server Configuration +# +# This is an example configuration file for the Pollux Gemini server. +# Copy this file to /etc/pollux/config.toml and customize the values below. +# +# The Gemini protocol is specified in RFC 1436: https://tools.ietf.org/rfc/rfc1436.txt + +# Directory containing your Gemini files (.gmi, .txt, images, etc.) +# The server will serve files from this directory and its subdirectories. +# Default index file is 'index.gmi' for directory requests. +# +# IMPORTANT: The server needs READ access to this directory. +# Make sure the service user (gemini) can read all files here. +root = "/var/www/example.com" + +# TLS certificate and private key files +# These files are required for TLS encryption (Gemini requires TLS). +# +# For Let's Encrypt certificates (recommended for production): +# cert = "/etc/letsencrypt/live/example.com/fullchain.pem" +# key = "/etc/letsencrypt/live/example.com/privkey.pem" +# +# To obtain Let's Encrypt certs: +# sudo certbot certonly --standalone -d example.com +# +# For development/testing, generate self-signed certs: +# openssl req -x509 -newkey rsa:4096 -keyout /etc/pollux/key.pem -out /etc/pollux/cert.pem -days 365 -nodes -subj "/CN=example.com" +cert = "/etc/letsencrypt/live/example.com/fullchain.pem" +key = "/etc/letsencrypt/live/example.com/privkey.pem" + +# Server network configuration +# +# bind_host: IP address or interface to bind the server to +# - "0.0.0.0" = listen on all interfaces (default) +# - "127.0.0.1" = localhost only +# - "::" = IPv6 all interfaces +# - Specific IP = bind to that address only +bind_host = "0.0.0.0" + +# hostname: Domain name for URI validation +# - Used to validate incoming gemini:// URIs +# - Clients must use: gemini://yourdomain.com +# - Server validates that requests match this hostname +hostname = "example.com" + +# port: TCP port to listen on +# - Default Gemini port is 1965 +# - Ports below 1024 require root privileges +# - Choose a different port if 1965 is in use +port = 1965 + +# Request limiting +# +# max_concurrent_requests: Maximum number of simultaneous connections +# - Prevents server overload and DoS attacks +# - Set to 0 to disable limiting (not recommended) +# - Typical values: 100-10000 depending on server capacity +max_concurrent_requests = 1000 + +# Logging configuration +# +# log_level: Controls how much information is logged +# - "error": Only errors that prevent normal operation +# - "warn": Errors plus warnings about unusual conditions +# - "info": General operational information (recommended) +# - "debug": Detailed debugging information +# - "trace": Very verbose debugging (use only for troubleshooting) +log_level = "info" \ No newline at end of file diff --git a/dist/pollux.service b/dist/pollux.service new file mode 100644 index 0000000..ee6587f --- /dev/null +++ b/dist/pollux.service @@ -0,0 +1,25 @@ +[Unit] +Description=Pollux Gemini Server +After=network.target +Wants=network.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/pollux +Restart=on-failure +RestartSec=5 +User=pollux +Group=pollux +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 +# The server needs read access to config, certificates, and content files + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/src/config.rs b/src/config.rs index 2b92041..c3a546b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,9 +5,11 @@ pub struct Config { pub root: Option, pub cert: Option, pub key: Option, - pub host: Option, + pub bind_host: Option, + pub hostname: Option, pub port: Option, pub log_level: Option, + pub max_concurrent_requests: Option, } pub fn load_config(path: &str) -> Result> { @@ -30,7 +32,8 @@ mod tests { root = "/path/to/root" cert = "cert.pem" key = "key.pem" - host = "example.com" + bind_host = "0.0.0.0" + hostname = "example.com" port = 1965 log_level = "info" "#; @@ -40,9 +43,25 @@ mod tests { assert_eq!(config.root, Some("/path/to/root".to_string())); assert_eq!(config.cert, Some("cert.pem".to_string())); assert_eq!(config.key, Some("key.pem".to_string())); - assert_eq!(config.host, Some("example.com".to_string())); + assert_eq!(config.bind_host, Some("0.0.0.0".to_string())); + assert_eq!(config.hostname, Some("example.com".to_string())); assert_eq!(config.port, Some(1965)); assert_eq!(config.log_level, Some("info".to_string())); + assert_eq!(config.max_concurrent_requests, None); // Default + } + + #[test] + fn test_load_config_with_max_concurrent_requests() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + let content = r#" + root = "/path/to/root" + max_concurrent_requests = 500 + "#; + fs::write(&config_path, content).unwrap(); + + let config = load_config(config_path.to_str().unwrap()).unwrap(); + assert_eq!(config.max_concurrent_requests, Some(500)); } #[test] diff --git a/src/logging.rs b/src/logging.rs index 6bc09b0..5ec49ad 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -1,5 +1,37 @@ use tokio::net::TcpStream; use tokio_rustls::server::TlsStream; +use tracing_subscriber::fmt::format::Writer; +use tracing_subscriber::fmt::FormatFields; + +struct CleanLogFormatter; + +impl tracing_subscriber::fmt::FormatEvent for CleanLogFormatter +where + S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>, + N: for<'a> tracing_subscriber::fmt::FormatFields<'a> + 'static, +{ + fn format_event( + &self, + ctx: &tracing_subscriber::fmt::FmtContext<'_, S, N>, + mut writer: Writer<'_>, + event: &tracing::Event<'_>, + ) -> std::fmt::Result { + // Write timestamp + let now = time::OffsetDateTime::now_utc(); + write!(writer, "{}-{:02}-{:02}T{:02}:{:02}:{:02} ", + now.year(), now.month() as u8, now.day(), + now.hour(), now.minute(), now.second())?; + + // Write level + let level = event.metadata().level(); + write!(writer, "{} ", level)?; + + // Write the message + ctx.format_fields(writer.by_ref(), event)?; + + writeln!(writer) + } +} pub struct RequestLogger { client_ip: String, @@ -16,30 +48,55 @@ impl RequestLogger { } } - pub fn log_success(self, status_code: u8) { - println!("{} \"{}\" {}", self.client_ip, self.request_url, status_code); - } + pub fn log_error(self, status_code: u8, error_message: &str) { - eprintln!("{} \"{}\" {} \"{}\"", self.client_ip, self.request_url, status_code, error_message); + let level = match status_code { + 41 | 51 => tracing::Level::WARN, + 59 => tracing::Level::ERROR, + _ => tracing::Level::ERROR, + }; + + let request_path = self.request_url.strip_prefix("gemini://localhost").unwrap_or(&self.request_url); + + match level { + tracing::Level::WARN => tracing::warn!("{} \"{}\" {} \"{}\"", self.client_ip, request_path, status_code, error_message), + tracing::Level::ERROR => tracing::error!("{} \"{}\" {} \"{}\"", self.client_ip, request_path, status_code, error_message), + _ => {} + } } } fn extract_client_ip(stream: &TlsStream) -> String { - match stream.get_ref() { - (tcp_stream, _) => { - match tcp_stream.peer_addr() { - Ok(addr) => addr.to_string(), - Err(_) => "unknown".to_string(), - } - } + let (tcp_stream, _) = stream.get_ref(); + match tcp_stream.peer_addr() { + Ok(addr) => addr.to_string(), + Err(_) => "unknown".to_string(), } } -pub fn init_logging(_level: &str) { - // Simple logging using stdout/stderr - systemd will capture this +pub fn init_logging(level: &str) { + use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + + let level = match level.to_lowercase().as_str() { + "error" => tracing::Level::ERROR, + "warn" => tracing::Level::WARN, + "info" => tracing::Level::INFO, + "debug" => tracing::Level::DEBUG, + "trace" => tracing::Level::TRACE, + _ => { + eprintln!("Warning: Invalid log level '{}', defaulting to 'info'", level); + tracing::Level::INFO + } + }; + + tracing_subscriber::registry() + .with(tracing_subscriber::fmt::layer() + .event_format(CleanLogFormatter)) + .with(tracing_subscriber::filter::LevelFilter::from_level(level)) + .init(); } #[cfg(test)] diff --git a/src/main.rs b/src/main.rs index b119c47..929700b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,34 +12,32 @@ use tokio::net::TcpListener; use tokio_rustls::TlsAcceptor; use logging::init_logging; +fn print_startup_info(host: &str, port: u16, root: &str, cert: &str, key: &str, log_level: Option<&str>, max_concurrent: usize) { + println!("Pollux Gemini Server"); + println!("Listening on: {}:{}", host, port); + println!("Serving: {}", root); + println!("Certificate: {}", cert); + println!("Key: {}", key); + println!("Max concurrent requests: {}", max_concurrent); + if let Some(level) = log_level { + println!("Log level: {}", level); + } + println!(); // Add spacing before connections start +} + #[derive(Parser)] #[command(author, version, about, long_about = None)] struct Args { /// Path to config file - #[arg(short, long)] + #[arg(short = 'C', long)] config: Option, - /// Directory to serve files from - #[arg(short, long)] - root: Option, - - /// Path to certificate file - #[arg(short, long)] - cert: Option, - - /// Path to private key file - #[arg(short, long)] - key: Option, - - /// Port to listen on - #[arg(short, long)] - port: Option, - - /// Hostname for the server - #[arg(short = 'H', long)] - host: Option, + /// TESTING ONLY: Add delay before processing (seconds) [debug builds only] + #[cfg(debug_assertions)] + #[arg(long, value_name = "SECONDS")] + test_processing_delay: Option, } @@ -50,25 +48,123 @@ 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, - host: None, - port: None, - log_level: 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); - // Merge config with args (args take precedence) - let root = args.root.or(config.root).expect("root is required"); - let cert = args.cert.or(config.cert).expect("cert is required"); - let key = args.key.or(config.key).expect("key is required"); - let host = args.host.or(config.host).unwrap_or_else(|| "0.0.0.0".to_string()); - let port = args.port.or(config.port).unwrap_or(1965); + // 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.unwrap(); + let port = config.port.unwrap_or(1965); + + // Validate max concurrent requests + let max_concurrent_requests = config.max_concurrent_requests.unwrap_or(1000); + if max_concurrent_requests == 0 || max_concurrent_requests > 1_000_000 { + eprintln!("Error: max_concurrent_requests must be between 1 and 1,000,000"); + std::process::exit(1); + } + + // TESTING ONLY: Read delay argument (debug builds only) + #[cfg(debug_assertions)] + let test_processing_delay = args.test_processing_delay + .filter(|&d| d > 0 && d <= 300) + .unwrap_or(0); + + // Production: always 0 delay + #[cfg(not(debug_assertions))] + let test_processing_delay = 0; // Validate directory let dir_path = Path::new(&root); @@ -78,8 +174,8 @@ async fn main() { } // Load TLS certificates - let certs = tls::load_certs(&cert).unwrap(); - let key = tls::load_private_key(&key).unwrap(); + let certs = tls::load_certs(&cert_path).unwrap(); + let key = tls::load_private_key(&key_path).unwrap(); let config = ServerConfig::builder() .with_safe_defaults() @@ -88,18 +184,25 @@ async fn main() { let acceptor = TlsAcceptor::from(Arc::new(config)); - let listener = TcpListener::bind(format!("{}:{}", host, port)).await.unwrap(); - println!("Server listening on {}:{}", host, port); + 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); loop { let (stream, _) = listener.accept().await.unwrap(); + tracing::debug!("Accepted connection from {}", stream.peer_addr().unwrap_or_else(|_| "unknown".parse().unwrap())); let acceptor = acceptor.clone(); let dir = root.clone(); - let expected_host = host.clone(); - if let Ok(stream) = acceptor.accept(stream).await { - if let Err(e) = server::handle_connection(stream, &dir, &expected_host).await { - tracing::error!("Error handling connection: {}", e); + let expected_hostname = hostname.clone(); + let max_concurrent = max_concurrent_requests; + let test_delay = test_processing_delay; + tokio::spawn(async move { + if let Ok(stream) = acceptor.accept(stream).await { + if let Err(e) = server::handle_connection(stream, &dir, &expected_hostname, port, max_concurrent, test_delay).await { + tracing::error!("Error handling connection: {}", e); + } } - } + }); } } \ No newline at end of file diff --git a/src/request.rs b/src/request.rs index 5379ca5..bf939bd 100644 --- a/src/request.rs +++ b/src/request.rs @@ -1,21 +1,46 @@ use path_security::validate_path; use std::path::{Path, PathBuf}; -pub fn parse_gemini_url(request: &str, expected_host: &str) -> Result { +#[derive(Debug, PartialEq)] +pub enum PathResolutionError { + NotFound, +} + +pub fn parse_gemini_url(request: &str, hostname: &str, expected_port: u16) -> Result { if let Some(url) = request.strip_prefix("gemini://") { - let host_end = url.find('/').unwrap_or(url.len()); - let host = &url[..host_end]; - if host != expected_host { + let host_port_end = url.find('/').unwrap_or(url.len()); + let host_port = &url[..host_port_end]; + + // Parse host and port + let (host, port_str) = if let Some(colon_pos) = host_port.find(':') { + let host = &host_port[..colon_pos]; + let port_str = &host_port[colon_pos + 1..]; + (host, Some(port_str)) + } else { + (host_port, None) + }; + + // Validate host + if host != hostname { return Err(()); // Hostname mismatch } - let path = if host_end < url.len() { &url[host_end..] } else { "/" }; + + // Validate port + let port = port_str + .and_then(|p| p.parse::().ok()) + .unwrap_or(1965); + if port != expected_port { + return Err(()); // Port mismatch + } + + let path = if host_port_end < url.len() { &url[host_port_end..] } else { "/" }; Ok(path.trim().to_string()) } else { Err(()) } } -pub fn resolve_file_path(path: &str, dir: &str) -> Result { +pub fn resolve_file_path(path: &str, dir: &str) -> Result { let file_path_str = if path == "/" { "index.gmi".to_string() } else if path.ends_with('/') { @@ -25,8 +50,18 @@ pub fn resolve_file_path(path: &str, dir: &str) -> Result { }; match validate_path(Path::new(&file_path_str), Path::new(dir)) { - Ok(safe_path) => Ok(safe_path), - Err(_) => Err(()), + Ok(safe_path) => { + // Path is secure, now check if file exists + if safe_path.exists() { + Ok(safe_path) + } else { + Err(PathResolutionError::NotFound) + } + }, + Err(_) => { + // Path validation failed - treat as not found + Err(PathResolutionError::NotFound) + }, } } @@ -54,23 +89,25 @@ mod tests { #[test] fn test_parse_gemini_url_valid() { - assert_eq!(parse_gemini_url("gemini://gemini.jeena.net/", "gemini.jeena.net"), Ok("/".to_string())); - assert_eq!(parse_gemini_url("gemini://gemini.jeena.net/posts/test", "gemini.jeena.net"), Ok("/posts/test".to_string())); + assert_eq!(parse_gemini_url("gemini://gemini.jeena.net/", "gemini.jeena.net", 1965), Ok("/".to_string())); + assert_eq!(parse_gemini_url("gemini://gemini.jeena.net/posts/test", "gemini.jeena.net", 1965), Ok("/posts/test".to_string())); } #[test] fn test_parse_gemini_url_invalid_host() { - assert!(parse_gemini_url("gemini://foo.com/", "gemini.jeena.net").is_err()); + assert!(parse_gemini_url("gemini://foo.com/", "gemini.jeena.net", 1965).is_err()); } #[test] fn test_parse_gemini_url_no_prefix() { - assert!(parse_gemini_url("http://gemini.jeena.net/", "gemini.jeena.net").is_err()); + assert!(parse_gemini_url("http://gemini.jeena.net/", "gemini.jeena.net", 1965).is_err()); } #[test] fn test_resolve_file_path_root() { let temp_dir = TempDir::new().unwrap(); + // Create index.gmi file since we now check for existence + std::fs::write(temp_dir.path().join("index.gmi"), "# Test").unwrap(); assert!(resolve_file_path("/", temp_dir.path().to_str().unwrap()).is_ok()); } @@ -78,19 +115,28 @@ mod tests { fn test_resolve_file_path_directory() { let temp_dir = TempDir::new().unwrap(); std::fs::create_dir(temp_dir.path().join("test")).unwrap(); + std::fs::write(temp_dir.path().join("test/index.gmi"), "# Test").unwrap(); assert!(resolve_file_path("/test/", temp_dir.path().to_str().unwrap()).is_ok()); } #[test] fn test_resolve_file_path_file() { let temp_dir = TempDir::new().unwrap(); + std::fs::write(temp_dir.path().join("test.gmi"), "# Test").unwrap(); assert!(resolve_file_path("/test.gmi", temp_dir.path().to_str().unwrap()).is_ok()); } #[test] fn test_resolve_file_path_traversal() { let temp_dir = TempDir::new().unwrap(); - assert!(resolve_file_path("/../etc/passwd", temp_dir.path().to_str().unwrap()).is_err()); + assert_eq!(resolve_file_path("/../etc/passwd", temp_dir.path().to_str().unwrap()), Err(PathResolutionError::NotFound)); + } + + #[test] + fn test_resolve_file_path_not_found() { + let temp_dir = TempDir::new().unwrap(); + // Don't create the file, should return NotFound error + assert_eq!(resolve_file_path("/nonexistent.gmi", temp_dir.path().to_str().unwrap()), Err(PathResolutionError::NotFound)); } #[test] diff --git a/src/server.rs b/src/server.rs index f23c9e1..1e5e577 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,43 +1,58 @@ -use crate::request::{parse_gemini_url, resolve_file_path, get_mime_type}; +use crate::request::{parse_gemini_url, resolve_file_path, get_mime_type, PathResolutionError}; use crate::logging::RequestLogger; use std::fs; use std::io; use std::path::Path; +use std::sync::atomic::{AtomicUsize, Ordering}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; use tokio::time::{timeout, Duration}; use tokio_rustls::server::TlsStream; +static ACTIVE_REQUESTS: AtomicUsize = AtomicUsize::new(0); + pub async fn serve_file( stream: &mut TlsStream, file_path: &Path, + request: &str, ) -> io::Result<()> { if file_path.exists() && file_path.is_file() { - let mime_type = get_mime_type(&file_path); - let content = fs::read(&file_path)?; - let mut response = format!("20 {}\r\n", mime_type).into_bytes(); - response.extend(content); - stream.write_all(&response).await?; + let mime_type = get_mime_type(file_path); + let header = format!("20 {}\r\n", mime_type); + stream.write_all(header.as_bytes()).await?; + // Log success after sending header + let client_ip = match stream.get_ref().0.peer_addr() { + Ok(addr) => addr.to_string(), + Err(_) => "unknown".to_string(), + }; + let request_path = request.strip_prefix("gemini://localhost").unwrap_or(request); + tracing::info!("{} \"{}\" 20 \"Success\"", client_ip, request_path); + // Then send body + let content = fs::read(file_path)?; + stream.write_all(&content).await?; stream.flush().await?; Ok(()) } else { - Err(io::Error::new(io::ErrorKind::NotFound, "File not found")) + Err(tokio::io::Error::new(tokio::io::ErrorKind::NotFound, "File not found")) } } pub async fn handle_connection( mut stream: TlsStream, dir: &str, - expected_host: &str, + hostname: &str, + expected_port: u16, + max_concurrent_requests: usize, + _test_processing_delay: u64, ) -> io::Result<()> { - const MAX_REQUEST_SIZE: usize = 4096; + const MAX_REQUEST_SIZE: usize = 1026; const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); let mut request_buf = Vec::new(); let read_future = async { loop { if request_buf.len() >= MAX_REQUEST_SIZE { - return Err(io::Error::new(io::ErrorKind::InvalidData, "Request too large")); + return Err(tokio::io::Error::new(tokio::io::ErrorKind::InvalidData, "Request too large")); } let mut byte = [0; 1]; stream.read_exact(&mut byte).await?; @@ -49,45 +64,109 @@ pub async fn handle_connection( Ok(()) }; - if timeout(REQUEST_TIMEOUT, read_future).await.is_err() { - let request_str = String::from_utf8_lossy(&request_buf).trim().to_string(); - let logger = RequestLogger::new(&stream, request_str); - logger.log_error(59, "Request timeout"); - send_response(&mut stream, "59 Bad Request\r\n").await?; - return Ok(()); - } + match timeout(REQUEST_TIMEOUT, read_future).await { + Ok(Ok(())) => { + // Read successful, continue processing + let request = String::from_utf8_lossy(&request_buf).trim().to_string(); - let request = String::from_utf8_lossy(&request_buf).trim().to_string(); - - // Parse Gemini URL - let path = match parse_gemini_url(&request, expected_host) { - Ok(p) => p, - Err(_) => { + // Initialize logger early for all request types let logger = RequestLogger::new(&stream, request.clone()); - logger.log_error(59, "Invalid URL format"); - send_response(&mut stream, "59 Bad Request\r\n").await?; + + // Check concurrent request limit after TLS handshake and request read + let current = ACTIVE_REQUESTS.fetch_add(1, Ordering::Relaxed); + if current >= max_concurrent_requests { + logger.log_error(41, "Concurrent request limit exceeded"); + ACTIVE_REQUESTS.fetch_sub(1, Ordering::Relaxed); + // Rate limited - send proper 41 response + send_response(&mut stream, "41 Server unavailable\r\n").await?; + return Ok(()); + } + + // Process the request + // Validate request + if request.is_empty() { + logger.log_error(59, "Empty request"); + ACTIVE_REQUESTS.fetch_sub(1, Ordering::Relaxed); + return send_response(&mut stream, "59 Bad Request\r\n").await; + } + + if request.len() > 1024 { + logger.log_error(59, "Request too large"); + ACTIVE_REQUESTS.fetch_sub(1, Ordering::Relaxed); + return send_response(&mut stream, "59 Bad Request\r\n").await; + } + + // Parse Gemini URL + let path = match parse_gemini_url(&request, hostname, expected_port) { + Ok(p) => p, + Err(_) => { + logger.log_error(59, "Invalid URL format"); + ACTIVE_REQUESTS.fetch_sub(1, Ordering::Relaxed); + return send_response(&mut stream, "59 Bad Request\r\n").await; + } + }; + + // TESTING ONLY: Add delay for rate limiting tests (debug builds only) + #[cfg(debug_assertions)] + if _test_processing_delay > 0 { + tokio::time::sleep(tokio::time::Duration::from_secs(_test_processing_delay)).await; + } + + // Resolve file path with security + let file_path = match resolve_file_path(&path, dir) { + Ok(fp) => fp, + Err(PathResolutionError::NotFound) => { + logger.log_error(51, "File not found"); + ACTIVE_REQUESTS.fetch_sub(1, Ordering::Relaxed); + return send_response(&mut stream, "51 Not found\r\n").await; + } + }; + + // No delay for normal operation + + // Processing complete + + // Serve the file + match serve_file(&mut stream, &file_path, &request).await { + Ok(_) => { + // Success already logged in serve_file + } + Err(_) => { + // File transmission failed + logger.log_error(51, "File transmission failed"); + let _ = send_response(&mut stream, "51 Not found\r\n").await; + } + } + } + Ok(Err(e)) => { + // Read failed, check error type + let request_str = String::from_utf8_lossy(&request_buf).trim().to_string(); + let logger = RequestLogger::new(&stream, request_str); + + match e.kind() { + tokio::io::ErrorKind::InvalidData => { + logger.log_error(59, "Request too large"); + let _ = send_response(&mut stream, "59 Bad Request\r\n").await; + }, + _ => { + logger.log_error(59, "Bad request"); + let _ = send_response(&mut stream, "59 Bad Request\r\n").await; + } + } + ACTIVE_REQUESTS.fetch_sub(1, Ordering::Relaxed); + }, + Err(_) => { + // Timeout + let request_str = String::from_utf8_lossy(&request_buf).trim().to_string(); + let logger = RequestLogger::new(&stream, request_str); + logger.log_error(41, "Server unavailable"); + let _ = send_response(&mut stream, "41 Server unavailable\r\n").await; + ACTIVE_REQUESTS.fetch_sub(1, Ordering::Relaxed); return Ok(()); } - }; - - // Initialize logger now that we have the full request URL - let logger = RequestLogger::new(&stream, request.clone()); - - // Resolve file path with security - let file_path = match resolve_file_path(&path, dir) { - Ok(fp) => fp, - Err(_) => { - logger.log_error(59, "Path traversal attempt"); - return send_response(&mut stream, "59 Bad Request\r\n").await; - } - }; - - // Serve the file - match serve_file(&mut stream, &file_path).await { - Ok(_) => logger.log_success(20), - Err(_) => logger.log_error(51, "File not found"), } - + + ACTIVE_REQUESTS.fetch_sub(1, Ordering::Relaxed); Ok(()) } 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