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

3
.gitignore vendored
View file

@ -22,3 +22,6 @@ Cargo.lock
# IDE files
.vscode/
.idea/
# Local project files
BACKLOG.md

157
AGENTS.md
View file

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

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.
## [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
### Added

View file

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

137
README.md
View file

@ -1,6 +1,6 @@
# 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
@ -16,18 +16,77 @@ cargo build --release
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:
```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
root = "/path/to/static/files"
cert = "/path/to/cert.pem"
key = "/path/to/key.pem"
bind_host = "0.0.0.0"
hostname = "gemini.example.com"
bind_host = "0.0.0.0"
port = 1965
log_level = "info"
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`)
- `--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
- Use development certificates only for local testing
- Production certificates should be obtained via Let's Encrypt or your CA
- Rotate certificates regularly and restart the server
#### File Organization
- Create `index.gmi` files in directories for automatic serving
- Use `.gmi` extension for Gemini text files
- Store certificates outside web-accessible directories
- Use proper file permissions (readable by server user only)
### Limitations
- **No dynamic content** - Pollux serves only static files
- **Single certificate per server** - All hosts currently share the same TLS certificate (can be enhanced)
- **No CGI support** - No server-side processing or scripting
- **Memory usage** - All host configurations are loaded into memory
- **No HTTP support** - Gemini protocol only
## Examples
The `examples/` directory contains sample configuration files:
- `virtual-hosting.toml` - Multi-host setup with different certificates
- `single-host.toml` - Legacy single-host configuration
- `development.toml` - Local development with self-signed certificates
## Testing
Run `cargo test` for the full test suite, which includes 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
sudo cp target/release/pollux /usr/local/bin/
# 2. Get certificates
sudo certbot certonly --standalone -d example.com
# 3. Create directories and user
# 2. Create directories and user
sudo useradd -r -s /bin/false pollux
sudo usermod -a -G ssl-cert pollux
sudo mkdir -p /etc/pollux /var/www/example.com
sudo chown -R pollux:pollux /var/www/example.com
sudo mkdir -p /etc/pollux/tls /var/gemini
sudo chown -R pollux:pollux /var/gemini
# 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
sudo cp dist/config.toml /etc/pollux/
# 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
sudo cp dist/pollux.service /etc/systemd/system/
@ -57,24 +60,23 @@ sudo cp target/release/pollux /usr/local/bin/
#### Certificate Setup
**For Production:** Obtain certificates from your preferred Certificate Authority and place them in `/etc/pollux/`. 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).
**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
# Generate certificates
openssl req -x509 -newkey rsa:4096 \
-keyout /etc/pollux/key.pem \
-out /etc/pollux/cert.pem \
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"
# Set permissions
sudo chown pollux:pollux /etc/pollux/*.pem
sudo chmod 644 /etc/pollux/cert.pem
sudo chmod 600 /etc/pollux/key.pem
# Set permissions (already correct when run as pollux user)
sudo chmod 644 /etc/pollux/tls/cert.pem
sudo chmod 600 /etc/pollux/tls/key.pem
```
### 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
# Create directories
sudo mkdir -p /etc/pollux /var/www/example.com
sudo chown -R pollux:pollux /var/www/example.com
sudo mkdir -p /etc/pollux/tls /var/gemini
sudo chown -R pollux:pollux /var/gemini
```
### Configuration
@ -98,26 +100,44 @@ sudo chown -R pollux:pollux /var/www/example.com
Edit `/etc/pollux/config.toml`:
```toml
root = "/var/www/example.com"
cert = "/etc/pollux/cert.pem"
key = "/etc/pollux/key.pem"
# Global settings
bind_host = "0.0.0.0"
hostname = "example.com"
port = 1965
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
```bash
# Copy your Gemini files
sudo cp -r gemini-content/* /var/www/example.com/
sudo cp -r gemini-content/* /var/gemini/
# Set permissions
sudo chown -R pollux:pollux /var/www/example.com
sudo find /var/www/example.com -type f -exec chmod 644 {} \;
sudo find /var/www/example.com -type d -exec chmod 755 {} \;
sudo chown -R pollux:pollux /var/gemini
sudo find /var/gemini -type f -exec chmod 644 {} \;
sudo find /var/gemini -type d -exec chmod 755 {} \;
```
### Service Installation
@ -128,7 +148,9 @@ sudo cp dist/pollux.service /etc/systemd/system/
# If your paths differ, edit the service file
sudo editor /etc/systemd/system/pollux.service
# Update ReadOnlyPaths to match your config
# Update ReadOnlyPaths to match your config:
# - /etc/pollux for config and TLS certificates
# - /var/gemini for your content root
# Enable and start
sudo systemctl daemon-reload
@ -154,10 +176,10 @@ openssl s_client -connect example.com:1965 -servername example.com <<< "gemini:/
### Permission Issues
```bash
# Check certificate access
sudo -u pollux cat /etc/pollux/cert.pem
sudo -u pollux cat /etc/pollux/tls/cert.pem
# Check content access
sudo -u pollux ls -la /var/www/example.com/
sudo -u pollux ls -la /var/gemini/
```
### Port Issues
@ -182,13 +204,13 @@ sudo systemctl reload pollux
See `config.toml` for all available options. Key settings:
- `root`: Directory containing your .gmi files
- `cert`/`key`: TLS certificate paths
- `bind_host`: IP/interface to bind to
- `hostname`: Domain name for URI validation
- `port`: Listen port (1965 is standard)
- `max_concurrent_requests`: Connection limit
- `log_level`: Logging verbosity
- `root`: Directory containing your .gmi files (per host section)
- `cert`/`key`: TLS certificate paths (per host section)
- `bind_host`: IP/interface to bind to (global)
- `port`: Listen port (1965 is standard, per host override possible)
- `max_concurrent_requests`: Connection limit (global)
Logging is configured via the `RUST_LOG` environment variable (see Logging Configuration section).
## Certificate Management

