Compare commits

...

5 commits
v1.0.0 ... main

Author SHA1 Message Date
9ca54e98c9 Fix denial of service, information disclosure and TLS identity bugs
An adversarial review of the request path turned up nine issues, all
reproduced against a running server before and after the fix.

The most serious was a permanent denial of service. The in-flight request
counter was decremented on paths that never incremented it; being unsigned
it wrapped to usize::MAX, after which every request was answered with
"41 Server unavailable" until the process restarted. A client that completed
the TLS handshake and disconnected without sending a request was enough --
a port scan, a health check, a cancelled page load. The counter is replaced
by a semaphore permit released on every exit path.

Two further remote denials of service: an error from accept() propagated out
of main and ended the process, so exhausting the descriptor limit killed the
server; and responses were read whole into memory, so cost scaled with
concurrent requests times file size. Accept errors are now logged and retried
after a backoff, and responses stream in 64 KiB chunks capped at 64 MiB.
Peers that open a socket and never send a ClientHello are bounded by a 10s
handshake timeout and a connection cap (max_connections, default 512).

Every virtual host was served whichever certificate came first in a HashMap,
which varies per process, so a host's certificate changed between restarts.
Because Gemini clients pin certificates on first use, this trained users to
dismiss the mismatch warning that would otherwise reveal interception.
Certificates are now selected by SNI with a deterministic fallback, so
unknown hosts still complete a handshake and receive 53.

Dotfiles inside a content root were public, exposing .git/config and any
credentials in it for capsule roots that are git working copies. Path
resolution is now in-tree: reject non-plain components, then compare
canonical prefixes. This replaces path-security, an unaudited micro-crate in
the security boundary that also rejected legitimate filenames containing
'%', '~' or '$'.

rustls moves 0.21 -> 0.23; the 0.21 branch is end of life. TLS 1.2 and 1.3
only, as before.

Malformed requests logged attacker-controlled text at ERROR on every request,
letting a client drive log volume; client-caused conditions now log at debug,
truncated. Hostname routing accepts the authority forms the specification
permits (mixed case, explicit port, userinfo, trailing dot), which previously
returned 53. Per-host port and log_level were accepted and silently ignored,
and now warn at startup.

Adds tests/connection_lifecycle.rs covering the counter underflow, the
oversized-response refusal, hidden files and authority normalization. Fixes
the test client, which sent host:port as SNI. 42 -> 56 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 04:30:38 +00:00
7de660dbb6 Add tests for config error reporting and multi-vhost startup
Test that the server correctly reports missing certificate errors,
rejects invalid hostnames, fails gracefully on port conflicts, and
starts successfully with multiple virtual hosts configured.
2026-03-05 20:34:31 +09:00
55fe47b172 Replace custom logging with tracing crate and RUST_LOG env var
- Remove custom logging module and init_logging function
- Update main.rs to use tracing_subscriber with EnvFilter
- Remove log_level from global config structure
- Update documentation and tests to use RUST_LOG
- Format long lines in config.rs and test files for better readability
2026-01-22 05:25:46 +00:00
50a4d9bc75 chore: Remove BACKLOG.md from version control
- Remove BACKLOG.md from git tracking (file kept locally)
- Add BACKLOG.md to .gitignore to prevent future commits
- Backlog files should be local project documentation, not in version control
2026-01-22 02:40:10 +00:00
0459cb6220 feat: Implement virtual hosting for multi-domain Gemini server
- Add hostname-based request routing for multiple capsules per server
- Parse virtual host configs from TOML sections ([hostname])
- Implement per-host certificate and content isolation
- Add comprehensive virtual host testing and validation
- Update docs and examples for multi-host deployments

This enables Pollux to serve multiple Gemini domains from one instance,
providing the foundation for multi-tenant Gemini hosting.
2026-01-22 02:38:09 +00:00
28 changed files with 3324 additions and 896 deletions

5
.gitignore vendored
View file

@ -21,4 +21,7 @@ Cargo.lock
# IDE files # IDE files
.vscode/ .vscode/
.idea/ .idea/
# Local project files
BACKLOG.md

157
AGENTS.md
View file

