diff --git a/Cargo.toml b/Cargo.toml index 8633826..dd4f9e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "irondrop" -version = "2.6.4" +version = "2.6.5" edition = "2024" license = "MIT" description = "Drop files, not dependencies - a well tested fully featured & battle-ready server in a single Rust binary with support for indexing through 10M files." @@ -11,8 +11,10 @@ readme = "README.md" clap = { version = "4.6", features = ["derive"] } glob = "0.3.3" log = "0.4.29" -env_logger = "0.11.9" +env_logger = "0.11.10" base64 = "0.22.1" +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "logging", "tls12"] } +rustls-pemfile = "2" [dev-dependencies] @@ -20,6 +22,7 @@ reqwest = { version = "0.13", features = ["blocking", "json"] } serde_json = "1.0" tempfile = "3.27" threadpool = "1.8.1" +rcgen = "0.14" [profile.release] opt-level = 'z' # Optimize for size diff --git a/README.md b/README.md index a0be826..ac6cbb8 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ IronDrop focuses on predictable behavior, simplicity, and low overhead. Use it t - Search (standard and ultra-compact modes for large directories) - Monitoring dashboard at `/monitor` and a JSON endpoint (`/monitor?json=1`) - Basic security features: rate limiting, optional Basic Auth, path safety checks +- Native SSL/TLS support via `--ssl-cert` and `--ssl-key` (built-in HTTPS, no reverse proxy required) - Single binary; templates and assets are embedded - Pure standard library networking and file I/O (no external HTTP stack or async runtime) - Ultra-compact search index option for very large directory trees (tested up to ~10M entries) @@ -36,7 +37,7 @@ Designed to keep memory usage steady and to stream large files without buffering ## Security -Includes rate limiting, optional Basic Auth, basic input validation, and path traversal protection. See [RFC & OWASP Compliance](./doc/RFC_OWASP_COMPLIANCE.md) and [Security Fixes](./doc/SECURITY_FIXES.md) for details. +Includes native SSL/TLS (HTTPS), rate limiting, optional Basic Auth, basic input validation, and path traversal protection. See [RFC & OWASP Compliance](./doc/RFC_OWASP_COMPLIANCE.md) and [Security Fixes](./doc/SECURITY_FIXES.md) for details. ## πŸ“¦ Installation @@ -145,6 +146,46 @@ irondrop -d /path/to/media \ irondrop --config-file ./config/production.ini ``` +#### πŸ”’ HTTPS File Server +```bash +# Generate a self-signed certificate (for testing) +openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes -subj '/CN=localhost' + +# Serve files over HTTPS +irondrop -d ./files --ssl-cert cert.pem --ssl-key key.pem --listen 0.0.0.0 + +# HTTPS with authentication +irondrop -d ./files --ssl-cert cert.pem --ssl-key key.pem \ + --username admin --password secret --listen 0.0.0.0 +``` + +#### 🌐 Reverse Proxy (Nginx) +For production deployments, it is recommended to run IronDrop behind Nginx. + +**Root Domain Configuration:** +```nginx +location / { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + client_max_body_size 0; # Enable large uploads + proxy_buffering off; # Enable streaming +} +``` + +**Subpath Configuration (e.g., `/webstorage/`):** +```nginx +location /webstorage/ { + proxy_pass http://127.0.0.1:8080/; + proxy_redirect / /webstorage/; + sub_filter 'href="/"' 'href="/webstorage/"'; + sub_filter_once off; + # ... see deployment guide for full sub_filter list +} +``` + +See the [Deployment Guide](./doc/DEPLOYMENT.md#nginx-reverse-proxy-deployment) for full configuration examples and optimization settings. + ### πŸ› οΈ Configuration Options #### **Command Line Options** @@ -160,6 +201,8 @@ IronDrop offers extensive customization through command-line arguments: | `-a, --allowed-extensions` | Restrict file types | `-a "*.pdf,*.doc,*.zip"` | | `-t, --threads` | Worker threads (default: 8) | `-t 16` | | `--config-file` | Use INI configuration file | `--config-file prod.ini` | +| `--ssl-cert` | SSL certificate file (PEM) for HTTPS | `--ssl-cert cert.pem` | +| `--ssl-key` | SSL private key file (PEM) for HTTPS | `--ssl-key key.pem` | | `-v, --verbose` | Debug logging | `-v true` | #### **πŸ“„ Configuration File (Recommended for Production)** @@ -197,6 +240,7 @@ Once IronDrop is running, these endpoints are available: - Use authentication (`--username`/`--password`) when exposing to untrusted networks - Adjust `--threads` based on workload +- Use `--ssl-cert` and `--ssl-key` for native HTTPS without a reverse proxy ### ❓ Need Help? diff --git a/config/irondrop.ini b/config/irondrop.ini index 110f664..c2c49be 100644 --- a/config/irondrop.ini +++ b/config/irondrop.ini @@ -162,6 +162,31 @@ allowed_extensions = *.pdf,*.doc,*.zip,*.txt username = testuser password = testpass123 +# =============================================================================== +# πŸ”’ SSL/TLS CONFIGURATION +# =============================================================================== + +[ssl] +# πŸ” Native HTTPS Support - Serve files over encrypted connections +# β€’ Requires both cert and key to be specified +# β€’ Supports PEM format certificates and private keys +# β€’ Uses TLS 1.2 and TLS 1.3 (via rustls - pure Rust TLS) +# β€’ No OpenSSL runtime dependency required +# +# πŸ”§ How to generate a self-signed certificate (for testing): +# openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem \ +# -days 365 -nodes -subj '/CN=localhost' +# +# 🏒 For production, use certificates from a trusted CA (Let's Encrypt, etc.) +# +# ⚠️ Both cert and key must be provided together. Omit both for plain HTTP. + +# SSL Certificate - Path to PEM certificate file +# cert = /etc/irondrop/cert.pem + +# SSL Private Key - Path to PEM private key file +# key = /etc/irondrop/key.pem + # =============================================================================== # πŸ“ LOGGING CONFIGURATION # =============================================================================== @@ -230,6 +255,10 @@ detailed = true # port = 443 # directory = /var/company-files # threads = 16 +# +# [ssl] +# cert = /etc/letsencrypt/live/files.example.com/fullchain.pem +# key = /etc/letsencrypt/live/files.example.com/privkey.pem # # [upload] # enable_upload = true @@ -273,6 +302,7 @@ detailed = true # βœ… 5. Enable uploads if needed (set enable_upload = true) # βœ… 6. Add authentication for network access (set username/password) # βœ… 7. Configure allowed file extensions for security +# βœ… 7b. (Optional) Add SSL cert and key for HTTPS # βœ… 8. Run: irondrop --config-file my-config.ini # βœ… 9. Open browser: http://localhost:8080 (or your chosen port) # βœ… 10. Enjoy blazing-fast file sharing! πŸš€ diff --git a/doc/API_REFERENCE.md b/doc/API_REFERENCE.md index d1a54c4..96ac2d5 100644 --- a/doc/API_REFERENCE.md +++ b/doc/API_REFERENCE.md @@ -1,4 +1,4 @@ -# IronDrop API Reference v2.6.4 +# IronDrop API Reference v2.6.5 ## Overview @@ -31,7 +31,7 @@ User-Agent: #### Response Headers ```http # Standard headers -Server: IronDrop/2.6 +Server: IronDrop/2.6.5 Content-Type: Content-Length: Connection: keep-alive @@ -193,9 +193,9 @@ Content-Type: text/html; charset=utf-8 #### `POST /_irondrop/upload` Uploads files using direct binary streaming for optimal performance and unlimited file size support. -**Direct Upload Features (v2.6.4):** +**Direct Upload Features (v2.6.5):** - **Direct Binary Streaming**: No multipart parsing overhead -- **Automatic Mode Selection**: Small uploads (≀2MB) processed in memory, large uploads (>2MB) streamed to disk +- **Automatic Mode Selection**: Small uploads (≀64MB) processed in memory, large uploads (>64MB) streamed to disk - **Constant Memory Usage**: ~7MB RAM usage regardless of file size - **Unlimited File Sizes**: No artificial size restrictions - **Atomic Operations**: Complete uploads or clean failure with automatic cleanup @@ -211,8 +211,8 @@ X-Filename: document.pdf ``` **Processing Modes:** -- **Memory Mode** (≀2MB): Direct processing in memory for minimal latency -- **Streaming Mode** (>2MB): Direct streaming to disk with constant ~7MB memory usage +- **Memory Mode** (≀64MB): Direct processing in memory for minimal latency +- **Streaming Mode** (>64MB): Direct streaming to disk with constant ~7MB memory usage **Success Response (JSON):** ```json @@ -292,7 +292,7 @@ Content-Type: text/html ### 4. Search API -#### `GET /api/search` +#### `GET /_irondrop/search` Searches for files and directories within the served directory tree. **Query Parameters:** @@ -304,10 +304,10 @@ Searches for files and directories within the served directory tree. **Examples:** ```http -GET /api/search?q=document -GET /api/search?q=report&limit=20&offset=10 -GET /api/search?q=Config&case_sensitive=true -GET /api/search?q=readme&path=/docs +GET /_irondrop/search?q=document +GET /_irondrop/search?q=report&limit=20&offset=10 +GET /_irondrop/search?q=Config&case_sensitive=true +GET /_irondrop/search?q=readme&path=/docs ``` **Success Response:** @@ -413,20 +413,20 @@ Static asset not found ### 6. Health and Monitoring -#### `GET /_health` +#### `GET /_irondrop/health` Basic health check endpoint. **Response:** ```json { "status": "healthy", - "version": "2.6", + "version": "2.6.5", "uptime_seconds": 3600, "timestamp": "2024-01-01T12:00:00Z" } ``` -#### `GET /_status` +#### `GET /_irondrop/status` Detailed server status and statistics. **Response:** @@ -472,7 +472,7 @@ Content-Type: text/html; charset=utf-8 ``` -#### `GET /_irondrop/monitor?json=1` +#### `GET /_irondrop/_irondrop/monitor?json=1` Machine-readable JSON stats for integration with external monitoring / scripting. **Response (JSON):** @@ -539,12 +539,12 @@ API information and capabilities. }, "health_check": { "method": "GET", - "path": "/_health", + "path": "/_irondrop/health", "description": "Basic health check" }, "status": { "method": "GET", - "path": "/_status", + "path": "/_irondrop/status", "description": "Detailed server status" } }, @@ -709,7 +709,7 @@ if (result.status === 'success') { **Search Files:** ```javascript // Search for files -const searchResponse = await fetch('/api/search?q=document&limit=10'); +const searchResponse = await fetch('/_irondrop/search?q=document&limit=10'); const searchData = await searchResponse.json(); if (searchData.status === 'success') { @@ -723,7 +723,7 @@ if (searchData.status === 'success') { **Health Check:** ```javascript // Monitor server health -const health = await fetch('/_health').then(r => r.json()); +const health = await fetch('/_irondrop/health').then(r => r.json()); console.log(`Server uptime: ${health.uptime_seconds}s`); ``` @@ -746,12 +746,12 @@ curl "http://localhost:8080/directory" -H "Accept: application/json" | jq . **Search files:** ```bash -curl "http://localhost:8080/api/search?q=document&limit=5" | jq . +curl "http://localhost:8080/_irondrop/search?q=document&limit=5" | jq . ``` **Health check:** ```bash -curl http://localhost:8080/_health +curl http://localhost:8080/_irondrop/health ``` **With authentication:** @@ -794,7 +794,7 @@ if response.status_code == 200: ```python import requests -response = requests.get('http://localhost:8080/api/search', +response = requests.get('http://localhost:8080/_irondrop/search', params={'q': 'document', 'limit': 10}) data = response.json() @@ -827,4 +827,4 @@ All inputs are validated: - File names for path traversal attempts - HTTP headers for malformed content -This API reference covers all functionality available in IronDrop v2.6.4 and provides comprehensive examples for client integration. \ No newline at end of file +This API reference covers all functionality available in IronDrop v2.6.5 and provides comprehensive examples for client integration. \ No newline at end of file diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md index c1ee068..683a336 100644 --- a/doc/ARCHITECTURE.md +++ b/doc/ARCHITECTURE.md @@ -1,4 +1,4 @@ -# IronDrop Architecture Documentation v2.6.4 +# IronDrop Architecture Documentation v2.6.5 ## Overview @@ -99,7 +99,7 @@ IronDrop is a file server written in Rust. It uses only the standard library for β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β–Ό β–Ό β–Ό β–Ό β–Ό β–Ό [Static Assets] [Health] [Upload API] [File Sys] [Search API] [Monitor] - /_irondrop/ /_health /_irondrop/ Directory /_irondrop/ /monitor + /_irondrop/ /_irondrop/health /_irondrop/ Directory /_irondrop/ /monitor /static/* /upload Listing /search β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β–Ό β–Ό β–Ό β–Ό β–Ό β–Ό @@ -166,7 +166,7 @@ templates/ └── script.js # Error page enhancements tests/ -β”œβ”€β”€ comprehensive_test.rs # Core server tests (19 tests) +β”œβ”€β”€ integration_test.rs # Core server tests (19 tests) β”œβ”€β”€ integration_test.rs # Auth + security tests (6 tests) β”œβ”€β”€ edge_case_test.rs # Upload edge cases (10 tests) β”œβ”€β”€ memory_optimization_test.rs # Memory efficiency (6 tests) @@ -236,7 +236,7 @@ Standard Entry (24 bytes): Ultra-Compact Entry (11 bytes): The search system integrates with the HTTP layer through dedicated endpoints: -- **`GET /api/search?q=query`**: Primary search interface +- **`GET /_irondrop/search?q=query`**: Primary search interface - **Frontend Integration**: Real-time search with 300ms debouncing - **Result Pagination**: Configurable limits and offsets - **JSON Response Format**: Structured results with metadata @@ -257,14 +257,14 @@ Request β†’ Cache Check β†’ Hit: Return Cached Results ## HTTP Layer Streaming Architecture ### Overview -IronDrop v2.6.4 provides advanced HTTP layer streaming for efficient handling of large file uploads. The system automatically switches between memory-based and disk-based processing based on content size, providing optimal performance and resource utilization. +IronDrop v2.6.5 provides advanced HTTP layer streaming for efficient handling of large file uploads. The system automatically switches between memory-based and disk-based processing based on content size, providing optimal performance and resource utilization. ### RequestBody Architecture ```rust pub enum RequestBody { - Memory(Vec), // Small uploads (≀1MB) - File(PathBuf), // Large uploads (>1MB) + Memory(Vec), // Small uploads (≀64MB) + File(PathBuf), // Large uploads (>64MB) } ``` @@ -281,7 +281,7 @@ The `RequestBody` enum provides a unified interface for handling HTTP request bo HTTP Request β†’ Content-Length Check β†’ Size Threshold Comparison β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ ≀1MB β”‚ >1MB β”‚ + β”‚ ≀64MB β”‚ >64MB β”‚ β–Ό β–Ό β–Ό Memory Processing Disk Streaming Disk Streaming β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” @@ -301,8 +301,8 @@ HTTP Request β†’ Content-Length Check β†’ Size Threshold Comparison | Upload Size | Processing Mode | Memory Usage | Disk I/O | Latency | |-------------|----------------|--------------|----------|---------| | <1KB | Memory | ~1KB | None | <1ms | -| 1KB-1MB | Memory | ~Size | None | <10ms | -| 1MB-100MB | Disk Streaming | ~64KB | Sequential| <100ms | +| 1KB-64MB | Memory | ~Size | None | <10ms | +| 64MB-100MB | Disk Streaming | ~64KB | Sequential| <100ms | | 100MB-1GB | Disk Streaming | ~64KB | Sequential| <1s | | 1GB-10GB | Disk Streaming | ~64KB | Sequential| <10s | @@ -356,7 +356,7 @@ The streaming system provides comprehensive monitoring capabilities: ```rust pub struct StreamingConfig { - pub memory_threshold: usize, // Default: 1MB + pub memory_threshold: usize, // Default: 64MB pub chunk_size: usize, // Default: 64KB pub temp_dir: Option, // Default: system temp pub max_concurrent: usize, // Default: 10 @@ -398,8 +398,8 @@ Dedicated HTTP streaming tests verify correct behavior: 4. **Audit and Monitoring Layer** - Comprehensive request logging with unique IDs - Performance metrics collection and statistics - - Health check endpoints (`/_health`, `/_status`) - - Unified monitoring dashboard (`/monitor`, `/monitor?json=1`) + - Health check endpoints (`/_irondrop/health`, `/_irondrop/status`) + - Unified monitoring dashboard (`/monitor`, `/_irondrop/monitor?json=1`) - Error tracking and security event logging ### Security Features by Component @@ -419,8 +419,8 @@ Dedicated HTTP streaming tests verify correct behavior: - **Baseline**: ~3MB + (thread_count Γ— 8KB stack) - **Template Cache**: In-memory storage for frequently accessed templates - **Upload Buffer**: HTTP streaming with automatic memory/disk switching -- **Small Uploads (≀1MB)**: Direct memory processing for optimal performance -- **Large Uploads (>1MB)**: Disk streaming with ~64KB memory footprint +- **Small Uploads (≀64MB)**: Direct memory processing for optimal performance +- **Large Uploads (>64MB)**: Disk streaming with ~64KB memory footprint - **File Operations**: Configurable chunk sizes (default: 1KB) ### Concurrent Processing @@ -481,7 +481,7 @@ Static Asset Request β†’ Asset Router β†’ Direct File Serving β†’ CSS/JS Respons ### Test Coverage by Component | Test File | Component Coverage | Test Count | |-----------|-------------------|------------| -| `comprehensive_test.rs` | Core server functionality | 19 | +| `integration_test.rs` | Core server functionality | 19 | | `integration_test.rs` | Authentication and security | 6 | | `upload_integration_test.rs` | Upload system | 29 | | `multipart_test.rs` | Multipart parser | 7 | @@ -577,4 +577,4 @@ pub enum AppError { 4. **CDN Integration**: Edge caching and global distribution 5. **Database Caching**: Redis integration for session management -This architecture documentation reflects the current state of IronDrop v2.6.4 and serves as a foundation for understanding the system's design principles, implementation details, and operational characteristics. \ No newline at end of file +This architecture documentation reflects the current state of IronDrop v2.6.5 and serves as a foundation for understanding the system's design principles, implementation details, and operational characteristics. \ No newline at end of file diff --git a/doc/CONFIGURATION_SYSTEM.md b/doc/CONFIGURATION_SYSTEM.md index d261d15..0306426 100644 --- a/doc/CONFIGURATION_SYSTEM.md +++ b/doc/CONFIGURATION_SYSTEM.md @@ -1,4 +1,4 @@ -## IronDrop Configuration System (v2.6.4) +## IronDrop Configuration System (v2.6.5) ### Overview IronDrop 2.5 introduces a first‑class configuration system with hierarchical precedence and zero external dependencies. It complements (not replaces) the existing CLI flags, enabling reproducible deployments, easier automation, and environment portability. The system is intentionally simple: an internal INI parser (`src/config/ini_parser.rs`) plus a composition layer (`src/config/mod.rs`) that merges values from multiple sources. @@ -31,9 +31,11 @@ If none exist, startup proceeds with defaults + CLI overrides. | Flag | Description | |------|-------------| | `--config-file ` | Explicit path to an INI configuration file. Errors if not found. | +| `--ssl-cert ` | Path to PEM certificate file for HTTPS. Requires `--ssl-key`. | +| `--ssl-key ` | Path to PEM private key file for HTTPS. Requires `--ssl-cert`. | ### INI Format Features -* Sections (`[server]`, `[upload]`, `[auth]`, `[logging]`, `[security]`) +* Sections (`[server]`, `[upload]`, `[auth]`, `[logging]`, `[security]`, `[ssl]`) * Comments starting with `#` or `;` * Key = value pairs (whitespace tolerant) * Inline comments after values (`key = value # note`) @@ -64,6 +66,10 @@ allowed_extensions = *.zip,*.txt,*.pdf [logging] verbose = true # Enables debug logging detailed = false # Enables info‑level below verbose + +[ssl] +cert = /path/to/cert.pem # PEM certificate file (required for HTTPS) +key = /path/to/key.pem # PEM private key file (required for HTTPS) ``` ### Data Type Parsing diff --git a/doc/DEPLOYMENT.md b/doc/DEPLOYMENT.md index 3312990..2fd0fd6 100644 --- a/doc/DEPLOYMENT.md +++ b/doc/DEPLOYMENT.md @@ -1,4 +1,4 @@ -# IronDrop Deployment Guide v2.6.4 +# IronDrop Deployment Guide v2.6.5 ## Overview @@ -248,19 +248,100 @@ docker-compose logs -f irondrop docker-compose pull && docker-compose up -d ``` -## Reverse Proxy Configuration +## Native SSL/TLS (HTTPS) -### nginx Configuration +IronDrop has built-in TLS support using rustls (a pure Rust TLS implementation). This means you can serve files over HTTPS directly without a reverse proxy. -```nginx -# /etc/nginx/sites-available/irondrop -upstream irondrop_backend { - server 127.0.0.1:8080; - # For multiple instances: - # server 127.0.0.1:8081; - # server 127.0.0.1:8082; -} +### Quick Setup + +```bash +# Generate a self-signed certificate (testing only) +openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem \ + -days 365 -nodes -subj '/CN=localhost' + +# Start HTTPS server +irondrop -d /var/www/files --ssl-cert cert.pem --ssl-key key.pem --listen 0.0.0.0 +``` + +### Production Setup with Let's Encrypt + +```bash +# Install certbot and obtain certificate +sudo certbot certonly --standalone -d files.example.com + +# Start with Let's Encrypt certificates +irondrop -d /var/www/files \ + --ssl-cert /etc/letsencrypt/live/files.example.com/fullchain.pem \ + --ssl-key /etc/letsencrypt/live/files.example.com/privkey.pem \ + --listen 0.0.0.0 --port 443 +``` + +### INI Configuration + +```ini +[server] +listen = 0.0.0.0 +port = 443 + +[ssl] +cert = /etc/letsencrypt/live/files.example.com/fullchain.pem +key = /etc/letsencrypt/live/files.example.com/privkey.pem +``` + +### systemd Service with HTTPS + +```ini +[Unit] +Description=IronDrop HTTPS File Server +After=network.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/irondrop -d /var/www/files \ + --ssl-cert /etc/letsencrypt/live/files.example.com/fullchain.pem \ + --ssl-key /etc/letsencrypt/live/files.example.com/privkey.pem \ + --listen 0.0.0.0 --port 443 +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +### TLS Details + +- **Protocol versions**: TLS 1.2 and TLS 1.3 +- **Implementation**: rustls (pure Rust, no OpenSSL dependency) +- **Certificate format**: PEM (both certificate and private key) +- **Certificate chains**: Supported (include full chain in cert file) +- **Performance**: TLS handshake runs on thread pool workers alongside request handling + +### Native TLS vs Reverse Proxy + +| Feature | Native TLS | Reverse Proxy (nginx) | +|---------|-----------|----------------------| +| Setup complexity | Simple (2 flags) | More configuration | +| HTTP/2 | Not supported | Supported | +| Load balancing | Single instance | Multiple backends | +| Certificate management | Manual or certbot | Manual or certbot | +| Additional dependencies | None | nginx/Apache | +| Best for | Simple deployments | High-traffic production | + +For simple deployments, native TLS is sufficient. For high-traffic production with HTTP/2, load balancing, or caching, a reverse proxy is recommended. +## Reverse Proxy Configuration (Optional) + +> **Note:** IronDrop now supports native HTTPS via `--ssl-cert` and `--ssl-key`. A reverse proxy is only needed for advanced features like HTTP/2, load balancing, or caching. See [Native SSL/TLS](#native-ssltls-https) above. + +### Nginx Reverse Proxy Deployment + +Using Nginx as a reverse proxy is recommended for production environments to handle SSL termination, load balancing, and complex path configurations. + +#### 1. Root Domain Deployment +Use this configuration to host IronDrop directly at a domain or subdomain (e.g., `https://files.example.com/`). + +**Nginx Configuration:** +```nginx server { listen 80; server_name files.example.com; @@ -271,65 +352,99 @@ server { listen 443 ssl http2; server_name files.example.com; - # SSL configuration - ssl_certificate /path/to/cert.pem; - ssl_certificate_key /path/to/key.pem; - ssl_protocols TLSv1.2 TLSv1.3; - ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; - ssl_prefer_server_ciphers off; - - # Security headers - add_header X-Frame-Options DENY always; - add_header X-Content-Type-Options nosniff always; - add_header X-XSS-Protection "1; mode=block" always; - add_header Strict-Transport-Security "max-age=63072000" always; - - # Upload size limit - client_max_body_size 10G; - client_body_timeout 300s; - - # Compression - gzip on; - gzip_vary on; - gzip_min_length 1024; - gzip_types text/plain text/css application/javascript application/json; + # SSL configuration (Certbot/Let's Encrypt recommended) + ssl_certificate /etc/letsencrypt/live/files.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/files.example.com/privkey.pem; location / { - proxy_pass http://irondrop_backend; + proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; - # Timeouts - proxy_connect_timeout 30s; - proxy_send_timeout 300s; - proxy_read_timeout 300s; - - # Buffer settings for large uploads + # Performance optimizations for large files + client_max_body_size 0; # Disable limit for large uploads + proxy_read_timeout 1d; + proxy_send_timeout 1d; + proxy_connect_timeout 1d; + send_timeout 1d; + proxy_max_temp_file_size 0; + + # Disable buffering for streaming proxy_buffering off; proxy_request_buffering off; } +} +``` + +#### 2. Subpath Deployment +Use this configuration to host IronDrop on a custom subpath (e.g., `https://example.com/webstorage/`). This requires path rewriting and HTML link filtering. - # Health check endpoint - location /_health { - proxy_pass http://irondrop_backend; - access_log off; +**Nginx Configuration:** +```nginx +server { + listen 443 ssl http2; + server_name example.com; + + # 1. Main Application Subpath + location /webstorage/ { + proxy_pass http://127.0.0.1:8080/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Rewrite app redirects and HTML links for subpath compatibility + proxy_redirect / /webstorage/; + sub_filter 'href="/"' 'href="/webstorage/"'; + sub_filter 'href="/monitor"' 'href="/webstorage/monitor"'; + sub_filter 'href="/search"' 'href="/webstorage/search"'; + sub_filter 'href="/upload"' 'href="/webstorage/upload"'; + sub_filter 'href="/_irondrop/' 'href="/webstorage/_irondrop/'; + sub_filter_once off; + + # Large file transfer settings + client_max_body_size 0; + proxy_read_timeout 1d; + proxy_send_timeout 1d; + proxy_connect_timeout 1d; + proxy_max_temp_file_size 0; + send_timeout 1d; + proxy_buffering off; } - # Rate limiting - limit_req_zone $binary_remote_addr zone=uploads:10m rate=10r/m; - location /upload { - limit_req zone=uploads burst=5 nodelay; - proxy_pass http://irondrop_backend; + # 2. Internal Asset Handling + location /_irondrop/ { + proxy_pass http://127.0.0.1:8080/_irondrop/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; - # Extended timeouts for uploads - proxy_send_timeout 600s; - proxy_read_timeout 600s; + proxy_redirect / /webstorage/; + sub_filter 'href="/"' 'href="/webstorage/"'; + sub_filter 'href="/monitor"' 'href="/webstorage/monitor"'; + sub_filter 'href="/upload"' 'href="/webstorage/upload"'; + sub_filter_once off; + + client_max_body_size 0; + proxy_read_timeout 1d; + send_timeout 1d; } + + # 3. Convenience Redirects + location = /webstorage { return 301 /webstorage/; } } ``` +#### Deployment Steps +1. **Save Configuration:** Save the block to `/etc/nginx/sites-available/irondrop`. +2. **Enable Site:** `sudo ln -s /etc/nginx/sites-available/irondrop /etc/nginx/sites-enabled/` (if using Debian/Ubuntu). +3. **Customize Path:** If using the subpath config, replace all instances of `/webstorage/` with your desired subpath. +4. **Test Syntax:** `sudo nginx -t` +5. **Reload Nginx:** `sudo systemctl reload nginx` + ### Apache Configuration ```apache @@ -674,4 +789,4 @@ perf record -g irondrop -d /srv/files strace -p $(pgrep irondrop) ``` -This deployment guide provides comprehensive coverage of production deployment scenarios and operational best practices for IronDrop v2.6.4. \ No newline at end of file +This deployment guide provides comprehensive coverage of production deployment scenarios and operational best practices for IronDrop v2.6.5. \ No newline at end of file diff --git a/doc/HTTP_STREAMING.md b/doc/HTTP_STREAMING.md index b23c902..d7c7dc1 100644 --- a/doc/HTTP_STREAMING.md +++ b/doc/HTTP_STREAMING.md @@ -1,10 +1,10 @@ -# IronDrop Direct Upload Streaming (v2.6.4) +# IronDrop Direct Upload Streaming (v2.6.5) ## Overview IronDrop implements direct streaming uploads. Large request bodies are streamed to disk, avoiding unbounded memory growth. Small bodies are processed in memory. -**Status**: Production-ready (v2.6.4) +**Status**: Production-ready (v2.6.5) - Direct streaming implementation with bounded memory usage - Handling from small to very large files - Tests cover stability and cleanup @@ -13,8 +13,8 @@ IronDrop implements direct streaming uploads. Large request bodies are streamed ## Key Features ### Direct streaming logic -- **Small uploads** (≀2MB): Processed in memory for optimal performance -- **Large uploads** (>2MB): Directly streamed to disk with constant memory usage +- **Small uploads** (≀64MB): Processed in memory for optimal performance +- **Large uploads** (>64MB): Directly streamed to disk with constant memory usage - **No size limits**: Removed artificial 10GB restrictions - **Constant memory**: RAM usage stays at ~7MB regardless of file size @@ -182,7 +182,7 @@ fn test_small_body_memory_storage() { #[test] fn test_large_body_disk_streaming() { // Verifies large uploads are streamed to disk - let large_body = "a".repeat(2 * 1024 * 1024); // 2MB + let large_body = "a".repeat(2 * 1024 * 1024); // 64MB // ... test implementation } ``` @@ -201,7 +201,7 @@ fn test_large_body_disk_streaming() { ```bash # Optional: Override default streaming threshold -export IRONDROP_STREAMING_THRESHOLD=2097152 # 2MB +export IRONDROP_STREAMING_THRESHOLD=2097152 # 64MB ``` ### CLI Configuration @@ -310,7 +310,7 @@ The streaming system integrates with IronDrop's monitoring: ## Version History -- **v2.6.4**: Direct streaming implementation with unlimited file size support +- **v2.6.5**: Direct streaming implementation with unlimited file size support - Automatic memory/disk switching based on content size - `RequestBody` enum with `Memory` and `File` variants - Comprehensive test coverage with dedicated streaming tests diff --git a/doc/MONITORING.md b/doc/MONITORING.md index ae8c71d..06b8072 100644 --- a/doc/MONITORING.md +++ b/doc/MONITORING.md @@ -1,4 +1,4 @@ -# IronDrop Monitoring Guide (v2.6.4) +# IronDrop Monitoring Guide (v2.6.5) This guide documents the built-in monitoring capabilities introduced with the `/monitor` endpoint and supporting health APIs. @@ -9,13 +9,13 @@ IronDrop exposes lightweight operational telemetry without external dependencies | Endpoint | Format | Purpose | |----------|--------|---------| | `/monitor` | HTML | Human dashboard for live stats | -| `/monitor?json=1` | JSON | Machine-readable metrics for scripting / scraping | +| `/_irondrop/monitor?json=1` | JSON | Machine-readable metrics for scripting / scraping | | `/_health` | JSON | Minimal liveness probe (OK / version / uptime) | | `/_status` | JSON | Extended status (configuration + cumulative counters) | ## Data Model -`/monitor?json=1` returns three top-level sections: +`/_irondrop/monitor?json=1` returns three top-level sections: ```json { @@ -64,7 +64,7 @@ The `/monitor` HTML view is an embedded template with: ### Quick CLI Scrape ```bash -curl -s http://localhost:8080/monitor?json=1 | jq '.requests.bytes_served' +curl -s http://localhost:8080/_irondrop/monitor?json=1 | jq '.requests.bytes_served' ``` ### Basic Health Probe (Kubernetes / Docker) @@ -74,7 +74,7 @@ curl -f http://localhost:8080/_health > /dev/null || echo "Unhealthy" ### Shell Alert When Upload Failures Detected ```bash -if [ "$(curl -s http://localhost:8080/monitor?json=1 | jq '.uploads.failed_uploads')" -gt 0 ]; then +if [ "$(curl -s http://localhost:8080/_irondrop/monitor?json=1 | jq '.uploads.failed_uploads')" -gt 0 ]; then echo "Upload failures detected" >&2 fi ``` @@ -83,7 +83,7 @@ fi ```bash prev=0 while sleep 60; do - cur=$(curl -s http://localhost:8080/monitor?json=1 | jq '.requests.total') + cur=$(curl -s http://localhost:8080/_irondrop/monitor?json=1 | jq '.requests.total') echo "RPM=$((cur-prev))" prev=$cur done @@ -125,4 +125,4 @@ done Monitoring schema may evolve with additive fields. Consumers should ignore unknown keys. Breaking changes (renames/removals) will bump minor version >= 2.x. --- -*Monitoring Guide for IronDrop v2.6.4* +*Monitoring Guide for IronDrop v2.6.5* diff --git a/doc/MULTIPART_README.md b/doc/MULTIPART_README.md index ac84179..a06f92e 100644 --- a/doc/MULTIPART_README.md +++ b/doc/MULTIPART_README.md @@ -1,10 +1,10 @@ -# IronDrop Direct Upload System v2.6.4 +# IronDrop Direct Upload System v2.6.5 This document describes the simplified direct upload system that replaced the multipart parser in IronDrop. ## Overview -IronDrop replaces legacy multipart parsing with a direct binary upload system focused on predictable memory use and simpler processing. The system handles raw binary uploads with bounded memory. (v2.6.4) +IronDrop replaces legacy multipart parsing with a direct binary upload system focused on predictable memory use and simpler processing. The system handles raw binary uploads with bounded memory. (v2.6.5) **Current Status**: Production-ready with direct streaming implementation and comprehensive test coverage (verified memory stability across all file sizes). diff --git a/doc/README.md b/doc/README.md index ad5c7d9..81a987d 100644 --- a/doc/README.md +++ b/doc/README.md @@ -99,7 +99,7 @@ Native zero-dependency template engine: variables, conditionals, embedded assets - **Streaming Tests**: HTTP layer streaming validation and large file bash integration tests - **Test Infrastructure**: Helper functions, data management, execution procedures -**Implementation Status**: βœ… **Production Ready** (v2.6.4) +**Implementation Status**: βœ… **Production Ready** (v2.6.5) - **English-Only Testing**: All test messages and output standardized to English - **Comprehensive Coverage**: Edge cases, security scenarios, performance validation, and streaming functionality - **Memory Optimization Tests**: Ultra-compact search engine validation for 10M+ files @@ -119,7 +119,7 @@ Native zero-dependency template engine: variables, conditionals, embedded assets - Multi-file concurrent upload handling - Client-side validation and error handling -**Implementation Status**: βœ… **Production Ready** (v2.6.4) +**Implementation Status**: βœ… **Production Ready** (v2.6.5) - Complete upload system with 29 comprehensive tests - Professional UI matching IronDrop's design language - Integrated with template engine and security systems @@ -136,7 +136,7 @@ Native zero-dependency template engine: variables, conditionals, embedded assets - CLI configuration security enhancements - Defense-in-depth implementation details -**Security Status**: βœ… **Fully Implemented** (v2.6.4) +**Security Status**: βœ… **Fully Implemented** (v2.6.5) - Comprehensive input validation at multiple layers - System directory blacklisting and write permission checks - Direct streaming with unlimited file size support @@ -154,7 +154,7 @@ Native zero-dependency template engine: variables, conditionals, embedded assets - Configuration options and customization - Comprehensive API usage examples -**Implementation Status**: βœ… **Production Ready** (v2.6.4) +**Implementation Status**: βœ… **Production Ready** (v2.6.5) - RFC 7578 compliance with robust boundary detection and streaming support - Advanced streaming implementation for memory-efficient large file processing - 7+ dedicated test cases covering edge cases and streaming scenarios @@ -175,7 +175,7 @@ Native zero-dependency template engine: variables, conditionals, embedded assets - **Integration Guide**: Seamless integration with existing upload handlers - **Testing Framework**: Comprehensive test coverage with dedicated HTTP streaming tests -**Implementation Status**: βœ… **Production Ready** (v2.6.4) +**Implementation Status**: βœ… **Production Ready** (v2.6.5) - **Automatic Mode Selection**: ≀1MB in memory, >1MB streamed to disk - **Zero Configuration**: Works transparently with existing upload handlers - **Resource Protection**: Prevents memory exhaustion from large uploads @@ -211,7 +211,7 @@ Native zero-dependency template engine: variables, conditionals, embedded assets - Security implementation and access control - Configuration options and troubleshooting guide -**Implementation Status**: βœ… **Production Ready** (v2.6.4) +**Implementation Status**: βœ… **Production Ready** (v2.6.5) - **Standard Search Engine**: Thread-safe search with LRU caching (5-minute TTL) - **Ultra-Compact Search Engine**: Memory-optimized for massive directories (10M+ files) - **Automatic Mode Selection**: Transparent switching based on directory size @@ -222,7 +222,7 @@ Native zero-dependency template engine: variables, conditionals, embedded assets - Accessibility-compliant UI with keyboard navigation support - Performance testing and benchmarking infrastructure -**πŸŽ‰ NEW in v2.6**: Revolutionary direct streaming upload system with **unlimited file size support**, constant memory usage (~7MB), and simplified binary upload architecture. (v2.6.4) +**πŸŽ‰ NEW in v2.6**: Revolutionary direct streaming upload system with **unlimited file size support**, constant memory usage (~7MB), and simplified binary upload architecture. (v2.6.5) --- @@ -330,7 +330,7 @@ Open a browser at [http://127.0.0.1:8080](http://127.0.0.1:8080) and you will se --- -## πŸŽ‰ What's New in v2.6.4 +## πŸŽ‰ What's New in v2.6.5 ### πŸ“€ **Complete File Upload System** IronDrop v2.5 introduces a **production-ready file upload system** with enterprise-grade features: diff --git a/doc/SEARCH_FEATURE.md b/doc/SEARCH_FEATURE.md index d7b83f5..6c03164 100644 --- a/doc/SEARCH_FEATURE.md +++ b/doc/SEARCH_FEATURE.md @@ -172,7 +172,7 @@ This selection is transparent to the API and frontend - search behavior remains ## API Endpoints -### GET `/api/search?q={query}&limit={limit}&offset={offset}` +### GET `/_irondrop/search?q={query}&limit={limit}&offset={offset}` **Purpose**: Perform search query against the directory index using the optimal search engine diff --git a/doc/TEMPLATE_SYSTEM.md b/doc/TEMPLATE_SYSTEM.md index 8a5a7bc..118acee 100644 --- a/doc/TEMPLATE_SYSTEM.md +++ b/doc/TEMPLATE_SYSTEM.md @@ -1,6 +1,6 @@ -# IronDrop Template & UI System Documentation (v2.6.4) +# IronDrop Template & UI System Documentation (v2.6.5) -**Status**: Production ready (v2.6.4) +**Status**: Production ready (v2.6.5) **Audience**: Backend & Frontend Developers, UI/UX Engineers, Integrators @@ -340,6 +340,6 @@ let err_html = engine.render_error_page(404, "Not Found", get_error_description( --- -*This document is part of the IronDrop v2.6.4 documentation suite and will evolve with future template system enhancements.* +*This document is part of the IronDrop v2.6.5 documentation suite and will evolve with future template system enhancements.* Return to documentation index: [./README.md](./README.md) diff --git a/doc/TESTING_DOCUMENTATION.md b/doc/TESTING_DOCUMENTATION.md index cce4c93..9054374 100644 --- a/doc/TESTING_DOCUMENTATION.md +++ b/doc/TESTING_DOCUMENTATION.md @@ -1,6 +1,6 @@ # IronDrop Testing Documentation -Version 2.6.4 - Test Suite Overview +Version 2.6.5 - Test Suite Overview ## Overview @@ -390,7 +390,7 @@ fn test_new_feature() { - Code formatting validation - Documentation completeness -## Recent Improvements (v2.6.4) +## Recent Improvements (v2.6.5) ### Critical Fixes and Enhancements @@ -448,6 +448,6 @@ fn test_new_feature() { --- -*This document is part of the IronDrop v2.6.4 documentation suite. The test suite is continuously evolving to ensure comprehensive coverage and reliability.* +*This document is part of the IronDrop v2.6.5 documentation suite. The test suite is continuously evolving to ensure comprehensive coverage and reliability.* Return to documentation index: [./README.md](./README.md) \ No newline at end of file diff --git a/irondrop.log b/irondrop.log new file mode 100644 index 0000000..e69de29 diff --git a/src/cli.rs b/src/cli.rs index ef3a5c8..30efcdd 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -71,6 +71,14 @@ pub struct Cli { /// Log directory path - Directory where timestamped log files will be created. If not provided, logs go to stdout πŸ“ #[arg(long, value_parser = validate_log_dir)] pub log_dir: Option, + + /// Path to SSL/TLS certificate file (PEM format) for HTTPS support + #[arg(long, value_parser = validate_ssl_file)] + pub ssl_cert: Option, + + /// Path to SSL/TLS private key file (PEM format) for HTTPS support + #[arg(long, value_parser = validate_ssl_file)] + pub ssl_key: Option, } /// Validate upload size (minimum 1 MB, no upper limit for direct streaming) @@ -130,6 +138,13 @@ impl Cli { } } + // Validate SSL configuration consistency + if self.ssl_cert.is_some() != self.ssl_key.is_some() { + return Err(AppError::InvalidConfiguration( + "Both --ssl-cert and --ssl-key must be provided together for HTTPS".to_string(), + )); + } + // Validate main serving directory if !self.directory.exists() { return Err(AppError::DirectoryNotFound( @@ -204,6 +219,8 @@ mod tests { max_upload_size: Some(100), config_file: None, log_dir: None, + ssl_cert: None, + ssl_key: None, }; // Test conversion @@ -239,6 +256,8 @@ mod tests { max_upload_size: Some(100), config_file: None, log_dir: None, + ssl_cert: None, + ssl_key: None, }; assert!(cli.validate().is_ok()); @@ -293,3 +312,25 @@ fn validate_log_dir(s: &str) -> Result { )), } } + +/// Validate SSL certificate/key file path exists and is readable +fn validate_ssl_file(s: &str) -> Result { + if s.is_empty() { + return Err("SSL file path cannot be empty".to_string()); + } + + let path = PathBuf::from(s); + + if !path.exists() { + return Err(format!("SSL file does not exist: {}", path.display())); + } + + if !path.is_file() { + return Err(format!("SSL path is not a file: {}", path.display())); + } + + match std::fs::File::open(&path) { + Ok(_) => Ok(path), + Err(e) => Err(format!("Cannot read SSL file {}: {}", path.display(), e)), + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index c3c4ce8..2141523 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -31,6 +31,10 @@ pub struct Config { pub verbose: bool, pub detailed_logging: bool, pub log_dir: Option, + + // SSL settings + pub ssl_cert: Option, + pub ssl_key: Option, } impl Config { @@ -75,6 +79,8 @@ impl Config { verbose: Self::get_verbose(&ini, cli), detailed_logging: Self::get_detailed_logging(&ini, cli), log_dir: Self::get_log_dir(&ini, cli), + ssl_cert: Self::get_ssl_cert(&ini, cli), + ssl_key: Self::get_ssl_key(&ini, cli), }; log::debug!("Configuration loading completed successfully"); @@ -308,6 +314,20 @@ impl Config { ini.get_string("logging", "log_dir").map(PathBuf::from) } + fn get_ssl_cert(ini: &IniConfig, cli: &Cli) -> Option { + if let Some(ref cert) = cli.ssl_cert { + return Some(cert.clone()); + } + ini.get_string("ssl", "cert").map(PathBuf::from) + } + + fn get_ssl_key(ini: &IniConfig, cli: &Cli) -> Option { + if let Some(ref key) = cli.ssl_key { + return Some(key.clone()); + } + ini.get_string("ssl", "key").map(PathBuf::from) + } + /// Print configuration summary pub fn print_summary(&self) { log::info!("Configuration Summary:"); @@ -333,6 +353,13 @@ impl Config { log::info!(" Allowed Extensions: {:?}", self.allowed_extensions); log::info!(" Verbose Logging: {}", self.verbose); log::info!(" Detailed Logging: {}", self.detailed_logging); + if let (Some(cert), Some(key)) = (&self.ssl_cert, &self.ssl_key) { + log::info!(" SSL/TLS: Enabled"); + log::info!(" SSL Certificate: {}", cert.display()); + log::info!(" SSL Key: {}", key.display()); + } else { + log::info!(" SSL/TLS: Disabled (HTTP only)"); + } } } @@ -358,6 +385,8 @@ mod tests { max_upload_size: None, config_file: None, log_dir: None, + ssl_cert: None, + ssl_key: None, } } diff --git a/src/handlers.rs b/src/handlers.rs index cdd9d21..df46c4d 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -568,6 +568,8 @@ pub fn handle_file_request( verbose: cli.verbose.unwrap_or(false), detailed_logging: cli.detailed_logging.unwrap_or(false), log_dir: cli.log_dir.clone(), + ssl_cert: cli.ssl_cert.clone(), + ssl_key: cli.ssl_key.clone(), }); let html_content = generate_directory_listing(&full_path, &request.path, config.as_ref())?; diff --git a/src/http.rs b/src/http.rs index ac3cf8a..9652086 100644 --- a/src/http.rs +++ b/src/http.rs @@ -7,12 +7,14 @@ use crate::fs::FileDetails; use crate::response::create_error_response; use crate::router::Router; use log::{debug, error, info, trace, warn}; +use rustls; use std::collections::HashMap; use std::fs::File; use std::io::prelude::*; use std::net::TcpStream; use std::path::PathBuf; use std::sync::Arc; +use std::time::Duration; /// Maximum size for request body (10GB) to prevent memory exhaustion attacks const MAX_REQUEST_BODY_SIZE: usize = 10 * 1024 * 1024 * 1024; @@ -24,6 +26,56 @@ const MAX_HEADERS_SIZE: usize = 8 * 1024; /// This ensures total memory usage stays well below 128MB pub const STREAM_TO_DISK_THRESHOLD: usize = 64 * 1024 * 1024; +/// Abstraction over plain TCP and TLS-encrypted streams. +/// Allows the HTTP handling code to work transparently with both. +pub enum ClientStream { + Plain(TcpStream), + Tls(Box>), +} + +impl ClientStream { + /// Get the peer address of the underlying TCP connection + pub fn peer_addr(&self) -> std::io::Result { + match self { + ClientStream::Plain(s) => s.peer_addr(), + ClientStream::Tls(s) => s.sock.peer_addr(), + } + } + + /// Set the read timeout on the underlying TCP connection + pub fn set_read_timeout(&self, dur: Option) -> std::io::Result<()> { + match self { + ClientStream::Plain(s) => s.set_read_timeout(dur), + ClientStream::Tls(s) => s.sock.set_read_timeout(dur), + } + } +} + +impl std::io::Read for ClientStream { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + match self { + ClientStream::Plain(s) => s.read(buf), + ClientStream::Tls(s) => s.read(buf), + } + } +} + +impl std::io::Write for ClientStream { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + match self { + ClientStream::Plain(s) => s.write(buf), + ClientStream::Tls(s) => s.write(buf), + } + } + + fn flush(&mut self) -> std::io::Result<()> { + match self { + ClientStream::Plain(s) => s.flush(), + ClientStream::Tls(s) => s.flush(), + } + } +} + /// Represents a parsed incoming HTTP request. #[derive(Debug)] pub struct Request { @@ -85,7 +137,7 @@ impl Request { } /// Enhanced HTTP request parser with better performance and compliance - pub fn from_stream(stream: &mut TcpStream) -> Result { + pub fn from_stream(stream: &mut ClientStream) -> Result { trace!("Starting HTTP request parsing from stream"); // Set a reasonable timeout for reading requests stream.set_read_timeout(Some(std::time::Duration::from_secs(30)))?; @@ -192,7 +244,9 @@ impl Request { } /// Read HTTP headers efficiently in chunks and return remaining bytes from body - fn read_headers_with_remaining(stream: &mut TcpStream) -> Result<(String, Vec), AppError> { + fn read_headers_with_remaining( + stream: &mut ClientStream, + ) -> Result<(String, Vec), AppError> { let mut buffer = vec![0; MAX_HEADERS_SIZE]; let mut total_read = 0; @@ -266,7 +320,7 @@ impl Request { /// Read request body based on Content-Length header with security validations /// Large bodies are streamed to disk to prevent memory exhaustion fn read_request_body( - stream: &mut TcpStream, + stream: &mut ClientStream, headers: &HashMap, remaining_bytes: Vec, ) -> Result, AppError> { @@ -312,7 +366,7 @@ impl Request { /// Read small request body into memory fn read_body_to_memory( - stream: &mut TcpStream, + stream: &mut ClientStream, content_length: usize, remaining_bytes: Vec, ) -> Result, AppError> { @@ -366,7 +420,7 @@ impl Request { /// Read large request body directly to disk to prevent memory exhaustion fn read_body_to_disk( - stream: &mut TcpStream, + stream: &mut ClientStream, content_length: usize, remaining_bytes: Vec, ) -> Result<(PathBuf, u64), AppError> { @@ -505,7 +559,7 @@ impl Request { /// Top-level function to handle a client connection. #[allow(clippy::too_many_arguments)] pub fn handle_client( - mut stream: TcpStream, + mut stream: ClientStream, base_dir: &Arc, allowed_extensions: &Arc>, username: &Arc>, @@ -643,7 +697,7 @@ fn route_request( /// Sends a fully formed `Response` to the client with enhanced headers. fn send_response( - stream: &mut TcpStream, + stream: &mut ClientStream, response: Response, log_prefix: &str, ) -> Result { @@ -658,39 +712,48 @@ fn send_response( ); let mut response_str = format!( - "HTTP/1.1 {} {}\r\n", + "HTTP/1.1 {} {} +", response.status_code, response.status_text ); // Add standard server headers first - response_str.push_str(&format!("Server: irondrop/{}\r\n", crate::VERSION)); - response_str.push_str("Connection: close\r\n"); + response_str.push_str(&format!( + "Server: irondrop/{} +", + crate::VERSION + )); + response_str.push_str( + "Connection: close +", + ); // Add response-specific headers - for (key, value) in response.headers { + for (key, value) in &response.headers { trace!("{} Response header: {}: {}", log_prefix, key, value); - response_str.push_str(&format!("{key}: {value}\r\n")); + response_str.push_str(&format!( + "{key}: {value} +" + )); } - // Calculate and add content length for text and binary responses without copying - match &response.body { - ResponseBody::Text(text) => { - let bytes = text.as_bytes(); - response_str.push_str(&format!("Content-Length: {}\r\n", bytes.len())); - } - ResponseBody::StaticText(text) => { - let bytes = text.as_bytes(); - response_str.push_str(&format!("Content-Length: {}\r\n", bytes.len())); - } - ResponseBody::Binary(bytes) => { - response_str.push_str(&format!("Content-Length: {}\r\n", bytes.len())); - } - ResponseBody::StaticBinary(bytes) => { - response_str.push_str(&format!("Content-Length: {}\r\n", bytes.len())); - } - ResponseBody::Stream(file_details) => { - response_str.push_str(&format!("Content-Length: {}\r\n", file_details.size)); - } + // Add Content-Length header ONLY if it is not already present in response.headers + let has_content_length = response + .headers + .keys() + .any(|k| k.to_lowercase() == "content-length"); + if !has_content_length { + let length = match &response.body { + ResponseBody::Text(text) => text.len(), + ResponseBody::StaticText(text) => text.len(), + ResponseBody::Binary(bytes) => bytes.len(), + ResponseBody::StaticBinary(bytes) => bytes.len(), + ResponseBody::Stream(file_details) => file_details.size as usize, + }; + response_str.push_str(&format!( + "Content-Length: {length} +" + )); } response_str.push_str("\r\n"); @@ -774,7 +837,7 @@ fn send_response( } /// Sends a pre-canned error response using the new response system. -fn send_error_response(stream: &mut TcpStream, error: AppError, log_prefix: &str) { +fn send_error_response(stream: &mut ClientStream, error: AppError, log_prefix: &str) { let (status_code, status_text) = match error { AppError::NotFound => (404, "Not Found"), AppError::Forbidden => (403, "Forbidden"), diff --git a/src/response.rs b/src/response.rs index 9b2e548..23b5ea0 100644 --- a/src/response.rs +++ b/src/response.rs @@ -4,7 +4,6 @@ use crate::error::AppError; use crate::templates::{TemplateEngine, get_error_description}; use log::{debug, error, trace}; use std::io::prelude::*; -use std::net::TcpStream; use std::path::Path; /// Native MIME type detection for common file types @@ -104,7 +103,7 @@ impl HttpResponse { self } - pub fn send(self, stream: &mut TcpStream, log_prefix: &str) -> Result<(), AppError> { + pub fn send(self, stream: &mut impl Write, log_prefix: &str) -> Result<(), AppError> { debug!( "{} Sending HTTP response: {} {}", log_prefix, self.status_code, self.status_text @@ -172,7 +171,7 @@ pub fn create_error_response(status_code: u16, status_text: &str) -> HttpRespons /// Legacy function for compatibility - will be removed in refactor pub fn send_response( - stream: &mut TcpStream, + stream: &mut impl Write, status_code: u16, status_text: &str, body: &str, diff --git a/src/server.rs b/src/server.rs index fbdc529..91910c8 100644 --- a/src/server.rs +++ b/src/server.rs @@ -16,6 +16,9 @@ use std::sync::{Arc, Mutex, mpsc}; use std::thread; use std::time::{Duration, Instant}; +use rustls::ServerConfig; +use std::io::BufReader; + #[cfg(target_os = "linux")] use std::fs; #[cfg(any(target_os = "macos", target_os = "windows"))] @@ -1267,6 +1270,87 @@ mod threadpool_tests { } } +/// Load TLS certificates from a PEM file +fn load_tls_certs( + path: &std::path::Path, +) -> Result>, AppError> { + let file = std::fs::File::open(path).map_err(|e| { + AppError::InvalidConfiguration(format!( + "Failed to open SSL certificate file {}: {}", + path.display(), + e + )) + })?; + let mut reader = BufReader::new(file); + let certs: Vec> = rustls_pemfile::certs(&mut reader) + .collect::, _>>() + .map_err(|e| { + AppError::InvalidConfiguration(format!( + "Failed to parse SSL certificate file {}: {}", + path.display(), + e + )) + })?; + if certs.is_empty() { + return Err(AppError::InvalidConfiguration(format!( + "No certificates found in {}", + path.display() + ))); + } + info!( + "Loaded {} certificate(s) from {}", + certs.len(), + path.display() + ); + Ok(certs) +} + +/// Load TLS private key from a PEM file +fn load_tls_key( + path: &std::path::Path, +) -> Result, AppError> { + let file = std::fs::File::open(path).map_err(|e| { + AppError::InvalidConfiguration(format!( + "Failed to open SSL key file {}: {}", + path.display(), + e + )) + })?; + let mut reader = BufReader::new(file); + let key = rustls_pemfile::private_key(&mut reader) + .map_err(|e| { + AppError::InvalidConfiguration(format!( + "Failed to parse SSL key file {}: {}", + path.display(), + e + )) + })? + .ok_or_else(|| { + AppError::InvalidConfiguration(format!("No private key found in {}", path.display())) + })?; + info!("Loaded private key from {}", path.display()); + Ok(key) +} + +/// Build TLS server configuration from certificate and key paths +fn build_tls_config( + cert_path: &std::path::Path, + key_path: &std::path::Path, +) -> Result, AppError> { + let certs = load_tls_certs(cert_path)?; + let key = load_tls_key(key_path)?; + + let config = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(certs, key) + .map_err(|e| { + AppError::InvalidConfiguration(format!("Failed to build TLS configuration: {}", e)) + })?; + + info!("TLS configuration built successfully"); + Ok(Arc::new(config)) +} + /// Run server with new configuration system pub fn run_server_with_config(config: Config) -> Result<(), AppError> { // Convert Config back to Cli for compatibility with existing code @@ -1286,6 +1370,8 @@ pub fn run_server_with_config(config: Config) -> Result<(), AppError> { max_upload_size: Some(config.max_upload_size / (1024 * 1024)), // Convert bytes back to MB config_file: None, // Not needed for server execution log_dir: config.log_dir, + ssl_cert: config.ssl_cert, + ssl_key: config.ssl_key, }; run_server(cli, None, None) @@ -1335,6 +1421,20 @@ pub fn run_server( listener.set_nonblocking(true)?; debug!("Server bound successfully to: {}", local_addr); + // Set up TLS configuration if SSL cert and key are provided + let tls_config: Option> = + if let (Some(cert_path), Some(key_path)) = (&cli.ssl_cert, &cli.ssl_key) { + info!( + "πŸ”’ Setting up TLS with cert: {}, key: {}", + cert_path.display(), + key_path.display() + ); + Some(build_tls_config(cert_path, key_path)?) + } else { + None + }; + let is_https = tls_config.is_some(); + // Initialize security and monitoring systems debug!("Initializing rate limiter: 120 req/min, 10 concurrent per IP"); let rate_limiter = Arc::new(RateLimiter::new(120, 10)); // 120 req/min, 10 concurrent per IP @@ -1349,12 +1449,17 @@ pub fn run_server( )); } + let protocol = if is_https { "https" } else { "http" }; info!( - "πŸš€ Server listening on {} for directory '{}' (allowed extensions: {:?})", + "πŸš€ Server listening on {}://{} for directory '{}' (allowed extensions: {:?})", + protocol, local_addr, base_dir.display(), allowed_extensions ); + if is_https { + info!("πŸ”’ TLS/SSL: Enabled"); + } info!("⚑ Security: Rate limiting enabled (120 req/min, 10 concurrent per IP)"); info!("πŸ“Š Monitoring: Statistics collection enabled"); @@ -1491,6 +1596,7 @@ pub fn run_server( stats, cli_ref, router, + tls_config_clone, ) = ( base_dir.clone(), allowed_extensions.clone(), @@ -1501,6 +1607,7 @@ pub fn run_server( stats.clone(), cli_arc.clone(), shared_router.clone(), + tls_config.clone(), ); trace!("Submitting client {} to thread pool", client_ip); @@ -1508,8 +1615,25 @@ pub fn run_server( trace!("Thread pool worker starting for client: {}", client_ip); let start_time = Instant::now(); + // Wrap stream with TLS if configured + let client_stream = if let Some(ref tls_cfg) = tls_config_clone { + match rustls::ServerConnection::new(Arc::clone(tls_cfg)) { + Ok(conn) => { + let tls_stream = rustls::StreamOwned::new(conn, stream); + crate::http::ClientStream::Tls(Box::new(tls_stream)) + } + Err(e) => { + error!("TLS handshake setup failed for {}: {}", client_ip, e); + rate_limiter.release_connection(client_ip); + return; + } + } + } else { + crate::http::ClientStream::Plain(stream) + }; + let result = handle_client_with_stats( - stream, + client_stream, peer_addr, &base_dir, &allowed_extensions, @@ -1588,7 +1712,7 @@ pub fn run_server( /// Enhanced client handler with statistics tracking #[allow(clippy::too_many_arguments)] fn handle_client_with_stats( - stream: std::net::TcpStream, + stream: crate::http::ClientStream, peer_addr: SocketAddr, base_dir: &Arc, allowed_extensions: &Arc>, diff --git a/src/upload.rs b/src/upload.rs index c58f1c9..2b9e30d 100644 --- a/src/upload.rs +++ b/src/upload.rs @@ -1011,6 +1011,8 @@ mod tests { max_upload_size: Some(100), // 100MB for testing config_file: None, log_dir: None, + ssl_cert: None, + ssl_key: None, } } diff --git a/tests/config_test.rs b/tests/config_test.rs index 1483d68..d814e9e 100644 --- a/tests/config_test.rs +++ b/tests/config_test.rs @@ -180,6 +180,8 @@ verbose = false max_upload_size: Some(10240), config_file: Some(config_file.to_string_lossy().to_string()), log_dir: None, + ssl_cert: None, + ssl_key: None, }; let config = Config::load(&cli).expect("Failed to load config"); @@ -225,6 +227,8 @@ max_upload_size = 1GB max_upload_size: None, config_file: Some(explicit_config.to_string_lossy().to_string()), log_dir: None, + ssl_cert: None, + ssl_key: None, }; let config = Config::load(&cli).expect("Failed to load config"); @@ -254,6 +258,8 @@ fn test_config_defaults() { max_upload_size: Some(10240), config_file: None, log_dir: None, + ssl_cert: None, + ssl_key: None, }; let config = Config::load(&cli).expect("Failed to load config"); @@ -292,6 +298,8 @@ fn test_config_file_load_error() { max_upload_size: Some(10240), config_file: Some(nonexistent_config.to_string_lossy().to_string()), log_dir: None, + ssl_cert: None, + ssl_key: None, }; let result = Config::load(&cli); @@ -358,6 +366,8 @@ directory = {} max_upload_size: None, config_file: Some(config_file.to_string_lossy().to_string()), log_dir: None, + ssl_cert: None, + ssl_key: None, }; let config = Config::load(&cli).expect("Failed to load config"); @@ -399,6 +409,8 @@ port = 9999 max_upload_size: None, config_file: Some(config_file.to_string_lossy().to_string()), log_dir: None, + ssl_cert: None, + ssl_key: None, }; let config = Config::load(&cli).expect("Failed to load config"); @@ -445,6 +457,8 @@ fn test_config_invalid_port_values() { max_upload_size: None, config_file: Some(config_file.to_string_lossy().to_string()), log_dir: None, + ssl_cert: None, + ssl_key: None, }; let result = Config::load(&cli); @@ -488,6 +502,8 @@ fn test_config_invalid_port_values() { max_upload_size: None, config_file: Some(config_file.to_string_lossy().to_string()), log_dir: None, + ssl_cert: None, + ssl_key: None, }; let result = Config::load(&cli); @@ -542,6 +558,8 @@ fn test_config_invalid_file_size_formats() { max_upload_size: None, config_file: Some(config_file.to_string_lossy().to_string()), log_dir: None, + ssl_cert: None, + ssl_key: None, }; let result = Config::load(&cli); @@ -616,6 +634,8 @@ fn test_config_boolean_edge_cases() { max_upload_size: None, config_file: Some(config_file.to_string_lossy().to_string()), log_dir: None, + ssl_cert: None, + ssl_key: None, }; let result = Config::load(&cli); @@ -662,6 +682,8 @@ fn test_config_malformed_ini_syntax() { max_upload_size: None, config_file: Some(config_file.to_string_lossy().to_string()), log_dir: None, + ssl_cert: None, + ssl_key: None, }; let result = Config::load(&cli); diff --git a/tests/direct_upload_test.rs b/tests/direct_upload_test.rs index c08dd4c..44992ff 100644 --- a/tests/direct_upload_test.rs +++ b/tests/direct_upload_test.rs @@ -24,6 +24,8 @@ fn create_test_cli(upload_dir: PathBuf) -> Cli { max_upload_size: Some(100), // 100MB config_file: None, log_dir: None, + ssl_cert: None, + ssl_key: None, } } diff --git a/tests/http_parser_test.rs b/tests/http_parser_test.rs index 72e3a45..28a7792 100644 --- a/tests/http_parser_test.rs +++ b/tests/http_parser_test.rs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT -use irondrop::http::Request; +use irondrop::http::{ClientStream, Request}; use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; use std::thread; @@ -18,8 +18,9 @@ fn serve_and_parse(request: &str) -> Result std::thread::sleep(std::time::Duration::from_millis(50)); }); - let mut client = TcpStream::connect(addr).unwrap(); - Request::from_stream(&mut client).map(|r| { + let client = TcpStream::connect(addr).unwrap(); + let mut client_stream = ClientStream::Plain(client); + Request::from_stream(&mut client_stream).map(|r| { handle.join().unwrap(); r }) diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 31800c2..2fd2315 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -47,6 +47,8 @@ fn setup_test_server(username: Option, password: Option) -> Test max_upload_size: Some(10240), config_file: None, log_dir: None, + ssl_cert: None, + ssl_key: None, }; let (shutdown_tx, shutdown_rx) = mpsc::channel(); @@ -446,6 +448,8 @@ where max_upload_size: Some(10240), config_file: None, log_dir: None, + ssl_cert: None, + ssl_key: None, }; let (shutdown_tx, shutdown_rx) = mpsc::channel(); diff --git a/tests/log_dir_test.rs b/tests/log_dir_test.rs index 03061f5..38aad5f 100644 --- a/tests/log_dir_test.rs +++ b/tests/log_dir_test.rs @@ -32,6 +32,8 @@ fn create_test_cli_with_log_dir(log_dir: Option) -> Cli { max_upload_size: None, config_file: None, log_dir, + ssl_cert: None, + ssl_key: None, } } diff --git a/tests/monitor_test.rs b/tests/monitor_test.rs index 4affd1e..6b7ccc0 100644 --- a/tests/monitor_test.rs +++ b/tests/monitor_test.rs @@ -44,6 +44,8 @@ fn setup_test_server() -> TestServer { max_upload_size: Some(10240), config_file: None, log_dir: None, + ssl_cert: None, + ssl_key: None, }; let (shutdown_tx, shutdown_rx) = mpsc::channel(); diff --git a/tests/ssl_test.rs b/tests/ssl_test.rs new file mode 100644 index 0000000..5f84abb --- /dev/null +++ b/tests/ssl_test.rs @@ -0,0 +1,462 @@ +// SPDX-License-Identifier: MIT +//! SSL/TLS integration tests for the file server. + +use irondrop::cli::Cli; +use irondrop::server::run_server; +use rcgen::generate_simple_self_signed; +use reqwest::StatusCode; +use reqwest::blocking::Client; +use std::fs::{self, File}; +use std::io::Write; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::mpsc; +use std::thread::{self, JoinHandle}; +use tempfile::{TempDir, tempdir}; + +/// A helper struct to manage a running test server with SSL support. +struct TestServer { + addr: SocketAddr, + shutdown_tx: mpsc::Sender<()>, + handle: Option>, + _temp_dir: TempDir, +} + +impl Drop for TestServer { + fn drop(&mut self) { + if let Some(handle) = self.handle.take() { + self.shutdown_tx.send(()).ok(); + handle.join().unwrap(); + } + } +} + +/// Ensure the rustls crypto provider is installed (idempotent). +fn install_crypto_provider() { + let _ = rustls::crypto::ring::default_provider().install_default(); +} + +/// Generate self-signed TLS certificates for testing. +fn generate_test_certs(dir: &std::path::Path) -> (PathBuf, PathBuf) { + let subject_alt_names = vec!["localhost".to_string(), "127.0.0.1".to_string()]; + let cert = generate_simple_self_signed(subject_alt_names).unwrap(); + + let cert_path = dir.join("cert.pem"); + let key_path = dir.join("key.pem"); + + fs::write(&cert_path, cert.cert.pem()).unwrap(); + fs::write(&key_path, cert.signing_key.serialize_pem()).unwrap(); + + (cert_path, key_path) +} + +/// Build a reqwest client that accepts self-signed certificates. +fn https_client() -> Client { + Client::builder() + .danger_accept_invalid_certs(true) + .build() + .unwrap() +} + +/// Start an HTTPS test server with optional authentication. +fn setup_ssl_server(username: Option, password: Option) -> TestServer { + install_crypto_provider(); + let dir = tempdir().unwrap(); + + // Create a test file for downloads. + let file_path = dir.path().join("test.txt"); + let mut file = File::create(&file_path).unwrap(); + writeln!(file, "hello from ssl test file").unwrap(); + + // Create a subdirectory with a file for directory listing tests. + let sub_dir = dir.path().join("subdir"); + fs::create_dir(&sub_dir).unwrap(); + let sub_file = sub_dir.join("nested.txt"); + let mut f = File::create(&sub_file).unwrap(); + writeln!(f, "nested content").unwrap(); + + let (cert_path, key_path) = generate_test_certs(dir.path()); + + let cli = Cli { + directory: dir.path().to_path_buf(), + listen: Some("127.0.0.1".to_string()), + port: Some(0), + allowed_extensions: Some("*".to_string()), + threads: Some(4), + chunk_size: Some(1024), + verbose: Some(false), + detailed_logging: Some(false), + username, + password, + enable_upload: Some(false), + max_upload_size: Some(10240), + config_file: None, + log_dir: None, + ssl_cert: Some(cert_path), + ssl_key: Some(key_path), + }; + + let (shutdown_tx, shutdown_rx) = mpsc::channel(); + let (addr_tx, addr_rx) = mpsc::channel(); + + let server_handle = thread::spawn(move || { + if let Err(e) = run_server(cli, Some(shutdown_rx), Some(addr_tx)) { + eprintln!("SSL server thread failed: {e}"); + } + }); + + let server_addr = addr_rx.recv().unwrap(); + + TestServer { + addr: server_addr, + shutdown_tx, + handle: Some(server_handle), + _temp_dir: dir, + } +} + +// --------------------------------------------------------------------------- +// Test 1: Basic HTTPS request returns 200 OK +// --------------------------------------------------------------------------- +#[test] +fn test_https_basic_request() { + let server = setup_ssl_server(None, None); + let client = https_client(); + + let res = client + .get(format!("https://{}/", server.addr)) + .send() + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); +} + +// --------------------------------------------------------------------------- +// Test 2: File download over HTTPS returns correct content +// --------------------------------------------------------------------------- +#[test] +fn test_https_file_download() { + let server = setup_ssl_server(None, None); + let client = https_client(); + + let res = client + .get(format!("https://{}/test.txt", server.addr)) + .send() + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(res.text().unwrap(), "hello from ssl test file\n"); +} + +// --------------------------------------------------------------------------- +// Test 3: Directory listing works over HTTPS +// --------------------------------------------------------------------------- +#[test] +fn test_https_directory_listing() { + let server = setup_ssl_server(None, None); + let client = https_client(); + + let res = client + .get(format!("https://{}/", server.addr)) + .send() + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let body = res.text().unwrap(); + assert!(body.contains("test.txt"), "listing should contain test.txt"); + assert!(body.contains("subdir"), "listing should contain subdir"); +} + +// --------------------------------------------------------------------------- +// Test 4: HTTPS with basic authentication +// --------------------------------------------------------------------------- +#[test] +fn test_https_with_authentication() { + let server = setup_ssl_server(Some("admin".to_string()), Some("secret".to_string())); + let client = https_client(); + + // Without credentials -> 401 Unauthorized + let res = client + .get(format!("https://{}/", server.addr)) + .send() + .unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + assert!(res.headers().contains_key("www-authenticate")); + + // With correct credentials -> 200 OK + let res = client + .get(format!("https://{}/", server.addr)) + .basic_auth("admin", Some("secret")) + .send() + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let body = res.text().unwrap(); + assert!(body.contains("test.txt")); +} + +// --------------------------------------------------------------------------- +// Test 5: Health endpoint works over HTTPS +// --------------------------------------------------------------------------- +#[test] +fn test_https_health_endpoint() { + let server = setup_ssl_server(None, None); + let client = https_client(); + + let res = client + .get(format!("https://{}/_irondrop/health", server.addr)) + .send() + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); +} + +// --------------------------------------------------------------------------- +// Test 5b: Monitor endpoint works over HTTPS (HTML and JSON) +// --------------------------------------------------------------------------- +#[test] +fn test_https_monitor_endpoint() { + let server = setup_ssl_server(None, None); + let client = https_client(); + + // HTML monitor page + let res = client + .get(format!("https://{}/_irondrop/monitor", server.addr)) + .send() + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let body = res.text().unwrap(); + assert!( + body.contains("html") || body.contains("HTML"), + "monitor should return an HTML page" + ); + + // JSON monitor endpoint + let res = client + .get(format!("https://{}/_irondrop/monitor?json=1", server.addr)) + .send() + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let body = res.text().unwrap(); + assert!( + body.contains("total_requests") || body.contains("uptime"), + "JSON monitor should contain stats fields" + ); + + // Legacy /monitor path + let res = client + .get(format!("https://{}/monitor", server.addr)) + .send() + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); +} + +// --------------------------------------------------------------------------- +// Test 6: Server fails to start with non-existent cert file +// --------------------------------------------------------------------------- +#[test] +fn test_ssl_missing_cert_file() { + install_crypto_provider(); + let dir = tempdir().unwrap(); + + // Create a valid key but point cert to a non-existent file. + let (_, key_path) = generate_test_certs(dir.path()); + let bogus_cert = dir.path().join("nonexistent_cert.pem"); + + let cli = Cli { + directory: dir.path().to_path_buf(), + listen: Some("127.0.0.1".to_string()), + port: Some(0), + allowed_extensions: Some("*".to_string()), + threads: Some(4), + chunk_size: Some(1024), + verbose: Some(false), + detailed_logging: Some(false), + username: None, + password: None, + enable_upload: Some(false), + max_upload_size: Some(10240), + config_file: None, + log_dir: None, + ssl_cert: Some(bogus_cert), + ssl_key: Some(key_path), + }; + + let (shutdown_tx, shutdown_rx) = mpsc::channel(); + let result = run_server(cli, Some(shutdown_rx), None); + assert!( + result.is_err(), + "run_server should fail with missing cert file" + ); + drop(shutdown_tx); +} + +// --------------------------------------------------------------------------- +// Test 7: Server fails to start with non-existent key file +// --------------------------------------------------------------------------- +#[test] +fn test_ssl_missing_key_file() { + install_crypto_provider(); + let dir = tempdir().unwrap(); + + // Create a valid cert but point key to a non-existent file. + let (cert_path, _) = generate_test_certs(dir.path()); + let bogus_key = dir.path().join("nonexistent_key.pem"); + + let cli = Cli { + directory: dir.path().to_path_buf(), + listen: Some("127.0.0.1".to_string()), + port: Some(0), + allowed_extensions: Some("*".to_string()), + threads: Some(4), + chunk_size: Some(1024), + verbose: Some(false), + detailed_logging: Some(false), + username: None, + password: None, + enable_upload: Some(false), + max_upload_size: Some(10240), + config_file: None, + log_dir: None, + ssl_cert: Some(cert_path), + ssl_key: Some(bogus_key), + }; + + let (shutdown_tx, shutdown_rx) = mpsc::channel(); + let result = run_server(cli, Some(shutdown_rx), None); + assert!( + result.is_err(), + "run_server should fail with missing key file" + ); + drop(shutdown_tx); +} + +// --------------------------------------------------------------------------- +// Test 8: Validation error when only cert is provided (no key) +// --------------------------------------------------------------------------- +#[test] +fn test_ssl_cert_without_key() { + let dir = tempdir().unwrap(); + let (cert_path, _) = generate_test_certs(dir.path()); + + let cli = Cli { + directory: dir.path().to_path_buf(), + listen: Some("127.0.0.1".to_string()), + port: Some(0), + allowed_extensions: Some("*".to_string()), + threads: Some(4), + chunk_size: Some(1024), + verbose: Some(false), + detailed_logging: Some(false), + username: None, + password: None, + enable_upload: Some(false), + max_upload_size: Some(10240), + config_file: None, + log_dir: None, + ssl_cert: Some(cert_path), + ssl_key: None, + }; + + let result = cli.validate(); + assert!( + result.is_err(), + "validate() should fail when ssl_cert is set without ssl_key" + ); + let err_msg = format!("{}", result.unwrap_err()); + assert!( + err_msg.contains("Both --ssl-cert and --ssl-key must be provided together"), + "error message should mention both flags are required, got: {err_msg}" + ); +} + +// --------------------------------------------------------------------------- +// Test 9: Validation error when only key is provided (no cert) +// --------------------------------------------------------------------------- +#[test] +fn test_ssl_key_without_cert() { + let dir = tempdir().unwrap(); + let (_, key_path) = generate_test_certs(dir.path()); + + let cli = Cli { + directory: dir.path().to_path_buf(), + listen: Some("127.0.0.1".to_string()), + port: Some(0), + allowed_extensions: Some("*".to_string()), + threads: Some(4), + chunk_size: Some(1024), + verbose: Some(false), + detailed_logging: Some(false), + username: None, + password: None, + enable_upload: Some(false), + max_upload_size: Some(10240), + config_file: None, + log_dir: None, + ssl_cert: None, + ssl_key: Some(key_path), + }; + + let result = cli.validate(); + assert!( + result.is_err(), + "validate() should fail when ssl_key is set without ssl_cert" + ); + let err_msg = format!("{}", result.unwrap_err()); + assert!( + err_msg.contains("Both --ssl-cert and --ssl-key must be provided together"), + "error message should mention both flags are required, got: {err_msg}" + ); +} + +// --------------------------------------------------------------------------- +// Test 10: Plain HTTP still works when no SSL config is provided +// --------------------------------------------------------------------------- +#[test] +fn test_http_still_works_without_ssl() { + let dir = tempdir().unwrap(); + + let file_path = dir.path().join("hello.txt"); + let mut file = File::create(&file_path).unwrap(); + writeln!(file, "plain http content").unwrap(); + + let cli = Cli { + directory: dir.path().to_path_buf(), + listen: Some("127.0.0.1".to_string()), + port: Some(0), + allowed_extensions: Some("*".to_string()), + threads: Some(4), + chunk_size: Some(1024), + verbose: Some(false), + detailed_logging: Some(false), + username: None, + password: None, + enable_upload: Some(false), + max_upload_size: Some(10240), + config_file: None, + log_dir: None, + ssl_cert: None, + ssl_key: None, + }; + + let (shutdown_tx, shutdown_rx) = mpsc::channel(); + let (addr_tx, addr_rx) = mpsc::channel(); + + let server_handle = thread::spawn(move || { + if let Err(e) = run_server(cli, Some(shutdown_rx), Some(addr_tx)) { + eprintln!("HTTP server thread failed: {e}"); + } + }); + + let server_addr = addr_rx.recv().unwrap(); + + let server = TestServer { + addr: server_addr, + shutdown_tx, + handle: Some(server_handle), + _temp_dir: dir, + }; + + let client = Client::new(); + let res = client + .get(format!("http://{}/hello.txt", server.addr)) + .send() + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(res.text().unwrap(), "plain http content\n"); +}