81
dist/config.toml vendored
View file

@ -5,28 +5,11 @@
#
# The Gemini protocol is specified in RFC 1436: https://tools.ietf.org/rfc/rfc1436.txt
# Directory containing your Gemini files (.gmi, .txt, images, etc.)
# The server will serve files from this directory and its subdirectories.
# Default index file is 'index.gmi' for directory requests.
#
# IMPORTANT: The server needs READ access to this directory.
# Make sure the service user (gemini) can read all files here.
root = "/var/www/example.com"
# TLS certificate and private key files
# These files are required for TLS encryption (Gemini requires TLS).
#
# For Let's Encrypt certificates (recommended for production):
# cert = "/etc/letsencrypt/live/example.com/fullchain.pem"
# key = "/etc/letsencrypt/live/example.com/privkey.pem"
#
# To obtain Let's Encrypt certs:
# sudo certbot certonly --standalone -d example.com
#
# For development/testing, generate self-signed certs:
# openssl req -x509 -newkey rsa:4096 -keyout /etc/pollux/key.pem -out /etc/pollux/cert.pem -days 365 -nodes -subj "/CN=example.com"
cert = "/etc/letsencrypt/live/example.com/fullchain.pem"
key = "/etc/letsencrypt/live/example.com/privkey.pem"
# For additional hostnames, add more sections like:
# ["blog.example.com"]
# root = "/var/gemini/blog"
# cert = "/etc/pollux/tls/blog.crt"
# key = "/etc/pollux/tls/blog.key"
# Server network configuration
#
@ -37,12 +20,6 @@ key = "/etc/letsencrypt/live/example.com/privkey.pem"
# - Specific IP = bind to that address only
bind_host = "0.0.0.0"
# hostname: Domain name for URI validation
# - Used to validate incoming gemini:// URIs
# - Clients must use: gemini://yourdomain.com
# - Server validates that requests match this hostname
hostname = "example.com"
# port: TCP port to listen on
# - Default Gemini port is 1965
# - Ports below 1024 require root privileges
@ -51,18 +28,46 @@ port = 1965
# Request limiting
#
# max_concurrent_requests: Maximum number of simultaneous connections
# - Prevents server overload and DoS attacks
# - Set to 0 to disable limiting (not recommended)
# max_concurrent_requests: Maximum number of requests processed at once
# - Requests above the limit are answered with "41 Server unavailable"
# - Must be between 1 and 1000000; the server will not start on 0
# - Typical values: 100-10000 depending on server capacity
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
#
# log_level: Controls how much information is logged
# - "error": Only errors that prevent normal operation
# - "warn": Errors plus warnings about unusual conditions
# - "info": General operational information (recommended)
# - "debug": Detailed debugging information
# - "trace": Very verbose debugging (use only for troubleshooting)
log_level = "info"
# Logging is controlled by the RUST_LOG environment variable, not this file.
# In the systemd unit, set for example:
# Environment=RUST_LOG=info
# Levels, least to most verbose: error, warn, info, debug, trace
# Host configuration
# Each hostname needs its own section with root, cert, and key settings
["example.com"]
# Directory containing your Gemini files (.gmi, .txt, images, etc.)
# The server will serve files from this directory and its subdirectories.
# Default index file is 'index.gmi' for directory requests.
#
# IMPORTANT: The server needs READ access to this directory.
# Make sure the service user can read all files here.
root = "/var/gemini"
# TLS certificate and private key files
# These files are required for TLS encryption (Gemini requires TLS).
#
# For self-signed certificates (development/testing):
cert = "/etc/pollux/tls/cert.pem"
key = "/etc/pollux/tls/key.pem"
#
# Generate self-signed certs with:
# openssl req -x509 -newkey rsa:4096 -keyout /etc/pollux/tls/key.pem -out /etc/pollux/tls/cert.pem -days 365 -nodes -subj "/CN=example.com"
#
# For Let's Encrypt certificates, use paths under /etc/letsencrypt/live/

8
dist/pollux.service vendored
View file