@ -1,135 +1,38 @@
# Overview # AGENTS.md
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.
# Build/Test/Lint Commands ## Introduction
This is a modern Rust project for a Gemini server. Follow these guidelines for
## Core Commands development, testing, and security.
- `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 <test_name>` - Run a specific test
- `cargo test <module>::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 <name>` - 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<T, E>` 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 <name>` - 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 ## Testing
- Run `cargo test` before every commit to prevent regressions - Use unit tests for individual components and integration tests for
- Pre-commit hook automatically runs full test suite end-to-end features.
- Rate limiting integration test uses separate port for isolation - Test at appropriate levels to ensure reliability.
- All tests must pass before commits are allowed
- Test suite includes: unit tests, config validation, rate limiting under load
## Async Patterns ## Development Practices
- Use `.await` on async calls - Do not remove features unless explicitly ordered, especially those
- Prefer `tokio::fs` over `std::fs` in async contexts mentioned in README.md.
- Handle timeouts for network operations - Pre-commit hooks run all tests before commits.
- Use `Arc<Clone>` for shared data across tasks - Follow modern Rust best practices.
- Fix all compiler warnings before committing—they often indicate future bugs.
## Gemini Protocol Specific ## Security
- Response format: "STATUS META\r\n" - Cybersecurity is critical. Never remove guards for remote user input
- Status 20: Success (follow with MIME type) validation, such as URLs or file paths.
- Status 41: Server unavailable (timeout, overload)
- Status 51: Not found (resource doesn't exist)
- Status 59: Bad request (malformed URL, protocol violation)
- Default MIME: "text/gemini" for .gmi files
- Default file: "index.gmi" for directory requests
## Error Handling ## Planning and Tracking
- **Concurrent request limit exceeded**: Return status 41 "Server unavailable" - Use local BACKLOG.md to see planned work.
- **Timeout**: Return status 41 "Server unavailable" (not 59) - For multi-phase changes, add TODO items below the user story with checkboxes
- **Request too large**: Return status 59 "Bad request" and update them during implementation.
- **Empty request**: Return status 59 "Bad request"
- **Invalid URL format**: Return status 59 "Bad request"
- **Hostname mismatch**: Return status 59 "Bad request"
- **Path resolution failure**: Return status 51 "Not found" (including security violations)
- **File not found**: Return status 51 "Not found"
- Reject requests > 1024 bytes (per Gemini spec)
- Reject requests without proper `\r\n` termination
- Use `tokio::time::timeout()` for request timeout handling
- Configurable concurrent request limit: `max_concurrent_requests` (default: 1000)
## Configuration ## Tools
- TOML config files with `serde::Deserialize` - Use cargo for building and testing.
- CLI args override config file values - Run clippy for code quality checks.
- Required fields: root, cert, key, host - Use fmt for code formatting.
- Optional: port, log_level, max_concurrent_requests - Use --quiet flag to suppress startup output during testing.
- Follow project-specific tool usage as needed.
# Development Notes ## Logging
- Generate self-signed certificates for local testing in `tmp/` directory - Use tracing for logging in nginx/apache style.
- Use CN=localhost for development - Output goes to stderr for journald/systemd handling.
- Fix every compiler warning before committing any code - No custom log files or eprintln.
- 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`

View file

@ -1 +0,0 @@
# All backlog items completed ✅

View file

@ -2,6 +2,64 @@
All notable changes to Pollux will be documented in this file. All notable changes to Pollux will be documented in this file.
## [Unreleased]
### Security
- **Fixed a permanent denial of service from a single aborted connection**: the
in-flight request counter was decremented on paths that never incremented it.
Being unsigned it wrapped to `usize::MAX`, after which every request was
refused with `41 Server unavailable` until the process was restarted. A client
that completed the TLS handshake and disconnected without sending a request —
a port scan, a health check, a cancelled page load — was enough. The counter is
replaced by a semaphore permit that is released on every exit path.
- **Fixed a remote crash through file-descriptor exhaustion**: an error from
`accept()` propagated out of `main` and ended the process. Accept errors are
now logged and retried after a short backoff, so the listener survives.
- **Added a TLS handshake timeout (10s) and a connection limit**
(`max_connections`, default 512): a peer that opened a socket and never sent a
ClientHello previously held a task and descriptor indefinitely.
- **Fixed unbounded memory use when serving files**: responses were read whole
into memory, so cost scaled with concurrent requests times file size (eight
requests for a 144 MB file reached 1.18 GB resident). Responses now stream in
64 KiB chunks and are capped at 64 MiB, refused with `50` above that.
- **Fixed nondeterministic TLS certificate selection**: all virtual hosts were
served whichever certificate came first in a `HashMap`, which varies per
process, so a host's certificate changed between restarts. Because Gemini
clients pin certificates on first use, this trained users to dismiss the
mismatch warning that would otherwise reveal interception. Certificates are
now selected by SNI, with a deterministic fallback.
- **Stopped serving hidden files**: dotfiles and dot-directories inside a content
root were public, exposing `.git/config` and any credentials in it for capsule
roots that are git working copies.
- **Replaced the `path-security` dependency** with an in-tree check (reject
non-plain path components, then compare canonical prefixes). Same guarantees
against traversal and symlink escape, no third-party code in the security
boundary, and no false positives on filenames containing `~`, `$` or `%`.
- **Upgraded rustls 0.21 → 0.23**; the 0.21 branch is end-of-life and no longer
receives security fixes. TLS 1.2 and 1.3 only, as before.
- **Bounded and downgraded request logging**: malformed requests logged
attacker-controlled text at `ERROR` on every request, letting a client drive
log volume. Client-caused conditions now log at `debug` and logged request
text is truncated.
### Fixed
- Hostname routing now accepts the authority forms the Gemini specification
permits: mixed case, an explicit port, userinfo, and a fully-qualified trailing
dot. These previously returned `53 Proxy request refused`.
- File extensions are matched case-insensitively, so `PHOTO.JPEG` is served as
`image/jpeg` rather than `application/octet-stream`.
- Per-host `port` and `log_level` were accepted and silently ignored; Pollux now
warns at startup that they have no effect.
- Response reads and writes have a 60s timeout, so a client that stops reading
cannot hold a concurrency permit open indefinitely.
### Removed
- Dead `src/logging.rs` stub, the unused `parse_gemini_url` duplicate URL parser,
and the unused `time` dependency.
## [1.0.0] - 2026-01-17 ## [1.0.0] - 2026-01-17
### Added ### Added

View file

@ -6,16 +6,21 @@ description = "A Gemini server for serving static content"
[dependencies] [dependencies]
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
rustls = "0.21" # ring rather than the default aws-lc-rs backend: no cmake/C toolchain needed.
rustls-pemfile = "1.0" rustls = { version = "0.23", default-features = false, features = [
tokio-rustls = "0.24" "ring",
"std",
"tls12",
"logging",
] }
rustls-pemfile = "2"
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] }
clap = { version = "4.0", features = ["derive"] } clap = { version = "4.0", features = ["derive"] }
path-security = "0.2"
toml = "0.8" toml = "0.8"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
urlencoding = "2.1"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "ansi"] } tracing-subscriber = { version = "0.3", features = ["env-filter", "ansi"] }
time = "0.3"
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"

137
README.md
View file

@ -1,6 +1,6 @@
# Pollux - A Simple Gemini Server # Pollux - A Simple Gemini Server
Pollux is a lightweight Gemini server for serving static files securely. It supports TLS, hostname validation, and basic directory serving. 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.
## Requirements ## Requirements
@ -16,18 +16,77 @@ cargo build --release
This produces the `target/release/pollux` binary. This produces the `target/release/pollux` binary.
## Running ## 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
Create a config file at `/etc/pollux/config.toml` or use `--config` to specify a path: 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
max_concurrent_requests = 1000 # requests processed at once (default 1000)
max_connections = 512 # connections handled at once (default 512)
# 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"
```
Set the log level with the `RUST_LOG` environment variable (for example
`RUST_LOG=info`) rather than in the config file.
Keep `max_connections` comfortably below the process file-descriptor limit
(`ulimit -n`, or `LimitNOFILE` in the systemd unit). Connections above the
limit are dropped immediately, which keeps the listener responsive.
> **Not implemented:** per-host `port` and `log_level` keys are still parsed for
> backwards compatibility but have no effect — every host is served on the
> single global port. Pollux logs a warning at startup if it sees them.
### Features
- **Multiple hostnames** on a single server instance
- **Per-host TLS certificates** selected by SNI, so each capsule presents its own identity
- **Automatic content isolation** - each host serves only its own files
- **Path security** - directory traversal and symlink escapes are blocked
- **Hidden files are private** - dotfiles and dot-directories (`.git`, `.env`) are never served
- **Streamed responses** - memory use is independent of file size, capped at 64 MiB per response
- **Index file serving** - `index.gmi` files are served automatically
- **Hostname validation** - DNS-compliant hostname checking, matched case-insensitively
### 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 ```toml
root = "/path/to/static/files" root = "/path/to/static/files"
cert = "/path/to/cert.pem" cert = "/path/to/cert.pem"
key = "/path/to/key.pem" key = "/path/to/key.pem"
bind_host = "0.0.0.0"
hostname = "gemini.example.com" hostname = "gemini.example.com"
bind_host = "0.0.0.0"
port = 1965 port = 1965
log_level = "info"
max_concurrent_requests = 1000 max_concurrent_requests = 1000
``` ```
@ -69,14 +128,78 @@ Access with a Gemini client like Lagrange at `gemini://yourdomain.com/`.
- `--config` (`-C`): Path to config file (default `/etc/pollux/config.toml`) - `--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 - `--test-processing-delay` (debug builds only): Add delay before processing requests (seconds) - for testing rate limiting
### Certificate Management ## Logging
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 - Never commit certificate files to version control
- Use development certificates only for local testing - Use development certificates only for local testing
- Production certificates should be obtained via Let's Encrypt or your CA - 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 ## Testing
Run `cargo test` for the full test suite, which includes integration tests that require Python 3. Run `cargo test` for the full test suite, which includes comprehensive integration tests covering:
**Note**: Integration tests use Python 3 for Gemini protocol validation. If Python 3 is not available, integration tests will be skipped automatically. - 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.

98
dist/INSTALL.md vendored
View file

@ -16,20 +16,23 @@ This guide covers installing and configuring the Pollux Gemini server for produc
cargo build --release cargo build --release
sudo cp target/release/pollux /usr/local/bin/ sudo cp target/release/pollux /usr/local/bin/
# 2. Get certificates # 2. Create directories and user
sudo certbot certonly --standalone -d example.com
# 3. Create directories and user
sudo useradd -r -s /bin/false pollux sudo useradd -r -s /bin/false pollux
sudo usermod -a -G ssl-cert pollux sudo mkdir -p /etc/pollux/tls /var/gemini
sudo mkdir -p /etc/pollux /var/www/example.com sudo chown -R pollux:pollux /var/gemini
sudo chown -R pollux:pollux /var/www/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"
# 4. Install config # 4. Install config
sudo cp dist/config.toml /etc/pollux/ sudo cp dist/config.toml /etc/pollux/
# 5. Add your Gemini content # 5. Add your Gemini content
sudo cp -r your-content/* /var/www/example.com/ sudo cp -r your-content/* /var/gemini/
# 6. Install and start service # 6. Install and start service
sudo cp dist/pollux.service /etc/systemd/system/ sudo cp dist/pollux.service /etc/systemd/system/
@ -57,24 +60,23 @@ sudo cp target/release/pollux /usr/local/bin/
#### Certificate Setup #### Certificate Setup
**For Production:** Obtain certificates from your preferred Certificate Authority and place them in `/etc/pollux/`. Ensure they are readable by the pollux user. **For Production:** Obtain certificates from your preferred Certificate Authority and place them in `/etc/pollux/tls/`. Ensure they are readable by the pollux user.
**For Development/Testing:** Generate self-signed certificates (see Quick Start section). **For Development/Testing:** Generate self-signed certificates (see Quick Start section).
**Note:** Let's Encrypt certificates can be used but their installation and permission setup is beyond the scope of this documentation. **Note:** Let's Encrypt certificates can be used - place them under `/etc/letsencrypt/live/` and update your config accordingly.
```bash ```bash
# Generate certificates # Generate certificates
openssl req -x509 -newkey rsa:4096 \ sudo -u pollux openssl req -x509 -newkey rsa:4096 \
-keyout /etc/pollux/key.pem \ -keyout /etc/pollux/tls/key.pem \
-out /etc/pollux/cert.pem \ -out /etc/pollux/tls/cert.pem \
-days 365 -nodes \ -days 365 -nodes \
-subj "/CN=example.com" -subj "/CN=example.com"
# Set permissions # Set permissions (already correct when run as pollux user)
sudo chown pollux:pollux /etc/pollux/*.pem sudo chmod 644 /etc/pollux/tls/cert.pem
sudo chmod 644 /etc/pollux/cert.pem sudo chmod 600 /etc/pollux/tls/key.pem
sudo chmod 600 /etc/pollux/key.pem
``` ```
### User and Directory Setup ### User and Directory Setup
@ -89,8 +91,8 @@ sudo usermod -a -G ssl-cert pollux # Ubuntu/Debian
sudo usermod -a -G certbot pollux # Some systems sudo usermod -a -G certbot pollux # Some systems
# Create directories # Create directories
sudo mkdir -p /etc/pollux /var/www/example.com sudo mkdir -p /etc/pollux/tls /var/gemini
sudo chown -R pollux:pollux /var/www/example.com sudo chown -R pollux:pollux /var/gemini
``` ```
### Configuration ### Configuration
@ -98,26 +100,44 @@ sudo chown -R pollux:pollux /var/www/example.com
Edit `/etc/pollux/config.toml`: Edit `/etc/pollux/config.toml`:
```toml ```toml
root = "/var/www/example.com" # Global settings
cert = "/etc/pollux/cert.pem"
key = "/etc/pollux/key.pem"
bind_host = "0.0.0.0" bind_host = "0.0.0.0"
hostname = "example.com"
port = 1965 port = 1965
max_concurrent_requests = 1000 max_concurrent_requests = 1000
log_level = "info"
# 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
``` ```
### Content Setup ### Content Setup
```bash ```bash
# Copy your Gemini files # Copy your Gemini files
sudo cp -r gemini-content/* /var/www/example.com/ sudo cp -r gemini-content/* /var/gemini/
# Set permissions # Set permissions
sudo chown -R pollux:pollux /var/www/example.com sudo chown -R pollux:pollux /var/gemini
sudo find /var/www/example.com -type f -exec chmod 644 {} \; sudo find /var/gemini -type f -exec chmod 644 {} \;
sudo find /var/www/example.com -type d -exec chmod 755 {} \; sudo find /var/gemini -type d -exec chmod 755 {} \;
``` ```
### Service Installation ### Service Installation
@ -128,7 +148,9 @@ sudo cp dist/pollux.service /etc/systemd/system/
# If your paths differ, edit the service file # If your paths differ, edit the service file
sudo editor /etc/systemd/system/pollux.service sudo editor /etc/systemd/system/pollux.service
# Update ReadOnlyPaths to match your config # Update ReadOnlyPaths to match your config:
# - /etc/pollux for config and TLS certificates
# - /var/gemini for your content root
# Enable and start # Enable and start
sudo systemctl daemon-reload sudo systemctl daemon-reload
@ -154,10 +176,10 @@ openssl s_client -connect example.com:1965 -servername example.com <<< "gemini:/
### Permission Issues ### Permission Issues
```bash ```bash
# Check certificate access # Check certificate access
sudo -u pollux cat /etc/pollux/cert.pem sudo -u pollux cat /etc/pollux/tls/cert.pem
# Check content access # Check content access
sudo -u pollux ls -la /var/www/example.com/ sudo -u pollux ls -la /var/gemini/
``` ```
### Port Issues ### Port Issues
@ -182,13 +204,13 @@ sudo systemctl reload pollux
See `config.toml` for all available options. Key settings: See `config.toml` for all available options. Key settings:
- `root`: Directory containing your .gmi files - `root`: Directory containing your .gmi files (per host section)
- `cert`/`key`: TLS certificate paths - `cert`/`key`: TLS certificate paths (per host section)
- `bind_host`: IP/interface to bind to - `bind_host`: IP/interface to bind to (global)
- `hostname`: Domain name for URI validation - `port`: Listen port (1965 is standard, per host override possible)
- `port`: Listen port (1965 is standard) - `max_concurrent_requests`: Connection limit (global)
- `max_concurrent_requests`: Connection limit
- `log_level`: Logging verbosity Logging is configured via the `RUST_LOG` environment variable (see Logging Configuration section).
## Certificate Management ## Certificate Management

87
dist/config.toml vendored
View file

@ -5,31 +5,14 @@
# #
# The Gemini protocol is specified in RFC 1436: https://tools.ietf.org/rfc/rfc1436.txt # The Gemini protocol is specified in RFC 1436: https://tools.ietf.org/rfc/rfc1436.txt
# Directory containing your Gemini files (.gmi, .txt, images, etc.) # For additional hostnames, add more sections like:
# The server will serve files from this directory and its subdirectories. # ["blog.example.com"]
# Default index file is 'index.gmi' for directory requests. # root = "/var/gemini/blog"
# # cert = "/etc/pollux/tls/blog.crt"
# IMPORTANT: The server needs READ access to this directory. # key = "/etc/pollux/tls/blog.key"
# 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 # Server network configuration
# #
# bind_host: IP address or interface to bind the server to # bind_host: IP address or interface to bind the server to
# - "0.0.0.0" = listen on all interfaces (default) # - "0.0.0.0" = listen on all interfaces (default)
# - "127.0.0.1" = localhost only # - "127.0.0.1" = localhost only
@ -37,12 +20,6 @@ key = "/etc/letsencrypt/live/example.com/privkey.pem"
# - Specific IP = bind to that address only # - Specific IP = bind to that address only
bind_host = "0.0.0.0" 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 # port: TCP port to listen on
# - Default Gemini port is 1965 # - Default Gemini port is 1965
# - Ports below 1024 require root privileges # - Ports below 1024 require root privileges
@ -50,19 +27,47 @@ hostname = "example.com"
port = 1965 port = 1965
# Request limiting # Request limiting
# #
# max_concurrent_requests: Maximum number of simultaneous connections # max_concurrent_requests: Maximum number of requests processed at once
# - Prevents server overload and DoS attacks # - Requests above the limit are answered with "41 Server unavailable"
# - Set to 0 to disable limiting (not recommended) # - Must be between 1 and 1000000; the server will not start on 0
# - Typical values: 100-10000 depending on server capacity # - Typical values: 100-10000 depending on server capacity
max_concurrent_requests = 1000 max_concurrent_requests = 1000
# max_connections: Maximum number of connections handled at once
# - Connections above the limit are dropped immediately, which keeps the
# listener responsive under a flood of half-open connections
# - Keep this below the process file-descriptor limit (LimitNOFILE in the
# systemd unit, or `ulimit -n`)
# - Must be between 1 and 1000000; defaults to 512 if unset
max_connections = 512
# Logging configuration # Logging configuration
# #
# log_level: Controls how much information is logged # Logging is controlled by the RUST_LOG environment variable, not this file.
# - "error": Only errors that prevent normal operation # In the systemd unit, set for example:
# - "warn": Errors plus warnings about unusual conditions # Environment=RUST_LOG=info
# - "info": General operational information (recommended) # Levels, least to most verbose: error, warn, info, debug, trace
# - "debug": Detailed debugging information
# - "trace": Very verbose debugging (use only for troubleshooting) # Host configuration
log_level = "info" # 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/

8
dist/pollux.service vendored
View file

@ -13,12 +13,10 @@ Group=pollux
NoNewPrivileges=yes NoNewPrivileges=yes
ProtectHome=yes ProtectHome=yes
ProtectSystem=strict ProtectSystem=strict
ReadOnlyPaths=/etc/pollux /etc/letsencrypt/live/example.com /var/www/example.com ReadOnlyPaths=/etc/pollux /var/gemini
# NOTE: Adjust /etc/letsencrypt/live/example.com and /var/www/example.com to match your config
# The server needs read access to config, certificates, and content files
# NOTE: Adjust paths to match your config: # NOTE: Adjust paths to match your config:
# - /etc/letsencrypt/live/example.com for Let's Encrypt certs # - /etc/pollux for config and TLS certificates
# - /var/www/example.com for your content root # - /var/gemini for your content root
# The server needs read access to config, certificates, and content files # The server needs read access to config, certificates, and content files
[Install] [Install]

30
examples/development.toml Normal file
View file

@ -0,0 +1,30 @@
# 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 is set with the RUST_LOG environment variable, e.g. RUST_LOG=info (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"

16
examples/single-host.toml Normal file
View file

@ -0,0 +1,16 @@
# 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 is set with the RUST_LOG environment variable, e.g. RUST_LOG=info (info)
max_concurrent_requests = 100
# Host configuration
["example.com"]
root = "./content"
cert = "./tmp/cert.pem"
key = "./tmp/key.pem"

View file

@ -0,0 +1,34 @@
# 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 is set with the RUST_LOG environment variable, e.g. RUST_LOG=info (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
["dev.example.com"]
root = "/home/dev/gemini"
cert = "/etc/ssl/dev.crt"
key = "/etc/ssl/dev.key"

View file

@ -1,21 +1,242 @@
use serde::Deserialize; use serde::Deserialize;
use std::collections::HashMap;
use toml::Value;
#[derive(Deserialize)] #[derive(Debug)]
pub struct Config { pub struct Config {
pub root: Option<String>, // Global defaults (optional)
pub cert: Option<String>,
pub key: Option<String>,
pub bind_host: Option<String>, pub bind_host: Option<String>,
pub hostname: Option<String>,
pub port: Option<u16>, pub port: Option<u16>,
pub log_level: Option<String>,
pub max_concurrent_requests: Option<usize>, pub max_concurrent_requests: Option<usize>,
pub max_connections: Option<usize>,
// Per-hostname configurations, keyed by lowercase hostname
pub hosts: HashMap<String, HostConfig>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct HostConfig {
pub root: String,
pub cert: String,
pub key: String,
#[serde(default)]
#[allow(dead_code)]
pub port: Option<u16>, // override global port
#[serde(default)]
#[allow(dead_code)]
pub log_level: Option<String>, // override global log level
} }
pub fn load_config(path: &str) -> Result<Config, Box<dyn std::error::Error>> { pub fn load_config(path: &str) -> Result<Config, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)?; let content = std::fs::read_to_string(path)?;
let config: Config = toml::from_str(&content)?; let toml_value: Value = toml::from_str(&content)?;
Ok(config)
// 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");
let max_connections = extract_usize(&toml_value, "max_connections");
// Logging is driven by RUST_LOG. Older configs set this key and it never had
// any effect, so say so instead of ignoring it.
if extract_string(&toml_value, "log_level").is_some() {
tracing::warn!(
"'log_level' in the config file is not implemented and is ignored; \
set the RUST_LOG environment variable instead (for example RUST_LOG=info)"
);
}
// 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" | "max_connections" | "log_level"
) {
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());
}
// These are accepted for backwards compatibility but have never
// been implemented. Say so, rather than letting an operator
// believe a host is isolated on its own port when it is not.
if port_override.is_some() {
tracing::warn!(
"[{}]: per-host 'port' is not implemented and is ignored; \
every host is served on the global port",
key
);
}
if log_level_override.is_some() {
tracing::warn!(
"[{}]: per-host 'log_level' is not implemented and is ignored; \
use the RUST_LOG environment variable instead",
key
);
}
let host_config = HostConfig {
root,
cert,
key: key_path,
port: port_override,
log_level: log_level_override,
};
// Keyed lowercase so routing matches the case-insensitive
// hostname the request parser produces.
hosts.insert(key.to_ascii_lowercase(), 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,
max_connections,
hosts,
})
}
fn extract_string(value: &Value, key: &str) -> Option<String> {
value
.get(key)
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
fn extract_string_from_table(table: &toml::map::Map<String, Value>, key: &str) -> Option<String> {
table
.get(key)
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
fn extract_u16(value: &Value, key: &str) -> Option<u16> {
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<String, Value>, key: &str) -> Option<u16> {
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<usize> {
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<String, Value>,
key: &str,
section: &str,
) -> Result<String, Box<dyn std::error::Error>> {
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<char> = 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
} }
#[cfg(test)] #[cfg(test)]
@ -25,52 +246,178 @@ mod tests {
use tempfile::TempDir; use tempfile::TempDir;
#[test] #[test]
fn test_load_config_valid() { 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 temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml"); let config_path = temp_dir.path().join("config.toml");
let content = r#"
root = "/path/to/root" // Create the root directory and cert/key files
cert = "cert.pem" let root_dir = temp_dir.path().join("root");
key = "key.pem" fs::create_dir(&root_dir).unwrap();
bind_host = "0.0.0.0" let cert_path = temp_dir.path().join("cert.pem");
hostname = "example.com" 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 port = 1965
log_level = "info" log_level = "info"
"#; "#,
root_dir.display(),
cert_path.display(),
key_path.display()
);
fs::write(&config_path, content).unwrap(); fs::write(&config_path, content).unwrap();
let config = load_config(config_path.to_str().unwrap()).unwrap(); let config = load_config(config_path.to_str().unwrap()).unwrap();
assert_eq!(config.root, Some("/path/to/root".to_string())); assert_eq!(config.hosts.len(), 1);
assert_eq!(config.cert, Some("cert.pem".to_string())); assert!(config.hosts.contains_key("example.com"));
assert_eq!(config.key, Some("key.pem".to_string())); let host_config = &config.hosts["example.com"];
assert_eq!(config.bind_host, Some("0.0.0.0".to_string())); assert_eq!(host_config.root, root_dir.to_str().unwrap());
assert_eq!(config.hostname, Some("example.com".to_string())); assert_eq!(host_config.cert, cert_path.to_str().unwrap());
assert_eq!(config.port, Some(1965)); assert_eq!(host_config.key, key_path.to_str().unwrap());
assert_eq!(config.log_level, Some("info".to_string())); assert_eq!(host_config.port, Some(1965));
assert_eq!(config.max_concurrent_requests, None); // Default assert_eq!(host_config.log_level, Some("info".to_string()));
} }
#[test] #[test]
fn test_load_config_with_max_concurrent_requests() { 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() {
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml"); let config_path = temp_dir.path().join("config.toml");
let content = r#" let content = r#"
root = "/path/to/root" bind_host = "127.0.0.1"
max_concurrent_requests = 500 port = 1965
"#; "#;
fs::write(&config_path, content).unwrap(); fs::write(&config_path, content).unwrap();
let config = load_config(config_path.to_str().unwrap()).unwrap(); let result = load_config(config_path.to_str().unwrap());
assert_eq!(config.max_concurrent_requests, Some(500)); assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("No host configurations found"));
} }
#[test] #[test]
fn test_load_config_invalid() { fn test_load_config_invalid_hostname() {
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml"); let config_path = temp_dir.path().join("config.toml");
let content = "invalid toml"; let content = r#"
["-invalid.com"]
root = "/some/path"
cert = "cert.pem"
key = "key.pem"
"#;
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"));
}
#[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(); fs::write(&config_path, content).unwrap();
assert!(load_config(config_path.to_str().unwrap()).is_err()); assert!(load_config(config_path.to_str().unwrap()).is_err());
} }
}
#[test]
fn test_load_config_missing_required_fields() {
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
"#;
fs::write(&config_path, content).unwrap();
// Config parsing will fail if required fields are missing
assert!(load_config(config_path.to_str().unwrap()).is_err());
}
}

View file

@ -1,109 +0,0 @@
use tokio::net::TcpStream;
use tokio_rustls::server::TlsStream;
use tracing_subscriber::fmt::format::Writer;
use tracing_subscriber::fmt::FormatFields;
struct CleanLogFormatter;
impl<S, N> tracing_subscriber::fmt::FormatEvent<S, N> 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<TcpStream>, 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<TcpStream>) -> 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 {
#[test]
fn test_basic_functionality() {
// Basic test to ensure logging module compiles
assert!(true);
}
}

View file

@ -1,62 +1,191 @@
mod config; mod config;
mod tls;
mod request; mod request;
mod server; mod server;
mod logging; mod tls;
use clap::Parser; use clap::Parser;
use rustls::crypto::ring::sign::any_supported_type;
use rustls::server::{ClientHello, ResolvesServerCert};
use rustls::sign::CertifiedKey;
use rustls::ServerConfig; use rustls::ServerConfig;
use std::collections::HashMap;
use std::path::Path; use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio::sync::Semaphore;
use tokio::time::{timeout, Duration};
use tokio_rustls::TlsAcceptor; use tokio_rustls::TlsAcceptor;
use logging::init_logging; use tracing_subscriber::EnvFilter;
fn print_startup_info(host: &str, port: u16, root: &str, cert: &str, key: &str, log_level: Option<&str>, max_concurrent: usize) { /// A TLS handshake must complete within this window. Without a bound, a peer
println!("Pollux Gemini Server"); /// that opens a socket and never sends a ClientHello pins a file descriptor for
println!("Listening on: {}:{}", host, port); /// as long as it likes.
println!("Serving: {}", root); const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
println!("Certificate: {}", cert);
println!("Key: {}", key); /// Default ceiling on connections being handled at once. Keeps descriptor use
println!("Max concurrent requests: {}", max_concurrent); /// well under the usual 1024 soft limit, so exhausting it cannot stop the
if let Some(level) = log_level { /// listener from accepting.
println!("Log level: {}", level); const DEFAULT_MAX_CONNECTIONS: usize = 512;
/// Selects each virtual host's own certificate from the SNI name.
///
/// Previously every host was served whichever certificate happened to be first
/// in a `HashMap`, which varies per process. Gemini clients pin certificates on
/// first use, so an identity that changes between restarts trains users to
/// dismiss the mismatch warning that would otherwise reveal interception.
#[derive(Debug)]
struct SniCertResolver {
by_host: HashMap<String, Arc<CertifiedKey>>,
default: Arc<CertifiedKey>,
}
impl ResolvesServerCert for SniCertResolver {
fn resolve(&self, client_hello: ClientHello) -> Option<Arc<CertifiedKey>> {
let selected = client_hello
.server_name()
.and_then(|name| self.by_host.get(&name.to_ascii_lowercase()))
.cloned()
// An unknown or absent SNI name still gets a completed handshake so
// the request can be refused with a Gemini 53 rather than a TLS
// alert, which is far easier to diagnose.
.unwrap_or_else(|| self.default.clone());
Some(selected)
}
}
fn create_tls_config(
hosts: &HashMap<String, config::HostConfig>,
) -> Result<Arc<ServerConfig>, Box<dyn std::error::Error>> {
// Sorted, so the fallback certificate is stable across restarts instead of
// depending on hash iteration order.
let mut hostnames: Vec<&String> = hosts.keys().collect();
hostnames.sort();
let mut by_host = HashMap::new();
let mut default: Option<Arc<CertifiedKey>> = None;
for hostname in hostnames {
let host_config = &hosts[hostname];
let chain = tls::load_certs(&host_config.cert).map_err(|e| {
format!(
"Cannot load certificate '{}' for host '{}': {}",
host_config.cert, hostname, e
)
})?;
if chain.is_empty() {
return Err(format!(
"Certificate file '{}' for host '{}' contains no certificates",
host_config.cert, hostname
)
.into());
}
let key = tls::load_private_key(&host_config.key).map_err(|e| {
format!(
"Cannot load private key '{}' for host '{}': {}",
host_config.key, hostname, e
)
})?;
let signing_key = any_supported_type(&key).map_err(|e| {
format!(
"Unsupported private key '{}' for host '{}': {}",
host_config.key, hostname, e
)
})?;
let certified = Arc::new(CertifiedKey::new(chain, signing_key));
if default.is_none() {
default = Some(Arc::clone(&certified));
}
by_host.insert(hostname.to_ascii_lowercase(), certified);
}
let default = default.ok_or("No hosts configured")?;
let server_config = ServerConfig::builder()
.with_no_client_auth()
.with_cert_resolver(Arc::new(SniCertResolver { by_host, default }));
Ok(Arc::new(server_config))
}
fn print_startup_info(
config: &config::Config,
hosts: &HashMap<String, config::HostConfig>,
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);
} }
println!(); // Add spacing before connections start println!(); // Add spacing before connections start
} }
#[derive(Parser)] #[derive(Parser)]
#[command(author, version, about, long_about = None)] #[command(author, version, about, long_about = None)]
struct Args { struct Args {
/// Path to config file /// Path to configuration file
#[arg(short = 'C', long)] #[arg(short, long)]
config: Option<String>, config: Option<String>,
/// TESTING ONLY: Add delay before processing (seconds) [debug builds only] /// Suppress startup output (for testing)
#[cfg(debug_assertions)] #[arg(long)]
#[arg(long, value_name = "SECONDS")] quiet: bool,
/// Processing delay for testing (in milliseconds)
#[arg(long, hide = true)]
test_processing_delay: Option<u64>, test_processing_delay: Option<u64>,
} }
#[tokio::main] #[tokio::main]
async fn main() { async fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = Args::parse(); 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();
// Load config // Load config
let config_path = args.config.as_deref().unwrap_or("/etc/pollux/config.toml"); let config_path = args.config.as_deref().unwrap_or("/etc/pollux/config.toml");
// Check if config file exists
if !std::path::Path::new(&config_path).exists() { if !std::path::Path::new(&config_path).exists() {
eprintln!("Error: Config file '{}' not found", config_path); // User guidance goes to stdout BEFORE initializing tracing
eprintln!("Create the config file with required fields:"); // Use direct stderr for error, stdout for guidance
eprintln!(" root = \"/path/to/gemini/content\""); use std::io::Write;
eprintln!(" cert = \"/path/to/certificate.pem\""); let mut stderr = std::io::stderr();
eprintln!(" key = \"/path/to/private-key.pem\""); let mut stdout = std::io::stdout();
eprintln!(" bind_host = \"0.0.0.0\"");
eprintln!(" hostname = \"your.domain.com\""); 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();
}
std::process::exit(1); std::process::exit(1);
} }
@ -64,101 +193,108 @@ async fn main() {
let config = match config::load_config(config_path) { let config = match config::load_config(config_path) {
Ok(config) => config, Ok(config) => config,
Err(e) => { Err(e) => {
eprintln!("Error: Failed to parse config file '{}': {}", config_path, e); tracing::error!("Failed to parse config file '{}': {}", config_path, e);
eprintln!("Check the TOML syntax and ensure all values are properly quoted."); tracing::error!(
"Check the TOML syntax and ensure host sections are properly formatted."
);
std::process::exit(1); std::process::exit(1);
} }
}; };
// Validate required fields // Validate host configurations
if config.root.is_none() { for (hostname, host_config) in &config.hosts {
eprintln!("Error: 'root' field is required in config file"); // Validate root directory exists and is readable
eprintln!("Add: root = \"/path/to/gemini/content\""); let root_path = Path::new(&host_config.root);
std::process::exit(1); 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);
}
if config.cert.is_none() { // Validate certificate files (always required for TLS)
eprintln!("Error: 'cert' field is required in config file"); let cert_path = Path::new(&host_config.cert);
eprintln!("Add: cert = \"/path/to/certificate.pem\""); if !cert_path.exists() {
std::process::exit(1); 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);
}
if config.key.is_none() { let key_path = Path::new(&host_config.key);
eprintln!("Error: 'key' field is required in config file"); if !key_path.exists() {
eprintln!("Add: key = \"/path/to/private-key.pem\""); tracing::error!(
std::process::exit(1); "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);
}
} }
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 // Validate max concurrent requests
let max_concurrent_requests = config.max_concurrent_requests.unwrap_or(1000); let max_concurrent_requests = config.max_concurrent_requests.unwrap_or(1000);
if max_concurrent_requests == 0 || max_concurrent_requests > 1_000_000 { if max_concurrent_requests == 0 || max_concurrent_requests > 1_000_000 {
eprintln!("Error: max_concurrent_requests must be between 1 and 1,000,000"); tracing::error!("max_concurrent_requests must be between 1 and 1,000,000");
std::process::exit(1);
}
let max_connections = config.max_connections.unwrap_or(DEFAULT_MAX_CONNECTIONS);
if max_connections == 0 || max_connections > 1_000_000 {
tracing::error!("max_connections must be between 1 and 1,000,000");
std::process::exit(1); std::process::exit(1);
} }
// TESTING ONLY: Read delay argument (debug builds only) // TESTING ONLY: Read delay argument (debug builds only)
#[cfg(debug_assertions)] #[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) .filter(|&d| d > 0 && d <= 300)
.unwrap_or(0); .unwrap_or(0);
@ -166,43 +302,75 @@ async fn main() {
#[cfg(not(debug_assertions))] #[cfg(not(debug_assertions))]
let test_processing_delay = 0; let test_processing_delay = 0;
// Validate directory // Print startup information
let dir_path = Path::new(&root); print_startup_info(&config, &config.hosts, args.quiet);
if !dir_path.exists() || !dir_path.is_dir() {
eprintln!("Error: Directory '{}' does not exist or is not a directory", root); // Phase 3: TLS mode (always enabled)
std::process::exit(1); 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...");
} }
// Load TLS certificates let bind_host = config.bind_host.as_deref().unwrap_or("0.0.0.0");
let certs = tls::load_certs(&cert_path).unwrap(); let port = config.port.unwrap_or(1965);
let key = tls::load_private_key(&key_path).unwrap();
let config = ServerConfig::builder() let listener = TcpListener::bind(format!("{}:{}", bind_host, port)).await?;
.with_safe_defaults() if !args.quiet {
.with_no_client_auth() println!(
.with_single_cert(certs, key).unwrap(); "Listening on {}:{} for all virtual hosts (TLS enabled)",
bind_host, port
);
}
let acceptor = TlsAcceptor::from(Arc::new(config)); // Shared once rather than cloned per connection.
let hosts = Arc::new(config.hosts);
let listener = TcpListener::bind(format!("{}:{}", bind_host, port)).await.unwrap(); let request_limiter = Arc::new(Semaphore::new(max_concurrent_requests));
let connection_limiter = Arc::new(Semaphore::new(max_connections));
// Print startup information
print_startup_info(&bind_host, port, &root, &cert_path, &key_path, Some(log_level), max_concurrent_requests);
loop { loop {
let (stream, _) = listener.accept().await.unwrap(); let (stream, peer) = match listener.accept().await {
tracing::debug!("Accepted connection from {}", stream.peer_addr().unwrap_or_else(|_| "unknown".parse().unwrap())); Ok(accepted) => accepted,
Err(e) => {
// Descriptor exhaustion and aborted connections are transient.
// Propagating the error here used to end the process, turning a
// burst of half-open connections into a permanent outage.
tracing::warn!("Accept failed: {}", e);
tokio::time::sleep(Duration::from_millis(100)).await;
continue;
}
};
let Ok(connection_permit) = Arc::clone(&connection_limiter).try_acquire_owned() else {
tracing::warn!("Connection limit reached, dropping connection from {}", peer);
continue; // stream is dropped here, closing the socket
};
let acceptor = acceptor.clone(); let acceptor = acceptor.clone();
let dir = root.clone(); let hosts = Arc::clone(&hosts);
let expected_hostname = hostname.clone(); let request_limiter = Arc::clone(&request_limiter);
let max_concurrent = max_concurrent_requests;
let test_delay = test_processing_delay; let test_delay = test_processing_delay;
tokio::spawn(async move { tokio::spawn(async move {
if let Ok(stream) = acceptor.accept(stream).await { let _connection_permit = connection_permit;
if let Err(e) = server::handle_connection(stream, &dir, &expected_hostname, port, max_concurrent, test_delay).await {
tracing::error!("Error handling connection: {}", e); match timeout(HANDSHAKE_TIMEOUT, acceptor.accept(stream)).await {
Ok(Ok(tls_stream)) => {
if let Err(e) = server::handle_connection(
tls_stream,
&hosts,
&request_limiter,
test_delay,
)
.await
{
tracing::debug!("Error handling connection from {}: {}", peer, e);
}
} }
Ok(Err(e)) => tracing::debug!("TLS handshake with {} failed: {}", peer, e),
Err(_) => tracing::debug!("TLS handshake with {} timed out", peer),
} }
}); });
} }
} }

View file

@ -1,84 +1,67 @@
use path_security::validate_path; use std::path::{Component, Path, PathBuf};
use std::path::{Path, PathBuf};
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq)]
pub enum PathResolutionError { pub enum PathResolutionError {
NotFound, NotFound,
} }
pub fn parse_gemini_url(request: &str, hostname: &str, expected_port: u16) -> Result<String, ()> { /// Map a request path onto a file inside `dir`.
if let Some(url) = request.strip_prefix("gemini://") { ///
let host_port_end = url.find('/').unwrap_or(url.len()); /// The returned path is canonical and always lives under the canonical `dir`.
let host_port = &url[..host_port_end]; /// Traversal is refused lexically before any filesystem access, and symlinks
/// that would leave the content root are caught afterwards by comparing
// Parse host and port /// canonical prefixes. Components starting with `.` are refused so that
let (host, port_str) = if let Some(colon_pos) = host_port.find(':') { /// repository metadata and dotfiles inside a content root stay private.
let host = &host_port[..colon_pos]; pub fn resolve_file_path(path: &str, dir: &str) -> Result<PathBuf, PathResolutionError> {
let port_str = &host_port[colon_pos + 1..]; // Strip exactly one leading slash: a path like "//etc/passwd" must stay
(host, Some(port_str)) // absolute so the component check below rejects it.
} else { let relative = match path {
(host_port, None) "" | "/" => "index.gmi".to_string(),
}; p if p.ends_with('/') => format!("{}index.gmi", p.strip_prefix('/').unwrap_or(p)),
p => p.strip_prefix('/').unwrap_or(p).to_string(),
// Validate host };
if host != hostname { let relative = Path::new(&relative);
return Err(()); // Hostname mismatch
// Only plain names may appear. This rejects "..", ".", root prefixes and
// (on Windows) drive and UNC prefixes.
for component in relative.components() {
match component {
Component::Normal(name) => {
if name.to_string_lossy().starts_with('.') {
return Err(PathResolutionError::NotFound);
}
}
_ => return Err(PathResolutionError::NotFound),
} }
// Validate port
let port = port_str
.and_then(|p| p.parse::<u16>().ok())
.unwrap_or(1965);
if port != expected_port {
return Err(()); // Port mismatch
}
let path = if host_port_end < url.len() { &url[host_port_end..] } else { "/" };
Ok(path.trim().to_string())
} else {
Err(())
} }
let base = std::fs::canonicalize(dir).map_err(|_| PathResolutionError::NotFound)?;
let resolved =
std::fs::canonicalize(base.join(relative)).map_err(|_| PathResolutionError::NotFound)?;
if !resolved.starts_with(&base) {
return Err(PathResolutionError::NotFound);
}
Ok(resolved)
} }
pub fn resolve_file_path(path: &str, dir: &str) -> Result<PathBuf, PathResolutionError> { pub fn get_mime_type(file_path: &Path) -> &'static str {
let file_path_str = if path == "/" { let Some(ext) = file_path.extension() else {
"index.gmi".to_string() return "application/octet-stream";
} else if path.ends_with('/') {
format!("{}index.gmi", &path[1..])
} else {
path[1..].to_string()
}; };
match validate_path(Path::new(&file_path_str), Path::new(dir)) { match ext.to_string_lossy().to_ascii_lowercase().as_str() {
Ok(safe_path) => { "gmi" => "text/gemini",
// Path is secure, now check if file exists "txt" => "text/plain",
if safe_path.exists() { "html" | "htm" => "text/html",
Ok(safe_path) "png" => "image/png",
} else { "jpg" | "jpeg" => "image/jpeg",
Err(PathResolutionError::NotFound) "webp" => "image/webp",
} "gif" => "image/gif",
}, "svg" => "image/svg+xml",
Err(_) => { "pdf" => "application/pdf",
// Path validation failed - treat as not found _ => "application/octet-stream",
Err(PathResolutionError::NotFound)
},
}
}
pub fn get_mime_type(file_path: &Path) -> &str {
if let Some(ext) = file_path.extension() {
match ext.to_str() {
Some("gmi") => "text/gemini",
Some("txt") => "text/plain",
Some("html") => "text/html",
Some("png") => "image/png",
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("webp") => "image/webp",
Some("gif") => "image/gif",
_ => "application/octet-stream",
}
} else {
"application/octet-stream"
} }
} }
@ -87,26 +70,9 @@ mod tests {
use super::*; use super::*;
use tempfile::TempDir; use tempfile::TempDir;
#[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()));
}
#[test]
fn test_parse_gemini_url_invalid_host() {
assert!(parse_gemini_url("gemini://foo.com/", "gemini.jeena.net", 1965).is_err());
}
#[test]
fn test_parse_gemini_url_no_prefix() {
assert!(parse_gemini_url("http://gemini.jeena.net/", "gemini.jeena.net", 1965).is_err());
}
#[test] #[test]
fn test_resolve_file_path_root() { fn test_resolve_file_path_root() {
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
// Create index.gmi file since we now check for existence
std::fs::write(temp_dir.path().join("index.gmi"), "# Test").unwrap(); std::fs::write(temp_dir.path().join("index.gmi"), "# Test").unwrap();
assert!(resolve_file_path("/", temp_dir.path().to_str().unwrap()).is_ok()); assert!(resolve_file_path("/", temp_dir.path().to_str().unwrap()).is_ok());
} }
@ -127,39 +93,148 @@ mod tests {
} }
#[test] #[test]
fn test_resolve_file_path_traversal() { fn test_resolve_file_path_not_found() {
let temp_dir = TempDir::new().unwrap(); 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("/nonexistent.gmi", temp_dir.path().to_str().unwrap()),
Err(PathResolutionError::NotFound)
);
} }
#[test] #[test]
fn test_resolve_file_path_not_found() { fn test_resolve_file_path_nested_file() {
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
// Don't create the file, should return NotFound error std::fs::create_dir_all(temp_dir.path().join("a/b")).unwrap();
assert_eq!(resolve_file_path("/nonexistent.gmi", temp_dir.path().to_str().unwrap()), Err(PathResolutionError::NotFound)); std::fs::write(temp_dir.path().join("a/b/c.gmi"), "# Test").unwrap();
assert!(resolve_file_path("/a/b/c.gmi", temp_dir.path().to_str().unwrap()).is_ok());
}
/// Every traversal shape must fail, including the encoded forms the server
/// has already percent-decoded by the time it reaches us.
#[test]
fn test_resolve_file_path_traversal_rejected() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path().join("root");
std::fs::create_dir(&root).unwrap();
std::fs::write(temp_dir.path().join("secret.txt"), "SECRET").unwrap();
let dir = root.to_str().unwrap();
for attack in [
"/../secret.txt",
"/../../secret.txt",
"/a/../../secret.txt",
"/./../secret.txt",
"//etc/passwd",
"/etc/../etc/passwd",
"/..",
"/.",
] {
assert_eq!(
resolve_file_path(attack, dir),
Err(PathResolutionError::NotFound),
"traversal not blocked: {}",
attack
);
}
}
/// A symlink inside the root that points outside it must not be served.
#[cfg(unix)]
#[test]
fn test_resolve_file_path_symlink_escape_rejected() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path().join("root");
std::fs::create_dir(&root).unwrap();
let outside = temp_dir.path().join("secret.txt");
std::fs::write(&outside, "SECRET").unwrap();
std::os::unix::fs::symlink(&outside, root.join("escape.gmi")).unwrap();
assert_eq!(
resolve_file_path("/escape.gmi", root.to_str().unwrap()),
Err(PathResolutionError::NotFound)
);
}
/// A symlink that stays inside the root is legitimate and must still work.
#[cfg(unix)]
#[test]
fn test_resolve_file_path_symlink_inside_root_allowed() {
let temp_dir = TempDir::new().unwrap();
std::fs::write(temp_dir.path().join("real.gmi"), "# Real").unwrap();
std::os::unix::fs::symlink(
temp_dir.path().join("real.gmi"),
temp_dir.path().join("link.gmi"),
)
.unwrap();
assert!(resolve_file_path("/link.gmi", temp_dir.path().to_str().unwrap()).is_ok());
}
/// Dotfiles and dot-directories inside a content root are private: capsule
/// roots are often git working copies, which would otherwise expose
/// .git/config and any credentials in it.
#[test]
fn test_resolve_file_path_hidden_files_rejected() {
let temp_dir = TempDir::new().unwrap();
std::fs::write(temp_dir.path().join(".env"), "SECRET=1").unwrap();
std::fs::create_dir(temp_dir.path().join(".git")).unwrap();
std::fs::write(temp_dir.path().join(".git/config"), "[remote]").unwrap();
let dir = temp_dir.path().to_str().unwrap();
for hidden in ["/.env", "/.git/config", "/.git/"] {
assert_eq!(
resolve_file_path(hidden, dir),
Err(PathResolutionError::NotFound),
"hidden path served: {}",
hidden
);
}
}
/// Filenames the previous validator rejected as "suspicious" are ordinary
/// content and must be reachable.
#[test]
fn test_resolve_file_path_allows_unusual_but_valid_names() {
let temp_dir = TempDir::new().unwrap();
let dir = temp_dir.path().to_str().unwrap();
for name in ["~backup.gmi", "price$5.gmi", "100%-done.gmi", "café.gmi"] {
std::fs::write(temp_dir.path().join(name), "# ok").unwrap();
assert!(
resolve_file_path(&format!("/{}", name), dir).is_ok(),
"legitimate filename rejected: {}",
name
);
}
} }
#[test] #[test]
fn test_get_mime_type_gmi() { fn test_get_mime_type_gmi() {
let path = Path::new("test.gmi"); assert_eq!(get_mime_type(Path::new("test.gmi")), "text/gemini");
assert_eq!(get_mime_type(path), "text/gemini");
} }
#[test] #[test]
fn test_get_mime_type_png() { fn test_get_mime_type_png() {
let path = Path::new("test.png"); assert_eq!(get_mime_type(Path::new("test.png")), "image/png");
assert_eq!(get_mime_type(path), "image/png");
} }
#[test] #[test]
fn test_get_mime_type_unknown() { fn test_get_mime_type_unknown() {
let path = Path::new("test.xyz"); assert_eq!(
assert_eq!(get_mime_type(path), "application/octet-stream"); get_mime_type(Path::new("test.xyz")),
"application/octet-stream"
);
} }
#[test] #[test]
fn test_get_mime_type_no_extension() { fn test_get_mime_type_no_extension() {
let path = Path::new("test"); assert_eq!(get_mime_type(Path::new("test")), "application/octet-stream");
assert_eq!(get_mime_type(path), "application/octet-stream");
} }
}
/// Extensions are matched case-insensitively; UPPER.GMI is still Gemini text.
#[test]
fn test_get_mime_type_is_case_insensitive() {
assert_eq!(get_mime_type(Path::new("UPPER.GMI")), "text/gemini");
assert_eq!(get_mime_type(Path::new("Photo.JPEG")), "image/jpeg");
}
}

View file

@ -1,180 +1,373 @@
use crate::request::{parse_gemini_url, resolve_file_path, get_mime_type, PathResolutionError}; use crate::request::{get_mime_type, resolve_file_path};
use crate::logging::RequestLogger; use std::collections::HashMap;
use std::fs;
use std::io; use std::io;
use std::path::Path; use std::path::Path;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream; use tokio::net::TcpStream;
use tokio::sync::Semaphore;
use tokio::time::{timeout, Duration}; use tokio::time::{timeout, Duration};
use tokio_rustls::server::TlsStream; use tokio_rustls::server::TlsStream;
static ACTIVE_REQUESTS: AtomicUsize = AtomicUsize::new(0); /// Longest request line accepted: a 1024-byte URL plus CRLF.
const MAX_REQUEST_SIZE: usize = 1026;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
pub async fn serve_file( /// Largest file served in a single response. Bounds how much a slow client can
stream: &mut TlsStream<TcpStream>, /// make the server buffer, and keeps a stray large file out of a capsule.
file_path: &Path, const MAX_FILE_SIZE: u64 = 64 * 1024 * 1024;
request: &str,
) -> io::Result<()> { /// Response body chunk size. Memory use per request is this, not the file size.
if file_path.exists() && file_path.is_file() { const CHUNK_SIZE: usize = 64 * 1024;
let mime_type = get_mime_type(file_path);
let header = format!("20 {}\r\n", mime_type); /// Applies to each read and write, so a client that stops reading cannot hold a
stream.write_all(header.as_bytes()).await?; /// concurrency permit open indefinitely.
// Log success after sending header const IO_TIMEOUT: Duration = Duration::from_secs(60);
let client_ip = match stream.get_ref().0.peer_addr() {
Ok(addr) => addr.to_string(), /// Keep attacker-controlled text out of the logs beyond a bounded length.
Err(_) => "unknown".to_string(), fn truncate(request: &str) -> String {
}; const LIMIT: usize = 128;
let request_path = request.strip_prefix("gemini://localhost").unwrap_or(request); if request.chars().count() <= LIMIT {
tracing::info!("{} \"{}\" 20 \"Success\"", client_ip, request_path); return request.to_string();
// Then send body
let content = fs::read(file_path)?;
stream.write_all(&content).await?;
stream.flush().await?;
Ok(())
} else {
Err(tokio::io::Error::new(tokio::io::ErrorKind::NotFound, "File not found"))
} }
let head: String = request.chars().take(LIMIT).collect();
format!("{}… ({} bytes total)", head, request.len())
}
/// Extract the routing hostname and the decoded path from a Gemini URL.
///
/// The authority is folded to the canonical DNS form clients may legitimately
/// vary: userinfo and port are stripped, a fully-qualified trailing dot is
/// removed, and the result is lowercased. Without this, a spec-conformant
/// request such as `gemini://Example.com:1965/` would be refused.
pub fn extract_hostname_and_path(request: &str) -> Result<(String, String), ()> {
let url_part = request.strip_prefix("gemini://").ok_or(())?;
let slash_pos = url_part.find('/').unwrap_or(url_part.len());
let authority = &url_part[..slash_pos];
let path = if slash_pos < url_part.len() {
&url_part[slash_pos..]
} else {
"/"
};
// Discard any userinfo ahead of the host.
let authority = match authority.rsplit_once('@') {
Some((_, host)) => host,
None => authority,
};
// Strip the optional port, taking care not to split inside an IPv6 literal.
let host = if authority.starts_with('[') {
match authority.find(']') {
Some(end) => &authority[..=end],
None => return Err(()),
}
} else {
match authority.rsplit_once(':') {
Some((host, _port)) => host,
None => authority,
}
};
let host = host.trim_end_matches('.').to_ascii_lowercase();
if host.is_empty() || host.contains('/') {
return Err(());
}
let decoded_path = urlencoding::decode(path).map_err(|_| ())?;
Ok((host, decoded_path.to_string()))
}
/// Read one CRLF-terminated request line, bounded in both size and time.
async fn read_request(stream: &mut TlsStream<TcpStream>) -> io::Result<String> {
let read = async {
let mut buf = Vec::with_capacity(MAX_REQUEST_SIZE);
let mut byte = [0u8; 1];
loop {
if buf.len() >= MAX_REQUEST_SIZE {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"request line too long",
));
}
stream.read_exact(&mut byte).await?;
buf.push(byte[0]);
if buf.ends_with(b"\r\n") {
break;
}
}
Ok(buf)
};
let buf = timeout(REQUEST_TIMEOUT, read)
.await
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "request timed out"))??;
Ok(String::from_utf8_lossy(&buf).trim().to_string())
} }
pub async fn handle_connection( pub async fn handle_connection(
mut stream: TlsStream<TcpStream>, mut stream: TlsStream<TcpStream>,
dir: &str, hosts: &HashMap<String, crate::config::HostConfig>,
hostname: &str, request_limiter: &Semaphore,
expected_port: u16,
max_concurrent_requests: usize,
_test_processing_delay: u64, _test_processing_delay: u64,
) -> io::Result<()> { ) -> io::Result<()> {
const MAX_REQUEST_SIZE: usize = 1026; let request = match read_request(&mut stream).await {
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); Ok(request) => request,
Err(e) => {
let mut request_buf = Vec::new(); tracing::debug!("Request read failed: {}", e);
let read_future = async { return send_response(&mut stream, "59 Bad Request\r\n").await;
loop {
if request_buf.len() >= MAX_REQUEST_SIZE {
return Err(tokio::io::Error::new(tokio::io::ErrorKind::InvalidData, "Request too large"));
}
let mut byte = [0; 1];
stream.read_exact(&mut byte).await?;
request_buf.push(byte[0]);
if request_buf.ends_with(b"\r\n") {
break;
}
} }
Ok(())
}; };
match timeout(REQUEST_TIMEOUT, read_future).await { // Held until this function returns, on every path including early returns
Ok(Ok(())) => { // and panics. Nothing here adjusts a counter by hand.
// Read successful, continue processing let _permit = match request_limiter.try_acquire() {
let request = String::from_utf8_lossy(&request_buf).trim().to_string(); Ok(permit) => permit,
// Initialize logger early for all request types
let logger = RequestLogger::new(&stream, request.clone());
// Check concurrent request limit after TLS handshake and request read
let current = ACTIVE_REQUESTS.fetch_add(1, Ordering::Relaxed);
if current >= max_concurrent_requests {
logger.log_error(41, "Concurrent request limit exceeded");
ACTIVE_REQUESTS.fetch_sub(1, Ordering::Relaxed);
// Rate limited - send proper 41 response
send_response(&mut stream, "41 Server unavailable\r\n").await?;
return Ok(());
}
// Process the request
// Validate request
if request.is_empty() {
logger.log_error(59, "Empty request");
ACTIVE_REQUESTS.fetch_sub(1, Ordering::Relaxed);
return send_response(&mut stream, "59 Bad Request\r\n").await;
}
if request.len() > 1024 {
logger.log_error(59, "Request too large");
ACTIVE_REQUESTS.fetch_sub(1, Ordering::Relaxed);
return send_response(&mut stream, "59 Bad Request\r\n").await;
}
// Parse Gemini URL
let path = match parse_gemini_url(&request, hostname, expected_port) {
Ok(p) => p,
Err(_) => {
logger.log_error(59, "Invalid URL format");
ACTIVE_REQUESTS.fetch_sub(1, Ordering::Relaxed);
return send_response(&mut stream, "59 Bad Request\r\n").await;
}
};
// TESTING ONLY: Add delay for rate limiting tests (debug builds only)
#[cfg(debug_assertions)]
if _test_processing_delay > 0 {
tokio::time::sleep(tokio::time::Duration::from_secs(_test_processing_delay)).await;
}
// Resolve file path with security
let file_path = match resolve_file_path(&path, dir) {
Ok(fp) => fp,
Err(PathResolutionError::NotFound) => {
logger.log_error(51, "File not found");
ACTIVE_REQUESTS.fetch_sub(1, Ordering::Relaxed);
return send_response(&mut stream, "51 Not found\r\n").await;
}
};
// No delay for normal operation
// Processing complete
// Serve the file
match serve_file(&mut stream, &file_path, &request).await {
Ok(_) => {
// Success already logged in serve_file
}
Err(_) => {
// File transmission failed
logger.log_error(51, "File transmission failed");
let _ = send_response(&mut stream, "51 Not found\r\n").await;
}
}
}
Ok(Err(e)) => {
// Read failed, check error type
let request_str = String::from_utf8_lossy(&request_buf).trim().to_string();
let logger = RequestLogger::new(&stream, request_str);
match e.kind() {
tokio::io::ErrorKind::InvalidData => {
logger.log_error(59, "Request too large");
let _ = send_response(&mut stream, "59 Bad Request\r\n").await;
},
_ => {
logger.log_error(59, "Bad request");
let _ = send_response(&mut stream, "59 Bad Request\r\n").await;
}
}
ACTIVE_REQUESTS.fetch_sub(1, Ordering::Relaxed);
},
Err(_) => { Err(_) => {
// Timeout tracing::warn!("Concurrent request limit reached, refusing request");
let request_str = String::from_utf8_lossy(&request_buf).trim().to_string(); return send_response(&mut stream, "41 Server unavailable\r\n").await;
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(());
} }
};
if request.is_empty() {
tracing::debug!("Empty request");
return send_response(&mut stream, "59 Bad Request\r\n").await;
}
let (hostname, path) = match extract_hostname_and_path(&request) {
Ok(parsed) => parsed,
Err(_) => {
tracing::debug!("Invalid URL format: {}", truncate(&request));
return send_response(&mut stream, "59 Bad Request\r\n").await;
}
};
let host_config = match hosts.get(&hostname) {
Some(config) => config,
None => {
tracing::debug!("Unknown hostname: {}", truncate(&hostname));
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(Duration::from_secs(_test_processing_delay)).await;
}
let file_path = match resolve_file_path(&path, &host_config.root) {
Ok(file_path) => file_path,
Err(_) => {
tracing::debug!("Path resolution failed for: {}", truncate(&path));
return send_response(&mut stream, "51 Not found\r\n").await;
}
};
// serve_file owns the whole response, including its own error statuses:
// once the header is on the wire a second status line cannot be sent. It
// reports back which status it used so the access log stays accurate.
match serve_file(&mut stream, &file_path).await {
Ok(status) => tracing::info!("{} {}", truncate(&request), status),
Err(e) => tracing::debug!("Error serving {}: {}", file_path.display(), e),
} }
ACTIVE_REQUESTS.fetch_sub(1, Ordering::Relaxed);
Ok(()) Ok(())
} }
async fn send_response( /// Send a complete response for `file_path`, streaming the body.
stream: &mut TlsStream<TcpStream>, ///
response: &str, /// Exactly one status line is written, and the status used is returned so the
) -> io::Result<()> { /// caller can log it. Errors after the header has been sent are reported to the
stream.write_all(response.as_bytes()).await?; /// caller for logging only.
stream.flush().await?; async fn serve_file<S>(stream: &mut S, file_path: &Path) -> io::Result<&'static str>
where
S: AsyncWriteExt + Unpin,
{
// Open first and stat the handle rather than the path, so the file cannot be
// swapped for a symlink between the check and the read.
let mut file = match tokio::fs::File::open(file_path).await {
Ok(file) => file,
Err(e) => {
tracing::debug!("Cannot open {}: {}", file_path.display(), e);
send_response(stream, "51 Not found\r\n").await?;
return Ok("51 Not found");
}
};
let metadata = match file.metadata().await {
Ok(metadata) => metadata,
Err(e) => {
tracing::debug!("Cannot stat {}: {}", file_path.display(), e);
send_response(stream, "51 Not found\r\n").await?;
return Ok("51 Not found");
}
};
// Directories and special files are not content.
if !metadata.is_file() {
send_response(stream, "51 Not found\r\n").await?;
return Ok("51 Not found");
}
if metadata.len() > MAX_FILE_SIZE {
tracing::warn!(
"Refusing to serve {}: {} bytes exceeds the {} byte limit",
file_path.display(),
metadata.len(),
MAX_FILE_SIZE
);
send_response(stream, "50 Permanent failure\r\n").await?;
return Ok("50 Permanent failure");
}
send_response(stream, &format!("20 {}\r\n", get_mime_type(file_path))).await?;
// Fixed-size chunks: peak memory is CHUNK_SIZE per request regardless of
// how large the file is or how slowly the client reads.
let mut buf = vec![0u8; CHUNK_SIZE];
loop {
let read = timeout(IO_TIMEOUT, file.read(&mut buf))
.await
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "file read timed out"))??;
if read == 0 {
break;
}
timeout(IO_TIMEOUT, stream.write_all(&buf[..read]))
.await
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "response write timed out"))??;
}
timeout(IO_TIMEOUT, stream.flush())
.await
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "response flush timed out"))??;
Ok("20 Success")
}
async fn send_response<S>(stream: &mut S, response: &str) -> io::Result<()>
where
S: AsyncWriteExt + Unpin,
{
timeout(IO_TIMEOUT, stream.write_all(response.as_bytes()))
.await
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "response write timed out"))??;
timeout(IO_TIMEOUT, stream.flush())
.await
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "response flush timed out"))??;
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_hostname_and_path_valid() {
assert_eq!(
extract_hostname_and_path("gemini://example.com/"),
Ok(("example.com".to_string(), "/".to_string()))
);
assert_eq!(
extract_hostname_and_path("gemini://example.com"),
Ok(("example.com".to_string(), "/".to_string()))
);
assert_eq!(
extract_hostname_and_path("gemini://example.com/page.gmi"),
Ok(("example.com".to_string(), "/page.gmi".to_string()))
);
assert_eq!(
extract_hostname_and_path("gemini://sub.example.com/a/b.txt"),
Ok(("sub.example.com".to_string(), "/a/b.txt".to_string()))
);
}
#[test]
fn test_extract_hostname_and_path_invalid() {
for invalid in [
"",
"http://example.com/",
"gemini://",
"//example.com/",
"gemini:///path",
] {
assert!(
extract_hostname_and_path(invalid).is_err(),
"should reject: {}",
invalid
);
}
}
/// DNS names are case-insensitive, so routing must be too.
#[test]
fn test_extract_hostname_is_lowercased() {
assert_eq!(
extract_hostname_and_path("gemini://EXAMPLE.COM/x.gmi"),
Ok(("example.com".to_string(), "/x.gmi".to_string()))
);
}
/// The Gemini spec permits an explicit port; it must not change routing.
#[test]
fn test_extract_hostname_strips_port() {
assert_eq!(
extract_hostname_and_path("gemini://example.com:1965/"),
Ok(("example.com".to_string(), "/".to_string()))
);
assert_eq!(
extract_hostname_and_path("gemini://example.com:1965/a.gmi"),
Ok(("example.com".to_string(), "/a.gmi".to_string()))
);
}
#[test]
fn test_extract_hostname_strips_userinfo_and_trailing_dot() {
assert_eq!(
extract_hostname_and_path("gemini://user@example.com/"),
Ok(("example.com".to_string(), "/".to_string()))
);
assert_eq!(
extract_hostname_and_path("gemini://example.com./"),
Ok(("example.com".to_string(), "/".to_string()))
);
}
#[test]
fn test_extract_hostname_ipv6_literal() {
assert_eq!(
extract_hostname_and_path("gemini://[::1]:1965/x.gmi"),
Ok(("[::1]".to_string(), "/x.gmi".to_string()))
);
assert_eq!(
extract_hostname_and_path("gemini://[::1]/"),
Ok(("[::1]".to_string(), "/".to_string()))
);
}
/// The path is percent-decoded, so encoded traversal reaches the path
/// validator in its decoded form and is rejected there.
#[test]
fn test_extract_path_is_percent_decoded() {
assert_eq!(
extract_hostname_and_path("gemini://example.com/%2e%2e%2fsecret"),
Ok(("example.com".to_string(), "/../secret".to_string()))
);
assert_eq!(
extract_hostname_and_path("gemini://example.com/caf%C3%A9.gmi"),
Ok(("example.com".to_string(), "/café.gmi".to_string()))
);
}
#[test]
fn test_truncate_bounds_log_output() {
assert_eq!(truncate("short"), "short");
let long = "a".repeat(500);
let truncated = truncate(&long);
assert!(truncated.starts_with(&"a".repeat(128)));
assert!(truncated.contains("500 bytes total"));
assert!(truncated.chars().count() < 200);
}
}

