diff --git a/.gitignore b/.gitignore index e82a040..14b878f 100644 --- a/.gitignore +++ b/.gitignore @@ -21,7 +21,4 @@ Cargo.lock # IDE files .vscode/ -.idea/ - -# Local project files -BACKLOG.md \ No newline at end of file +.idea/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index b3c8c07..1766002 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,38 +1,135 @@ -# AGENTS.md +# 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. -## Introduction -This is a modern Rust project for a Gemini server. Follow these guidelines for -development, testing, and security. +# Build/Test/Lint Commands + +## 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 + +## 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 + +# Code Style Guidelines + +## 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 + +## 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 + +## Naming Conventions +- `PascalCase` for types, structs, enums +- `snake_case` for functions, variables, modules +- `SCREAMING_SNAKE_CASE` for constants +- Use descriptive names that indicate purpose + +## 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 -- Use unit tests for individual components and integration tests for - end-to-end features. -- Test at appropriate levels to ensure reliability. +- 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 -## Development Practices -- Do not remove features unless explicitly ordered, especially those - mentioned in README.md. -- Pre-commit hooks run all tests before commits. -- Follow modern Rust best practices. -- Fix all compiler warnings before committing—they often indicate future bugs. +## 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 -## Security -- Cybersecurity is critical. Never remove guards for remote user input - validation, such as URLs or file paths. +## 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 -## Planning and Tracking -- Use local BACKLOG.md to see planned work. -- For multi-phase changes, add TODO items below the user story with checkboxes - and update them during implementation. +## 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) -## Tools -- Use cargo for building and testing. -- Run clippy for code quality checks. -- Use fmt for code formatting. -- Use --quiet flag to suppress startup output during testing. -- Follow project-specific tool usage as needed. +## 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 -## Logging -- Use tracing for logging in nginx/apache style. -- Output goes to stderr for journald/systemd handling. -- No custom log files or eprintln. +# 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" + +## 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/Cargo.toml b/Cargo.toml index a98954c..54c8690 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,6 @@ clap = { version = "4.0", features = ["derive"] } path-security = "0.2" toml = "0.8" serde = { version = "1.0", features = ["derive"] } -urlencoding = "2.1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "ansi"] } time = "0.3" diff --git a/README.md b/README.md index 24db44b..7a97510 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Pollux - A Simple Gemini Server -Pollux is a lightweight Gemini server for serving static files securely. It supports **virtual hosting**, allowing multiple Gemini capsules on a single server instance. Features include TLS encryption, hostname validation, directory serving, and comprehensive security protections. +Pollux is a lightweight Gemini server for serving static files securely. It supports TLS, hostname validation, and basic directory serving. ## Requirements @@ -16,64 +16,16 @@ cargo build --release This produces the `target/release/pollux` binary. -## Virtual Hosting - -Pollux supports **virtual hosting**, allowing you to serve multiple Gemini capsules from a single server. Each hostname can have its own root directory, certificates, and configuration. - -### Configuration +## Running Create a config file at `/etc/pollux/config.toml` or use `--config` to specify a path: -```toml -# Global settings (optional) -bind_host = "0.0.0.0" -port = 1965 -log_level = "info" -max_concurrent_requests = 1000 - -# Virtual host configurations -["example.com"] -root = "/var/gemini/example.com" -cert = "/etc/ssl/example.com.crt" -key = "/etc/ssl/example.com.key" - -["blog.example.com"] -root = "/var/gemini/blog" -cert = "/etc/ssl/blog.crt" -key = "/etc/ssl/blog.key" - -["another-site.net"] -root = "/var/gemini/another" -cert = "/etc/ssl/another.crt" -key = "/etc/ssl/another.key" -port = 1966 # Optional per-host port override -``` - -### Features - -- **Multiple hostnames** on a single server instance -- **Per-host TLS certificates** for proper security isolation -- **Automatic content isolation** - each host serves only its own files -- **Path security** - directory traversal attacks are blocked -- **Index file serving** - `index.gmi` files are served automatically -- **Hostname validation** - DNS-compliant hostname checking - -### Request Routing - -- `gemini://example.com/` → serves `/var/gemini/example.com/index.gmi` -- `gemini://blog.example.com/article.gmi` → serves `/var/gemini/blog/article.gmi` -- `gemini://unknown.com/` → returns status 53 "Proxy request refused" - -### Single Host Mode (Legacy) - -For backward compatibility, you can still use the old single-host format: - ```toml root = "/path/to/static/files" cert = "/path/to/cert.pem" key = "/path/to/key.pem" -hostname = "gemini.example.com" bind_host = "0.0.0.0" +hostname = "gemini.example.com" port = 1965 log_level = "info" max_concurrent_requests = 1000 @@ -117,78 +69,14 @@ Access with a Gemini client like Lagrange at `gemini://yourdomain.com/`. - `--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 -## Logging +### Certificate Management -Pollux uses the `tracing` crate for structured logging. Configure log levels with the `RUST_LOG` environment variable: - -```bash -# Basic usage -export RUST_LOG=info -./pollux - -# Module-specific levels -export RUST_LOG=pollux=debug,sqlx=info - -# Maximum verbosity -export RUST_LOG=trace -``` - -Available levels: `error`, `warn`, `info`, `debug`, `trace` - -## Security - -Pollux is designed with security as a priority: - -- **Path traversal protection** - requests like `../../../etc/passwd` are blocked -- **TLS encryption** - all connections are encrypted with valid certificates -- **Content isolation** - virtual hosts cannot access each other's files -- **Request validation** - malformed requests are rejected -- **Rate limiting** - configurable concurrent request limits prevent abuse - -### Best Practices - -#### Virtual Hosting Setup -- Use separate TLS certificates for each hostname when possible -- Keep host root directories separate and properly permissioned -- Use DNS-compliant hostnames (no underscores, proper formatting) -- Monitor logs for unknown hostname attempts - -#### 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 -- Rotate certificates regularly and restart the server - -#### File Organization -- Create `index.gmi` files in directories for automatic serving -- Use `.gmi` extension for Gemini text files -- Store certificates outside web-accessible directories -- Use proper file permissions (readable by server user only) - -### Limitations - -- **No dynamic content** - Pollux serves only static files -- **Single certificate per server** - All hosts currently share the same TLS certificate (can be enhanced) -- **No CGI support** - No server-side processing or scripting -- **Memory usage** - All host configurations are loaded into memory -- **No HTTP support** - Gemini protocol only - -## Examples - -The `examples/` directory contains sample configuration files: - -- `virtual-hosting.toml` - Multi-host setup with different certificates -- `single-host.toml` - Legacy single-host configuration -- `development.toml` - Local development with self-signed certificates ## Testing -Run `cargo test` for the full test suite, which includes comprehensive integration tests covering: +Run `cargo test` for the full test suite, which includes integration tests that require Python 3. -- Virtual hosting with multiple hostnames -- TLS certificate validation -- Path security and isolation -- Concurrent request handling -- Performance validation - -**Note**: Some integration tests use Python 3 for Gemini protocol validation. If Python 3 is not available, certain tests will be skipped automatically. +**Note**: Integration tests use Python 3 for Gemini protocol validation. If Python 3 is not available, integration tests will be skipped automatically. diff --git a/dist/INSTALL.md b/dist/INSTALL.md index 0d79719..7cfc68c 100644 --- a/dist/INSTALL.md +++ b/dist/INSTALL.md @@ -16,23 +16,20 @@ This guide covers installing and configuring the Pollux Gemini server for produc cargo build --release sudo cp target/release/pollux /usr/local/bin/ -# 2. Create directories and user -sudo useradd -r -s /bin/false pollux -sudo mkdir -p /etc/pollux/tls /var/gemini -sudo chown -R pollux:pollux /var/gemini +# 2. Get certificates +sudo certbot certonly --standalone -d example.com -# 3. Generate certificates -sudo -u pollux openssl req -x509 -newkey rsa:4096 \ - -keyout /etc/pollux/tls/key.pem \ - -out /etc/pollux/tls/cert.pem \ - -days 365 -nodes \ - -subj "/CN=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/gemini/ +sudo cp -r your-content/* /var/www/example.com/ # 6. Install and start service sudo cp dist/pollux.service /etc/systemd/system/ @@ -60,23 +57,24 @@ sudo cp target/release/pollux /usr/local/bin/ #### Certificate Setup -**For Production:** Obtain certificates from your preferred Certificate Authority and place them in `/etc/pollux/tls/`. Ensure they are readable by the pollux user. +**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 - place them under `/etc/letsencrypt/live/` and update your config accordingly. +**Note:** Let's Encrypt certificates can be used but their installation and permission setup is beyond the scope of this documentation. ```bash # Generate certificates -sudo -u pollux openssl req -x509 -newkey rsa:4096 \ - -keyout /etc/pollux/tls/key.pem \ - -out /etc/pollux/tls/cert.pem \ +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 (already correct when run as pollux user) -sudo chmod 644 /etc/pollux/tls/cert.pem -sudo chmod 600 /etc/pollux/tls/key.pem +# 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 @@ -91,8 +89,8 @@ sudo usermod -a -G ssl-cert pollux # Ubuntu/Debian sudo usermod -a -G certbot pollux # Some systems # Create directories -sudo mkdir -p /etc/pollux/tls /var/gemini -sudo chown -R pollux:pollux /var/gemini +sudo mkdir -p /etc/pollux /var/www/example.com +sudo chown -R pollux:pollux /var/www/example.com ``` ### Configuration @@ -100,44 +98,26 @@ sudo chown -R pollux:pollux /var/gemini Edit `/etc/pollux/config.toml`: ```toml -# Global settings +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 - -# Host configuration -["example.com"] -root = "/var/gemini" -cert = "/etc/pollux/tls/cert.pem" -key = "/etc/pollux/tls/key.pem" -``` - -### Logging Configuration - -Pollux uses structured logging with the `tracing` crate. Configure log levels using the `RUST_LOG` environment variable: - -```bash -# Set log level before starting the service -export RUST_LOG=info -sudo systemctl start pollux - -# Or for debugging -export RUST_LOG=pollux=debug -sudo systemctl restart pollux - -# Available levels: error, warn, info, debug, trace +log_level = "info" ``` ### Content Setup ```bash # Copy your Gemini files -sudo cp -r gemini-content/* /var/gemini/ +sudo cp -r gemini-content/* /var/www/example.com/ # Set permissions -sudo chown -R pollux:pollux /var/gemini -sudo find /var/gemini -type f -exec chmod 644 {} \; -sudo find /var/gemini -type d -exec chmod 755 {} \; +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 @@ -148,9 +128,7 @@ 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: -# - /etc/pollux for config and TLS certificates -# - /var/gemini for your content root +# Update ReadOnlyPaths to match your config # Enable and start sudo systemctl daemon-reload @@ -176,10 +154,10 @@ openssl s_client -connect example.com:1965 -servername example.com <<< "gemini:/ ### Permission Issues ```bash # Check certificate access -sudo -u pollux cat /etc/pollux/tls/cert.pem +sudo -u pollux cat /etc/pollux/cert.pem # Check content access -sudo -u pollux ls -la /var/gemini/ +sudo -u pollux ls -la /var/www/example.com/ ``` ### Port Issues @@ -204,13 +182,13 @@ sudo systemctl reload pollux See `config.toml` for all available options. Key settings: -- `root`: Directory containing your .gmi files (per host section) -- `cert`/`key`: TLS certificate paths (per host section) -- `bind_host`: IP/interface to bind to (global) -- `port`: Listen port (1965 is standard, per host override possible) -- `max_concurrent_requests`: Connection limit (global) - -Logging is configured via the `RUST_LOG` environment variable (see Logging Configuration section). +- `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 diff --git a/dist/config.toml b/dist/config.toml index 2650430..ab7067a 100644 --- a/dist/config.toml +++ b/dist/config.toml @@ -5,14 +5,31 @@ # # The Gemini protocol is specified in RFC 1436: https://tools.ietf.org/rfc/rfc1436.txt -# For additional hostnames, add more sections like: -# ["blog.example.com"] -# root = "/var/gemini/blog" -# cert = "/etc/pollux/tls/blog.crt" -# key = "/etc/pollux/tls/blog.key" +# 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 @@ -20,6 +37,12 @@ # - 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 @@ -27,7 +50,7 @@ bind_host = "0.0.0.0" 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) @@ -35,34 +58,11 @@ port = 1965 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" - -# Host configuration -# Each hostname needs its own section with root, cert, and key settings -["example.com"] -# 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 can read all files here. -root = "/var/gemini" - -# TLS certificate and private key files -# These files are required for TLS encryption (Gemini requires TLS). -# -# For self-signed certificates (development/testing): -cert = "/etc/pollux/tls/cert.pem" -key = "/etc/pollux/tls/key.pem" -# -# Generate self-signed certs with: -# openssl req -x509 -newkey rsa:4096 -keyout /etc/pollux/tls/key.pem -out /etc/pollux/tls/cert.pem -days 365 -nodes -subj "/CN=example.com" -# -# For Let's Encrypt certificates, use paths under /etc/letsencrypt/live/ \ No newline at end of file +log_level = "info" \ No newline at end of file diff --git a/dist/pollux.service b/dist/pollux.service index 29ccb3c..ee6587f 100644 --- a/dist/pollux.service +++ b/dist/pollux.service @@ -13,10 +13,12 @@ Group=pollux NoNewPrivileges=yes ProtectHome=yes ProtectSystem=strict -ReadOnlyPaths=/etc/pollux /var/gemini +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/pollux for config and TLS certificates -# - /var/gemini for your content root +# - /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] diff --git a/examples/development.toml b/examples/development.toml deleted file mode 100644 index 183cd32..0000000 --- a/examples/development.toml +++ /dev/null @@ -1,30 +0,0 @@ -# Pollux Development Configuration -# -# Example configuration for local development with self-signed certificates. -# NOT suitable for production use. - -bind_host = "127.0.0.1" -port = 1965 -log_level = "debug" -max_concurrent_requests = 100 - -# Local development site -["localhost"] -root = "./content" -cert = "./tmp/cert.pem" -key = "./tmp/key.pem" - -# Alternative hostname for testing -["gemini.local"] -root = "./content" -cert = "./tmp/cert.pem" -key = "./tmp/key.pem" - -# Generate self-signed certificates with: -# mkdir -p tmp -# openssl req -x509 -newkey rsa:2048 \ -# -keyout tmp/key.pem \ -# -out tmp/cert.pem \ -# -days 365 \ -# -nodes \ -# -subj "/CN=localhost" \ No newline at end of file diff --git a/examples/single-host.toml b/examples/single-host.toml deleted file mode 100644 index 6873ea5..0000000 --- a/examples/single-host.toml +++ /dev/null @@ -1,16 +0,0 @@ -# Pollux Single Host Example Configuration -# -# Example configuration for a single Gemini capsule. -# For multiple hosts, use virtual hosting instead. - -# Global settings -bind_host = "127.0.0.1" -port = 1965 -log_level = "info" -max_concurrent_requests = 100 - -# Host configuration -["example.com"] -root = "./content" -cert = "./tmp/cert.pem" -key = "./tmp/key.pem" \ No newline at end of file diff --git a/examples/virtual-hosting.toml b/examples/virtual-hosting.toml deleted file mode 100644 index bf425fd..0000000 --- a/examples/virtual-hosting.toml +++ /dev/null @@ -1,35 +0,0 @@ -# Pollux Virtual Hosting Example Configuration -# -# This example shows how to configure multiple Gemini capsules -# on a single server instance. - -# Global settings (applied to all hosts unless overridden) -bind_host = "0.0.0.0" -port = 1965 -log_level = "info" -max_concurrent_requests = 1000 - -# Main website -["example.com"] -root = "/var/gemini/example.com" -cert = "/etc/ssl/example.com.crt" -key = "/etc/ssl/example.com.key" - -# Blog subdomain -["blog.example.com"] -root = "/var/gemini/blog" -cert = "/etc/ssl/blog.example.com.crt" -key = "/etc/ssl/blog.example.com.key" - -# Personal site -["tilde.example.com"] -root = "/home/user/public_gemini" -cert = "/etc/ssl/tilde.crt" -key = "/etc/ssl/tilde.key" - -# Development site (different port) -["dev.example.com"] -root = "/home/dev/gemini" -cert = "/etc/ssl/dev.crt" -key = "/etc/ssl/dev.key" -port = 1966 \ No newline at end of file diff --git a/src/config.rs b/src/config.rs index 73f8db1..c3a546b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,210 +1,21 @@ use serde::Deserialize; -use std::collections::HashMap; -use toml::Value; -#[derive(Debug)] +#[derive(Deserialize)] pub struct Config { - // Global defaults (optional) + pub root: Option, + pub cert: Option, + pub key: Option, pub bind_host: Option, + pub hostname: Option, pub port: Option, + pub log_level: Option, pub max_concurrent_requests: Option, - - // Per-hostname configurations - pub hosts: HashMap, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct HostConfig { - pub root: String, - pub cert: String, - pub key: String, - #[serde(default)] - #[allow(dead_code)] - pub port: Option, // override global port - #[serde(default)] - #[allow(dead_code)] - pub log_level: Option, // override global log level } pub fn load_config(path: &str) -> Result> { let content = std::fs::read_to_string(path)?; - let toml_value: Value = toml::from_str(&content)?; - - // Extract global settings - let bind_host = extract_string(&toml_value, "bind_host"); - let port = extract_u16(&toml_value, "port"); - let max_concurrent_requests = extract_usize(&toml_value, "max_concurrent_requests"); - - // Extract host configurations - let mut hosts = HashMap::new(); - - if let Some(table) = toml_value.as_table() { - for (key, value) in table { - // Skip global config keys - if matches!( - key.as_str(), - "bind_host" | "port" | "max_concurrent_requests" - ) { - continue; - } - - // This should be a hostname section - if let Some(host_table) = value.as_table() { - let root = extract_required_string(host_table, "root", key)?; - let cert = extract_required_string(host_table, "cert", key)?; - let key_path = extract_required_string(host_table, "key", key)?; - let port_override = extract_u16_from_table(host_table, "port"); - let log_level_override = extract_string_from_table(host_table, "log_level"); - - // Validate hostname - if !is_valid_hostname(key) { - return Err(format!( - "Invalid hostname '{}'. Hostnames must be valid DNS names.", - key - ) - .into()); - } - - // Validate that root directory exists - if !std::path::Path::new(&root).exists() { - return Err(format!("Error for host '{}': Root directory '{}' does not exist\nCreate the directory and add your Gemini files (.gmi, .txt, images)", key, root).into()); - } - - // Validate that certificate file exists - if !std::path::Path::new(&cert).exists() { - return Err(format!("Error for host '{}': Certificate file '{}' does not exist\nGenerate or obtain TLS certificates for your domain", key, cert).into()); - } - - // Validate that key file exists - if !std::path::Path::new(&key_path).exists() { - return Err(format!("Error for host '{}': Key file '{}' does not exist\nGenerate or obtain TLS certificates for your domain", key, key_path).into()); - } - - let host_config = HostConfig { - root, - cert, - key: key_path, - port: port_override, - log_level: log_level_override, - }; - - hosts.insert(key.clone(), host_config); - } - } - } - - // Validate that we have at least one host configured - if hosts.is_empty() { - return Err("No host configurations found. Add at least one [hostname] section.".into()); - } - - Ok(Config { - bind_host, - port, - max_concurrent_requests, - hosts, - }) -} - -fn extract_string(value: &Value, key: &str) -> Option { - value - .get(key) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) -} - -fn extract_string_from_table(table: &toml::map::Map, key: &str) -> Option { - table - .get(key) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) -} - -fn extract_u16(value: &Value, key: &str) -> Option { - value - .get(key) - .and_then(|v| v.as_integer()) - .and_then(|i| u16::try_from(i).ok()) -} - -fn extract_u16_from_table(table: &toml::map::Map, key: &str) -> Option { - table - .get(key) - .and_then(|v| v.as_integer()) - .and_then(|i| u16::try_from(i).ok()) -} - -fn extract_usize(value: &Value, key: &str) -> Option { - value - .get(key) - .and_then(|v| v.as_integer()) - .and_then(|i| usize::try_from(i).ok()) -} - -fn extract_required_string( - table: &toml::map::Map, - key: &str, - section: &str, -) -> Result> { - table - .get(key) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .ok_or_else(|| format!("Missing required field '{}' in [{}] section", key, section).into()) -} - -/// Validate that a hostname is a proper DNS name -fn is_valid_hostname(hostname: &str) -> bool { - if hostname.is_empty() || hostname.len() > 253 { - return false; - } - - // Allow localhost for testing - if hostname == "localhost" { - return true; - } - - // Basic validation: no control characters, no spaces, reasonable characters - for ch in hostname.chars() { - if ch.is_control() || ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r' { - return false; - } - } - - // Must contain at least one dot (be a domain) - if !hostname.contains('.') { - return false; - } - - // Check each label (parts separated by dots) - for label in hostname.split('.') { - if label.is_empty() || label.len() > 63 { - return false; - } - - // Labels can contain letters, digits, and hyphens - // Must start and end with alphanumeric characters - let chars: Vec = label.chars().collect(); - if chars.is_empty() { - return false; - } - - if !chars[0].is_alphanumeric() { - return false; - } - - if chars.len() > 1 && !chars[chars.len() - 1].is_alphanumeric() { - return false; - } - - for &ch in &chars { - if !ch.is_alphanumeric() && ch != '-' { - return false; - } - } - } - - true + let config: Config = toml::from_str(&content)?; + Ok(config) } #[cfg(test)] @@ -214,178 +25,52 @@ mod tests { use tempfile::TempDir; #[test] - fn test_is_valid_hostname() { - // Valid hostnames - assert!(is_valid_hostname("example.com")); - assert!(is_valid_hostname("sub.example.com")); - assert!(is_valid_hostname("localhost")); - assert!(is_valid_hostname("my-host-123.example.org")); - - // Invalid hostnames - assert!(!is_valid_hostname("")); - assert!(!is_valid_hostname("-invalid.com")); - assert!(!is_valid_hostname("invalid-.com")); - assert!(!is_valid_hostname("invalid..com")); - assert!(!is_valid_hostname("invalid.com.")); - assert!(!is_valid_hostname("inval!d.com")); - assert!(is_valid_hostname( - "too.long.label.that.exceeds.sixty.three.characters.example.com" - )); - } - - #[test] - fn test_load_config_valid_single_host() { - let temp_dir = TempDir::new().unwrap(); - let config_path = temp_dir.path().join("config.toml"); - - // Create the root directory and cert/key files - let root_dir = temp_dir.path().join("root"); - fs::create_dir(&root_dir).unwrap(); - let cert_path = temp_dir.path().join("cert.pem"); - let key_path = temp_dir.path().join("key.pem"); - fs::write(&cert_path, "dummy cert").unwrap(); - fs::write(&key_path, "dummy key").unwrap(); - - let content = format!( - r#" - ["example.com"] - root = "{}" - cert = "{}" - key = "{}" - port = 1965 - log_level = "info" - "#, - root_dir.display(), - cert_path.display(), - key_path.display() - ); - fs::write(&config_path, content).unwrap(); - - let config = load_config(config_path.to_str().unwrap()).unwrap(); - assert_eq!(config.hosts.len(), 1); - assert!(config.hosts.contains_key("example.com")); - let host_config = &config.hosts["example.com"]; - assert_eq!(host_config.root, root_dir.to_str().unwrap()); - assert_eq!(host_config.cert, cert_path.to_str().unwrap()); - assert_eq!(host_config.key, key_path.to_str().unwrap()); - assert_eq!(host_config.port, Some(1965)); - assert_eq!(host_config.log_level, Some("info".to_string())); - } - - #[test] - fn test_load_config_valid_multiple_hosts() { - let temp_dir = TempDir::new().unwrap(); - let config_path = temp_dir.path().join("config.toml"); - - // Create directories and cert files for both hosts - let site1_root = temp_dir.path().join("site1"); - let site2_root = temp_dir.path().join("site2"); - fs::create_dir(&site1_root).unwrap(); - fs::create_dir(&site2_root).unwrap(); - - let site1_cert = temp_dir.path().join("site1.crt"); - let site1_key = temp_dir.path().join("site1.key"); - let site2_cert = temp_dir.path().join("site2.crt"); - let site2_key = temp_dir.path().join("site2.key"); - - fs::write(&site1_cert, "dummy cert 1").unwrap(); - fs::write(&site1_key, "dummy key 1").unwrap(); - fs::write(&site2_cert, "dummy cert 2").unwrap(); - fs::write(&site2_key, "dummy key 2").unwrap(); - - let content = format!( - r#" - ["site1.com"] - root = "{}" - cert = "{}" - key = "{}" - - ["site2.org"] - root = "{}" - cert = "{}" - key = "{}" - port = 1966 - "#, - site1_root.display(), - site1_cert.display(), - site1_key.display(), - site2_root.display(), - site2_cert.display(), - site2_key.display() - ); - fs::write(&config_path, content).unwrap(); - - let config = load_config(config_path.to_str().unwrap()).unwrap(); - assert_eq!(config.hosts.len(), 2); - assert!(config.hosts.contains_key("site1.com")); - assert!(config.hosts.contains_key("site2.org")); - - let site1 = &config.hosts["site1.com"]; - assert_eq!(site1.root, site1_root.to_str().unwrap()); - assert_eq!(site1.port, None); - - let site2 = &config.hosts["site2.org"]; - assert_eq!(site2.root, site2_root.to_str().unwrap()); - assert_eq!(site2.port, Some(1966)); - } - - #[test] - fn test_load_config_no_hosts() { + fn test_load_config_valid() { let temp_dir = TempDir::new().unwrap(); let config_path = temp_dir.path().join("config.toml"); let content = r#" - bind_host = "127.0.0.1" - port = 1965 - "#; - fs::write(&config_path, content).unwrap(); - - let result = load_config(config_path.to_str().unwrap()); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("No host configurations found")); - } - - #[test] - fn test_load_config_invalid_hostname() { - let temp_dir = TempDir::new().unwrap(); - let config_path = temp_dir.path().join("config.toml"); - let content = r#" - ["-invalid.com"] - root = "/some/path" + root = "/path/to/root" cert = "cert.pem" key = "key.pem" + bind_host = "0.0.0.0" + hostname = "example.com" + port = 1965 + log_level = "info" "#; fs::write(&config_path, content).unwrap(); - let result = load_config(config_path.to_str().unwrap()); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("Invalid hostname")); + let config = load_config(config_path.to_str().unwrap()).unwrap(); + 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.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_invalid_toml() { - let temp_dir = TempDir::new().unwrap(); - let config_path = temp_dir.path().join("config.toml"); - let content = "invalid toml content"; - fs::write(&config_path, content).unwrap(); - - assert!(load_config(config_path.to_str().unwrap()).is_err()); - } - - #[test] - fn test_load_config_missing_required_fields() { + 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#" - ["example.com"] - root = "/path" - # missing cert and key + root = "/path/to/root" + max_concurrent_requests = 500 "#; fs::write(&config_path, content).unwrap(); - // Config parsing will fail if required fields are missing + let config = load_config(config_path.to_str().unwrap()).unwrap(); + assert_eq!(config.max_concurrent_requests, Some(500)); + } + + #[test] + fn test_load_config_invalid() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + let content = "invalid toml"; + fs::write(&config_path, content).unwrap(); + assert!(load_config(config_path.to_str().unwrap()).is_err()); } -} +} \ No newline at end of file diff --git a/src/logging.rs b/src/logging.rs index 98c8cea..5ec49ad 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -1,5 +1,103 @@ -// Logging module - now unused as logging is handled directly in main.rs -// All logging functionality moved to main.rs with RUST_LOG environment variable support +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, + request_url: String, +} + +impl RequestLogger { + pub fn new(stream: &TlsStream, request_url: String) -> Self { + let client_ip = extract_client_ip(stream); + + Self { + client_ip, + request_url, + } + } + + + + pub fn log_error(self, status_code: u8, error_message: &str) { + 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 { + 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) { + 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)] mod tests { @@ -8,4 +106,4 @@ mod tests { // Basic test to ensure logging module compiles assert!(true); } -} +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 6bfbe3b..929700b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,8 @@ mod config; -mod logging; +mod tls; mod request; mod server; -mod tls; +mod logging; use clap::Parser; use rustls::ServerConfig; @@ -10,102 +10,53 @@ use std::path::Path; use std::sync::Arc; use tokio::net::TcpListener; use tokio_rustls::TlsAcceptor; -use tracing_subscriber::EnvFilter; +use logging::init_logging; -fn create_tls_config( - hosts: &std::collections::HashMap, -) -> Result, Box> { - // For Phase 3, we'll use the first host's certificate for all connections - // TODO: Phase 4 could implement proper SNI-based certificate selection - let first_host = hosts.values().next().ok_or("No hosts configured")?; - - let certs = tls::load_certs(&first_host.cert)?; - let key = tls::load_private_key(&first_host.key)?; - - let config = ServerConfig::builder() - .with_safe_defaults() - .with_no_client_auth() - .with_single_cert(certs, key)?; - - Ok(Arc::new(config)) -} - -fn print_startup_info( - config: &config::Config, - hosts: &std::collections::HashMap, - quiet: bool, -) { - if quiet { - return; - } - println!("Pollux Gemini Server (Virtual Host Mode)"); - println!("Configured hosts:"); - for (hostname, host_config) in hosts { - println!(" {} -> {}", hostname, host_config.root); - } - println!("Global settings:"); - if let Some(ref host) = config.bind_host { - println!(" Bind host: {}", host); - } - if let Some(port) = config.port { - println!(" Default port: {}", port); - } - - if let Some(max_concurrent) = config.max_concurrent_requests { - println!(" Max concurrent requests: {}", max_concurrent); +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 configuration file - #[arg(short, long)] + /// Path to config file + #[arg(short = 'C', long)] config: Option, - /// Suppress startup output (for testing) - #[arg(long)] - quiet: bool, - - /// Processing delay for testing (in milliseconds) - #[arg(long, hide = true)] + /// TESTING ONLY: Add delay before processing (seconds) [debug builds only] + #[cfg(debug_assertions)] + #[arg(long, value_name = "SECONDS")] test_processing_delay: Option, } -#[tokio::main] -async fn main() -> Result<(), Box> { - let args = Args::parse(); - // Initialize logging with RUST_LOG support - tracing_subscriber::fmt() - .with_env_filter(EnvFilter::from_default_env()) - .with_writer(std::io::stderr) - .init(); + +#[tokio::main] +async fn main() { + let args = Args::parse(); // Load config let config_path = args.config.as_deref().unwrap_or("/etc/pollux/config.toml"); + // Check if config file exists if !std::path::Path::new(&config_path).exists() { - // User guidance goes to stdout BEFORE initializing tracing - // Use direct stderr for error, stdout for guidance - use std::io::Write; - let mut stderr = std::io::stderr(); - let mut stdout = std::io::stdout(); - - writeln!(stderr, "Config file '{}' not found", config_path).unwrap(); - if !args.quiet { - writeln!( - stdout, - "Create the config file with virtual host sections like:" - ) - .unwrap(); - writeln!(stdout, "[example.com]").unwrap(); - writeln!(stdout, "root = \"/var/gemini\"").unwrap(); - writeln!(stdout, "cert = \"/etc/pollux/tls/cert.pem\"").unwrap(); - writeln!(stdout, "key = \"/etc/pollux/tls/key.pem\"").unwrap(); - - stdout.flush().unwrap(); - } + 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); } @@ -113,102 +64,101 @@ async fn main() -> Result<(), Box> { let config = match config::load_config(config_path) { Ok(config) => config, Err(e) => { - tracing::error!("Failed to parse config file '{}': {}", config_path, e); - tracing::error!( - "Check the TOML syntax and ensure host sections are properly formatted." - ); + 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 host configurations - for (hostname, host_config) in &config.hosts { - // Validate root directory exists and is readable - let root_path = Path::new(&host_config.root); - if !root_path.exists() { - tracing::error!( - "Root directory '{}' for host '{}' does not exist", - host_config.root, - hostname - ); - tracing::error!("Create the directory and add your Gemini files (.gmi, .txt, images)"); - std::process::exit(1); - } - if !root_path.is_dir() { - tracing::error!( - "Root path '{}' for host '{}' is not a directory", - host_config.root, - hostname - ); - tracing::error!("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) { - tracing::error!( - "Cannot read root directory '{}' for host '{}': {}", - host_config.root, - hostname, - e - ); - tracing::error!("Ensure the directory exists and the server user has read permission"); - std::process::exit(1); - } - - // Validate certificate files (always required for TLS) - let cert_path = Path::new(&host_config.cert); - if !cert_path.exists() { - tracing::error!( - "Certificate file '{}' for host '{}' does not exist", - host_config.cert, - hostname - ); - tracing::error!("Generate or obtain TLS certificates for your domain"); - std::process::exit(1); - } - if let Err(e) = std::fs::File::open(cert_path) { - tracing::error!( - "Cannot read certificate file '{}' for host '{}': {}", - host_config.cert, - hostname, - e - ); - tracing::error!("Ensure the file exists and the server user has read permission"); - std::process::exit(1); - } - - let key_path = Path::new(&host_config.key); - if !key_path.exists() { - tracing::error!( - "Private key file '{}' for host '{}' does not exist", - host_config.key, - hostname - ); - tracing::error!("Generate or obtain TLS private key for your domain"); - std::process::exit(1); - } - if let Err(e) = std::fs::File::open(key_path) { - tracing::error!( - "Cannot read private key file '{}' for host '{}': {}", - host_config.key, - hostname, - e - ); - tracing::error!("Ensure the file exists and the server user has read permission"); - 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); + + // 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 { - tracing::error!("max_concurrent_requests must be between 1 and 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 + let test_processing_delay = args.test_processing_delay .filter(|&d| d > 0 && d <= 300) .unwrap_or(0); @@ -216,55 +166,43 @@ async fn main() -> Result<(), Box> { #[cfg(not(debug_assertions))] let test_processing_delay = 0; + // Validate directory + let dir_path = Path::new(&root); + if !dir_path.exists() || !dir_path.is_dir() { + eprintln!("Error: Directory '{}' does not exist or is not a directory", root); + std::process::exit(1); + } + + // Load TLS certificates + let certs = tls::load_certs(&cert_path).unwrap(); + let key = tls::load_private_key(&key_path).unwrap(); + + let config = ServerConfig::builder() + .with_safe_defaults() + .with_no_client_auth() + .with_single_cert(certs, key).unwrap(); + + let acceptor = TlsAcceptor::from(Arc::new(config)); + + let listener = TcpListener::bind(format!("{}:{}", bind_host, port)).await.unwrap(); + // Print startup information - print_startup_info(&config, &config.hosts, args.quiet); - - // Phase 3: TLS mode (always enabled) - let tls_config = create_tls_config(&config.hosts)?; - let acceptor = TlsAcceptor::from(tls_config); - - if !args.quiet { - println!("Starting Pollux Gemini Server with Virtual Host support..."); - } - - let bind_host = config.bind_host.as_deref().unwrap_or("0.0.0.0"); - let port = config.port.unwrap_or(1965); - - let listener = TcpListener::bind(format!("{}:{}", bind_host, port)).await?; - if !args.quiet { - println!( - "Listening on {}:{} for all virtual hosts (TLS enabled)", - bind_host, port - ); - } + print_startup_info(&bind_host, port, &root, &cert_path, &key_path, Some(log_level), max_concurrent_requests); loop { - let (stream, _) = listener.accept().await?; - - let hosts_clone = config.hosts.clone(); - let acceptor_clone = acceptor.clone(); + 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_hostname = hostname.clone(); let max_concurrent = max_concurrent_requests; let test_delay = test_processing_delay; - tokio::spawn(async move { - // TLS connection with hostname routing - match acceptor_clone.accept(stream).await { - Ok(tls_stream) => { - if let Err(e) = server::handle_connection( - tls_stream, - &hosts_clone, - max_concurrent, - test_delay, - ) - .await - { - tracing::error!("Error handling connection: {}", e); - } - } - Err(e) => { - tracing::error!("TLS handshake failed: {}", e); + 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 3fc591c..bf939bd 100644 --- a/src/request.rs +++ b/src/request.rs @@ -6,12 +6,11 @@ pub enum PathResolutionError { NotFound, } -#[allow(dead_code)] pub fn parse_gemini_url(request: &str, hostname: &str, expected_port: u16) -> Result { if let Some(url) = request.strip_prefix("gemini://") { 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]; @@ -20,23 +19,21 @@ pub fn parse_gemini_url(request: &str, hostname: &str, expected_port: u16) -> Re } else { (host_port, None) }; - + // Validate host if host != hostname { - return Err(()); // Hostname mismatch + return Err(()); // Hostname mismatch } - + // Validate port - let port = port_str.and_then(|p| p.parse::().ok()).unwrap_or(1965); + let port = port_str + .and_then(|p| p.parse::().ok()) + .unwrap_or(1965); if port != expected_port { - return Err(()); // Port mismatch + return Err(()); // Port mismatch } - - let path = if host_port_end < url.len() { - &url[host_port_end..] - } else { - "/" - }; + + let path = if host_port_end < url.len() { &url[host_port_end..] } else { "/" }; Ok(path.trim().to_string()) } else { Err(()) @@ -60,11 +57,11 @@ pub fn resolve_file_path(path: &str, dir: &str) -> Result { // Path validation failed - treat as not found Err(PathResolutionError::NotFound) - } + }, } } @@ -92,18 +89,8 @@ mod tests { #[test] fn test_parse_gemini_url_valid() { - 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()) - ); + 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] @@ -142,20 +129,14 @@ mod tests { #[test] fn test_resolve_file_path_traversal() { let temp_dir = TempDir::new().unwrap(); - assert_eq!( - resolve_file_path("/../etc/passwd", temp_dir.path().to_str().unwrap()), - Err(PathResolutionError::NotFound) - ); + 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) - ); + assert_eq!(resolve_file_path("/nonexistent.gmi", temp_dir.path().to_str().unwrap()), Err(PathResolutionError::NotFound)); } #[test] @@ -181,4 +162,4 @@ mod tests { let path = Path::new("test"); assert_eq!(get_mime_type(path), "application/octet-stream"); } -} +} \ No newline at end of file diff --git a/src/server.rs b/src/server.rs index 3a06e83..1e5e577 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,4 +1,5 @@ -use crate::request::{get_mime_type, resolve_file_path}; +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; @@ -10,37 +11,37 @@ use tokio_rustls::server::TlsStream; static ACTIVE_REQUESTS: AtomicUsize = AtomicUsize::new(0); -/// Extract hostname and path from a Gemini URL -/// Returns (hostname, path) or error for invalid URLs -pub fn extract_hostname_and_path(request: &str) -> Result<(String, String), ()> { - if !request.starts_with("gemini://") { - return Err(()); - } - - let url_part = &request[9..]; // Remove "gemini://" prefix - let slash_pos = url_part.find('/').unwrap_or(url_part.len()); - - let hostname = &url_part[..slash_pos]; - let path = if slash_pos < url_part.len() { - url_part[slash_pos..].to_string() +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 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 { - "/".to_string() - }; - - // Basic hostname validation - if hostname.is_empty() || hostname.contains('/') { - return Err(()); + Err(tokio::io::Error::new(tokio::io::ErrorKind::NotFound, "File not found")) } - - // URL decode the path - let decoded_path = urlencoding::decode(&path).map_err(|_| ())?; - - Ok((hostname.to_string(), decoded_path.to_string())) } pub async fn handle_connection( mut stream: TlsStream, - hosts: &std::collections::HashMap, + dir: &str, + hostname: &str, + expected_port: u16, max_concurrent_requests: usize, _test_processing_delay: u64, ) -> io::Result<()> { @@ -51,10 +52,7 @@ pub async fn handle_connection( let read_future = async { loop { if request_buf.len() >= MAX_REQUEST_SIZE { - return Err(tokio::io::Error::new( - tokio::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?; @@ -72,13 +70,12 @@ pub async fn handle_connection( let request = String::from_utf8_lossy(&request_buf).trim().to_string(); // Initialize logger early for all request types - // TODO: Phase 3 - re-enable RequestLogger with proper TLS stream - // let logger = RequestLogger::new(&stream, request.clone()); + let logger = RequestLogger::new(&stream, request.clone()); - // Check concurrent request limit after connection establishment + // Check concurrent request limit after TLS handshake and request read let current = ACTIVE_REQUESTS.fetch_add(1, Ordering::Relaxed); if current >= max_concurrent_requests { - tracing::error!("Concurrent request limit exceeded"); + 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?; @@ -88,65 +85,84 @@ pub async fn handle_connection( // Process the request // Validate request if request.is_empty() { - tracing::error!("Empty request"); + logger.log_error(59, "Empty request"); ACTIVE_REQUESTS.fetch_sub(1, Ordering::Relaxed); return send_response(&mut stream, "59 Bad Request\r\n").await; } - // Extract hostname and path - let (hostname, path) = match extract_hostname_and_path(&request) { - Ok(result) => result, + 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(_) => { - tracing::error!("Invalid URL format: {}", request); + 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; } }; - // Route to appropriate host - let host_config = match hosts.get(&hostname) { - Some(config) => config, - None => { - tracing::error!("Unknown hostname: {}", hostname); - ACTIVE_REQUESTS.fetch_sub(1, Ordering::Relaxed); - return send_response(&mut stream, "53 Proxy request refused\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 - let file_path = match resolve_file_path(&path, &host_config.root) { - Ok(path) => path, - Err(_) => { - tracing::error!("Path resolution failed for: {}", path); + // 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; } }; - tracing::info!("{} 20 Success", request); + // No delay for normal operation + + // Processing complete // Serve the file match serve_file(&mut stream, &file_path, &request).await { - Ok(_) => {} - Err(e) => { - tracing::error!("Error serving file {}: {}", file_path.display(), e); + 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)) => { - tracing::error!("Request read error: {}", e); - let _ = send_response(&mut stream, "59 Bad Request\r\n").await; - } + // 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(_) => { - tracing::error!("Request timeout"); - let _ = send_response(&mut stream, "59 Bad Request\r\n").await; + // 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(()); } } @@ -154,30 +170,11 @@ pub async fn handle_connection( Ok(()) } -async fn serve_file(stream: &mut S, file_path: &Path, _request: &str) -> io::Result<()> -where - S: AsyncWriteExt + Unpin, -{ - if file_path.exists() && file_path.is_file() { - let mime_type = get_mime_type(file_path); - let response_header = format!("20 {}\r\n", mime_type); - send_response(stream, &response_header).await?; - - // Read and send file content - let content = fs::read(file_path)?; - stream.write_all(&content).await?; - stream.flush().await?; - } else { - return Err(io::Error::new(io::ErrorKind::NotFound, "File not found")); - } - Ok(()) -} - -async fn send_response(stream: &mut S, response: &str) -> io::Result<()> -where - S: AsyncWriteExt + Unpin, -{ +async fn send_response( + stream: &mut TlsStream, + response: &str, +) -> io::Result<()> { stream.write_all(response.as_bytes()).await?; stream.flush().await?; Ok(()) -} +} \ No newline at end of file diff --git a/src/tls.rs b/src/tls.rs index 04b4cff..ff741b7 100644 --- a/src/tls.rs +++ b/src/tls.rs @@ -24,8 +24,5 @@ pub fn load_private_key(filename: &str) -> io::Result { } } - Err(io::Error::new( - io::ErrorKind::InvalidData, - "No supported private key found", - )) -} + Err(io::Error::new(io::ErrorKind::InvalidData, "No supported private key found")) +} \ No newline at end of file diff --git a/tests/common.rs b/tests/common.rs index 7f401cd..9ddde09 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -1,29 +1,4 @@ use std::path::Path; - -#[allow(dead_code)] -pub fn generate_test_certificates_for_host(temp_dir: &Path, hostname: &str) { - let cert_path = temp_dir.join(format!("{}.pem", hostname)); - let key_path = temp_dir.join(format!("{}_key.pem", hostname)); - - // Generate self-signed certificate for testing - // This is a simplified version - in production, use proper certificates - std::fs::write( - &cert_path, - format!( - "-----BEGIN CERTIFICATE-----\nTest cert for {}\n-----END CERTIFICATE-----\n", - hostname - ), - ) - .unwrap(); - std::fs::write( - &key_path, - format!( - "-----BEGIN PRIVATE KEY-----\nTest key for {}\n-----END PRIVATE KEY-----\n", - hostname - ), - ) - .unwrap(); -} use tempfile::TempDir; pub fn setup_test_environment() -> TempDir { @@ -37,47 +12,26 @@ pub fn setup_test_environment() -> TempDir { // Generate test certificates generate_test_certificates(temp_dir.path()); - // Verify certificates were created successfully - let cert_path = temp_dir.path().join("cert.pem"); - let key_path = temp_dir.path().join("key.pem"); - assert!(cert_path.exists(), "Certificate file was not created"); - assert!(key_path.exists(), "Private key file was not created"); - temp_dir } fn generate_test_certificates(temp_dir: &Path) { use std::process::Command; - // Generate self-signed certificate for testing let cert_path = temp_dir.join("cert.pem"); let key_path = temp_dir.join("key.pem"); - // Use openssl to generate a test certificate - let output = Command::new("openssl") + 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", + "req", "-x509", "-newkey", "rsa:2048", + "-keyout", &key_path.to_string_lossy(), + "-out", &cert_path.to_string_lossy(), + "-days", "1", "-nodes", - "-subj", - "/CN=localhost", + "-subj", "/CN=localhost" ]) - .output(); + .status() + .unwrap(); - match output { - Ok(result) if result.status.success() => { - // Certificate generation successful - } - _ => { - panic!("Failed to generate test certificates with OpenSSL. Make sure OpenSSL is installed and available in PATH."); - } - } -} + 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 index cdd4022..9a3c951 100644 --- a/tests/config_validation.rs +++ b/tests/config_validation.rs @@ -7,153 +7,116 @@ fn test_missing_config_file() { let output = Command::new(env!("CARGO_BIN_EXE_pollux")) .arg("--config") .arg("nonexistent.toml") - .env("RUST_LOG", "error") .output() .unwrap(); assert!(!output.status.success()); let stderr = String::from_utf8(output.stderr).unwrap(); - let stdout = String::from_utf8(output.stdout).unwrap(); assert!(stderr.contains("Config file 'nonexistent.toml' not found")); - assert!(stdout.contains("Create the config file with")); + 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#" -bind_host = "127.0.0.1" - -["example.com"] -root = "/definitely/does/not/exist" -cert = "{}" -key = "{}" - "#, - temp_dir.path().join("cert.pem").display(), - temp_dir.path().join("key.pem").display() - ); + 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) - .arg("--quiet") - .env("RUST_LOG", "error") .output() .unwrap(); assert!(!output.status.success()); let stderr = String::from_utf8(output.stderr).unwrap(); - assert!(stderr.contains("Failed to parse config file")); - assert!(stderr.contains( - "Error for host 'example.com': Root directory '/definitely/does/not/exist' does not exist" - )); + 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#" -bind_host = "127.0.0.1" - -["example.com"] -root = "{}" -cert = "/nonexistent/cert.pem" -key = "{}" - "#, - temp_dir.path().join("content").display(), - temp_dir.path().join("key.pem").display() - ); + 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) - .arg("--quiet") - .env("RUST_LOG", "error") .output() .unwrap(); assert!(!output.status.success()); let stderr = String::from_utf8(output.stderr).unwrap(); - assert!(stderr.contains( - "Error for host 'example.com': Certificate file '/nonexistent/cert.pem' does not exist" - )); + 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_multiple_hosts_missing_certificate() { +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"); - - // Create host directories - std::fs::create_dir(temp_dir.path().join("host1")).unwrap(); - std::fs::create_dir(temp_dir.path().join("host2")).unwrap(); - - // Generate certificate for only host1 - let cert1_path = temp_dir.path().join("host1_cert.pem"); - let key1_path = temp_dir.path().join("host1_key.pem"); - - let cert_result = std::process::Command::new("openssl") - .args(&[ - "req", - "-x509", - "-newkey", - "rsa:2048", - "-keyout", - &key1_path.to_string_lossy(), - "-out", - &cert1_path.to_string_lossy(), - "-days", - "1", - "-nodes", - "-subj", - "/CN=host1.com", - ]) - .output(); - - if cert_result.is_err() { - panic!("Failed to generate test certificate"); - } - - let config_content = format!( - r#" -bind_host = "127.0.0.1" - -["host1.com"] -root = "{}" -cert = "{}" -key = "{}" - -["host2.com"] -root = "{}" -cert = "/nonexistent/cert.pem" -key = "/nonexistent/key.pem" -"#, - temp_dir.path().join("host1").display(), - cert1_path.display(), - key1_path.display(), - temp_dir.path().join("host2").display() - ); - + 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 output = Command::new(env!("CARGO_BIN_EXE_pollux")) + let mut server_process = Command::new(env!("CARGO_BIN_EXE_pollux")) .arg("--config") .arg(&config_path) - .arg("--quiet") - .env("RUST_LOG", "error") - .output() + .spawn() .unwrap(); - assert!(!output.status.success()); - let stderr = String::from_utf8(output.stderr).unwrap(); - assert!(stderr.contains( - "Error for host 'host2.com': Certificate file '/nonexistent/cert.pem' does not exist" - )); -} + // 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 index f4ee198..351715f 100755 --- a/tests/gemini_test_client.py +++ b/tests/gemini_test_client.py @@ -8,7 +8,6 @@ Used by integration tests for rate limiting validation. Usage: python3 tests/gemini_test_client.py gemini://host:port/path """ -import os import sys import socket import ssl @@ -20,65 +19,51 @@ def main(): url = sys.argv[1] - # Parse URL (basic parsing) - allow any protocol for testing - if url.startswith('gemini://'): - url_parts = url[9:].split('/', 1) # Remove gemini:// - host = url_parts[0] - path = '/' + url_parts[1] if len(url_parts) > 1 else '/' + # 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: - # For non-gemini URLs, try to extract host anyway for testing - if '://' in url: - protocol, rest = url.split('://', 1) - url_parts = rest.split('/', 1) - host = url_parts[0] - path = '/' + url_parts[1] if len(url_parts) > 1 else '/' - else: - # No protocol, assume it's host/path - url_parts = url.split('/', 1) - host = url_parts[0] - path = '/' + url_parts[1] if len(url_parts) > 1 else '/' - - # Get port from environment or use default - port = int(os.environ.get('GEMINI_PORT', '1965')) - - # Allow overriding the connection host (useful for testing with localhost) - connect_host = os.environ.get('GEMINI_CONNECT_HOST', host) - + host = host_port + port = 1965 + try: - # Create SSL connection with permissive settings for self-signed certs - context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + # Create SSL connection + context = ssl.create_default_context() context.check_hostname = False context.verify_mode = ssl.CERT_NONE - # Load default certificates to avoid some SSL issues - context.load_default_certs() - - sock = socket.create_connection((connect_host, port), timeout=5.0) + + sock = socket.create_connection((host, port), timeout=5.0) ssl_sock = context.wrap_socket(sock, server_hostname=host) - - # Send request (full URL for Gemini protocol over TLS) + + # Send request request = f"{url}\r\n" ssl_sock.send(request.encode('utf-8')) - - # Read full response (header + body) + + # Read response header response = b'' - while len(response) < 1024: # Read up to 1KB for test responses - try: - data = ssl_sock.recv(1024) - if not data: - break - response += data - except: + 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: - # Decode and return the full response - full_response = response.decode('utf-8', errors='ignore') - print(full_response.strip()) + 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}") diff --git a/tests/rate_limiting.rs b/tests/rate_limiting.rs index 3b49881..afb2547 100644 --- a/tests/rate_limiting.rs +++ b/tests/rate_limiting.rs @@ -7,37 +7,23 @@ fn test_rate_limiting_with_concurrent_requests() { // Create config with rate limiting enabled let config_path = temp_dir.path().join("config.toml"); - - // Use existing content directory and cert files from setup_test_environment - let root_dir = temp_dir.path().join("content"); - let cert_path = temp_dir.path().join("cert.pem"); - let key_path = temp_dir.path().join("key.pem"); - - let config_content = format!( - r#" -bind_host = "127.0.0.1" -port = {} -max_concurrent_requests = 1 - -["localhost"] -root = "{}" -cert = "{}" -key = "{}" - "#, - port, - root_dir.display(), - cert_path.display(), - key_path.display() - ); + 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("--quiet") .arg("--test-processing-delay") - .arg("3") // 3 second delay per request + .arg("1") // 1 second delay per request .spawn() .expect("Failed to start server"); @@ -47,13 +33,11 @@ key = "{}" // Spawn 5 concurrent client processes let mut handles = vec![]; for _ in 0..5 { - let url = format!("gemini://localhost/test.gmi"); + 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) - .env("GEMINI_PORT", &port.to_string()) - .env("RATE_LIMIT_TEST", "true") .output() }); handles.push(handle); @@ -74,31 +58,8 @@ key = "{}" 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(); - // Debug output - tracing::debug!("Test results: {:?}", results); - tracing::debug!( - "Success: {}, Rate limited: {}", - success_count, - rate_limited_count - ); - - // Strict validation - rate limiting must work deterministically with delay - assert_eq!( - success_count, 1, - "Expected exactly 1 successful request with limit=1, got {}. Results: {:?}", - success_count, results - ); - assert_eq!( - rate_limited_count, 4, - "Expected exactly 4 rate limited requests with limit=1, got {}. Results: {:?}", - rate_limited_count, results - ); - - // Verify all requests received valid responses - assert_eq!( - success_count + rate_limited_count, - 5, - "All 5 requests should receive responses. Results: {:?}", - results - ); -} + // 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 diff --git a/tests/virtual_host_config.rs b/tests/virtual_host_config.rs deleted file mode 100644 index a343e6e..0000000 --- a/tests/virtual_host_config.rs +++ /dev/null @@ -1,364 +0,0 @@ -mod common; - -#[test] -fn test_single_host_config() { - let temp_dir = tempfile::TempDir::new().unwrap(); - let config_path = temp_dir.path().join("config.toml"); - let port = 1967 + (std::process::id() % 1000) as u16; - - // Create content directory and certificates - let content_dir = temp_dir.path().join("content"); - std::fs::create_dir(&content_dir).unwrap(); - - // Generate test certificates - use std::process::Command; - let cert_path = temp_dir.path().join("cert.pem"); - let key_path = temp_dir.path().join("key.pem"); - - let cert_result = 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=example.com", - ]) - .output(); - - if cert_result.is_err() { - panic!("Failed to generate test certificates for config test"); - } - - let config_content = format!( - r#" -bind_host = "127.0.0.1" -port = {} - -["example.com"] -root = "{}" -cert = "{}" -key = "{}" -"#, - port, - content_dir.display(), - cert_path.display(), - key_path.display() - ); - std::fs::write(&config_path, config_content).unwrap(); - - let mut server_process = std::process::Command::new(env!("CARGO_BIN_EXE_pollux")) - .arg("--config") - .arg(&config_path) - .arg("--quiet") - .spawn() - .unwrap(); - - std::thread::sleep(std::time::Duration::from_millis(500)); - assert!( - server_process.try_wait().unwrap().is_none(), - "Server should start with valid single host config" - ); - server_process.kill().unwrap(); -} - -#[test] -fn test_multiple_hosts_config() { - let temp_dir = common::setup_test_environment(); - let config_path = temp_dir.path().join("config.toml"); - let config_content = format!( - r#" -[site1.com] -root = "{}" -cert = "{}" -key = "{}" - -[site2.org] -root = "{}" -cert = "{}" -key = "{}" - -bind_host = "127.0.0.1" -port = 1965 -"#, - temp_dir.path().join("site1").display(), - temp_dir.path().join("site1_cert.pem").display(), - temp_dir.path().join("site1_key.pem").display(), - temp_dir.path().join("site2").display(), - temp_dir.path().join("site2_cert.pem").display(), - temp_dir.path().join("site2_key.pem").display() - ); - std::fs::write(&config_path, config_content).unwrap(); - - // Create additional directories and generate certificates - std::fs::create_dir(temp_dir.path().join("site1")).unwrap(); - std::fs::create_dir(temp_dir.path().join("site2")).unwrap(); - - // Generate certificates for each host - use std::process::Command; - - // Site 1 certificate - let cert_result1 = Command::new("openssl") - .args(&[ - "req", - "-x509", - "-newkey", - "rsa:2048", - "-keyout", - &temp_dir.path().join("site1_key.pem").to_string_lossy(), - "-out", - &temp_dir.path().join("site1_cert.pem").to_string_lossy(), - "-days", - "1", - "-nodes", - "-subj", - "/CN=site1.com", - ]) - .output(); - - // Site 2 certificate - let cert_result2 = Command::new("openssl") - .args(&[ - "req", - "-x509", - "-newkey", - "rsa:2048", - "-keyout", - &temp_dir.path().join("site2_key.pem").to_string_lossy(), - "-out", - &temp_dir.path().join("site2_cert.pem").to_string_lossy(), - "-days", - "1", - "-nodes", - "-subj", - "/CN=site2.org", - ]) - .output(); - - if cert_result1.is_err() || cert_result2.is_err() { - panic!("Failed to generate test certificates for multiple hosts test"); - } - - // Test server starts successfully with multiple host config - let port = 1968 + (std::process::id() % 1000) as u16; - let config_content = format!( - r#" -bind_host = "127.0.0.1" -port = {} - -["site1.com"] -root = "{}" -cert = "{}" -key = "{}" - -["site2.org"] -root = "{}" -cert = "{}" -key = "{}" -"#, - port, - temp_dir.path().join("site1").display(), - temp_dir.path().join("site1_cert.pem").display(), - temp_dir.path().join("site1_key.pem").display(), - temp_dir.path().join("site2").display(), - temp_dir.path().join("site2_cert.pem").display(), - temp_dir.path().join("site2_key.pem").display() - ); - std::fs::write(&config_path, config_content).unwrap(); - - let mut server_process = std::process::Command::new(env!("CARGO_BIN_EXE_pollux")) - .arg("--config") - .arg(&config_path) - .arg("--quiet") - .spawn() - .unwrap(); - - std::thread::sleep(std::time::Duration::from_millis(500)); - assert!( - server_process.try_wait().unwrap().is_none(), - "Server should start with valid multiple host config" - ); - server_process.kill().unwrap(); -} - -#[test] -fn test_missing_required_fields_in_host_config() { - let temp_dir = common::setup_test_environment(); - let config_path = temp_dir.path().join("config.toml"); - let config_content = r#" -bind_host = "127.0.0.1" -port = 1965 - -["example.com"] -root = "/tmp/content" -# missing cert and key -"#; - std::fs::write(&config_path, config_content).unwrap(); - - let output = std::process::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("Missing required field") || stderr.contains("missing field")); -} - -#[test] -fn test_invalid_hostname_config() { - let temp_dir = common::setup_test_environment(); - let config_path = temp_dir.path().join("config.toml"); - let config_content = format!( - r#" -["invalid"] -root = "{}" -cert = "{}" -key = "{}" -"#, - 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 = std::process::Command::new(env!("CARGO_BIN_EXE_pollux")) - .arg("--config") - .arg(&config_path) - .arg("--quiet") - .output() - .unwrap(); - - assert!(!output.status.success()); - let stderr = String::from_utf8(output.stderr).unwrap(); - assert!(stderr.contains("Invalid hostname")); -} - -#[test] -fn test_no_hosts_config() { - let temp_dir = common::setup_test_environment(); - let config_path = temp_dir.path().join("config.toml"); - let config_content = r#" -bind_host = "127.0.0.1" -port = 1965 -# No host sections defined -"#; - std::fs::write(&config_path, config_content).unwrap(); - - let output = std::process::Command::new(env!("CARGO_BIN_EXE_pollux")) - .arg("--config") - .arg(&config_path) - .arg("--quiet") - .output() - .unwrap(); - - assert!(!output.status.success()); - let stderr = String::from_utf8(output.stderr).unwrap(); - assert!(stderr.contains("No host configurations found")); -} - -#[test] -fn test_duplicate_hostname_config() { - let temp_dir = common::setup_test_environment(); - let config_path = temp_dir.path().join("config.toml"); - let config_content = format!( - r#" -[example.com] -root = "{}" -cert = "{}" -key = "{}" - -[example.com] -root = "{}" -cert = "{}" -key = "{}" -"#, - temp_dir.path().join("path1").display(), - temp_dir.path().join("cert1.pem").display(), - temp_dir.path().join("key1.pem").display(), - temp_dir.path().join("path2").display(), - temp_dir.path().join("cert2.pem").display(), - temp_dir.path().join("key2.pem").display() - ); - std::fs::write(&config_path, config_content).unwrap(); - - // Create the directories and certs - std::fs::create_dir(temp_dir.path().join("path1")).unwrap(); - std::fs::create_dir(temp_dir.path().join("path2")).unwrap(); - std::fs::write(temp_dir.path().join("cert1.pem"), "cert1").unwrap(); - std::fs::write(temp_dir.path().join("key1.pem"), "key1").unwrap(); - std::fs::write(temp_dir.path().join("cert2.pem"), "cert2").unwrap(); - std::fs::write(temp_dir.path().join("key2.pem"), "key2").unwrap(); - - let output = std::process::Command::new(env!("CARGO_BIN_EXE_pollux")) - .arg("--config") - .arg(&config_path) - .arg("--quiet") - .output() - .unwrap(); - - // Duplicate table headers are not allowed in TOML, so this should fail - assert!(!output.status.success()); -} - -#[test] -fn test_host_with_port_override() { - let temp_dir = common::setup_test_environment(); - let config_path = temp_dir.path().join("config.toml"); - // Test server starts successfully - let port = 1969 + (std::process::id() % 1000) as u16; - let config_content = format!( - r#" -bind_host = "127.0.0.1" -port = {} - -["example.com"] -root = "{}" -cert = "{}" -key = "{}" -port = 1970 # Override global port -"#, - port, - 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 mut server_process = std::process::Command::new(env!("CARGO_BIN_EXE_pollux")) - .arg("--config") - .arg(&config_path) - .arg("--quiet") - .spawn() - .unwrap(); - - std::thread::sleep(std::time::Duration::from_millis(500)); - assert!( - server_process.try_wait().unwrap().is_none(), - "Server should start with host port override" - ); - server_process.kill().unwrap(); -} - -#[test] -fn test_config_file_not_found() { - let output = std::process::Command::new(env!("CARGO_BIN_EXE_pollux")) - .arg("--config") - .arg("nonexistent.toml") - .arg("--quiet") - .env("RUST_LOG", "error") - .output() - .unwrap(); - - assert!(!output.status.success()); - let stderr = String::from_utf8(output.stderr).unwrap(); - assert!(stderr.contains("Config file 'nonexistent.toml' not found")); -} diff --git a/tests/virtual_host_integration.rs b/tests/virtual_host_integration.rs deleted file mode 100644 index 27cf8e8..0000000 --- a/tests/virtual_host_integration.rs +++ /dev/null @@ -1,384 +0,0 @@ -mod common; - -use std::sync::Arc; -use std::thread; -use std::time::{Duration, Instant}; - -#[test] -fn test_concurrent_requests_multiple_hosts() { - let temp_dir = common::setup_test_environment(); - - // Create content for multiple hosts - let hosts = vec!["site1.com", "site2.org", "site3.net"]; - let mut host_roots = Vec::new(); - - for host in &hosts { - let root_dir = temp_dir.path().join(host.replace(".", "_")); - std::fs::create_dir(&root_dir).unwrap(); - std::fs::write(root_dir.join("index.gmi"), format!("Welcome to {}", host)).unwrap(); - host_roots.push(root_dir); - } - - // Create config with multiple hosts - let config_path = temp_dir.path().join("config.toml"); - let port = 1969 + (std::process::id() % 1000) as u16; - - let mut config_content = format!( - r#" -bind_host = "127.0.0.1" -port = {} - -"#, - port - ); - - for (i, host) in hosts.iter().enumerate() { - config_content.push_str(&format!( - r#" -["{}"] -root = "{}" -cert = "{}" -key = "{}" -"#, - host, - host_roots[i].display(), - temp_dir.path().join("cert.pem").display(), - temp_dir.path().join("key.pem").display() - )); - } - - std::fs::write(&config_path, config_content).unwrap(); - - // Start server with TLS - let mut server_process = std::process::Command::new(env!("CARGO_BIN_EXE_pollux")) - .arg("--config") - .arg(&config_path) - .arg("--quiet") - .spawn() - .unwrap(); - - std::thread::sleep(Duration::from_millis(500)); - - // Spawn multiple threads making concurrent requests - let mut handles = Vec::new(); - let port_arc = Arc::new(port); - - for i in 0..10 { - let host = hosts[i % hosts.len()].to_string(); - let port_clone = Arc::clone(&port_arc); - - let handle = thread::spawn(move || { - let response = - make_gemini_request("127.0.0.1", *port_clone, &format!("gemini://{}/", host)); - assert!( - response.starts_with("20"), - "Request {} failed: {}", - i, - response - ); - assert!( - response.contains(&format!("Welcome to {}", host)), - "Wrong content for request {}: {}", - i, - response - ); - response - }); - - handles.push(handle); - } - - // Collect results - let mut results = Vec::new(); - for handle in handles { - results.push(handle.join().unwrap()); - } - - assert_eq!(results.len(), 10, "All concurrent requests should complete"); - - server_process.kill().unwrap(); -} - -#[test] -fn test_mixed_valid_invalid_hostnames() { - let temp_dir = common::setup_test_environment(); - - // Create content for one valid host - let root_dir = temp_dir.path().join("valid_site"); - std::fs::create_dir(&root_dir).unwrap(); - std::fs::write(root_dir.join("index.gmi"), "Valid site content").unwrap(); - - // Create config - let config_path = temp_dir.path().join("config.toml"); - let port = 1970 + (std::process::id() % 1000) as u16; - let config_content = format!( - r#" -bind_host = "127.0.0.1" -port = {} - -["valid.com"] -root = "{}" -cert = "{}" -key = "{}" -"#, - port, - root_dir.display(), - temp_dir.path().join("cert.pem").display(), - temp_dir.path().join("key.pem").display() - ); - std::fs::write(&config_path, config_content).unwrap(); - - // Start server with TLS - let mut server_process = std::process::Command::new(env!("CARGO_BIN_EXE_pollux")) - .arg("--config") - .arg(&config_path) - .arg("--quiet") - .spawn() - .unwrap(); - - std::thread::sleep(Duration::from_millis(500)); - - // Test valid hostname - let valid_response = make_gemini_request("127.0.0.1", port, "gemini://valid.com/"); - assert!( - valid_response.starts_with("20"), - "Valid host should work: {}", - valid_response - ); - assert!( - valid_response.contains("Valid site content"), - "Should serve correct content: {}", - valid_response - ); - - // Test various invalid hostnames - let invalid_hosts = vec![ - "invalid.com", - "unknown.net", - "nonexistent.invalid", - "site.with.dots.com", - ]; - - for invalid_host in invalid_hosts { - let response = - make_gemini_request("127.0.0.1", port, &format!("gemini://{}/", invalid_host)); - assert!( - response.starts_with("53"), - "Invalid host '{}' should return 53, got: {}", - invalid_host, - response - ); - } - - server_process.kill().unwrap(); -} - -#[test] -fn test_load_performance_basic() { - let temp_dir = common::setup_test_environment(); - - // Create a simple host - let root_dir = temp_dir.path().join("perf_test"); - std::fs::create_dir(&root_dir).unwrap(); - std::fs::write(root_dir.join("index.gmi"), "Performance test content").unwrap(); - - let config_path = temp_dir.path().join("config.toml"); - let port = 1971 + (std::process::id() % 1000) as u16; - let config_content = format!( - r#" -bind_host = "127.0.0.1" -port = {} - -["perf.com"] -root = "{}" -cert = "{}" -key = "{}" -"#, - port, - root_dir.display(), - temp_dir.path().join("cert.pem").display(), - temp_dir.path().join("key.pem").display() - ); - std::fs::write(&config_path, config_content).unwrap(); - - // Start server with TLS - let mut server_process = std::process::Command::new(env!("CARGO_BIN_EXE_pollux")) - .arg("--config") - .arg(&config_path) - .arg("--quiet") - .spawn() - .unwrap(); - - std::thread::sleep(Duration::from_millis(500)); - - // Measure time for multiple requests - let start = Instant::now(); - const NUM_REQUESTS: usize = 50; - - for i in 0..NUM_REQUESTS { - let response = make_gemini_request("127.0.0.1", port, "gemini://perf.com/"); - assert!( - response.starts_with("20"), - "Request {} failed: {}", - i, - response - ); - } - - let elapsed = start.elapsed(); - let avg_time = elapsed.as_millis() as f64 / NUM_REQUESTS as f64; - - tracing::debug!( - "Processed {} requests in {:?} (avg: {:.2}ms per request)", - NUM_REQUESTS, - elapsed, - avg_time - ); - - // Basic performance check - should be reasonably fast - assert!( - avg_time < 100.0, - "Average request time too slow: {:.2}ms", - avg_time - ); - - server_process.kill().unwrap(); -} - -#[test] -fn test_full_request_lifecycle() { - let temp_dir = common::setup_test_environment(); - - // Create complex content structure - let root_dir = temp_dir.path().join("lifecycle_test"); - std::fs::create_dir(&root_dir).unwrap(); - - // Create directory with index - let blog_dir = root_dir.join("blog"); - std::fs::create_dir(&blog_dir).unwrap(); - std::fs::write(blog_dir.join("index.gmi"), "Blog index content").unwrap(); - - // Create individual file - std::fs::write(root_dir.join("about.gmi"), "About page content").unwrap(); - - // Create root index - std::fs::write(root_dir.join("index.gmi"), "Main site content").unwrap(); - - let config_path = temp_dir.path().join("config.toml"); - let port = 1972 + (std::process::id() % 1000) as u16; - let cert_path = temp_dir.path().join("cert.pem"); - let key_path = temp_dir.path().join("key.pem"); - - let config_content = format!( - r#" -bind_host = "127.0.0.1" -port = {} - -["lifecycle.com"] -root = "{}" -cert = "{}" -key = "{}" -"#, - port, - root_dir.display(), - cert_path.display(), - key_path.display() - ); - std::fs::write(&config_path, config_content).unwrap(); - - // Start server with TLS - let mut server_process = std::process::Command::new(env!("CARGO_BIN_EXE_pollux")) - .arg("--config") - .arg(&config_path) - .arg("--quiet") - .spawn() - .unwrap(); - - std::thread::sleep(Duration::from_millis(500)); - - // Test root index - let root_response = make_gemini_request("127.0.0.1", port, "gemini://lifecycle.com/"); - assert!( - root_response.starts_with("20"), - "Root request failed: {}", - root_response - ); - assert!( - root_response.contains("Main site content"), - "Wrong root content: {}", - root_response - ); - - // Test explicit index - let index_response = make_gemini_request("127.0.0.1", port, "gemini://lifecycle.com/index.gmi"); - assert!( - index_response.starts_with("20"), - "Index request failed: {}", - index_response - ); - assert!( - index_response.contains("Main site content"), - "Wrong index content: {}", - index_response - ); - - // Test subdirectory index - let blog_response = make_gemini_request("127.0.0.1", port, "gemini://lifecycle.com/blog/"); - assert!( - blog_response.starts_with("20"), - "Blog request failed: {}", - blog_response - ); - assert!( - blog_response.contains("Blog index content"), - "Wrong blog content: {}", - blog_response - ); - - // Test individual file - let about_response = make_gemini_request("127.0.0.1", port, "gemini://lifecycle.com/about.gmi"); - assert!( - about_response.starts_with("20"), - "About request failed: {}", - about_response - ); - assert!( - about_response.contains("About page content"), - "Wrong about content: {}", - about_response - ); - - // Test not found - let notfound_response = - make_gemini_request("127.0.0.1", port, "gemini://lifecycle.com/nonexistent.gmi"); - assert!( - notfound_response.starts_with("51"), - "Not found should return 51: {}", - notfound_response - ); - - server_process.kill().unwrap(); -} - -fn make_gemini_request(host: &str, port: u16, url: &str) -> String { - // Use the Python client for TLS requests - use std::process::Command; - - let output = Command::new("python3") - .arg("tests/gemini_test_client.py") - .arg(url) - .env("GEMINI_PORT", &port.to_string()) - .env("GEMINI_CONNECT_HOST", host) - .output(); - - match output { - Ok(result) => { - if result.status.success() { - String::from_utf8_lossy(&result.stdout).trim().to_string() - } else { - format!("Error: Python client failed with status {}", result.status) - } - } - Err(e) => format!("Error: Failed to run Python client: {}", e), - } -} diff --git a/tests/virtual_host_paths.rs b/tests/virtual_host_paths.rs deleted file mode 100644 index 8dcc7d8..0000000 --- a/tests/virtual_host_paths.rs +++ /dev/null @@ -1,167 +0,0 @@ -mod common; - -#[test] -fn test_per_host_content_isolation() { - let temp_dir = common::setup_test_environment(); - - // Create different content for each host - let site1_root = temp_dir.path().join("site1"); - let site2_root = temp_dir.path().join("site2"); - std::fs::create_dir(&site1_root).unwrap(); - std::fs::create_dir(&site2_root).unwrap(); - - // Create different index.gmi files for each site - std::fs::write(site1_root.join("index.gmi"), "Welcome to Site 1").unwrap(); - std::fs::write(site2_root.join("index.gmi"), "Welcome to Site 2").unwrap(); - - // Create config with two hosts - let config_path = temp_dir.path().join("config.toml"); - let port = 1965 + (std::process::id() % 1000) as u16; // Use dynamic port - let config_content = format!( - r#" -bind_host = "127.0.0.1" -port = {} - -["site1.com"] -root = "{}" -cert = "{}" -key = "{}" - -["site2.org"] -root = "{}" -cert = "{}" -key = "{}" -"#, - port, - site1_root.display(), - temp_dir.path().join("cert.pem").display(), - temp_dir.path().join("key.pem").display(), - site2_root.display(), - temp_dir.path().join("cert.pem").display(), - temp_dir.path().join("key.pem").display() - ); - std::fs::write(&config_path, config_content).unwrap(); - - // Start server with TLS - let mut server_process = std::process::Command::new(env!("CARGO_BIN_EXE_pollux")) - .arg("--config") - .arg(&config_path) - .arg("--quiet") - .spawn() - .unwrap(); - - // Wait for server to start - std::thread::sleep(std::time::Duration::from_millis(500)); - - // Test site1.com serves its content - let response1 = make_gemini_request("127.0.0.1", port, "gemini://site1.com/"); - assert!( - response1.starts_with("20"), - "Expected success for site1.com, got: {}", - response1 - ); - assert!( - response1.contains("Welcome to Site 1"), - "Should serve site1 content, got: {}", - response1 - ); - - // Test site2.org serves its content - let response2 = make_gemini_request("127.0.0.1", port, "gemini://site2.org/"); - assert!( - response2.starts_with("20"), - "Expected success for site2.org, got: {}", - response2 - ); - assert!( - response2.contains("Welcome to Site 2"), - "Should serve site2 content, got: {}", - response2 - ); - - server_process.kill().unwrap(); -} - -#[test] -fn test_per_host_path_security() { - let temp_dir = common::setup_test_environment(); - - // Create directory structure for site1 - let site1_root = temp_dir.path().join("site1"); - std::fs::create_dir(&site1_root).unwrap(); - std::fs::create_dir(site1_root.join("subdir")).unwrap(); - std::fs::write( - site1_root.join("subdir").join("secret.gmi"), - "Secret content", - ) - .unwrap(); - - // Create config - let config_path = temp_dir.path().join("config.toml"); - let port = 1968 + (std::process::id() % 1000) as u16; - let cert_path = temp_dir.path().join("cert.pem"); - let key_path = temp_dir.path().join("key.pem"); - - let config_content = format!( - r#" -bind_host = "127.0.0.1" -port = {} - -["site1.com"] -root = "{}" -cert = "{}" -key = "{}" -"#, - port, - site1_root.display(), - cert_path.display(), - key_path.display() - ); - std::fs::write(&config_path, config_content).unwrap(); - - let mut server_process = std::process::Command::new(env!("CARGO_BIN_EXE_pollux")) - .arg("--config") - .arg(&config_path) - .arg("--quiet") - .spawn() - .unwrap(); - - std::thread::sleep(std::time::Duration::from_millis(500)); - - // Test path traversal attempt should be blocked - let response = make_gemini_request("127.0.0.1", port, "gemini://site1.com/../../../etc/passwd"); - assert!( - response.starts_with("51"), - "Path traversal should be blocked, got: {}", - response - ); - - // Test valid subdirectory access should work - let response = make_gemini_request("127.0.0.1", port, "gemini://site1.com/subdir/secret.gmi"); - assert!( - response.starts_with("20"), - "Valid subdirectory access should work, got: {}", - response - ); - assert!( - response.contains("Secret content"), - "Should serve content from subdirectory, got: {}", - response - ); - - server_process.kill().unwrap(); -} - -fn make_gemini_request(host: &str, port: u16, url: &str) -> String { - // Use the Python client for TLS requests - use std::process::Command; - - let output = Command::new("python3") - .arg("tests/gemini_test_client.py") - .arg(url) - .env("GEMINI_PORT", &port.to_string()) - .env("GEMINI_CONNECT_HOST", host) - .output() - .unwrap(); - String::from_utf8(output.stdout).unwrap() -} diff --git a/tests/virtual_host_routing.rs b/tests/virtual_host_routing.rs deleted file mode 100644 index 50c61ba..0000000 --- a/tests/virtual_host_routing.rs +++ /dev/null @@ -1,239 +0,0 @@ -mod common; - -/// Make a Gemini request over TLS and return the response -fn make_gemini_request(host: &str, port: u16, request: &str) -> String { - // Use the Python client for TLS requests - use std::process::Command; - - let url = request.to_string(); - - let output = Command::new("python3") - .arg("tests/gemini_test_client.py") - .arg(url) - .env("GEMINI_PORT", &port.to_string()) - .env("GEMINI_CONNECT_HOST", host) - .output() - .expect("Failed to run test client"); - - String::from_utf8(output.stdout).unwrap().trim().to_string() -} - -// Unit tests for hostname extraction - temporarily disabled due to import issues -// TODO: Fix import path for server functions -/* -#[test] -fn test_extract_hostname_and_path_valid_urls() { - // Test various valid Gemini URLs - let test_cases = vec![ - ("gemini://example.com/", ("example.com", "/")), - ("gemini://example.com/page.gmi", ("example.com", "/page.gmi")), - ("gemini://sub.example.com/path/to/file.txt", ("sub.example.com", "/path/to/file.txt")), - ("gemini://localhost:1965/", ("localhost", "/")), - ("gemini://test.com", ("test.com", "/")), - ]; - - for (url, expected) in test_cases { - let result = pollux::server::extract_hostname_and_path(url); - assert!(result.is_ok(), "Failed to parse: {}", url); - let (hostname, path) = result.unwrap(); - assert_eq!(hostname, expected.0, "Hostname mismatch for: {}", url); - assert_eq!(path, expected.1, "Path mismatch for: {}", url); - } -} - -#[test] -fn test_extract_hostname_and_path_invalid_urls() { - // Test invalid URLs - let invalid_urls = vec![ - "", // empty - "http://example.com/", // wrong scheme - "gemini://", // no hostname - "//example.com/", // no scheme - "gemini://example.com:99999/", // port is handled by path - "gemini://example.com?query", // query params not supported - ]; - - for url in invalid_urls { - let result = pollux::server::extract_hostname_and_path(url); - assert!(result.is_err(), "Should fail for invalid URL: {}", url); - } -} -*/ - -#[test] -fn test_virtual_host_routing_multiple_hosts() { - let temp_dir = common::setup_test_environment(); - let port = 2000 + (std::process::id() % 1000) as u16; - - // Create directories for hosts (content already exists from setup_test_environment) - std::fs::create_dir(temp_dir.path().join("site1")).unwrap(); - std::fs::create_dir(temp_dir.path().join("site2")).unwrap(); - - // Create config with two hosts - let config_path = temp_dir.path().join("config.toml"); - let content = format!( - r#" -bind_host = "127.0.0.1" -port = {} - -["site1.com"] -root = "{}" -cert = "{}" -key = "{}" - -["site2.org"] -root = "{}" -cert = "{}" -key = "{}" -"#, - port, - temp_dir.path().join("site1").display(), - temp_dir.path().join("cert.pem").display(), - temp_dir.path().join("key.pem").display(), - temp_dir.path().join("site2").display(), - temp_dir.path().join("cert.pem").display(), - temp_dir.path().join("key.pem").display() - ); - std::fs::write(&config_path, content).unwrap(); - - // Create host-specific content - std::fs::create_dir_all(temp_dir.path().join("site1")).unwrap(); - std::fs::create_dir_all(temp_dir.path().join("site2")).unwrap(); - std::fs::write( - temp_dir.path().join("site1").join("index.gmi"), - "# Site 1 Content\n", - ) - .unwrap(); - std::fs::write( - temp_dir.path().join("site2").join("index.gmi"), - "# Site 2 Content\n", - ) - .unwrap(); - - // Use the same certs for both hosts (server uses first cert anyway) - - // Start server with TLS - let mut server_process = std::process::Command::new(env!("CARGO_BIN_EXE_pollux")) - .arg("--config") - .arg(&config_path) - .arg("--quiet") - .spawn() - .unwrap(); - - // Wait for server to start - std::thread::sleep(std::time::Duration::from_millis(500)); - - // Test request to site1.com with TLS - let response1 = make_gemini_request("127.0.0.1", port, "gemini://site1.com/index.gmi"); - assert!( - response1.starts_with("20"), - "Expected success response for site1.com, got: {}", - response1 - ); - - // Test request to site2.org - let response2 = make_gemini_request("127.0.0.1", port, "gemini://site2.org/index.gmi"); - assert!( - response2.starts_with("20"), - "Expected success response for site2.org, got: {}", - response2 - ); - - server_process.kill().unwrap(); -} - -#[test] -fn test_virtual_host_routing_known_hostname() { - let temp_dir = common::setup_test_environment(); - let port = 2100 + (std::process::id() % 1000) as u16; - - // Content directory already created by setup_test_environment - - // Config with only one host - let config_path = temp_dir.path().join("config.toml"); - let content = format!( - r#" -bind_host = "127.0.0.1" -port = {} - -["example.com"] -root = "{}" -cert = "{}" -key = "{}" -"#, - port, - 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, content).unwrap(); - - // Start server with TLS - let mut server_process = std::process::Command::new(env!("CARGO_BIN_EXE_pollux")) - .arg("--config") - .arg(&config_path) - .arg("--quiet") - .spawn() - .unwrap(); - - // Wait for server to start - std::thread::sleep(std::time::Duration::from_millis(500)); - - // Test request to unknown hostname - let response = make_gemini_request("127.0.0.1", port, "gemini://unknown.com/index.gmi"); - assert!( - response.starts_with("53"), - "Should return status 53 for unknown hostname, got: {}", - response - ); - - server_process.kill().unwrap(); -} - -#[test] -fn test_virtual_host_routing_malformed_url() { - let temp_dir = common::setup_test_environment(); - let port = 2200 + (std::process::id() % 1000) as u16; - - // Content directory already created by setup_test_environment - - // Config with one host - let config_path = temp_dir.path().join("config.toml"); - let content = format!( - r#" -bind_host = "127.0.0.1" -port = {} - -["example.com"] -root = "{}" -cert = "{}" -key = "{}" -"#, - port, - 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, content).unwrap(); - - // Start server with TLS - let mut server_process = std::process::Command::new(env!("CARGO_BIN_EXE_pollux")) - .arg("--config") - .arg(&config_path) - .arg("--quiet") - .spawn() - .unwrap(); - - // Wait for server to start - std::thread::sleep(std::time::Duration::from_millis(500)); - - // Test malformed URL (wrong protocol) - let response = make_gemini_request("127.0.0.1", port, "http://example.com/index.gmi"); - assert!( - response.starts_with("59"), - "Should return status 59 for malformed URL, got: {}", - response - ); - - server_process.kill().unwrap(); -}