@ -13,12 +13,10 @@ Group=pollux
NoNewPrivileges=yes
ProtectHome=yes
ProtectSystem=strict
ReadOnlyPaths=/etc/pollux /etc/letsencrypt/live/example.com /var/www/example.com
# NOTE: Adjust /etc/letsencrypt/live/example.com and /var/www/example.com to match your config
# The server needs read access to config, certificates, and content files
ReadOnlyPaths=/etc/pollux /var/gemini
# NOTE: Adjust paths to match your config:
# - /etc/letsencrypt/live/example.com for Let's Encrypt certs
# - /var/www/example.com for your content root
# - /etc/pollux for config and TLS certificates
# - /var/gemini for your content root
# The server needs read access to config, certificates, and content files
[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 std::collections::HashMap;
use toml::Value;
#[derive(Deserialize)]
#[derive(Debug)]
pub struct Config {
pub root: Option<String>,
pub cert: Option<String>,
pub key: Option<String>,
// Global defaults (optional)
pub bind_host: Option<String>,
pub hostname: Option<String>,
pub port: Option<u16>,
pub log_level: Option<String>,
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>> {
let content = std::fs::read_to_string(path)?;
let config: Config = toml::from_str(&content)?;
Ok(config)
let toml_value: Value = toml::from_str(&content)?;
// Extract global settings
let bind_host = extract_string(&toml_value, "bind_host");
let port = extract_u16(&toml_value, "port");
let max_concurrent_requests = extract_usize(&toml_value, "max_concurrent_requests");
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)]
@ -25,52 +246,178 @@ mod tests {
use tempfile::TempDir;
#[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 config_path = temp_dir.path().join("config.toml");
let content = r#"
root = "/path/to/root"
cert = "cert.pem"
key = "key.pem"
bind_host = "0.0.0.0"
hostname = "example.com"
// Create the root directory and cert/key files
let root_dir = temp_dir.path().join("root");
fs::create_dir(&root_dir).unwrap();
let cert_path = temp_dir.path().join("cert.pem");
let key_path = temp_dir.path().join("key.pem");
fs::write(&cert_path, "dummy cert").unwrap();
fs::write(&key_path, "dummy key").unwrap();
let content = format!(
r#"
["example.com"]
root = "{}"
cert = "{}"
key = "{}"
port = 1965
log_level = "info"
"#;
"#,
root_dir.display(),
cert_path.display(),
key_path.display()
);
fs::write(&config_path, content).unwrap();
let config = load_config(config_path.to_str().unwrap()).unwrap();
assert_eq!(config.root, Some("/path/to/root".to_string()));
assert_eq!(config.cert, Some("cert.pem".to_string()));
assert_eq!(config.key, Some("key.pem".to_string()));
assert_eq!(config.bind_host, Some("0.0.0.0".to_string()));
assert_eq!(config.hostname, Some("example.com".to_string()));
assert_eq!(config.port, Some(1965));
assert_eq!(config.log_level, Some("info".to_string()));
assert_eq!(config.max_concurrent_requests, None); // Default
assert_eq!(config.hosts.len(), 1);
assert!(config.hosts.contains_key("example.com"));
let host_config = &config.hosts["example.com"];
assert_eq!(host_config.root, root_dir.to_str().unwrap());
assert_eq!(host_config.cert, cert_path.to_str().unwrap());
assert_eq!(host_config.key, key_path.to_str().unwrap());
assert_eq!(host_config.port, Some(1965));
assert_eq!(host_config.log_level, Some("info".to_string()));
}
#[test]
fn test_load_config_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 config_path = temp_dir.path().join("config.toml");
let content = r#"
root = "/path/to/root"
max_concurrent_requests = 500
bind_host = "127.0.0.1"
port = 1965
"#;
fs::write(&config_path, content).unwrap();
let config = load_config(config_path.to_str().unwrap()).unwrap();
assert_eq!(config.max_concurrent_requests, Some(500));
let result = load_config(config_path.to_str().unwrap());
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("No host configurations found"));
}
#[test]
fn test_load_config_invalid() {
fn test_load_config_invalid_hostname() {
let temp_dir = TempDir::new().unwrap();
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();
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 tls;
mod request;
mod server;
mod logging;
mod tls;
use clap::Parser;
use rustls::crypto::ring::sign::any_supported_type;
use rustls::server::{ClientHello, ResolvesServerCert};
use rustls::sign::CertifiedKey;
use rustls::ServerConfig;
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use tokio::net::TcpListener;
use tokio::sync::Semaphore;
use tokio::time::{timeout, Duration};
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) {
println!("Pollux Gemini Server");
println!("Listening on: {}:{}", host, port);
println!("Serving: {}", root);
println!("Certificate: {}", cert);
println!("Key: {}", key);
/// A TLS handshake must complete within this window. Without a bound, a peer
/// that opens a socket and never sends a ClientHello pins a file descriptor for
/// as long as it likes.
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
/// Default ceiling on connections being handled at once. Keeps descriptor use
/// well under the usual 1024 soft limit, so exhausting it cannot stop the
/// listener from accepting.
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);
if let Some(level) = log_level {
println!("Log level: {}", level);
}
println!(); // Add spacing before connections start
}
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Args {
/// Path to config file
#[arg(short = 'C', long)]
/// Path to configuration file
#[arg(short, long)]
config: Option<String>,
/// TESTING ONLY: Add delay before processing (seconds) [debug builds only]
#[cfg(debug_assertions)]
#[arg(long, value_name = "SECONDS")]
/// Suppress startup output (for testing)
#[arg(long)]
quiet: bool,
/// Processing delay for testing (in milliseconds)
#[arg(long, hide = true)]
test_processing_delay: Option<u64>,
}
#[tokio::main]
async fn main() {
async fn main() -> Result<(), Box<dyn std::error::Error>> {
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
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() {
eprintln!("Error: Config file '{}' not found", config_path);
eprintln!("Create the config file with required fields:");
eprintln!(" root = \"/path/to/gemini/content\"");
eprintln!(" cert = \"/path/to/certificate.pem\"");
eprintln!(" key = \"/path/to/private-key.pem\"");
eprintln!(" bind_host = \"0.0.0.0\"");
eprintln!(" hostname = \"your.domain.com\"");
// User guidance goes to stdout BEFORE initializing tracing
// Use direct stderr for error, stdout for guidance
use std::io::Write;
let mut stderr = std::io::stderr();
let mut stdout = std::io::stdout();
writeln!(stderr, "Config file '{}' not found", config_path).unwrap();
if !args.quiet {
writeln!(
stdout,
"Create the config file with virtual host sections like:"
)
.unwrap();
writeln!(stdout, "[example.com]").unwrap();
writeln!(stdout, "root = \"/var/gemini\"").unwrap();
writeln!(stdout, "cert = \"/etc/pollux/tls/cert.pem\"").unwrap();
writeln!(stdout, "key = \"/etc/pollux/tls/key.pem\"").unwrap();
stdout.flush().unwrap();
}
std::process::exit(1);
}
@ -64,101 +193,108 @@ async fn main() {
let config = match config::load_config(config_path) {
Ok(config) => config,
Err(e) => {
eprintln!("Error: Failed to parse config file '{}': {}", config_path, e);
eprintln!("Check the TOML syntax and ensure all values are properly quoted.");
tracing::error!("Failed to parse config file '{}': {}", config_path, e);
tracing::error!(
"Check the TOML syntax and ensure host sections are properly formatted."
);
std::process::exit(1);
}
};
// Validate required fields
if config.root.is_none() {
eprintln!("Error: 'root' field is required in config file");
eprintln!("Add: root = \"/path/to/gemini/content\"");
std::process::exit(1);
}
if config.cert.is_none() {
eprintln!("Error: 'cert' field is required in config file");
eprintln!("Add: cert = \"/path/to/certificate.pem\"");
std::process::exit(1);
}
if config.key.is_none() {
eprintln!("Error: 'key' field is required in config file");
eprintln!("Add: key = \"/path/to/private-key.pem\"");
std::process::exit(1);
}
if config.hostname.is_none() {
eprintln!("Error: 'hostname' field is required in config file");
eprintln!("Add: hostname = \"your.domain.com\"");
std::process::exit(1);
}
// Validate filesystem
let root_path = std::path::Path::new(config.root.as_ref().unwrap());
// Validate host configurations
for (hostname, host_config) in &config.hosts {
// Validate root directory exists and is readable
let root_path = Path::new(&host_config.root);
if !root_path.exists() {
eprintln!("Error: Root directory '{}' does not exist", config.root.as_ref().unwrap());
eprintln!("Create the directory and add your Gemini files (.gmi, .txt, images)");
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() {
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");
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) {
eprintln!("Error: Cannot read root directory '{}': {}", config.root.as_ref().unwrap(), e);
eprintln!("Ensure the directory exists and the server user has read permission");
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);
}
let cert_path = std::path::Path::new(config.cert.as_ref().unwrap());
// Validate certificate files (always required for TLS)
let cert_path = Path::new(&host_config.cert);
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");
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) {
eprintln!("Error: Cannot read certificate file '{}': {}", config.cert.as_ref().unwrap(), e);
eprintln!("Ensure the file exists and the server user has read permission");
tracing::error!(
"Cannot read certificate file '{}' for host '{}': {}",
host_config.cert,
hostname,
e
);
tracing::error!("Ensure the file exists and the server user has read permission");
std::process::exit(1);
}
let key_path = std::path::Path::new(config.key.as_ref().unwrap());
let key_path = Path::new(&host_config.key);
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");
tracing::error!(
"Private key file '{}' for host '{}' does not exist",
host_config.key,
hostname
);
tracing::error!("Generate or obtain TLS private key for your domain");
std::process::exit(1);
}
if let Err(e) = std::fs::File::open(key_path) {
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");
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);
}
// Initialize logging after config validation
let log_level = config.log_level.as_deref().unwrap_or("info");
init_logging(log_level);
// Extract validated config values
let root = config.root.unwrap();
let cert_path = config.cert.unwrap();
let key_path = config.key.unwrap();
let bind_host = config.bind_host.unwrap_or_else(|| "0.0.0.0".to_string());
let hostname = config.hostname.unwrap();
let port = config.port.unwrap_or(1965);
}
// Validate max concurrent requests
let max_concurrent_requests = config.max_concurrent_requests.unwrap_or(1000);
if max_concurrent_requests == 0 || max_concurrent_requests > 1_000_000 {
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);
}
// TESTING ONLY: Read delay argument (debug builds only)
#[cfg(debug_assertions)]
let test_processing_delay = args.test_processing_delay
let test_processing_delay = args
.test_processing_delay
.filter(|&d| d > 0 && d <= 300)
.unwrap_or(0);
@ -166,42 +302,74 @@ async fn main() {
#[cfg(not(debug_assertions))]
let test_processing_delay = 0;
// Validate directory
let dir_path = Path::new(&root);
if !dir_path.exists() || !dir_path.is_dir() {
eprintln!("Error: Directory '{}' does not exist or is not a directory", root);
std::process::exit(1);
// Print startup information
print_startup_info(&config, &config.hosts, args.quiet);
// Phase 3: TLS mode (always enabled)
let tls_config = create_tls_config(&config.hosts)?;
let acceptor = TlsAcceptor::from(tls_config);
if !args.quiet {
println!("Starting Pollux Gemini Server with Virtual Host support...");
}
// Load TLS certificates
let certs = tls::load_certs(&cert_path).unwrap();
let key = tls::load_private_key(&key_path).unwrap();
let bind_host = config.bind_host.as_deref().unwrap_or("0.0.0.0");
let port = config.port.unwrap_or(1965);
let config = ServerConfig::builder()
.with_safe_defaults()
.with_no_client_auth()
.with_single_cert(certs, key).unwrap();
let listener = TcpListener::bind(format!("{}:{}", bind_host, port)).await?;
if !args.quiet {
println!(
"Listening on {}:{} for all virtual hosts (TLS enabled)",
bind_host, port
);
}
let acceptor = TlsAcceptor::from(Arc::new(config));
let listener = TcpListener::bind(format!("{}:{}", bind_host, port)).await.unwrap();
// Print startup information
print_startup_info(&bind_host, port, &root, &cert_path, &key_path, Some(log_level), max_concurrent_requests);
// Shared once rather than cloned per connection.
let hosts = Arc::new(config.hosts);
let request_limiter = Arc::new(Semaphore::new(max_concurrent_requests));
let connection_limiter = Arc::new(Semaphore::new(max_connections));
loop {
let (stream, _) = listener.accept().await.unwrap();
tracing::debug!("Accepted connection from {}", stream.peer_addr().unwrap_or_else(|_| "unknown".parse().unwrap()));
let acceptor = acceptor.clone();
let dir = root.clone();
let expected_hostname = hostname.clone();
let max_concurrent = max_concurrent_requests;
let test_delay = test_processing_delay;
tokio::spawn(async move {
if let Ok(stream) = acceptor.accept(stream).await {
if let Err(e) = server::handle_connection(stream, &dir, &expected_hostname, port, max_concurrent, test_delay).await {
tracing::error!("Error handling connection: {}", e);
let (stream, peer) = match listener.accept().await {
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 hosts = Arc::clone(&hosts);
let request_limiter = Arc::clone(&request_limiter);
let test_delay = test_processing_delay;
tokio::spawn(async move {
let _connection_permit = connection_permit;
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,85 +1,68 @@
use path_security::validate_path;
use std::path::{Path, PathBuf};
use std::path::{Component, Path, PathBuf};
#[derive(Debug, PartialEq)]
pub enum PathResolutionError {
NotFound,
}
pub fn parse_gemini_url(request: &str, hostname: &str, expected_port: u16) -> Result<String, ()> {
if let Some(url) = request.strip_prefix("gemini://") {
let host_port_end = url.find('/').unwrap_or(url.len());
let host_port = &url[..host_port_end];
// Parse host and port
let (host, port_str) = if let Some(colon_pos) = host_port.find(':') {
let host = &host_port[..colon_pos];
let port_str = &host_port[colon_pos + 1..];
(host, Some(port_str))
} else {
(host_port, None)
};
// Validate host
if host != hostname {
return Err(()); // Hostname mismatch
}
// 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(())
}
}
/// Map a request path onto a file inside `dir`.
///
/// The returned path is canonical and always lives under the canonical `dir`.
/// Traversal is refused lexically before any filesystem access, and symlinks
/// that would leave the content root are caught afterwards by comparing
/// canonical prefixes. Components starting with `.` are refused so that
/// repository metadata and dotfiles inside a content root stay private.
pub fn resolve_file_path(path: &str, dir: &str) -> Result<PathBuf, PathResolutionError> {
let file_path_str = if path == "/" {
"index.gmi".to_string()
} else if path.ends_with('/') {
format!("{}index.gmi", &path[1..])
} else {
path[1..].to_string()
// Strip exactly one leading slash: a path like "//etc/passwd" must stay
// absolute so the component check below rejects it.
let relative = match path {
"" | "/" => "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(),
};
let relative = Path::new(&relative);
// 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),
}
}
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 get_mime_type(file_path: &Path) -> &'static str {
let Some(ext) = file_path.extension() else {
return "application/octet-stream";
};
match validate_path(Path::new(&file_path_str), Path::new(dir)) {
Ok(safe_path) => {
// Path is secure, now check if file exists
if safe_path.exists() {
Ok(safe_path)
} else {
Err(PathResolutionError::NotFound)
}
},
Err(_) => {
// Path validation failed - treat as not found
Err(PathResolutionError::NotFound)
},
}
}
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",
match ext.to_string_lossy().to_ascii_lowercase().as_str() {
"gmi" => "text/gemini",
"txt" => "text/plain",
"html" | "htm" => "text/html",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"webp" => "image/webp",
"gif" => "image/gif",
"svg" => "image/svg+xml",
"pdf" => "application/pdf",
_ => "application/octet-stream",
}
} else {
"application/octet-stream"
}
}
#[cfg(test)]
@ -87,26 +70,9 @@ mod tests {
use super::*;
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]
fn test_resolve_file_path_root() {
let temp_dir = TempDir::new().unwrap();
// Create index.gmi file since we now check for existence
std::fs::write(temp_dir.path().join("index.gmi"), "# Test").unwrap();
assert!(resolve_file_path("/", temp_dir.path().to_str().unwrap()).is_ok());
}
@ -127,39 +93,148 @@ mod tests {
}
#[test]
fn test_resolve_file_path_traversal() {
fn test_resolve_file_path_not_found() {
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]
fn test_resolve_file_path_not_found() {
fn test_resolve_file_path_nested_file() {
let temp_dir = TempDir::new().unwrap();
// Don't create the file, should return NotFound error
assert_eq!(resolve_file_path("/nonexistent.gmi", temp_dir.path().to_str().unwrap()), Err(PathResolutionError::NotFound));
std::fs::create_dir_all(temp_dir.path().join("a/b")).unwrap();
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]
fn test_get_mime_type_gmi() {
let path = Path::new("test.gmi");
assert_eq!(get_mime_type(path), "text/gemini");
assert_eq!(get_mime_type(Path::new("test.gmi")), "text/gemini");
}
#[test]
fn test_get_mime_type_png() {
let path = Path::new("test.png");
assert_eq!(get_mime_type(path), "image/png");
assert_eq!(get_mime_type(Path::new("test.png")), "image/png");
}
#[test]
fn test_get_mime_type_unknown() {
let path = Path::new("test.xyz");
assert_eq!(get_mime_type(path), "application/octet-stream");
assert_eq!(
get_mime_type(Path::new("test.xyz")),
"application/octet-stream"
);
}
#[test]
fn test_get_mime_type_no_extension() {
let path = Path::new("test");
assert_eq!(get_mime_type(path), "application/octet-stream");
assert_eq!(get_mime_type(Path::new("test")), "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::logging::RequestLogger;
use std::fs;
use crate::request::{get_mime_type, resolve_file_path};
use std::collections::HashMap;
use std::io;
use std::path::Path;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::sync::Semaphore;
use tokio::time::{timeout, Duration};
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(
stream: &mut TlsStream<TcpStream>,
file_path: &Path,
request: &str,
) -> io::Result<()> {
if file_path.exists() && file_path.is_file() {
let mime_type = get_mime_type(file_path);
let header = format!("20 {}\r\n", mime_type);
stream.write_all(header.as_bytes()).await?;
// Log success after sending header
let client_ip = match stream.get_ref().0.peer_addr() {
Ok(addr) => addr.to_string(),
Err(_) => "unknown".to_string(),
};
let request_path = request.strip_prefix("gemini://localhost").unwrap_or(request);
tracing::info!("{} \"{}\" 20 \"Success\"", client_ip, request_path);
// Then send body
let content = fs::read(file_path)?;
stream.write_all(&content).await?;
stream.flush().await?;
Ok(())
} else {
Err(tokio::io::Error::new(tokio::io::ErrorKind::NotFound, "File not found"))
/// Largest file served in a single response. Bounds how much a slow client can
/// make the server buffer, and keeps a stray large file out of a capsule.
const MAX_FILE_SIZE: u64 = 64 * 1024 * 1024;
/// Response body chunk size. Memory use per request is this, not the file size.
const CHUNK_SIZE: usize = 64 * 1024;
/// Applies to each read and write, so a client that stops reading cannot hold a
/// concurrency permit open indefinitely.
const IO_TIMEOUT: Duration = Duration::from_secs(60);
/// Keep attacker-controlled text out of the logs beyond a bounded length.
fn truncate(request: &str) -> String {
const LIMIT: usize = 128;
if request.chars().count() <= LIMIT {
return request.to_string();
}
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(
mut stream: TlsStream<TcpStream>,
dir: &str,
hostname: &str,
expected_port: u16,
max_concurrent_requests: usize,
hosts: &HashMap<String, crate::config::HostConfig>,
request_limiter: &Semaphore,
_test_processing_delay: u64,
) -> io::Result<()> {
const MAX_REQUEST_SIZE: usize = 1026;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
let mut request_buf = Vec::new();
let read_future = async {
loop {
if request_buf.len() >= MAX_REQUEST_SIZE {
return Err(tokio::io::Error::new(tokio::io::ErrorKind::InvalidData, "Request too large"));
let request = match read_request(&mut stream).await {
Ok(request) => request,
Err(e) => {
tracing::debug!("Request read failed: {}", e);
return send_response(&mut stream, "59 Bad Request\r\n").await;
}
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 {
Ok(Ok(())) => {
// Read successful, continue processing
let request = String::from_utf8_lossy(&request_buf).trim().to_string();
// 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,
// Held until this function returns, on every path including early returns
// and panics. Nothing here adjusts a counter by hand.
let _permit = match request_limiter.try_acquire() {
Ok(permit) => permit,
Err(_) => {
logger.log_error(59, "Invalid URL format");
ACTIVE_REQUESTS.fetch_sub(1, Ordering::Relaxed);
tracing::warn!("Concurrent request limit reached, refusing request");
return send_response(&mut stream, "41 Server unavailable\r\n").await;
}
};
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(tokio::time::Duration::from_secs(_test_processing_delay)).await;
tokio::time::sleep(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);
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;
}
};
// No delay for normal operation
// Processing complete
// Serve the file
match serve_file(&mut stream, &file_path, &request).await {
Ok(_) => {
// Success already logged in serve_file
}
Err(_) => {
// File transmission failed
logger.log_error(51, "File transmission failed");
let _ = send_response(&mut stream, "51 Not found\r\n").await;
}
}
}
Ok(Err(e)) => {
// Read failed, check error type
let request_str = String::from_utf8_lossy(&request_buf).trim().to_string();
let logger = RequestLogger::new(&stream, request_str);
match e.kind() {
tokio::io::ErrorKind::InvalidData => {
logger.log_error(59, "Request too large");
let _ = send_response(&mut stream, "59 Bad Request\r\n").await;
},
_ => {
logger.log_error(59, "Bad request");
let _ = send_response(&mut stream, "59 Bad Request\r\n").await;
}
}
ACTIVE_REQUESTS.fetch_sub(1, Ordering::Relaxed);
},
Err(_) => {
// Timeout
let request_str = String::from_utf8_lossy(&request_buf).trim().to_string();
let logger = RequestLogger::new(&stream, request_str);
logger.log_error(41, "Server unavailable");
let _ = send_response(&mut stream, "41 Server unavailable\r\n").await;
ACTIVE_REQUESTS.fetch_sub(1, Ordering::Relaxed);
return Ok(());
}
// 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(())
}
async fn send_response(
stream: &mut TlsStream<TcpStream>,
response: &str,
) -> io::Result<()> {
stream.write_all(response.as_bytes()).await?;
stream.flush().await?;
/// Send a complete response for `file_path`, streaming the body.
///
/// Exactly one status line is written, and the status used is returned so the
/// caller can log it. Errors after the header has been sent are reported to the
/// caller for logging only.
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(())
}
#[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::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 mut reader = BufReader::new(certfile);
rustls_pemfile::certs(&mut reader)?
.into_iter()
.map(|v| Ok(rustls::Certificate(v)))
.collect()
rustls_pemfile::certs(&mut reader).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 mut reader = BufReader::new(keyfile);
loop {
match rustls_pemfile::read_one(&mut reader)? {
Some(rustls_pemfile::Item::RSAKey(key)) => return Ok(rustls::PrivateKey(key)),
Some(rustls_pemfile::Item::PKCS8Key(key)) => return Ok(rustls::PrivateKey(key)),
Some(rustls_pemfile::Item::ECKey(key)) => return Ok(rustls::PrivateKey(key)),
None => break,
_ => {}
}
}
Err(io::Error::new(io::ErrorKind::InvalidData, "No supported private key found"))
// Accepts PKCS#8, PKCS#1 (RSA) and SEC1 (EC) keys.
rustls_pemfile::private_key(&mut reader)?.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
"no supported private key found in file",
)
})
}

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;
#[allow(dead_code)]
pub fn generate_test_certificates_for_host(temp_dir: &Path, hostname: &str) {
let cert_path = temp_dir.join(format!("{}.pem", hostname));
let key_path = temp_dir.join(format!("{}_key.pem", hostname));
// Generate self-signed certificate for testing
// This is a simplified version - in production, use proper certificates
std::fs::write(
&cert_path,
format!(
"-----BEGIN CERTIFICATE-----\nTest cert for {}\n-----END CERTIFICATE-----\n",
hostname
),
)
.unwrap();
std::fs::write(
&key_path,
format!(
"-----BEGIN PRIVATE KEY-----\nTest key for {}\n-----END PRIVATE KEY-----\n",
hostname
),
)
.unwrap();
}
use tempfile::TempDir;
pub fn setup_test_environment() -> TempDir {
@ -12,26 +37,47 @@ pub fn setup_test_environment() -> TempDir {
// Generate test certificates
generate_test_certificates(temp_dir.path());
// Verify certificates were created successfully
let cert_path = temp_dir.path().join("cert.pem");
let key_path = temp_dir.path().join("key.pem");
assert!(cert_path.exists(), "Certificate file was not created");
assert!(key_path.exists(), "Private key file was not created");
temp_dir
}
fn generate_test_certificates(temp_dir: &Path) {
use std::process::Command;
// Generate self-signed certificate for testing
let cert_path = temp_dir.join("cert.pem");
let key_path = temp_dir.join("key.pem");
let status = Command::new("openssl")
// Use openssl to generate a test certificate
let output = Command::new("openssl")
.args(&[
"req", "-x509", "-newkey", "rsa:2048",
"-keyout", &key_path.to_string_lossy(),
"-out", &cert_path.to_string_lossy(),
"-days", "1",
"req",
"-x509",
"-newkey",
"rsa:2048",
"-keyout",
&key_path.to_string_lossy(),
"-out",
&cert_path.to_string_lossy(),
"-days",
"1",
"-nodes",
"-subj", "/CN=localhost"
"-subj",
"/CN=localhost",
])
.status()
.unwrap();
.output();
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"))
.arg("--config")
.arg("nonexistent.toml")
.env("RUST_LOG", "error")
.output()
.unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8(output.stderr).unwrap();
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(stderr.contains("Config file 'nonexistent.toml' not found"));
assert!(stderr.contains("Create the config file with required fields"));
}
#[test]
fn test_missing_hostname() {
let temp_dir = common::setup_test_environment();
let config_path = temp_dir.path().join("config.toml");
let config_content = format!(r#"
root = "{}"
cert = "{}"
key = "{}"
bind_host = "127.0.0.1"
"#, temp_dir.path().join("content").display(), temp_dir.path().join("cert.pem").display(), temp_dir.path().join("key.pem").display());
std::fs::write(&config_path, config_content).unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_pollux"))
.arg("--config")
.arg(&config_path)
.output()
.unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(stderr.contains("'hostname' field is required"));
assert!(stderr.contains("Add: hostname = \"your.domain.com\""));
assert!(stdout.contains("Create the config file with"));
}
#[test]
fn test_nonexistent_root_directory() {
let temp_dir = common::setup_test_environment();
let config_path = temp_dir.path().join("config.toml");
let config_content = format!(r#"
let config_content = format!(
r#"
bind_host = "127.0.0.1"
["example.com"]
root = "/definitely/does/not/exist"
cert = "{}"
key = "{}"
hostname = "example.com"
bind_host = "127.0.0.1"
"#, temp_dir.path().join("cert.pem").display(), temp_dir.path().join("key.pem").display());
"#,
temp_dir.path().join("cert.pem").display(),
temp_dir.path().join("key.pem").display()
);
std::fs::write(&config_path, config_content).unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_pollux"))
.arg("--config")
.arg(&config_path)
.arg("--quiet")
.env("RUST_LOG", "error")
.output()
.unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(stderr.contains("Error: Root directory '/definitely/does/not/exist' does not exist"));
assert!(stderr.contains("Create the directory and add your Gemini files (.gmi, .txt, images)"));
assert!(stderr.contains("Failed to parse config file"));
assert!(stderr.contains(
"Error for host 'example.com': Root directory '/definitely/does/not/exist' does not exist"
));
}
#[test]
fn test_missing_certificate_file() {
let temp_dir = common::setup_test_environment();
let config_path = temp_dir.path().join("config.toml");
let config_content = format!(r#"
let config_content = format!(
r#"
bind_host = "127.0.0.1"
["example.com"]
root = "{}"
cert = "/nonexistent/cert.pem"
key = "{}"
hostname = "example.com"
bind_host = "127.0.0.1"
"#, temp_dir.path().join("content").display(), temp_dir.path().join("key.pem").display());
"#,
temp_dir.path().join("content").display(),
temp_dir.path().join("key.pem").display()
);
std::fs::write(&config_path, config_content).unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_pollux"))
.arg("--config")
.arg(&config_path)
.arg("--quiet")
.env("RUST_LOG", "error")
.output()
.unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(stderr.contains("Error: 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"));
}
#[test]
fn test_valid_config_startup() {
fn test_multiple_hosts_missing_certificate() {
let temp_dir = common::setup_test_environment();
let port = 1967 + (std::process::id() % 1000) as u16;
let config_path = temp_dir.path().join("config.toml");
let config_content = format!(r#"
// Create host directories
std::fs::create_dir(temp_dir.path().join("host1")).unwrap();
std::fs::create_dir(temp_dir.path().join("host2")).unwrap();
// Generate certificate for only host1
let cert1_path = temp_dir.path().join("host1_cert.pem");
let key1_path = temp_dir.path().join("host1_key.pem");
let cert_result = std::process::Command::new("openssl")
.args(&[
"req",
"-x509",
"-newkey",
"rsa:2048",
"-keyout",
&key1_path.to_string_lossy(),
"-out",
&cert1_path.to_string_lossy(),
"-days",
"1",
"-nodes",
"-subj",
"/CN=host1.com",
])
.output();
if cert_result.is_err() {
panic!("Failed to generate test certificate");
}
let config_content = format!(
r#"
bind_host = "127.0.0.1"
["host1.com"]
root = "{}"
cert = "{}"
key = "{}"
hostname = "localhost"
bind_host = "127.0.0.1"
port = {}
"#, temp_dir.path().join("content").display(), temp_dir.path().join("cert.pem").display(), temp_dir.path().join("key.pem").display(), port);
["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();
let mut server_process = Command::new(env!("CARGO_BIN_EXE_pollux"))
let output = Command::new(env!("CARGO_BIN_EXE_pollux"))
.arg("--config")
.arg(&config_path)
.spawn()
.arg("--quiet")
.env("RUST_LOG", "error")
.output()
.unwrap();
// Wait for server to start
std::thread::sleep(std::time::Duration::from_millis(500));
// Check server is still running (didn't exit with error)
assert!(server_process.try_wait().unwrap().is_none(), "Server should still be running with valid config");
// Kill server
server_process.kill().unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(stderr.contains(
"Error for host 'host2.com': Certificate file '/nonexistent/cert.pem' does not exist"
));
}

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
"""
import os
import sys
import socket
import ssl
@ -19,48 +20,71 @@ def main():
url = sys.argv[1]
# Parse URL (basic parsing)
if not url.startswith('gemini://'):
print("Error: URL must start with gemini://", file=sys.stderr)
sys.exit(1)
# Parse URL (basic parsing) - allow any protocol for testing
if url.startswith('gemini://'):
url_parts = url[9:].split('/', 1) # Remove gemini://
host_port = url_parts[0]
host = url_parts[0]
path = '/' + url_parts[1] if len(url_parts) > 1 else '/'
else:
# For non-gemini URLs, try to extract host anyway for testing
if '://' in url:
protocol, rest = url.split('://', 1)
url_parts = rest.split('/', 1)
host = url_parts[0]
path = '/' + url_parts[1] if len(url_parts) > 1 else '/'
else:
# No protocol, assume it's host/path
url_parts = url.split('/', 1)
host = url_parts[0]
path = '/' + url_parts[1] if len(url_parts) > 1 else '/'
if ':' in host_port:
host, port = host_port.rsplit(':', 1)
port = int(port)
else:
host = host_port
port = 1965
# 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:
# Create SSL connection
context = ssl.create_default_context()
# Create SSL connection with permissive settings for self-signed certs
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
# Load default certificates to avoid some SSL issues
context.load_default_certs()
sock = socket.create_connection((host, port), timeout=5.0)
ssl_sock = context.wrap_socket(sock, server_hostname=host)
sock = socket.create_connection((connect_host, port), timeout=5.0)
ssl_sock = context.wrap_socket(sock, server_hostname=sni_host)
# Send request
# Send request (full URL for Gemini protocol over TLS)
request = f"{url}\r\n"
ssl_sock.send(request.encode('utf-8'))
# Read response header
# Read full response (header + body)
response = b''
while b'\r\n' not in response and len(response) < 1024:
data = ssl_sock.recv(1)
while len(response) < 1024: # Read up to 1KB for test responses
try:
data = ssl_sock.recv(1024)
if not data:
break
response += data
except:
break
ssl_sock.close()
if response:
status_line = response.decode('utf-8', errors='ignore').split('\r\n')[0]
print(status_line)
# Decode and return the full response
full_response = response.decode('utf-8', errors='ignore')
print(full_response.strip())
else:
print("Error: No response")

View file

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

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();
}