View file

@ -1,28 +1,22 @@
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use std::fs; use std::fs;
use std::io::{self, BufReader}; use std::io::{self, BufReader};
pub fn load_certs(filename: &str) -> io::Result<Vec<rustls::Certificate>> { pub fn load_certs(filename: &str) -> io::Result<Vec<CertificateDer<'static>>> {
let certfile = fs::File::open(filename)?; let certfile = fs::File::open(filename)?;
let mut reader = BufReader::new(certfile); let mut reader = BufReader::new(certfile);
rustls_pemfile::certs(&mut reader)? rustls_pemfile::certs(&mut reader).collect()
.into_iter()
.map(|v| Ok(rustls::Certificate(v)))
.collect()
} }
pub fn load_private_key(filename: &str) -> io::Result<rustls::PrivateKey> { pub fn load_private_key(filename: &str) -> io::Result<PrivateKeyDer<'static>> {
let keyfile = fs::File::open(filename)?; let keyfile = fs::File::open(filename)?;
let mut reader = BufReader::new(keyfile); let mut reader = BufReader::new(keyfile);
loop { // Accepts PKCS#8, PKCS#1 (RSA) and SEC1 (EC) keys.
match rustls_pemfile::read_one(&mut reader)? { rustls_pemfile::private_key(&mut reader)?.ok_or_else(|| {
Some(rustls_pemfile::Item::RSAKey(key)) => return Ok(rustls::PrivateKey(key)), io::Error::new(
Some(rustls_pemfile::Item::PKCS8Key(key)) => return Ok(rustls::PrivateKey(key)), io::ErrorKind::InvalidData,
Some(rustls_pemfile::Item::ECKey(key)) => return Ok(rustls::PrivateKey(key)), "no supported private key found in file",
None => break, )
_ => {} })
} }
}
Err(io::Error::new(io::ErrorKind::InvalidData, "No supported private key found"))
}

46
tests/abort_connection.py Normal file
View file

@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""
Connection-abort helper for regression tests.
Completes a TLS handshake and then closes the connection without ever sending a
request line. This is the shape of traffic that used to underflow the server's
in-flight request counter and refuse every later request with "41".
Usage: python3 tests/abort_connection.py <hostname> [count]
Environment: GEMINI_PORT, GEMINI_CONNECT_HOST
"""
import os
import socket
import ssl
import sys
def main():
hostname = sys.argv[1] if len(sys.argv) > 1 else "localhost"
count = int(sys.argv[2]) if len(sys.argv) > 2 else 1
port = int(os.environ.get("GEMINI_PORT", "1965"))
connect_host = os.environ.get("GEMINI_CONNECT_HOST", hostname)
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
aborted = 0
for _ in range(count):
try:
sock = socket.create_connection((connect_host, port), timeout=5.0)
tls_sock = context.wrap_socket(sock, server_hostname=hostname)
# Close with no request bytes sent at all.
tls_sock.close()
aborted += 1
except Exception as e:
print(f"Error: {e}")
sys.exit(1)
print(f"aborted {aborted}")
if __name__ == "__main__":
main()

View file

@ -1,4 +1,29 @@
use std::path::Path; 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; use tempfile::TempDir;
pub fn setup_test_environment() -> TempDir { pub fn setup_test_environment() -> TempDir {
@ -12,26 +37,47 @@ pub fn setup_test_environment() -> TempDir {
// Generate test certificates // Generate test certificates
generate_test_certificates(temp_dir.path()); 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 temp_dir
} }
fn generate_test_certificates(temp_dir: &Path) { fn generate_test_certificates(temp_dir: &Path) {
use std::process::Command; use std::process::Command;
// Generate self-signed certificate for testing
let cert_path = temp_dir.join("cert.pem"); let cert_path = temp_dir.join("cert.pem");
let key_path = temp_dir.join("key.pem"); let key_path = temp_dir.join("key.pem");
let status = Command::new("openssl") // Use openssl to generate a test certificate
let output = Command::new("openssl")
.args(&[ .args(&[
"req", "-x509", "-newkey", "rsa:2048", "req",
"-keyout", &key_path.to_string_lossy(), "-x509",
"-out", &cert_path.to_string_lossy(), "-newkey",
"-days", "1", "rsa:2048",
"-keyout",
&key_path.to_string_lossy(),
"-out",
&cert_path.to_string_lossy(),
"-days",
"1",
"-nodes", "-nodes",
"-subj", "/CN=localhost" "-subj",
"/CN=localhost",
]) ])
.status() .output();
.unwrap();
assert!(status.success(), "Failed to generate test certificates"); 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.");
}
}
}

View file

@ -7,116 +7,153 @@ fn test_missing_config_file() {
let output = Command::new(env!("CARGO_BIN_EXE_pollux")) let output = Command::new(env!("CARGO_BIN_EXE_pollux"))
.arg("--config") .arg("--config")
.arg("nonexistent.toml") .arg("nonexistent.toml")
.env("RUST_LOG", "error")
.output() .output()
.unwrap(); .unwrap();
assert!(!output.status.success()); assert!(!output.status.success());
let stderr = String::from_utf8(output.stderr).unwrap(); 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!(stderr.contains("Config file 'nonexistent.toml' not found"));
assert!(stderr.contains("Create the config file with required fields")); assert!(stdout.contains("Create the config file with"));
}
#[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] #[test]
fn test_nonexistent_root_directory() { fn test_nonexistent_root_directory() {
let temp_dir = common::setup_test_environment(); let temp_dir = common::setup_test_environment();
let config_path = temp_dir.path().join("config.toml"); let config_path = temp_dir.path().join("config.toml");
let config_content = format!(r#" let config_content = format!(
root = "/definitely/does/not/exist" r#"
cert = "{}" bind_host = "127.0.0.1"
key = "{}"
hostname = "example.com" ["example.com"]
bind_host = "127.0.0.1" root = "/definitely/does/not/exist"
"#, temp_dir.path().join("cert.pem").display(), temp_dir.path().join("key.pem").display()); cert = "{}"
key = "{}"
"#,
temp_dir.path().join("cert.pem").display(),
temp_dir.path().join("key.pem").display()
);
std::fs::write(&config_path, config_content).unwrap(); std::fs::write(&config_path, config_content).unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_pollux")) let output = Command::new(env!("CARGO_BIN_EXE_pollux"))
.arg("--config") .arg("--config")
.arg(&config_path) .arg(&config_path)
.arg("--quiet")
.env("RUST_LOG", "error")
.output() .output()
.unwrap(); .unwrap();
assert!(!output.status.success()); assert!(!output.status.success());
let stderr = String::from_utf8(output.stderr).unwrap(); let stderr = String::from_utf8(output.stderr).unwrap();
assert!(stderr.contains("Error: Root directory '/definitely/does/not/exist' does not exist")); assert!(stderr.contains("Failed to parse config file"));
assert!(stderr.contains("Create the directory and add your Gemini files (.gmi, .txt, images)")); assert!(stderr.contains(
"Error for host 'example.com': Root directory '/definitely/does/not/exist' does not exist"
));
} }
#[test] #[test]
fn test_missing_certificate_file() { fn test_missing_certificate_file() {
let temp_dir = common::setup_test_environment(); let temp_dir = common::setup_test_environment();
let config_path = temp_dir.path().join("config.toml"); let config_path = temp_dir.path().join("config.toml");
let config_content = format!(r#" let config_content = format!(
root = "{}" r#"
cert = "/nonexistent/cert.pem" bind_host = "127.0.0.1"
key = "{}"
hostname = "example.com" ["example.com"]
bind_host = "127.0.0.1" root = "{}"
"#, temp_dir.path().join("content").display(), temp_dir.path().join("key.pem").display()); cert = "/nonexistent/cert.pem"
key = "{}"
"#,
temp_dir.path().join("content").display(),
temp_dir.path().join("key.pem").display()
);
std::fs::write(&config_path, config_content).unwrap(); std::fs::write(&config_path, config_content).unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_pollux")) let output = Command::new(env!("CARGO_BIN_EXE_pollux"))
.arg("--config") .arg("--config")
.arg(&config_path) .arg(&config_path)
.arg("--quiet")
.env("RUST_LOG", "error")
.output() .output()
.unwrap(); .unwrap();
assert!(!output.status.success()); assert!(!output.status.success());
let stderr = String::from_utf8(output.stderr).unwrap(); let stderr = String::from_utf8(output.stderr).unwrap();
assert!(stderr.contains("Error: Certificate file '/nonexistent/cert.pem' does not exist")); assert!(stderr.contains(
"Error for host 'example.com': Certificate file '/nonexistent/cert.pem' does not exist"
));
assert!(stderr.contains("Generate or obtain TLS certificates for your domain")); assert!(stderr.contains("Generate or obtain TLS certificates for your domain"));
} }
#[test] #[test]
fn test_valid_config_startup() { fn test_multiple_hosts_missing_certificate() {
let temp_dir = common::setup_test_environment(); let temp_dir = common::setup_test_environment();
let port = 1967 + (std::process::id() % 1000) as u16;
let config_path = temp_dir.path().join("config.toml"); let config_path = temp_dir.path().join("config.toml");
let config_content = format!(r#"
root = "{}" // Create host directories
cert = "{}" std::fs::create_dir(temp_dir.path().join("host1")).unwrap();
key = "{}" std::fs::create_dir(temp_dir.path().join("host2")).unwrap();
hostname = "localhost"
bind_host = "127.0.0.1" // Generate certificate for only host1
port = {} let cert1_path = temp_dir.path().join("host1_cert.pem");
"#, temp_dir.path().join("content").display(), temp_dir.path().join("cert.pem").display(), temp_dir.path().join("key.pem").display(), port); 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()
);
std::fs::write(&config_path, config_content).unwrap(); std::fs::write(&config_path, config_content).unwrap();
let mut server_process = Command::new(env!("CARGO_BIN_EXE_pollux")) let output = Command::new(env!("CARGO_BIN_EXE_pollux"))
.arg("--config") .arg("--config")
.arg(&config_path) .arg(&config_path)
.spawn() .arg("--quiet")
.env("RUST_LOG", "error")
.output()
.unwrap(); .unwrap();
// Wait for server to start assert!(!output.status.success());
std::thread::sleep(std::time::Duration::from_millis(500)); let stderr = String::from_utf8(output.stderr).unwrap();
assert!(stderr.contains(
// Check server is still running (didn't exit with error) "Error for host 'host2.com': Certificate file '/nonexistent/cert.pem' does not exist"
assert!(server_process.try_wait().unwrap().is_none(), "Server should still be running with valid config"); ));
}
// Kill server
server_process.kill().unwrap();
}

View file

@ -0,0 +1,218 @@
//! Regression tests for connection and request lifecycle handling.
//!
//! These cover remotely triggerable availability bugs rather than routing
//! behaviour: a server that stops answering is as broken as one that leaks.
mod common;
use std::process::{Child, Command};
use std::time::Duration;
/// Owns a server process and reaps it on drop.
///
/// A panicking assertion must not leave a live server behind: the orphan keeps
/// the test harness's stdout pipe open, so the whole run hangs instead of
/// reporting the failure.
struct ServerProcess(Child);
impl Drop for ServerProcess {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
fn start_server(config_path: &std::path::Path) -> ServerProcess {
let child = Command::new(env!("CARGO_BIN_EXE_pollux"))
.arg("--config")
.arg(config_path)
.arg("--quiet")
.spawn()
.expect("failed to start server");
std::thread::sleep(Duration::from_millis(600));
ServerProcess(child)
}
fn write_config(dir: &std::path::Path, root: &std::path::Path, port: u16) -> std::path::PathBuf {
let config_path = dir.join("config.toml");
let content = format!(
r#"
bind_host = "127.0.0.1"
port = {}
["localhost"]
root = "{}"
cert = "{}"
key = "{}"
"#,
port,
root.display(),
dir.join("cert.pem").display(),
dir.join("key.pem").display()
);
std::fs::write(&config_path, content).unwrap();
config_path
}
fn request(port: u16, url: &str) -> String {
let output = Command::new("python3")
.arg("tests/gemini_test_client.py")
.arg(url)
.env("GEMINI_PORT", port.to_string())
.env("GEMINI_CONNECT_HOST", "127.0.0.1")
.output()
.expect("failed to run test client");
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
fn abort_connections(port: u16, count: usize) {
let output = Command::new("python3")
.arg("tests/abort_connection.py")
.arg("localhost")
.arg(count.to_string())
.env("GEMINI_PORT", port.to_string())
.env("GEMINI_CONNECT_HOST", "127.0.0.1")
.output()
.expect("failed to run abort helper");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains(&format!("aborted {}", count)),
"abort helper did not complete: {}",
stdout
);
}
/// A client that completes the TLS handshake and disconnects without sending a
/// request must not affect later requests.
///
/// This previously decremented the in-flight request counter without a matching
/// increment. The counter is unsigned, so it wrapped to usize::MAX and every
/// subsequent request was refused with "41 Server unavailable" until restart.
/// One aborted connection was enough to take the capsule down permanently.
#[test]
fn test_aborted_connection_does_not_disable_server() {
let temp_dir = common::setup_test_environment();
let root = temp_dir.path().join("content");
let port = 2300 + (std::process::id() % 700) as u16;
let config_path = write_config(temp_dir.path(), &root, port);
let _server = start_server(&config_path);
let before = request(port, "gemini://localhost/test.gmi");
assert!(
before.starts_with("20"),
"baseline request should succeed, got: {}",
before
);
abort_connections(port, 1);
let after = request(port, "gemini://localhost/test.gmi");
assert!(
after.starts_with("20"),
"server refused a request after one aborted connection (counter underflow), got: {}",
after
);
// A sustained burst must not degrade it either.
abort_connections(port, 25);
let after_burst = request(port, "gemini://localhost/test.gmi");
assert!(
after_burst.starts_with("20"),
"server refused a request after 25 aborted connections, got: {}",
after_burst
);
}
/// Files above the response size limit are refused instead of being buffered
/// whole into memory, where concurrent requests multiplied the cost.
#[test]
fn test_oversized_file_is_refused() {
let temp_dir = common::setup_test_environment();
let root = temp_dir.path().join("content");
let port = 3100 + (std::process::id() % 700) as u16;
// Just over the 64 MiB response limit.
let big = vec![b'x'; 64 * 1024 * 1024 + 1024];
std::fs::write(root.join("big.gmi"), &big).unwrap();
let config_path = write_config(temp_dir.path(), &root, port);
let _server = start_server(&config_path);
let response = request(port, "gemini://localhost/big.gmi");
assert!(
response.starts_with("50"),
"oversized file should be refused with 50, got: {}",
response
);
// A normal file on the same server still works.
let ok = request(port, "gemini://localhost/test.gmi");
assert!(
ok.starts_with("20"),
"normal file should still serve, got: {}",
ok
);
}
/// Dotfiles are not served. Capsule roots are frequently git working copies, so
/// this is what keeps .git/config and any credentials in it private.
#[test]
fn test_hidden_files_are_not_served() {
let temp_dir = common::setup_test_environment();
let root = temp_dir.path().join("content");
let port = 3800 + (std::process::id() % 700) as u16;
std::fs::write(root.join(".env"), "SECRET_TOKEN=hunter2").unwrap();
std::fs::create_dir_all(root.join(".git")).unwrap();
std::fs::write(root.join(".git/config"), "[remote]\n url = secret").unwrap();
let config_path = write_config(temp_dir.path(), &root, port);
let _server = start_server(&config_path);
for hidden in ["gemini://localhost/.env", "gemini://localhost/.git/config"] {
let response = request(port, hidden);
assert!(
response.starts_with("51"),
"hidden path {} should not be served, got: {}",
hidden,
response
);
assert!(
!response.contains("hunter2") && !response.contains("secret"),
"hidden file contents leaked for {}: {}",
hidden,
response
);
}
}
/// Routing accepts the authority forms the Gemini specification permits:
/// mixed case, an explicit port, userinfo, and a fully-qualified trailing dot.
#[test]
fn test_authority_forms_are_normalized() {
let temp_dir = common::setup_test_environment();
let root = temp_dir.path().join("content");
let port = 4500 + (std::process::id() % 700) as u16;
let config_path = write_config(temp_dir.path(), &root, port);
let _server = start_server(&config_path);
for url in [
"gemini://localhost/test.gmi",
"gemini://LOCALHOST/test.gmi",
"gemini://localhost./test.gmi",
&format!("gemini://localhost:{}/test.gmi", port),
] {
let response = request(port, url);
assert!(
response.starts_with("20"),
"{} should be routed to the localhost vhost, got: {}",
url,
response
);
}
}

View file

@ -8,6 +8,7 @@ Used by integration tests for rate limiting validation.
Usage: python3 tests/gemini_test_client.py gemini://host:port/path Usage: python3 tests/gemini_test_client.py gemini://host:port/path
""" """
import os
import sys import sys
import socket import socket
import ssl import ssl
@ -19,51 +20,74 @@ def main():
url = sys.argv[1] url = sys.argv[1]
# Parse URL (basic parsing) # Parse URL (basic parsing) - allow any protocol for testing
if not url.startswith('gemini://'): if url.startswith('gemini://'):
print("Error: URL must start with gemini://", file=sys.stderr) url_parts = url[9:].split('/', 1) # Remove gemini://
sys.exit(1) host = url_parts[0]
path = '/' + url_parts[1] if len(url_parts) > 1 else '/'
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: else:
host = host_port # For non-gemini URLs, try to extract host anyway for testing
port = 1965 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)
# SNI carries a hostname only: never a port, and never userinfo. Passing
# "host:port" here makes the handshake fail before the server sees anything.
sni_host = host.rsplit('@', 1)[-1]
if sni_host.startswith('['):
sni_host = sni_host[:sni_host.index(']') + 1] if ']' in sni_host else sni_host
elif ':' in sni_host:
sni_host = sni_host.rsplit(':', 1)[0]
sni_host = sni_host.rstrip('.')
try: try:
# Create SSL connection # Create SSL connection with permissive settings for self-signed certs
context = ssl.create_default_context() context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False context.check_hostname = False
context.verify_mode = ssl.CERT_NONE context.verify_mode = ssl.CERT_NONE
# Load default certificates to avoid some SSL issues
sock = socket.create_connection((host, port), timeout=5.0) context.load_default_certs()
ssl_sock = context.wrap_socket(sock, server_hostname=host)
sock = socket.create_connection((connect_host, port), timeout=5.0)
# Send request ssl_sock = context.wrap_socket(sock, server_hostname=sni_host)
# Send request (full URL for Gemini protocol over TLS)
request = f"{url}\r\n" request = f"{url}\r\n"
ssl_sock.send(request.encode('utf-8')) ssl_sock.send(request.encode('utf-8'))
# Read response header # Read full response (header + body)
response = b'' response = b''
while b'\r\n' not in response and len(response) < 1024: while len(response) < 1024: # Read up to 1KB for test responses
data = ssl_sock.recv(1) try:
if not data: data = ssl_sock.recv(1024)
if not data:
break
response += data
except:
break break
response += data
ssl_sock.close() ssl_sock.close()
if response: if response:
status_line = response.decode('utf-8', errors='ignore').split('\r\n')[0] # Decode and return the full response
print(status_line) full_response = response.decode('utf-8', errors='ignore')
print(full_response.strip())
else: else:
print("Error: No response") print("Error: No response")
except Exception as e: except Exception as e:
print(f"Error: {e}") print(f"Error: {e}")

View file

@ -7,23 +7,37 @@ fn test_rate_limiting_with_concurrent_requests() {
// Create config with rate limiting enabled // Create config with rate limiting enabled
let config_path = temp_dir.path().join("config.toml"); let config_path = temp_dir.path().join("config.toml");
let config_content = format!(r#"
root = "{}" // Use existing content directory and cert files from setup_test_environment
cert = "{}" let root_dir = temp_dir.path().join("content");
key = "{}" let cert_path = temp_dir.path().join("cert.pem");
hostname = "localhost" let key_path = temp_dir.path().join("key.pem");
bind_host = "127.0.0.1"
port = {} let config_content = format!(
max_concurrent_requests = 1 r#"
"#, temp_dir.path().join("content").display(), temp_dir.path().join("cert.pem").display(), temp_dir.path().join("key.pem").display(), port); 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()
);
std::fs::write(&config_path, config_content).unwrap(); std::fs::write(&config_path, config_content).unwrap();
// Start server binary with test delay to simulate processing time // Start server binary with test delay to simulate processing time
let mut server_process = std::process::Command::new(env!("CARGO_BIN_EXE_pollux")) let mut server_process = std::process::Command::new(env!("CARGO_BIN_EXE_pollux"))
.arg("--config") .arg("--config")
.arg(&config_path) .arg(&config_path)
.arg("--quiet")
.arg("--test-processing-delay") .arg("--test-processing-delay")
.arg("1") // 1 second delay per request .arg("3") // 3 second delay per request
.spawn() .spawn()
.expect("Failed to start server"); .expect("Failed to start server");
@ -33,11 +47,13 @@ fn test_rate_limiting_with_concurrent_requests() {
// Spawn 5 concurrent client processes // Spawn 5 concurrent client processes
let mut handles = vec![]; let mut handles = vec![];
for _ in 0..5 { for _ in 0..5 {
let url = format!("gemini://localhost:{}/test.gmi", port); let url = format!("gemini://localhost/test.gmi");
let handle = std::thread::spawn(move || { let handle = std::thread::spawn(move || {
std::process::Command::new("python3") std::process::Command::new("python3")
.arg("tests/gemini_test_client.py") .arg("tests/gemini_test_client.py")
.arg(url) .arg(url)
.env("GEMINI_PORT", &port.to_string())
.env("RATE_LIMIT_TEST", "true")
.output() .output()
}); });
handles.push(handle); handles.push(handle);
@ -58,8 +74,31 @@ fn test_rate_limiting_with_concurrent_requests() {
let success_count = results.iter().filter(|r| r.starts_with("20")).count(); 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(); let rate_limited_count = results.iter().filter(|r| r.starts_with("41")).count();
// Validation // Debug output
assert!(success_count >= 1, "At least 1 request should succeed, got results: {:?}", results); tracing::debug!("Test results: {:?}", results);
assert!(rate_limited_count >= 1, "At least 1 request should be rate limited, got results: {:?}", results); tracing::debug!(
assert_eq!(success_count + rate_limited_count, 5, "All requests should get valid responses, got results: {:?}", results); "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
);
}

View file

@ -0,0 +1,364 @@
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"));
}

View file

@ -0,0 +1,384 @@
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),
}
}

167
tests/virtual_host_paths.rs Normal file
View file

@ -0,0 +1,167 @@
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()
}

View file

@ -0,0 +1,239 @@
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();
}