diff --git a/.github/workflows/docker-security-scan.yml b/.github/workflows/docker-security-scan.yml index f5e8b48e..e7256a2c 100644 --- a/.github/workflows/docker-security-scan.yml +++ b/.github/workflows/docker-security-scan.yml @@ -107,6 +107,7 @@ jobs: - mysql:8.0 - grafana/grafana:11.4.0 - prom/prometheus:v3.0.1 + - caddy:2.10 steps: - name: Display vulnerabilities (table format) @@ -219,3 +220,11 @@ jobs: sarif_file: sarif-third-party-prom-prometheus-v3.0.1-${{ github.run_id }}/trivy.sarif category: docker-third-party-prom-prometheus-v3.0.1 continue-on-error: true + + - name: Upload third-party caddy SARIF + if: always() + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: sarif-third-party-caddy-2.10-${{ github.run_id }}/trivy.sarif + category: docker-third-party-caddy-2.10 + continue-on-error: true diff --git a/.gitignore b/.gitignore index 20be5886..89145854 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,7 @@ repomix-output.xml # Rust build artifacts target/ Cargo.lock +rustc-ice-*.txt # Template build directory (runtime-generated configs) build/ diff --git a/Cargo.toml b/Cargo.toml index d7792474..50dde037 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,6 +65,7 @@ tracing-appender = "0.2" tracing-subscriber = { version = "0.3", features = [ "env-filter", "json", "fmt" ] } url = { version = "2.0", features = [ "serde" ] } uuid = { version = "1.0", features = [ "v4", "serde" ] } +email_address = "0.2.9" [dev-dependencies] rstest = "0.26" diff --git a/docs/contributing/templates/caddy.md b/docs/contributing/templates/caddy.md new file mode 100644 index 00000000..2ee88b46 --- /dev/null +++ b/docs/contributing/templates/caddy.md @@ -0,0 +1,133 @@ +# Caddy Templates + +Documentation for Caddy reverse proxy templates used for automatic HTTPS with Let's Encrypt. + +## Overview + +Caddy provides automatic HTTPS termination for HTTP services. The template generates a Caddyfile +based on which services have TLS configured in the environment configuration. + +## Template Files + +### `templates/caddy/Caddyfile.tera` + +Dynamic Tera template that generates a Caddyfile. Only services with TLS configured +will have entries in the generated file. + +## Template Variables + +The template receives a `CaddyContext` with the following structure: + +| Variable | Type | Description | +| --------------- | -------------------------- | --------------------------------------------------- | +| `admin_email` | `String` | Admin email for Let's Encrypt notifications | +| `use_staging` | `bool` | Use Let's Encrypt staging environment (for testing) | +| `tracker_api` | `Option` | TLS config for Tracker API (if enabled) | +| `http_trackers` | `Vec` | TLS configs for HTTP trackers (only those with TLS) | +| `grafana` | `Option` | TLS config for Grafana (if enabled) | + +### `ServiceTlsConfig` Structure + +| Field | Type | Description | +| -------- | -------- | --------------------------------------------- | +| `domain` | `String` | Domain name for this service | +| `port` | `u16` | Port number (pre-extracted from bind_address) | + +## Context Data Preparation + +Following the project's [Context Data Preparation Pattern](template-system-architecture.md#-context-data-preparation-pattern), +all data is pre-processed in Rust before being passed to the template: + +- **Ports are extracted** from `bind_address` strings (e.g., `"0.0.0.0:7070"` β†’ `7070`) +- **Only TLS-enabled services** are included in the context +- **The template receives ready-to-use values** - no parsing required + +### Example: Port Extraction in Rust + +```rust +// In the context builder (Rust code) +let http_api_port = tracker_config.http_api.bind_address.port(); // u16 + +// Context passed to template +CaddyContext { + tracker_api: Some(ServiceTlsConfig { + domain: "api.example.com".to_string(), + port: http_api_port, // Already extracted as u16 + }), + // ... +} +``` + +```tera +{# In the template - receives ready-to-use port #} +{{ tracker_api.domain }} { + reverse_proxy tracker:{{ tracker_api.port }} +} +``` + +## Conditional Rendering + +The template uses Tera conditionals to include only services with TLS configured: + +- `{% if tracker_api %}` - Include API block only if TLS is enabled for API +- `{% for http_tracker in http_trackers %}` - Iterate only over trackers with TLS +- `{% if grafana %}` - Include Grafana block only if TLS is enabled + +Services without TLS configuration remain accessible via HTTP on their configured ports. + +## Let's Encrypt Environments + +### Production (Default) + +Uses the production Let's Encrypt API. Certificates are trusted by all browsers. + +**Rate limits** (production): + +- 50 certificates per registered domain per week +- 5 duplicate certificates per week + +### Staging + +Set `use_staging: true` in your environment configuration for testing: + +```json +{ + "https": { + "admin_email": "admin@example.com", + "use_staging": true + } +} +``` + +This configures Caddy to use `https://acme-staging-v02.api.letsencrypt.org/directory`. + +**Important notes about staging**: + +- Staging certificates will show browser warnings (not trusted by browsers) +- Use staging only for testing the HTTPS flow, not for production +- Staging has much higher rate limits than production + +## Docker Compose Integration + +When Caddy is enabled (any service has TLS configured), the following is added to `docker-compose.yml`: + +- **Caddy service**: Runs `caddy:2.10` image with ports 80, 443, and 443/udp (HTTP/3) +- **proxy_network**: Network connecting Caddy to services it proxies +- **caddy_data volume**: Persists TLS certificates (critical for avoiding rate limits) +- **caddy_config volume**: Persists Caddy configuration cache + +Services with TLS enabled are automatically connected to the `proxy_network`. + +## Caddyfile Syntax Notes + +- **Caddy requires TABS for indentation**, not spaces +- The template uses actual tab characters for proper Caddyfile formatting +- Global options are enclosed in `{ }` at the top of the file +- Site blocks use the format `domain.com { ... }` + +## Related Documentation + +- [Template System Architecture](template-system-architecture.md) - Overall template system design +- [Context Data Preparation Pattern](template-system-architecture.md#-context-data-preparation-pattern) - How to prepare data for templates +- [Tera Template Guidelines](tera.md) - Tera syntax and best practices +- [HTTPS Setup Guide](../../user-guide/https-setup.md) - User documentation (coming soon) diff --git a/docs/contributing/templates/template-system-architecture.md b/docs/contributing/templates/template-system-architecture.md index 27d35630..184b2237 100644 --- a/docs/contributing/templates/template-system-architecture.md +++ b/docs/contributing/templates/template-system-architecture.md @@ -245,6 +245,178 @@ impl DockerComposeProjectGenerator { - Template syntax validation and error handling - Strongly typed wrappers prevent runtime template errors +## 🎯 Context Data Preparation Pattern + +**Templates should receive only pre-processed, ready-to-use data.** All data transformation, parsing, and extraction must happen in Rust code when building the Context, not in the template. + +### Core Principle + +The Context acts as a **presentation layer** for templates: + +- **Rust code** does the heavy lifting: parsing, validation, extraction, conversion +- **Templates** only do simple variable interpolation and conditional rendering +- **No custom Tera filters** for data transformation (e.g., no `extract_port` filter) + +### Why This Matters + +1. **Testability**: Rust transformations are unit-testable; template logic is harder to test +2. **Type Safety**: Rust catches errors at compile time; template errors appear at runtime +3. **Simplicity**: Templates remain simple and readable +4. **Consistency**: All data preparation follows the same pattern +5. **Debugging**: Errors in data preparation have clear stack traces + +### Example: Port Extraction + +**❌ WRONG - Processing in template:** + +```tera +{# Template tries to extract port from bind_address #} +reverse_proxy tracker:{{ tracker.http_api.bind_address | extract_port }} +``` + +Problems: + +- Requires custom Tera filter registration +- Error handling in templates is awkward +- Template becomes coupled to data structure + +**βœ… CORRECT - Pre-processed in Rust:** + +```rust +// Context struct with ready-to-use values +pub struct CaddyContext { + pub http_api_port: u16, // Already extracted from bind_address + pub http_api_domain: String, + // ... +} + +// Port extraction happens in Rust when building context +impl CaddyContext { + pub fn from_config(config: &TrackerConfig) -> Self { + Self { + http_api_port: config.http_api.bind_address.port(), // Extraction here + http_api_domain: config.http_api.tls.as_ref() + .map(|tls| tls.domain.clone()) + .unwrap_or_default(), + } + } +} +``` + +```tera +{# Template receives ready-to-use port number #} +reverse_proxy tracker:{{ http_api_port }} +``` + +### Example: Conditional Data + +**❌ WRONG - Complex logic in template:** + +```tera +{% if tracker.http_api.tls is defined and tracker.http_api.tls.domain != "" %} +{{ tracker.http_api.tls.domain }} { + reverse_proxy tracker:{{ tracker.http_api.bind_address | extract_port }} +} +{% endif %} +``` + +**βœ… CORRECT - Rust prepares filtered list:** + +```rust +// Context contains only services that need rendering +pub struct CaddyContext { + pub services: Vec, // Only TLS-enabled services included +} + +pub struct CaddyService { + pub domain: String, + pub upstream_port: u16, +} + +// Filtering happens in Rust +impl CaddyContext { + pub fn from_config(config: &EnvironmentConfig) -> Self { + let mut services = Vec::new(); + + // Only add if TLS is configured + if let Some(tls) = &config.tracker.http_api.tls { + services.push(CaddyService { + domain: tls.domain.clone(), + upstream_port: config.tracker.http_api.bind_address.port(), + }); + } + + Self { services } + } +} +``` + +```tera +{# Template simply iterates pre-filtered list #} +{% for service in services %} +{{ service.domain }} { + reverse_proxy tracker:{{ service.upstream_port }} +} +{% endfor %} +``` + +### Data Flow Summary + +```text +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Domain Config │────▢│ Context Builder │────▢│ Template β”‚ +β”‚ (raw data) β”‚ β”‚ (Rust processing) β”‚ β”‚ (simple output) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ β”‚ β”‚ + Parse ports Filter by Convert types + condition to strings +``` + +### Guidelines for Context Design + +1. **Flatten nested structures**: If template needs `config.tracker.http_api.bind_address.port()`, provide `http_api_port: u16` +2. **Pre-filter collections**: If template only renders TLS-enabled services, filter in Rust first +3. **Use primitive types**: Prefer `String`, `u16`, `bool` over complex domain types +4. **Handle optionals in Rust**: Don't pass `Option` to templates; provide defaults or filter out +5. **Name for template clarity**: Use names like `http_api_port` not `bind_address_port_number` + +## πŸ“ Templates Directory Organization + +The `templates/` directory should contain **only template files** (`.tera` files and static configuration files). Documentation about templates should be placed in `docs/contributing/templates/`. + +### DO βœ… + +- Place template files (`.tera`, `.yml`, `.toml`, etc.) in `templates//` +- Add comments directly in template files to explain template-specific details +- Create documentation in `docs/contributing/templates/.md` for detailed explanations + +### DON'T ❌ + +- ❌ Add `README.md` files in `templates/` subdirectories +- ❌ Add documentation files in the `templates/` directory structure +- ❌ Mix documentation with template source files + +### Service Documentation Location + +| Service | Templates Location | Documentation Location | +| -------------- | --------------------------- | ----------------------------------------------- | +| Ansible | `templates/ansible/` | `docs/contributing/templates/ansible.md` | +| Caddy | `templates/caddy/` | `docs/contributing/templates/caddy.md` | +| Docker Compose | `templates/docker-compose/` | `docs/contributing/templates/docker-compose.md` | +| Grafana | `templates/grafana/` | `docs/contributing/templates/grafana.md` | +| Prometheus | `templates/prometheus/` | `docs/contributing/templates/prometheus.md` | +| Tofu | `templates/tofu/` | `docs/contributing/templates/tofu.md` | +| Tracker | `templates/tracker/` | `docs/contributing/templates/tracker.md` | + +### Rationale + +1. **Clean separation**: Template files are source code; documentation is separate +2. **Embedded templates**: The `templates/` directory is embedded in the binary - documentation files would unnecessarily increase binary size +3. **Consistency**: All documentation lives in `docs/`, not scattered across the codebase +4. **Discoverability**: Contributors know to look in `docs/contributing/templates/` for template documentation + ## ⚠️ Important Behaviors ### Template Persistence diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 1a56480b..50651b27 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -6,6 +6,9 @@ This directory contains architectural decision records for the Torrust Tracker D | Status | Date | Decision | Summary | | ------------- | ---------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| βœ… Accepted | 2026-01-20 | [Caddy for TLS Termination](./caddy-for-tls-termination.md) | Use Caddy v2.10 as TLS proxy for automatic HTTPS with WebSocket support | +| βœ… Accepted | 2026-01-20 | [Per-Service TLS Configuration](./per-service-tls-configuration.md) | Use domain + use_tls_proxy fields instead of nested tls section for explicit TLS opt-in | +| βœ… Accepted | 2026-01-20 | [Uniform HTTP Tracker TLS Requirement](./uniform-http-tracker-tls-requirement.md) | All HTTP trackers must use same TLS setting due to tracker's global on_reverse_proxy | | βœ… Accepted | 2026-01-10 | [Hetzner SSH Key Dual Injection Pattern](./hetzner-ssh-key-dual-injection.md) | Use both OpenTofu SSH key and cloud-init for debugging capability with manual hardening | | βœ… Accepted | 2026-01-10 | [Configuration and Data Directories as Secrets](./configuration-directories-as-secrets.md) | Treat envs/, data/, build/ as secrets; no env var injection; users secure via permissions | | βœ… Accepted | 2026-01-07 | [Configuration DTO Layer Placement](./configuration-dto-layer-placement.md) | Keep configuration DTOs in application layer, not domain; defer package extraction | diff --git a/docs/decisions/caddy-for-tls-termination.md b/docs/decisions/caddy-for-tls-termination.md new file mode 100644 index 00000000..ee154efd --- /dev/null +++ b/docs/decisions/caddy-for-tls-termination.md @@ -0,0 +1,140 @@ +# Decision: Caddy for TLS Termination + +## Status + +Accepted + +## Date + +2026-01-20 + +## Context + +The Torrust Tracker Deployer needed automatic HTTPS support for all HTTP services: + +- Tracker REST API +- HTTP Tracker(s) +- Grafana monitoring UI +- Health Check API + +Key requirements: + +1. **Automatic certificate management** - No manual certificate generation or renewal +2. **WebSocket support** - Grafana Live requires WebSocket connections +3. **Simple configuration** - Minimize operational complexity +4. **Docker-friendly** - Easy integration in Docker Compose deployments +5. **Production-ready** - Mature and reliable for production use + +## Decision + +We adopted **Caddy v2.10** as the TLS termination proxy for all HTTP services. + +### Implementation + +Caddy is deployed as a Docker container in the same Docker Compose stack, serving as a reverse proxy for all HTTP services that need HTTPS: + +```yaml +services: + caddy: + image: caddy:2.10 + ports: + - "80:80" # HTTP (ACME challenges) + - "443:443" # HTTPS + - "443:443/udp" # HTTP/3 (QUIC) + volumes: + - ./storage/caddy/etc/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data # TLS certificates + - caddy_config:/config +``` + +Configuration uses Caddy's simple Caddyfile format: + +```caddyfile +{ + email admin@example.com +} + +api.example.com { + reverse_proxy tracker:1212 +} + +grafana.example.com { + reverse_proxy grafana:3000 +} +``` + +## Consequences + +### Positive + +- **Zero-configuration HTTPS**: Certificates are automatically obtained and renewed via Let's Encrypt +- **WebSocket support**: Caddy handles WebSocket upgrades transparently (no special configuration) +- **Simple configuration**: Caddyfile is ~21 lines vs hundreds for nginx+certbot +- **HTTP/3 support**: QUIC protocol support is included by default +- **Hot reload**: Configuration changes apply without service interruption +- **Production-proven**: Caddy has been stable since 2015 with large community + +### Negative + +- **Larger binary**: ~40MB vs ~4MB for Pingoo or ~1MB for nginx +- **Post-quantum cryptography requires custom build**: Standard Caddy doesn't include PQC, but can be compiled with [Cloudflare's Go fork (CFGo)](https://github.com/cloudflare/go) to support X25519Kyber768 key exchange. Pingoo includes X25519MLKEM768 by default. +- **Additional container**: One more service in the Docker Compose stack + +### Neutral + +- **Certificate storage**: Requires persistent volume (`caddy_data`) for certificates + +## Alternatives Considered + +### 1. Pingoo (Rust-based TLS proxy) + +**Evaluated in**: [Issue #234](https://github.com/torrust/torrust-tracker-deployer/issues/234) + +**Rejected because**: Pingoo strips the `Upgrade` HTTP header, breaking WebSocket connections required by Grafana Live. + +```rust +// From Pingoo source (http_proxy_service.rs): +let dominated_headers = &[ + "host", + "upgrade", // ← WebSocket upgrade stripped! + "connection", + ... +]; +``` + +**Result**: Experiments 1-3 (HTTP) succeeded, but Experiment 4 (Grafana WebSocket) failed. + +**Upstream issue filed**: [pingooio/pingoo#23](https://github.com/pingooio/pingoo/issues/23) + +### 2. nginx + certbot + +**Traditional approach** with manual configuration. + +**Rejected because**: + +1. **Manual setup**: Must run certbot manually to generate first certificate +2. **Cron-based renewal**: Requires bash script/cronjob for certificate renewal +3. **Complex configuration**: ~200+ lines of nginx.conf for SSL, headers, locations +4. **WebSocket configuration**: Requires explicit `proxy_set_header Upgrade` and `Connection` headers +5. **Multi-domain complexity**: Each subdomain needs separate certificate management + +### 3. Traefik + +**Alternative reverse proxy** with automatic HTTPS. + +**Not evaluated** because Caddy already met all requirements with simpler configuration. + +## Related Decisions + +- [prometheus-integration-pattern.md](./prometheus-integration-pattern.md) - Prometheus is enabled by default +- [grafana-integration-pattern.md](./grafana-integration-pattern.md) - Grafana requires Prometheus dependency + +## References + +- [Issue #270 - Evaluate Caddy for HTTPS Termination](https://github.com/torrust/torrust-tracker-deployer/issues/270) +- [Issue #272 - Add HTTPS Support with Caddy](https://github.com/torrust/torrust-tracker-deployer/issues/272) +- [Issue #234 - Pingoo Evaluation](https://github.com/torrust/torrust-tracker-deployer/issues/234) +- [Caddy Official Documentation](https://caddyserver.com/docs/) +- [Caddy GitHub Repository](https://github.com/caddyserver/caddy) +- [Let's Encrypt Documentation](https://letsencrypt.org/docs/) +- [Go Post-Quantum with Caddy](https://sam-burns.com/posts/go-post-quantum-with-caddy/) - Tutorial on compiling Caddy with Cloudflare's Go fork for PQC support diff --git a/docs/decisions/per-service-tls-configuration.md b/docs/decisions/per-service-tls-configuration.md new file mode 100644 index 00000000..9ccd71a8 --- /dev/null +++ b/docs/decisions/per-service-tls-configuration.md @@ -0,0 +1,255 @@ +# Decision: Per-Service TLS Configuration with domain + use_tls_proxy + +## Status + +Accepted + +## Date + +2026-01-20 + +## Context + +When implementing HTTPS support for the deployer, we needed to decide how to configure TLS for each service. The deployer manages multiple HTTP services: + +- Tracker REST API (HTTP API) +- One or more HTTP Trackers +- Grafana monitoring UI +- Health Check API + +Key design considerations: + +1. **Flexibility**: Some services might need HTTPS while others use HTTP +2. **Domain routing**: Caddy routes by domain name (subdomain-based routing) +3. **TLS proxy opt-in**: Services should explicitly opt into TLS proxying +4. **Configuration clarity**: Users should clearly see what each setting controls +5. **Validation**: Invalid combinations should be caught at configuration time + +### Original Design (tls section) + +The initial design used a nested `tls` section within each service: + +```json +{ + "grafana": { + "admin_user": "admin", + "admin_password": "admin", + "tls": { + "domain": "grafana.example.com" + } + } +} +``` + +This implied: if `tls` section exists, use HTTPS. + +### Problem with Original Design + +1. **Implicit activation**: Presence of section implies activation (no explicit flag) +2. **Domain without TLS**: What if user wants domain for other purposes but not TLS? +3. **TLS without domain**: Error case is less obvious +4. **Nested complexity**: Extra level of nesting for just one field + +## Decision + +We use **flat configuration with two explicit fields** at the service level: + +- `domain` (string, optional) - Domain name for the service +- `use_tls_proxy` (boolean, optional) - Whether to route through Caddy for HTTPS + +### Implementation + +Each service configuration includes these optional fields: + +```rust +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct GrafanaSection { + pub admin_user: String, + pub admin_password: PlainPassword, + + /// Domain name for external HTTPS access (optional) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub domain: Option, + + /// Whether to use TLS proxy via Caddy (default: false) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub use_tls_proxy: Option, +} +``` + +### Configuration Matrix + +| domain | use_tls_proxy | Result | +| ------ | ------------- | ---------------------------------------------- | +| None | None/false | HTTP access via IP:port | +| Some | false | Domain configured but not proxied (future use) | +| Some | true | HTTPS via Caddy with automatic certificates | +| None | true | **Validation Error** - TLS requires domain | + +### Example Configurations + +**HTTPS-enabled Grafana**: + +```json +{ + "grafana": { + "admin_user": "admin", + "admin_password": "admin", + "domain": "grafana.example.com", + "use_tls_proxy": true + } +} +``` + +**HTTP-only Grafana (no TLS)**: + +```json +{ + "grafana": { + "admin_user": "admin", + "admin_password": "admin" + } +} +``` + +**Domain without TLS (future-proofing)**: + +```json +{ + "grafana": { + "admin_user": "admin", + "admin_password": "admin", + "domain": "grafana.example.com" + } +} +``` + +### Global HTTPS Section + +A global `https` section provides configuration shared across all TLS-enabled services: + +```json +{ + "https": { + "admin_email": "admin@example.com", + "use_staging": false + } +} +``` + +This section is **required** when any service has `use_tls_proxy: true`. + +### Validation Rules + +1. If `use_tls_proxy: true` is set, `domain` is required for that service +2. If any service has `use_tls_proxy: true`, global `https.admin_email` is required +3. If `https.admin_email` is provided but no service uses TLS proxy, validation fails + +### Naming Choice: `use_tls_proxy` vs `use_tls` + +We deliberately chose the name `use_tls_proxy` instead of `use_tls` or `tls_enabled` to avoid confusion with the tracker's **native TLS support**. + +The Torrust Tracker supports TLS termination directly without a proxy through its [`TslConfig`](https://docs.rs/torrust-tracker-configuration/latest/torrust_tracker_configuration/struct.TslConfig.html) configuration: + +```rust +pub struct TslConfig { + pub ssl_cert_path: Utf8PathBuf, + pub ssl_key_path: Utf8PathBuf, +} +``` + +This native TLS configuration is available for [`HttpTracker`](https://docs.rs/torrust-tracker-configuration/latest/torrust_tracker_configuration/type.HttpTracker.html) via the `tsl_config` field. + +**Why this matters for the deployer**: + +- **Future feature**: The deployer may eventually support enabling native TLS on the tracker (without a reverse proxy) +- **Naming conflict**: Using `use_tls` could create ambiguity between proxy-based and native TLS +- **Clear semantics**: `use_tls_proxy` explicitly indicates "route traffic through Caddy for TLS termination" + +By using `use_tls_proxy`, we reserve the namespace for potential future fields like: + +- `use_native_tls` - Enable TLS directly on the tracker +- `tls_cert_path` / `tls_key_path` - Certificate paths for native TLS + +This naming also aligns with the related decision about [uniform HTTP tracker TLS requirement](./uniform-http-tracker-tls-requirement.md), where all HTTP trackers must share the same TLS proxy setting due to the tracker's global `on_reverse_proxy` configuration. + +## Consequences + +### Positive + +- **Explicit activation**: `use_tls_proxy: true` clearly states intent +- **Flat structure**: No nested `tls` section, easier to read and edit +- **Future flexibility**: `domain` can be used for other purposes without TLS +- **Clear validation**: Invalid combinations produce specific error messages +- **Symmetry**: Same pattern used for all services (HTTP API, HTTP trackers, Grafana, Health Check) + +### Negative + +- **Two fields instead of one**: More verbose when enabling TLS +- **Refactoring cost**: Required changing existing design mid-implementation + +### Neutral + +- **Boolean defaults**: `use_tls_proxy` defaults to `false` (HTTP) + +## Alternatives Considered + +### 1. Nested tls Section (Original Design) + +```json +{ + "grafana": { + "tls": { + "domain": "grafana.example.com" + } + } +} +``` + +**Rejected because**: + +- Implicit activation (section presence = enabled) +- Extra nesting for single field +- Domain and TLS tightly coupled + +### 2. Single Boolean with Auto-Domain + +```json +{ + "grafana": { + "use_https": true + } +} +``` + +**Rejected because**: + +- Domain still needed for Caddy routing +- Would require deriving domain from environment name (too magical) + +### 3. Domain-Only (Implicit TLS) + +```json +{ + "grafana": { + "domain": "grafana.example.com" + } +} +``` + +**Rejected because**: + +- Domain might be wanted without TLS in future use cases +- No explicit opt-in for TLS proxy + +## Related Decisions + +- [caddy-for-tls-termination.md](./caddy-for-tls-termination.md) - Why Caddy was chosen +- [uniform-http-tracker-tls-requirement.md](./uniform-http-tracker-tls-requirement.md) - Why all HTTP trackers must use same TLS setting + +## References + +- [Issue #272 - Add HTTPS Support with Caddy](https://github.com/torrust/torrust-tracker-deployer/issues/272) +- [Commit: Replace tls with domain+use_tls_proxy](https://github.com/torrust/torrust-tracker-deployer/pull/273) (refactoring commits) +- [Tracker HttpTracker Configuration](https://docs.rs/torrust-tracker-configuration/latest/torrust_tracker_configuration/type.HttpTracker.html) - Native HTTP tracker configuration with optional TLS +- [Tracker TslConfig Documentation](https://docs.rs/torrust-tracker-configuration/latest/torrust_tracker_configuration/struct.TslConfig.html) - Native TLS configuration for direct TLS termination diff --git a/docs/decisions/uniform-http-tracker-tls-requirement.md b/docs/decisions/uniform-http-tracker-tls-requirement.md new file mode 100644 index 00000000..eb58bbb9 --- /dev/null +++ b/docs/decisions/uniform-http-tracker-tls-requirement.md @@ -0,0 +1,206 @@ +# Decision: Uniform TLS Requirement for HTTP Trackers + +## Status + +Accepted + +## Date + +2026-01-20 + +## Context + +The deployer supports configuring multiple HTTP trackers in a single deployment: + +```json +{ + "tracker": { + "http_trackers": [ + { + "bind_address": "0.0.0.0:7070", + "domain": "http1.example.com", + "use_tls_proxy": true + }, + { + "bind_address": "0.0.0.0:7071", + "domain": "http2.example.com", + "use_tls_proxy": true + }, + { "bind_address": "0.0.0.0:7072" } + ] + } +} +``` + +During HTTPS implementation, we discovered a **critical limitation** in the Torrust Tracker configuration: the `on_reverse_proxy` setting is **global**, not per-tracker. + +### The Problem + +When a tracker runs behind a reverse proxy (like Caddy), it needs to read `X-Forwarded-For` headers to determine the real client IP address. The tracker has a configuration option for this: + +```toml +[core.net] +on_reverse_proxy = true # Applies to ALL HTTP trackers +``` + +This creates a conflict: + +- **Trackers behind Caddy**: Need `on_reverse_proxy = true` to read client IPs from headers +- **Direct HTTP trackers**: Need `on_reverse_proxy = false` to read client IPs from socket + +With the global setting, you cannot mix these configurations. + +### What Happens with Mixed Configuration + +**If `on_reverse_proxy = true` (for proxied trackers)**: + +- Direct HTTP trackers will look for `X-Forwarded-For` header +- Header won't exist for direct connections +- Client IP detection fails or uses wrong IP + +**If `on_reverse_proxy = false` (for direct trackers)**: + +- Proxied trackers will see Caddy's IP as the client IP +- All peers appear to come from the same IP (the proxy) +- BitTorrent swarm peer identification breaks + +## Decision + +We enforce a **uniform TLS configuration** for all HTTP trackers in a deployment: + +> **If ANY HTTP tracker uses `use_tls_proxy: true`, ALL HTTP trackers MUST use `use_tls_proxy: true`.** + +### Implementation + +Validation is performed at environment creation time: + +```rust +fn validate_uniform_http_tracker_tls( + http_trackers: &[HttpTrackerSection], +) -> Result<(), CreateConfigError> { + let any_uses_tls = http_trackers.iter().any(|t| t.use_tls_proxy.unwrap_or(false)); + let all_use_tls = http_trackers.iter().all(|t| t.use_tls_proxy.unwrap_or(false)); + + if any_uses_tls && !all_use_tls { + return Err(CreateConfigError::MixedHttpTrackerTls { + message: "All HTTP trackers must use the same TLS proxy setting".into(), + hint: "Either set use_tls_proxy: true for all trackers, or remove it from all".into(), + }); + } + + Ok(()) +} +``` + +### Error Message + +When users attempt mixed configuration: + +```text +Configuration Error: Mixed HTTP tracker TLS settings + +All HTTP trackers must use the same TLS proxy configuration due to a +limitation in the Torrust Tracker's global `on_reverse_proxy` setting. + +Current configuration: + - http1.example.com (port 7070): use_tls_proxy = true + - http2.example.com (port 7071): use_tls_proxy = true + - Port 7072: use_tls_proxy = false + +Fix: Either set use_tls_proxy: true for all HTTP trackers, + or remove TLS proxy from all HTTP trackers. + +See: docs/external-issues/tracker/on-reverse-proxy-global-setting.md +``` + +## Consequences + +### Positive + +- **Prevents silent failures**: Users get clear error instead of broken peer detection +- **Consistent behavior**: All trackers behave identically +- **Future-proof**: When tracker fixes the issue, we can remove this constraint + +### Negative + +- **Reduced flexibility**: Cannot mix HTTP and HTTPS trackers in same deployment +- **All-or-nothing**: Users must decide on proxy for entire tracker deployment + +### Neutral + +- **Workaround exists**: Users can run separate deployments for different TLS requirements + +## Upstream Issue + +We have filed an issue with the Torrust Tracker to request per-tracker `on_reverse_proxy` configuration: + +**Issue**: [torrust/torrust-tracker#1640](https://github.com/torrust/torrust-tracker/issues/1640) + +### Proposed Tracker Fix + +```toml +[core.net] +on_reverse_proxy = false # Default for trackers without explicit setting + +[[http_trackers]] +bind_address = "0.0.0.0:7070" +on_reverse_proxy = true # Override: this tracker is behind a proxy + +[[http_trackers]] +bind_address = "0.0.0.0:7071" +on_reverse_proxy = true # Override: this tracker is behind a proxy + +[[http_trackers]] +bind_address = "0.0.0.0:7072" +# No override: uses global default (false) - direct access +``` + +When this is implemented in the tracker, we can: + +1. Remove the uniform TLS validation +2. Generate per-tracker `on_reverse_proxy` settings +3. Allow mixed HTTP/HTTPS tracker configurations + +## Alternatives Considered + +### 1. Silent Acceptance (No Validation) + +Allow mixed configuration and let it fail at runtime. + +**Rejected because**: + +- Peer IP detection would silently fail +- Hard to debug (symptoms appear in BitTorrent client behavior) +- Poor user experience + +### 2. Auto-Proxy All Trackers + +If any tracker needs TLS, automatically proxy all trackers. + +**Rejected because**: + +- Changes user intent without explicit consent +- Might not match user's network architecture +- Could expose internal trackers unintentionally + +### 3. Warning Instead of Error + +Allow mixed config with a warning. + +**Rejected because**: + +- Users would proceed and hit runtime issues +- Warning fatigue leads to ignoring important messages +- Fails principle of "actionable errors" + +## Related Decisions + +- [caddy-for-tls-termination.md](./caddy-for-tls-termination.md) - Why Caddy was chosen for TLS +- [per-service-tls-configuration.md](./per-service-tls-configuration.md) - Per-service TLS configuration pattern + +## References + +- [Issue #272 - Add HTTPS Support with Caddy](https://github.com/torrust/torrust-tracker-deployer/issues/272) +- [Upstream Issue: torrust/torrust-tracker#1640](https://github.com/torrust/torrust-tracker/issues/1640) +- [External Issue Documentation](../external-issues/tracker/on-reverse-proxy-global-setting.md) +- [Torrust Tracker Network Configuration](https://docs.rs/torrust-tracker-configuration/latest/torrust_tracker_configuration/v2_0_0/network/struct.Network.html) diff --git a/docs/external-issues/tracker/on-reverse-proxy-global-setting.md b/docs/external-issues/tracker/on-reverse-proxy-global-setting.md new file mode 100644 index 00000000..81411b8b --- /dev/null +++ b/docs/external-issues/tracker/on-reverse-proxy-global-setting.md @@ -0,0 +1,189 @@ +# Tracker `on_reverse_proxy` is Global Instead of Per-HTTP-Tracker + +**Issue Date**: January 16, 2026 +**Affected Component**: Torrust Tracker Configuration +**Status**: Documented - Issue filed in tracker repository +**Upstream Issue**: [torrust/torrust-tracker#1640](https://github.com/torrust/torrust-tracker/issues/1640) + +## Problem Description + +The Torrust Tracker has a configuration option `[core.net].on_reverse_proxy` that controls whether the tracker expects `X-Forwarded-For` HTTP headers to determine the real client IP address. This setting is **global** and applies to **all HTTP trackers** in the deployment. + +This creates a limitation: you cannot have some HTTP trackers behind a reverse proxy while others are accessed directly in the same deployment. + +## How We Discovered This + +While implementing HTTPS support with Caddy as a TLS-terminating reverse proxy in the [Torrust Tracker Deployer](https://github.com/torrust/torrust-tracker-deployer), we needed to configure the tracker to work behind Caddy. + +Our use case: + +- Multiple HTTP trackers on different ports (e.g., 7070, 7071, 7072) +- Some trackers exposed via HTTPS through Caddy (TLS termination) +- Some trackers exposed directly via HTTP (no proxy) + +**Example configuration intent**: + +```json +{ + "http_trackers": [ + { + "bind_address": "0.0.0.0:7070", + "domain": "http1.tracker.local", + "use_tls_proxy": true + }, + { + "bind_address": "0.0.0.0:7071", + "domain": "http2.tracker.local", + "use_tls_proxy": true + }, + { + "bind_address": "0.0.0.0:7072" + } + ] +} +``` + +In this scenario: + +- Trackers on ports 7070 and 7071 are behind Caddy (need `on_reverse_proxy = true`) +- Tracker on port 7072 is direct (needs `on_reverse_proxy = false`) + +However, the current tracker configuration only allows: + +```toml +[core.net] +on_reverse_proxy = true # Applies to ALL HTTP trackers +``` + +## Root Cause + +The `on_reverse_proxy` setting is defined in `[core.net]` which is a global network configuration section, not per-tracker. Looking at the tracker's network configuration structure: + +**Reference**: [Torrust Tracker Network Configuration](https://docs.rs/torrust-tracker-configuration/latest/torrust_tracker_configuration/v2_0_0/network/struct.Network.html) + +```rust +pub struct Network { + // ... + pub on_reverse_proxy: bool, // Global setting + // ... +} +``` + +Each HTTP tracker configuration does not have its own `on_reverse_proxy` field. + +## Impact + +### For Deployer Users + +When `on_reverse_proxy = true` is set globally: + +1. **All HTTP trackers expect `X-Forwarded-For` headers** +2. Trackers accessed directly (without proxy) will **fail to identify client IPs correctly** +3. The tracker will see the absence of `X-Forwarded-For` and may log warnings or behave unexpectedly + +When `on_reverse_proxy = false` is set globally: + +1. **All HTTP trackers ignore `X-Forwarded-For` headers** +2. Trackers behind a reverse proxy will **see the proxy's IP as the client IP** +3. All peers from different clients will appear to come from the same IP (the proxy) +4. This breaks peer identification in swarms + +### Current Workaround in Deployer + +We enforce a rule in the deployer: + +> **If ANY HTTP tracker uses a TLS proxy, ALL HTTP trackers must use the TLS proxy.** + +This is documented as a known limitation and validated during environment creation: + +```text +Known Limitation (due to tracker's global setting): + +If you have multiple HTTP trackers where some use use_tls_proxy and others don't, +the ones without it will still receive the global on_reverse_proxy = true setting +and may fail if they receive direct requests without X-Forwarded-For headers. + +Workaround: Ensure all HTTP trackers in a deployment either ALL use the TLS proxy +or NONE use it. +``` + +This limitation reduces deployment flexibility and forces users into an all-or-nothing approach. + +## Recommended Solution + +Add an optional `on_reverse_proxy` field to each HTTP tracker configuration, allowing per-tracker control: + +### Proposed Configuration Structure + +```toml +[core.net] +on_reverse_proxy = false # Default for trackers without explicit setting + +[[http_trackers]] +bind_address = "0.0.0.0:7070" +on_reverse_proxy = true # Override: this tracker is behind a proxy + +[[http_trackers]] +bind_address = "0.0.0.0:7071" +on_reverse_proxy = true # Override: this tracker is behind a proxy + +[[http_trackers]] +bind_address = "0.0.0.0:7072" +# No override: uses global default (false) - direct access +``` + +### Behavior + +1. If `on_reverse_proxy` is specified on an HTTP tracker, use that value +2. If not specified, fall back to `[core.net].on_reverse_proxy` (backward compatible) +3. Each HTTP tracker independently decides whether to read `X-Forwarded-For` + +### Implementation Considerations + +The HTTP tracker request handler would need to check its own `on_reverse_proxy` setting when extracting the client IP, rather than checking the global setting. + +**Pseudocode change**: + +```rust +// Before (global check) +fn get_client_ip(request: &Request, config: &Config) -> IpAddr { + if config.core.net.on_reverse_proxy { + extract_from_x_forwarded_for(request) + } else { + request.peer_addr() + } +} + +// After (per-tracker check) +fn get_client_ip(request: &Request, tracker_config: &HttpTrackerConfig) -> IpAddr { + let on_reverse_proxy = tracker_config.on_reverse_proxy + .unwrap_or(config.core.net.on_reverse_proxy); + + if on_reverse_proxy { + extract_from_x_forwarded_for(request) + } else { + request.peer_addr() + } +} +``` + +## Benefits of This Change + +1. **Flexible deployments**: Mix proxied and direct HTTP trackers in one deployment +2. **Backward compatible**: Global setting remains the default +3. **Clearer intent**: Each tracker explicitly declares its network topology +4. **Better for edge cases**: Internal trackers (localhost) vs external (behind proxy) + +## Use Cases Enabled + +1. **Mixed TLS/non-TLS deployment**: Some trackers via HTTPS (Caddy), some via direct HTTP +2. **Internal monitoring**: Direct localhost tracker for Prometheus, proxied trackers for public access +3. **Gradual migration**: Move trackers behind proxy one at a time during migration +4. **Multi-tenant**: Different trackers for different networks with different proxy configurations + +## References + +- [Torrust Tracker Network Configuration Docs](https://docs.rs/torrust-tracker-configuration/latest/torrust_tracker_configuration/v2_0_0/network/struct.Network.html) +- [Torrust Tracker Repository](https://github.com/torrust/torrust-tracker) +- [Deployer Issue #272 - Add HTTPS Support](https://github.com/torrust/torrust-tracker-deployer/issues/272) +- [Deployer PR #273 - HTTPS Implementation](https://github.com/torrust/torrust-tracker-deployer/pull/273) diff --git a/docs/issues/272-add-https-support-with-caddy.md b/docs/issues/272-add-https-support-with-caddy.md index 77fe3c20..6503ec19 100644 --- a/docs/issues/272-add-https-support-with-caddy.md +++ b/docs/issues/272-add-https-support-with-caddy.md @@ -19,12 +19,12 @@ Production deployment at `/opt/torrust/` on Hetzner server (46.224.206.37) serve ## Goals -- [ ] Integrate Caddy into deployer Tera templates -- [ ] Support HTTPS for all HTTP services (Tracker API, HTTP Tracker, Grafana) -- [ ] Enable automatic Let's Encrypt certificate management -- [ ] Add HTTPS configuration to environment schema -- [ ] Implement security scanning for Caddy in CI/CD -- [ ] Document HTTPS setup in user guide +- [x] Integrate Caddy into deployer Tera templates +- [x] Support HTTPS for all HTTP services (Tracker API, HTTP Tracker, Grafana) +- [x] Enable automatic Let's Encrypt certificate management +- [x] Add HTTPS configuration to environment schema +- [x] Implement security scanning for Caddy in CI/CD +- [x] Document HTTPS setup in user guide - [ ] Add E2E tests for HTTPS functionality ## πŸ—οΈ Architecture Requirements @@ -102,15 +102,15 @@ Production deployment at `/opt/torrust/` on Hetzner server (46.224.206.37) serve - Each HTTP tracker maps to its configured port - Follow Torrust Tracker convention: if `tls` section exists in service config, HTTPS is enabled -**Template Variables**: +**Template Variables** (pre-processed in Rust Context): -- `{{ https.admin_email }}` - Admin email for Let's Encrypt notifications (common config) -- `{{ tracker.http_api.tls.domain }}` - Domain for Tracker API (if tls section present) -- `{{ tracker.http_api.bind_address }}` - Parse port from bind_address (e.g., "0.0.0.0:1212" β†’ port 1212) -- `{{ tracker.http_trackers[*].tls.domain }}` - Domains for HTTP trackers (if tls section present) -- `{{ tracker.http_trackers[*].bind_address }}` - Parse port from each tracker's bind_address -- `{{ grafana.tls.domain }}` - Domain for Grafana UI (if tls section present) -- Grafana port: hardcoded 3000 (matches docker-compose template) +Following the [Context Data Preparation Pattern](../contributing/templates/template-system-architecture.md#-context-data-preparation-pattern), all data is pre-processed in Rust before being passed to the template. The template receives ready-to-use values: + +- `{{ admin_email }}` - Admin email for Let's Encrypt notifications +- `{{ use_staging }}` - Boolean for Let's Encrypt staging environment +- `{{ http_api_service }}` - Optional service object with `domain` and `port` (only present if TLS configured) +- `{{ http_tracker_services }}` - Array of service objects, each with `domain` and `port` (only TLS-enabled trackers) +- `{{ grafana_service }}` - Optional service object with `domain` and `port` (only present if TLS configured) **Example**: @@ -121,40 +121,38 @@ Production deployment at `/opt/torrust/` on Hetzner server (46.224.206.37) serve # Global options { # Email for Let's Encrypt notifications - email {{ https.admin_email }} + email {{ admin_email }} - {% if https.use_staging %} + {% if use_staging %} # Use Let's Encrypt staging environment (for testing, avoids rate limits) # WARNING: Staging certificates will show browser warnings (not trusted) acme_ca https://acme-staging-v02.api.letsencrypt.org/directory {% endif %} } -{% if tracker.http_api.tls %} +{% if http_api_service %} # Tracker REST API -{{ tracker.http_api.tls.domain }} { - reverse_proxy tracker:{{ tracker.http_api.bind_address | extract_port }} +{{ http_api_service.domain }} { + reverse_proxy tracker:{{ http_api_service.port }} } {% endif %} -{% for http_tracker in tracker.http_trackers %} -{% if http_tracker.tls %} +{% for service in http_tracker_services %} # HTTP Tracker -{{ http_tracker.tls.domain }} { - reverse_proxy tracker:{{ http_tracker.bind_address | extract_port }} +{{ service.domain }} { + reverse_proxy tracker:{{ service.port }} } -{% endif %} {% endfor %} -{% if grafana.tls %} +{% if grafana_service %} # Grafana UI with WebSocket support -{{ grafana.tls.domain }} { - reverse_proxy grafana:3000 +{{ grafana_service.domain }} { + reverse_proxy grafana:{{ grafana_service.port }} } {% endif %} ``` -**Note**: The `extract_port` Tera filter extracts the port from bind_address (e.g., "0.0.0.0:7070" β†’ "7070") +**Note**: Port extraction and TLS filtering happens in Rust when building `CaddyContext`, not in the template. See [Context Data Preparation Pattern](../contributing/templates/template-system-architecture.md#-context-data-preparation-pattern). **Configuration Example** (user input): @@ -617,91 +615,192 @@ Add link to HTTPS setup guide. ### Phase 1: Template Creation (3-4 hours) -- [ ] Create `templates/caddy/Caddyfile.tera` based on production configuration -- [ ] Create `templates/caddy/README.md` documenting template variables -- [ ] Update `templates/docker-compose/docker-compose.yml.tera` with Caddy service block -- [ ] Register Caddyfile in appropriate ProjectGenerator (likely new `CaddyProjectGenerator`) -- [ ] Test template rendering manually with sample data +- [x] Create `templates/caddy/Caddyfile.tera` based on production configuration +- [x] Create `docs/contributing/templates/caddy.md` documenting template variables (per project convention: no README in templates/) +- [x] Update `templates/docker-compose/docker-compose.yml.tera` with Caddy service block +- [x] Register Caddyfile in `CaddyProjectGenerator` (`src/infrastructure/templating/caddy/`) +- [x] Test template rendering with sample data (14 unit tests in `CaddyProjectGenerator` and `CaddyfileRenderer`) ### Phase 2: Configuration DTOs (3-4 hours) -- [ ] Create `src/application/command_handlers/create/config/https.rs` with DTOs - - [ ] `HttpsConfig` struct with `admin_email` and `use_staging` fields - - [ ] `admin_email: String` (required if TLS configured) - - [ ] `use_staging: bool` (optional, defaults to false for production) - - [ ] `TlsConfig` struct with only `domain` field (service-specific) -- [ ] Update existing service DTOs to include optional `tls` field: - - [ ] `HttpApiSection` in `tracker.rs` - add `tls: Option` - - [ ] `HttpTrackerSection` in `tracker.rs` - add `tls: Option` - - [ ] `GrafanaSection` in `grafana.rs` - add `tls: Option` -- [ ] Update `EnvironmentCreationConfig` to include optional `HttpsConfig` -- [ ] Add validation logic: - - [ ] `has_any_tls_configured()` - check if any service has `tls` section - - [ ] If any service has TLS, `https` section with `admin_email` is required - - [ ] If `https.admin_email` provided, at least one service must have TLS configured - - [ ] Email format validation for `HttpsConfig.admin_email` - - [ ] Domain name format validation in each service's `TlsConfig` -- [ ] Add proper type wrappers for sensitive data (AdminEmail) per [docs/contributing/secret-handling.md](../contributing/secret-handling.md) -- [ ] Create unit tests for all validation scenarios +- [x] Create `src/application/command_handlers/create/config/https.rs` with DTOs + - [x] `HttpsSection` struct with `admin_email` and `use_staging` fields + - [x] `admin_email: String` (required if TLS configured) + - [x] `use_staging: bool` (optional, defaults to false for production) + - [x] `TlsSection` struct with only `domain` field (service-specific) +- [x] Update existing service DTOs to include optional `tls` field: + - [x] `HttpApiSection` in `tracker.rs` - add `tls: Option` + - [x] `HttpTrackerSection` in `tracker.rs` - add `tls: Option` + - [x] `GrafanaSection` in `grafana.rs` - add `tls: Option` +- [x] Update `EnvironmentCreationConfig` to include optional `HttpsSection` +- [x] Add validation logic: + - [x] `has_any_tls_configured()` - check if any service has `tls` section + - [x] If any service has TLS, `https` section with `admin_email` is required + - [x] If `https.admin_email` provided, at least one service must have TLS configured + - [x] Email format validation for `HttpsSection.admin_email` (using `email_address` crate via `Email` type in `src/shared/email.rs`) + - [x] Domain name format validation in each service's `TlsSection` (using `DomainName` type in `src/shared/domain_name.rs`) +- [x] Add proper type wrappers for validation (`Email`, `DomainName` in `src/shared/`) - Note: DTOs remain as `String` primitives for JSON serialization, domain types used for validation during boundary crossing +- [x] Create unit tests for all validation scenarios ### Phase 3: Template Rendering Integration (3-4 hours) -- [ ] Implement Tera `extract_port` filter to parse port from bind_address strings -- [ ] Update Ansible template renderer to handle Caddy templates -- [ ] Pass configuration to Tera context: - - [ ] `https.admin_email` (required if any service has TLS) - - [ ] `https.use_staging` (optional, defaults to false) - - [ ] `tracker.http_api` (with optional tls field) - - [ ] `tracker.http_trackers[]` (array, each with optional tls field) - - [ ] `grafana` (with optional tls field) -- [ ] Handle conditional rendering in templates: - - [ ] `needs_caddy` variable checks if any service has `tls.is_some()` - - [ ] Only include Caddy service in docker-compose if `needs_caddy` is true - - [ ] `{% if tracker.http_api.tls %}` for API service block in Caddyfile - - [ ] `{% if http_tracker.tls %}` inside tracker iteration in Caddyfile - - [ ] `{% if grafana.tls %}` for Grafana service block in Caddyfile -- [ ] Update `ReleaseCommand` to include Caddy template generation -- [ ] Test template generation with various scenarios: - - [ ] All services HTTPS - - [ ] Only Tracker API HTTPS - - [ ] Multiple HTTP trackers, mixed HTTPS/HTTP - - [ ] No HTTPS (Caddy not deployed) +- [x] Create `CaddyProjectGenerator` following Project Generator pattern +- [x] Create `CaddyContext` with pre-processed data (following [Context Data Preparation Pattern](../contributing/templates/template-system-architecture.md#-context-data-preparation-pattern)): + - [x] `admin_email: String` - extracted from config + - [x] `use_staging: bool` - extracted from config + - [x] `http_api_service: Option` - only if TLS configured, with pre-extracted port + - [x] `http_tracker_services: Vec` - only TLS-enabled trackers, with pre-extracted ports + - [x] `grafana_service: Option` - only if TLS configured, with pre-extracted port +- [x] Create `CaddyService` struct with `domain: String` and `port: u16` +- [x] Implement port extraction in Rust (from `SocketAddr`) when building context +- [x] Handle conditional rendering in templates: + - [x] `needs_caddy` variable checks if any service list is non-empty + - [x] Only include Caddy service in docker-compose if `needs_caddy` is true + - [x] `{% if http_api_service %}` for API service block in Caddyfile + - [x] `{% for service in http_tracker_services %}` for tracker iteration in Caddyfile + - [x] `{% if grafana_service %}` for Grafana service block in Caddyfile +- [x] Update `ReleaseCommand` to include Caddy template generation: + - [x] Add `RenderCaddyTemplates` step to `ReleaseStep` enum + - [x] Add `DeployCaddyConfigToRemote` step to `ReleaseStep` enum + - [x] Create `RenderCaddyTemplatesStep` for template rendering + - [x] Create `DeployCaddyConfigStep` for Ansible deployment + - [x] Create Ansible playbook `deploy-caddy-config.yml` + - [x] Register playbook in `copy_static_templates` method + - [x] Integrate `CaddyContext` into Docker Compose template rendering + - [x] Add error variant `CaddyConfigDeployment` with help text +- [x] Test template generation with various scenarios: + - [x] All services HTTPS + - [x] Only Tracker API HTTPS + - [x] Multiple HTTP trackers, mixed HTTPS/HTTP + - [x] No HTTPS (Caddy not deployed) ### Phase 4: Security Workflow Updates (1 hour) -- [ ] Add `caddy:2.10` to security scan workflow matrix -- [ ] Add SARIF upload step for Caddy scan results -- [ ] Update `docs/security/docker/scans/README.md` with Caddy entry -- [ ] Run security scan locally to verify configuration -- [ ] Document vulnerability assessment (reference [docs/research/caddy-tls-proxy-evaluation/security-scan.md](../research/caddy-tls-proxy-evaluation/security-scan.md)) +- [x] Add `caddy:2.10` to security scan workflow matrix +- [x] Add SARIF upload step for Caddy scan results +- [x] Update `docs/security/docker/scans/README.md` with Caddy entry +- [x] Run security scan locally to verify configuration +- [x] Document vulnerability assessment (reference [docs/research/caddy-tls-proxy-evaluation/security-scan.md](../research/caddy-tls-proxy-evaluation/security-scan.md)) ### Phase 5: Documentation (4-5 hours) -- [ ] Create `docs/user-guide/https-setup.md` with complete setup guide: - - [ ] Prerequisites (domain names, DNS configuration) - - [ ] **Configuration patterns**: All services, single service, multiple trackers - - [ ] **Selective HTTPS**: How to enable HTTPS for some services but not others - - [ ] **Multiple HTTP trackers**: Configuration examples with mixed HTTPS/HTTP - - [ ] Environment configuration examples for each pattern - - [ ] Let's Encrypt certificate process - - [ ] **Let's Encrypt staging environment**: Document `use_staging: true` for testing (avoids rate limits) - - [ ] **Rate limits**: Document Let's Encrypt limits (50 certs/week, 5 duplicates/week) - - [ ] **Staging certificates warning**: Browser warnings expected (not trusted), only for testing - - [ ] Troubleshooting common issues - - [ ] Certificate renewal (automatic) - - [ ] Domain verification requirements -- [ ] Update `docs/user-guide/README.md` with HTTPS guide link -- [ ] Update `docs/user-guide/configuration.md` with HTTPS config examples: - - [ ] Example: HTTPS only for API (sensitive token) - - [ ] Example: Multiple trackers, selective HTTPS - - [ ] Example: VPN deployment without HTTPS -- [ ] Create example environment files in `envs/` demonstrating patterns -- [ ] Add troubleshooting section for common certificate issues -- [ ] Document Let's Encrypt rate limits and best practices +- [x] Create `docs/user-guide/services/https.md` with complete HTTPS setup guide: + - [x] Overview of HTTPS architecture with Caddy + - [x] Prerequisites (domain names, DNS configuration, firewall) + - [x] **Global HTTPS configuration**: `admin_email` and `use_staging` options + - [x] **Per-service TLS configuration**: `domain` and `use_tls_proxy` pattern + - [x] Services supporting HTTPS (Tracker HTTP API, HTTP Trackers, Health Check API, Grafana) + - [x] Let's Encrypt certificate process (automatic acquisition and renewal) + - [x] **Let's Encrypt staging environment**: Document `use_staging: true` for testing (avoids rate limits) + - [x] **Rate limits**: Document Let's Encrypt limits (50 certs/week, 5 duplicates/week) + - [x] **Staging certificates warning**: Browser warnings expected (not trusted), only for testing + - [x] Complete configuration example with all services HTTPS-enabled + - [x] Verification commands for checking HTTPS functionality + - [x] Troubleshooting section (DNS, firewall, certificates, Caddy logs) + - [x] Architecture explanation (Caddy as TLS termination proxy) +- [x] Update `docs/user-guide/services/README.md` with HTTPS service entry +- [x] Update `docs/user-guide/README.md` with HTTPS reference in services section +- [x] Update `docs/user-guide/services/grafana.md` with TLS proxy fields documentation +- [x] Regenerate JSON schema (`schemas/environment-config.json`) ### Phase 6: E2E Testing (5-6 hours) -**Automated E2E Tests**: +**Revised Strategy** (2026-01-20): + +The original plan to test multiple HTTPS patterns is not feasible because the Torrust Tracker +has only one config option to enable the TLS proxy - we cannot have some HTTP trackers using +HTTPS while others use HTTP simultaneously. Instead, we'll take a simpler, more maintainable approach: + +1. **Enable HTTPS for all HTTP trackers** in the E2E test configuration +2. **Use the `test` command** (smoke test) instead of manual validation +3. **Test non-HTTPS via UDP tracker** which never uses the Caddy proxy + +This approach provides comprehensive HTTPS coverage while leveraging existing infrastructure. + +**Why the `test` command cannot be used in Docker-based E2E tests** (2026-01-20): + +The `e2e_deployment_workflow_tests` binary uses Docker containers (via testcontainers) with +bridge networking, which creates a port mapping layer: + +- **Internal ports**: Tracker binds to configured ports (e.g., 1212, 7070) inside the container +- **External ports**: Docker maps these to random host ports (e.g., 1212 β†’ 32942) + +The `test` command reads `service_endpoints` from the persisted environment state, which contains +the configured internal ports (e.g., `http://127.0.0.1:1212/api/health_check`). However, to access +services from the host, we need the external mapped ports. This is similar to trying to test +infrastructure behind a VPN - the command only knows about local configuration, not external +network layers. + +The existing `run_run_validation()` in Docker-based E2E tests already handles this by using +`runtime_env.container_ports` which contains the actual Docker-mapped ports. + +**Solution: Use LXD VM-based E2E tests** (2026-01-20): + +The `e2e_complete_workflow_tests` binary uses LXD virtual machines instead of Docker containers. +In this setup, there's no port mapping layer - the configured ports match the actual ports +accessible from the host. The `test` command works correctly here because: + +- The VM has its own IP address (e.g., `10.x.x.x`) +- Services bind to configured ports (1212, 7070, etc.) +- The `test` command can reach services directly at `http://10.x.x.x:1212/api/health_check` + +This test binary already calls `validate_deployment()` which runs the `test` command, and it +passes successfully (verified 2026-01-20): + +```text +Deployment validated successfully, step: "test", environment: e2e-complete, status: "success" +``` + +**Note**: The LXD-based E2E tests cannot run on GitHub Actions CI due to network connectivity +requirements. They must be run manually on a local machine with LXD configured. + +**Implementation Status**: + +- [x] `test` command already supports HTTPS via `ServiceEndpoint::https()` with domain resolution +- [x] `validate_deployment()` exists in `E2eTestRunner` and calls the `test` command +- [x] LXD VM-based E2E tests (`e2e_complete_workflow_tests`) call `validate_deployment()` after `run_services()` +- [x] Verified LXD VM-based E2E tests pass (2026-01-20) +- [ ] Add HTTPS configuration to LXD VM-based E2E test config (future enhancement) +- [ ] Run LXD VM-based E2E tests with HTTPS configuration to validate end-to-end flow + +**Configuration Example** (E2E test config): + +```json +{ + "tracker": { + "http_trackers": [ + { + "bind_address": "0.0.0.0:7070", + "domain": "http1.tracker.local", + "use_tls_proxy": true + } + ], + "http_api": { + "bind_address": "0.0.0.0:1212", + "domain": "api.tracker.local", + "use_tls_proxy": true, + "admin_token": "MyAccessToken" + } + }, + "grafana": { + "admin_user": "admin", + "admin_password": "e2e-test-password", + "domain": "grafana.tracker.local", + "use_tls_proxy": true + }, + "https": { + "admin_email": "admin@tracker.local", + "use_staging": true + } +} +``` + +**Non-HTTPS coverage** (tested implicitly): + +- UDP tracker - never uses Caddy proxy, validates non-TLS path +- Health Check API - can be tested independently without TLS + +**Automated E2E Tests** (deferred - may not be needed): - [ ] Create E2E test environment configs with various HTTPS patterns: - [ ] All services HTTPS @@ -723,47 +822,787 @@ Add link to HTTPS setup guide. **Manual E2E Test** (reproduce production locally): -- [ ] Create manual test environment config in `envs/`: - - [ ] Base on production config (`envs/docker-hetzner-test.json`) - - [ ] Replace Hetzner provider with LXD provider - - [ ] Add TLS configuration matching production (all services HTTPS) - - [ ] Use test domains (e.g., `api.local.torrust-tracker.com`) -- [ ] Run provisioning locally: +- [x] Create manual test environment config in `envs/`: + - [x] Base on production config (`envs/docker-hetzner-test.json`) + - [x] Replace Hetzner provider with LXD provider + - [x] Add TLS configuration matching production (all services HTTPS) + - [x] Use test domains (e.g., `api.tracker.local`) +- [x] Run full deployment workflow locally: ```bash cargo run -- create environment --env-file envs/manual-https-test.json - cargo run -- create templates --env-name manual-https-test - cargo run -- create release --env-name manual-https-test + cargo run -- provision manual-https-test + cargo run -- configure manual-https-test + cargo run -- release manual-https-test + cargo run -- run manual-https-test ``` -- [ ] Verify rendered templates in `build/manual-https-test/`: - - [ ] Check `caddy/Caddyfile` contains all service blocks with correct domains - - [ ] Check `docker-compose/docker-compose.yml` includes Caddy service - - [ ] Verify port extraction from bind_address (e.g., 0.0.0.0:7070 β†’ 7070) - - [ ] Confirm Caddy volumes (caddy_data, caddy_config) are present +- [x] Verify rendered templates in `build/manual-https-test/`: + - [x] Check `caddy/Caddyfile` contains all service blocks with correct domains + - [x] Check `docker-compose/docker-compose.yml` includes Caddy service + - [x] Verify port extraction from bind_address (e.g., 0.0.0.0:7070 β†’ 7070) + - [x] Confirm Caddy volumes (caddy_data, caddy_config) are present +- [x] Verify Caddyfile deployed to server at `/opt/torrust/storage/caddy/etc/Caddyfile` +- [x] Verify Caddy container running and healthy +- [x] Verify Caddy logs show successful certificate acquisition (local CA for `.local` domains) +- [x] Verify HTTPS endpoints accessible via curl: + - [x] `https://api.tracker.local` - Tracker API responds (HTTP/2 500, expected - auth required) + - [x] `https://grafana.tracker.local` - Grafana redirects to `/login` (HTTP/2 302) + - [x] `https://http1.tracker.local` - HTTP Tracker responds (HTTP/2 404, expected for GET) + - [x] `https://http2.tracker.local` - HTTP Tracker responds (HTTP/2 404, expected for GET) +- [x] Verify HTTPβ†’HTTPS redirect works (HTTP 308 Permanent Redirect) +- [x] Verify `via: 1.1 Caddy` header present in responses +- [x] Verify HTTP/2 and HTTP/3 enabled (`alt-svc: h3=":443"` header) +- [x] Verify port filtering (TLS ports NOT exposed, non-TLS ports exposed) - [ ] Compare with production templates to ensure consistency - [ ] Document manual test procedure in `docs/e2e-testing/manual-https-testing.md` -### Phase 7: Schema Generation (30 minutes) +**Manual Test Results** (2026-01-14): + +| Test | Status | Notes | +| ------------------------------- | ------- | ------------------------------------------ | +| Caddyfile template rendering | βœ… Pass | Clean output, no formatting warnings | +| Caddy service in docker-compose | βœ… Pass | Ports 80, 443, 443/udp exposed | +| Caddyfile deployment to server | βœ… Pass | `/opt/torrust/storage/caddy/etc/Caddyfile` | +| Caddy container health | βœ… Pass | Running, healthy | +| Certificate acquisition | βœ… Pass | Local CA used for `.local` domains | +| HTTPS API endpoint | βœ… Pass | HTTP/2 500 (auth required) | +| HTTPS Grafana endpoint | βœ… Pass | HTTP/2 302 redirect to /login | +| HTTPS HTTP Tracker 1 | βœ… Pass | HTTP/2 404 (expected for GET) | +| HTTPS HTTP Tracker 2 | βœ… Pass | HTTP/2 404 (expected for GET) | +| HTTPβ†’HTTPS redirect | βœ… Pass | 308 Permanent Redirect | +| HTTP/2 enabled | βœ… Pass | Confirmed in response | +| HTTP/3 available | βœ… Pass | `alt-svc: h3=":443"` header | +| TLS port filtering | βœ… Pass | TLS ports hidden, non-TLS ports exposed | + +**Local DNS Setup** (for testing): + +Add to `/etc/hosts` (replace IP with your LXD VM IP): + +```text +10.140.190.58 api.tracker.local +10.140.190.58 http1.tracker.local +10.140.190.58 http2.tracker.local +10.140.190.58 grafana.tracker.local +``` + +**Certificate Behavior**: + +| Domain Type | Certificate Source | Trust Level | +| ------------------------------------------ | -------------------------- | ------------------------------ | +| Real domains (e.g., `tracker.example.com`) | Let's Encrypt (or staging) | Browser trusted | +| Local domains (e.g., `*.tracker.local`) | Caddy's Local CA | Self-signed (browser warnings) | +| Unreachable domains / No internet | Caddy's Local CA | Self-signed | + +**Manual E2E Test Procedure**: + +This section documents the step-by-step procedure for running manual E2E tests with HTTPS support. + +**1. Setup the environment configuration file**: + +Create an environment configuration file (e.g., `envs/manual-https-test.json`) with the desired HTTPS settings. See `envs/manual-https-test.json` for a complete example. + +**2. Run the deployment workflow**: + +```bash +# Destroy any existing environment with the same name +cargo run -- destroy manual-https-test + +# Clean up local build artifacts (if needed) +rm -rf data/manual-https-test build/manual-https-test + +# Create the environment +cargo run -- create environment --env-file envs/manual-https-test.json + +# Provision infrastructure (creates LXD VM) +cargo run -- provision manual-https-test + +# Configure the environment (install Docker, etc.) +cargo run -- configure manual-https-test + +# Release application (render templates and deploy to VM) +cargo run -- release manual-https-test + +# Run the application (start Docker Compose services) +cargo run -- run manual-https-test +``` + +**3. Verify local build artifacts**: + +Check the generated templates in `build//`: + +```bash +# Verify docker-compose.yml has correct port exposure +cat build/manual-https-test/docker-compose/docker-compose.yml + +# Verify Caddyfile has all TLS services configured +cat build/manual-https-test/caddy/Caddyfile +``` + +**4. Verify deployment on the VM**: + +Get the VM IP from the provision output or from environment data: + +```bash +# Check environment state for VM IP +cat data/manual-https-test/environment.json | jq '.context.provisioned_context.instance_ip' +``` + +SSH into the VM to verify services: + +```bash +# Check running containers (use your SSH key path) +ssh -i fixtures/testing_rsa -o StrictHostKeyChecking=no torrust@ "docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'" + +# Check Caddyfile inside the Caddy container +ssh -i fixtures/testing_rsa -o StrictHostKeyChecking=no torrust@ "docker exec caddy cat /etc/caddy/Caddyfile" + +# Check container logs if needed +ssh -i fixtures/testing_rsa -o StrictHostKeyChecking=no torrust@ "docker logs caddy --tail 50" +ssh -i fixtures/testing_rsa -o StrictHostKeyChecking=no torrust@ "docker logs tracker --tail 50" +``` + +**5. Key file locations on the VM**: + +| File | Location on VM | Location in Container | +| ------------------ | ------------------------------------------ | ------------------------------------ | +| docker-compose.yml | `/opt/torrust/docker-compose.yml` | N/A | +| .env | `/opt/torrust/.env` | N/A | +| Caddyfile | `/opt/torrust/storage/caddy/etc/Caddyfile` | `/etc/caddy/Caddyfile` (bind mount) | +| Tracker config | `/opt/torrust/storage/tracker/etc/` | `/etc/torrust/tracker/` (bind mount) | +| Caddy certificates | Docker volume `caddy_data` | `/data/` | + +**App directory**: The application is deployed to `/opt/torrust/`, **NOT** `/home/torrust/app/`. This is the working directory for docker compose commands on the VM. + +```bash +# Example: Check running containers on the VM +ssh -i fixtures/testing_rsa torrust@ "cd /opt/torrust && docker compose ps" + +# Example: View docker-compose.yml on the VM +ssh -i fixtures/testing_rsa torrust@ "cat /opt/torrust/docker-compose.yml" +``` + +**6. Port exposure verification**: + +For mixed TLS/non-TLS configurations, verify correct port exposure: + +- TLS-enabled services (API, HTTP trackers with TLS, Grafana with TLS) should NOT have ports exposed directly +- Non-TLS services (UDP trackers, HTTP trackers without TLS) should have ports exposed +- Caddy ports (80, 443, 443/udp) should always be exposed when HTTPS is configured + +Example verification with `docker ps`: + +```text +# Expected output for mixed TLS config (7070, 7071 have TLS, 7072 doesn't): +tracker 6969/udp, 7072/tcp # 7070, 7071 NOT exposed (Caddy handles them) +caddy 80/tcp, 443/tcp, 443/udp # Entry point for HTTPS +``` + +### Phase 7: CLI Command Compatibility with HTTPS (3-4 hours) + +When HTTPS is enabled, the deployer commands must adapt their behavior to work with domain-based URLs instead of direct IP addresses, and handle internal ports that are no longer directly accessible. + +#### 7.1: Update `test` command for HTTPS-enabled environments + +**Current Problem**: The `test` command validates services by accessing them directly via IP and internal ports (e.g., `http://10.140.190.214:1212/api/health_check`). When TLS is enabled for a service: + +1. The internal port (e.g., 1212) is not exposed externally - only Caddy ports (80, 443) are exposed +2. The service should be accessed via its HTTPS domain (e.g., `https://api.tracker.local`) + +**Current Behavior** (fails when TLS enabled): + +```text +$ cargo run -- test manual-https-test + +⏳ [1/3] Validating environment... +⏳ βœ“ Environment name validated: manual-https-test (took 0ms) +⏳ [2/3] Creating command handler... +⏳ βœ“ Done (took 0ms) +⏳ [3/3] Testing infrastructure... +❌ Test command failed: Validation failed for environment 'manual-https-test': Remote action failed: Action 'running-services-validation' validation failed: Tracker API external health check failed: error sending request for url (http://10.140.190.214:1212/api/health_check). Check that tracker is running and firewall allows port 1212. +``` + +**Required Changes**: + +- [ ] Detect if a service has TLS enabled from environment configuration +- [ ] For TLS-enabled services: + - [ ] Use the configured domain with HTTPS protocol instead of IP with internal port + - [ ] For local/test domains (e.g., `.local`), accept self-signed certificates from Caddy's local CA + - [ ] Show clear message: "Testing via HTTPS endpoint: https://api.tracker.local" +- [ ] For non-TLS services: + - [ ] Continue using direct IP and port access as before +- [ ] Update error messages to clarify the HTTPS testing behavior + +**Expected Behavior After Fix**: + +```text +Testing Tracker API via HTTPS: https://api.tracker.local/api/health_check βœ… +Testing HTTP Tracker (non-TLS): http://10.140.190.214:7072/announce βœ… +``` + +#### 7.2: Update `show` command for HTTPS-enabled environments βœ… COMPLETE + +**Current Problem**: The `show` command displays service endpoints using only IP addresses and internal ports, which are misleading when HTTPS is enabled: + +1. Displayed URLs may not work (internal ports not exposed) +2. Users don't know the correct HTTPS URLs to use +3. No indication that domain-based access is required + +**Current Behavior** (shows incorrect URLs when TLS enabled): + +```text +$ cargo run -- show manual-https-test + +Environment: manual-https-test +State: Running +Provider: LXD +Created: 2026-01-14 11:08:00 UTC + +Infrastructure: + Instance IP: 10.140.190.214 + SSH Port: 22 + SSH User: torrust + SSH Key: /home/.../fixtures/testing_rsa + +Connection: + ssh -i /home/.../fixtures/testing_rsa torrust@10.140.190.214 + +Tracker Services: + UDP Trackers: + - udp://10.140.190.214:6969/announce + HTTP Trackers: + - http://10.140.190.214:7070/announce # ❌ Port not exposed (TLS enabled) + - http://10.140.190.214:7071/announce # ❌ Port not exposed (TLS enabled) + - http://10.140.190.214:7072/announce # βœ… Works (no TLS) + API Endpoint: + - http://10.140.190.214:1212/api # ❌ Port not exposed (TLS enabled) + Health Check: + - http://10.140.190.214:1313/health_check + +Prometheus: + Internal only (localhost:9090) - not exposed externally + +Grafana: + http://10.140.190.214:3100/ # ❌ Port not exposed (TLS enabled) + +Services are running. Use 'test' to verify health. +``` + +**Required Changes**: + +- [x] Detect if a service has TLS enabled from environment configuration +- [x] For TLS-enabled services: + - [x] Show HTTPS URL with configured domain: `https://api.tracker.local` + - [ ] Show HTTP redirect URL: `http://api.tracker.local` (redirects to HTTPS) _(deferred - not essential)_ + - [x] Add note: "Direct IP access not available when TLS is enabled" +- [x] For non-TLS services: + - [x] Show direct IP URL as before: `http://10.140.190.214:7072` +- [x] Add informational section explaining: + - [x] "Services with TLS enabled must be accessed via their configured domain" + - [x] "For local domains (\*.local), add entries to /etc/hosts pointing to the VM IP" + - [x] "Internal ports are not directly accessible when TLS is enabled" + +**Expected Output After Fix**: + +```text +Environment: manual-https-test +State: Running +Provider: LXD +Created: 2026-01-14 11:08:00 UTC + +Infrastructure: + Instance IP: 10.140.190.214 + SSH Port: 22 + SSH User: torrust + +Tracker Services: + UDP Trackers: + - udp://10.140.190.214:6969/announce + HTTP Trackers (HTTPS via Caddy): + - https://http1.tracker.local/announce + - https://http2.tracker.local/announce + HTTP Trackers (direct): + - http://10.140.190.214:7072/announce + API Endpoint (HTTPS via Caddy): + - https://api.tracker.local/api + +Grafana (HTTPS via Caddy): + https://grafana.tracker.local/ + +Prometheus: + Internal only (localhost:9090) - not exposed externally + +Note: HTTPS services require domain-based access. For local domains (*.local), +add the following to your /etc/hosts file: + + 10.140.190.214 api.tracker.local http1.tracker.local http2.tracker.local grafana.tracker.local + +Internal ports (1212, 7070, 7071, 3000) are not directly accessible when TLS is enabled. +``` + +#### 7.3: Add TLS Support for Health Check API βœ… COMPLETE + +**Current State**: The health check API (`health_check_api`) doesn't support TLS configuration like other HTTP services (HTTP trackers, Tracker API, Grafana). + +**Problem**: Users may want to expose the health check API publicly with HTTPS for external monitoring systems, load balancers, or orchestration tools that need to verify service health. + +**Solution**: Add an optional `tls` field to the health check API configuration, following the same service-based TLS pattern used by other services. + +**Configuration Change**: + +```json +{ + "tracker": { + "health_check_api": { + "bind_address": "0.0.0.0:1313", + "tls": { + "domain": "health.tracker.local" + } + } + } +} +``` + +**Implementation Scope**: + +- [x] Add `tls: Option` to health check API domain model +- [x] Add `tls: Option` to health check API DTOs +- [x] Update Caddyfile template to include health check when TLS is configured +- [x] Update show command to display HTTPS URL when health check has TLS +- [ ] Update test command to use HTTPS for health check when TLS is configured (deferred to 7.1) + +> **Note**: JSON schema regeneration deferred to Phase 8. + +#### 7.4: Handle Localhost-Bound Services in Show Command and Validation βœ… COMPLETE + +**Current State**: + +- Services can bind to localhost (`127.0.0.1` or `::1`) +- If TLS is configured for such a service, Caddy cannot reach the backend (Caddy runs in a separate container, localhost is not shared between containers) +- The show command incorrectly displays public IP URLs for localhost-bound services + +**Problem Example**: Configuration has `"bind_address": "127.0.0.1:1313"` but show command displays `http://10.140.190.190:1313/health_check` which won't work because the service is only listening on localhost. + +**Solution** (two parts): + +##### Part A: Validation at Create Time + +Fail environment creation if any service has BOTH: + +- TLS configuration (`tls` section present) +- Localhost bind address (`127.0.0.1` or `::1`) + +**Error message example**: + +```text +Error: Invalid configuration for health_check_api + + The service binds to localhost (127.0.0.1:1313) but has TLS configured. + Caddy cannot proxy to localhost-bound services (different container network). + + To fix, either: + - Remove the 'tls' section to keep the service internal-only + - Change bind_address to '0.0.0.0:1313' to expose the service through Caddy +``` + +**Implementation Notes**: + +- Validation occurs in the domain layer when converting DTO to domain object (similar to the Grafanaβ†’Prometheus dependency validation) +- This is an internal rule per service, checked during DTO-to-domain conversion +- Services to validate: `health_check_api`, `http_api`, `http_trackers` (each individually) +- Grafana excluded: bind address is hardcoded (port 3000), not user-configurable +- Localhost detection: Check for `127.0.0.1` and `::1` (IPv6 localhost) only, not entire ranges + +##### Part B: Show Command for Localhost Services (without TLS) + +For services bound to localhost WITHOUT TLS, display: -- [ ] Regenerate JSON schema from Rust DTOs: +```text +Health Check: + Internal only (localhost:1313) - access via SSH tunnel +``` + +Instead of the incorrect: + +```text +Health Check: + - http://10.140.190.190:1313/health_check +``` + +**Implementation Notes**: + +- Add `is_localhost_only: bool` field to `ServiceInfo` for relevant services (don't put message in URL field) +- Show "Internal only" message for localhost-bound services - never hide services from output +- Principle: Keep user informed about everything. If keeping a service internal was an error, the user catches it sooner rather than wondering why the service is missing from output. + +**Implementation Scope**: + +- [x] Add validation in domain layer to reject localhost + TLS combinations (during DTO-to-domain conversion) +- [x] Update show command to detect localhost-bound services +- [x] Add `is_localhost_only` field to `ServiceInfo` for health check, API, and HTTP trackers +- [x] Display "Internal only" message for internal-only services +- [x] Apply to: health check API, HTTP API, HTTP trackers (Grafana excluded - hardcoded port) + +#### 7.5: Fix `on_reverse_proxy` Tracker Configuration Bug + +**Problem**: + +The Torrust Tracker has a configuration option `[core.net].on_reverse_proxy` that tells the tracker whether it's running behind a reverse proxy. When `true`, the tracker expects the `X-Forwarded-For` HTTP header to get the real client IP instead of the proxy's IP. This is critical for HTTP trackers to correctly identify peers. + +Currently, in `templates/tracker/tracker.toml.tera`, this option is **hardcoded to `true`**: + +```toml +[core.net] +on_reverse_proxy = true +``` + +This is wrong because: + +1. When an HTTP tracker is exposed directly (no Caddy proxy), the tracker expects `X-Forwarded-For` headers that won't exist, causing incorrect peer identification +2. The current implementation assumes all HTTP trackers with TLS go through Caddy, but users might want to use the tracker's built-in TLS support without a proxy + +**Tracker Configuration Limitation**: + +The `on_reverse_proxy` option is **global** (in `[core.net]`), not per-tracker. This means: + +- ALL HTTP trackers share the same setting +- You cannot have some trackers behind a proxy and others direct in the same deployment +- If ANY tracker uses a proxy, ALL trackers must be configured for proxy mode + +This is a limitation in the Torrust Tracker itself (not the deployer). A proper fix would require the tracker to support per-tracker `on_reverse_proxy` settings. + +**Upstream Issue**: [torrust/torrust-tracker#1640](https://github.com/torrust/torrust-tracker/issues/1640) + +**How to Reproduce**: + +1. Deploy the manual test environment with mixed TLS/non-TLS HTTP trackers: + + ```bash + cargo run -- show manual-https-test + ``` + +2. Verify the tracker config has `on_reverse_proxy = true` (set because trackers 7070, 7071 use TLS proxy): + + ```bash + cat build/manual-https-test/tracker/tracker.toml | grep -A2 "core.net" + # Output: [core.net] + # on_reverse_proxy = true + ``` + +3. Make a direct HTTP announce request to the tracker on port 7072 (no proxy): + + ```bash + curl -v "http://:7072/announce?info_hash=%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00&peer_id=-TR3000-000000000000&port=6881&uploaded=0&downloaded=0&left=0&event=started" + ``` + +4. Observe the failure response: + + ```text + d14:failure reason208:Error resolving peer IP: missing or invalid the right most + X-Forwarded-For IP (mandatory on reverse proxy tracker configuration)e + ``` + +The tracker on port 7072 expects `X-Forwarded-For` header (due to global `on_reverse_proxy = true`) but doesn't receive it from direct requests, causing the announce to fail. + +**Solution**: + +Rename `tls` to a clearer structure with `domain` at the top level and `use_tls_proxy` as a separate boolean. The `tls` name was misleading because it doesn't map to the tracker's TLS config - the domain is only used for Caddy proxy configuration. + +**Before** (current - using `tls` object): + +```json +{ + "environment": { + "name": "manual-https-test" + }, + "ssh_credentials": { + "private_key_path": "/path/to/fixtures/testing_rsa", + "public_key_path": "/path/to/fixtures/testing_rsa.pub" + }, + "provider": { + "provider": "lxd", + "profile_name": "torrust-profile-manual-https-test" + }, + "tracker": { + "core": { + "database": { + "driver": "sqlite3", + "database_name": "tracker.db" + }, + "private": false + }, + "udp_trackers": [ + { + "bind_address": "0.0.0.0:6969" + } + ], + "http_trackers": [ + { + "bind_address": "0.0.0.0:7070", + "tls": { + "domain": "http1.tracker.local" + } + }, + { + "bind_address": "0.0.0.0:7071", + "tls": { + "domain": "http2.tracker.local" + } + }, + { + "bind_address": "0.0.0.0:7072" + } + ], + "http_api": { + "bind_address": "0.0.0.0:1212", + "admin_token": "MyAccessToken", + "tls": { + "domain": "api.tracker.local" + } + }, + "health_check_api": { + "bind_address": "0.0.0.0:1313", + "tls": { + "domain": "health.tracker.local" + } + } + }, + "grafana": { + "admin_user": "admin", + "admin_password": "admin-password", + "tls": { + "domain": "grafana.tracker.local" + } + }, + "prometheus": { + "scrape_interval_in_secs": 15 + }, + "https": { + "admin_email": "admin@tracker.local", + "use_staging": true + } +} +``` + +**After** (proposed - using `domain` + `use_tls_proxy`): + +```json +{ + "environment": { + "name": "manual-https-test" + }, + "ssh_credentials": { + "private_key_path": "/path/to/fixtures/testing_rsa", + "public_key_path": "/path/to/fixtures/testing_rsa.pub" + }, + "provider": { + "provider": "lxd", + "profile_name": "torrust-profile-manual-https-test" + }, + "tracker": { + "core": { + "database": { + "driver": "sqlite3", + "database_name": "tracker.db" + }, + "private": false + }, + "udp_trackers": [ + { + "bind_address": "0.0.0.0:6969" + } + ], + "http_trackers": [ + { + "bind_address": "0.0.0.0:7070", + "domain": "http1.tracker.local", + "use_tls_proxy": true + }, + { + "bind_address": "0.0.0.0:7071", + "domain": "http2.tracker.local", + "use_tls_proxy": true + }, + { + "bind_address": "0.0.0.0:7072" + } + ], + "http_api": { + "bind_address": "0.0.0.0:1212", + "admin_token": "MyAccessToken", + "domain": "api.tracker.local", + "use_tls_proxy": true + }, + "health_check_api": { + "bind_address": "0.0.0.0:1313", + "domain": "health.tracker.local", + "use_tls_proxy": true + } + }, + "grafana": { + "admin_user": "admin", + "admin_password": "admin-password", + "domain": "grafana.tracker.local", + "use_tls_proxy": true + }, + "prometheus": { + "scrape_interval_in_secs": 15 + }, + "https": { + "admin_email": "admin@tracker.local", + "use_staging": true + } +} +``` + +**Configuration Semantics**: + +| `domain` | `use_tls_proxy` | Meaning | +| -------- | --------------- | --------------------------------------------------------- | +| absent | absent | Direct HTTP, no proxy | +| present | absent | HTTP with domain (for future use, e.g., DNS-based access) | +| present | `true` | HTTPS via Caddy proxy (TLS termination) | +| absent | `true` | **INVALID** - TLS proxy needs domain for virtual host | + +**Why `use_tls_proxy` (not `on_reverse_proxy`)?**: + +The name `use_tls_proxy` accurately describes what our Caddy proxy does: **TLS termination**. This naming choice is intentional for future compatibility: + +1. **Current state**: The tracker has a global `[core.net].on_reverse_proxy` option +2. **Future state**: The tracker may add per-tracker `on_reverse_proxy` support +3. **No conflict**: When that happens, we can expose both options without ambiguity: + +```json +{ + "bind_address": "0.0.0.0:7071", + "domain": "http2.tracker.local", + "use_tls_proxy": true, + "on_reverse_proxy": true +} +``` + +**Dependency Rule**: `use_tls_proxy: true` β†’ tracker's `on_reverse_proxy` MUST be `true`. This is enforced automatically: + +- When `use_tls_proxy: true`, the deployer sets the tracker's `[core.net].on_reverse_proxy = true` +- This is because Caddy sends `X-Forwarded-For` headers that the tracker must read + +**Future Compatibility**: If the tracker adds per-tracker `on_reverse_proxy`: + +- `use_tls_proxy` controls Caddy inclusion and implies `on_reverse_proxy: true` +- `on_reverse_proxy` could be explicitly set for edge cases (non-TLS reverse proxy) +- Validation: `use_tls_proxy: true` + `on_reverse_proxy: false` = **INVALID** + +**Behavior**: + +1. **Tracker config** (`[core.net].on_reverse_proxy`): + - Set to `true` if ANY HTTP tracker has `use_tls_proxy: true` + - Set to `false` otherwise + - Note: This only affects HTTP trackers; other services ignore it + +2. **Caddy config** (Caddyfile): + - Include service in Caddy config only if `use_tls_proxy: true` + - Requires `domain` to be present for the virtual host configuration + +3. **Validation rules**: + - `use_tls_proxy: true` requires `domain` to be present + - Localhost bind addresses with `use_tls_proxy: true` should be rejected (proxy can't reach localhost) + +**Known Limitation** (due to tracker's global setting): + +If you have multiple HTTP trackers where some use `use_tls_proxy` and others don't, the ones without it will still receive the global `on_reverse_proxy = true` setting and may fail if they receive direct requests without `X-Forwarded-For` headers. + +**Workaround**: Ensure all HTTP trackers in a deployment either ALL use the TLS proxy or NONE use it. + +**Reference**: [Torrust Tracker Network Configuration](https://docs.rs/torrust-tracker-configuration/latest/torrust_tracker_configuration/v2_0_0/network/struct.Network.html) + +**Implementation Scope**: + +The implementation is split into incremental steps, one service type at a time, to minimize risk and simplify review. + +##### Step 7.5.1: HTTP Trackers + +- [x] Add `domain: Option` and `use_tls_proxy: Option` to `HttpTrackerSection` DTO +- [x] Update `HttpTrackerConfig` domain type to include `use_tls_proxy` and `domain` +- [x] Add validation: `use_tls_proxy: true` requires `domain` to be present +- [x] Add validation: `use_tls_proxy: true` with localhost bind address β†’ reject +- [x] Update tracker config template (`templates/tracker/tracker.toml.tera`) to conditionally set `on_reverse_proxy` based on ANY HTTP tracker having `use_tls_proxy: true` +- [x] Update Caddy template (`templates/caddy/Caddyfile.tera`) to check `use_tls_proxy` for HTTP trackers +- [x] Update show command `ServiceInfo` for HTTP trackers +- [x] Update `envs/manual-https-test.json` for HTTP trackers only +- [x] Remove `TlsSection` from HTTP trackers (keep in other services temporarily) +- [x] Add unit tests for HTTP tracker validation +- [x] Run E2E tests to verify HTTP trackers work + +##### Step 7.5.2: Tracker REST API + +- [x] Add `domain: Option` and `use_tls_proxy: Option` to `HttpApiSection` DTO +- [x] Update `HttpApiConfig` domain type +- [x] Add validation rules (same as HTTP trackers) +- [x] Update Caddy template for API +- [x] Update show command `ServiceInfo` for API +- [x] Update `envs/manual-https-test.json` for API +- [x] Remove `TlsSection` from API +- [x] Add unit tests for API validation +- [x] Run E2E tests + +##### Step 7.5.3: Tracker Health Check API + +- [x] Add `domain: Option` and `use_tls_proxy: Option` to `HealthCheckApiSection` DTO +- [x] Update `HealthCheckApiConfig` domain type +- [x] Add validation rules +- [x] Update Caddy template for health check +- [x] Update show command `ServiceInfo` for health check +- [x] Update `envs/manual-https-test.json` for health check +- [x] Remove `TlsSection` from health check +- [x] Add unit tests +- [x] Run E2E tests + +##### Step 7.5.4: Grafana + +- [x] Add `domain: Option` and `use_tls_proxy: Option` to `GrafanaSection` DTO +- [x] Update `GrafanaConfig` domain type +- [x] Add validation rules (note: Grafana has no configurable bind address, so localhost validation not needed) +- [x] Update Caddy template for Grafana +- [x] Update show command `ServiceInfo` for Grafana +- [x] Update `envs/manual-https-test.json` for Grafana +- [x] Remove `TlsSection` from Grafana +- [x] Add unit tests +- [x] Run E2E tests + +##### Step 7.5.5: Cleanup and Final Verification + +- [x] Remove `TlsSection` type completely (should be unused after all services migrated) +- [x] Remove `domain::tls` module completely (unused after migration) +- [x] Run full E2E test suite +- [x] Run all linters +- [x] Manual verification with `envs/manual-https-test.json` + +### Phase 8: Schema Generation (30 minutes) + +- [x] Regenerate JSON schema from Rust DTOs: ```bash cargo run --bin torrust-tracker-deployer -- create schema > schemas/environment-config.json ``` -- [ ] Verify schema includes HTTPS configuration section -- [ ] Verify schema validation rules match Rust DTO constraints -- [ ] Test schema with example HTTPS-enabled environment file -- [ ] Commit updated schema file - -### Phase 8: Create ADR (1 hour) - -- [ ] Create `docs/decisions/caddy-for-tls-termination.md` -- [ ] Document decision rationale (reference #270 evaluation) -- [ ] Document alternatives considered (Pingoo, nginx+certbot) -- [ ] Document implementation approach -- [ ] Document risks and mitigations +- [x] Verify schema includes HTTPS configuration section +- [x] Verify schema validation rules match Rust DTO constraints +- [x] Test schema with example HTTPS-enabled environment file +- [x] Commit updated schema file + +### Phase 9: Create ADRs (1 hour) + +- [x] Create `docs/decisions/caddy-for-tls-termination.md` + - Document decision rationale (reference #270 evaluation) + - Document alternatives considered (Pingoo, nginx+certbot) + - Document implementation approach +- [x] Create `docs/decisions/per-service-tls-configuration.md` + - Document why we use `domain` + `use_tls_proxy` instead of nested `tls` section + - Document configuration matrix and validation rules +- [x] Create `docs/decisions/uniform-http-tracker-tls-requirement.md` + - Document tracker `on_reverse_proxy` global limitation + - Document why mixed HTTP tracker TLS is not allowed + - Reference upstream issue torrust/torrust-tracker#1640 ## Acceptance Criteria @@ -806,7 +1645,10 @@ Add link to HTTPS setup guide. - [ ] User guide index updated with HTTPS documentation link - [ ] Configuration examples include HTTPS scenarios - [ ] Troubleshooting section covers common certificate issues -- [ ] ADR created documenting Caddy adoption decision +- [x] ADRs created documenting key decisions: + - [x] `caddy-for-tls-termination.md` - Caddy adoption decision + - [x] `per-service-tls-configuration.md` - domain + use_tls_proxy pattern + - [x] `uniform-http-tracker-tls-requirement.md` - tracker on_reverse_proxy limitation **Testing**: @@ -826,6 +1668,20 @@ Add link to HTTPS setup guide. - [ ] Valid: no HTTPS configuration at all - [ ] WebSocket connectivity tested through Caddy proxy +**CLI Command Compatibility**: + +- [ ] `test` command works correctly with HTTPS-enabled services: + - [ ] Uses HTTPS domain URLs for TLS-enabled services + - [ ] Uses direct IP/port for non-TLS services + - [ ] Accepts self-signed certificates for local domains (e.g., `*.local`) + - [ ] Shows clear message indicating HTTPS test mode +- [ ] `show` command displays correct endpoints: + - [ ] Shows HTTPS URLs with domains for TLS-enabled services + - [ ] Shows direct IP/port for non-TLS services + - [ ] Includes note about domain-based access requirement + - [ ] Provides `/etc/hosts` configuration hint for local domains + - [ ] Clarifies internal ports are not accessible when TLS is enabled + **Production Verification**: - [ ] Test deployment with all services HTTPS enabled diff --git a/docs/security/docker/scans/README.md b/docs/security/docker/scans/README.md index faaa8478..041cea52 100644 --- a/docs/security/docker/scans/README.md +++ b/docs/security/docker/scans/README.md @@ -7,17 +7,19 @@ This directory contains historical security scan results for Docker images used | Image | Version | HIGH | CRITICAL | Status | Last Scan | Details | | -------------------------- | ------- | ---- | -------- | ------------ | ------------ | ----------------------------------- | | `torrust/tracker-deployer` | latest | 25 | 7 | ⚠️ Monitored | Jan 10, 2026 | [View](torrust-tracker-deployer.md) | +| `caddy` | 2.10 | 3 | 1 | ⚠️ Monitored | Jan 13, 2026 | [View](caddy.md) | | `prom/prometheus` | v3.5.0 | 0 | 0 | βœ… SECURE | Dec 29, 2025 | [View](prometheus.md) | | `grafana/grafana` | 12.3.1 | 0 | 0 | βœ… SECURE | Dec 29, 2025 | [View](grafana.md) | | `mysql` | 8.4 | 0 | 0 | βœ… SECURE | Dec 29, 2025 | [View](mysql.md) | -**Overall Status**: ⚠️ Deployer image has upstream Debian vulnerabilities (no fixes available yet). All other images secure. +**Overall Status**: ⚠️ Deployer and Caddy images have upstream vulnerabilities (fixes available, monitoring for releases). ## Scan Archives Each file contains the complete scan history for a service: - [torrust-tracker-deployer.md](torrust-tracker-deployer.md) - The deployer Docker image +- [caddy.md](caddy.md) - Caddy TLS termination proxy - [prometheus.md](prometheus.md) - Prometheus monitoring - [grafana.md](grafana.md) - Grafana dashboards - [mysql.md](mysql.md) - MySQL database diff --git a/docs/security/docker/scans/caddy.md b/docs/security/docker/scans/caddy.md new file mode 100644 index 00000000..4e66fbb7 --- /dev/null +++ b/docs/security/docker/scans/caddy.md @@ -0,0 +1,68 @@ +# Caddy Security Scan History + +**Image**: `caddy:2.10` +**Purpose**: TLS termination proxy for HTTPS support +**Documentation**: [Caddy TLS Proxy Evaluation](../../research/caddy-tls-proxy-evaluation/README.md) + +## Current Status + +| Version | HIGH | CRITICAL | Status | Scan Date | +| ------- | ---- | -------- | ------------ | ------------ | +| 2.10 | 3 | 1 | ⚠️ Monitored | Jan 13, 2026 | + +**Deployment Status**: βœ… Safe to deploy with monitoring + +## Vulnerability Summary + +The Caddy 2.10 image has: + +- **Alpine base image**: Clean (0 vulnerabilities) +- **Caddy binary (Go)**: 4 vulnerabilities in dependencies (not Caddy core) + +All vulnerabilities have fixed versions available upstream and are expected to be resolved in the next Caddy release. + +## Scan History + +### January 13, 2026 - caddy:2.10 + +**Scanner**: Trivy v0.68 + +| Target | Type | HIGH | CRITICAL | +| -------------------------- | -------- | ---- | -------- | +| caddy:2.10 (alpine 3.22.2) | alpine | 0 | 0 | +| usr/bin/caddy | gobinary | 3 | 1 | + +**Vulnerabilities Found**: + +| CVE | Severity | Component | Fixed Version | +| -------------- | -------- | --------------------------------- | --------------- | +| CVE-2025-44005 | CRITICAL | github.com/smallstep/certificates | 0.29.0 | +| CVE-2025-59530 | HIGH | github.com/quic-go/quic-go | 0.49.1, 0.54.1 | +| CVE-2025-58183 | HIGH | stdlib (archive/tar) | 1.24.8, 1.25.2 | +| CVE-2025-61729 | HIGH | stdlib (crypto/x509) | 1.24.11, 1.25.5 | + +**Risk Assessment**: + +1. **CVE-2025-44005**: Authorization bypass in certificate creation (smallstep library) +2. **CVE-2025-59530**: QUIC protocol crash (affects HTTP/3 only) +3. **CVE-2025-58183**: Unbounded allocation in tar parsing +4. **CVE-2025-61729**: Resource consumption in x509 certificate validation + +**Recommendation**: Deploy with monitoring. Update to patched version when Caddy v2.11 releases. + +## Related Documentation + +- [Full Security Analysis](../../../research/caddy-tls-proxy-evaluation/security-scan.md) +- [Caddy Evaluation Summary](../../../research/caddy-tls-proxy-evaluation/README.md) +- [HTTPS Implementation](../../../issues/272-add-https-support-with-caddy.md) + +## How to Rescan + +```bash +trivy image --severity HIGH,CRITICAL caddy:2.10 +``` + +## Security Advisories + +- **Caddy**: +- **Alpine Linux**: diff --git a/docs/user-guide/README.md b/docs/user-guide/README.md index 58959c03..40377478 100644 --- a/docs/user-guide/README.md +++ b/docs/user-guide/README.md @@ -308,8 +308,13 @@ The Torrust Tracker Deployer supports optional services that can be enabled in y ### Available Services -- **[Prometheus Monitoring](services/prometheus.md)** - Metrics collection and monitoring (enabled by default) +- **[HTTPS Support](services/https.md)** - Automatic TLS/SSL with Let's Encrypt (disabled by default) + - Automatic certificate management via Caddy reverse proxy + - Per-service TLS configuration (API, HTTP trackers, Health Check API, Grafana) + - HTTP/2 and HTTP/3 support + - Enabled by adding `domain` and `use_tls_proxy: true` to individual services +- **[Prometheus Monitoring](services/prometheus.md)** - Metrics collection and monitoring (enabled by default) - Automatic metrics scraping from tracker API - Web UI on port 9090 - Configurable scrape intervals diff --git a/docs/user-guide/services/README.md b/docs/user-guide/services/README.md index 3e19e1e4..ee78e224 100644 --- a/docs/user-guide/services/README.md +++ b/docs/user-guide/services/README.md @@ -14,8 +14,14 @@ The services documentation provides comprehensive guides for each optional servi ## Available Services -- **[Prometheus Monitoring](prometheus.md)** - Metrics collection and monitoring service +- **[HTTPS Support](https.md)** - Automatic TLS/SSL with Let's Encrypt + - Automatic certificate management via Caddy reverse proxy + - Per-service TLS configuration (API, HTTP trackers, Health Check API, Grafana) + - HTTP/2 and HTTP/3 support + - Automatic HTTP to HTTPS redirect + - Disabled by default, enabled by adding `domain` and `use_tls_proxy: true` to services +- **[Prometheus Monitoring](prometheus.md)** - Metrics collection and monitoring service - Automatic metrics scraping from tracker API endpoints - Web UI for querying and visualizing metrics - Configurable scrape intervals @@ -92,8 +98,6 @@ To exclude a service from your deployment, simply remove its configuration secti As the deployer evolves, additional optional services may be added to this directory: -- Database services (MySQL, PostgreSQL) -- Reverse proxy services (Nginx, Traefik) - Logging aggregation (Loki, Elasticsearch) - Alerting services (Alertmanager) diff --git a/docs/user-guide/services/grafana.md b/docs/user-guide/services/grafana.md index 2d02791a..7129e4e6 100644 --- a/docs/user-guide/services/grafana.md +++ b/docs/user-guide/services/grafana.md @@ -49,10 +49,23 @@ Add the `grafana` section to your environment configuration file: - Default: `admin` - **⚠️ SECURITY WARNING**: Always change the default password before deploying to production environments +**grafana.domain** (optional): + +- Domain name for HTTPS access via Caddy reverse proxy +- When present with `use_tls_proxy: true`, Grafana is accessible via HTTPS at this domain +- Caddy automatically obtains and renews TLS certificates + +**grafana.use_tls_proxy** (optional): + +- Boolean to enable HTTPS via Caddy reverse proxy +- Default: `false` (HTTP only) +- Requires `domain` to be set +- When enabled, port 3100 is not exposed; access is via HTTPS (port 443) + **Examples**: ```json -// Development environment (simple credentials) +// Development environment (HTTP only) { "grafana": { "admin_user": "admin", @@ -60,15 +73,19 @@ Add the `grafana` section to your environment configuration file: } } -// Production environment (strong credentials) +// Production environment with HTTPS { "grafana": { "admin_user": "grafana-admin", - "admin_password": "Str0ng!P@ssw0rd#2024" + "admin_password": "Str0ng!P@ssw0rd#2024", + "domain": "grafana.example.com", + "use_tls_proxy": true } } ``` +> **Note**: When using HTTPS, you must also configure the global `https` section with `admin_email`. See the [HTTPS Guide](https.md) for complete documentation. + **Real Example**: See [`envs/manual-test-grafana.json`](../../../envs/manual-test-grafana.json) for a working configuration. ### Prometheus Dependency @@ -117,23 +134,32 @@ To deploy without Grafana visualization, remove the entire `grafana` section fro After deployment, the Grafana web UI is available at: +**HTTP (default)**: + ```text http://:3100 ``` -Where `` is the IP address of your deployed VM instance. +**HTTPS (when TLS enabled)**: + +```text +https:// +``` + +Where `` is the IP address of your deployed VM instance and `` is the configured domain (e.g., `grafana.example.com`). ### Finding Your VM IP -```bash -# Extract IP from environment state -cat data//environment.json | grep ip_address +Use the `show` command to display environment information including the VM IP: -# Or use jq for cleaner output -INSTANCE_IP=$(cat data//environment.json | jq -r '.Running.context.runtime_outputs.instance_ip') -echo "Grafana UI: http://$INSTANCE_IP:3100" +```bash +torrust-tracker-deployer show ``` +Look for the "Instance IP" field in the output. + +> **Note**: JSON output format is planned for future releases to enable scripting. + ### First Login 1. Open `http://:3100` in your web browser @@ -151,17 +177,14 @@ echo "Grafana UI: http://$INSTANCE_IP:3100" After first login, you need to add Prometheus as a datasource: 1. **Navigate to Configuration**: - - Click the gear icon (βš™οΈ) in the left sidebar - Select **Data Sources** 2. **Add New Datasource**: - - Click **Add data source** - Select **Prometheus** from the list 3. **Configure Datasource**: - - **Name**: `Prometheus` (or any name you prefer) - **URL**: `http://prometheus:9090` - **Access**: `Server (default)` @@ -184,7 +207,6 @@ The Torrust project provides two sample dashboards for visualizing tracker metri #### Available Dashboards 1. **stats.json** - Statistics Dashboard - - Displays data from the `/api/v1/stats` tracker endpoint - Shows high-level tracker statistics - Good for general monitoring @@ -199,17 +221,14 @@ The Torrust project provides two sample dashboards for visualizing tracker metri #### Import Process 1. **Navigate to Dashboards**: - - Click the **+** icon in the left sidebar - Select **Import** 2. **Upload Dashboard**: - - Click **Upload JSON file** and select the dashboard file - Or paste the JSON content directly into the text area 3. **Configure Import**: - - **Name**: Keep the default or customize - **Folder**: Select a folder or leave as default - **Prometheus**: Select the datasource you created earlier @@ -254,16 +273,13 @@ After importing, you can: ### Creating Custom Dashboards 1. **New Dashboard**: - - Click **+** icon β†’ **Dashboard** - Click **Add visualization** 2. **Select Data Source**: - - Choose your Prometheus datasource 3. **Write Query**: - - Use Prometheus query language (PromQL) - Examples: @@ -279,7 +295,6 @@ After importing, you can: ``` 4. **Customize Visualization**: - - Choose panel type (Graph, Stat, Gauge, Table, etc.) - Set thresholds and colors - Add units and labels @@ -501,13 +516,11 @@ services: A separate issue is planned to add: 1. **Auto-Provision Prometheus Datasource**: - - Automatically create datasource during deployment - Zero-config experience for users - No manual setup steps required 2. **Auto-Import Tracker Dashboards**: - - Automatically import `stats.json` and `metrics.json` - Dashboards available immediately after deployment - Provisioning via `provisioning/dashboards/` directory @@ -525,6 +538,7 @@ A separate issue is planned to add: ## Related Documentation +- **[HTTPS Guide](https.md)** - Enable HTTPS with automatic TLS certificates - **[Prometheus Service Guide](prometheus.md)** - Metrics collection service - **[Manual Verification Guide](../../e2e-testing/manual/grafana-verification.md)** - Detailed verification steps - **[Grafana Integration ADR](../../decisions/grafana-integration-pattern.md)** - Design decisions and rationale diff --git a/docs/user-guide/services/https.md b/docs/user-guide/services/https.md new file mode 100644 index 00000000..e505875b --- /dev/null +++ b/docs/user-guide/services/https.md @@ -0,0 +1,442 @@ +# HTTPS Support (TLS/SSL) + +This guide covers enabling HTTPS for your Torrust Tracker deployment using automatic TLS certificates. + +## Overview + +The deployer includes [Caddy](https://caddyserver.com/) as an automatic TLS reverse proxy. When you enable HTTPS for any service, Caddy: + +- Automatically obtains and renews TLS certificates from Let's Encrypt +- Handles HTTPS termination (services run HTTP internally) +- Redirects HTTP to HTTPS automatically +- Supports HTTP/2 and HTTP/3 + +## Prerequisites + +Before enabling HTTPS, ensure you have: + +1. **Domain names** - Valid domain names pointing to your server's IP address +2. **DNS configured** - A records for each domain pointing to your server +3. **Ports 80 and 443 open** - Required for Let's Encrypt certificate validation +4. **Public IP** - Your server must be reachable from the internet + +> **Note**: For local testing with `.local` domains, Caddy uses its internal CA. Certificates will show browser warnings but work correctly. + +## Configuration + +### Global HTTPS Settings + +Add an `https` section to your environment configuration: + +```json +{ + "https": { + "admin_email": "admin@example.com", + "use_staging": false + } +} +``` + +**Configuration Fields**: + +| Field | Required | Default | Description | +| ------------- | ----------------------------- | ------- | --------------------------------------- | +| `admin_email` | Yes (if any service uses TLS) | - | Email for Let's Encrypt notifications | +| `use_staging` | No | `false` | Use Let's Encrypt staging (for testing) | + +### Enabling TLS Per Service + +Each service that supports HTTPS has two fields: + +- `domain` - The domain name for certificate acquisition +- `use_tls_proxy` - Whether to enable HTTPS via Caddy + +**Both fields are required** to enable HTTPS for a service. + +#### Tracker HTTP API + +```json +{ + "tracker": { + "http_api": { + "bind_address": "0.0.0.0:1212", + "admin_token": "MySecureToken", + "domain": "api.tracker.example.com", + "use_tls_proxy": true + } + } +} +``` + +#### HTTP Trackers + +Each HTTP tracker can independently have HTTPS enabled: + +```json +{ + "tracker": { + "http_trackers": [ + { + "bind_address": "0.0.0.0:7070", + "domain": "http1.tracker.example.com", + "use_tls_proxy": true + }, + { + "bind_address": "0.0.0.0:7071", + "domain": "http2.tracker.example.com", + "use_tls_proxy": true + }, + { + "bind_address": "0.0.0.0:7072" + } + ] + } +} +``` + +In this example, the first two trackers use HTTPS while the third uses HTTP only. + +#### Health Check API + +```json +{ + "tracker": { + "health_check_api": { + "bind_address": "0.0.0.0:1313", + "domain": "health.tracker.example.com", + "use_tls_proxy": true + } + } +} +``` + +#### Grafana + +```json +{ + "grafana": { + "admin_user": "admin", + "admin_password": "SecurePassword123!", + "domain": "grafana.example.com", + "use_tls_proxy": true + } +} +``` + +## Let's Encrypt + +### Production vs Staging + +| Environment | CA URL | Rate Limits | Browser Trust | +| ------------------------ | -------------------------------------- | -------------------------------- | ----------------- | +| **Production** (default) | `acme-v02.api.letsencrypt.org` | 50 certs/week, 5 duplicates/week | βœ… Trusted | +| **Staging** | `acme-staging-v02.api.letsencrypt.org` | Much higher | ❌ Shows warnings | + +**Use staging for**: + +- Initial testing and development +- CI/CD environments +- Verifying configuration before production + +```json +{ + "https": { + "admin_email": "admin@example.com", + "use_staging": true + } +} +``` + +### Rate Limits + +Production Let's Encrypt has these rate limits: + +- **50 certificates per week** per registered domain +- **5 duplicate certificates per week** per domain set +- **300 pending authorizations** per account +- **5 failed validations** per hostname per hour + +> **Tip**: Always test with `"use_staging": true` first to avoid hitting rate limits. + +### Certificate Renewal + +Caddy automatically renews certificates: + +- Renewal attempts begin **30 days** before expiry +- Renewal happens **daily at random times** to distribute load +- **No manual intervention** required +- Admin email receives warnings if renewal fails + +## Configuration Examples + +### Example 1: All Services with HTTPS + +Production deployment with HTTPS for all services: + +```json +{ + "environment": { + "name": "production" + }, + "ssh_credentials": { + "private_key_path": "~/.ssh/id_rsa", + "public_key_path": "~/.ssh/id_rsa.pub" + }, + "provider": { + "provider": "lxd", + "profile_name": "torrust-profile-prod" + }, + "https": { + "admin_email": "admin@example.com" + }, + "tracker": { + "http_api": { + "bind_address": "0.0.0.0:1212", + "admin_token": "MySecureToken", + "domain": "api.tracker.example.com", + "use_tls_proxy": true + }, + "http_trackers": [ + { + "bind_address": "0.0.0.0:7070", + "domain": "http.tracker.example.com", + "use_tls_proxy": true + } + ] + }, + "grafana": { + "admin_user": "admin", + "admin_password": "SecurePassword123!", + "domain": "grafana.example.com", + "use_tls_proxy": true + }, + "prometheus": { + "scrape_interval_in_secs": 15 + } +} +``` + +### Example 2: Only Tracker API with HTTPS + +Secure only the API (contains sensitive admin token), other services use HTTP: + +```json +{ + "https": { + "admin_email": "admin@example.com" + }, + "tracker": { + "http_api": { + "bind_address": "0.0.0.0:1212", + "admin_token": "MySecureToken", + "domain": "api.tracker.example.com", + "use_tls_proxy": true + }, + "http_trackers": [ + { + "bind_address": "0.0.0.0:7070" + } + ] + }, + "grafana": { + "admin_user": "admin", + "admin_password": "SecurePassword123!" + } +} +``` + +### Example 3: Local Testing with `.local` Domains + +For local development using LXD with self-signed certificates: + +```json +{ + "https": { + "admin_email": "admin@tracker.local", + "use_staging": true + }, + "tracker": { + "http_api": { + "bind_address": "0.0.0.0:1212", + "admin_token": "MyAccessToken", + "domain": "api.tracker.local", + "use_tls_proxy": true + }, + "http_trackers": [ + { + "bind_address": "0.0.0.0:7070", + "domain": "http1.tracker.local", + "use_tls_proxy": true + } + ] + }, + "grafana": { + "admin_user": "admin", + "admin_password": "admin-password", + "domain": "grafana.tracker.local", + "use_tls_proxy": true + } +} +``` + +> **Important**: Add entries to `/etc/hosts` on your machine to resolve `.local` domains: +> +> ```text +> api.tracker.local +> http1.tracker.local +> grafana.tracker.local +> ``` + +### Example 4: No HTTPS + +To deploy without HTTPS, simply omit the `https` section and `domain`/`use_tls_proxy` fields: + +```json +{ + "tracker": { + "http_api": { + "bind_address": "0.0.0.0:1212", + "admin_token": "MyAccessToken" + }, + "http_trackers": [ + { + "bind_address": "0.0.0.0:7070" + } + ] + } +} +``` + +## Disabling HTTPS + +To disable HTTPS for a service, either: + +1. **Remove both fields** - Omit `domain` and `use_tls_proxy` +2. **Set `use_tls_proxy: false`** - Keep domain but disable TLS + +If no services use HTTPS, you can also remove the `https` section entirely. + +## Port Behavior with HTTPS + +When HTTPS is enabled for a service: + +| Service | Without TLS | With TLS | +| ------------ | ------------------------- | ------------------------------------- | +| Tracker API | Port exposed (e.g., 1212) | Port hidden, accessed via Caddy (443) | +| HTTP Tracker | Port exposed (e.g., 7070) | Port hidden, accessed via Caddy (443) | +| Grafana | Port 3100 exposed | Port hidden, accessed via Caddy (443) | + +**Security benefit**: Backend service ports are not exposed when TLS is enabled, reducing attack surface. + +## Verification + +After deployment, verify HTTPS is working: + +### Check Certificate + +```bash +# Get VM IP from environment using the show command +torrust-tracker-deployer show +# Look for "Instance IP" in the output and set it: +INSTANCE_IP= + +# Test HTTPS endpoint (replace domain with your actual domain) +curl -v --resolve api.tracker.example.com:443:$INSTANCE_IP https://api.tracker.example.com/api/health_check +``` + +### Verify HTTP to HTTPS Redirect + +```bash +curl -I --resolve api.tracker.example.com:80:$INSTANCE_IP http://api.tracker.example.com/ +# Should return: HTTP/1.1 308 Permanent Redirect +``` + +### Check Caddy Status + +```bash +ssh -i torrust@$INSTANCE_IP "docker logs caddy --tail 20" +``` + +### Expected Responses + +| Endpoint | Expected Response | Notes | +| ------------ | ----------------------------- | -------------------------------- | +| Tracker API | HTTP 500 (Unauthorized) | Auth required, but TLS works | +| HTTP Tracker | HTTP 404 | GET not supported, but TLS works | +| Grafana | HTTP 302 (Redirect to /login) | Login page loads | + +## Troubleshooting + +### Certificate Acquisition Failed + +**Symptoms**: Caddy logs show ACME errors, browser shows certificate warnings. + +**Solutions**: + +1. **Check DNS** - Ensure domain points to your server's IP +2. **Check ports** - Ports 80 and 443 must be open and reachable +3. **Check rate limits** - Try staging mode first +4. **Check domain** - Must be a valid, publicly resolvable domain + +### Connection Refused + +**Symptoms**: `curl: (7) Failed to connect` + +**Solutions**: + +1. **Check Caddy is running**: `docker ps | grep caddy` +2. **Check firewall**: Ports 80, 443 must be open +3. **Check logs**: `docker logs caddy` + +### Self-Signed Certificate Warning + +**For `.local` domains**: This is expected. Caddy uses its internal CA for domains that can't be validated via Let's Encrypt. + +**For real domains**: Check that DNS is configured correctly and the domain is publicly reachable. + +### Invalid Configuration Errors + +| Error | Cause | Solution | +| ------------------------- | ---------------------------------------- | ------------------------------------------------- | +| `TlsProxyWithoutDomain` | `use_tls_proxy: true` without `domain` | Add `domain` field | +| `InvalidDomain` | Invalid domain format | Check domain syntax | +| `InvalidAdminEmail` | Invalid email format | Check email syntax | +| `HttpsRequiresTlsService` | `https` section without any TLS services | Add `use_tls_proxy: true` to at least one service | +| `TlsRequiresHttpsSection` | TLS service without `https` section | Add `https` section with `admin_email` | + +## Architecture + +### How It Works + +```text +Internet β†’ Port 443 β†’ Caddy (TLS termination) β†’ HTTP β†’ Service Container + ↑ + Reverse proxy by domain +``` + +1. **Caddy receives HTTPS requests** on port 443 +2. **Terminates TLS** using Let's Encrypt certificates +3. **Proxies to backend** via internal Docker network (HTTP) +4. **Returns response** over the encrypted connection + +### Docker Network + +- All services run in the same Docker network (`torrust-network`) +- Caddy accesses backend services by container name (e.g., `tracker:1212`) +- Backend ports are only exposed to Caddy, not to the host (when TLS enabled) + +### Certificate Storage + +Caddy stores certificates in Docker volumes: + +- `caddy_data` - Certificates and private keys +- `caddy_config` - Caddy configuration cache + +These volumes persist across container restarts, preventing unnecessary certificate reissuance. + +## Related Documentation + +- **[Security Guide](../security.md)** - Firewall and security configuration +- **[Grafana Service](grafana.md)** - Grafana-specific configuration +- **[Prometheus Service](prometheus.md)** - Prometheus-specific configuration +- **[Template Customization](../template-customization.md)** - Advanced template options diff --git a/project-words.txt b/project-words.txt index 64dfc31d..a232b0dc 100644 --- a/project-words.txt +++ b/project-words.txt @@ -28,8 +28,6 @@ Hillsboro Hostnames LUKS Liskov -smallstep -letsencrypt MAAACBA MLKEM MVVM @@ -47,6 +45,7 @@ QUIC RAII RUSTDOCFLAGS Repomix +Rescan Rustdoc SARIF SCRIPTDIR @@ -155,6 +154,7 @@ hexdump hotfixes htdocs hugepages +idna impls incompletei intervali @@ -170,6 +170,7 @@ kutca larstobi leecher leechers +letsencrypt libc libldap libpam @@ -243,6 +244,7 @@ println procps promtool publickey +publicsuffix pytest readlink realpath @@ -255,6 +257,7 @@ resolv rgba rootpass rpcinterface +rsplit rstest runbooks runcmd @@ -273,6 +276,7 @@ selectattr serde serverurl shellcheck +smallstep smorimoto spki spΓ«cial @@ -324,6 +328,7 @@ unergonomic unittests unrepresentable unsubscription +userexample usermod useroutput userpass @@ -338,6 +343,7 @@ vulns webservers writeln wrongpassword +yourdomain youruser zeroize Γ‰mojis @@ -345,3 +351,4 @@ zeroize ΠΊΠ»ΡŽΡ‡ ΠΊΠΎΠ½Ρ„ΠΈΠ³ Ρ„Π°ΠΉΠ» +reissuance diff --git a/schemas/environment-config.json b/schemas/environment-config.json index 08f02a5a..93051069 100644 --- a/schemas/environment-config.json +++ b/schemas/environment-config.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "EnvironmentCreationConfig", - "description": "Configuration for creating a deployment environment\n\nThis is the top-level configuration object that contains all information\nneeded to create a new deployment environment. It deserializes from JSON\nconfiguration and provides type-safe conversion to domain parameters.\n\n# Examples\n\n```rust\nuse torrust_tracker_deployer_lib::application::command_handlers::create::config::{\n EnvironmentCreationConfig, EnvironmentSection, ProviderSection, LxdProviderSection\n};\n\nlet json = r#\"{\n \"environment\": {\n \"name\": \"dev\"\n },\n \"ssh_credentials\": {\n \"private_key_path\": \"fixtures/testing_rsa\",\n \"public_key_path\": \"fixtures/testing_rsa.pub\"\n },\n \"provider\": {\n \"provider\": \"lxd\",\n \"profile_name\": \"torrust-profile-dev\"\n },\n \"tracker\": {\n \"core\": {\n \"database\": {\n \"driver\": \"sqlite3\",\n \"database_name\": \"tracker.db\"\n },\n \"private\": false\n },\n \"udp_trackers\": [\n {\n \"bind_address\": \"0.0.0.0:6969\"\n }\n ],\n \"http_trackers\": [\n {\n \"bind_address\": \"0.0.0.0:7070\"\n }\n ],\n \"http_api\": {\n \"bind_address\": \"0.0.0.0:1212\",\n \"admin_token\": \"MyAccessToken\"\n }\n },\n \"prometheus\": {\n \"scrape_interval_in_secs\": 15\n },\n \"grafana\": {\n \"admin_user\": \"admin\",\n \"admin_password\": \"admin\"\n }\n}\"#;\n\nlet config: EnvironmentCreationConfig = serde_json::from_str(json)?;\n# Ok::<(), Box>(())\n```", + "description": "Configuration for creating a deployment environment\n\nThis is the top-level configuration object that contains all information\nneeded to create a new deployment environment. It deserializes from JSON\nconfiguration and provides type-safe conversion to domain parameters.\n\n# Examples\n\n```rust\nuse torrust_tracker_deployer_lib::application::command_handlers::create::config::{\n EnvironmentCreationConfig, EnvironmentSection, ProviderSection, LxdProviderSection\n};\n\nlet json = r#\"{\n \"environment\": {\n \"name\": \"dev\"\n },\n \"ssh_credentials\": {\n \"private_key_path\": \"fixtures/testing_rsa\",\n \"public_key_path\": \"fixtures/testing_rsa.pub\"\n },\n \"provider\": {\n \"provider\": \"lxd\",\n \"profile_name\": \"torrust-profile-dev\"\n },\n \"tracker\": {\n \"core\": {\n \"database\": {\n \"driver\": \"sqlite3\",\n \"database_name\": \"tracker.db\"\n },\n \"private\": false\n },\n \"udp_trackers\": [\n {\n \"bind_address\": \"0.0.0.0:6969\"\n }\n ],\n \"http_trackers\": [\n {\n \"bind_address\": \"0.0.0.0:7070\"\n }\n ],\n \"http_api\": {\n \"bind_address\": \"0.0.0.0:1212\",\n \"admin_token\": \"MyAccessToken\"\n },\n \"health_check_api\": {\n \"bind_address\": \"127.0.0.1:1313\"\n }\n },\n \"prometheus\": {\n \"scrape_interval_in_secs\": 15\n },\n \"grafana\": {\n \"admin_user\": \"admin\",\n \"admin_password\": \"admin\"\n }\n}\"#;\n\nlet config: EnvironmentCreationConfig = serde_json::from_str(json)?;\n# Ok::<(), Box>(())\n```", "type": "object", "properties": { "environment": { @@ -20,6 +20,18 @@ ], "default": null }, + "https": { + "description": "HTTPS configuration (optional)\n\nWhen present, enables HTTPS for services that have TLS configured.\nContains common settings like admin email for Let's Encrypt.\n\n**Required if any service has TLS configured** - The `admin_email`\nis needed for Let's Encrypt certificate management.\n\nUses `HttpsSection` for JSON parsing.", + "anyOf": [ + { + "$ref": "#/$defs/HttpsSection" + }, + { + "type": "null" + } + ], + "default": null + }, "prometheus": { "description": "Prometheus monitoring configuration (optional)\n\nWhen present, Prometheus will be deployed to monitor the tracker.\nUses `PrometheusSection` for JSON parsing with String primitives.\nConverted to domain `PrometheusConfig` via `to_environment_params()`.", "anyOf": [ @@ -138,7 +150,7 @@ ] }, "GrafanaSection": { - "description": "Grafana configuration section (DTO)\n\nThis is a DTO that deserializes from JSON strings and validates\nwhen converting to the domain `GrafanaConfig`.\n\n# Security\n\nThe `admin_password` field uses `PlainPassword` type alias for string at\nDTO boundaries. It will be converted to `Password` (secrecy-wrapped) in\nthe domain layer.\n\n# Examples\n\n```json\n{\n \"admin_user\": \"admin\",\n \"admin_password\": \"admin\"\n}\n```", + "description": "Grafana configuration section (DTO)\n\nThis is a DTO that deserializes from JSON strings and validates\nwhen converting to the domain `GrafanaConfig`.\n\n# Security\n\nThe `admin_password` field uses `PlainPassword` type alias for string at\nDTO boundaries. It will be converted to `Password` (secrecy-wrapped) in\nthe domain layer.\n\n# Examples\n\n```json\n{\n \"admin_user\": \"admin\",\n \"admin_password\": \"admin\"\n}\n```\n\nWith TLS proxy configuration:\n```json\n{\n \"admin_user\": \"admin\",\n \"admin_password\": \"admin\",\n \"domain\": \"grafana.example.com\",\n \"use_tls_proxy\": true\n}\n```", "type": "object", "properties": { "admin_password": { @@ -148,6 +160,20 @@ "admin_user": { "description": "Grafana admin username", "type": "string" + }, + "domain": { + "description": "Domain name for external HTTPS access (optional)\n\nWhen present, defines the domain at which Grafana will be accessible.\nCaddy uses this for automatic certificate management.", + "type": [ + "string", + "null" + ] + }, + "use_tls_proxy": { + "description": "Whether to use TLS proxy via Caddy (default: false)\n\nWhen true:\n- Caddy handles HTTPS termination with automatic certificates\n- Requires a domain to be configured\n- Grafana is accessed via HTTPS through Caddy", + "type": [ + "boolean", + "null" + ] } }, "required": [ @@ -160,6 +186,20 @@ "properties": { "bind_address": { "type": "string" + }, + "domain": { + "description": "Domain name for HTTPS access via Caddy reverse proxy\n\nWhen present with `use_tls_proxy: true`, this service will be accessible\nvia HTTPS at this domain. The domain will be used for Let's Encrypt\ncertificate acquisition.", + "type": [ + "string", + "null" + ] + }, + "use_tls_proxy": { + "description": "Whether to proxy this service through Caddy with TLS termination\n\nWhen `true`, the service will be accessible via HTTPS through Caddy.\nRequires `domain` to be set.\nThis is useful for exposing health checks to external monitoring systems.", + "type": [ + "boolean", + "null" + ] } }, "required": [ @@ -202,6 +242,20 @@ }, "bind_address": { "type": "string" + }, + "domain": { + "description": "Domain name for HTTPS certificate acquisition\n\nWhen present along with `use_tls_proxy: true`, this service will be\naccessible via HTTPS through the Caddy reverse proxy using this domain.\nThe domain is used for Let's Encrypt certificate acquisition.", + "type": [ + "string", + "null" + ] + }, + "use_tls_proxy": { + "description": "Whether to proxy this service through Caddy with TLS termination\n\nWhen `true`:\n- The service is proxied through Caddy with HTTPS enabled\n- `domain` field is required\n- Cannot be used with localhost bind addresses (`127.0.0.1`, `::1`)\n\nWhen `false` or omitted:\n- The service is accessed directly without TLS termination\n- `domain` field is optional (ignored if present)", + "type": [ + "boolean", + "null" + ] } }, "required": [ @@ -214,12 +268,44 @@ "properties": { "bind_address": { "type": "string" + }, + "domain": { + "description": "Domain name for HTTPS certificate acquisition\n\nWhen present along with `use_tls_proxy: true`, this HTTP tracker will be\naccessible via HTTPS through the Caddy reverse proxy using this domain.\nThe domain is used for Let's Encrypt certificate acquisition.", + "type": [ + "string", + "null" + ] + }, + "use_tls_proxy": { + "description": "Whether to proxy this service through Caddy with TLS termination\n\nWhen `true`:\n- The service is proxied through Caddy with HTTPS enabled\n- `domain` field is required\n- Cannot be used with localhost bind addresses (`127.0.0.1`, `::1`)\n- Implies the tracker's `on_reverse_proxy` should be `true`\n\nWhen `false` or omitted:\n- The service is accessed directly without TLS termination\n- `domain` field is optional (ignored if present)", + "type": [ + "boolean", + "null" + ] } }, "required": [ "bind_address" ] }, + "HttpsSection": { + "description": "Common HTTPS configuration (top-level)\n\nContains configuration shared across all TLS-enabled services.\nThis section is required if any service has TLS enabled.\n\n# Let's Encrypt Environments\n\n- **Production** (default): Uses `https://acme-v02.api.letsencrypt.org/directory`\n - Rate limits: 50 certs/week per domain, 5 duplicates/week\n - Certificates are trusted by all browsers\n\n- **Staging** (`use_staging: true`): Uses `https://acme-staging-v02.api.letsencrypt.org/directory`\n - Much higher rate limits for testing\n - Certificates show browser warnings (not trusted)\n - Use only for testing the HTTPS flow\n\n# Examples\n\nProduction configuration:\n```json\n{\n \"https\": {\n \"admin_email\": \"admin@example.com\"\n }\n}\n```\n\nStaging configuration (for testing):\n```json\n{\n \"https\": {\n \"admin_email\": \"admin@example.com\",\n \"use_staging\": true\n }\n}\n```", + "type": "object", + "properties": { + "admin_email": { + "description": "Admin email for Let's Encrypt certificate notifications\n\nThis email will receive:\n- Certificate expiration warnings (30 days before expiry)\n- Certificate renewal failure notifications\n- Important Let's Encrypt service announcements\n\n**Note**: This email may be publicly visible in certificate transparency logs.", + "type": "string" + }, + "use_staging": { + "description": "Use Let's Encrypt staging environment for testing\n\nWhen `true`:\n- Uses staging CA: `https://acme-staging-v02.api.letsencrypt.org/directory`\n- Certificates will show browser warnings (not trusted by browsers)\n- Higher rate limits allow extensive testing\n\nWhen `false` or omitted (default):\n- Uses production CA: `https://acme-v02.api.letsencrypt.org/directory`\n- Certificates are trusted by all browsers\n- Subject to rate limits (50 certs/week, 5 duplicates/week)", + "type": "boolean", + "default": false + } + }, + "required": [ + "admin_email" + ] + }, "LxdProviderSection": { "description": "LXD-specific configuration section\n\nUses raw `String` for JSON deserialization. Convert to domain `LxdConfig`\nvia `ProviderSection::to_provider_config()`.\n\n# Examples\n\n```rust\nuse torrust_tracker_deployer_lib::application::command_handlers::create::config::LxdProviderSection;\n\nlet section = LxdProviderSection {\n profile_name: \"torrust-profile-dev\".to_string(),\n};\n```", "type": "object", diff --git a/src/application/command_handlers/create/config/environment_config.rs b/src/application/command_handlers/create/config/environment_config.rs index 0883e9cb..8bab794c 100644 --- a/src/application/command_handlers/create/config/environment_config.rs +++ b/src/application/command_handlers/create/config/environment_config.rs @@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize}; use crate::adapters::ssh::SshCredentials; use crate::domain::grafana::GrafanaConfig; +use crate::domain::https::HttpsConfig; use crate::domain::prometheus::PrometheusConfig; use crate::domain::provider::{Provider, ProviderConfig}; use crate::domain::tracker::TrackerConfig; @@ -16,6 +17,7 @@ use crate::domain::{EnvironmentName, InstanceName}; use super::errors::CreateConfigError; use super::grafana::GrafanaSection; +use super::https::HttpsSection; use super::prometheus::PrometheusSection; use super::provider::{HetznerProviderSection, LxdProviderSection, ProviderSection}; use super::ssh_credentials_config::SshCredentialsConfig; @@ -122,6 +124,18 @@ pub struct EnvironmentCreationConfig { /// Converted to domain `GrafanaConfig` via `to_environment_params()`. #[serde(default)] pub grafana: Option, + + /// HTTPS configuration (optional) + /// + /// When present, enables HTTPS for services that have TLS configured. + /// Contains common settings like admin email for Let's Encrypt. + /// + /// **Required if any service has TLS configured** - The `admin_email` + /// is needed for Let's Encrypt certificate management. + /// + /// Uses `HttpsSection` for JSON parsing. + #[serde(default)] + pub https: Option, } /// Environment-specific configuration section @@ -179,6 +193,7 @@ impl EnvironmentCreationConfig { /// TrackerSection::default(), /// None, /// None, + /// None, /// ); /// ``` #[must_use] @@ -189,6 +204,7 @@ impl EnvironmentCreationConfig { tracker: TrackerSection, prometheus: Option, grafana: Option, + https: Option, ) -> Self { Self { environment, @@ -197,6 +213,7 @@ impl EnvironmentCreationConfig { tracker, prometheus, grafana, + https, } } @@ -217,6 +234,7 @@ impl EnvironmentCreationConfig { /// - SSH username must follow Linux username requirements (see `Username`) /// - SSH key files must exist and be accessible /// - Grafana requires Prometheus (dependency validation) + /// - HTTPS configuration must be consistent (section present iff services have TLS) /// /// # Instance Name Auto-Generation /// @@ -233,6 +251,9 @@ impl EnvironmentCreationConfig { /// - SSH private key file does not exist /// - SSH public key file does not exist /// - Grafana is configured but Prometheus is not (dependency violation) + /// - HTTPS section is defined but no service has TLS configured + /// - A service has TLS configured but HTTPS section is missing + /// - HTTPS admin email is invalid /// /// # Examples /// @@ -261,6 +282,7 @@ impl EnvironmentCreationConfig { /// TrackerSection::default(), /// None, /// None, + /// None, // HTTPS configuration /// ); /// /// let result = config.to_environment_params()?; @@ -282,9 +304,13 @@ impl EnvironmentCreationConfig { TrackerConfig, Option, Option, + Option, ), CreateConfigError, > { + // Validate HTTPS configuration consistency before any other conversion + self.validate_https_config()?; + // Convert environment name string to domain type let environment_name = EnvironmentName::new(&self.environment.name)?; @@ -327,6 +353,11 @@ impl EnvironmentCreationConfig { return Err(CreateConfigError::GrafanaRequiresPrometheus); } + // Convert HTTPS section to domain type (already validated above) + let https_config = self + .https + .map(|section| HttpsConfig::new(section.admin_email, section.use_staging)); + Ok(( environment_name, instance_name, @@ -336,6 +367,7 @@ impl EnvironmentCreationConfig { tracker_config, prometheus_config, grafana_config, + https_config, )) } @@ -353,6 +385,66 @@ impl EnvironmentCreationConfig { .expect("Generated instance name should always be valid for valid environment names") } + /// Checks if any service has TLS configured + /// + /// Returns `true` if at least one of the following services has TLS: + /// - Tracker HTTP API + /// - Any HTTP tracker + /// - Grafana + /// + /// This is used for validation to ensure that when the HTTPS section is + /// defined, at least one service actually uses it. + #[must_use] + pub fn has_any_tls_configured(&self) -> bool { + // Check HTTP API + if self.tracker.http_api.use_tls_proxy == Some(true) { + return true; + } + + // Check HTTP trackers + for http_tracker in &self.tracker.http_trackers { + if http_tracker.use_tls_proxy == Some(true) { + return true; + } + } + + // Check Grafana + if let Some(ref grafana) = self.grafana { + if grafana.use_tls_proxy == Some(true) { + return true; + } + } + + false + } + + /// Validates HTTPS configuration consistency + /// + /// Validates that: + /// - If any service has TLS configured, the HTTPS section must be present + /// - If HTTPS section is present, at least one service must have TLS configured + /// - If HTTPS section is present, the admin email must be valid + /// + /// # Errors + /// + /// Returns `CreateConfigError::TlsWithoutHttpsSection` if a service has TLS but no HTTPS section. + /// Returns `CreateConfigError::HttpsSectionWithoutTls` if HTTPS section exists but no service has TLS. + /// Returns `CreateConfigError::InvalidAdminEmail` if the admin email format is invalid. + pub fn validate_https_config(&self) -> Result<(), CreateConfigError> { + let has_tls = self.has_any_tls_configured(); + + match (&self.https, has_tls) { + // TLS on services but no HTTPS section - error + (None, true) => Err(CreateConfigError::TlsWithoutHttpsSection), + // HTTPS section but no TLS on any service - error + (Some(_), false) => Err(CreateConfigError::HttpsSectionWithoutTls), + // HTTPS section with TLS on services - validate the section + (Some(https_section), true) => https_section.validate(), + // No HTTPS section and no TLS - valid (HTTP-only setup) + (None, false) => Ok(()), + } + } + /// Creates a template instance with placeholder values for a specific provider /// /// This method generates a configuration template with placeholder values @@ -415,15 +507,20 @@ impl EnvironmentCreationConfig { }], http_trackers: vec![super::tracker::HttpTrackerSection { bind_address: "0.0.0.0:7070".to_string(), + domain: None, + use_tls_proxy: None, }], http_api: super::tracker::HttpApiSection { bind_address: "0.0.0.0:1212".to_string(), admin_token: "MyAccessToken".to_string(), + domain: None, + use_tls_proxy: None, }, health_check_api: super::tracker::HealthCheckApiSection::default(), }, prometheus: Some(PrometheusSection::default()), grafana: Some(GrafanaSection::default()), + https: None, // Set to HttpsSection with admin_email for HTTPS deployments } } @@ -529,6 +626,7 @@ mod tests { TrackerSection::default(), None, None, + None, ); assert_eq!(config.environment.name, "dev"); @@ -678,6 +776,7 @@ mod tests { TrackerSection::default(), None, None, + None, ); let json = serde_json::to_string(&config).unwrap(); @@ -704,6 +803,7 @@ mod tests { TrackerSection::default(), None, None, + None, ); let result = config.to_environment_params(); @@ -718,6 +818,7 @@ mod tests { _tracker, _prometheus, _grafana, + _https, ) = result.unwrap(); assert_eq!(name.as_str(), "dev"); @@ -745,6 +846,7 @@ mod tests { TrackerSection::default(), None, None, + None, ); let result = config.to_environment_params(); @@ -759,6 +861,7 @@ mod tests { _tracker, _prometheus, _grafana, + _https, ) = result.unwrap(); assert_eq!(name.as_str(), "prod"); @@ -783,6 +886,7 @@ mod tests { TrackerSection::default(), None, None, + None, ); let result = config.to_environment_params(); @@ -814,6 +918,7 @@ mod tests { TrackerSection::default(), None, None, + None, ); let result = config.to_environment_params(); @@ -848,6 +953,7 @@ mod tests { TrackerSection::default(), None, None, + None, ); let result = config.to_environment_params(); @@ -884,6 +990,7 @@ mod tests { TrackerSection::default(), None, None, + None, ); let result = config.to_environment_params(); @@ -919,6 +1026,7 @@ mod tests { TrackerSection::default(), None, None, + None, ); let result = config.to_environment_params(); @@ -954,6 +1062,7 @@ mod tests { TrackerSection::default(), None, None, + None, ); let result = config.to_environment_params(); @@ -987,6 +1096,7 @@ mod tests { TrackerSection::default(), None, None, + None, ); let ( @@ -998,6 +1108,7 @@ mod tests { _tracker, _prometheus, _grafana, + _https, ) = config.to_environment_params().unwrap(); let environment = Environment::new( name.clone(), @@ -1031,6 +1142,7 @@ mod tests { TrackerSection::default(), None, None, + None, ); let json = serde_json::to_string_pretty(&original).unwrap(); @@ -1121,6 +1233,7 @@ mod tests { TrackerSection::default(), None, None, + None, ); // Both should serialize to same structure (different values) @@ -1252,4 +1365,250 @@ mod tests { assert!(nested_path.exists()); assert!(nested_path.parent().unwrap().exists()); } + + // HTTPS Validation Tests + + #[test] + fn it_should_return_false_for_has_any_tls_configured_when_no_tls() { + let config = EnvironmentCreationConfig::new( + EnvironmentSection { + name: "dev".to_string(), + instance_name: None, + }, + SshCredentialsConfig::new( + "fixtures/testing_rsa".to_string(), + "fixtures/testing_rsa.pub".to_string(), + "torrust".to_string(), + 22, + ), + default_lxd_provider("torrust-profile-dev"), + TrackerSection::default(), + None, + None, + None, + ); + + assert!(!config.has_any_tls_configured()); + } + + #[test] + fn it_should_return_true_for_has_any_tls_configured_when_http_api_has_tls() { + use crate::application::command_handlers::create::config::tracker::{ + DatabaseSection, HealthCheckApiSection, HttpApiSection, HttpTrackerSection, + TrackerCoreSection, TrackerSection, UdpTrackerSection, + }; + + let tracker_section = TrackerSection { + core: TrackerCoreSection { + database: DatabaseSection::Sqlite { + database_name: "tracker.db".to_string(), + }, + private: false, + }, + udp_trackers: vec![UdpTrackerSection { + bind_address: "0.0.0.0:6969".to_string(), + }], + http_trackers: vec![HttpTrackerSection { + bind_address: "0.0.0.0:7070".to_string(), + domain: None, + use_tls_proxy: None, + }], + http_api: HttpApiSection { + bind_address: "0.0.0.0:1212".to_string(), + admin_token: "MyAccessToken".to_string(), + domain: Some("api.tracker.example.com".to_string()), + use_tls_proxy: Some(true), + }, + health_check_api: HealthCheckApiSection::default(), + }; + + let config = EnvironmentCreationConfig::new( + EnvironmentSection { + name: "dev".to_string(), + instance_name: None, + }, + SshCredentialsConfig::new( + "fixtures/testing_rsa".to_string(), + "fixtures/testing_rsa.pub".to_string(), + "torrust".to_string(), + 22, + ), + default_lxd_provider("torrust-profile-dev"), + tracker_section, + None, + None, + None, + ); + + assert!(config.has_any_tls_configured()); + } + + #[test] + fn it_should_pass_validation_when_no_https_and_no_tls() { + let config = EnvironmentCreationConfig::new( + EnvironmentSection { + name: "dev".to_string(), + instance_name: None, + }, + SshCredentialsConfig::new( + "fixtures/testing_rsa".to_string(), + "fixtures/testing_rsa.pub".to_string(), + "torrust".to_string(), + 22, + ), + default_lxd_provider("torrust-profile-dev"), + TrackerSection::default(), + None, + None, + None, + ); + + assert!(config.validate_https_config().is_ok()); + } + + #[test] + fn it_should_fail_validation_when_tls_without_https_section() { + use crate::application::command_handlers::create::config::tracker::{ + DatabaseSection, HealthCheckApiSection, HttpApiSection, HttpTrackerSection, + TrackerCoreSection, TrackerSection, UdpTrackerSection, + }; + + let tracker_section = TrackerSection { + core: TrackerCoreSection { + database: DatabaseSection::Sqlite { + database_name: "tracker.db".to_string(), + }, + private: false, + }, + udp_trackers: vec![UdpTrackerSection { + bind_address: "0.0.0.0:6969".to_string(), + }], + http_trackers: vec![HttpTrackerSection { + bind_address: "0.0.0.0:7070".to_string(), + domain: Some("tracker.example.com".to_string()), + use_tls_proxy: Some(true), + }], + http_api: HttpApiSection { + bind_address: "0.0.0.0:1212".to_string(), + admin_token: "MyAccessToken".to_string(), + domain: None, + use_tls_proxy: None, + }, + health_check_api: HealthCheckApiSection::default(), + }; + + let config = EnvironmentCreationConfig::new( + EnvironmentSection { + name: "dev".to_string(), + instance_name: None, + }, + SshCredentialsConfig::new( + "fixtures/testing_rsa".to_string(), + "fixtures/testing_rsa.pub".to_string(), + "torrust".to_string(), + 22, + ), + default_lxd_provider("torrust-profile-dev"), + tracker_section, + None, + None, + None, // No HTTPS section + ); + + let result = config.validate_https_config(); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + CreateConfigError::TlsWithoutHttpsSection + )); + } + + #[test] + fn it_should_fail_validation_when_https_section_without_any_tls() { + use crate::application::command_handlers::create::config::https::HttpsSection; + + let config = EnvironmentCreationConfig::new( + EnvironmentSection { + name: "dev".to_string(), + instance_name: None, + }, + SshCredentialsConfig::new( + "fixtures/testing_rsa".to_string(), + "fixtures/testing_rsa.pub".to_string(), + "torrust".to_string(), + 22, + ), + default_lxd_provider("torrust-profile-dev"), + TrackerSection::default(), // No TLS on any service + None, + None, + Some(HttpsSection { + admin_email: "admin@example.com".to_string(), + use_staging: false, + }), + ); + + let result = config.validate_https_config(); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + CreateConfigError::HttpsSectionWithoutTls + )); + } + + #[test] + fn it_should_pass_validation_when_https_section_with_tls() { + use crate::application::command_handlers::create::config::https::HttpsSection; + use crate::application::command_handlers::create::config::tracker::{ + DatabaseSection, HealthCheckApiSection, HttpApiSection, HttpTrackerSection, + TrackerCoreSection, TrackerSection, UdpTrackerSection, + }; + + let tracker_section = TrackerSection { + core: TrackerCoreSection { + database: DatabaseSection::Sqlite { + database_name: "tracker.db".to_string(), + }, + private: false, + }, + udp_trackers: vec![UdpTrackerSection { + bind_address: "0.0.0.0:6969".to_string(), + }], + http_trackers: vec![HttpTrackerSection { + bind_address: "0.0.0.0:7070".to_string(), + domain: Some("tracker.example.com".to_string()), + use_tls_proxy: Some(true), + }], + http_api: HttpApiSection { + bind_address: "0.0.0.0:1212".to_string(), + admin_token: "MyAccessToken".to_string(), + domain: None, + use_tls_proxy: None, + }, + health_check_api: HealthCheckApiSection::default(), + }; + + let config = EnvironmentCreationConfig::new( + EnvironmentSection { + name: "dev".to_string(), + instance_name: None, + }, + SshCredentialsConfig::new( + "fixtures/testing_rsa".to_string(), + "fixtures/testing_rsa.pub".to_string(), + "torrust".to_string(), + 22, + ), + default_lxd_provider("torrust-profile-dev"), + tracker_section, + None, + None, + Some(HttpsSection { + admin_email: "admin@example.com".to_string(), + use_staging: false, + }), + ); + + assert!(config.validate_https_config().is_ok()); + } } diff --git a/src/application/command_handlers/create/config/errors.rs b/src/application/command_handlers/create/config/errors.rs index c3918032..4c3429a4 100644 --- a/src/application/command_handlers/create/config/errors.rs +++ b/src/application/command_handlers/create/config/errors.rs @@ -110,6 +110,41 @@ pub enum CreateConfigError { /// Tracker configuration validation failed #[error("Tracker configuration validation failed: {0}")] TrackerConfigValidation(#[from] TrackerConfigError), + + /// Invalid admin email format for HTTPS configuration + #[error("Invalid admin email '{email}': {reason}")] + InvalidAdminEmail { + /// The invalid email that was provided + email: String, + /// The reason why the email is invalid + reason: String, + }, + + /// Invalid domain name format for TLS configuration + #[error("Invalid domain '{domain}': {reason}")] + InvalidDomain { + /// The invalid domain that was provided + domain: String, + /// The reason why the domain is invalid + reason: String, + }, + + /// TLS configured for services but HTTPS section missing + #[error("TLS configured for services but 'https' section is missing")] + TlsWithoutHttpsSection, + + /// HTTPS section provided but no services have TLS configured + #[error("HTTPS section provided but no services have TLS configured")] + HttpsSectionWithoutTls, + + /// TLS proxy enabled but domain not specified + #[error("TLS proxy enabled for {service_type} '{bind_address}' but domain is missing")] + TlsProxyWithoutDomain { + /// The type of service (e.g., "HTTP tracker", "API") + service_type: String, + /// The bind address of the service + bind_address: String, + }, } impl CreateConfigError { @@ -450,6 +485,144 @@ impl CreateConfigError { \n\ Related: docs/external-issues/tracker/udp-tcp-port-sharing-allowed.md" } + Self::InvalidAdminEmail { .. } => { + "Invalid admin email format for HTTPS configuration.\n\ + \n\ + The admin email is used for Let's Encrypt certificate notifications:\n\ + - Certificate expiration warnings (30 days before expiry)\n\ + - Certificate renewal failure notifications\n\ + - Important Let's Encrypt service announcements\n\ + \n\ + Requirements:\n\ + - Must contain '@' with content on both sides\n\ + - Must have a valid domain part (with at least one dot)\n\ + \n\ + Valid examples:\n\ + - admin@example.com\n\ + - certificates@my-company.org\n\ + - alerts+ssl@subdomain.example.com\n\ + \n\ + Fix:\n\ + Update the admin_email in your https configuration:\n\ + \n\ + \"https\": {\n\ + \"admin_email\": \"admin@yourdomain.com\"\n\ + }\n\ + \n\ + Note: This email may be visible in certificate transparency logs." + } + Self::InvalidDomain { .. } => { + "Invalid domain name format for TLS configuration.\n\ + \n\ + Domain names are used for:\n\ + - HTTPS certificate acquisition (Let's Encrypt HTTP-01 challenge)\n\ + - Caddy reverse proxy routing\n\ + - SNI-based TLS termination\n\ + \n\ + Requirements:\n\ + - Contains only letters, numbers, dots, and hyphens\n\ + - Has at least one dot (TLD separator)\n\ + - Doesn't start or end with dots or hyphens\n\ + \n\ + Valid examples:\n\ + - api.example.com\n\ + - tracker.torrust.org\n\ + - grafana.my-project.io\n\ + \n\ + Invalid examples:\n\ + - localhost (no TLD)\n\ + - -example.com (starts with hyphen)\n\ + - example_domain.com (underscore not allowed)\n\ + \n\ + Fix:\n\ + Update the domain in your service's tls configuration:\n\ + \n\ + \"tls\": {\n\ + \"domain\": \"api.yourdomain.com\"\n\ + }\n\ + \n\ + Note: The domain must point to your server's IP before certificate acquisition." + } + Self::TlsWithoutHttpsSection => { + "TLS configured for services but 'https' section is missing.\n\ + \n\ + You have configured TLS for one or more services but haven't provided\n\ + the required HTTPS configuration section with admin_email.\n\ + \n\ + The admin_email is required because:\n\ + - Let's Encrypt requires an email for certificate management\n\ + - You'll receive expiration warnings and renewal failure notifications\n\ + \n\ + Fix:\n\ + Add an 'https' section to your environment configuration:\n\ + \n\ + \"https\": {\n\ + \"admin_email\": \"admin@yourdomain.com\",\n\ + \"use_staging\": false // optional, defaults to false\n\ + }\n\ + \n\ + Note: Set use_staging to true for testing (avoids rate limits, but\n\ + certificates will show browser warnings)." + } + Self::HttpsSectionWithoutTls => { + "HTTPS section provided but no services have TLS configured.\n\ + \n\ + You have provided an 'https' section with admin_email but no services\n\ + have TLS enabled. This is likely a configuration error.\n\ + \n\ + To enable HTTPS for a service, add a 'tls' section to it:\n\ + \n\ + For Tracker API:\n\ + \"http_api\": {\n\ + \"bind_address\": \"0.0.0.0:1212\",\n\ + \"admin_token\": \"MyAccessToken\",\n\ + \"tls\": {\n\ + \"domain\": \"api.example.com\"\n\ + }\n\ + }\n\ + \n\ + For HTTP Tracker:\n\ + \"http_trackers\": [{\n\ + \"bind_address\": \"0.0.0.0:7070\",\n\ + \"tls\": {\n\ + \"domain\": \"tracker.example.com\"\n\ + }\n\ + }]\n\ + \n\ + For Grafana:\n\ + \"grafana\": {\n\ + \"admin_user\": \"admin\",\n\ + \"admin_password\": \"admin\",\n\ + \"tls\": {\n\ + \"domain\": \"grafana.example.com\"\n\ + }\n\ + }\n\ + \n\ + Alternatively, remove the 'https' section entirely if you don't want HTTPS." + } + Self::TlsProxyWithoutDomain { .. } => { + "TLS proxy enabled but domain is missing.\n\ + \n\ + When use_tls_proxy is set to true, you must also specify a domain name\n\ + for the HTTPS certificate acquisition.\n\ + \n\ + The domain is required because:\n\ + - Caddy needs it to request a Let's Encrypt certificate\n\ + - SNI-based TLS termination routes requests to the correct service\n\ + \n\ + Fix:\n\ + Add a domain when enabling the TLS proxy:\n\ + \n\ + For HTTP Tracker:\n\ + \"http_trackers\": [{\n\ + \"bind_address\": \"0.0.0.0:7070\",\n\ + \"domain\": \"tracker.example.com\",\n\ + \"use_tls_proxy\": true\n\ + }]\n\ + \n\ + Alternatively, if you don't want HTTPS for this service,\n\ + remove or set use_tls_proxy to false." + } } } } @@ -534,7 +707,7 @@ mod tests { let help = error.help(); assert!(!help.is_empty(), "Help text should not be empty"); assert!( - help.contains("Fix:") || help.contains("Common"), + help.contains("Fix") || help.contains("Common"), "Help should contain actionable guidance" ); } diff --git a/src/application/command_handlers/create/config/grafana.rs b/src/application/command_handlers/create/config/grafana.rs index 0c703da4..3a18bfd7 100644 --- a/src/application/command_handlers/create/config/grafana.rs +++ b/src/application/command_handlers/create/config/grafana.rs @@ -11,6 +11,8 @@ use crate::application::command_handlers::create::config::errors::CreateConfigEr use crate::domain::grafana::GrafanaConfig; use crate::shared::secrets::PlainPassword; +use crate::shared::DomainName; + /// Grafana configuration section (DTO) /// /// This is a DTO that deserializes from JSON strings and validates @@ -30,6 +32,16 @@ use crate::shared::secrets::PlainPassword; /// "admin_password": "admin" /// } /// ``` +/// +/// With TLS proxy configuration: +/// ```json +/// { +/// "admin_user": "admin", +/// "admin_password": "admin", +/// "domain": "grafana.example.com", +/// "use_tls_proxy": true +/// } +/// ``` #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct GrafanaSection { /// Grafana admin username @@ -40,6 +52,22 @@ pub struct GrafanaSection { /// This will be converted to `Password` type in the domain layer /// to prevent accidental exposure in logs or debug output. pub admin_password: PlainPassword, + + /// Domain name for external HTTPS access (optional) + /// + /// When present, defines the domain at which Grafana will be accessible. + /// Caddy uses this for automatic certificate management. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub domain: Option, + + /// Whether to use TLS proxy via Caddy (default: false) + /// + /// When true: + /// - Caddy handles HTTPS termination with automatic certificates + /// - Requires a domain to be configured + /// - Grafana is accessed via HTTPS through Caddy + #[serde(default, skip_serializing_if = "Option::is_none")] + pub use_tls_proxy: Option, } impl Default for GrafanaSection { @@ -48,6 +76,8 @@ impl Default for GrafanaSection { Self { admin_user: default_config.admin_user().to_string(), admin_password: default_config.admin_password().expose_secret().to_string(), + domain: None, + use_tls_proxy: None, } } } @@ -61,12 +91,37 @@ impl GrafanaSection { /// /// # Errors /// - /// Currently returns `Ok` for all valid inputs. Future versions may - /// add validation for `admin_user` format or password strength requirements. + /// Returns `CreateConfigError::InvalidDomain` if the domain is invalid. + /// Returns `CreateConfigError::TlsProxyWithoutDomain` if `use_tls_proxy` + /// is true but no domain is provided. pub fn to_grafana_config(&self) -> Result { + let use_tls_proxy = self.use_tls_proxy.unwrap_or(false); + + // Validate: use_tls_proxy requires domain + if use_tls_proxy && self.domain.is_none() { + return Err(CreateConfigError::TlsProxyWithoutDomain { + service_type: "Grafana".to_string(), + bind_address: "N/A (hardcoded port 3000)".to_string(), + }); + } + + // Parse domain if present + let domain = + match &self.domain { + Some(domain_str) => Some(DomainName::new(domain_str).map_err(|e| { + CreateConfigError::InvalidDomain { + domain: domain_str.clone(), + reason: e.to_string(), + } + })?), + None => None, + }; + Ok(GrafanaConfig::new( self.admin_user.clone(), self.admin_password.clone(), + domain, + use_tls_proxy, )) } } @@ -80,6 +135,8 @@ mod tests { let section = GrafanaSection::default(); assert_eq!(section.admin_user, "admin"); assert_eq!(section.admin_password, "admin"); + assert!(section.domain.is_none()); + assert!(section.use_tls_proxy.is_none()); } #[test] @@ -87,6 +144,8 @@ mod tests { let section = GrafanaSection { admin_user: "custom_admin".to_string(), admin_password: "secure_password".to_string(), + domain: None, + use_tls_proxy: None, }; let result = section.to_grafana_config(); @@ -112,6 +171,8 @@ mod tests { let section = GrafanaSection { admin_user: "admin".to_string(), admin_password: "secret_password".to_string(), + domain: None, + use_tls_proxy: None, }; let config = section.to_grafana_config().unwrap(); @@ -121,4 +182,76 @@ mod tests { assert!(debug_output.contains("[REDACTED]")); assert!(!debug_output.contains("secret_password")); } + + #[test] + fn it_should_convert_with_domain_and_tls_proxy() { + let section = GrafanaSection { + admin_user: "admin".to_string(), + admin_password: "password".to_string(), + domain: Some("grafana.example.com".to_string()), + use_tls_proxy: Some(true), + }; + + let result = section.to_grafana_config(); + assert!(result.is_ok()); + + let config = result.unwrap(); + assert_eq!(config.tls_domain(), Some("grafana.example.com")); + assert!(config.use_tls_proxy()); + } + + #[test] + fn it_should_convert_with_domain_without_tls_proxy() { + let section = GrafanaSection { + admin_user: "admin".to_string(), + admin_password: "password".to_string(), + domain: Some("grafana.example.com".to_string()), + use_tls_proxy: Some(false), + }; + + let result = section.to_grafana_config(); + assert!(result.is_ok()); + + let config = result.unwrap(); + assert_eq!( + config.domain(), + Some(&DomainName::new("grafana.example.com").unwrap()) + ); + assert!(!config.use_tls_proxy()); + } + + #[test] + fn it_should_return_error_when_tls_proxy_enabled_without_domain() { + let section = GrafanaSection { + admin_user: "admin".to_string(), + admin_password: "password".to_string(), + domain: None, + use_tls_proxy: Some(true), + }; + + let result = section.to_grafana_config(); + assert!(result.is_err()); + + let err = result.unwrap_err(); + assert!(matches!( + err, + CreateConfigError::TlsProxyWithoutDomain { .. } + )); + } + + #[test] + fn it_should_return_error_for_invalid_domain() { + let section = GrafanaSection { + admin_user: "admin".to_string(), + admin_password: "password".to_string(), + domain: Some(String::new()), + use_tls_proxy: Some(true), + }; + + let result = section.to_grafana_config(); + assert!(result.is_err()); + + let err = result.unwrap_err(); + assert!(matches!(err, CreateConfigError::InvalidDomain { .. })); + } } diff --git a/src/application/command_handlers/create/config/https.rs b/src/application/command_handlers/create/config/https.rs new file mode 100644 index 00000000..de06ccad --- /dev/null +++ b/src/application/command_handlers/create/config/https.rs @@ -0,0 +1,218 @@ +//! HTTPS Configuration DTOs (Application Layer) +//! +//! This module contains DTO types for HTTPS/TLS configuration used in +//! environment creation. These types enable automatic HTTPS setup with +//! Caddy as a TLS termination proxy. +//! +//! ## Architecture +//! +//! The HTTPS configuration follows a **service-based approach** where: +//! - Common HTTPS settings (admin email, staging flag) are at the top level +//! - Each service (tracker API, HTTP trackers, Grafana) has optional TLS config +//! +//! See [ADR: Service-Based TLS Configuration](../../../../docs/decisions/) for rationale. +//! +//! ## DTO vs Domain Types +//! +//! These types are Data Transfer Objects that use primitive types (`String`) for +//! JSON deserialization. Validation converts to domain types (e.g., `Email`) which +//! provide RFC-compliant validation via external crates like `email_address`. +//! +//! This separation allows: +//! - Clean JSON serialization/deserialization at DTO boundaries +//! - Rich domain validation via strongly-typed domain types +//! - No domain type coupling to serialization concerns +//! +//! ## Security +//! +//! The `admin_email` field is considered semi-sensitive as it's used in +//! Let's Encrypt certificate requests and may be visible in certificate +//! transparency logs. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::errors::CreateConfigError; +use crate::shared::Email; + +/// Common HTTPS configuration (top-level) +/// +/// Contains configuration shared across all TLS-enabled services. +/// This section is required if any service has TLS enabled. +/// +/// # Let's Encrypt Environments +/// +/// - **Production** (default): Uses `https://acme-v02.api.letsencrypt.org/directory` +/// - Rate limits: 50 certs/week per domain, 5 duplicates/week +/// - Certificates are trusted by all browsers +/// +/// - **Staging** (`use_staging: true`): Uses `https://acme-staging-v02.api.letsencrypt.org/directory` +/// - Much higher rate limits for testing +/// - Certificates show browser warnings (not trusted) +/// - Use only for testing the HTTPS flow +/// +/// # Examples +/// +/// Production configuration: +/// ```json +/// { +/// "https": { +/// "admin_email": "admin@example.com" +/// } +/// } +/// ``` +/// +/// Staging configuration (for testing): +/// ```json +/// { +/// "https": { +/// "admin_email": "admin@example.com", +/// "use_staging": true +/// } +/// } +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct HttpsSection { + /// Admin email for Let's Encrypt certificate notifications + /// + /// This email will receive: + /// - Certificate expiration warnings (30 days before expiry) + /// - Certificate renewal failure notifications + /// - Important Let's Encrypt service announcements + /// + /// **Note**: This email may be publicly visible in certificate transparency logs. + pub admin_email: String, + + /// Use Let's Encrypt staging environment for testing + /// + /// When `true`: + /// - Uses staging CA: `https://acme-staging-v02.api.letsencrypt.org/directory` + /// - Certificates will show browser warnings (not trusted by browsers) + /// - Higher rate limits allow extensive testing + /// + /// When `false` or omitted (default): + /// - Uses production CA: `https://acme-v02.api.letsencrypt.org/directory` + /// - Certificates are trusted by all browsers + /// - Subject to rate limits (50 certs/week, 5 duplicates/week) + #[serde(default)] + pub use_staging: bool, +} + +impl HttpsSection { + /// Creates a new HTTPS configuration section + #[must_use] + pub fn new(admin_email: String, use_staging: bool) -> Self { + Self { + admin_email, + use_staging, + } + } + + /// Validates the HTTPS configuration + /// + /// Uses the domain-level `Email` type for RFC-compliant validation via + /// the `email_address` crate. + /// + /// # Errors + /// + /// Returns `CreateConfigError::InvalidAdminEmail` if the email format is invalid. + pub fn validate(&self) -> Result<(), CreateConfigError> { + // Validate email using the domain type for RFC-compliant validation + Email::new(&self.admin_email).map_err(|e| CreateConfigError::InvalidAdminEmail { + email: self.admin_email.clone(), + reason: e.to_string(), + })?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod https_section_tests { + use super::*; + + #[test] + fn it_should_create_https_section_with_defaults() { + let section = HttpsSection::new("admin@example.com".to_string(), false); + assert_eq!(section.admin_email, "admin@example.com"); + assert!(!section.use_staging); + } + + #[test] + fn it_should_create_https_section_with_staging() { + let section = HttpsSection::new("admin@example.com".to_string(), true); + assert!(section.use_staging); + } + + #[test] + fn it_should_validate_valid_email() { + let section = HttpsSection::new("admin@example.com".to_string(), false); + assert!(section.validate().is_ok()); + } + + #[test] + fn it_should_reject_email_without_at_symbol() { + let section = HttpsSection::new("invalid-email".to_string(), false); + let result = section.validate(); + assert!(result.is_err()); + if let Err(CreateConfigError::InvalidAdminEmail { email, .. }) = result { + assert_eq!(email, "invalid-email"); + } else { + panic!("Expected InvalidAdminEmail error"); + } + } + + #[test] + fn it_should_reject_email_with_empty_local_part() { + let section = HttpsSection::new("@example.com".to_string(), false); + assert!(section.validate().is_err()); + } + + #[test] + fn it_should_reject_email_with_empty_domain_part() { + let section = HttpsSection::new("admin@".to_string(), false); + assert!(section.validate().is_err()); + } + + #[test] + fn it_should_deserialize_from_json() { + let json = r#"{"admin_email": "test@example.com", "use_staging": true}"#; + let section: HttpsSection = serde_json::from_str(json).unwrap(); + assert_eq!(section.admin_email, "test@example.com"); + assert!(section.use_staging); + } + + #[test] + fn it_should_deserialize_with_default_use_staging() { + let json = r#"{"admin_email": "test@example.com"}"#; + let section: HttpsSection = serde_json::from_str(json).unwrap(); + assert!(!section.use_staging); + } + } + + /// Tests for email validation in HTTPS context + /// + /// Note: Comprehensive email format validation tests are in `src/shared/email.rs`. + /// These tests verify the integration of the `Email` type with `HttpsSection`. + mod email_validation_integration_tests { + use super::*; + + #[test] + fn it_should_accept_rfc_compliant_email() { + let section = HttpsSection::new("user@example.com".to_string(), false); + assert!(section.validate().is_ok()); + } + + #[test] + fn it_should_reject_rfc_non_compliant_email() { + let section = HttpsSection::new("invalid-email".to_string(), false); + let result = section.validate(); + assert!(matches!( + result, + Err(CreateConfigError::InvalidAdminEmail { .. }) + )); + } + } +} diff --git a/src/application/command_handlers/create/config/mod.rs b/src/application/command_handlers/create/config/mod.rs index e732ae95..62e95283 100644 --- a/src/application/command_handlers/create/config/mod.rs +++ b/src/application/command_handlers/create/config/mod.rs @@ -99,7 +99,7 @@ //! let config: EnvironmentCreationConfig = serde_json::from_str(json)?; //! //! // Convert to domain parameters -//! let (name, instance_name, provider_config, credentials, port, tracker, _prometheus, _grafana) = config.to_environment_params()?; +//! let (name, instance_name, provider_config, credentials, port, tracker, _prometheus, _grafana, _https) = config.to_environment_params()?; //! //! // Create domain entity - Environment::new() will use the provider_config //! let created_at = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(); @@ -133,6 +133,7 @@ pub mod environment_config; pub mod errors; pub mod grafana; +pub mod https; pub mod prometheus; pub mod provider; pub mod ssh_credentials_config; @@ -142,6 +143,7 @@ pub mod tracker; pub use environment_config::{EnvironmentCreationConfig, EnvironmentSection}; pub use errors::CreateConfigError; pub use grafana::GrafanaSection; +pub use https::HttpsSection; pub use prometheus::PrometheusSection; pub use provider::{HetznerProviderSection, LxdProviderSection, ProviderSection}; pub use ssh_credentials_config::SshCredentialsConfig; diff --git a/src/application/command_handlers/create/config/tracker/health_check_api_section.rs b/src/application/command_handlers/create/config/tracker/health_check_api_section.rs index 9556cc54..57213d30 100644 --- a/src/application/command_handlers/create/config/tracker/health_check_api_section.rs +++ b/src/application/command_handlers/create/config/tracker/health_check_api_section.rs @@ -5,10 +5,27 @@ use serde::{Deserialize, Serialize}; use crate::application::command_handlers::create::config::errors::CreateConfigError; use crate::domain::tracker::HealthCheckApiConfig; +use crate::shared::DomainName; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] pub struct HealthCheckApiSection { pub bind_address: String, + + /// Domain name for HTTPS access via Caddy reverse proxy + /// + /// When present with `use_tls_proxy: true`, this service will be accessible + /// via HTTPS at this domain. The domain will be used for Let's Encrypt + /// certificate acquisition. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub domain: Option, + + /// Whether to proxy this service through Caddy with TLS termination + /// + /// When `true`, the service will be accessible via HTTPS through Caddy. + /// Requires `domain` to be set. + /// This is useful for exposing health checks to external monitoring systems. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub use_tls_proxy: Option, } impl HealthCheckApiSection { @@ -18,6 +35,11 @@ impl HealthCheckApiSection { /// /// Returns `CreateConfigError::InvalidBindAddress` if the bind address cannot be parsed as a valid IP:PORT combination. /// Returns `CreateConfigError::DynamicPortNotSupported` if port 0 (dynamic port assignment) is specified. + /// Returns `CreateConfigError::InvalidDomain` if the domain is invalid. + /// Returns `CreateConfigError::TlsProxyWithoutDomain` if `use_tls_proxy` is true but domain is missing. + /// + /// Note: Localhost + TLS validation is performed at the domain layer + /// (see `TrackerConfig::validate()`) to avoid duplicating business rules. pub fn to_health_check_api_config(&self) -> Result { // Validate that the bind address can be parsed as SocketAddr let bind_address = self.bind_address.parse::().map_err(|e| { @@ -34,7 +56,33 @@ impl HealthCheckApiSection { }); } - Ok(HealthCheckApiConfig { bind_address }) + let use_tls_proxy = self.use_tls_proxy.unwrap_or(false); + + // Validate: use_tls_proxy requires domain + if use_tls_proxy && self.domain.is_none() { + return Err(CreateConfigError::TlsProxyWithoutDomain { + service_type: "Health Check API".to_string(), + bind_address: self.bind_address.clone(), + }); + } + + // Parse domain if present + let domain = + match &self.domain { + Some(domain_str) => Some(DomainName::new(domain_str).map_err(|e| { + CreateConfigError::InvalidDomain { + domain: domain_str.clone(), + reason: e.to_string(), + } + })?), + None => None, + }; + + Ok(HealthCheckApiConfig { + bind_address, + domain, + use_tls_proxy, + }) } } @@ -42,6 +90,8 @@ impl Default for HealthCheckApiSection { fn default() -> Self { Self { bind_address: "127.0.0.1:1313".to_string(), + domain: None, + use_tls_proxy: None, } } } @@ -54,6 +104,8 @@ mod tests { fn it_should_convert_to_domain_config_when_bind_address_is_valid() { let section = HealthCheckApiSection { bind_address: "127.0.0.1:1313".to_string(), + domain: None, + use_tls_proxy: None, }; let config = section.to_health_check_api_config().unwrap(); @@ -62,12 +114,34 @@ mod tests { config.bind_address, "127.0.0.1:1313".parse::().unwrap() ); + assert!(!config.use_tls_proxy); + assert!(config.domain.is_none()); + } + + #[test] + fn it_should_convert_to_domain_config_with_tls_proxy() { + let section = HealthCheckApiSection { + bind_address: "0.0.0.0:1313".to_string(), + domain: Some("health.tracker.local".to_string()), + use_tls_proxy: Some(true), + }; + + let config = section.to_health_check_api_config().unwrap(); + + assert_eq!( + config.bind_address, + "0.0.0.0:1313".parse::().unwrap() + ); + assert!(config.use_tls_proxy); + assert_eq!(config.tls_domain(), Some("health.tracker.local")); } #[test] fn it_should_fail_when_bind_address_is_invalid() { let section = HealthCheckApiSection { bind_address: "invalid".to_string(), + domain: None, + use_tls_proxy: None, }; let result = section.to_health_check_api_config(); @@ -83,6 +157,8 @@ mod tests { fn it_should_reject_dynamic_port_assignment() { let section = HealthCheckApiSection { bind_address: "0.0.0.0:0".to_string(), + domain: None, + use_tls_proxy: None, }; let result = section.to_health_check_api_config(); @@ -98,6 +174,8 @@ mod tests { fn it_should_allow_ipv6_addresses() { let section = HealthCheckApiSection { bind_address: "[::1]:1313".to_string(), + domain: None, + use_tls_proxy: None, }; let result = section.to_health_check_api_config(); @@ -109,6 +187,8 @@ mod tests { fn it_should_allow_any_port_except_zero() { let section = HealthCheckApiSection { bind_address: "127.0.0.1:8080".to_string(), + domain: None, + use_tls_proxy: None, }; let result = section.to_health_check_api_config(); @@ -121,5 +201,55 @@ mod tests { let section = HealthCheckApiSection::default(); assert_eq!(section.bind_address, "127.0.0.1:1313"); + assert!(section.domain.is_none()); + assert!(section.use_tls_proxy.is_none()); + } + + #[test] + fn it_should_fail_when_domain_is_invalid() { + let section = HealthCheckApiSection { + bind_address: "0.0.0.0:1313".to_string(), + domain: Some("invalid domain with spaces".to_string()), + use_tls_proxy: Some(true), + }; + + let result = section.to_health_check_api_config(); + + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + CreateConfigError::InvalidDomain { .. } + )); + } + + #[test] + fn it_should_fail_when_use_tls_proxy_without_domain() { + let section = HealthCheckApiSection { + bind_address: "0.0.0.0:1313".to_string(), + domain: None, + use_tls_proxy: Some(true), + }; + + let result = section.to_health_check_api_config(); + + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + CreateConfigError::TlsProxyWithoutDomain { .. } + )); + } + + #[test] + fn it_should_allow_domain_without_tls_proxy() { + let section = HealthCheckApiSection { + bind_address: "0.0.0.0:1313".to_string(), + domain: Some("health.tracker.local".to_string()), + use_tls_proxy: None, + }; + + let config = section.to_health_check_api_config().unwrap(); + + assert!(!config.use_tls_proxy); + assert!(config.domain.is_some()); } } diff --git a/src/application/command_handlers/create/config/tracker/http_api_section.rs b/src/application/command_handlers/create/config/tracker/http_api_section.rs index e7c89ce1..be690e5a 100644 --- a/src/application/command_handlers/create/config/tracker/http_api_section.rs +++ b/src/application/command_handlers/create/config/tracker/http_api_section.rs @@ -6,11 +6,33 @@ use serde::{Deserialize, Serialize}; use crate::application::command_handlers::create::config::errors::CreateConfigError; use crate::domain::tracker::HttpApiConfig; use crate::shared::secrets::PlainApiToken; +use crate::shared::DomainName; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] pub struct HttpApiSection { pub bind_address: String, pub admin_token: PlainApiToken, + + /// Domain name for HTTPS certificate acquisition + /// + /// When present along with `use_tls_proxy: true`, this service will be + /// accessible via HTTPS through the Caddy reverse proxy using this domain. + /// The domain is used for Let's Encrypt certificate acquisition. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub domain: Option, + + /// Whether to proxy this service through Caddy with TLS termination + /// + /// When `true`: + /// - The service is proxied through Caddy with HTTPS enabled + /// - `domain` field is required + /// - Cannot be used with localhost bind addresses (`127.0.0.1`, `::1`) + /// + /// When `false` or omitted: + /// - The service is accessed directly without TLS termination + /// - `domain` field is optional (ignored if present) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub use_tls_proxy: Option, } impl HttpApiSection { @@ -20,6 +42,11 @@ impl HttpApiSection { /// /// Returns `CreateConfigError::InvalidBindAddress` if the bind address cannot be parsed as a valid IP:PORT combination. /// Returns `CreateConfigError::DynamicPortNotSupported` if port 0 (dynamic port assignment) is specified. + /// Returns `CreateConfigError::InvalidDomain` if the domain is invalid. + /// Returns `CreateConfigError::TlsProxyWithoutDomain` if `use_tls_proxy` is true but domain is missing. + /// + /// Note: Localhost + TLS validation is performed at the domain layer + /// (see `TrackerConfig::validate()`) to avoid duplicating business rules. pub fn to_http_api_config(&self) -> Result { // Validate that the bind address can be parsed as SocketAddr let bind_address = self.bind_address.parse::().map_err(|e| { @@ -36,10 +63,34 @@ impl HttpApiSection { }); } - // Domain type now uses SocketAddr (Step 0.7 completed) + let use_tls_proxy = self.use_tls_proxy.unwrap_or(false); + + // Validate: use_tls_proxy: true requires domain + if use_tls_proxy && self.domain.is_none() { + return Err(CreateConfigError::TlsProxyWithoutDomain { + service_type: "HTTP API".to_string(), + bind_address: self.bind_address.clone(), + }); + } + + // Convert domain to domain type with validation (if present) + let domain = match &self.domain { + Some(domain_str) => { + let domain = + DomainName::new(domain_str).map_err(|e| CreateConfigError::InvalidDomain { + domain: domain_str.clone(), + reason: e.to_string(), + })?; + Some(domain) + } + None => None, + }; + Ok(HttpApiConfig { bind_address, admin_token: self.admin_token.clone().into(), + domain, + use_tls_proxy, }) } } @@ -53,6 +104,8 @@ mod tests { let section = HttpApiSection { bind_address: "0.0.0.0:1212".to_string(), admin_token: "MyAccessToken".to_string(), + domain: None, + use_tls_proxy: None, }; let result = section.to_http_api_config(); @@ -64,6 +117,7 @@ mod tests { "0.0.0.0:1212".parse::().unwrap() ); assert_eq!(config.admin_token.expose_secret(), "MyAccessToken"); + assert!(!config.use_tls_proxy); } #[test] @@ -71,6 +125,8 @@ mod tests { let section = HttpApiSection { bind_address: "invalid-address".to_string(), admin_token: "token".to_string(), + domain: None, + use_tls_proxy: None, }; let result = section.to_http_api_config(); @@ -88,6 +144,8 @@ mod tests { let section = HttpApiSection { bind_address: "0.0.0.0:0".to_string(), admin_token: "token".to_string(), + domain: None, + use_tls_proxy: None, }; let result = section.to_http_api_config(); @@ -105,6 +163,8 @@ mod tests { let section = HttpApiSection { bind_address: "0.0.0.0:1212".to_string(), admin_token: "MyAccessToken".to_string(), + domain: None, + use_tls_proxy: None, }; let json = serde_json::to_string(§ion).unwrap(); @@ -120,5 +180,76 @@ mod tests { let section: HttpApiSection = serde_json::from_str(json).unwrap(); assert_eq!(section.bind_address, "0.0.0.0:1212"); assert_eq!(section.admin_token, "MyAccessToken"); + assert!(section.domain.is_none()); + assert!(section.use_tls_proxy.is_none()); + } + + #[test] + fn it_should_allow_non_localhost_with_tls_proxy() { + let section = HttpApiSection { + bind_address: "0.0.0.0:1212".to_string(), + admin_token: "token".to_string(), + domain: Some("api.tracker.local".to_string()), + use_tls_proxy: Some(true), + }; + + let result = section.to_http_api_config(); + + assert!(result.is_ok()); + let config = result.unwrap(); + assert!(config.use_tls_proxy); + assert!(config.domain.is_some()); + } + + #[test] + fn it_should_reject_tls_proxy_without_domain() { + let section = HttpApiSection { + bind_address: "0.0.0.0:1212".to_string(), + admin_token: "token".to_string(), + domain: None, + use_tls_proxy: Some(true), + }; + + let result = section.to_http_api_config(); + assert!(result.is_err()); + + if let Err(CreateConfigError::TlsProxyWithoutDomain { + service_type, + bind_address, + }) = result + { + assert_eq!(service_type, "HTTP API"); + assert_eq!(bind_address, "0.0.0.0:1212"); + } else { + panic!("Expected TlsProxyWithoutDomain error"); + } + } + + #[test] + fn it_should_accept_domain_without_tls_proxy() { + // Domain provided but use_tls_proxy is false - domain is ignored + let section = HttpApiSection { + bind_address: "0.0.0.0:1212".to_string(), + admin_token: "token".to_string(), + domain: Some("api.tracker.local".to_string()), + use_tls_proxy: Some(false), + }; + + let result = section.to_http_api_config(); + assert!(result.is_ok()); + + let config = result.unwrap(); + assert!(!config.use_tls_proxy); + // Domain is still stored but won't be used for TLS + assert!(config.domain.is_some()); + } + + #[test] + fn it_should_deserialize_with_new_fields() { + let json = r#"{"bind_address":"0.0.0.0:1212","admin_token":"token","domain":"api.example.com","use_tls_proxy":true}"#; + let section: HttpApiSection = serde_json::from_str(json).unwrap(); + assert_eq!(section.bind_address, "0.0.0.0:1212"); + assert_eq!(section.domain, Some("api.example.com".to_string())); + assert_eq!(section.use_tls_proxy, Some(true)); } } diff --git a/src/application/command_handlers/create/config/tracker/http_tracker_section.rs b/src/application/command_handlers/create/config/tracker/http_tracker_section.rs index 0f8e7f9f..b9716a8d 100644 --- a/src/application/command_handlers/create/config/tracker/http_tracker_section.rs +++ b/src/application/command_handlers/create/config/tracker/http_tracker_section.rs @@ -5,10 +5,33 @@ use serde::{Deserialize, Serialize}; use crate::application::command_handlers::create::config::errors::CreateConfigError; use crate::domain::tracker::HttpTrackerConfig; +use crate::shared::DomainName; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] pub struct HttpTrackerSection { pub bind_address: String, + + /// Domain name for HTTPS certificate acquisition + /// + /// When present along with `use_tls_proxy: true`, this HTTP tracker will be + /// accessible via HTTPS through the Caddy reverse proxy using this domain. + /// The domain is used for Let's Encrypt certificate acquisition. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub domain: Option, + + /// Whether to proxy this service through Caddy with TLS termination + /// + /// When `true`: + /// - The service is proxied through Caddy with HTTPS enabled + /// - `domain` field is required + /// - Cannot be used with localhost bind addresses (`127.0.0.1`, `::1`) + /// - Implies the tracker's `on_reverse_proxy` should be `true` + /// + /// When `false` or omitted: + /// - The service is accessed directly without TLS termination + /// - `domain` field is optional (ignored if present) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub use_tls_proxy: Option, } impl HttpTrackerSection { @@ -18,6 +41,11 @@ impl HttpTrackerSection { /// /// Returns `CreateConfigError::InvalidBindAddress` if the bind address cannot be parsed as a valid IP:PORT combination. /// Returns `CreateConfigError::DynamicPortNotSupported` if port 0 (dynamic port assignment) is specified. + /// Returns `CreateConfigError::InvalidDomain` if the domain is invalid. + /// Returns `CreateConfigError::TlsProxyWithoutDomain` if `use_tls_proxy` is true but domain is missing. + /// + /// Note: Localhost + TLS validation is performed at the domain layer + /// (see `TrackerConfig::validate()`) to avoid duplicating business rules. pub fn to_http_tracker_config(&self) -> Result { // Validate that the bind address can be parsed as SocketAddr let bind_address = self.bind_address.parse::().map_err(|e| { @@ -34,8 +62,34 @@ impl HttpTrackerSection { }); } - // Domain type now uses SocketAddr (Step 0.7 completed) - Ok(HttpTrackerConfig { bind_address }) + let use_tls_proxy = self.use_tls_proxy.unwrap_or(false); + + // Validate: use_tls_proxy: true requires domain + if use_tls_proxy && self.domain.is_none() { + return Err(CreateConfigError::TlsProxyWithoutDomain { + service_type: "HTTP tracker".to_string(), + bind_address: self.bind_address.clone(), + }); + } + + // Convert domain to domain type with validation (if present) + let domain = match &self.domain { + Some(domain_str) => { + let domain = + DomainName::new(domain_str).map_err(|e| CreateConfigError::InvalidDomain { + domain: domain_str.clone(), + reason: e.to_string(), + })?; + Some(domain) + } + None => None, + }; + + Ok(HttpTrackerConfig { + bind_address, + domain, + use_tls_proxy, + }) } } @@ -47,6 +101,8 @@ mod tests { fn it_should_convert_valid_bind_address_to_http_tracker_config() { let section = HttpTrackerSection { bind_address: "0.0.0.0:7070".to_string(), + domain: None, + use_tls_proxy: None, }; let result = section.to_http_tracker_config(); @@ -57,12 +113,15 @@ mod tests { config.bind_address, "0.0.0.0:7070".parse::().unwrap() ); + assert!(!config.use_tls_proxy); } #[test] fn it_should_fail_for_invalid_bind_address() { let section = HttpTrackerSection { bind_address: "not-valid".to_string(), + domain: None, + use_tls_proxy: None, }; let result = section.to_http_tracker_config(); @@ -79,6 +138,8 @@ mod tests { fn it_should_reject_port_zero() { let section = HttpTrackerSection { bind_address: "0.0.0.0:0".to_string(), + domain: None, + use_tls_proxy: None, }; let result = section.to_http_tracker_config(); @@ -95,6 +156,8 @@ mod tests { fn it_should_be_serializable() { let section = HttpTrackerSection { bind_address: "0.0.0.0:7070".to_string(), + domain: None, + use_tls_proxy: None, }; let json = serde_json::to_string(§ion).unwrap(); @@ -107,5 +170,73 @@ mod tests { let json = r#"{"bind_address":"0.0.0.0:7070"}"#; let section: HttpTrackerSection = serde_json::from_str(json).unwrap(); assert_eq!(section.bind_address, "0.0.0.0:7070"); + assert!(section.domain.is_none()); + assert!(section.use_tls_proxy.is_none()); + } + + #[test] + fn it_should_allow_non_localhost_with_tls_proxy() { + let section = HttpTrackerSection { + bind_address: "0.0.0.0:7070".to_string(), + domain: Some("tracker.local".to_string()), + use_tls_proxy: Some(true), + }; + + let result = section.to_http_tracker_config(); + + assert!(result.is_ok()); + let config = result.unwrap(); + assert!(config.use_tls_proxy); + assert!(config.domain.is_some()); + } + + #[test] + fn it_should_reject_tls_proxy_without_domain() { + let section = HttpTrackerSection { + bind_address: "0.0.0.0:7070".to_string(), + domain: None, + use_tls_proxy: Some(true), + }; + + let result = section.to_http_tracker_config(); + assert!(result.is_err()); + + if let Err(CreateConfigError::TlsProxyWithoutDomain { + service_type, + bind_address, + }) = result + { + assert_eq!(service_type, "HTTP tracker"); + assert_eq!(bind_address, "0.0.0.0:7070"); + } else { + panic!("Expected TlsProxyWithoutDomain error"); + } + } + + #[test] + fn it_should_accept_domain_without_tls_proxy() { + // Domain provided but use_tls_proxy is false - domain is ignored + let section = HttpTrackerSection { + bind_address: "0.0.0.0:7070".to_string(), + domain: Some("tracker.local".to_string()), + use_tls_proxy: Some(false), + }; + + let result = section.to_http_tracker_config(); + assert!(result.is_ok()); + + let config = result.unwrap(); + assert!(!config.use_tls_proxy); + // Domain is still stored but won't be used for TLS + assert!(config.domain.is_some()); + } + + #[test] + fn it_should_deserialize_with_new_fields() { + let json = r#"{"bind_address":"0.0.0.0:7070","domain":"tracker.example.com","use_tls_proxy":true}"#; + let section: HttpTrackerSection = serde_json::from_str(json).unwrap(); + assert_eq!(section.bind_address, "0.0.0.0:7070"); + assert_eq!(section.domain, Some("tracker.example.com".to_string())); + assert_eq!(section.use_tls_proxy, Some(true)); } } diff --git a/src/application/command_handlers/create/config/tracker/tracker_section.rs b/src/application/command_handlers/create/config/tracker/tracker_section.rs index 76ae797f..9e8b8f86 100644 --- a/src/application/command_handlers/create/config/tracker/tracker_section.rs +++ b/src/application/command_handlers/create/config/tracker/tracker_section.rs @@ -128,10 +128,14 @@ impl Default for TrackerSection { }], http_trackers: vec![HttpTrackerSection { bind_address: "0.0.0.0:7070".to_string(), + domain: None, + use_tls_proxy: None, }], http_api: HttpApiSection { bind_address: "0.0.0.0:1212".to_string(), admin_token: "MyAccessToken".to_string(), + domain: None, + use_tls_proxy: None, }, health_check_api: HealthCheckApiSection::default(), } @@ -160,10 +164,14 @@ mod tests { }], http_trackers: vec![HttpTrackerSection { bind_address: "0.0.0.0:7070".to_string(), + domain: None, + use_tls_proxy: None, }], http_api: HttpApiSection { bind_address: "0.0.0.0:1212".to_string(), admin_token: "MyAccessToken".to_string(), + domain: None, + use_tls_proxy: None, }, health_check_api: HealthCheckApiSection::default(), }; @@ -205,14 +213,20 @@ mod tests { http_trackers: vec![ HttpTrackerSection { bind_address: "0.0.0.0:7070".to_string(), + domain: None, + use_tls_proxy: None, }, HttpTrackerSection { bind_address: "0.0.0.0:7071".to_string(), + domain: None, + use_tls_proxy: None, }, ], http_api: HttpApiSection { bind_address: "0.0.0.0:1212".to_string(), admin_token: "MyAccessToken".to_string(), + domain: None, + use_tls_proxy: None, }, health_check_api: HealthCheckApiSection::default(), }; @@ -239,6 +253,8 @@ mod tests { http_api: HttpApiSection { bind_address: "0.0.0.0:1212".to_string(), admin_token: "MyAccessToken".to_string(), + domain: None, + use_tls_proxy: None, }, health_check_api: HealthCheckApiSection::default(), }; @@ -266,10 +282,14 @@ mod tests { }], http_trackers: vec![HttpTrackerSection { bind_address: "0.0.0.0:7070".to_string(), + domain: None, + use_tls_proxy: None, }], http_api: HttpApiSection { bind_address: "0.0.0.0:1212".to_string(), admin_token: "MyAccessToken".to_string(), + domain: None, + use_tls_proxy: None, }, health_check_api: HealthCheckApiSection::default(), }; @@ -326,10 +346,14 @@ mod tests { udp_trackers: vec![], http_trackers: vec![HttpTrackerSection { bind_address: "0.0.0.0:7070".to_string(), + domain: None, + use_tls_proxy: None, }], http_api: HttpApiSection { bind_address: "0.0.0.0:7070".to_string(), admin_token: "token".to_string(), + domain: None, + use_tls_proxy: None, }, health_check_api: HealthCheckApiSection::default(), }; @@ -357,10 +381,14 @@ mod tests { }], http_trackers: vec![HttpTrackerSection { bind_address: "0.0.0.0:7070".to_string(), + domain: None, + use_tls_proxy: None, }], http_api: HttpApiSection { bind_address: "0.0.0.0:1212".to_string(), admin_token: "token".to_string(), + domain: None, + use_tls_proxy: None, }, health_check_api: HealthCheckApiSection::default(), }; diff --git a/src/application/command_handlers/create/handler.rs b/src/application/command_handlers/create/handler.rs index bdf93124..3a18d680 100644 --- a/src/application/command_handlers/create/handler.rs +++ b/src/application/command_handlers/create/handler.rs @@ -76,6 +76,7 @@ use super::errors::CreateCommandHandlerError; /// TrackerSection::default(), /// None, // prometheus /// None, // grafana +/// None, // https /// ); /// /// // Execute command with working directory @@ -192,6 +193,7 @@ impl CreateCommandHandler { /// TrackerSection::default(), /// None, // prometheus /// None, // grafana + /// None, // https /// ); /// /// let working_dir = std::path::Path::new("."); @@ -222,6 +224,7 @@ impl CreateCommandHandler { tracker_config, prometheus_config, grafana_config, + https_config, ) = config .to_environment_params() .map_err(CreateCommandHandlerError::InvalidConfiguration)?; @@ -244,6 +247,7 @@ impl CreateCommandHandler { tracker_config, prometheus_config, grafana_config, + https_config, working_dir, self.clock.now(), ); diff --git a/src/application/command_handlers/create/mod.rs b/src/application/command_handlers/create/mod.rs index 5bdebf1f..1a1b20a7 100644 --- a/src/application/command_handlers/create/mod.rs +++ b/src/application/command_handlers/create/mod.rs @@ -59,6 +59,7 @@ //! TrackerSection::default(), //! None, // prometheus //! None, // grafana +//! None, // https //! ); //! //! // Execute command with working directory diff --git a/src/application/command_handlers/create/tests/builders.rs b/src/application/command_handlers/create/tests/builders.rs index f968d082..b578d2e0 100644 --- a/src/application/command_handlers/create/tests/builders.rs +++ b/src/application/command_handlers/create/tests/builders.rs @@ -279,6 +279,7 @@ pub fn create_valid_test_config(temp_dir: &TempDir, env_name: &str) -> Environme TrackerSection::default(), None, None, + None, // HTTPS configuration ) } diff --git a/src/application/command_handlers/create/tests/integration.rs b/src/application/command_handlers/create/tests/integration.rs index 34215b2b..8cda03f5 100644 --- a/src/application/command_handlers/create/tests/integration.rs +++ b/src/application/command_handlers/create/tests/integration.rs @@ -143,6 +143,7 @@ fn it_should_fail_with_invalid_environment_name() { TrackerSection::default(), None, None, + None, // HTTPS configuration ); // Act @@ -194,6 +195,7 @@ fn it_should_fail_when_ssh_private_key_not_found() { TrackerSection::default(), None, None, + None, // HTTPS configuration ); // Act diff --git a/src/application/command_handlers/release/errors.rs b/src/application/command_handlers/release/errors.rs index 4025d9c6..e0529a01 100644 --- a/src/application/command_handlers/release/errors.rs +++ b/src/application/command_handlers/release/errors.rs @@ -52,6 +52,10 @@ pub enum ReleaseCommandHandlerError { #[error("Prometheus storage creation failed: {0}")] PrometheusStorageCreation(String), + /// Caddy configuration deployment failed + #[error("Caddy configuration deployment failed: {0}")] + CaddyConfigDeployment(String), + /// General deployment operation failed #[error("Deployment failed: {message}")] Deployment { @@ -111,6 +115,11 @@ impl Traceable for ReleaseCommandHandlerError { "ReleaseCommandHandlerError: Prometheus storage creation failed - {message}" ) } + Self::CaddyConfigDeployment(message) => { + format!( + "ReleaseCommandHandlerError: Caddy configuration deployment failed - {message}" + ) + } Self::Deployment { message, .. } | Self::DeploymentFailed { message, .. } => { format!("ReleaseCommandHandlerError: Deployment failed - {message}") } @@ -135,6 +144,7 @@ impl Traceable for ReleaseCommandHandlerError { | Self::TrackerStorageCreation(_) | Self::TrackerDatabaseInit(_) | Self::PrometheusStorageCreation(_) + | Self::CaddyConfigDeployment(_) | Self::ReleaseOperationFailed { .. } => None, } } @@ -148,7 +158,8 @@ impl Traceable for ReleaseCommandHandlerError { Self::TemplateRendering(_) | Self::TrackerStorageCreation(_) | Self::TrackerDatabaseInit(_) - | Self::PrometheusStorageCreation(_) => ErrorKind::TemplateRendering, + | Self::PrometheusStorageCreation(_) + | Self::CaddyConfigDeployment(_) => ErrorKind::TemplateRendering, Self::Deployment { .. } | Self::ReleaseOperationFailed { .. } => { ErrorKind::InfrastructureOperation } @@ -343,6 +354,32 @@ Common causes: - Ansible playbook not found - Network connectivity issues +For more information, see docs/user-guide/commands.md" + } + Self::CaddyConfigDeployment(_) => { + "Caddy Configuration Deployment Failed - Troubleshooting: + +1. Verify the target instance is reachable: + ssh @ + +2. Check that the Caddyfile was generated in the build directory: + ls build//caddy/Caddyfile + +3. Verify the Ansible playbook exists: + ls templates/ansible/deploy-caddy-config.yml + +4. Check that the instance has sufficient disk space: + df -h + +5. Review the error message above for specific details + +Common causes: +- Caddyfile not generated (HTTPS not configured) +- Insufficient disk space on target instance +- Permission denied on target directories +- Ansible playbook not found +- Network connectivity issues + For more information, see docs/user-guide/commands.md" } Self::Deployment { .. } => { diff --git a/src/application/command_handlers/release/handler.rs b/src/application/command_handlers/release/handler.rs index 36610c35..fb29ba4b 100644 --- a/src/application/command_handlers/release/handler.rs +++ b/src/application/command_handlers/release/handler.rs @@ -11,11 +11,13 @@ use crate::adapters::ansible::AnsibleClient; use crate::application::command_handlers::common::StepResult; use crate::application::steps::{ application::{ - CreatePrometheusStorageStep, CreateTrackerStorageStep, DeployGrafanaProvisioningStep, - DeployPrometheusConfigStep, DeployTrackerConfigStep, InitTrackerDatabaseStep, + CreatePrometheusStorageStep, CreateTrackerStorageStep, DeployCaddyConfigStep, + DeployGrafanaProvisioningStep, DeployPrometheusConfigStep, DeployTrackerConfigStep, + InitTrackerDatabaseStep, }, rendering::{ - RenderGrafanaTemplatesStep, RenderPrometheusTemplatesStep, RenderTrackerTemplatesStep, + RenderCaddyTemplatesStep, RenderGrafanaTemplatesStep, RenderPrometheusTemplatesStep, + RenderTrackerTemplatesStep, }, DeployComposeFilesStep, RenderDockerComposeTemplatesStep, }; @@ -219,10 +221,16 @@ impl ReleaseCommandHandler { // Step 9: Deploy Grafana provisioning to remote (if enabled) self.deploy_grafana_provisioning_to_remote(environment, instance_ip)?; - // Step 10: Render Docker Compose templates + // Step 10: Render Caddy configuration templates (if HTTPS enabled) + Self::render_caddy_templates(environment)?; + + // Step 11: Deploy Caddy configuration to remote (if HTTPS enabled) + self.deploy_caddy_config_to_remote(environment, instance_ip)?; + + // Step 12: Render Docker Compose templates let compose_build_dir = self.render_docker_compose_templates(environment).await?; - // Step 11: Deploy compose files to remote + // Step 13: Deploy compose files to remote self.deploy_compose_files_to_remote(environment, &compose_build_dir, instance_ip)?; let released = environment.clone().released(); @@ -533,6 +541,101 @@ impl ReleaseCommandHandler { Ok(()) } + /// Render Caddy configuration templates (if HTTPS enabled) + /// + /// This step is optional and only executes if HTTPS is configured in the environment. + /// If HTTPS is not configured, the step is skipped without error. + /// + /// # Errors + /// + /// Returns a tuple of (error, `ReleaseStep::RenderCaddyTemplates`) if rendering fails + #[allow(clippy::result_large_err)] + fn render_caddy_templates( + environment: &Environment, + ) -> StepResult<(), ReleaseCommandHandlerError, ReleaseStep> { + let current_step = ReleaseStep::RenderCaddyTemplates; + + // Check if HTTPS is configured + if environment.context().user_inputs.https.is_none() { + info!( + command = "release", + step = %current_step, + status = "skipped", + "HTTPS not configured - skipping Caddy template rendering" + ); + return Ok(()); + } + + let template_manager = Arc::new(TemplateManager::new(environment.templates_dir())); + let step = RenderCaddyTemplatesStep::new( + Arc::new(environment.clone()), + template_manager, + environment.build_dir().clone(), + ); + + step.execute().map_err(|e| { + ( + ReleaseCommandHandlerError::TemplateRendering(e.to_string()), + current_step, + ) + })?; + + info!( + command = "release", + step = %current_step, + "Caddy configuration templates rendered successfully" + ); + + Ok(()) + } + + /// Deploy Caddy configuration to the remote host (if HTTPS enabled) + /// + /// This step is optional and only executes if HTTPS is configured in the environment. + /// If HTTPS is not configured, the step is skipped without error. + /// + /// # Errors + /// + /// Returns a tuple of (error, `ReleaseStep::DeployCaddyConfigToRemote`) if deployment fails + #[allow(clippy::result_large_err, clippy::unused_self)] + fn deploy_caddy_config_to_remote( + &self, + environment: &Environment, + _instance_ip: IpAddr, + ) -> StepResult<(), ReleaseCommandHandlerError, ReleaseStep> { + let current_step = ReleaseStep::DeployCaddyConfigToRemote; + + // Check if HTTPS is configured + if environment.context().user_inputs.https.is_none() { + info!( + command = "release", + step = %current_step, + status = "skipped", + "HTTPS not configured - skipping Caddy config deployment" + ); + return Ok(()); + } + + let ansible_client = Arc::new(AnsibleClient::new(environment.build_dir().join("ansible"))); + + DeployCaddyConfigStep::new(ansible_client) + .execute() + .map_err(|e| { + ( + ReleaseCommandHandlerError::CaddyConfigDeployment(e.to_string()), + current_step, + ) + })?; + + info!( + command = "release", + step = %current_step, + "Caddy configuration deployed to remote successfully" + ); + + Ok(()) + } + /// Deploy Grafana provisioning configuration to the remote host (if enabled) /// /// This step is optional and only executes if Grafana is configured in the environment. diff --git a/src/application/command_handlers/show/handler.rs b/src/application/command_handlers/show/handler.rs index 9fbd2035..b3e25ae5 100644 --- a/src/application/command_handlers/show/handler.rs +++ b/src/application/command_handlers/show/handler.rs @@ -142,13 +142,21 @@ impl ShowCommandHandler { // Add service info for Released/Running states if Self::should_show_services(any_env.state_name()) { - // Try to use stored service endpoints first, fall back to computing from config - let services = if let Some(endpoints) = any_env.service_endpoints() { + // If HTTPS is configured, always compute from tracker config to show TLS domains + // Otherwise, try stored endpoints first for backward compatibility + let services = if any_env.https_config().is_some() { + // HTTPS enabled: compute from config to show proper TLS domains + let tracker_config = any_env.tracker_config(); + let grafana_config = any_env.grafana_config(); + ServiceInfo::from_tracker_config(tracker_config, instance_ip, grafana_config) + } else if let Some(endpoints) = any_env.service_endpoints() { + // No HTTPS: use stored endpoints (backward compatibility) ServiceInfo::from_service_endpoints(endpoints) } else { - // Backward compatibility: compute from tracker config + // Fallback: compute from tracker config let tracker_config = any_env.tracker_config(); - ServiceInfo::from_tracker_config(tracker_config, instance_ip) + let grafana_config = any_env.grafana_config(); + ServiceInfo::from_tracker_config(tracker_config, instance_ip, grafana_config) }; info = info.with_services(services); @@ -158,8 +166,8 @@ impl ShowCommandHandler { } // Add Grafana info if configured - if any_env.grafana_config().is_some() { - info = info.with_grafana(GrafanaInfo::from_instance_ip(instance_ip)); + if let Some(grafana) = any_env.grafana_config() { + info = info.with_grafana(GrafanaInfo::from_config(grafana, instance_ip)); } } } diff --git a/src/application/command_handlers/show/info/grafana.rs b/src/application/command_handlers/show/info/grafana.rs index 484129e9..959b876a 100644 --- a/src/application/command_handlers/show/info/grafana.rs +++ b/src/application/command_handlers/show/info/grafana.rs @@ -6,6 +6,8 @@ use std::net::IpAddr; use url::Url; +use crate::domain::grafana::GrafanaConfig; + /// Grafana visualization service information for display purposes /// /// This information shows the status of the Grafana service when configured. @@ -15,16 +17,19 @@ use url::Url; pub struct GrafanaInfo { /// Grafana dashboard URL pub url: Url, + + /// Whether Grafana is accessed via HTTPS through Caddy + pub uses_https: bool, } impl GrafanaInfo { /// Create a new `GrafanaInfo` #[must_use] - pub fn new(url: Url) -> Self { - Self { url } + pub fn new(url: Url, uses_https: bool) -> Self { + Self { url, uses_https } } - /// Build `GrafanaInfo` from instance IP + /// Build `GrafanaInfo` from instance IP (HTTP direct access) /// /// Grafana is exposed on port 3100 (mapped from internal port 3000). /// @@ -36,7 +41,27 @@ impl GrafanaInfo { pub fn from_instance_ip(instance_ip: IpAddr) -> Self { let url = Url::parse(&format!("http://{instance_ip}:3100")) // DevSkim: ignore DS137138 .expect("Valid IP address should produce valid URL"); - Self::new(url) + Self::new(url, false) + } + + /// Build `GrafanaInfo` from Grafana configuration + /// + /// If TLS is configured, returns HTTPS URL with domain. + /// Otherwise, returns HTTP URL with IP address. + /// + /// # Panics + /// + /// This function will panic if the URL cannot be parsed, which should + /// never happen since we construct valid URLs. + #[must_use] + pub fn from_config(config: &GrafanaConfig, instance_ip: IpAddr) -> Self { + if let Some(domain) = config.tls_domain() { + let url = Url::parse(&format!("https://{domain}")) + .expect("Valid domain should produce valid URL"); + Self::new(url, true) + } else { + Self::from_instance_ip(instance_ip) + } } } @@ -49,8 +74,9 @@ mod tests { #[test] fn it_should_create_grafana_info_from_url() { let url = Url::parse("http://10.0.0.1:3100").unwrap(); // DevSkim: ignore DS137138 - let info = GrafanaInfo::new(url.clone()); + let info = GrafanaInfo::new(url.clone(), false); assert_eq!(info.url, url); + assert!(!info.uses_https); } #[test] @@ -59,5 +85,36 @@ mod tests { let info = GrafanaInfo::from_instance_ip(ip); assert_eq!(info.url.host_str(), Some("192.168.1.100")); assert_eq!(info.url.port(), Some(3100)); + assert!(!info.uses_https); + } + + #[test] + fn it_should_create_grafana_info_with_https_from_config() { + use crate::domain::grafana::GrafanaConfig; + use crate::shared::domain_name::DomainName; + + let domain = DomainName::new("grafana.tracker.local").unwrap(); + let config = + GrafanaConfig::new("admin".to_string(), "pass".to_string(), Some(domain), true); + let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); + + let info = GrafanaInfo::from_config(&config, ip); + + assert_eq!(info.url.scheme(), "https"); + assert_eq!(info.url.host_str(), Some("grafana.tracker.local")); + assert!(info.uses_https); + } + + #[test] + fn it_should_create_grafana_info_with_http_from_config_without_tls() { + let config = GrafanaConfig::new("admin".to_string(), "pass".to_string(), None, false); + let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); + + let info = GrafanaInfo::from_config(&config, ip); + + assert_eq!(info.url.scheme(), "http"); + assert_eq!(info.url.host_str(), Some("10.0.0.1")); + assert_eq!(info.url.port(), Some(3100)); + assert!(!info.uses_https); } } diff --git a/src/application/command_handlers/show/info/mod.rs b/src/application/command_handlers/show/info/mod.rs index 5bb2ae88..2b2e2fff 100644 --- a/src/application/command_handlers/show/info/mod.rs +++ b/src/application/command_handlers/show/info/mod.rs @@ -21,7 +21,7 @@ use chrono::{DateTime, Utc}; pub use self::grafana::GrafanaInfo; pub use self::prometheus::PrometheusInfo; -pub use self::tracker::ServiceInfo; +pub use self::tracker::{LocalhostServiceInfo, ServiceInfo, TlsDomainInfo}; /// Environment information for display purposes /// diff --git a/src/application/command_handlers/show/info/tracker.rs b/src/application/command_handlers/show/info/tracker.rs index 0fb75a5e..b3df62dc 100644 --- a/src/application/command_handlers/show/info/tracker.rs +++ b/src/application/command_handlers/show/info/tracker.rs @@ -5,6 +5,8 @@ use std::net::IpAddr; use crate::domain::environment::runtime_outputs::ServiceEndpoints; +use crate::domain::grafana::GrafanaConfig; +use crate::domain::tracker::config::is_localhost; use crate::domain::tracker::TrackerConfig; /// Tracker service information for display purposes @@ -12,80 +14,245 @@ use crate::domain::tracker::TrackerConfig; /// This information is available for Released and Running states and shows /// the tracker services configured for the environment. #[derive(Debug, Clone)] +#[allow(clippy::struct_excessive_bools)] pub struct ServiceInfo { /// UDP tracker URLs (e.g., `udp://10.0.0.1:6969/announce`) pub udp_trackers: Vec, - /// HTTP tracker URLs (e.g., `http://10.0.0.1:7070/announce`) - pub http_trackers: Vec, + /// HTTP tracker URLs with HTTPS via Caddy (e.g., `https://http1.tracker.local/announce`) + pub https_http_trackers: Vec, - /// HTTP API endpoint URL (e.g., `http://10.0.0.1:1212/api`) + /// HTTP tracker URLs with direct access (e.g., `http://10.0.0.1:7072/announce`) + pub direct_http_trackers: Vec, + + /// HTTP tracker URLs that are localhost-only (internal access via SSH tunnel) + pub localhost_http_trackers: Vec, + + /// HTTP API endpoint URL (e.g., `http://10.0.0.1:1212/api` or `https://api.tracker.local/api`) pub api_endpoint: String, - /// Health check API URL (e.g., `http://10.0.0.1:1313/health_check`) + /// Whether the API endpoint uses HTTPS via Caddy + pub api_uses_https: bool, + + /// Whether the API endpoint is localhost-only (not externally accessible) + pub api_is_localhost_only: bool, + + /// Health check API URL (e.g., `http://10.0.0.1:1313/health_check` or `https://health.tracker.local/health_check`) pub health_check_url: String, + + /// Whether the health check endpoint uses HTTPS via Caddy + pub health_check_uses_https: bool, + + /// Whether the health check endpoint is localhost-only (not externally accessible) + pub health_check_is_localhost_only: bool, + + /// Domains configured for TLS services (for /etc/hosts hint) + pub tls_domains: Vec, +} + +/// Information about a localhost-only service (for SSH tunnel hint) +#[derive(Debug, Clone)] +pub struct LocalhostServiceInfo { + /// The service name (e.g., `http_tracker_1`) + pub service_name: String, + /// The port the service is bound to on localhost + pub port: u16, +} + +/// Information about a TLS-enabled domain for /etc/hosts hint +#[derive(Debug, Clone)] +pub struct TlsDomainInfo { + /// The domain name + pub domain: String, + /// Internal port that is NOT exposed (for informational purposes) + pub internal_port: u16, +} + +impl TlsDomainInfo { + /// Create a new `TlsDomainInfo` + #[must_use] + pub fn new(domain: String, internal_port: u16) -> Self { + Self { + domain, + internal_port, + } + } } impl ServiceInfo { /// Create a new `ServiceInfo` #[must_use] + #[allow(clippy::too_many_arguments)] + #[allow(clippy::fn_params_excessive_bools)] pub fn new( udp_trackers: Vec, - http_trackers: Vec, + https_http_trackers: Vec, + direct_http_trackers: Vec, + localhost_http_trackers: Vec, api_endpoint: String, + api_uses_https: bool, + api_is_localhost_only: bool, health_check_url: String, + health_check_uses_https: bool, + health_check_is_localhost_only: bool, + tls_domains: Vec, ) -> Self { Self { udp_trackers, - http_trackers, + https_http_trackers, + direct_http_trackers, + localhost_http_trackers, api_endpoint, + api_uses_https, + api_is_localhost_only, health_check_url, + health_check_uses_https, + health_check_is_localhost_only, + tls_domains, } } /// Build `ServiceInfo` from tracker configuration and instance IP /// /// This method constructs service URLs by combining the configured bind - /// addresses with the actual instance IP address. + /// addresses with the actual instance IP address. It separates HTTP trackers + /// into HTTPS-enabled (via Caddy), direct HTTP access, and localhost-only groups. + /// + /// # Arguments + /// + /// * `tracker_config` - The tracker configuration containing service settings + /// * `instance_ip` - The IP address of the deployed instance + /// * `grafana_config` - Optional Grafana configuration (for TLS domain info) #[must_use] - pub fn from_tracker_config(tracker_config: &TrackerConfig, instance_ip: IpAddr) -> Self { + pub fn from_tracker_config( + tracker_config: &TrackerConfig, + instance_ip: IpAddr, + grafana_config: Option<&GrafanaConfig>, + ) -> Self { let udp_trackers = tracker_config .udp_trackers .iter() .map(|udp| format!("udp://{}:{}/announce", instance_ip, udp.bind_address.port())) .collect(); - let http_trackers = tracker_config - .http_trackers - .iter() - .map(|http| { - format!( + // Separate HTTP trackers by TLS configuration and localhost status + let mut https_http_trackers = Vec::new(); + let mut direct_http_trackers = Vec::new(); + let mut localhost_http_trackers = Vec::new(); + let mut tls_domains = Vec::new(); + + for (index, http) in tracker_config.http_trackers.iter().enumerate() { + if http.use_tls_proxy { + if let Some(domain) = &http.domain { + // TLS-enabled tracker - use HTTPS domain URL + // Note: localhost + TLS is rejected at config validation time, + // so we don't need to check for it here + https_http_trackers.push(format!("https://{}/announce", domain.as_str())); + tls_domains.push(TlsDomainInfo { + domain: domain.as_str().to_string(), + internal_port: http.bind_address.port(), + }); + } + } else if is_localhost(&http.bind_address) { + // Localhost-only tracker - internal access only + localhost_http_trackers.push(LocalhostServiceInfo { + service_name: format!("http_tracker_{}", index + 1), + port: http.bind_address.port(), + }); + } else { + // Non-TLS, non-localhost tracker - use direct IP URL + direct_http_trackers.push(format!( "http://{}:{}/announce", // DevSkim: ignore DS137138 instance_ip, http.bind_address.port() + )); + } + } + + // Build API endpoint based on TLS configuration and localhost status + let api_is_localhost_only = is_localhost(&tracker_config.http_api.bind_address); + let (api_endpoint, api_uses_https) = if tracker_config.http_api.use_tls_proxy { + if let Some(domain) = &tracker_config.http_api.domain { + tls_domains.push(TlsDomainInfo { + domain: domain.as_str().to_string(), + internal_port: tracker_config.http_api.bind_address.port(), + }); + (format!("https://{}/api", domain.as_str()), true) + } else { + // TLS proxy without domain shouldn't happen after validation + ( + format!( + "http://{}:{}/api", // DevSkim: ignore DS137138 + instance_ip, + tracker_config.http_api.bind_address.port() + ), + false, ) - }) - .collect(); + } + } else { + ( + format!( + "http://{}:{}/api", // DevSkim: ignore DS137138 + instance_ip, + tracker_config.http_api.bind_address.port() + ), + false, + ) + }; - let api_endpoint = format!( - "http://{}:{}/api", // DevSkim: ignore DS137138 - instance_ip, - tracker_config.http_api.bind_address.port() - ); + // Add Grafana TLS domain if configured + if let Some(grafana) = grafana_config { + if let Some(domain) = grafana.tls_domain() { + tls_domains.push(TlsDomainInfo { + domain: domain.to_string(), + internal_port: 3000, // Grafana internal port + }); + } + } - let health_check_url = format!( - "http://{}:{}/health_check", // DevSkim: ignore DS137138 - instance_ip, - tracker_config.health_check_api.bind_address.port() - ); + // Build health check URL based on TLS configuration and localhost status + let health_check_is_localhost_only = + is_localhost(&tracker_config.health_check_api.bind_address); + let (health_check_url, health_check_uses_https) = + if let Some(domain) = tracker_config.health_check_api.tls_domain() { + tls_domains.push(TlsDomainInfo { + domain: domain.to_string(), + internal_port: tracker_config.health_check_api.bind_address.port(), + }); + (format!("https://{domain}/health_check"), true) + } else { + ( + format!( + "http://{}:{}/health_check", // DevSkim: ignore DS137138 + instance_ip, + tracker_config.health_check_api.bind_address.port() + ), + false, + ) + }; - Self::new(udp_trackers, http_trackers, api_endpoint, health_check_url) + Self::new( + udp_trackers, + https_http_trackers, + direct_http_trackers, + localhost_http_trackers, + api_endpoint, + api_uses_https, + api_is_localhost_only, + health_check_url, + health_check_uses_https, + health_check_is_localhost_only, + tls_domains, + ) } /// Build `ServiceInfo` from stored `ServiceEndpoints` /// /// This method extracts service URLs from the runtime outputs /// that were stored when services were started. + /// + /// Note: This method is for backward compatibility with stored endpoints. + /// New deployments should use `from_tracker_config` which has full TLS awareness. #[must_use] pub fn from_service_endpoints(endpoints: &ServiceEndpoints) -> Self { let udp_trackers = endpoints @@ -94,7 +261,9 @@ impl ServiceInfo { .map(ToString::to_string) .collect(); - let http_trackers = endpoints + // For backward compatibility, all HTTP trackers go to direct access + // (stored endpoints don't have TLS or localhost information) + let direct_http_trackers = endpoints .http_trackers .iter() .map(ToString::to_string) @@ -110,7 +279,45 @@ impl ServiceInfo { .as_ref() .map_or_else(String::new, ToString::to_string); - Self::new(udp_trackers, http_trackers, api_endpoint, health_check_url) + Self::new( + udp_trackers, + Vec::new(), // No HTTPS trackers from legacy endpoints + direct_http_trackers, + Vec::new(), // No localhost tracker info from legacy endpoints + api_endpoint, + false, // Legacy endpoints don't have TLS info + false, // Legacy endpoints don't have localhost info + health_check_url, + false, // Legacy endpoints don't have health check TLS info + false, // Legacy endpoints don't have localhost info + Vec::new(), // No TLS domains from legacy endpoints + ) + } + + /// Returns true if any service has TLS enabled + #[must_use] + pub fn has_any_tls(&self) -> bool { + !self.tls_domains.is_empty() + } + + /// Returns true if any service is localhost-only + #[must_use] + pub fn has_any_localhost_only(&self) -> bool { + self.api_is_localhost_only + || self.health_check_is_localhost_only + || !self.localhost_http_trackers.is_empty() + } + + /// Returns all TLS domain names (for /etc/hosts hint) + #[must_use] + pub fn tls_domain_names(&self) -> Vec<&str> { + self.tls_domains.iter().map(|d| d.domain.as_str()).collect() + } + + /// Returns all internal ports that are not exposed due to TLS + #[must_use] + pub fn unexposed_ports(&self) -> Vec { + self.tls_domains.iter().map(|d| d.internal_port).collect() } } @@ -122,14 +329,179 @@ mod tests { fn it_should_create_service_info() { let services = ServiceInfo::new( vec!["udp://10.0.0.1:6969/announce".to_string()], - vec!["http://10.0.0.1:7070/announce".to_string()], // DevSkim: ignore DS137138 + vec!["https://http1.tracker.local/announce".to_string()], + vec!["http://10.0.0.1:7072/announce".to_string()], // DevSkim: ignore DS137138 + vec![], // No localhost HTTP trackers "http://10.0.0.1:1212/api".to_string(), // DevSkim: ignore DS137138 - "http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + false, + false, // API not localhost-only + "http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + false, // Health check doesn't use HTTPS + false, // Health check not localhost-only + vec![TlsDomainInfo { + domain: "http1.tracker.local".to_string(), + internal_port: 7070, + }], ); assert_eq!(services.udp_trackers.len(), 1); - assert_eq!(services.http_trackers.len(), 1); + assert_eq!(services.https_http_trackers.len(), 1); + assert_eq!(services.direct_http_trackers.len(), 1); assert!(services.api_endpoint.contains("1212")); + assert!(!services.api_uses_https); + assert!(!services.api_is_localhost_only); assert!(services.health_check_url.contains("1313")); + assert!(services.has_any_tls()); + assert!(!services.has_any_localhost_only()); + } + + #[test] + fn it_should_return_tls_domain_names() { + let services = ServiceInfo::new( + vec![], + vec!["https://api.tracker.local/announce".to_string()], + vec![], + vec![], + "https://api.tracker.local/api".to_string(), + true, + false, // API not localhost-only + "http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + false, // Health check doesn't use HTTPS + false, // Health check not localhost-only + vec![ + TlsDomainInfo { + domain: "api.tracker.local".to_string(), + internal_port: 1212, + }, + TlsDomainInfo { + domain: "grafana.tracker.local".to_string(), + internal_port: 3000, + }, + ], + ); + + let domains = services.tls_domain_names(); + assert_eq!(domains.len(), 2); + assert!(domains.contains(&"api.tracker.local")); + assert!(domains.contains(&"grafana.tracker.local")); + } + + #[test] + fn it_should_return_unexposed_ports() { + let services = ServiceInfo::new( + vec![], + vec![], + vec![], + vec![], + "https://api.tracker.local/api".to_string(), + true, + false, // API not localhost-only + String::new(), + false, // Health check doesn't use HTTPS + false, // Health check not localhost-only + vec![ + TlsDomainInfo { + domain: "api.tracker.local".to_string(), + internal_port: 1212, + }, + TlsDomainInfo { + domain: "http1.tracker.local".to_string(), + internal_port: 7070, + }, + ], + ); + + let ports = services.unexposed_ports(); + assert_eq!(ports.len(), 2); + assert!(ports.contains(&1212)); + assert!(ports.contains(&7070)); + } + + #[test] + fn it_should_detect_no_tls_when_empty() { + let services = ServiceInfo::new( + vec!["udp://10.0.0.1:6969/announce".to_string()], + vec![], + vec!["http://10.0.0.1:7070/announce".to_string()], // DevSkim: ignore DS137138 + vec![], // No localhost HTTP trackers + "http://10.0.0.1:1212/api".to_string(), // DevSkim: ignore DS137138 + false, + false, // API not localhost-only + "http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + false, // Health check doesn't use HTTPS + false, // Health check not localhost-only + vec![], + ); + + assert!(!services.has_any_tls()); + assert!(!services.has_any_localhost_only()); + assert!(services.tls_domain_names().is_empty()); + assert!(services.unexposed_ports().is_empty()); + } + + #[test] + fn it_should_detect_localhost_only_api() { + let services = ServiceInfo::new( + vec![], + vec![], + vec![], + vec![], + "http://127.0.0.1:1212/api".to_string(), // DevSkim: ignore DS137138 + false, + true, // API is localhost-only + "http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + false, + false, + vec![], + ); + + assert!(services.has_any_localhost_only()); + assert!(services.api_is_localhost_only); + assert!(!services.health_check_is_localhost_only); + } + + #[test] + fn it_should_detect_localhost_only_health_check() { + let services = ServiceInfo::new( + vec![], + vec![], + vec![], + vec![], + "http://10.0.0.1:1212/api".to_string(), // DevSkim: ignore DS137138 + false, + false, + "http://127.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + false, + true, // Health check is localhost-only + vec![], + ); + + assert!(services.has_any_localhost_only()); + assert!(!services.api_is_localhost_only); + assert!(services.health_check_is_localhost_only); + } + + #[test] + fn it_should_detect_localhost_only_http_trackers() { + let services = ServiceInfo::new( + vec![], + vec![], + vec![], + vec![LocalhostServiceInfo { + service_name: "http_tracker_1".to_string(), + port: 7070, + }], + "http://10.0.0.1:1212/api".to_string(), // DevSkim: ignore DS137138 + false, + false, + "http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + false, + false, + vec![], + ); + + assert!(services.has_any_localhost_only()); + assert_eq!(services.localhost_http_trackers.len(), 1); + assert_eq!(services.localhost_http_trackers[0].port, 7070); } } diff --git a/src/application/command_handlers/test/handler.rs b/src/application/command_handlers/test/handler.rs index 2773eb21..30381d61 100644 --- a/src/application/command_handlers/test/handler.rs +++ b/src/application/command_handlers/test/handler.rs @@ -10,10 +10,19 @@ //! //! The test command validates deployed services through: //! -//! 1. **Docker Compose Service Status** - Verifies containers are running -//! 2. **External Health Checks** - Tests service accessibility from outside the VM: -//! - Tracker API health endpoint (required): `http://:/api/health_check` -//! - HTTP Tracker health endpoint (required): `http://:/health_check` +//! 1. **External Health Checks** - Tests service accessibility from outside the VM: +//! - Tracker API health endpoint (required) +//! - HTTP Tracker health endpoint (required) +//! +//! ## HTTPS Support +//! +//! When services have TLS enabled via Caddy reverse proxy: +//! - Uses HTTPS URLs with the configured domain +//! - Resolves domains locally to the VM IP (no DNS dependency for testing) +//! - Accepts self-signed certificates for `.local` domains +//! +//! This approach allows testing to work without DNS configuration while still +//! being realistic (Caddy receives the correct SNI/Host header). //! //! ## Why External-Only Validation? //! @@ -33,16 +42,15 @@ //! For rationale and alternatives, see: //! - `docs/decisions/test-command-as-smoke-test.md` - Architectural decision record -use std::net::SocketAddr; use std::sync::Arc; use tracing::{info, instrument}; use super::errors::TestCommandHandlerError; -use crate::adapters::ssh::SshConfig; use crate::domain::environment::repository::{EnvironmentRepository, TypedEnvironmentRepository}; +use crate::domain::tracker::config::{HttpApiConfig, HttpTrackerConfig}; use crate::domain::EnvironmentName; -use crate::infrastructure::external_validators::RunningServicesValidator; +use crate::infrastructure::external_validators::{RunningServicesValidator, ServiceEndpoint}; use crate::infrastructure::remote_actions::RemoteAction; /// `TestCommandHandler` orchestrates smoke testing for running Torrust Tracker services @@ -124,33 +132,31 @@ impl TestCommandHandler { environment_name: env_name.to_string(), })?; - // Extract tracker ports from configuration + // Extract tracker config let tracker_config = any_env.tracker_config(); - // Get HTTP API port from bind_address (e.g., "0.0.0.0:1212" -> 1212) - let tracker_api_port = Some(Self::extract_port_from_bind_address( - &tracker_config.http_api.bind_address, - )) - .ok_or_else(|| TestCommandHandlerError::InvalidTrackerConfiguration { - message: format!( - "Invalid HTTP API bind_address: {}. Expected format: 'host:port'", - tracker_config.http_api.bind_address - ), - })?; - - // Get all HTTP Tracker ports - let http_tracker_ports: Vec = tracker_config + // Build service endpoints from configuration (with server IP) + let tracker_api_endpoint = Self::build_api_endpoint(instance_ip, &tracker_config.http_api); + let http_tracker_endpoints: Vec = tracker_config .http_trackers .iter() - .map(|tracker| Self::extract_port_from_bind_address(&tracker.bind_address)) + .map(|config| Self::build_http_tracker_endpoint(instance_ip, config)) .collect(); - let ssh_config = - SshConfig::with_default_port(any_env.ssh_credentials().clone(), instance_ip); + // Log endpoint information + info!( + command = "test", + environment = %env_name, + instance_ip = ?instance_ip, + api_endpoint_tls = tracker_api_endpoint.uses_tls(), + api_endpoint_domain = ?tracker_api_endpoint.domain(), + http_tracker_count = http_tracker_endpoints.len(), + "Starting service health checks" + ); // Validate running services with external accessibility checks let services_validator = - RunningServicesValidator::new(ssh_config, tracker_api_port, http_tracker_ports.clone()); + RunningServicesValidator::new(tracker_api_endpoint, http_tracker_endpoints); services_validator.execute(&instance_ip).await?; @@ -158,17 +164,43 @@ impl TestCommandHandler { command = "test", environment = %env_name, instance_ip = ?instance_ip, - tracker_api_port = tracker_api_port, - http_tracker_ports = ?http_tracker_ports, "Service testing workflow completed successfully" ); Ok(()) } - /// Extract port number from `SocketAddr` (e.g., `"0.0.0.0:1212".parse()` returns 1212) - fn extract_port_from_bind_address(bind_address: &SocketAddr) -> u16 { - bind_address.port() + /// Build a `ServiceEndpoint` from the HTTP API configuration + fn build_api_endpoint(server_ip: std::net::IpAddr, config: &HttpApiConfig) -> ServiceEndpoint { + let port = config.bind_address.port(); + let path = "/api/health_check"; + let socket_addr = std::net::SocketAddr::new(server_ip, port); + + if let Some(domain) = config.tls_domain() { + ServiceEndpoint::https(domain, path, server_ip) + .expect("Valid TLS domain should produce valid HTTPS URL") + } else { + ServiceEndpoint::http(socket_addr, path) + .expect("Valid socket address should produce valid HTTP URL") + } + } + + /// Build a `ServiceEndpoint` from the HTTP Tracker configuration + fn build_http_tracker_endpoint( + server_ip: std::net::IpAddr, + config: &HttpTrackerConfig, + ) -> ServiceEndpoint { + let port = config.bind_address.port(); + let path = "/health_check"; + let socket_addr = std::net::SocketAddr::new(server_ip, port); + + if let Some(domain) = config.tls_domain() { + ServiceEndpoint::https(domain, path, server_ip) + .expect("Valid TLS domain should produce valid HTTPS URL") + } else { + ServiceEndpoint::http(socket_addr, path) + .expect("Valid socket address should produce valid HTTP URL") + } } /// Load environment from storage diff --git a/src/application/steps/application/deploy_caddy_config.rs b/src/application/steps/application/deploy_caddy_config.rs new file mode 100644 index 00000000..f7f2f75b --- /dev/null +++ b/src/application/steps/application/deploy_caddy_config.rs @@ -0,0 +1,116 @@ +//! Caddy configuration deployment step +//! +//! This module provides the `DeployCaddyConfigStep` which handles deployment +//! of the Caddyfile configuration file to remote hosts via Ansible playbooks. +//! +//! ## Key Features +//! +//! - Creates Caddy storage directories on remote host +//! - Deploys Caddyfile from build directory to remote host +//! - Sets appropriate ownership and permissions +//! - Verifies successful deployment with assertions +//! - Only executes when HTTPS/TLS is configured in environment +//! +//! ## Deployment Flow +//! +//! 1. Create storage directories (/opt/torrust/storage/caddy/{etc,data,config}) +//! 2. Copy Caddyfile from build directory to remote host +//! 3. Set file permissions (0644) and ownership +//! 4. Verify file exists and has correct properties +//! +//! ## File Locations +//! +//! - **Source**: `{build_dir}/caddy/Caddyfile` +//! - **Destination**: `/opt/torrust/storage/caddy/etc/Caddyfile` +//! - **Container Mount**: Mounted as `/etc/caddy/Caddyfile` + +use std::sync::Arc; + +use tracing::{info, instrument}; + +use crate::adapters::ansible::AnsibleClient; +use crate::shared::command::CommandError; + +/// Step that deploys Caddy configuration to a remote host via Ansible +/// +/// This step creates the necessary storage directories and copies the rendered +/// Caddyfile configuration file from the build directory to the remote host's +/// Caddy configuration directory. +pub struct DeployCaddyConfigStep { + ansible_client: Arc, +} + +impl DeployCaddyConfigStep { + /// Create a new Caddy configuration deployment step + /// + /// # Arguments + /// + /// * `ansible_client` - Ansible client for running playbooks + #[must_use] + pub fn new(ansible_client: Arc) -> Self { + Self { ansible_client } + } + + /// Execute the configuration deployment + /// + /// Creates Caddy storage directories and runs the Ansible playbook that + /// deploys the Caddyfile configuration file. + /// + /// # Errors + /// + /// Returns `CommandError` if: + /// - Ansible playbook execution fails + /// - Directory creation fails + /// - File copying fails + /// - Permission setting fails + /// - Verification assertions fail + #[instrument( + name = "deploy_caddy_config", + skip_all, + fields(step_type = "deployment", component = "caddy", method = "ansible") + )] + pub fn execute(&self) -> Result<(), CommandError> { + info!( + step = "deploy_caddy_config", + action = "deploy_file", + "Deploying Caddy configuration to remote host" + ); + + match self.ansible_client.run_playbook("deploy-caddy-config", &[]) { + Ok(_) => { + info!( + step = "deploy_caddy_config", + status = "success", + "Caddy configuration deployed successfully" + ); + Ok(()) + } + Err(e) => { + tracing::error!( + step = "deploy_caddy_config", + error = %e, + "Failed to deploy Caddy configuration" + ); + Err(e) + } + } + } +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + + #[test] + fn it_should_create_deploy_caddy_config_step() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let ansible_client = Arc::new(AnsibleClient::new(temp_dir.path().to_path_buf())); + + let step = DeployCaddyConfigStep::new(ansible_client); + + // Step should be created successfully + assert!(!std::ptr::addr_of!(step).cast::<()>().is_null()); + } +} diff --git a/src/application/steps/application/mod.rs b/src/application/steps/application/mod.rs index 4f840d56..a1602c32 100644 --- a/src/application/steps/application/mod.rs +++ b/src/application/steps/application/mod.rs @@ -31,6 +31,7 @@ pub mod create_prometheus_storage; pub mod create_tracker_storage; +pub mod deploy_caddy_config; pub mod deploy_compose_files; pub mod deploy_grafana_provisioning; pub mod deploy_prometheus_config; @@ -41,6 +42,7 @@ pub mod start_services; pub use create_prometheus_storage::CreatePrometheusStorageStep; pub use create_tracker_storage::CreateTrackerStorageStep; +pub use deploy_caddy_config::DeployCaddyConfigStep; pub use deploy_compose_files::{DeployComposeFilesStep, DeployComposeFilesStepError}; pub use deploy_grafana_provisioning::DeployGrafanaProvisioningStep; pub use deploy_prometheus_config::DeployPrometheusConfigStep; diff --git a/src/application/steps/rendering/caddy_templates.rs b/src/application/steps/rendering/caddy_templates.rs new file mode 100644 index 00000000..b137fdbb --- /dev/null +++ b/src/application/steps/rendering/caddy_templates.rs @@ -0,0 +1,244 @@ +//! Caddy template rendering step +//! +//! This module provides the `RenderCaddyTemplatesStep` which handles rendering +//! of Caddy configuration templates to the build directory. This step prepares +//! Caddy Caddyfile for deployment to the remote host. +//! +//! ## Key Features +//! +//! - Template rendering for Caddy TLS proxy configuration +//! - Integration with the `CaddyProjectGenerator` for file generation +//! - Build directory preparation for deployment operations +//! - Automatic extraction of TLS-enabled services from tracker config +//! +//! ## Usage Context +//! +//! This step is typically executed during the release workflow, after +//! infrastructure provisioning and software installation, to prepare +//! the Caddy configuration files for deployment. +//! +//! ## Architecture +//! +//! This step follows the three-level architecture: +//! - **Command** (Level 1): `ReleaseCommandHandler` orchestrates the release workflow +//! - **Step** (Level 2): This `RenderCaddyTemplatesStep` handles template rendering +//! - The templates are rendered locally, no remote action is needed + +use std::path::PathBuf; +use std::sync::Arc; + +use tracing::{info, instrument}; + +use crate::domain::environment::Environment; +use crate::domain::template::TemplateManager; +use crate::infrastructure::templating::caddy::{ + CaddyContext, CaddyProjectGenerator, CaddyProjectGeneratorError, CaddyService, +}; + +/// Step that renders Caddy templates to the build directory +/// +/// This step handles the preparation of Caddy configuration files +/// by rendering templates to the build directory. The rendered files are +/// then ready to be deployed to the remote host. +/// +/// Caddy is only rendered when: +/// 1. HTTPS configuration is present in the environment +/// 2. At least one service has TLS configured +pub struct RenderCaddyTemplatesStep { + environment: Arc>, + template_manager: Arc, + build_dir: PathBuf, +} + +impl RenderCaddyTemplatesStep { + /// Creates a new `RenderCaddyTemplatesStep` + /// + /// # Arguments + /// + /// * `environment` - The deployment environment + /// * `template_manager` - The template manager for accessing templates + /// * `build_dir` - The build directory where templates will be rendered + #[must_use] + pub fn new( + environment: Arc>, + template_manager: Arc, + build_dir: PathBuf, + ) -> Self { + Self { + environment, + template_manager, + build_dir, + } + } + + /// Execute the template rendering step + /// + /// This will render Caddy templates to the build directory if HTTPS + /// configuration is present in the environment and at least one service + /// has TLS configured. + /// + /// # Returns + /// + /// Returns the path to the Caddy build directory on success, or `None` + /// if HTTPS/TLS is not configured. + /// + /// # Errors + /// + /// Returns an error if: + /// * Template rendering fails + /// * Directory creation fails + /// * File writing fails + #[instrument( + name = "render_caddy_templates", + skip_all, + fields( + step_type = "rendering", + template_type = "caddy", + build_dir = %self.build_dir.display() + ) + )] + pub fn execute(&self) -> Result, CaddyProjectGeneratorError> { + // Check if HTTPS is configured + let Some(https_config) = &self.environment.context().user_inputs.https else { + info!( + step = "render_caddy_templates", + status = "skipped", + reason = "https_not_configured", + "Skipping Caddy template rendering - HTTPS not configured" + ); + return Ok(None); + }; + + // Build CaddyContext from environment configuration + let caddy_context = self.build_caddy_context(https_config); + + // Check if any service has TLS configured + if !caddy_context.has_any_tls() { + info!( + step = "render_caddy_templates", + status = "skipped", + reason = "no_tls_services", + "Skipping Caddy template rendering - no services have TLS configured" + ); + return Ok(None); + } + + info!( + step = "render_caddy_templates", + templates_dir = %self.template_manager.templates_dir().display(), + build_dir = %self.build_dir.display(), + admin_email = %https_config.admin_email(), + use_staging = https_config.use_staging(), + "Rendering Caddy configuration templates" + ); + + let generator = CaddyProjectGenerator::new(&self.build_dir, self.template_manager.clone()); + + generator.render(&caddy_context)?; + + let caddy_build_dir = self.build_dir.join("caddy"); + + info!( + step = "render_caddy_templates", + caddy_build_dir = %caddy_build_dir.display(), + status = "success", + "Caddy templates rendered successfully" + ); + + Ok(Some(caddy_build_dir)) + } + + /// Build a `CaddyContext` from the environment configuration + /// + /// Extracts TLS-enabled services from tracker config and builds + /// the context with pre-extracted ports. + fn build_caddy_context( + &self, + https_config: &crate::domain::https::HttpsConfig, + ) -> CaddyContext { + let user_inputs = &self.environment.context().user_inputs; + let tracker = &user_inputs.tracker; + + let mut context = CaddyContext::new(https_config.admin_email(), https_config.use_staging()); + + // Add Tracker HTTP API if TLS configured + if let Some(tls_config) = tracker.http_api_tls_domain() { + let port = tracker.http_api_port(); + context = context.with_tracker_api(CaddyService::new(tls_config, port)); + } + + // Add HTTP Trackers with TLS configured + for (domain, port) in tracker.http_trackers_with_tls() { + context = context.with_http_tracker(CaddyService::new(domain, port)); + } + + // Add Health Check API if TLS configured + if let Some(tls_domain) = tracker.health_check_api_tls_domain() { + let port = tracker.health_check_api_port(); + context = context.with_health_check_api(CaddyService::new(tls_domain, port)); + } + + // Add Grafana if TLS configured + if let Some(ref grafana) = user_inputs.grafana { + if let Some(tls_domain) = grafana.tls_domain() { + // Grafana default port is 3000 + context = context.with_grafana(CaddyService::new(tls_domain, 3000)); + } + } + + context + } +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + use crate::domain::environment::testing::EnvironmentTestBuilder; + + #[test] + fn it_should_create_render_caddy_templates_step() { + let templates_dir = TempDir::new().expect("Failed to create templates dir"); + let build_dir = TempDir::new().expect("Failed to create build dir"); + + let (environment, _, _, _temp_dir) = + EnvironmentTestBuilder::new().build_with_custom_paths(); + let environment = Arc::new(environment); + + let template_manager = Arc::new(TemplateManager::new(templates_dir.path().to_path_buf())); + let step = RenderCaddyTemplatesStep::new( + environment.clone(), + template_manager.clone(), + build_dir.path().to_path_buf(), + ); + + assert_eq!(step.build_dir, build_dir.path()); + assert_eq!(step.template_manager.templates_dir(), templates_dir.path()); + } + + #[test] + fn it_should_skip_rendering_when_https_not_configured() { + let templates_dir = TempDir::new().expect("Failed to create templates dir"); + let build_dir = TempDir::new().expect("Failed to create build dir"); + + // Build environment without HTTPS config (default) + let (environment, _, _, _temp_dir) = + EnvironmentTestBuilder::new().build_with_custom_paths(); + let environment = Arc::new(environment); + + let template_manager = Arc::new(TemplateManager::new(templates_dir.path().to_path_buf())); + let step = RenderCaddyTemplatesStep::new( + environment, + template_manager, + build_dir.path().to_path_buf(), + ); + + let result = step.execute(); + assert!(result.is_ok(), "Should succeed when HTTPS not configured"); + assert!( + result.unwrap().is_none(), + "Should return None when HTTPS not configured" + ); + } +} diff --git a/src/application/steps/rendering/docker_compose_templates.rs b/src/application/steps/rendering/docker_compose_templates.rs index f48e2660..b5eeba06 100644 --- a/src/application/steps/rendering/docker_compose_templates.rs +++ b/src/application/steps/rendering/docker_compose_templates.rs @@ -29,11 +29,12 @@ use std::sync::Arc; use tracing::{info, instrument}; +use crate::domain::environment::user_inputs::UserInputs; use crate::domain::environment::Environment; use crate::domain::template::TemplateManager; use crate::domain::tracker::{DatabaseConfig, TrackerConfig}; use crate::infrastructure::templating::docker_compose::template::wrappers::docker_compose::{ - DockerComposeContext, DockerComposeContextBuilder, MysqlSetupConfig, TrackerPorts, + DockerComposeContext, DockerComposeContextBuilder, MysqlSetupConfig, TrackerServiceConfig, }; use crate::infrastructure::templating::docker_compose::template::wrappers::env::EnvContext; use crate::infrastructure::templating::docker_compose::{ @@ -107,15 +108,15 @@ impl RenderDockerComposeTemplatesStep { let generator = DockerComposeProjectGenerator::new(&self.build_dir, &self.template_manager); let admin_token = self.extract_admin_token(); - let ports = self.build_tracker_ports(); + let tracker = self.build_tracker_config(); // Create contexts based on database configuration let database_config = self.environment.database_config(); let (env_context, builder) = match database_config { - DatabaseConfig::Sqlite(..) => Self::create_sqlite_contexts(admin_token, ports), + DatabaseConfig::Sqlite(..) => Self::create_sqlite_contexts(admin_token, tracker), DatabaseConfig::Mysql(mysql_config) => Self::create_mysql_contexts( admin_token, - ports, + tracker, mysql_config.port, mysql_config.database_name.clone(), mysql_config.username.clone(), @@ -128,6 +129,9 @@ impl RenderDockerComposeTemplatesStep { // Apply Grafana configuration (independent of database choice) let builder = self.apply_grafana_config(builder); + + // Apply Caddy configuration (if HTTPS enabled) + let builder = self.apply_caddy_config(builder); let docker_compose_context = builder.build(); // Apply Grafana credentials to env context @@ -151,31 +155,68 @@ impl RenderDockerComposeTemplatesStep { self.environment.admin_token().to_string() } - fn build_tracker_ports(&self) -> TrackerPorts { + fn build_tracker_config(&self) -> TrackerServiceConfig { let tracker_config = self.environment.tracker_config(); - let (udp_tracker_ports, http_tracker_ports, http_api_port) = - Self::extract_tracker_ports(tracker_config); + let user_inputs = &self.environment.context().user_inputs; + + let (udp_tracker_ports, http_tracker_ports_without_tls, http_api_port, http_api_has_tls) = + Self::extract_tracker_ports(tracker_config, user_inputs); + + // Determine which features are enabled (affects tracker networks) + let has_prometheus = self.environment.prometheus_config().is_some(); + let has_mysql = matches!( + self.environment.database_config(), + DatabaseConfig::Mysql(..) + ); + let has_caddy = self.has_caddy_enabled(); - TrackerPorts { + TrackerServiceConfig::new( udp_tracker_ports, - http_tracker_ports, + http_tracker_ports_without_tls, http_api_port, + http_api_has_tls, + has_prometheus, + has_mysql, + has_caddy, + ) + } + + /// Check if Caddy is enabled (HTTPS with at least one TLS-configured service) + fn has_caddy_enabled(&self) -> bool { + let user_inputs = &self.environment.context().user_inputs; + + // Check if HTTPS is configured + if user_inputs.https.is_none() { + return false; } + + let tracker = &user_inputs.tracker; + + // Check if any service has TLS configured + let tracker_api_has_tls = tracker.http_api_tls_domain().is_some(); + let http_trackers_have_tls = !tracker.http_trackers_with_tls().is_empty(); + let grafana_has_tls = user_inputs + .grafana + .as_ref() + .is_some_and(|g| g.tls_domain().is_some()); + + // Caddy is enabled if HTTPS is configured AND at least one service has TLS + tracker_api_has_tls || http_trackers_have_tls || grafana_has_tls } fn create_sqlite_contexts( admin_token: String, - ports: TrackerPorts, + tracker: TrackerServiceConfig, ) -> (EnvContext, DockerComposeContextBuilder) { let env_context = EnvContext::new(admin_token); - let builder = DockerComposeContext::builder(ports); + let builder = DockerComposeContext::builder(tracker); (env_context, builder) } fn create_mysql_contexts( admin_token: String, - ports: TrackerPorts, + tracker: TrackerServiceConfig, port: u16, database_name: String, username: String, @@ -200,7 +241,7 @@ impl RenderDockerComposeTemplatesStep { port, }; - let builder = DockerComposeContext::builder(ports).with_mysql(mysql_config); + let builder = DockerComposeContext::builder(tracker).with_mysql(mysql_config); (env_context, builder) } @@ -227,6 +268,42 @@ impl RenderDockerComposeTemplatesStep { } } + fn apply_caddy_config( + &self, + builder: DockerComposeContextBuilder, + ) -> DockerComposeContextBuilder { + let user_inputs = &self.environment.context().user_inputs; + + // Check if HTTPS is configured + let Some(https_config) = &user_inputs.https else { + return builder; + }; + + let tracker = &user_inputs.tracker; + + // Check if any service has TLS configured + let has_tracker_api_tls = tracker.http_api_tls_domain().is_some(); + let has_http_tracker_tls = !tracker.http_trackers_with_tls().is_empty(); + let has_grafana_tls = user_inputs + .grafana + .as_ref() + .is_some_and(|g| g.tls_domain().is_some()); + + let has_any_tls = has_tracker_api_tls || has_http_tracker_tls || has_grafana_tls; + + // Note: The CaddyContext with full service details is built separately + // in caddy_templates.rs for the Caddyfile.tera template. The docker-compose + // template only needs to know if Caddy is enabled, not the service details. + let _ = https_config; // Silence unused warning - admin_email/use_staging used in caddy_templates.rs + + // Only add Caddy if at least one service has TLS + if has_any_tls { + builder.with_caddy() + } else { + builder + } + } + fn apply_grafana_env_context(&self, env_context: EnvContext) -> EnvContext { if let Some(grafana_config) = self.environment.grafana_config() { env_context.with_grafana( @@ -238,25 +315,45 @@ impl RenderDockerComposeTemplatesStep { } } - fn extract_tracker_ports(tracker_config: &TrackerConfig) -> (Vec, Vec, u16) { - // Extract UDP tracker ports + fn extract_tracker_ports( + tracker_config: &TrackerConfig, + user_inputs: &UserInputs, + ) -> (Vec, Vec, u16, bool) { + // Extract UDP tracker ports (always exposed - no TLS termination via Caddy) let udp_ports: Vec = tracker_config .udp_trackers .iter() .map(|tracker| tracker.bind_address.port()) .collect(); - // Extract HTTP tracker ports - let http_ports: Vec = tracker_config + // Get the set of HTTP tracker ports that have TLS enabled + let tls_enabled_ports: std::collections::HashSet = user_inputs + .tracker + .http_trackers_with_tls() + .iter() + .map(|(_, port)| *port) + .collect(); + + // Extract HTTP tracker ports WITHOUT TLS (these need to be exposed) + let http_ports_without_tls: Vec = tracker_config .http_trackers .iter() .map(|tracker| tracker.bind_address.port()) + .filter(|port| !tls_enabled_ports.contains(port)) .collect(); // Extract HTTP API port let api_port = tracker_config.http_api.bind_address.port(); - (udp_ports, http_ports, api_port) + // Check if HTTP API has TLS enabled + let http_api_has_tls = user_inputs.tracker.http_api_tls_domain().is_some(); + + ( + udp_ports, + http_ports_without_tls, + api_port, + http_api_has_tls, + ) } } diff --git a/src/application/steps/rendering/mod.rs b/src/application/steps/rendering/mod.rs index 3965ed93..6bebcf24 100644 --- a/src/application/steps/rendering/mod.rs +++ b/src/application/steps/rendering/mod.rs @@ -24,6 +24,7 @@ //! runtime information like IP addresses, SSH keys, and deployment settings. pub mod ansible_templates; +pub mod caddy_templates; pub mod docker_compose_templates; pub mod grafana_templates; pub mod opentofu_templates; @@ -31,6 +32,7 @@ pub mod prometheus_templates; pub mod tracker_templates; pub use ansible_templates::RenderAnsibleTemplatesStep; +pub use caddy_templates::RenderCaddyTemplatesStep; pub use docker_compose_templates::RenderDockerComposeTemplatesStep; pub use grafana_templates::RenderGrafanaTemplatesStep; pub use opentofu_templates::RenderOpenTofuTemplatesStep; diff --git a/src/bin/e2e_deployment_workflow_tests.rs b/src/bin/e2e_deployment_workflow_tests.rs index 5210a212..94231ffd 100644 --- a/src/bin/e2e_deployment_workflow_tests.rs +++ b/src/bin/e2e_deployment_workflow_tests.rs @@ -61,6 +61,7 @@ use tracing::{error, info}; use torrust_tracker_deployer_lib::adapters::ssh::SshCredentials; use torrust_tracker_deployer_lib::bootstrap::logging::{LogFormat, LogOutput, LoggingBuilder}; +use torrust_tracker_deployer_lib::infrastructure::external_validators::ServiceEndpoint; use torrust_tracker_deployer_lib::shared::Username; use torrust_tracker_deployer_lib::testing::e2e::containers::actions::{ SshKeySetupAction, SshWaitAction, @@ -303,15 +304,27 @@ async fn run_deployer_workflow( // Validate services are running using actual mapped ports from runtime environment // Note: E2E deployment environment has Prometheus and Grafana enabled + // Note: E2E tests use HTTP (no TLS) since we don't set up Caddy in test containers let run_services = RunServiceValidation { prometheus: true, grafana: true, }; + let server_ip = socket_addr.ip(); + let api_endpoint = ServiceEndpoint::http( + std::net::SocketAddr::new(server_ip, runtime_env.container_ports.http_api_port), + "/api/health_check", + ) + .expect("Valid socket address should produce valid HTTP URL"); + let http_tracker_endpoint = ServiceEndpoint::http( + std::net::SocketAddr::new(server_ip, runtime_env.container_ports.http_tracker_port), + "/health_check", + ) + .expect("Valid socket address should produce valid HTTP URL"); run_run_validation( socket_addr, ssh_credentials, - runtime_env.container_ports.http_api_port, - vec![runtime_env.container_ports.http_tracker_port], + api_endpoint, + vec![http_tracker_endpoint], Some(run_services), ) .await diff --git a/src/domain/environment/context.rs b/src/domain/environment/context.rs index 7e8c9a76..030bcd52 100644 --- a/src/domain/environment/context.rs +++ b/src/domain/environment/context.rs @@ -213,7 +213,7 @@ impl EnvironmentContext { /// /// This creates absolute paths for data and build directories by using the /// provided working directory as the base, and allows specifying custom - /// tracker, prometheus, and grafana configurations. + /// tracker, prometheus, grafana, and https configurations. #[must_use] #[allow(clippy::too_many_arguments)] // Public API with necessary configuration parameters pub fn with_working_dir_and_tracker( @@ -224,6 +224,7 @@ impl EnvironmentContext { tracker_config: crate::domain::tracker::TrackerConfig, prometheus_config: Option, grafana_config: Option, + https_config: Option, working_dir: &std::path::Path, created_at: DateTime, ) -> Self { @@ -237,6 +238,7 @@ impl EnvironmentContext { tracker_config, prometheus_config, grafana_config, + https_config, ), internal_config: InternalConfig::with_working_dir(name, working_dir), runtime_outputs: RuntimeOutputs { diff --git a/src/domain/environment/mod.rs b/src/domain/environment/mod.rs index 51b5a4f0..459cb253 100644 --- a/src/domain/environment/mod.rs +++ b/src/domain/environment/mod.rs @@ -300,7 +300,7 @@ impl Environment { /// /// This creates absolute paths for data and build directories by using the /// provided working directory as the base, and allows specifying custom - /// tracker, prometheus, and grafana configurations. + /// tracker, prometheus, grafana, and https configurations. #[must_use] #[allow(clippy::needless_pass_by_value)] // Public API takes ownership for ergonomics #[allow(clippy::too_many_arguments)] // Public API with necessary configuration parameters @@ -312,6 +312,7 @@ impl Environment { tracker_config: TrackerConfig, prometheus_config: Option, grafana_config: Option, + https_config: Option, working_dir: &std::path::Path, created_at: DateTime, ) -> Environment { @@ -323,6 +324,7 @@ impl Environment { tracker_config, prometheus_config, grafana_config, + https_config, working_dir, created_at, ); @@ -1119,6 +1121,7 @@ mod tests { tracker: TrackerConfig::default(), prometheus: Some(PrometheusConfig::default()), grafana: Some(GrafanaConfig::default()), + https: None, }, internal_config: InternalConfig { data_dir: data_dir.clone(), diff --git a/src/domain/environment/state/mod.rs b/src/domain/environment/state/mod.rs index c324c329..bb25a150 100644 --- a/src/domain/environment/state/mod.rs +++ b/src/domain/environment/state/mod.rs @@ -583,6 +583,20 @@ impl AnyEnvironmentState { self.context().user_inputs.grafana.as_ref() } + /// Get the HTTPS configuration if enabled, regardless of current state + /// + /// This method provides access to the HTTPS configuration without needing to + /// pattern match on the specific state variant. + /// + /// # Returns + /// + /// - `Some(&HttpsConfig)` if HTTPS/TLS is configured for this environment + /// - `None` if HTTPS is not enabled + #[must_use] + pub fn https_config(&self) -> Option<&crate::domain::https::HttpsConfig> { + self.context().user_inputs.https.as_ref() + } + /// Check if this environment was registered from existing infrastructure /// /// Registered environments have infrastructure that was created externally diff --git a/src/domain/environment/state/release_failed.rs b/src/domain/environment/state/release_failed.rs index 6cf1f861..2e5593a9 100644 --- a/src/domain/environment/state/release_failed.rs +++ b/src/domain/environment/state/release_failed.rs @@ -48,6 +48,10 @@ pub enum ReleaseStep { RenderGrafanaTemplates, /// Deploying Grafana provisioning configuration to the remote host via Ansible DeployGrafanaProvisioning, + /// Rendering Caddy configuration templates to the build directory (if HTTPS enabled) + RenderCaddyTemplates, + /// Deploying Caddy configuration to the remote host via Ansible (if HTTPS enabled) + DeployCaddyConfigToRemote, /// Rendering Docker Compose templates to the build directory RenderDockerComposeTemplates, /// Deploying compose files to the remote host via Ansible @@ -66,6 +70,8 @@ impl fmt::Display for ReleaseStep { Self::DeployPrometheusConfigToRemote => "Deploy Prometheus Config to Remote", Self::RenderGrafanaTemplates => "Render Grafana Templates", Self::DeployGrafanaProvisioning => "Deploy Grafana Provisioning", + Self::RenderCaddyTemplates => "Render Caddy Templates", + Self::DeployCaddyConfigToRemote => "Deploy Caddy Config to Remote", Self::RenderDockerComposeTemplates => "Render Docker Compose Templates", Self::DeployComposeFilesToRemote => "Deploy Compose Files to Remote", }; diff --git a/src/domain/environment/testing.rs b/src/domain/environment/testing.rs index 2ea861c8..6419fe62 100644 --- a/src/domain/environment/testing.rs +++ b/src/domain/environment/testing.rs @@ -161,6 +161,7 @@ impl EnvironmentTestBuilder { tracker: TrackerConfig::default(), prometheus: self.prometheus_config, grafana: Some(GrafanaConfig::default()), + https: None, }, internal_config: InternalConfig { data_dir: data_dir.clone(), diff --git a/src/domain/environment/user_inputs.rs b/src/domain/environment/user_inputs.rs index c2839f4c..9964c123 100644 --- a/src/domain/environment/user_inputs.rs +++ b/src/domain/environment/user_inputs.rs @@ -21,6 +21,7 @@ use crate::adapters::ssh::SshCredentials; use crate::domain::environment::EnvironmentName; use crate::domain::grafana::GrafanaConfig; +use crate::domain::https::HttpsConfig; use crate::domain::prometheus::PrometheusConfig; use crate::domain::provider::{Provider, ProviderConfig}; use crate::domain::tracker::TrackerConfig; @@ -63,6 +64,7 @@ use serde::{Deserialize, Serialize}; /// tracker: TrackerConfig::default(), /// prometheus: Some(PrometheusConfig::default()), /// grafana: Some(GrafanaConfig::default()), +/// https: None, /// }; /// # Ok::<(), Box>(()) /// ``` @@ -100,6 +102,13 @@ pub struct UserInputs { /// Requires Prometheus to be enabled - dependency validated at configuration time. /// Default: `Some(GrafanaConfig::default())` in generated templates. pub grafana: Option, + + /// HTTPS/TLS configuration for Caddy reverse proxy (optional) + /// + /// When present, Caddy service is deployed as a TLS termination proxy. + /// When absent (`None`), services are exposed directly over HTTP. + /// Requires at least one service to have TLS configuration. + pub https: Option, } impl UserInputs { @@ -168,14 +177,16 @@ impl UserInputs { tracker: TrackerConfig::default(), prometheus: Some(PrometheusConfig::default()), grafana: Some(GrafanaConfig::default()), + https: None, } } /// Creates a new `UserInputs` with custom tracker configuration /// /// This is similar to `new` but allows specifying custom tracker, - /// prometheus, and grafana configurations instead of using defaults. + /// prometheus, grafana, and https configurations instead of using defaults. #[must_use] + #[allow(clippy::too_many_arguments)] pub fn with_tracker( name: &EnvironmentName, provider_config: ProviderConfig, @@ -184,6 +195,7 @@ impl UserInputs { tracker: TrackerConfig, prometheus: Option, grafana: Option, + https: Option, ) -> Self { let instance_name = Self::generate_instance_name(name); @@ -196,6 +208,7 @@ impl UserInputs { tracker, prometheus, grafana, + https, } } diff --git a/src/domain/grafana/config.rs b/src/domain/grafana/config.rs index ff533a83..cba5a215 100644 --- a/src/domain/grafana/config.rs +++ b/src/domain/grafana/config.rs @@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize}; +use crate::shared::domain_name::DomainName; use crate::shared::secrets::Password; /// Grafana metrics visualization configuration @@ -20,6 +21,19 @@ pub struct GrafanaConfig { /// - Memory zeroing when the value is dropped /// - Explicit `.expose_secret()` calls required to access plaintext admin_password: Password, + + /// Domain name for the service (optional) + /// + /// When present, defines the hostname for accessing Grafana. + /// Can be used with or without TLS proxy. + #[serde(skip_serializing_if = "Option::is_none")] + domain: Option, + + /// Whether TLS termination via Caddy is enabled + /// + /// When true, Grafana will be accessible via HTTPS through + /// the Caddy reverse proxy using the configured domain. + use_tls_proxy: bool, } impl GrafanaConfig { @@ -30,14 +44,26 @@ impl GrafanaConfig { /// ```rust /// use torrust_tracker_deployer_lib::domain::grafana::GrafanaConfig; /// - /// let config = GrafanaConfig::new("admin".to_string(), "password".to_string()); + /// let config = GrafanaConfig::new( + /// "admin".to_string(), + /// "password".to_string(), + /// None, + /// false, + /// ); /// assert_eq!(config.admin_user(), "admin"); /// ``` #[must_use] - pub fn new(admin_user: String, admin_password: String) -> Self { + pub fn new( + admin_user: String, + admin_password: String, + domain: Option, + use_tls_proxy: bool, + ) -> Self { Self { admin_user, admin_password: Password::new(admin_password), + domain, + use_tls_proxy, } } @@ -52,6 +78,31 @@ impl GrafanaConfig { pub fn admin_password(&self) -> &Password { &self.admin_password } + + /// Returns the domain if configured + #[must_use] + pub fn domain(&self) -> Option<&DomainName> { + self.domain.as_ref() + } + + /// Returns the TLS domain if TLS proxy is enabled + /// + /// Returns the domain only when both domain is set AND `use_tls_proxy` is true. + /// This is used to determine if the service should be accessed via HTTPS. + #[must_use] + pub fn tls_domain(&self) -> Option<&str> { + if self.use_tls_proxy { + self.domain.as_ref().map(DomainName::as_str) + } else { + None + } + } + + /// Returns whether TLS proxy is enabled + #[must_use] + pub fn use_tls_proxy(&self) -> bool { + self.use_tls_proxy + } } impl Default for GrafanaConfig { @@ -59,6 +110,8 @@ impl Default for GrafanaConfig { Self { admin_user: "admin".to_string(), admin_password: Password::new("admin"), + domain: None, + use_tls_proxy: false, } } } @@ -73,6 +126,8 @@ mod tests { assert_eq!(config.admin_user, "admin"); assert_eq!(config.admin_password.expose_secret(), "admin"); + assert!(config.domain.is_none()); + assert!(!config.use_tls_proxy); } #[test] @@ -80,33 +135,69 @@ mod tests { let config = GrafanaConfig { admin_user: "custom_admin".to_string(), admin_password: Password::new("custom_pass"), + domain: None, + use_tls_proxy: false, }; assert_eq!(config.admin_user, "custom_admin"); assert_eq!(config.admin_password.expose_secret(), "custom_pass"); } + #[test] + fn it_should_create_grafana_config_with_domain_and_tls_proxy() { + let domain = DomainName::new("grafana.example.com").unwrap(); + let config = GrafanaConfig::new( + "admin".to_string(), + "password".to_string(), + Some(domain.clone()), + true, + ); + + assert_eq!(config.domain(), Some(&domain)); + assert!(config.use_tls_proxy()); + assert_eq!(config.tls_domain(), Some("grafana.example.com")); + } + + #[test] + fn it_should_return_none_for_tls_domain_when_tls_proxy_disabled() { + let domain = DomainName::new("grafana.example.com").unwrap(); + let config = GrafanaConfig::new( + "admin".to_string(), + "password".to_string(), + Some(domain.clone()), + false, + ); + + assert_eq!(config.domain(), Some(&domain)); + assert!(!config.use_tls_proxy()); + assert!(config.tls_domain().is_none()); + } + #[test] fn it_should_serialize_grafana_config_to_json() { let config = GrafanaConfig { admin_user: "admin".to_string(), admin_password: Password::new("secret123"), + domain: None, + use_tls_proxy: false, }; let json = serde_json::to_string(&config).expect("Failed to serialize"); assert!(json.contains("\"admin_user\":\"admin\"")); assert!(json.contains("\"admin_password\":\"secret123\"")); + assert!(json.contains("\"use_tls_proxy\":false")); } #[test] fn it_should_deserialize_grafana_config_from_json() { - let json = r#"{"admin_user":"admin","admin_password":"secret123"}"#; + let json = r#"{"admin_user":"admin","admin_password":"secret123","use_tls_proxy":false}"#; let config: GrafanaConfig = serde_json::from_str(json).expect("Failed to deserialize"); assert_eq!(config.admin_user, "admin"); assert_eq!(config.admin_password.expose_secret(), "secret123"); + assert!(!config.use_tls_proxy); } #[test] @@ -114,6 +205,8 @@ mod tests { let config = GrafanaConfig { admin_user: "admin".to_string(), admin_password: Password::new("super_secret"), + domain: None, + use_tls_proxy: false, }; let debug_output = format!("{config:?}"); @@ -128,6 +221,8 @@ mod tests { let config = GrafanaConfig { admin_user: "admin".to_string(), admin_password: Password::new("password"), + domain: None, + use_tls_proxy: false, }; let cloned = config.clone(); diff --git a/src/domain/https/config.rs b/src/domain/https/config.rs new file mode 100644 index 00000000..97a551c1 --- /dev/null +++ b/src/domain/https/config.rs @@ -0,0 +1,179 @@ +//! HTTPS configuration domain type +//! +//! This module defines the domain-level HTTPS configuration that is stored +//! in the environment and used to configure Caddy TLS termination. +//! +//! ## Domain vs DTO +//! +//! This is the domain type. The DTO version (`HttpsSection`) is in the +//! application layer at `src/application/command_handlers/create/config/https.rs`. +//! +//! The domain type is validated when created from the DTO and carries +//! the configuration through the environment lifecycle. + +use serde::{Deserialize, Serialize}; + +use crate::shared::Email; + +/// Domain-level HTTPS configuration for TLS termination +/// +/// Contains validated HTTPS settings used for Caddy reverse proxy configuration. +/// This type is created from the application-layer DTO (`HttpsSection`) after +/// validation and stored in the environment. +/// +/// # Let's Encrypt Environments +/// +/// - **Production** (default): Trusted certificates, rate-limited +/// - **Staging**: Untrusted test certificates, higher rate limits +/// +/// # Example +/// +/// ```rust +/// use torrust_tracker_deployer_lib::domain::https::HttpsConfig; +/// +/// let config = HttpsConfig::new("admin@example.com", false); +/// assert_eq!(config.admin_email(), "admin@example.com"); +/// assert!(!config.use_staging()); +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HttpsConfig { + /// Admin email for Let's Encrypt notifications + /// + /// Receives certificate expiration warnings and renewal failure notifications. + admin_email: String, + + /// Whether to use Let's Encrypt staging environment + /// + /// - `true`: Use staging CA (for testing, certificates not trusted) + /// - `false`: Use production CA (trusted certificates) + use_staging: bool, +} + +impl HttpsConfig { + /// Creates a new HTTPS configuration + /// + /// # Arguments + /// + /// * `admin_email` - Admin email for Let's Encrypt (already validated) + /// * `use_staging` - Whether to use staging environment + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::domain::https::HttpsConfig; + /// + /// // Production configuration + /// let config = HttpsConfig::new("admin@example.com", false); + /// assert!(!config.use_staging()); + /// + /// // Staging configuration (for testing) + /// let staging = HttpsConfig::new("admin@example.com", true); + /// assert!(staging.use_staging()); + /// ``` + #[must_use] + pub fn new(admin_email: impl Into, use_staging: bool) -> Self { + Self { + admin_email: admin_email.into(), + use_staging, + } + } + + /// Creates an HTTPS config from a validated email + /// + /// This is the preferred factory method when working with validated + /// email addresses from the application layer. + /// + /// # Arguments + /// + /// * `email` - Validated email address + /// * `use_staging` - Whether to use staging environment + #[must_use] + pub fn from_validated_email(email: &Email, use_staging: bool) -> Self { + Self { + admin_email: email.to_string(), + use_staging, + } + } + + /// Returns the admin email address + #[must_use] + pub fn admin_email(&self) -> &str { + &self.admin_email + } + + /// Returns whether to use Let's Encrypt staging environment + #[must_use] + pub fn use_staging(&self) -> bool { + self.use_staging + } +} + +impl Default for HttpsConfig { + /// Creates a default HTTPS configuration + /// + /// Uses a placeholder email that should be replaced before deployment. + fn default() -> Self { + Self { + admin_email: "admin@example.com".to_string(), + use_staging: false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_create_https_config_with_production_ca() { + let config = HttpsConfig::new("admin@tracker.example.com", false); + + assert_eq!(config.admin_email(), "admin@tracker.example.com"); + assert!(!config.use_staging()); + } + + #[test] + fn it_should_create_https_config_with_staging_ca() { + let config = HttpsConfig::new("admin@tracker.example.com", true); + + assert_eq!(config.admin_email(), "admin@tracker.example.com"); + assert!(config.use_staging()); + } + + #[test] + fn it_should_create_default_https_config() { + let config = HttpsConfig::default(); + + assert_eq!(config.admin_email(), "admin@example.com"); + assert!(!config.use_staging()); + } + + #[test] + fn it_should_serialize_to_json() { + let config = HttpsConfig::new("admin@example.com", true); + + let json = serde_json::to_string(&config).expect("serialization should succeed"); + + assert!(json.contains("\"admin_email\":\"admin@example.com\"")); + assert!(json.contains("\"use_staging\":true")); + } + + #[test] + fn it_should_deserialize_from_json() { + let json = r#"{"admin_email":"test@example.com","use_staging":false}"#; + + let config: HttpsConfig = + serde_json::from_str(json).expect("deserialization should succeed"); + + assert_eq!(config.admin_email(), "test@example.com"); + assert!(!config.use_staging()); + } + + #[test] + fn it_should_be_cloneable() { + let config = HttpsConfig::new("admin@example.com", true); + let cloned = config.clone(); + + assert_eq!(config, cloned); + } +} diff --git a/src/domain/https/mod.rs b/src/domain/https/mod.rs new file mode 100644 index 00000000..84a137ce --- /dev/null +++ b/src/domain/https/mod.rs @@ -0,0 +1,17 @@ +//! HTTPS domain types +//! +//! This module contains domain types for HTTPS/TLS configuration. +//! +//! ## Purpose +//! +//! The `HttpsConfig` type represents validated HTTPS settings that are stored +//! in the environment and used for Caddy TLS termination configuration. +//! +//! ## See Also +//! +//! - Application layer DTOs: `src/application/command_handlers/create/config/https.rs` +//! - Caddy template context: `src/infrastructure/templating/caddy/` + +pub mod config; + +pub use config::HttpsConfig; diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 7854e639..ce2541da 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -15,6 +15,7 @@ pub mod environment; pub mod grafana; +pub mod https; pub mod instance_name; pub mod profile_name; pub mod prometheus; diff --git a/src/domain/tracker/config/health_check_api.rs b/src/domain/tracker/config/health_check_api.rs index 2647e5bb..1c951b5f 100644 --- a/src/domain/tracker/config/health_check_api.rs +++ b/src/domain/tracker/config/health_check_api.rs @@ -4,6 +4,8 @@ use std::net::SocketAddr; use serde::{Deserialize, Serialize}; +use crate::shared::domain_name::DomainName; + /// Health Check API configuration /// /// The Health Check API is a minimal HTTP endpoint used by Docker and container @@ -18,6 +20,40 @@ pub struct HealthCheckApiConfig { deserialize_with = "crate::domain::tracker::config::deserialize_socket_addr" )] pub bind_address: SocketAddr, + + /// Domain name for external HTTPS access (optional) + /// + /// When present, defines the domain at which this service will be accessible. + /// Caddy uses this for automatic certificate management. + #[serde(skip_serializing_if = "Option::is_none")] + pub domain: Option, + + /// Whether to use TLS proxy via Caddy (default: false) + /// + /// When true: + /// - Caddy handles HTTPS termination with automatic certificates + /// - Requires a domain to be configured + /// - Service receives plain HTTP from Caddy internally + #[serde(default)] + pub use_tls_proxy: bool, +} + +impl HealthCheckApiConfig { + /// Returns whether TLS proxy is enabled + #[must_use] + pub fn uses_tls_proxy(&self) -> bool { + self.use_tls_proxy + } + + /// Returns the TLS domain if TLS proxy is configured + #[must_use] + pub fn tls_domain(&self) -> Option<&str> { + if self.use_tls_proxy { + self.domain.as_ref().map(DomainName::as_str) + } else { + None + } + } } #[cfg(test)] @@ -28,32 +64,96 @@ mod tests { fn it_should_create_health_check_api_config() { let config = HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), + domain: None, + use_tls_proxy: false, }; assert_eq!( config.bind_address, "127.0.0.1:1313".parse::().unwrap() ); + assert!(!config.use_tls_proxy); + assert!(config.domain.is_none()); + } + + #[test] + fn it_should_create_health_check_api_config_with_tls_proxy() { + let domain = DomainName::new("health.tracker.local").unwrap(); + let config = HealthCheckApiConfig { + bind_address: "0.0.0.0:1313".parse().unwrap(), + domain: Some(domain), + use_tls_proxy: true, + }; + + assert!(config.uses_tls_proxy()); + assert_eq!(config.tls_domain(), Some("health.tracker.local")); } #[test] fn it_should_serialize_health_check_api_config() { let config = HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), + domain: None, + use_tls_proxy: false, }; let json = serde_json::to_value(&config).unwrap(); assert_eq!(json["bind_address"], "127.0.0.1:1313"); + // domain should not be serialized when None + assert!(json.get("domain").is_none()); + // use_tls_proxy should be serialized + assert_eq!(json["use_tls_proxy"], false); + } + + #[test] + fn it_should_serialize_health_check_api_config_with_tls_proxy() { + let domain = DomainName::new("health.tracker.local").unwrap(); + let config = HealthCheckApiConfig { + bind_address: "0.0.0.0:1313".parse().unwrap(), + domain: Some(domain), + use_tls_proxy: true, + }; + + let json = serde_json::to_value(&config).unwrap(); + assert_eq!(json["bind_address"], "0.0.0.0:1313"); + assert_eq!(json["domain"], "health.tracker.local"); + assert_eq!(json["use_tls_proxy"], true); } #[test] fn it_should_deserialize_health_check_api_config() { - let json = r#"{"bind_address": "127.0.0.1:1313"}"#; + let json = r#"{"bind_address": "127.0.0.1:1313", "use_tls_proxy": false}"#; let config: HealthCheckApiConfig = serde_json::from_str(json).unwrap(); assert_eq!( config.bind_address, "127.0.0.1:1313".parse::().unwrap() ); + assert!(!config.use_tls_proxy); + } + + #[test] + fn it_should_deserialize_health_check_api_config_with_tls_proxy() { + let json = r#"{"bind_address": "0.0.0.0:1313", "domain": "health.tracker.local", "use_tls_proxy": true}"#; + let config: HealthCheckApiConfig = serde_json::from_str(json).unwrap(); + + assert_eq!( + config.bind_address, + "0.0.0.0:1313".parse::().unwrap() + ); + assert_eq!(config.tls_domain(), Some("health.tracker.local")); + } + + #[test] + fn it_should_return_none_for_tls_domain_when_tls_proxy_disabled() { + let domain = DomainName::new("health.tracker.local").unwrap(); + let config = HealthCheckApiConfig { + bind_address: "0.0.0.0:1313".parse().unwrap(), + domain: Some(domain), + use_tls_proxy: false, + }; + + assert!(!config.uses_tls_proxy()); + assert!(config.tls_domain().is_none()); } } diff --git a/src/domain/tracker/config/http.rs b/src/domain/tracker/config/http.rs index 2bb1a946..82087265 100644 --- a/src/domain/tracker/config/http.rs +++ b/src/domain/tracker/config/http.rs @@ -4,6 +4,8 @@ use std::net::SocketAddr; use serde::{Deserialize, Serialize}; +use crate::shared::DomainName; + /// HTTP tracker bind configuration #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct HttpTrackerConfig { @@ -13,6 +15,40 @@ pub struct HttpTrackerConfig { deserialize_with = "crate::domain::tracker::config::deserialize_socket_addr" )] pub bind_address: SocketAddr, + + /// Domain name for HTTPS certificate acquisition (optional) + /// + /// When present along with `use_tls_proxy: true`, this HTTP tracker will be + /// accessible via HTTPS through the Caddy reverse proxy using this domain. + #[serde(skip_serializing_if = "Option::is_none")] + pub domain: Option, + + /// Whether to proxy this service through Caddy with TLS termination + /// + /// When `true`: + /// - The service is proxied through Caddy with HTTPS enabled + /// - `domain` field is required + /// - Cannot be used with localhost bind addresses (`127.0.0.1`, `::1`) + /// - Implies the tracker's `on_reverse_proxy` should be `true` + pub use_tls_proxy: bool, +} + +impl HttpTrackerConfig { + /// Returns true if this tracker uses the TLS proxy + #[must_use] + pub fn uses_tls_proxy(&self) -> bool { + self.use_tls_proxy + } + + /// Returns the domain name if TLS proxy is enabled + #[must_use] + pub fn tls_domain(&self) -> Option<&DomainName> { + if self.use_tls_proxy { + self.domain.as_ref() + } else { + None + } + } } #[cfg(test)] @@ -20,35 +56,58 @@ mod tests { use super::*; #[test] - fn it_should_create_http_tracker_config() { + fn it_should_create_http_tracker_config_without_tls() { let config = HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), + domain: None, + use_tls_proxy: false, }; assert_eq!( config.bind_address, "0.0.0.0:7070".parse::().unwrap() ); + assert!(!config.uses_tls_proxy()); + assert!(config.tls_domain().is_none()); + } + + #[test] + fn it_should_create_http_tracker_config_with_tls() { + let config = HttpTrackerConfig { + bind_address: "0.0.0.0:7070".parse().unwrap(), + domain: Some(DomainName::new("tracker.example.com").unwrap()), + use_tls_proxy: true, + }; + + assert!(config.uses_tls_proxy()); + assert_eq!( + config.tls_domain().map(DomainName::as_str), + Some("tracker.example.com") + ); } #[test] fn it_should_serialize_http_tracker_config() { let json = serde_json::to_value(&HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), + domain: None, + use_tls_proxy: false, }) .unwrap(); assert_eq!(json["bind_address"], "0.0.0.0:7070"); + assert_eq!(json["use_tls_proxy"], false); } #[test] fn it_should_deserialize_http_tracker_config() { - let json = r#"{"bind_address": "0.0.0.0:7070"}"#; + let json = r#"{"bind_address": "0.0.0.0:7070", "use_tls_proxy": false}"#; let config: HttpTrackerConfig = serde_json::from_str(json).unwrap(); assert_eq!( config.bind_address, "0.0.0.0:7070".parse::().unwrap() ); + assert!(!config.use_tls_proxy); } } diff --git a/src/domain/tracker/config/http_api.rs b/src/domain/tracker/config/http_api.rs index f920ff7a..dad86c92 100644 --- a/src/domain/tracker/config/http_api.rs +++ b/src/domain/tracker/config/http_api.rs @@ -4,7 +4,7 @@ use std::net::SocketAddr; use serde::{Deserialize, Serialize}; -use crate::shared::ApiToken; +use crate::shared::{ApiToken, DomainName}; /// HTTP API configuration #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -18,6 +18,39 @@ pub struct HttpApiConfig { /// Admin access token for HTTP API authentication pub admin_token: ApiToken, + + /// Domain name for HTTPS certificate acquisition (optional) + /// + /// When present along with `use_tls_proxy: true`, this HTTP API will be + /// accessible via HTTPS through the Caddy reverse proxy using this domain. + #[serde(skip_serializing_if = "Option::is_none")] + pub domain: Option, + + /// Whether to proxy this service through Caddy with TLS termination + /// + /// When `true`: + /// - The service is proxied through Caddy with HTTPS enabled + /// - `domain` field is required + /// - Cannot be used with localhost bind addresses (`127.0.0.1`, `::1`) + pub use_tls_proxy: bool, +} + +impl HttpApiConfig { + /// Returns true if this API uses the TLS proxy + #[must_use] + pub fn uses_tls_proxy(&self) -> bool { + self.use_tls_proxy + } + + /// Returns the domain name if TLS proxy is enabled + #[must_use] + pub fn tls_domain(&self) -> Option<&DomainName> { + if self.use_tls_proxy { + self.domain.as_ref() + } else { + None + } + } } #[cfg(test)] @@ -25,10 +58,12 @@ mod tests { use super::*; #[test] - fn it_should_create_http_api_config() { + fn it_should_create_http_api_config_without_tls() { let config = HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "test_token".to_string().into(), + domain: None, + use_tls_proxy: false, }; assert_eq!( @@ -36,6 +71,24 @@ mod tests { "0.0.0.0:1212".parse::().unwrap() ); assert_eq!(config.admin_token.expose_secret(), "test_token"); + assert!(!config.uses_tls_proxy()); + assert!(config.tls_domain().is_none()); + } + + #[test] + fn it_should_create_http_api_config_with_tls() { + let config = HttpApiConfig { + bind_address: "0.0.0.0:1212".parse().unwrap(), + admin_token: "test_token".to_string().into(), + domain: Some(DomainName::new("api.example.com").unwrap()), + use_tls_proxy: true, + }; + + assert!(config.uses_tls_proxy()); + assert_eq!( + config.tls_domain().map(DomainName::as_str), + Some("api.example.com") + ); } #[test] @@ -43,16 +96,20 @@ mod tests { let config = HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "token123".to_string().into(), + domain: None, + use_tls_proxy: false, }; let json = serde_json::to_value(&config).unwrap(); assert_eq!(json["bind_address"], "0.0.0.0:1212"); assert_eq!(json["admin_token"], "token123"); + assert_eq!(json["use_tls_proxy"], false); } #[test] fn it_should_deserialize_http_api_config() { - let json = r#"{"bind_address": "0.0.0.0:1212", "admin_token": "MyToken"}"#; + let json = + r#"{"bind_address": "0.0.0.0:1212", "admin_token": "MyToken", "use_tls_proxy": false}"#; let config: HttpApiConfig = serde_json::from_str(json).unwrap(); assert_eq!( @@ -60,5 +117,6 @@ mod tests { "0.0.0.0:1212".parse::().unwrap() ); assert_eq!(config.admin_token.expose_secret(), "MyToken"); + assert!(!config.use_tls_proxy); } } diff --git a/src/domain/tracker/config/mod.rs b/src/domain/tracker/config/mod.rs index a3b1c20a..3c877679 100644 --- a/src/domain/tracker/config/mod.rs +++ b/src/domain/tracker/config/mod.rs @@ -5,11 +5,12 @@ use std::collections::HashMap; use std::fmt; -use std::net::SocketAddr; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use serde::{Deserialize, Serialize}; use super::{BindingAddress, Protocol}; +use crate::shared::DomainName; mod core; mod health_check_api; @@ -23,6 +24,28 @@ pub use http::HttpTrackerConfig; pub use http_api::HttpApiConfig; pub use udp::UdpTrackerConfig; +/// Checks if a socket address is bound to localhost (127.0.0.1 or `::1`). +/// +/// This is used to validate that TLS-enabled services don't bind to localhost, +/// since Caddy runs in a separate container and cannot reach localhost addresses. +/// +/// # Returns +/// +/// `true` if the address is IPv4 localhost (127.0.0.1) or IPv6 localhost (`::1`), +/// `false` otherwise. +/// +/// # Note +/// +/// This intentionally checks only exact localhost addresses (127.0.0.1 and `::1`), +/// not the entire 127.0.0.0/8 loopback range, as per design decision. +#[must_use] +pub fn is_localhost(addr: &SocketAddr) -> bool { + match addr.ip() { + IpAddr::V4(ipv4) => ipv4 == Ipv4Addr::LOCALHOST, + IpAddr::V6(ipv6) => ipv6 == Ipv6Addr::LOCALHOST, + } +} + /// Tracker deployment configuration /// /// This structure mirrors the real tracker configuration but only includes @@ -47,14 +70,18 @@ pub use udp::UdpTrackerConfig; /// UdpTrackerConfig { bind_address: "0.0.0.0:6969".parse().unwrap() }, /// ], /// http_trackers: vec![ -/// HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap() }, +/// HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), domain: None, use_tls_proxy: false }, /// ], /// http_api: HttpApiConfig { /// bind_address: "0.0.0.0:1212".parse().unwrap(), /// admin_token: "MyAccessToken".to_string().into(), +/// domain: None, +/// use_tls_proxy: false, /// }, /// health_check_api: HealthCheckApiConfig { /// bind_address: "127.0.0.1:1313".parse().unwrap(), +/// domain: None, +/// use_tls_proxy: false, /// }, /// }; /// ``` @@ -88,6 +115,18 @@ pub enum TrackerConfigError { /// Names of services attempting to bind to this address services: Vec, }, + + /// A service is bound to localhost but has TLS configured + /// + /// When a service binds to localhost (127.0.0.1 or `::1`), it cannot be proxied + /// through Caddy because Caddy runs in a separate container and localhost + /// addresses are not routable between containers. + LocalhostWithTls { + /// The service name (e.g., "HTTP API", "Health Check API", "HTTP Tracker #1") + service_name: String, + /// The bind address that uses localhost + bind_address: SocketAddr, + }, } impl fmt::Display for TrackerConfigError { @@ -109,6 +148,16 @@ impl fmt::Display for TrackerConfigError { Tip: Assign different port numbers to each service" ) } + Self::LocalhostWithTls { + service_name, + bind_address, + } => { + write!( + f, + "Localhost with TLS: '{service_name}' binds to {bind_address} which is not accessible for TLS proxy\n\ + Tip: Use a non-localhost address (e.g., 0.0.0.0) or remove TLS configuration" + ) + } } } } @@ -159,6 +208,38 @@ impl TrackerConfigError { See: docs/external-issues/tracker/udp-tcp-port-sharing-allowed.md\n", ); + help + } + Self::LocalhostWithTls { + service_name, + bind_address, + } => { + use std::fmt::Write; + + let mut help = String::from("Localhost with TLS - Detailed Troubleshooting:\n\n"); + + help.push_str("Affected service:\n"); + let _ = writeln!(help, " - {service_name}: {bind_address}\n"); + + help.push_str("Why this fails:\n"); + help.push_str( + "When a service binds to localhost (127.0.0.1 or `::1`), it is only accessible\n\ + from the same container. Caddy runs in a separate container and cannot\n\ + reach localhost addresses in the tracker container.\n\n", + ); + + help.push_str("Fix (choose one):\n"); + help.push_str( + "1. Change bind address to 0.0.0.0 to allow external access\n\ + 2. Remove TLS configuration if external access is not needed\n\n", + ); + + help.push_str("Note:\n"); + help.push_str( + "Services bound to localhost without TLS can be accessed via SSH tunnel.\n\ + Use: ssh -L local_port:localhost:remote_port user@host\n", + ); + help } } @@ -196,24 +277,72 @@ impl TrackerConfig { /// UdpTrackerConfig { bind_address: "0.0.0.0:6969".parse().unwrap() }, /// ], /// http_trackers: vec![ - /// HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap() }, + /// HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), domain: None, use_tls_proxy: false }, /// ], /// http_api: HttpApiConfig { /// bind_address: "0.0.0.0:1212".parse().unwrap(), /// admin_token: "MyAccessToken".to_string().into(), + /// domain: None, + /// use_tls_proxy: false, /// }, /// health_check_api: HealthCheckApiConfig { /// bind_address: "127.0.0.1:1313".parse().unwrap(), + /// domain: None, + /// use_tls_proxy: false, /// }, /// }; /// /// assert!(config.validate().is_ok()); /// ``` pub fn validate(&self) -> Result<(), TrackerConfigError> { + // Check for localhost + TLS combinations + self.check_localhost_with_tls()?; + + // Check for socket address conflicts let bindings = self.collect_bindings(); Self::check_for_conflicts(bindings) } + /// Checks that no service has both localhost binding and TLS configured + /// + /// When a service binds to localhost (127.0.0.1 or `::1`), it cannot be + /// proxied through Caddy because Caddy runs in a separate container. + /// + /// # Errors + /// + /// Returns `TrackerConfigError::LocalhostWithTls` if any service has + /// both a localhost binding and TLS configuration. + fn check_localhost_with_tls(&self) -> Result<(), TrackerConfigError> { + // Check HTTP API + if self.http_api.use_tls_proxy && is_localhost(&self.http_api.bind_address) { + return Err(TrackerConfigError::LocalhostWithTls { + service_name: "HTTP API".to_string(), + bind_address: self.http_api.bind_address, + }); + } + + // Check Health Check API + if self.health_check_api.use_tls_proxy && is_localhost(&self.health_check_api.bind_address) + { + return Err(TrackerConfigError::LocalhostWithTls { + service_name: "Health Check API".to_string(), + bind_address: self.health_check_api.bind_address, + }); + } + + // Check HTTP trackers + for (i, tracker) in self.http_trackers.iter().enumerate() { + if tracker.use_tls_proxy && is_localhost(&tracker.bind_address) { + return Err(TrackerConfigError::LocalhostWithTls { + service_name: format!("HTTP Tracker #{}", i + 1), + bind_address: tracker.bind_address, + }); + } + } + + Ok(()) + } + /// Checks for socket address conflicts in the collected bindings /// /// Examines the binding map to find any addresses that have multiple @@ -314,6 +443,57 @@ impl TrackerConfig { .or_default() .push(service_name.to_string()); } + + /// Returns the HTTP API TLS domain if configured + #[must_use] + pub fn http_api_tls_domain(&self) -> Option<&str> { + self.http_api.tls_domain().map(DomainName::as_str) + } + + /// Returns the HTTP API port number + #[must_use] + pub fn http_api_port(&self) -> u16 { + self.http_api.bind_address.port() + } + + /// Returns the Health Check API TLS domain if configured + #[must_use] + pub fn health_check_api_tls_domain(&self) -> Option<&str> { + self.health_check_api.tls_domain() + } + + /// Returns the Health Check API port number + #[must_use] + pub fn health_check_api_port(&self) -> u16 { + self.health_check_api.bind_address.port() + } + + /// Returns HTTP trackers that have TLS proxy enabled + /// + /// Returns a vector of tuples containing (domain, port) for each + /// HTTP tracker that has `use_tls_proxy: true` and a domain configured. + #[must_use] + pub fn http_trackers_with_tls(&self) -> Vec<(&str, u16)> { + self.http_trackers + .iter() + .filter(|tracker| tracker.use_tls_proxy) + .filter_map(|tracker| { + tracker + .domain + .as_ref() + .map(|domain| (domain.as_str(), tracker.bind_address.port())) + }) + .collect() + } + + /// Returns true if any HTTP tracker has `use_tls_proxy: true` + /// + /// This is used to determine if the tracker's global `on_reverse_proxy` + /// setting should be enabled in the tracker configuration template. + #[must_use] + pub fn any_http_tracker_uses_tls_proxy(&self) -> bool { + self.http_trackers.iter().any(|t| t.use_tls_proxy) + } } /// Trait for types that have a bind address @@ -360,13 +540,19 @@ impl Default for TrackerConfig { }], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().expect("valid address"), + domain: None, + use_tls_proxy: false, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().expect("valid address"), admin_token: "MyAccessToken".to_string().into(), + domain: None, + use_tls_proxy: false, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().expect("valid address"), + domain: None, + use_tls_proxy: false, }, } } @@ -391,6 +577,47 @@ where mod tests { use super::*; + mod is_localhost_tests { + use super::*; + + #[test] + fn it_should_detect_ipv4_localhost() { + let addr: SocketAddr = "127.0.0.1:8080".parse().unwrap(); + assert!(is_localhost(&addr)); + } + + #[test] + fn it_should_detect_ipv6_localhost() { + let addr: SocketAddr = "[::1]:8080".parse().unwrap(); + assert!(is_localhost(&addr)); + } + + #[test] + fn it_should_not_detect_all_interfaces_ipv4() { + let addr: SocketAddr = "0.0.0.0:8080".parse().unwrap(); + assert!(!is_localhost(&addr)); + } + + #[test] + fn it_should_not_detect_all_interfaces_ipv6() { + let addr: SocketAddr = "[::]:8080".parse().unwrap(); + assert!(!is_localhost(&addr)); + } + + #[test] + fn it_should_not_detect_specific_ip() { + let addr: SocketAddr = "10.0.0.1:8080".parse().unwrap(); + assert!(!is_localhost(&addr)); + } + + #[test] + fn it_should_not_detect_other_127_x_addresses() { + // Only 127.0.0.1 is considered localhost, not the entire 127.0.0.0/8 range + let addr: SocketAddr = "127.0.0.2:8080".parse().unwrap(); + assert!(!is_localhost(&addr)); + } + } + #[test] fn it_should_create_tracker_config() { let config = TrackerConfig { @@ -405,13 +632,19 @@ mod tests { }], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), + domain: None, + use_tls_proxy: false, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "test_token".to_string().into(), + domain: None, + use_tls_proxy: false, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), + domain: None, + use_tls_proxy: false, }, }; @@ -435,9 +668,13 @@ mod tests { http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "token123".to_string().into(), + domain: None, + use_tls_proxy: false, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), + domain: None, + use_tls_proxy: false, }, }; @@ -496,13 +733,19 @@ mod tests { }], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), + domain: None, + use_tls_proxy: false, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "token".to_string().into(), + domain: None, + use_tls_proxy: false, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), + domain: None, + use_tls_proxy: false, }, }; @@ -530,9 +773,13 @@ mod tests { http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "token".to_string().into(), + domain: None, + use_tls_proxy: false, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), + domain: None, + use_tls_proxy: false, }, }; @@ -568,17 +815,25 @@ mod tests { http_trackers: vec![ HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), + domain: None, + use_tls_proxy: false, }, HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), + domain: None, + use_tls_proxy: false, }, ], http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "token".to_string().into(), + domain: None, + use_tls_proxy: false, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), + domain: None, + use_tls_proxy: false, }, }; @@ -611,13 +866,19 @@ mod tests { udp_trackers: vec![], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), + domain: None, + use_tls_proxy: false, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), admin_token: "token".to_string().into(), + domain: None, + use_tls_proxy: false, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), + domain: None, + use_tls_proxy: false, }, }; @@ -652,13 +913,19 @@ mod tests { udp_trackers: vec![], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:1313".parse().unwrap(), + domain: None, + use_tls_proxy: false, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "token".to_string().into(), + domain: None, + use_tls_proxy: false, }, health_check_api: HealthCheckApiConfig { bind_address: "0.0.0.0:1313".parse().unwrap(), + domain: None, + use_tls_proxy: false, }, }; @@ -696,13 +963,19 @@ mod tests { }], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), + domain: None, + use_tls_proxy: false, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "token".to_string().into(), + domain: None, + use_tls_proxy: false, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), + domain: None, + use_tls_proxy: false, }, }; @@ -722,17 +995,25 @@ mod tests { http_trackers: vec![ HttpTrackerConfig { bind_address: "192.168.1.10:7070".parse().unwrap(), + domain: None, + use_tls_proxy: false, }, HttpTrackerConfig { bind_address: "192.168.1.20:7070".parse().unwrap(), + domain: None, + use_tls_proxy: false, }, ], http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "token".to_string().into(), + domain: None, + use_tls_proxy: false, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), + domain: None, + use_tls_proxy: false, }, }; @@ -751,13 +1032,19 @@ mod tests { udp_trackers: vec![], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), + domain: None, + use_tls_proxy: false, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), admin_token: "token".to_string().into(), + domain: None, + use_tls_proxy: false, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), + domain: None, + use_tls_proxy: false, }, }; @@ -783,4 +1070,194 @@ mod tests { assert!(help.contains("docs/external-issues/tracker/udp-tcp-port-sharing-allowed.md")); } } + + mod localhost_with_tls_validation { + use super::*; + + fn base_config() -> TrackerConfig { + TrackerConfig { + core: TrackerCoreConfig { + database: DatabaseConfig::Sqlite(SqliteConfig { + database_name: "tracker.db".to_string(), + }), + private: false, + }, + udp_trackers: vec![], + http_trackers: vec![], + http_api: HttpApiConfig { + bind_address: "0.0.0.0:1212".parse().unwrap(), + admin_token: "token".to_string().into(), + domain: None, + use_tls_proxy: false, + }, + health_check_api: HealthCheckApiConfig { + bind_address: "127.0.0.1:1313".parse().unwrap(), + domain: None, + use_tls_proxy: false, + }, + } + } + + #[test] + fn it_should_reject_http_api_localhost_ipv4_with_tls() { + let domain = crate::shared::DomainName::new("api.tracker.local").unwrap(); + let mut config = base_config(); + config.http_api = HttpApiConfig { + bind_address: "127.0.0.1:1212".parse().unwrap(), + admin_token: "token".to_string().into(), + domain: Some(domain), + use_tls_proxy: true, + }; + + let result = config.validate(); + assert!(result.is_err()); + + if let Err(TrackerConfigError::LocalhostWithTls { + service_name, + bind_address, + }) = result + { + assert_eq!(service_name, "HTTP API"); + assert_eq!( + bind_address, + "127.0.0.1:1212".parse::().unwrap() + ); + } else { + panic!("Expected LocalhostWithTls error"); + } + } + + #[test] + fn it_should_reject_http_api_localhost_ipv6_with_tls() { + let domain = crate::shared::DomainName::new("api.tracker.local").unwrap(); + let mut config = base_config(); + config.http_api = HttpApiConfig { + bind_address: "[::1]:1212".parse().unwrap(), + admin_token: "token".to_string().into(), + domain: Some(domain), + use_tls_proxy: true, + }; + + let result = config.validate(); + assert!(result.is_err()); + + if let Err(TrackerConfigError::LocalhostWithTls { + service_name, + bind_address, + }) = result + { + assert_eq!(service_name, "HTTP API"); + assert_eq!(bind_address, "[::1]:1212".parse::().unwrap()); + } else { + panic!("Expected LocalhostWithTls error"); + } + } + + #[test] + fn it_should_reject_health_check_api_localhost_with_tls() { + let domain = crate::shared::DomainName::new("health.tracker.local").unwrap(); + let mut config = base_config(); + config.health_check_api = HealthCheckApiConfig { + bind_address: "127.0.0.1:1313".parse().unwrap(), + domain: Some(domain), + use_tls_proxy: true, + }; + + let result = config.validate(); + assert!(result.is_err()); + + if let Err(TrackerConfigError::LocalhostWithTls { + service_name, + bind_address, + }) = result + { + assert_eq!(service_name, "Health Check API"); + assert_eq!( + bind_address, + "127.0.0.1:1313".parse::().unwrap() + ); + } else { + panic!("Expected LocalhostWithTls error"); + } + } + + #[test] + fn it_should_reject_http_tracker_localhost_with_tls() { + let domain = crate::shared::DomainName::new("tracker.local").unwrap(); + let mut config = base_config(); + config.http_trackers = vec![HttpTrackerConfig { + bind_address: "127.0.0.1:7070".parse().unwrap(), + domain: Some(domain), + use_tls_proxy: true, + }]; + + let result = config.validate(); + assert!(result.is_err()); + + if let Err(TrackerConfigError::LocalhostWithTls { + service_name, + bind_address, + }) = result + { + assert_eq!(service_name, "HTTP Tracker #1"); + assert_eq!( + bind_address, + "127.0.0.1:7070".parse::().unwrap() + ); + } else { + panic!("Expected LocalhostWithTls error"); + } + } + + #[test] + fn it_should_allow_localhost_without_tls() { + let config = base_config(); + // base_config has http_api on 0.0.0.0 and health_check_api on 127.0.0.1 without TLS + assert!(config.validate().is_ok()); + } + + #[test] + fn it_should_allow_non_localhost_with_tls() { + let domain = crate::shared::DomainName::new("api.tracker.local").unwrap(); + let mut config = base_config(); + config.http_api = HttpApiConfig { + bind_address: "0.0.0.0:1212".parse().unwrap(), + admin_token: "token".to_string().into(), + domain: Some(domain), + use_tls_proxy: true, + }; + + assert!(config.validate().is_ok()); + } + + #[test] + fn it_should_provide_clear_error_message_for_localhost_with_tls() { + let domain = crate::shared::DomainName::new("api.tracker.local").unwrap(); + let mut config = base_config(); + config.http_api = HttpApiConfig { + bind_address: "127.0.0.1:1212".parse().unwrap(), + admin_token: "token".to_string().into(), + domain: Some(domain), + use_tls_proxy: true, + }; + + let error = config.validate().unwrap_err(); + let error_message = error.to_string(); + + // Verify brief error message + assert!(error_message.contains("Localhost with TLS")); + assert!(error_message.contains("HTTP API")); + assert!(error_message.contains("127.0.0.1:1212")); + assert!(error_message.contains("Tip:")); + + // Verify detailed help + let help = error.help(); + assert!(help.contains("Localhost with TLS - Detailed Troubleshooting")); + assert!(help.contains("Why this fails")); + assert!(help.contains("Caddy")); + assert!(help.contains("Fix (choose one)")); + assert!(help.contains("0.0.0.0")); + assert!(help.contains("SSH tunnel")); + } + } } diff --git a/src/domain/tracker/mod.rs b/src/domain/tracker/mod.rs index aa9c9f0a..a7751728 100644 --- a/src/domain/tracker/mod.rs +++ b/src/domain/tracker/mod.rs @@ -34,25 +34,30 @@ //! UdpTrackerConfig { bind_address: "0.0.0.0:6868".parse().unwrap() }, //! ], //! http_trackers: vec![ -//! HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap() }, +//! HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), domain: None, use_tls_proxy: false }, //! ], //! http_api: HttpApiConfig { //! bind_address: "0.0.0.0:1212".parse().unwrap(), //! admin_token: "MyToken".to_string().into(), +//! domain: None, +//! use_tls_proxy: false, //! }, //! health_check_api: HealthCheckApiConfig { //! bind_address: "127.0.0.1:1313".parse().unwrap(), +//! domain: None, +//! use_tls_proxy: false, //! }, //! }; //! ``` mod binding_address; -mod config; +pub mod config; mod protocol; pub use binding_address::BindingAddress; pub use config::{ - DatabaseConfig, HealthCheckApiConfig, HttpApiConfig, HttpTrackerConfig, MysqlConfig, - SqliteConfig, TrackerConfig, TrackerConfigError, TrackerCoreConfig, UdpTrackerConfig, + is_localhost, DatabaseConfig, HealthCheckApiConfig, HttpApiConfig, HttpTrackerConfig, + MysqlConfig, SqliteConfig, TrackerConfig, TrackerConfigError, TrackerCoreConfig, + UdpTrackerConfig, }; pub use protocol::{Protocol, ProtocolParseError}; diff --git a/src/infrastructure/external_validators/mod.rs b/src/infrastructure/external_validators/mod.rs index 8362e7bb..9816cae5 100644 --- a/src/infrastructure/external_validators/mod.rs +++ b/src/infrastructure/external_validators/mod.rs @@ -25,8 +25,11 @@ //! //! ## Available Validators //! -//! - `running_services` - Validates Docker Compose services via external HTTP health checks +//! - `running_services` - Validates Docker Compose services via external HTTP/HTTPS health checks +//! - `service_endpoint` - Types for representing service endpoints (HTTP or HTTPS) pub mod running_services; +pub mod service_endpoint; pub use running_services::RunningServicesValidator; +pub use service_endpoint::ServiceEndpoint; diff --git a/src/infrastructure/external_validators/running_services.rs b/src/infrastructure/external_validators/running_services.rs index 731d91d4..3868fc52 100644 --- a/src/infrastructure/external_validators/running_services.rs +++ b/src/infrastructure/external_validators/running_services.rs @@ -20,201 +20,150 @@ //! - **Remote actions**: Validate internal VM state and configuration //! - **External validators**: Validate end-to-end accessibility including network and firewall //! +//! ## HTTPS Support +//! +//! When services have TLS enabled via Caddy reverse proxy: +//! - The validator uses HTTPS URLs with the configured domain +//! - Domains are resolved locally to the VM IP (no DNS dependency) +//! - Self-signed certificates are accepted for `.local` domains +//! +//! This approach allows testing to work without DNS configuration while still +//! being realistic (Caddy receives the correct SNI/Host header). +//! //! ## Current Scope (Torrust Tracker) //! //! This validator performs external validation only (from test runner to VM): -//! - Verifies Docker Compose services are running (via SSH: `docker compose ps`) -//! - Tests tracker API health endpoint from outside: `http://:1212/api/health_check` -//! - Tests HTTP tracker health endpoint from outside: `http://:7070/health_check` +//! - Tests tracker API health endpoint: HTTP or HTTPS depending on TLS config +//! - Tests HTTP tracker health endpoint: HTTP or HTTPS depending on TLS config //! //! **Validation Philosophy**: External checks are a superset of internal checks. //! If external validation passes, it proves: //! - Services are running inside the VM -//! - Firewall rules are configured correctly -//! - Services are accessible from outside the VM -//! -//! ## Why External-Only Validation? -//! -//! We don't perform separate internal checks (via SSH curl to localhost) because: -//! - External checks already verify service functionality -//! - Simpler E2E tests are easier to maintain -//! - If external check fails, debugging will reveal whether it's a service or firewall issue -//! - Avoiding dual validation reduces test complexity -//! -//! ## Future Enhancements -//! -//! When deploying additional Torrust services or expanding validation: -//! -//! 1. **External Accessibility Testing**: Test service accessibility from outside the VM, -//! not just from inside. For example, if the HTTP tracker is on port 7070, we need -//! to verify it's reachable from the test runner machine. -//! -//! 2. **Firewall Rule Verification**: External tests will implicitly validate that -//! firewall rules (UFW/iptables) are correctly configured. If a service is running -//! inside but not accessible from outside, it indicates a firewall misconfiguration. -//! -//! 3. **Protocol-Specific Tests**: -//! - HTTP Tracker announce: `curl http://localhost:7070/announce?info_hash=...` -//! - UDP Tracker announce (requires tracker client library from torrust-tracker) -//! - Additional Index API endpoints -//! -//! 4. **Both Internal and External Checks**: Consider running both types of validation: -//! - Internal (via SSH): Confirms service is running inside the container/VM -//! - External (from test runner): Confirms service is accessible through the network -//! -//! Example future validation for HTTP Tracker on port 7070: -//! ```text -//! // Internal check (current approach) -//! ssh user@vm "curl -sf http://localhost:7070/announce?info_hash=..." -//! -//! // External check (future enhancement) -//! curl -sf http://:7070/announce?info_hash=... -//! ``` -//! -//! This dual approach ensures complete end-to-end validation including network -//! configuration and firewall rules. -//! -//! ## Key Features -//! -//! - Validates services are in "running" state via `docker compose ps` (via SSH) -//! - Tests tracker API accessibility from outside the VM (external HTTP check) -//! - Tests HTTP tracker accessibility from outside the VM (external HTTP check) -//! - Comprehensive error reporting with actionable troubleshooting steps -//! -//! ## Validation Process -//! -//! The validator performs the following checks: -//! 1. SSH into VM and execute `docker compose ps` to verify services are running -//! 2. Check that containers are in "running" status (not "exited" or "restarting") -//! 3. Verify health check status if configured (e.g., "healthy") -//! 4. Test tracker API from outside: HTTP GET to `http://:1212/api/health_check` -//! 5. Test HTTP tracker from outside: HTTP GET to `http://:7070/health_check` -//! -//! This ensures end-to-end validation: -//! - Services are deployed and running -//! - Firewall rules allow external access +//! - Firewall rules are configured correctly (port 80/443 for TLS, or service port for HTTP) //! - Services are accessible from outside the VM +//! - TLS termination is working correctly (when enabled) use std::net::IpAddr; use std::path::PathBuf; -use tracing::{info, instrument}; +use std::time::Duration; + +use reqwest::ClientBuilder; +use tracing::{info, instrument, warn}; -use crate::adapters::ssh::SshConfig; +use super::service_endpoint::ServiceEndpoint; use crate::infrastructure::remote_actions::{RemoteAction, RemoteActionError}; /// Default deployment directory for Docker Compose files const DEFAULT_DEPLOY_DIR: &str = "/opt/torrust"; +/// HTTP client request timeout +const REQUEST_TIMEOUT_SECS: u64 = 10; + /// Action that validates Docker Compose services are running and healthy +/// +/// Supports both HTTP and HTTPS endpoints. For HTTPS endpoints: +/// - Uses domain-based URLs with the configured domain +/// - Resolves domain to IP locally (no DNS dependency for testing) +/// - Accepts self-signed certificates for `.local` domains pub struct RunningServicesValidator { deploy_dir: PathBuf, - tracker_api_port: u16, - http_tracker_ports: Vec, + tracker_api_endpoint: ServiceEndpoint, + http_tracker_endpoints: Vec, } impl RunningServicesValidator { - /// Create a new `RunningServicesValidator` with the specified SSH configuration + /// Create a new `RunningServicesValidator` with service endpoints /// /// Uses the default deployment directory `/opt/torrust`. /// /// # Arguments - /// * `ssh_config` - SSH connection configuration containing credentials and host IP - /// * `tracker_api_port` - Port for the tracker API health endpoint - /// * `http_tracker_ports` - Ports for HTTP tracker health endpoints (can be empty) + /// * `tracker_api_endpoint` - Endpoint for the tracker API health check + /// * `http_tracker_endpoints` - Endpoints for HTTP tracker health checks #[must_use] pub fn new( - _ssh_config: SshConfig, - tracker_api_port: u16, - http_tracker_ports: Vec, + tracker_api_endpoint: ServiceEndpoint, + http_tracker_endpoints: Vec, ) -> Self { Self { deploy_dir: PathBuf::from(DEFAULT_DEPLOY_DIR), - tracker_api_port, - http_tracker_ports, + tracker_api_endpoint, + http_tracker_endpoints, } } /// Create a new `RunningServicesValidator` with a custom deployment directory /// /// # Arguments - /// * `ssh_config` - SSH connection configuration containing credentials and host IP /// * `deploy_dir` - Path to the directory containing docker-compose.yml on the remote host - /// * `tracker_api_port` - Port for the tracker API health endpoint - /// * `http_tracker_ports` - Ports for HTTP tracker health endpoints (can be empty) + /// * `tracker_api_endpoint` - Endpoint for the tracker API health check + /// * `http_tracker_endpoints` - Endpoints for HTTP tracker health checks #[must_use] pub fn with_deploy_dir( - _ssh_config: SshConfig, deploy_dir: PathBuf, - tracker_api_port: u16, - http_tracker_ports: Vec, + tracker_api_endpoint: ServiceEndpoint, + http_tracker_endpoints: Vec, ) -> Self { Self { deploy_dir, - tracker_api_port, - http_tracker_ports, + tracker_api_endpoint, + http_tracker_endpoints, } } - /// Check service status using docker compose ps (human-readable format) - /// Validate external accessibility of tracker services - /// - /// # Arguments - /// * `server_ip` - IP address of the server to validate - /// * `tracker_api_port` - Port for the tracker API health endpoint - /// * `http_tracker_ports` - Ports for HTTP tracker health endpoints (can be empty) - async fn validate_external_accessibility( - &self, - server_ip: &IpAddr, - tracker_api_port: u16, - http_tracker_ports: &[u16], - ) -> Result<(), RemoteActionError> { + /// Validate external accessibility of all configured endpoints + async fn validate_external_accessibility(&self) -> Result<(), RemoteActionError> { // Check tracker API (required) - self.check_tracker_api_external(server_ip, tracker_api_port) + self.check_endpoint(&self.tracker_api_endpoint, "Tracker API") .await?; - // Check all HTTP trackers (required) - for port in http_tracker_ports { - self.check_http_tracker_external(server_ip, *port).await?; + // Check all HTTP trackers + for (idx, endpoint) in self.http_tracker_endpoints.iter().enumerate() { + let name = format!("HTTP Tracker {}", idx + 1); + self.check_endpoint(endpoint, &name).await?; } Ok(()) } - /// Check tracker API accessibility from outside the VM + /// Check a service endpoint for accessibility /// - /// # Arguments - /// * `server_ip` - IP address of the server - /// * `port` - Port for the tracker API health endpoint - async fn check_tracker_api_external( + /// Handles both HTTP and HTTPS endpoints. For HTTPS: + /// - Resolves domain to IP locally using reqwest's resolve feature + /// - Accepts self-signed certs for `.local` domains + async fn check_endpoint( &self, - server_ip: &IpAddr, - port: u16, + endpoint: &ServiceEndpoint, + service_name: &str, ) -> Result<(), RemoteActionError> { - info!( - action = "running_services_validation", - check = "tracker_api_external", - port = port, - validation_type = "external", - "Checking tracker API health endpoint (external from test runner)" - ); + let url = endpoint.url(); + + if endpoint.uses_tls() { + info!( + action = "running_services_validation", + check = "service_external", + service = service_name, + url = %url, + domain = ?endpoint.domain(), + resolve_to = %endpoint.server_ip(), + "Testing HTTPS endpoint (resolving domain to IP locally)" + ); + } else { + info!( + action = "running_services_validation", + check = "service_external", + service = service_name, + url = %url, + "Testing HTTP endpoint" + ); + } - let url = format!("http://{server_ip}:{port}/api/health_check"); // DevSkim: ignore DS137138 - let response = - reqwest::get(&url) - .await - .map_err(|e| RemoteActionError::ValidationFailed { - action_name: self.name().to_string(), - message: format!( - "Tracker API external health check failed: {e}. \ - Check that tracker is running and firewall allows port {port}." - ), - })?; + let response = self.make_request(endpoint).await?; if !response.status().is_success() { return Err(RemoteActionError::ValidationFailed { action_name: self.name().to_string(), message: format!( - "Tracker API returned HTTP {}: {}. Service may not be healthy.", + "{service_name} returned HTTP {}: {}. Service may not be healthy.", response.status(), response.status().canonical_reason().unwrap_or("Unknown") ), @@ -223,68 +172,74 @@ impl RunningServicesValidator { info!( action = "running_services_validation", - check = "tracker_api_external", - port = port, + check = "service_external", + service = service_name, + url = %url, status = "success", - validation_type = "external", - "Tracker API is accessible from outside (external check passed)" + "{service_name} health check passed" ); Ok(()) } - /// Check HTTP tracker accessibility from outside the VM + /// Make an HTTP/HTTPS request to the endpoint /// - /// # Arguments - /// * `server_ip` - IP address of the server - /// * `port` - Port for the HTTP tracker health endpoint - async fn check_http_tracker_external( + /// For HTTPS endpoints, this: + /// - Uses reqwest's `resolve()` to map domain to IP (like curl --resolve) + /// - Accepts self-signed certificates for `.local` domains + async fn make_request( &self, - server_ip: &IpAddr, - port: u16, - ) -> Result<(), RemoteActionError> { - info!( - action = "running_services_validation", - check = "http_tracker_external", - port = port, - validation_type = "external", - "Checking HTTP tracker health endpoint (external from test runner)" - ); - - let url = format!("http://{server_ip}:{port}/health_check"); // DevSkim: ignore DS137138 - let response = - reqwest::get(&url) - .await - .map_err(|e| RemoteActionError::ValidationFailed { - action_name: self.name().to_string(), - message: format!( - "HTTP Tracker external health check failed for URL '{url}': {e}. \n\ - Check that HTTP tracker is running and firewall allows port {port}." - ), - })?; - - if !response.status().is_success() { - return Err(RemoteActionError::ValidationFailed { - action_name: self.name().to_string(), - message: format!( - "HTTP Tracker returned HTTP {} for URL '{url}': {}. Service may not be healthy.", - response.status(), - response.status().canonical_reason().unwrap_or("Unknown") - ), - }); + endpoint: &ServiceEndpoint, + ) -> Result { + let url = endpoint.url(); + let mut client_builder = + ClientBuilder::new().timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS)); + + // For HTTPS endpoints, configure domain resolution and certificate handling + if let Some(domain) = endpoint.domain() { + // Resolve domain to IP locally (like curl --resolve) + client_builder = client_builder.resolve(domain, endpoint.socket_addr()); + + // Accept self-signed certs for .local domains (Caddy's internal CA) + if endpoint.is_local_domain() { + warn!( + action = "running_services_validation", + domain = domain, + "Accepting self-signed certificates for .local domain" + ); + client_builder = client_builder.danger_accept_invalid_certs(true); + } } - info!( - action = "running_services_validation", - check = "http_tracker_external", - port = port, - status = "success", - validation_type = "external", - url = %url, - "HTTP Tracker is accessible from outside (external check passed)" - ); - - Ok(()) + let client = client_builder + .build() + .map_err(|e| RemoteActionError::ValidationFailed { + action_name: self.name().to_string(), + message: format!("Failed to build HTTP client: {e}"), + })?; + + client.get(url.clone()).send().await.map_err(|e| { + let help_message = if endpoint.uses_tls() { + format!( + "HTTPS request to '{url}' failed: {e}. \ + Check that Caddy is running and port 443 is open. \ + Domain '{}' was resolved to {} for testing.", + endpoint.domain().unwrap_or("unknown"), + endpoint.server_ip() + ) + } else { + format!( + "HTTP request to '{url}' failed: {e}. \ + Check that service is running and firewall allows port {}.", + endpoint.port() + ) + }; + + RemoteActionError::ValidationFailed { + action_name: self.name().to_string(), + message: help_message, + } + }) } } @@ -304,20 +259,17 @@ impl RemoteAction for RunningServicesValidator { ) )] async fn execute(&self, server_ip: &IpAddr) -> Result<(), RemoteActionError> { + // Note: server_ip parameter is kept for trait compatibility and logging, + // but endpoints now contain their own server_ip for URL generation. + let _ = server_ip; // Suppress unused warning - used in instrument macro + info!( action = "running_services_validation", deploy_dir = %self.deploy_dir.display(), "Validating Docker Compose services are running via external accessibility" ); - // For E2E tests, external accessibility validation is sufficient - // If services are accessible externally, it proves they are running and healthy - self.validate_external_accessibility( - server_ip, - self.tracker_api_port, - &self.http_tracker_ports, - ) - .await?; + self.validate_external_accessibility().await?; info!( action = "running_services_validation", @@ -331,20 +283,19 @@ impl RemoteAction for RunningServicesValidator { #[cfg(test)] mod tests { + use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; - use crate::adapters::ssh::{SshConfig, SshCredentials}; - use crate::shared::Username; + use crate::shared::DomainName; use super::*; - fn create_test_ssh_config() -> SshConfig { - let credentials = SshCredentials::new( - PathBuf::from("/mock/path/to/private_key"), - PathBuf::from("/mock/path/to/public_key.pub"), - Username::new("testuser").unwrap(), - ); - SshConfig::with_default_port(credentials, "127.0.0.1".parse().unwrap()) + fn test_ip() -> IpAddr { + "10.0.0.1".parse().unwrap() + } + + fn test_socket_addr(port: u16) -> SocketAddr { + SocketAddr::new(test_ip(), port) } #[test] @@ -354,64 +305,68 @@ mod tests { #[test] fn it_should_return_correct_action_name_when_queried() { - // Can't test without SSH config, but we can verify the constant assert_eq!("running-services-validation", "running-services-validation"); } #[test] - fn it_should_accept_validation_when_http_tracker_ports_are_empty() { - let ssh_config = create_test_ssh_config(); - let validator = RunningServicesValidator::new(ssh_config, 6969, vec![]); + fn it_should_create_validator_with_http_endpoints() { + let api_endpoint = + ServiceEndpoint::http(test_socket_addr(1212), "/api/health_check").unwrap(); + let tracker_endpoints = + vec![ServiceEndpoint::http(test_socket_addr(7070), "/health_check").unwrap()]; + + let validator = RunningServicesValidator::new(api_endpoint.clone(), tracker_endpoints); - assert_eq!(validator.http_tracker_ports.len(), 0); + assert_eq!(validator.tracker_api_endpoint, api_endpoint); + assert_eq!(validator.http_tracker_endpoints.len(), 1); } #[test] - fn it_should_accept_validation_when_single_http_tracker_port_configured() { - let ssh_config = create_test_ssh_config(); - let validator = RunningServicesValidator::new(ssh_config, 6969, vec![6060]); + fn it_should_create_validator_with_https_endpoints() { + let domain = DomainName::new("api.tracker.local").unwrap(); + let api_endpoint = ServiceEndpoint::https(&domain, "/api/health_check", test_ip()).unwrap(); + let tracker_endpoints = vec![]; - assert_eq!(validator.http_tracker_ports.len(), 1); - assert_eq!(validator.http_tracker_ports[0], 6060); + let validator = RunningServicesValidator::new(api_endpoint.clone(), tracker_endpoints); + + assert!(validator.tracker_api_endpoint.uses_tls()); } #[test] - fn it_should_accept_validation_when_multiple_http_tracker_ports_configured() { - let ssh_config = create_test_ssh_config(); - let ports = vec![6060, 6061, 6062]; - let validator = RunningServicesValidator::new(ssh_config, 6969, ports.clone()); - - assert_eq!(validator.http_tracker_ports.len(), 3); - assert_eq!(validator.http_tracker_ports, ports); + fn it_should_create_validator_with_mixed_endpoints() { + let domain = DomainName::new("api.tracker.local").unwrap(); + let api_endpoint = ServiceEndpoint::https(&domain, "/api/health_check", test_ip()).unwrap(); + let tracker_endpoints = vec![ + ServiceEndpoint::http(test_socket_addr(7070), "/health_check").unwrap(), + ServiceEndpoint::http(test_socket_addr(7071), "/health_check").unwrap(), + ]; + + let validator = RunningServicesValidator::new(api_endpoint, tracker_endpoints); + + assert!(validator.tracker_api_endpoint.uses_tls()); + assert!(!validator.http_tracker_endpoints[0].uses_tls()); + assert!(!validator.http_tracker_endpoints[1].uses_tls()); } #[test] - fn it_should_accept_empty_ports_when_using_custom_deploy_dir() { - let ssh_config = create_test_ssh_config(); - let validator = RunningServicesValidator::with_deploy_dir( - ssh_config, - PathBuf::from("/custom/path"), - 6969, - vec![], - ); + fn it_should_accept_empty_tracker_endpoints() { + let api_endpoint = + ServiceEndpoint::http(test_socket_addr(1212), "/api/health_check").unwrap(); + let validator = RunningServicesValidator::new(api_endpoint, vec![]); - assert_eq!(validator.http_tracker_ports.len(), 0); - assert_eq!(validator.deploy_dir, PathBuf::from("/custom/path")); + assert_eq!(validator.http_tracker_endpoints.len(), 0); } #[test] - fn it_should_accept_multiple_ports_when_using_custom_deploy_dir() { - let ssh_config = create_test_ssh_config(); - let ports = vec![6060, 6061]; + fn it_should_use_custom_deploy_dir() { + let api_endpoint = + ServiceEndpoint::http(test_socket_addr(1212), "/api/health_check").unwrap(); let validator = RunningServicesValidator::with_deploy_dir( - ssh_config, PathBuf::from("/custom/path"), - 6969, - ports.clone(), + api_endpoint, + vec![], ); - assert_eq!(validator.http_tracker_ports.len(), 2); - assert_eq!(validator.http_tracker_ports, ports); assert_eq!(validator.deploy_dir, PathBuf::from("/custom/path")); } } diff --git a/src/infrastructure/external_validators/service_endpoint.rs b/src/infrastructure/external_validators/service_endpoint.rs new file mode 100644 index 00000000..11f1a826 --- /dev/null +++ b/src/infrastructure/external_validators/service_endpoint.rs @@ -0,0 +1,295 @@ +//! Service endpoint configuration for external validation +//! +//! This module provides types to represent service endpoints that can be +//! tested via HTTP or HTTPS. When TLS is enabled, the endpoint uses the +//! domain with HTTPS protocol and resolves it locally to the server IP. + +use std::net::{IpAddr, SocketAddr}; + +use url::Url; + +use crate::shared::DomainName; + +/// Error when creating a `ServiceEndpoint` with an invalid URL +#[derive(Debug, Clone, PartialEq)] +pub struct InvalidServiceEndpointUrl { + /// The URL string that failed to parse + pub url_string: String, + /// The parse error message + pub reason: String, +} + +impl std::fmt::Display for InvalidServiceEndpointUrl { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Invalid service endpoint URL '{}': {}", + self.url_string, self.reason + ) + } +} + +impl std::error::Error for InvalidServiceEndpointUrl {} + +/// Represents a service endpoint for external validation testing +/// +/// Internally stores a validated URL and the server IP address. +/// For HTTPS endpoints, the IP is used to resolve the domain locally +/// (since we can't rely on DNS for `.local` domains). +/// +/// # Examples +/// +/// ``` +/// use std::net::SocketAddr; +/// use torrust_tracker_deployer_lib::infrastructure::external_validators::ServiceEndpoint; +/// +/// // HTTP endpoint +/// let socket_addr: SocketAddr = "10.0.0.1:1212".parse().unwrap(); +/// let endpoint = ServiceEndpoint::http(socket_addr, "/api/health_check").unwrap(); +/// assert_eq!(endpoint.url().as_str(), "http://10.0.0.1:1212/api/health_check"); +/// ``` +#[derive(Debug, Clone, PartialEq)] +pub struct ServiceEndpoint { + /// The validated URL for this endpoint + url: Url, + + /// The server IP address. + /// For HTTP: extracted from the socket address. + /// For HTTPS: used to resolve the domain locally. + server_ip: IpAddr, +} + +impl ServiceEndpoint { + /// Create a new HTTP endpoint (no TLS) + /// + /// # Arguments + /// + /// * `socket_addr` - The IP address and port the service listens on + /// * `path` - The health check path (e.g., `/api/health_check`) + /// + /// # Errors + /// + /// Returns an error if the socket address and path don't form a valid URL. + pub fn http( + socket_addr: SocketAddr, + path: impl Into, + ) -> Result { + let path = path.into(); + let url_string = format!("http://{}:{}{}", socket_addr.ip(), socket_addr.port(), path); // DevSkim: ignore DS137138 + + let url = Url::parse(&url_string).map_err(|e| InvalidServiceEndpointUrl { + url_string, + reason: e.to_string(), + })?; + + Ok(Self { + url, + server_ip: socket_addr.ip(), + }) + } + + /// Create a new HTTPS endpoint with TLS + /// + /// # Arguments + /// + /// * `domain` - The domain name for HTTPS access (required for certificate issuance) + /// * `path` - The health check path (e.g., `/api/health_check`) + /// * `server_ip` - The IP address to resolve the domain to + /// + /// # Errors + /// + /// Returns an error if the domain and path don't form a valid URL. + pub fn https( + domain: &DomainName, + path: impl Into, + server_ip: IpAddr, + ) -> Result { + let path = path.into(); + let url_string = format!("https://{}{}", domain.as_str(), path); + + let url = Url::parse(&url_string).map_err(|e| InvalidServiceEndpointUrl { + url_string, + reason: e.to_string(), + })?; + + Ok(Self { url, server_ip }) + } + + /// Returns the URL for this endpoint + #[must_use] + pub fn url(&self) -> &Url { + &self.url + } + + /// Returns true if this endpoint uses TLS (HTTPS) + #[must_use] + pub fn uses_tls(&self) -> bool { + self.url.scheme() == "https" + } + + /// Returns the server IP address + #[must_use] + pub fn server_ip(&self) -> IpAddr { + self.server_ip + } + + /// Returns the port for this endpoint + /// + /// For HTTP: the configured port from the URL. + /// For HTTPS: 443 (the default HTTPS port). + #[must_use] + pub fn port(&self) -> u16 { + self.url.port_or_known_default().unwrap_or(80) + } + + /// Get the domain if this is an HTTPS endpoint + #[must_use] + pub fn domain(&self) -> Option<&str> { + if self.uses_tls() { + self.url.host_str() + } else { + None + } + } + + /// Check if the domain ends with `.local` (for self-signed cert handling) + #[must_use] + pub fn is_local_domain(&self) -> bool { + self.domain().is_some_and(|d| { + std::path::Path::new(d) + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("local")) + }) + } + + /// Returns the socket address for connecting to this endpoint + /// + /// Combines the server IP with the port from the URL. + #[must_use] + pub fn socket_addr(&self) -> SocketAddr { + SocketAddr::new(self.server_ip(), self.port()) + } +} + +#[cfg(test)] +mod tests { + use std::net::IpAddr; + + use super::*; + + fn test_ip() -> IpAddr { + "10.0.0.1".parse().unwrap() + } + + fn test_socket_addr(port: u16) -> SocketAddr { + SocketAddr::new(test_ip(), port) + } + + #[test] + fn it_should_create_http_endpoint() { + let endpoint = ServiceEndpoint::http(test_socket_addr(1212), "/api/health_check").unwrap(); + + assert_eq!(endpoint.server_ip(), test_ip()); + assert_eq!(endpoint.port(), 1212); + assert!(!endpoint.uses_tls()); + assert!(endpoint.domain().is_none()); + } + + #[test] + fn it_should_create_https_endpoint() { + let domain = DomainName::new("api.tracker.local").unwrap(); + let endpoint = ServiceEndpoint::https(&domain, "/api/health_check", test_ip()).unwrap(); + + assert_eq!(endpoint.server_ip(), test_ip()); + assert_eq!(endpoint.port(), 443); + assert!(endpoint.uses_tls()); + assert_eq!(endpoint.domain().unwrap(), "api.tracker.local"); + } + + #[test] + fn it_should_build_http_url() { + let endpoint = ServiceEndpoint::http(test_socket_addr(1212), "/api/health_check").unwrap(); + + assert_eq!( + endpoint.url().as_str(), + "http://10.0.0.1:1212/api/health_check" // DevSkim: ignore DS137138 + ); + } + + #[test] + fn it_should_build_https_url() { + let domain = DomainName::new("api.tracker.local").unwrap(); + let endpoint = ServiceEndpoint::https(&domain, "/api/health_check", test_ip()).unwrap(); + + // HTTPS uses domain, not IP + assert_eq!( + endpoint.url().as_str(), + "https://api.tracker.local/api/health_check" + ); + } + + #[test] + fn it_should_detect_local_domain() { + let domain = DomainName::new("api.tracker.local").unwrap(); + let endpoint = ServiceEndpoint::https(&domain, "/api/health_check", test_ip()).unwrap(); + + assert!(endpoint.is_local_domain()); + } + + #[test] + fn it_should_not_detect_non_local_domain_as_local() { + let domain = DomainName::new("api.tracker.example.com").unwrap(); + let endpoint = ServiceEndpoint::https(&domain, "/api/health_check", test_ip()).unwrap(); + + assert!(!endpoint.is_local_domain()); + } + + #[test] + fn it_should_return_port_443_for_https() { + let domain = DomainName::new("api.tracker.local").unwrap(); + let endpoint = ServiceEndpoint::https(&domain, "/api/health_check", test_ip()).unwrap(); + + assert_eq!(endpoint.port(), 443); + } + + #[test] + fn it_should_return_configured_port_for_http() { + let endpoint = ServiceEndpoint::http(test_socket_addr(1212), "/api/health_check").unwrap(); + + assert_eq!(endpoint.port(), 1212); + } + + #[test] + fn it_should_return_socket_addr_for_http() { + let endpoint = ServiceEndpoint::http(test_socket_addr(1212), "/api/health_check").unwrap(); + + assert_eq!(endpoint.socket_addr(), test_socket_addr(1212)); + } + + #[test] + fn it_should_return_socket_addr_with_port_443_for_https() { + let domain = DomainName::new("api.tracker.local").unwrap(); + let endpoint = ServiceEndpoint::https(&domain, "/api/health_check", test_ip()).unwrap(); + + assert_eq!(endpoint.socket_addr(), test_socket_addr(443)); + } + + #[test] + fn it_should_return_error_for_invalid_http_path() { + // A path with invalid characters that would break URL parsing + let result = ServiceEndpoint::http(test_socket_addr(1212), "not a valid path\x00"); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.url_string.contains("not a valid path")); + } + + #[test] + fn it_should_return_url_reference() { + let endpoint = ServiceEndpoint::http(test_socket_addr(1212), "/api/health_check").unwrap(); + + // url() returns a reference, not a clone + let url_ref: &Url = endpoint.url(); + assert_eq!(url_ref.path(), "/api/health_check"); + } +} diff --git a/src/infrastructure/templating/ansible/template/renderer/project_generator.rs b/src/infrastructure/templating/ansible/template/renderer/project_generator.rs index 47923576..4de7095b 100644 --- a/src/infrastructure/templating/ansible/template/renderer/project_generator.rs +++ b/src/infrastructure/templating/ansible/template/renderer/project_generator.rs @@ -312,6 +312,7 @@ impl AnsibleProjectGenerator { "create-prometheus-storage.yml", "deploy-prometheus-config.yml", "deploy-grafana-provisioning.yml", + "deploy-caddy-config.yml", "deploy-compose-files.yml", "run-compose-services.yml", ] { @@ -321,7 +322,7 @@ impl AnsibleProjectGenerator { tracing::debug!( "Successfully copied {} static template files", - 17 // ansible.cfg + 16 playbooks + 18 // ansible.cfg + 17 playbooks ); Ok(()) diff --git a/src/infrastructure/templating/ansible/template/wrappers/variables/context.rs b/src/infrastructure/templating/ansible/template/wrappers/variables/context.rs index bc30cb00..e55407a8 100644 --- a/src/infrastructure/templating/ansible/template/wrappers/variables/context.rs +++ b/src/infrastructure/templating/ansible/template/wrappers/variables/context.rs @@ -205,13 +205,19 @@ mod tests { ], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), + domain: None, + use_tls_proxy: false, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "MyAccessToken".to_string().into(), + domain: None, + use_tls_proxy: false, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), + domain: None, + use_tls_proxy: false, }, }; @@ -240,9 +246,13 @@ mod tests { http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "Token123".to_string().into(), + domain: None, + use_tls_proxy: false, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), + domain: None, + use_tls_proxy: false, }, }; @@ -277,13 +287,19 @@ mod tests { ], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), // Valid address + domain: None, + use_tls_proxy: false, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "Token".to_string().into(), + domain: None, + use_tls_proxy: false, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), + domain: None, + use_tls_proxy: false, }, }; diff --git a/src/infrastructure/templating/caddy/mod.rs b/src/infrastructure/templating/caddy/mod.rs new file mode 100644 index 00000000..7afd76bb --- /dev/null +++ b/src/infrastructure/templating/caddy/mod.rs @@ -0,0 +1,18 @@ +//! Caddy reverse proxy configuration management +//! +//! This module provides template rendering for Caddy TLS termination proxy, +//! enabling automatic HTTPS with Let's Encrypt for HTTP services. +//! +//! ## Services Supported +//! +//! - Tracker REST API +//! - HTTP Tracker(s) - supports multiple trackers +//! - Grafana UI (with WebSocket support) +//! +//! ## Template Rendering +//! +//! - `Caddyfile.tera` β†’ `Caddyfile` - Main Caddy configuration + +pub mod template; + +pub use template::{CaddyContext, CaddyProjectGenerator, CaddyProjectGeneratorError, CaddyService}; diff --git a/src/infrastructure/templating/caddy/template/mod.rs b/src/infrastructure/templating/caddy/template/mod.rs new file mode 100644 index 00000000..29571427 --- /dev/null +++ b/src/infrastructure/templating/caddy/template/mod.rs @@ -0,0 +1,10 @@ +//! Caddy template functionality +//! +//! This module provides template-related functionality for Caddy configuration, +//! including context for dynamic templates. + +pub mod renderer; +pub mod wrapper; + +pub use renderer::{CaddyProjectGenerator, CaddyProjectGeneratorError}; +pub use wrapper::{CaddyContext, CaddyService}; diff --git a/src/infrastructure/templating/caddy/template/renderer/caddyfile.rs b/src/infrastructure/templating/caddy/template/renderer/caddyfile.rs new file mode 100644 index 00000000..50b4c511 --- /dev/null +++ b/src/infrastructure/templating/caddy/template/renderer/caddyfile.rs @@ -0,0 +1,277 @@ +//! Caddyfile configuration renderer +//! +//! Renders Caddyfile.tera template using `CaddyContext`. + +use std::path::Path; +use std::sync::Arc; + +use thiserror::Error; +use tracing::instrument; + +use crate::domain::template::{TemplateManager, TemplateManagerError}; +use crate::infrastructure::templating::caddy::template::wrapper::CaddyContext; + +/// Errors that can occur during Caddyfile rendering +#[derive(Error, Debug)] +pub enum CaddyfileRendererError { + /// Failed to get template path from template manager + #[error("Failed to get template path for 'Caddyfile.tera': {0}")] + TemplatePathFailed(#[from] TemplateManagerError), + + /// Failed to read template file + #[error("Failed to read template file at '{path}': {source}")] + TemplateReadFailed { + path: String, + #[source] + source: std::io::Error, + }, + + /// Failed to create Tera instance + #[error("Failed to create Tera template engine: {0}")] + TeraCreationFailed(#[source] tera::Error), + + /// Failed to render template + #[error("Failed to render Caddyfile template: {0}")] + RenderFailed(#[source] tera::Error), + + /// Failed to write output file + #[error("Failed to write Caddyfile to '{path}': {source}")] + OutputWriteFailed { + path: String, + #[source] + source: std::io::Error, + }, +} + +/// Renders Caddyfile.tera template to Caddyfile configuration +/// +/// This renderer follows the Project Generator pattern: +/// 1. Loads Caddyfile.tera from the template manager +/// 2. Renders the template with `CaddyContext` +/// 3. Writes output to the specified directory +pub struct CaddyfileRenderer { + template_manager: Arc, +} + +impl CaddyfileRenderer { + /// Template filename for the Caddyfile Tera template + const CADDYFILE_TEMPLATE_FILE: &'static str = "Caddyfile.tera"; + + /// Output filename for the rendered Caddyfile + const CADDYFILE_OUTPUT_FILE: &'static str = "Caddyfile"; + + /// Directory path for Caddy templates + const CADDY_TEMPLATE_DIR: &'static str = "caddy"; + + /// Creates a new Caddyfile renderer + /// + /// # Arguments + /// + /// * `template_manager` - The template manager to load templates from + #[must_use] + pub fn new(template_manager: Arc) -> Self { + Self { template_manager } + } + + /// Renders the Caddyfile to a file + /// + /// # Arguments + /// + /// * `context` - The rendering context with services and configuration + /// * `output_dir` - Directory where Caddyfile will be written + /// + /// # Errors + /// + /// Returns an error if: + /// - Template file cannot be loaded + /// - Template file cannot be read + /// - Template rendering fails + /// - Output file cannot be written + #[instrument(skip(self, context), fields(output_dir = %output_dir.display()))] + pub fn render( + &self, + context: &CaddyContext, + output_dir: &Path, + ) -> Result<(), CaddyfileRendererError> { + // 1. Load template from template manager + let template_path = self.template_manager.get_template_path(&format!( + "{}/{}", + Self::CADDY_TEMPLATE_DIR, + Self::CADDYFILE_TEMPLATE_FILE + ))?; + + // 2. Read template content + let template_content = std::fs::read_to_string(&template_path).map_err(|source| { + CaddyfileRendererError::TemplateReadFailed { + path: template_path.display().to_string(), + source, + } + })?; + + // 3. Create Tera instance and add template + let mut tera = tera::Tera::default(); + tera.add_raw_template(Self::CADDYFILE_TEMPLATE_FILE, &template_content) + .map_err(CaddyfileRendererError::TeraCreationFailed)?; + + // 4. Convert context to Tera context + let tera_context = + tera::Context::from_serialize(context).map_err(CaddyfileRendererError::RenderFailed)?; + + // 5. Render template + let rendered = tera + .render(Self::CADDYFILE_TEMPLATE_FILE, &tera_context) + .map_err(CaddyfileRendererError::RenderFailed)?; + + // 6. Write output file + let output_path = output_dir.join(Self::CADDYFILE_OUTPUT_FILE); + std::fs::write(&output_path, rendered).map_err(|source| { + CaddyfileRendererError::OutputWriteFailed { + path: output_path.display().to_string(), + source, + } + })?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::TempDir; + + use super::*; + use crate::infrastructure::templating::caddy::template::wrapper::CaddyService; + + fn create_test_template_manager() -> (Arc, TempDir) { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let templates_dir = temp_dir.path().join("templates"); + let caddy_dir = templates_dir.join("caddy"); + + fs::create_dir_all(&caddy_dir).expect("Failed to create caddy dir"); + + let template_content = r"# Caddyfile for Torrust Tracker +{ + email {{ admin_email }} +{% if use_staging %} + acme_ca https://acme-staging-v02.api.letsencrypt.org/directory +{% endif %} +} +{% if tracker_api %} + +{{ tracker_api.domain }} { + reverse_proxy tracker:{{ tracker_api.port }} +} +{% endif %} +{% for http_tracker in http_trackers %} + +{{ http_tracker.domain }} { + reverse_proxy tracker:{{ http_tracker.port }} +} +{% endfor %} +{% if grafana %} + +{{ grafana.domain }} { + reverse_proxy grafana:3000 +} +{% endif %} +"; + + fs::write(caddy_dir.join("Caddyfile.tera"), template_content) + .expect("Failed to write template"); + + (Arc::new(TemplateManager::new(templates_dir)), temp_dir) + } + + #[test] + fn it_should_render_caddyfile_with_all_services() { + let (template_manager, _temp_dir) = create_test_template_manager(); + let caddyfile_renderer = CaddyfileRenderer::new(template_manager); + + let output_dir = TempDir::new().expect("Failed to create output dir"); + let caddy_ctx = CaddyContext::new("admin@example.com", false) + .with_tracker_api(CaddyService::new("api.example.com", 1212)) + .with_http_tracker(CaddyService::new("http1.example.com", 7070)) + .with_grafana(CaddyService::new("grafana.example.com", 3000)); + + caddyfile_renderer + .render(&caddy_ctx, output_dir.path()) + .expect("Failed to render"); + + let caddyfile_path = output_dir.path().join("Caddyfile"); + assert!(caddyfile_path.exists()); + + let file_content = fs::read_to_string(&caddyfile_path).expect("Failed to read"); + assert!(file_content.contains("email admin@example.com")); + assert!(file_content.contains("api.example.com")); + assert!(file_content.contains("reverse_proxy tracker:1212")); + assert!(file_content.contains("http1.example.com")); + assert!(file_content.contains("reverse_proxy tracker:7070")); + assert!(file_content.contains("grafana.example.com")); + assert!(!file_content.contains("acme_ca")); // Not staging + } + + #[test] + fn it_should_render_caddyfile_with_staging_ca() { + let (template_manager, _temp_dir) = create_test_template_manager(); + let caddyfile_renderer = CaddyfileRenderer::new(template_manager); + + let output_dir = TempDir::new().expect("Failed to create output dir"); + let caddy_ctx = CaddyContext::new("admin@example.com", true) + .with_tracker_api(CaddyService::new("api.example.com", 1212)); + + caddyfile_renderer + .render(&caddy_ctx, output_dir.path()) + .expect("Failed to render"); + + let file_content = + fs::read_to_string(output_dir.path().join("Caddyfile")).expect("Failed to read"); + assert!(file_content.contains("acme-staging-v02.api.letsencrypt.org")); + } + + #[test] + fn it_should_render_caddyfile_with_multiple_http_trackers() { + let (template_manager, _temp_dir) = create_test_template_manager(); + let caddyfile_renderer = CaddyfileRenderer::new(template_manager); + + let output_dir = TempDir::new().expect("Failed to create output dir"); + let caddy_ctx = CaddyContext::new("admin@example.com", false) + .with_http_tracker(CaddyService::new("http1.example.com", 7070)) + .with_http_tracker(CaddyService::new("http2.example.com", 7071)) + .with_http_tracker(CaddyService::new("http3.example.com", 7072)); + + caddyfile_renderer + .render(&caddy_ctx, output_dir.path()) + .expect("Failed to render"); + + let file_content = + fs::read_to_string(output_dir.path().join("Caddyfile")).expect("Failed to read"); + assert!(file_content.contains("http1.example.com")); + assert!(file_content.contains("reverse_proxy tracker:7070")); + assert!(file_content.contains("http2.example.com")); + assert!(file_content.contains("reverse_proxy tracker:7071")); + assert!(file_content.contains("http3.example.com")); + assert!(file_content.contains("reverse_proxy tracker:7072")); + } + + #[test] + fn it_should_render_caddyfile_without_optional_services() { + let (template_manager, _temp_dir) = create_test_template_manager(); + let caddyfile_renderer = CaddyfileRenderer::new(template_manager); + + let output_dir = TempDir::new().expect("Failed to create output dir"); + // Only API, no HTTP trackers or Grafana + let caddy_ctx = CaddyContext::new("admin@example.com", false) + .with_tracker_api(CaddyService::new("api.example.com", 1212)); + + caddyfile_renderer + .render(&caddy_ctx, output_dir.path()) + .expect("Failed to render"); + + let file_content = + fs::read_to_string(output_dir.path().join("Caddyfile")).expect("Failed to read"); + assert!(file_content.contains("api.example.com")); + assert!(!file_content.contains("grafana")); + } +} diff --git a/src/infrastructure/templating/caddy/template/renderer/mod.rs b/src/infrastructure/templating/caddy/template/renderer/mod.rs new file mode 100644 index 00000000..fc394af7 --- /dev/null +++ b/src/infrastructure/templating/caddy/template/renderer/mod.rs @@ -0,0 +1,9 @@ +//! Caddy template renderers +//! +//! This module provides renderers for Caddy configuration templates. + +mod caddyfile; +mod project_generator; + +pub use caddyfile::{CaddyfileRenderer, CaddyfileRendererError}; +pub use project_generator::{CaddyProjectGenerator, CaddyProjectGeneratorError}; diff --git a/src/infrastructure/templating/caddy/template/renderer/project_generator.rs b/src/infrastructure/templating/caddy/template/renderer/project_generator.rs new file mode 100644 index 00000000..3304f213 --- /dev/null +++ b/src/infrastructure/templating/caddy/template/renderer/project_generator.rs @@ -0,0 +1,276 @@ +//! Caddy Project Generator +//! +//! Orchestrates the rendering of Caddy configuration templates following +//! the Project Generator pattern. +//! +//! ## Architecture +//! +//! This follows the three-layer Project Generator pattern: +//! - **Context** (`CaddyContext`) - Defines variables needed by templates +//! - **Renderer** (`CaddyfileRenderer`) - Renders Caddyfile.tera template +//! - **`ProjectGenerator`** (this file) - Orchestrates all renderers +//! +//! ## Data Flow +//! +//! Environment Config β†’ `CaddyContext` (with pre-extracted ports) β†’ Template Rendering + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use thiserror::Error; +use tracing::instrument; + +use crate::domain::template::TemplateManager; +use crate::infrastructure::templating::caddy::template::{ + renderer::{CaddyfileRenderer, CaddyfileRendererError}, + wrapper::CaddyContext, +}; + +/// Errors that can occur during Caddy project generation +#[derive(Error, Debug)] +pub enum CaddyProjectGeneratorError { + /// Failed to create the build directory + #[error("Failed to create build directory '{directory}': {source}")] + DirectoryCreationFailed { + directory: String, + #[source] + source: std::io::Error, + }, + + /// Failed to render Caddyfile + #[error("Failed to render Caddyfile: {0}")] + RendererFailed(#[from] CaddyfileRendererError), + + /// No TLS configuration provided + #[error("Caddy project generator called but no TLS configuration provided. Use `CaddyContext::has_any_tls()` to check before calling.")] + NoTlsConfigured, +} + +/// Orchestrates Caddy configuration template rendering +/// +/// This is the Project Generator that coordinates Caddy template rendering. +/// It follows the standard pattern: +/// 1. Check if any TLS configuration exists +/// 2. Create build directory structure +/// 3. Call `CaddyfileRenderer` to render Caddyfile.tera +/// +/// # Conditional Deployment +/// +/// Caddy is only deployed when at least one service has TLS configured. +/// Use `CaddyContext::has_any_tls()` to check before calling this generator. +pub struct CaddyProjectGenerator { + build_dir: PathBuf, + caddyfile_renderer: CaddyfileRenderer, +} + +impl CaddyProjectGenerator { + /// Default relative path for Caddy configuration files + const CADDY_BUILD_PATH: &'static str = "caddy"; + + /// Creates a new Caddy project generator + /// + /// # Arguments + /// + /// * `build_dir` - The destination directory where templates will be rendered + /// * `template_manager` - The template manager to source templates from + #[must_use] + pub fn new>(build_dir: P, template_manager: Arc) -> Self { + let caddyfile_renderer = CaddyfileRenderer::new(template_manager); + + Self { + build_dir: build_dir.as_ref().to_path_buf(), + caddyfile_renderer, + } + } + + /// Renders Caddy configuration templates to the build directory + /// + /// This method: + /// 1. Verifies that at least one service has TLS configured + /// 2. Creates the build directory structure for Caddy config + /// 3. Renders Caddyfile.tera template with the provided context + /// 4. Writes the rendered content to Caddyfile + /// + /// # Arguments + /// + /// * `context` - The `CaddyContext` containing services and email configuration + /// + /// # Errors + /// + /// Returns an error if: + /// - No TLS configuration is provided (use `has_any_tls()` first) + /// - Build directory creation fails + /// - Template loading fails + /// - Template rendering fails + /// - Writing output file fails + /// + /// # Example + /// + /// ```rust,ignore + /// use torrust_tracker_deployer_lib::infrastructure::templating::caddy::{ + /// CaddyProjectGenerator, CaddyContext, CaddyService, + /// }; + /// + /// let generator = CaddyProjectGenerator::new(&build_dir, template_manager); + /// + /// let context = CaddyContext::new("admin@example.com", false) + /// .with_tracker_api(CaddyService::new("api.example.com", 1212)); + /// + /// // Only render if TLS is configured + /// if context.has_any_tls() { + /// generator.render(&context)?; + /// } + /// ``` + #[instrument( + name = "caddy_project_generator_render", + skip(self, context), + fields( + build_dir = %self.build_dir.display(), + has_tls = context.has_any_tls() + ) + )] + pub fn render(&self, context: &CaddyContext) -> Result<(), CaddyProjectGeneratorError> { + // Validate that TLS is configured + if !context.has_any_tls() { + return Err(CaddyProjectGeneratorError::NoTlsConfigured); + } + + // Create build directory for Caddy templates + let caddy_build_dir = self.build_dir.join(Self::CADDY_BUILD_PATH); + std::fs::create_dir_all(&caddy_build_dir).map_err(|source| { + CaddyProjectGeneratorError::DirectoryCreationFailed { + directory: caddy_build_dir.display().to_string(), + source, + } + })?; + + // Render Caddyfile using CaddyfileRenderer + self.caddyfile_renderer.render(context, &caddy_build_dir)?; + + Ok(()) + } + + /// Returns the path where Caddy files will be generated + #[must_use] + pub fn output_path(&self) -> PathBuf { + self.build_dir.join(Self::CADDY_BUILD_PATH) + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::TempDir; + + use super::*; + use crate::infrastructure::templating::caddy::template::wrapper::CaddyService; + + fn create_test_template_manager() -> (Arc, TempDir) { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let templates_dir = temp_dir.path().join("templates"); + let caddy_dir = templates_dir.join("caddy"); + + fs::create_dir_all(&caddy_dir).expect("Failed to create caddy dir"); + + let template_content = r"# Caddyfile for Torrust Tracker +{ + email {{ admin_email }} +{% if use_staging %} + acme_ca https://acme-staging-v02.api.letsencrypt.org/directory +{% endif %} +} +{% if tracker_api %} + +{{ tracker_api.domain }} { + reverse_proxy tracker:{{ tracker_api.port }} +} +{% endif %} +{% for http_tracker in http_trackers %} + +{{ http_tracker.domain }} { + reverse_proxy tracker:{{ http_tracker.port }} +} +{% endfor %} +{% if grafana %} + +{{ grafana.domain }} { + reverse_proxy grafana:3000 +} +{% endif %} +"; + + fs::write(caddy_dir.join("Caddyfile.tera"), template_content) + .expect("Failed to write template"); + + (Arc::new(TemplateManager::new(templates_dir)), temp_dir) + } + + #[test] + fn it_should_create_caddy_build_directory() { + let (template_manager, _temp_dir) = create_test_template_manager(); + let build_dir = TempDir::new().expect("Failed to create build dir"); + + let generator = CaddyProjectGenerator::new(build_dir.path(), template_manager); + + let caddy_ctx = CaddyContext::new("admin@example.com", false) + .with_tracker_api(CaddyService::new("api.example.com", 1212)); + + generator.render(&caddy_ctx).expect("Failed to render"); + + let caddy_dir = build_dir.path().join("caddy"); + assert!(caddy_dir.exists()); + assert!(caddy_dir.is_dir()); + } + + #[test] + fn it_should_render_caddyfile() { + let (template_manager, _temp_dir) = create_test_template_manager(); + let build_dir = TempDir::new().expect("Failed to create build dir"); + + let project_gen = CaddyProjectGenerator::new(build_dir.path(), template_manager); + + let caddy_ctx = CaddyContext::new("admin@example.com", false) + .with_tracker_api(CaddyService::new("api.example.com", 1212)) + .with_grafana(CaddyService::new("grafana.example.com", 3000)); + + project_gen.render(&caddy_ctx).expect("Failed to render"); + + let caddyfile_path = build_dir.path().join("caddy/Caddyfile"); + assert!(caddyfile_path.exists()); + + let file_content = fs::read_to_string(&caddyfile_path).expect("Failed to read"); + assert!(file_content.contains("email admin@example.com")); + assert!(file_content.contains("api.example.com")); + assert!(file_content.contains("grafana.example.com")); + } + + #[test] + fn it_should_fail_when_no_tls_configured() { + let (template_manager, _temp_dir) = create_test_template_manager(); + let build_dir = TempDir::new().expect("Failed to create build dir"); + + let project_gen = CaddyProjectGenerator::new(build_dir.path(), template_manager); + + // Empty context - no TLS configured + let caddy_ctx = CaddyContext::new("admin@example.com", false); + + let result = project_gen.render(&caddy_ctx); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + CaddyProjectGeneratorError::NoTlsConfigured + )); + } + + #[test] + fn it_should_return_correct_output_path() { + let (template_manager, _temp_dir) = create_test_template_manager(); + let build_dir = TempDir::new().expect("Failed to create build dir"); + + let generator = CaddyProjectGenerator::new(build_dir.path(), template_manager); + + let expected = build_dir.path().join("caddy"); + assert_eq!(generator.output_path(), expected); + } +} diff --git a/src/infrastructure/templating/caddy/template/wrapper/caddyfile/context.rs b/src/infrastructure/templating/caddy/template/wrapper/caddyfile/context.rs new file mode 100644 index 00000000..b41e717a --- /dev/null +++ b/src/infrastructure/templating/caddy/template/wrapper/caddyfile/context.rs @@ -0,0 +1,282 @@ +//! Caddy template context +//! +//! Defines the variables needed for Caddyfile.tera template rendering. +//! +//! ## Context Data Preparation Pattern +//! +//! This context follows the Context Data Preparation Pattern (see +//! `docs/contributing/templates/template-system-architecture.md`): +//! - Ports are pre-extracted in Rust from `SocketAddr` when building the context +//! - Templates receive ready-to-use data without complex Tera filters +//! - Each service includes both domain and port as simple types + +use serde::Serialize; + +/// Represents a service that can be proxied through Caddy +/// +/// Contains the domain name for TLS certificate acquisition and the +/// backend port for reverse proxying. +/// +/// # Example +/// +/// ```rust +/// use torrust_tracker_deployer_lib::infrastructure::templating::caddy::CaddyService; +/// +/// let service = CaddyService { +/// domain: "api.torrust-tracker.com".to_string(), +/// port: 1212, +/// }; +/// ``` +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct CaddyService { + /// Domain name for this service (used for TLS certificate) + /// + /// Must be a valid domain name that points to the deployment server. + /// Let's Encrypt will validate domain ownership via HTTP-01 challenge. + pub domain: String, + + /// Backend port where the service is listening + /// + /// This is the internal Docker network port, not the public-facing port. + /// Caddy will reverse proxy HTTPS traffic to this port. + pub port: u16, +} + +impl CaddyService { + /// Creates a new `CaddyService` + /// + /// # Arguments + /// + /// * `domain` - The domain name for TLS certificate + /// * `port` - The backend port for reverse proxying + #[must_use] + pub fn new(domain: impl Into, port: u16) -> Self { + Self { + domain: domain.into(), + port, + } + } +} + +/// Context for rendering Caddyfile.tera template +/// +/// Contains all variables needed for Caddy reverse proxy configuration. +/// Only services with TLS configuration will be included in this context. +/// +/// # Design Decisions +/// +/// - `tracker_api`, `grafana`: `Option` - only present if TLS configured +/// - `http_trackers`: `Vec` - only TLS-enabled trackers included +/// - Ports are pre-extracted in Rust (not in templates) per Context Data Preparation Pattern +/// +/// # Example +/// +/// ```rust +/// use torrust_tracker_deployer_lib::infrastructure::templating::caddy::{ +/// CaddyContext, CaddyService, +/// }; +/// +/// // All services with HTTPS +/// let context = CaddyContext { +/// admin_email: "admin@example.com".to_string(), +/// use_staging: false, +/// tracker_api: Some(CaddyService::new("api.example.com", 1212)), +/// http_trackers: vec![ +/// CaddyService::new("http1.example.com", 7070), +/// CaddyService::new("http2.example.com", 7071), +/// ], +/// grafana: Some(CaddyService::new("grafana.example.com", 3000)), +/// health_check_api: None, +/// }; +/// ``` +/// +/// # Data Flow +/// +/// Environment Config (tracker, grafana sections with tls) β†’ Application Layer +/// β†’ `CaddyContext` with pre-extracted ports +#[derive(Debug, Clone, Default, Serialize, PartialEq)] +pub struct CaddyContext { + /// Email for Let's Encrypt certificate notifications + /// + /// Required when any service has TLS configured. + /// Let's Encrypt sends expiration warnings to this email. + pub admin_email: String, + + /// Whether to use Let's Encrypt staging environment + /// + /// - `true`: Use staging CA (for testing, avoids rate limits) + /// - `false`: Use production CA (default, trusted certificates) + /// + /// Staging certificates show browser warnings (not trusted by browsers). + pub use_staging: bool, + + /// Tracker REST API service (if TLS configured) + /// + /// Present only if `tracker.http_api.tls` is configured. + pub tracker_api: Option, + + /// HTTP Tracker services with TLS proxy configured + /// + /// Contains only trackers that have `use_tls_proxy: true` and a domain. + /// Trackers without TLS proxy are served directly over HTTP, not through Caddy. + pub http_trackers: Vec, + + /// Health Check API service (if TLS proxy configured) + /// + /// Present only if `tracker.health_check_api.use_tls_proxy` is enabled. + /// The health check API provides a simple /health endpoint for monitoring. + pub health_check_api: Option, + + /// Grafana UI service (if TLS configured) + /// + /// Present only if `grafana.tls` is configured. + /// Caddy provides WebSocket support for Grafana Live features. + pub grafana: Option, +} + +impl CaddyContext { + /// Creates a new `CaddyContext` + /// + /// # Arguments + /// + /// * `admin_email` - Email for Let's Encrypt notifications + /// * `use_staging` - Whether to use Let's Encrypt staging environment + #[must_use] + pub fn new(admin_email: impl Into, use_staging: bool) -> Self { + Self { + admin_email: admin_email.into(), + use_staging, + tracker_api: None, + http_trackers: Vec::new(), + health_check_api: None, + grafana: None, + } + } + + /// Sets the Tracker API service + #[must_use] + pub fn with_tracker_api(mut self, service: CaddyService) -> Self { + self.tracker_api = Some(service); + self + } + + /// Adds an HTTP Tracker service + #[must_use] + pub fn with_http_tracker(mut self, service: CaddyService) -> Self { + self.http_trackers.push(service); + self + } + + /// Sets the Health Check API service + #[must_use] + pub fn with_health_check_api(mut self, service: CaddyService) -> Self { + self.health_check_api = Some(service); + self + } + + /// Sets the Grafana service + #[must_use] + pub fn with_grafana(mut self, service: CaddyService) -> Self { + self.grafana = Some(service); + self + } + + /// Returns true if any service has TLS configured + /// + /// Used to determine whether Caddy should be deployed at all. + #[must_use] + pub fn has_any_tls(&self) -> bool { + self.tracker_api.is_some() + || !self.http_trackers.is_empty() + || self.health_check_api.is_some() + || self.grafana.is_some() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_create_caddy_service() { + let service = CaddyService::new("api.example.com", 1212); + + assert_eq!(service.domain, "api.example.com"); + assert_eq!(service.port, 1212); + } + + #[test] + fn it_should_create_caddy_context_with_builder_pattern() { + let context = CaddyContext::new("admin@example.com", false) + .with_tracker_api(CaddyService::new("api.example.com", 1212)) + .with_http_tracker(CaddyService::new("http1.example.com", 7070)) + .with_http_tracker(CaddyService::new("http2.example.com", 7071)) + .with_grafana(CaddyService::new("grafana.example.com", 3000)); + + assert_eq!(context.admin_email, "admin@example.com"); + assert!(!context.use_staging); + assert!(context.tracker_api.is_some()); + assert_eq!(context.http_trackers.len(), 2); + assert!(context.grafana.is_some()); + } + + #[test] + fn it_should_detect_when_tls_is_configured() { + let empty_context = CaddyContext::default(); + assert!(!empty_context.has_any_tls()); + + let api_only = CaddyContext::new("admin@example.com", false) + .with_tracker_api(CaddyService::new("api.example.com", 1212)); + assert!(api_only.has_any_tls()); + + let http_tracker_only = CaddyContext::new("admin@example.com", false) + .with_http_tracker(CaddyService::new("http.example.com", 7070)); + assert!(http_tracker_only.has_any_tls()); + + let health_check_only = CaddyContext::new("admin@example.com", false) + .with_health_check_api(CaddyService::new("health.example.com", 1313)); + assert!(health_check_only.has_any_tls()); + + let grafana_only = CaddyContext::new("admin@example.com", false) + .with_grafana(CaddyService::new("grafana.example.com", 3000)); + assert!(grafana_only.has_any_tls()); + } + + #[test] + fn it_should_create_default_context() { + let context = CaddyContext::default(); + + assert_eq!(context.admin_email, ""); + assert!(!context.use_staging); + assert!(context.tracker_api.is_none()); + assert!(context.http_trackers.is_empty()); + assert!(context.health_check_api.is_none()); + assert!(context.grafana.is_none()); + } + + #[test] + fn it_should_serialize_to_json() { + let context = CaddyContext::new("admin@example.com", true) + .with_tracker_api(CaddyService::new("api.example.com", 1212)) + .with_http_tracker(CaddyService::new("http.example.com", 7070)); + + let json = serde_json::to_value(&context).expect("serialization should succeed"); + + assert_eq!(json["admin_email"], "admin@example.com"); + assert_eq!(json["use_staging"], true); + assert_eq!(json["tracker_api"]["domain"], "api.example.com"); + assert_eq!(json["tracker_api"]["port"], 1212); + assert_eq!(json["http_trackers"][0]["domain"], "http.example.com"); + assert_eq!(json["http_trackers"][0]["port"], 7070); + assert!(json["grafana"].is_null()); + } + + #[test] + fn it_should_use_staging_for_testing() { + let production = CaddyContext::new("admin@example.com", false); + let staging = CaddyContext::new("admin@example.com", true); + + assert!(!production.use_staging); + assert!(staging.use_staging); + } +} diff --git a/src/infrastructure/templating/caddy/template/wrapper/caddyfile/mod.rs b/src/infrastructure/templating/caddy/template/wrapper/caddyfile/mod.rs new file mode 100644 index 00000000..c1979b6f --- /dev/null +++ b/src/infrastructure/templating/caddy/template/wrapper/caddyfile/mod.rs @@ -0,0 +1,7 @@ +//! Caddyfile template context +//! +//! Defines the context and related types for Caddyfile.tera template rendering. + +mod context; + +pub use context::{CaddyContext, CaddyService}; diff --git a/src/infrastructure/templating/caddy/template/wrapper/mod.rs b/src/infrastructure/templating/caddy/template/wrapper/mod.rs new file mode 100644 index 00000000..8ca46957 --- /dev/null +++ b/src/infrastructure/templating/caddy/template/wrapper/mod.rs @@ -0,0 +1,7 @@ +//! Template wrappers for Caddyfile.tera +//! +//! This module provides context and template wrappers for Caddy configuration. + +pub mod caddyfile; + +pub use caddyfile::{CaddyContext, CaddyService}; diff --git a/src/infrastructure/templating/docker_compose/template/renderer/docker_compose.rs b/src/infrastructure/templating/docker_compose/template/renderer/docker_compose.rs index 16e0eb91..522bf14e 100644 --- a/src/infrastructure/templating/docker_compose/template/renderer/docker_compose.rs +++ b/src/infrastructure/templating/docker_compose/template/renderer/docker_compose.rs @@ -189,9 +189,22 @@ mod tests { use tempfile::TempDir; use crate::infrastructure::templating::docker_compose::template::wrappers::docker_compose::{ - DockerComposeContext, MysqlSetupConfig, TrackerPorts, + DockerComposeContext, MysqlSetupConfig, TrackerServiceConfig, }; + /// Helper to create `TrackerServiceConfig` for tests (no TLS, no networks) + fn test_tracker_config() -> TrackerServiceConfig { + TrackerServiceConfig::new( + vec![6868, 6969], // UDP ports + vec![7070], // HTTP ports without TLS + 1212, // API port + false, // API has no TLS + false, // has_prometheus + false, // has_mysql + false, // has_caddy + ) + } + #[test] fn it_should_create_renderer_with_template_manager() { let temp_dir = TempDir::new().unwrap(); @@ -214,11 +227,7 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); - let ports = TrackerPorts { - udp_tracker_ports: vec![6868, 6969], - http_tracker_ports: vec![7070], - http_api_port: 1212, - }; + let tracker = test_tracker_config(); let mysql_config = MysqlSetupConfig { root_password: "rootpass123".to_string(), database: "tracker_db".to_string(), @@ -226,7 +235,7 @@ mod tests { password: "userpass123".to_string(), port: 3306, }; - let mysql_context = DockerComposeContext::builder(ports) + let mysql_context = DockerComposeContext::builder(tracker) .with_mysql(mysql_config) .build(); @@ -304,12 +313,8 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); - let ports = TrackerPorts { - udp_tracker_ports: vec![6868, 6969], - http_tracker_ports: vec![7070], - http_api_port: 1212, - }; - let sqlite_context = DockerComposeContext::builder(ports).build(); + let tracker = test_tracker_config(); + let sqlite_context = DockerComposeContext::builder(tracker).build(); let renderer = DockerComposeRenderer::new(template_manager); let output_dir = TempDir::new().unwrap(); @@ -347,14 +352,19 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); - let ports = TrackerPorts { - udp_tracker_ports: vec![6868, 6969], - http_tracker_ports: vec![7070], - http_api_port: 1212, - }; + // Create tracker config with prometheus enabled + let tracker = TrackerServiceConfig::new( + vec![6868, 6969], // UDP ports + vec![7070], // HTTP ports without TLS + 1212, // API port + false, // API has no TLS + true, // has_prometheus + false, // has_mysql + false, // has_caddy + ); let prometheus_config = PrometheusConfig::new(std::num::NonZeroU32::new(15).expect("15 is non-zero")); - let context = DockerComposeContext::builder(ports) + let context = DockerComposeContext::builder(tracker) .with_prometheus(prometheus_config) .build(); @@ -419,12 +429,7 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); - let ports = TrackerPorts { - udp_tracker_ports: vec![6868, 6969], - http_tracker_ports: vec![7070], - http_api_port: 1212, - }; - let context = DockerComposeContext::builder(ports).build(); + let context = DockerComposeContext::builder(test_tracker_config()).build(); let renderer = DockerComposeRenderer::new(template_manager); let output_dir = TempDir::new().unwrap(); diff --git a/src/infrastructure/templating/docker_compose/template/renderer/project_generator.rs b/src/infrastructure/templating/docker_compose/template/renderer/project_generator.rs index 595c0802..495e53f6 100644 --- a/src/infrastructure/templating/docker_compose/template/renderer/project_generator.rs +++ b/src/infrastructure/templating/docker_compose/template/renderer/project_generator.rs @@ -185,7 +185,7 @@ mod tests { use tempfile::TempDir; use super::*; - use crate::infrastructure::templating::docker_compose::template::wrappers::docker_compose::TrackerPorts; + use crate::infrastructure::templating::docker_compose::template::wrappers::docker_compose::TrackerServiceConfig; use crate::infrastructure::templating::docker_compose::DOCKER_COMPOSE_SUBFOLDER; /// Creates a `TemplateManager` that uses the embedded templates @@ -207,12 +207,16 @@ mod tests { /// Helper function to create a test docker-compose context with `SQLite` fn create_test_docker_compose_context_sqlite() -> DockerComposeContext { // Use default test ports (matching TrackerConfig::default()) - let ports = TrackerPorts { - udp_tracker_ports: vec![6969], - http_tracker_ports: vec![7070], - http_api_port: 1212, - }; - DockerComposeContext::builder(ports).build() + let tracker = TrackerServiceConfig::new( + vec![6969], // UDP ports + vec![7070], // HTTP ports without TLS + 1212, // API port + false, // API has no TLS + false, // has_prometheus + false, // has_mysql + false, // has_caddy + ); + DockerComposeContext::builder(tracker).build() } #[tokio::test] diff --git a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/builder.rs b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/builder.rs index 2833678b..1e8b7ea2 100644 --- a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/builder.rs +++ b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/builder.rs @@ -4,31 +4,40 @@ use crate::domain::grafana::GrafanaConfig; use crate::domain::prometheus::PrometheusConfig; +use super::caddy::CaddyServiceConfig; use super::database::{DatabaseConfig, MysqlSetupConfig, DRIVER_MYSQL, DRIVER_SQLITE}; -use super::{DockerComposeContext, TrackerPorts}; +use super::grafana::GrafanaServiceConfig; +use super::mysql::MysqlServiceConfig; +use super::prometheus::PrometheusServiceConfig; +use super::{DockerComposeContext, TrackerServiceConfig}; /// Builder for `DockerComposeContext` /// /// Provides a fluent API for constructing Docker Compose contexts with optional features. /// Defaults to `SQLite` database configuration. +/// +/// The builder collects domain configuration objects and transforms them into +/// service configuration objects with pre-computed networks at build time. pub struct DockerComposeContextBuilder { - ports: TrackerPorts, + tracker: TrackerServiceConfig, database: DatabaseConfig, prometheus_config: Option, grafana_config: Option, + has_caddy: bool, } impl DockerComposeContextBuilder { /// Creates a new builder with default `SQLite` configuration - pub(super) fn new(ports: TrackerPorts) -> Self { + pub(super) fn new(tracker: TrackerServiceConfig) -> Self { Self { - ports, + tracker, database: DatabaseConfig { driver: DRIVER_SQLITE.to_string(), mysql: None, }, prometheus_config: None, grafana_config: None, + has_caddy: false, } } @@ -68,14 +77,62 @@ impl DockerComposeContextBuilder { self } + /// Enables Caddy TLS proxy + /// + /// When Caddy is enabled, it provides automatic HTTPS with Let's Encrypt + /// certificates for services that have TLS enabled. + #[must_use] + pub fn with_caddy(mut self) -> Self { + self.has_caddy = true; + self + } + /// Builds the `DockerComposeContext` + /// + /// Transforms domain configuration objects into service configuration + /// objects with pre-computed networks based on enabled features. #[must_use] pub fn build(self) -> DockerComposeContext { + let has_grafana = self.grafana_config.is_some(); + let has_caddy = self.has_caddy; + + // Build Prometheus service config if enabled + let prometheus = self.prometheus_config.map(|config| { + PrometheusServiceConfig::new(config.scrape_interval_in_secs(), has_grafana) + }); + + // Build Grafana service config if enabled + let grafana = self.grafana_config.map(|config| { + let has_tls = config.use_tls_proxy(); + GrafanaServiceConfig::new( + config.admin_user().to_string(), + config.admin_password().clone(), + has_tls, + has_caddy, + ) + }); + + // Build Caddy service config if enabled + let caddy = if has_caddy { + Some(CaddyServiceConfig::new()) + } else { + None + }; + + // Build MySQL service config if enabled + let mysql = if self.database.driver == DRIVER_MYSQL { + Some(MysqlServiceConfig::new()) + } else { + None + }; + DockerComposeContext { database: self.database, - ports: self.ports, - prometheus_config: self.prometheus_config, - grafana_config: self.grafana_config, + tracker: self.tracker, + prometheus, + grafana, + caddy, + mysql, } } } diff --git a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/caddy.rs b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/caddy.rs new file mode 100644 index 00000000..fab4d260 --- /dev/null +++ b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/caddy.rs @@ -0,0 +1,90 @@ +//! Caddy service configuration for Docker Compose +//! +//! This module defines the Caddy reverse proxy service configuration +//! for the docker-compose.yml template. +//! +//! ## Note on Context Separation +//! +//! This type (`CaddyServiceConfig`) is separate from the `CaddyContext` used +//! for rendering the Caddyfile.tera template. Each template has its own context: +//! +//! - `CaddyServiceConfig` (this module): For docker-compose.yml service definition +//! - `CaddyContext` (in caddy/template/wrapper): For Caddyfile content with domains/ports +//! +//! The docker-compose template only needs to know that Caddy is enabled +//! (for network/volume definitions), not the detailed service configurations. + +use serde::Serialize; + +/// Network names used by the Caddy service +const PROXY_NETWORK: &str = "proxy_network"; + +/// Caddy reverse proxy service configuration for Docker Compose +/// +/// Contains configuration for the Caddy service definition in docker-compose.yml. +/// This is intentionally minimal - the actual Caddy configuration (domains, ports) +/// is in the Caddyfile, rendered separately. +/// +/// # Example +/// +/// ```rust +/// use torrust_tracker_deployer_lib::infrastructure::templating::docker_compose::template::wrappers::docker_compose::context::CaddyServiceConfig; +/// +/// let caddy = CaddyServiceConfig::new(); +/// assert_eq!(caddy.networks, vec!["proxy_network"]); +/// ``` +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct CaddyServiceConfig { + /// Networks this service connects to + /// + /// Caddy always connects to `proxy_network` for reverse proxying + /// to backend services (tracker API, HTTP trackers, Grafana). + pub networks: Vec, +} + +impl CaddyServiceConfig { + /// Creates a new `CaddyServiceConfig` with default networks + /// + /// Caddy connects to: + /// - `proxy_network`: For reverse proxying to backend services + #[must_use] + pub fn new() -> Self { + Self { + networks: vec![PROXY_NETWORK.to_string()], + } + } +} + +impl Default for CaddyServiceConfig { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_create_caddy_config_with_proxy_network() { + let caddy = CaddyServiceConfig::new(); + + assert_eq!(caddy.networks, vec!["proxy_network"]); + } + + #[test] + fn it_should_implement_default() { + let caddy = CaddyServiceConfig::default(); + + assert_eq!(caddy.networks, vec!["proxy_network"]); + } + + #[test] + fn it_should_serialize_to_json() { + let caddy = CaddyServiceConfig::new(); + + let json = serde_json::to_value(&caddy).expect("serialization should succeed"); + + assert_eq!(json["networks"][0], "proxy_network"); + } +} diff --git a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/grafana.rs b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/grafana.rs new file mode 100644 index 00000000..f3a1e0aa --- /dev/null +++ b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/grafana.rs @@ -0,0 +1,94 @@ +//! Grafana service configuration for Docker Compose + +// External crates +use serde::Serialize; + +use crate::shared::secrets::Password; + +/// Grafana service configuration for Docker Compose +/// +/// Contains all configuration needed for the Grafana service in Docker Compose, +/// including admin credentials, TLS settings, and network connections. All logic +/// is pre-computed in Rust to keep the Tera template simple. +#[derive(Serialize, Debug, Clone)] +pub struct GrafanaServiceConfig { + /// Grafana admin username + pub admin_user: String, + /// Grafana admin password + pub admin_password: Password, + /// Whether Grafana has TLS enabled (port should not be exposed if true) + #[serde(default)] + pub has_tls: bool, + /// Networks the Grafana service should connect to + /// + /// Pre-computed list based on enabled features: + /// - Always includes `visualization_network` (queries Prometheus) + /// - Includes `proxy_network` if Caddy TLS proxy is enabled + pub networks: Vec, +} + +impl GrafanaServiceConfig { + /// Creates a new `GrafanaServiceConfig` with pre-computed networks + /// + /// # Arguments + /// + /// * `admin_user` - Grafana admin username + /// * `admin_password` - Grafana admin password + /// * `has_tls` - Whether Grafana has TLS enabled (via Caddy) + /// * `has_caddy` - Whether Caddy TLS proxy is enabled (adds `proxy_network`) + #[must_use] + pub fn new( + admin_user: String, + admin_password: Password, + has_tls: bool, + has_caddy: bool, + ) -> Self { + let networks = Self::compute_networks(has_caddy); + + Self { + admin_user, + admin_password, + has_tls, + networks, + } + } + + /// Computes the list of networks for the Grafana service + fn compute_networks(has_caddy: bool) -> Vec { + let mut networks = vec!["visualization_network".to_string()]; + + if has_caddy { + networks.push("proxy_network".to_string()); + } + + networks + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_create_grafana_config_with_only_visualization_network_when_caddy_disabled() { + let config = + GrafanaServiceConfig::new("admin".to_string(), Password::new("password"), false, false); + + assert_eq!(config.admin_user, "admin"); + assert!(!config.has_tls); + assert_eq!(config.networks, vec!["visualization_network"]); + } + + #[test] + fn it_should_create_grafana_config_with_both_networks_when_caddy_enabled() { + let config = + GrafanaServiceConfig::new("admin".to_string(), Password::new("password"), true, true); + + assert_eq!(config.admin_user, "admin"); + assert!(config.has_tls); + assert_eq!( + config.networks, + vec!["visualization_network", "proxy_network"] + ); + } +} diff --git a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/mod.rs b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/mod.rs index 58684253..9309c38e 100644 --- a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/mod.rs +++ b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/mod.rs @@ -6,19 +6,23 @@ // External crates use serde::Serialize; -// Internal crate -use crate::domain::grafana::GrafanaConfig; -use crate::domain::prometheus::PrometheusConfig; - // Submodules mod builder; +mod caddy; mod database; -mod ports; +mod grafana; +mod mysql; +mod prometheus; +mod tracker; // Re-exports pub use builder::DockerComposeContextBuilder; +pub use caddy::CaddyServiceConfig; pub use database::{DatabaseConfig, MysqlSetupConfig}; -pub use ports::TrackerPorts; +pub use grafana::GrafanaServiceConfig; +pub use mysql::MysqlServiceConfig; +pub use prometheus::PrometheusServiceConfig; +pub use tracker::{TrackerPorts, TrackerServiceConfig}; /// Context for rendering the docker-compose.yml template /// @@ -27,14 +31,29 @@ pub use ports::TrackerPorts; pub struct DockerComposeContext { /// Database configuration pub database: DatabaseConfig, - /// Tracker port configuration - pub ports: TrackerPorts, - /// Prometheus configuration (optional) + /// Tracker service configuration (ports, networks) + pub tracker: TrackerServiceConfig, + /// Prometheus service configuration (optional) + #[serde(skip_serializing_if = "Option::is_none")] + pub prometheus: Option, + /// Grafana service configuration (optional) + #[serde(skip_serializing_if = "Option::is_none")] + pub grafana: Option, + /// Caddy TLS proxy service configuration (optional) + /// + /// When present, Caddy reverse proxy is deployed for TLS termination. + /// When absent, services are exposed directly over HTTP. + /// + /// Note: This is separate from `CaddyContext` (used for Caddyfile.tera). + /// This type only contains the docker-compose service definition data. #[serde(skip_serializing_if = "Option::is_none")] - pub prometheus_config: Option, - /// Grafana configuration (optional) + pub caddy: Option, + /// `MySQL` service configuration (optional) + /// + /// Contains network configuration for the `MySQL` service. + /// This is separate from `MysqlSetupConfig` which contains credentials. #[serde(skip_serializing_if = "Option::is_none")] - pub grafana_config: Option, + pub mysql: Option, } impl DockerComposeContext { @@ -50,16 +69,20 @@ impl DockerComposeContext { /// # Examples /// /// ```rust - /// use torrust_tracker_deployer_lib::infrastructure::templating::docker_compose::template::wrappers::docker_compose::{DockerComposeContext, TrackerPorts, MysqlSetupConfig}; + /// use torrust_tracker_deployer_lib::infrastructure::templating::docker_compose::template::wrappers::docker_compose::{DockerComposeContext, TrackerServiceConfig, MysqlSetupConfig}; /// - /// let ports = TrackerPorts { - /// udp_tracker_ports: vec![6868, 6969], - /// http_tracker_ports: vec![7070], - /// http_api_port: 1212, - /// }; + /// let tracker_config = TrackerServiceConfig::new( + /// vec![6868, 6969], // UDP ports (always exposed) + /// vec![7070], // HTTP ports without TLS + /// 1212, // API port + /// false, // API has no TLS + /// false, // has_prometheus + /// false, // has_mysql + /// false, // has_caddy + /// ); /// /// // SQLite (default) - /// let context = DockerComposeContext::builder(ports.clone()).build(); + /// let context = DockerComposeContext::builder(tracker_config.clone()).build(); /// assert_eq!(context.database().driver(), "sqlite3"); /// /// // MySQL @@ -70,7 +93,7 @@ impl DockerComposeContext { /// password: "pass".to_string(), /// port: 3306, /// }; - /// let context = DockerComposeContext::builder(ports) + /// let context = DockerComposeContext::builder(tracker_config) /// .with_mysql(mysql_config) /// .build(); /// assert_eq!(context.database().driver(), "mysql"); @@ -86,52 +109,71 @@ impl DockerComposeContext { &self.database } - /// Get the tracker ports configuration + /// Get the tracker service configuration #[must_use] - pub fn ports(&self) -> &TrackerPorts { - &self.ports + pub fn tracker(&self) -> &TrackerServiceConfig { + &self.tracker } - /// Get the Prometheus configuration if present + /// Get the Prometheus service configuration if present #[must_use] - pub fn prometheus_config(&self) -> Option<&PrometheusConfig> { - self.prometheus_config.as_ref() + pub fn prometheus(&self) -> Option<&PrometheusServiceConfig> { + self.prometheus.as_ref() } - /// Get the Grafana configuration if present + /// Get the Grafana service configuration if present #[must_use] - pub fn grafana_config(&self) -> Option<&GrafanaConfig> { - self.grafana_config.as_ref() + pub fn grafana(&self) -> Option<&GrafanaServiceConfig> { + self.grafana.as_ref() + } + + /// Get the Caddy TLS proxy service configuration if present + #[must_use] + pub fn caddy(&self) -> Option<&CaddyServiceConfig> { + self.caddy.as_ref() + } + + /// Get the `MySQL` service configuration if present + #[must_use] + pub fn mysql(&self) -> Option<&MysqlServiceConfig> { + self.mysql.as_ref() } } #[cfg(test)] mod tests { + use crate::domain::prometheus::PrometheusConfig; + use super::*; + /// Helper to create `TrackerServiceConfig` for tests (no TLS, no networks) + fn test_tracker_config() -> TrackerServiceConfig { + TrackerServiceConfig::new( + vec![6868, 6969], // UDP ports + vec![7070], // HTTP ports without TLS + 1212, // API port + false, // API has no TLS + false, // has_prometheus + false, // has_mysql + false, // has_caddy + ) + } + #[test] fn it_should_create_context_with_sqlite_configuration() { - let ports = TrackerPorts { - udp_tracker_ports: vec![6868, 6969], - http_tracker_ports: vec![7070], - http_api_port: 1212, - }; - let context = DockerComposeContext::builder(ports).build(); + let tracker = test_tracker_config(); + let context = DockerComposeContext::builder(tracker).build(); assert_eq!(context.database().driver(), "sqlite3"); assert!(context.database().mysql().is_none()); - assert_eq!(context.ports().udp_tracker_ports, vec![6868, 6969]); - assert_eq!(context.ports().http_tracker_ports, vec![7070]); - assert_eq!(context.ports().http_api_port, 1212); + assert_eq!(context.tracker().udp_tracker_ports, vec![6868, 6969]); + assert_eq!(context.tracker().http_tracker_ports_without_tls, vec![7070]); + assert_eq!(context.tracker().http_api_port, 1212); } #[test] fn it_should_create_context_with_mysql_configuration() { - let ports = TrackerPorts { - udp_tracker_ports: vec![6868, 6969], - http_tracker_ports: vec![7070], - http_api_port: 1212, - }; + let tracker = test_tracker_config(); let mysql_config = MysqlSetupConfig { root_password: "root123".to_string(), database: "tracker".to_string(), @@ -139,7 +181,7 @@ mod tests { password: "pass456".to_string(), port: 3306, }; - let context = DockerComposeContext::builder(ports) + let context = DockerComposeContext::builder(tracker) .with_mysql(mysql_config) .build(); @@ -153,32 +195,24 @@ mod tests { assert_eq!(mysql.password, "pass456"); assert_eq!(mysql.port, 3306); - assert_eq!(context.ports().udp_tracker_ports, vec![6868, 6969]); - assert_eq!(context.ports().http_tracker_ports, vec![7070]); - assert_eq!(context.ports().http_api_port, 1212); + assert_eq!(context.tracker().udp_tracker_ports, vec![6868, 6969]); + assert_eq!(context.tracker().http_tracker_ports_without_tls, vec![7070]); + assert_eq!(context.tracker().http_api_port, 1212); } #[test] fn it_should_be_serializable_with_sqlite() { - let ports = TrackerPorts { - udp_tracker_ports: vec![6868, 6969], - http_tracker_ports: vec![7070], - http_api_port: 1212, - }; - let context = DockerComposeContext::builder(ports).build(); + let tracker = test_tracker_config(); + let context = DockerComposeContext::builder(tracker).build(); let serialized = serde_json::to_string(&context).unwrap(); assert!(serialized.contains("sqlite3")); - assert!(!serialized.contains("mysql")); + assert!(!serialized.contains("\"driver\":\"mysql\"")); } #[test] fn it_should_be_serializable_with_mysql() { - let ports = TrackerPorts { - udp_tracker_ports: vec![6868, 6969], - http_tracker_ports: vec![7070], - http_api_port: 1212, - }; + let tracker = test_tracker_config(); let mysql_config = MysqlSetupConfig { root_password: "root".to_string(), database: "db".to_string(), @@ -186,7 +220,7 @@ mod tests { password: "pass".to_string(), port: 3306, }; - let context = DockerComposeContext::builder(ports) + let context = DockerComposeContext::builder(tracker) .with_mysql(mysql_config) .build(); @@ -194,18 +228,14 @@ mod tests { assert!(serialized.contains("mysql")); assert!(serialized.contains("root")); assert!(serialized.contains("db")); - assert!(serialized.contains("user")); + assert!(serialized.contains("\"user\":\"user\"")); assert!(serialized.contains("pass")); assert!(serialized.contains("3306")); } #[test] fn it_should_be_cloneable() { - let ports = TrackerPorts { - udp_tracker_ports: vec![6868, 6969], - http_tracker_ports: vec![7070], - http_api_port: 1212, - }; + let tracker = test_tracker_config(); let mysql_config = MysqlSetupConfig { root_password: "root".to_string(), database: "db".to_string(), @@ -213,7 +243,7 @@ mod tests { password: "pass".to_string(), port: 3306, }; - let context = DockerComposeContext::builder(ports) + let context = DockerComposeContext::builder(tracker) .with_mysql(mysql_config) .build(); @@ -222,68 +252,115 @@ mod tests { } #[test] - fn it_should_not_include_prometheus_config_by_default() { - let ports = TrackerPorts { - udp_tracker_ports: vec![6868, 6969], - http_tracker_ports: vec![7070], - http_api_port: 1212, - }; - let context = DockerComposeContext::builder(ports).build(); + fn it_should_not_include_prometheus_by_default() { + let tracker = test_tracker_config(); + let context = DockerComposeContext::builder(tracker).build(); - assert!(context.prometheus_config().is_none()); + assert!(context.prometheus().is_none()); } #[test] - fn it_should_include_prometheus_config_when_added() { - let ports = TrackerPorts { - udp_tracker_ports: vec![6868, 6969], - http_tracker_ports: vec![7070], - http_api_port: 1212, - }; + fn it_should_include_prometheus_when_added() { + let tracker = test_tracker_config(); let prometheus_config = PrometheusConfig::new(std::num::NonZeroU32::new(30).expect("30 is non-zero")); - let context = DockerComposeContext::builder(ports) + let context = DockerComposeContext::builder(tracker) .with_prometheus(prometheus_config) .build(); - assert!(context.prometheus_config().is_some()); - assert_eq!( - context - .prometheus_config() - .unwrap() - .scrape_interval_in_secs(), - 30 - ); + assert!(context.prometheus().is_some()); + assert_eq!(context.prometheus().unwrap().scrape_interval_in_secs, 30); } #[test] - fn it_should_not_serialize_prometheus_config_when_absent() { - let ports = TrackerPorts { - udp_tracker_ports: vec![6868, 6969], - http_tracker_ports: vec![7070], - http_api_port: 1212, - }; - let context = DockerComposeContext::builder(ports).build(); + fn it_should_not_serialize_prometheus_when_absent() { + let tracker = test_tracker_config(); + let context = DockerComposeContext::builder(tracker).build(); let serialized = serde_json::to_string(&context).unwrap(); - assert!(!serialized.contains("prometheus_config")); + assert!(!serialized.contains("prometheus")); } #[test] - fn it_should_serialize_prometheus_config_when_present() { - let ports = TrackerPorts { - udp_tracker_ports: vec![6868, 6969], - http_tracker_ports: vec![7070], - http_api_port: 1212, - }; + fn it_should_serialize_prometheus_when_present() { + let tracker = test_tracker_config(); let prometheus_config = PrometheusConfig::new(std::num::NonZeroU32::new(20).expect("20 is non-zero")); - let context = DockerComposeContext::builder(ports) + let context = DockerComposeContext::builder(tracker) .with_prometheus(prometheus_config) .build(); let serialized = serde_json::to_string(&context).unwrap(); - assert!(serialized.contains("prometheus_config")); + assert!(serialized.contains("prometheus")); assert!(serialized.contains("\"scrape_interval_in_secs\":20")); } + + #[test] + fn it_should_compute_prometheus_networks_without_grafana() { + let tracker = test_tracker_config(); + let prometheus_config = + PrometheusConfig::new(std::num::NonZeroU32::new(15).expect("15 is non-zero")); + let context = DockerComposeContext::builder(tracker) + .with_prometheus(prometheus_config) + .build(); + + let prometheus = context.prometheus().unwrap(); + assert_eq!(prometheus.networks, vec!["metrics_network"]); + } + + #[test] + fn it_should_compute_prometheus_networks_with_grafana() { + use crate::domain::grafana::GrafanaConfig; + + let tracker = test_tracker_config(); + let prometheus_config = + PrometheusConfig::new(std::num::NonZeroU32::new(15).expect("15 is non-zero")); + let grafana_config = + GrafanaConfig::new("admin".to_string(), "password".to_string(), None, false); + let context = DockerComposeContext::builder(tracker) + .with_prometheus(prometheus_config) + .with_grafana(grafana_config) + .build(); + + let prometheus = context.prometheus().unwrap(); + assert_eq!( + prometheus.networks, + vec!["metrics_network", "visualization_network"] + ); + } + + #[test] + fn it_should_compute_grafana_networks_without_caddy() { + use crate::domain::grafana::GrafanaConfig; + + let tracker = test_tracker_config(); + let grafana_config = + GrafanaConfig::new("admin".to_string(), "password".to_string(), None, false); + let context = DockerComposeContext::builder(tracker) + .with_grafana(grafana_config) + .build(); + + let grafana = context.grafana().unwrap(); + assert_eq!(grafana.networks, vec!["visualization_network"]); + assert!(!grafana.has_tls); + } + + #[test] + fn it_should_compute_grafana_networks_with_caddy() { + use crate::domain::grafana::GrafanaConfig; + + let tracker = test_tracker_config(); + let grafana_config = + GrafanaConfig::new("admin".to_string(), "password".to_string(), None, false); + let context = DockerComposeContext::builder(tracker) + .with_grafana(grafana_config) + .with_caddy() + .build(); + + let grafana = context.grafana().unwrap(); + assert_eq!( + grafana.networks, + vec!["visualization_network", "proxy_network"] + ); + } } diff --git a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/mysql.rs b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/mysql.rs new file mode 100644 index 00000000..3ff842f1 --- /dev/null +++ b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/mysql.rs @@ -0,0 +1,91 @@ +//! `MySQL` service configuration for Docker Compose +//! +//! This module defines the `MySQL` service configuration for the docker-compose.yml template. +//! +//! ## Note on Configuration Separation +//! +//! There are two `MySQL`-related types in the docker-compose context: +//! +//! - `MysqlSetupConfig` (in database.rs): Contains credentials and initialization settings +//! for Docker Compose environment variables (root password, database name, user, etc.) +//! +//! - `MysqlServiceConfig` (this module): Contains service definition settings like networks, +//! following the same pattern as `CaddyServiceConfig`, `PrometheusServiceConfig`, etc. +//! +//! This separation keeps the pattern consistent across all services - each service +//! has its own config type for networks and service-specific settings. + +use serde::Serialize; + +/// Network names used by the `MySQL` service +const DATABASE_NETWORK: &str = "database_network"; + +/// `MySQL` service configuration for Docker Compose +/// +/// Contains configuration for the `MySQL` service definition in docker-compose.yml. +/// This is intentionally minimal - the actual `MySQL` setup configuration (credentials) +/// is in `MysqlSetupConfig`. +/// +/// # Example +/// +/// ```rust +/// use torrust_tracker_deployer_lib::infrastructure::templating::docker_compose::template::wrappers::docker_compose::context::MysqlServiceConfig; +/// +/// let mysql = MysqlServiceConfig::new(); +/// assert_eq!(mysql.networks, vec!["database_network"]); +/// ``` +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct MysqlServiceConfig { + /// Networks this service connects to + /// + /// `MySQL` only connects to `database_network` for isolation. + /// Only the tracker can access `MySQL` through this network. + pub networks: Vec, +} + +impl MysqlServiceConfig { + /// Creates a new `MysqlServiceConfig` with default networks + /// + /// `MySQL` connects to: + /// - `database_network`: For database access by the tracker + #[must_use] + pub fn new() -> Self { + Self { + networks: vec![DATABASE_NETWORK.to_string()], + } + } +} + +impl Default for MysqlServiceConfig { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_create_mysql_config_with_database_network() { + let mysql = MysqlServiceConfig::new(); + + assert_eq!(mysql.networks, vec!["database_network"]); + } + + #[test] + fn it_should_implement_default() { + let mysql = MysqlServiceConfig::default(); + + assert_eq!(mysql.networks, vec!["database_network"]); + } + + #[test] + fn it_should_serialize_to_json() { + let mysql = MysqlServiceConfig::new(); + + let json = serde_json::to_value(&mysql).expect("serialization should succeed"); + + assert_eq!(json["networks"][0], "database_network"); + } +} diff --git a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/ports.rs b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/ports.rs deleted file mode 100644 index 772fcc70..00000000 --- a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/ports.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! Tracker port configuration for Docker Compose - -// External crates -use serde::Serialize; - -/// Tracker port configuration -#[derive(Serialize, Debug, Clone)] -pub struct TrackerPorts { - /// UDP tracker ports - pub udp_tracker_ports: Vec, - /// HTTP tracker ports - pub http_tracker_ports: Vec, - /// HTTP API port - pub http_api_port: u16, -} diff --git a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/prometheus.rs b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/prometheus.rs new file mode 100644 index 00000000..8edf811b --- /dev/null +++ b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/prometheus.rs @@ -0,0 +1,74 @@ +//! Prometheus service configuration for Docker Compose + +// External crates +use serde::Serialize; + +/// Prometheus service configuration for Docker Compose +/// +/// Contains all configuration needed for the Prometheus service in Docker Compose, +/// including the scrape interval and network connections. All logic is pre-computed +/// in Rust to keep the Tera template simple. +#[derive(Serialize, Debug, Clone)] +pub struct PrometheusServiceConfig { + /// Scrape interval in seconds + pub scrape_interval_in_secs: u32, + /// Networks the Prometheus service should connect to + /// + /// Pre-computed list based on enabled features: + /// - Always includes `metrics_network` (scrapes metrics from tracker) + /// - Includes `visualization_network` if Grafana is enabled + pub networks: Vec, +} + +impl PrometheusServiceConfig { + /// Creates a new `PrometheusServiceConfig` with pre-computed networks + /// + /// # Arguments + /// + /// * `scrape_interval_in_secs` - The scrape interval in seconds + /// * `has_grafana` - Whether Grafana is enabled (adds `visualization_network`) + #[must_use] + pub fn new(scrape_interval_in_secs: u32, has_grafana: bool) -> Self { + let networks = Self::compute_networks(has_grafana); + + Self { + scrape_interval_in_secs, + networks, + } + } + + /// Computes the list of networks for the Prometheus service + fn compute_networks(has_grafana: bool) -> Vec { + let mut networks = vec!["metrics_network".to_string()]; + + if has_grafana { + networks.push("visualization_network".to_string()); + } + + networks + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_create_prometheus_config_with_only_metrics_network_when_grafana_disabled() { + let config = PrometheusServiceConfig::new(15, false); + + assert_eq!(config.scrape_interval_in_secs, 15); + assert_eq!(config.networks, vec!["metrics_network"]); + } + + #[test] + fn it_should_create_prometheus_config_with_both_networks_when_grafana_enabled() { + let config = PrometheusServiceConfig::new(30, true); + + assert_eq!(config.scrape_interval_in_secs, 30); + assert_eq!( + config.networks, + vec!["metrics_network", "visualization_network"] + ); + } +} diff --git a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/tracker.rs b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/tracker.rs new file mode 100644 index 00000000..fc5e81ba --- /dev/null +++ b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/tracker.rs @@ -0,0 +1,94 @@ +//! Tracker service configuration for Docker Compose + +// External crates +use serde::Serialize; + +/// Tracker service configuration for Docker Compose +/// +/// Contains all configuration needed for the tracker service in Docker Compose, +/// including port mappings and network connections. All logic is pre-computed +/// in Rust to keep the Tera template simple. +#[derive(Serialize, Debug, Clone)] +pub struct TrackerServiceConfig { + /// UDP tracker ports (always exposed - UDP doesn't use TLS termination via Caddy) + pub udp_tracker_ports: Vec, + /// HTTP tracker ports without TLS (only these are exposed in Docker Compose) + /// + /// Ports with TLS enabled are handled by Caddy and NOT included here. + pub http_tracker_ports_without_tls: Vec, + /// HTTP API port + pub http_api_port: u16, + /// Whether the HTTP API has TLS enabled (port should not be exposed if true) + #[serde(default)] + pub http_api_has_tls: bool, + /// Whether the tracker service needs a ports section at all + /// + /// Pre-computed flag: true if there are UDP ports, HTTP ports without TLS, + /// or the API port is exposed (no TLS). + #[serde(default)] + pub needs_ports_section: bool, + /// Networks the tracker service should connect to + /// + /// Pre-computed list based on enabled features (prometheus, mysql, caddy). + pub networks: Vec, +} + +impl TrackerServiceConfig { + /// Creates a new `TrackerServiceConfig` with pre-computed flags + /// + /// # Arguments + /// + /// * `udp_tracker_ports` - UDP tracker ports (always exposed) + /// * `http_tracker_ports_without_tls` - HTTP tracker ports that don't have TLS + /// * `http_api_port` - The HTTP API port number + /// * `http_api_has_tls` - Whether the API uses TLS (Caddy handles it) + /// * `has_prometheus` - Whether Prometheus is enabled (adds `metrics_network`) + /// * `has_mysql` - Whether `MySQL` is the database driver (adds `database_network`) + /// * `has_caddy` - Whether Caddy TLS proxy is enabled (adds `proxy_network`) + #[must_use] + #[allow(clippy::fn_params_excessive_bools)] + pub fn new( + udp_tracker_ports: Vec, + http_tracker_ports_without_tls: Vec, + http_api_port: u16, + http_api_has_tls: bool, + has_prometheus: bool, + has_mysql: bool, + has_caddy: bool, + ) -> Self { + let needs_ports_section = !udp_tracker_ports.is_empty() + || !http_tracker_ports_without_tls.is_empty() + || !http_api_has_tls; + + let networks = Self::compute_networks(has_prometheus, has_mysql, has_caddy); + + Self { + udp_tracker_ports, + http_tracker_ports_without_tls, + http_api_port, + http_api_has_tls, + needs_ports_section, + networks, + } + } + + /// Computes the list of networks for the tracker service + fn compute_networks(has_prometheus: bool, has_mysql: bool, has_caddy: bool) -> Vec { + let mut networks = Vec::new(); + + if has_prometheus { + networks.push("metrics_network".to_string()); + } + if has_mysql { + networks.push("database_network".to_string()); + } + if has_caddy { + networks.push("proxy_network".to_string()); + } + + networks + } +} + +// Type alias for backward compatibility +pub type TrackerPorts = TrackerServiceConfig; diff --git a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/mod.rs b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/mod.rs index b4007048..567ff210 100644 --- a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/mod.rs +++ b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/mod.rs @@ -3,5 +3,6 @@ pub mod template; pub use context::{ DockerComposeContext, DockerComposeContextBuilder, MysqlSetupConfig, TrackerPorts, + TrackerServiceConfig, }; pub use template::DockerComposeTemplate; diff --git a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/template.rs b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/template.rs index 006e030b..9263f831 100644 --- a/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/template.rs +++ b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/template.rs @@ -81,9 +81,22 @@ impl DockerComposeTemplate { #[cfg(test)] mod tests { - use super::super::context::{MysqlSetupConfig, TrackerPorts}; + use super::super::context::{MysqlSetupConfig, TrackerServiceConfig}; use super::*; + /// Helper to create `TrackerServiceConfig` for tests (no TLS, no networks) + fn test_tracker_config() -> TrackerServiceConfig { + TrackerServiceConfig::new( + vec![6868, 6969], // UDP ports + vec![7070], // HTTP ports without TLS + 1212, // API port + false, // API has no TLS + false, // has_prometheus + false, // has_mysql + false, // has_caddy + ) + } + #[test] fn it_should_create_docker_compose_template_with_sqlite() { let template_content = r#" @@ -99,12 +112,8 @@ services: let template_file = File::new("docker-compose.yml.tera", template_content.to_string()).unwrap(); - let ports = TrackerPorts { - udp_tracker_ports: vec![6868, 6969], - http_tracker_ports: vec![7070], - http_api_port: 1212, - }; - let context = DockerComposeContext::builder(ports).build(); + let tracker = test_tracker_config(); + let context = DockerComposeContext::builder(tracker).build(); let template = DockerComposeTemplate::new(&template_file, context).unwrap(); assert_eq!(template.database().driver(), "sqlite3"); @@ -129,11 +138,7 @@ services: let template_file = File::new("docker-compose.yml.tera", template_content.to_string()).unwrap(); - let ports = TrackerPorts { - udp_tracker_ports: vec![6868, 6969], - http_tracker_ports: vec![7070], - http_api_port: 1212, - }; + let tracker = test_tracker_config(); let mysql_config = MysqlSetupConfig { root_password: "root123".to_string(), database: "tracker".to_string(), @@ -141,7 +146,7 @@ services: password: "pass".to_string(), port: 3306, }; - let context = DockerComposeContext::builder(ports) + let context = DockerComposeContext::builder(tracker) .with_mysql(mysql_config) .build(); let template = DockerComposeTemplate::new(&template_file, context).unwrap(); @@ -163,12 +168,8 @@ services: "; let template_file = File::new("docker-compose.yml.tera", template_content.to_string()).unwrap(); - let ports = TrackerPorts { - udp_tracker_ports: vec![6868, 6969], - http_tracker_ports: vec![7070], - http_api_port: 1212, - }; - let context = DockerComposeContext::builder(ports).build(); + let tracker = test_tracker_config(); + let context = DockerComposeContext::builder(tracker).build(); let template = DockerComposeTemplate::new(&template_file, context).unwrap(); // Create temp directory for output @@ -192,12 +193,8 @@ services: let template_file = File::new("docker-compose.yml.tera", template_content.to_string()).unwrap(); - let ports = TrackerPorts { - udp_tracker_ports: vec![6868, 6969], - http_tracker_ports: vec![7070], - http_api_port: 1212, - }; - let context = DockerComposeContext::builder(ports).build(); + let tracker = test_tracker_config(); + let context = DockerComposeContext::builder(tracker).build(); let result = DockerComposeTemplate::new(&template_file, context); assert!(result.is_err()); diff --git a/src/infrastructure/templating/mod.rs b/src/infrastructure/templating/mod.rs index ddfc0ac3..2e5a9f34 100644 --- a/src/infrastructure/templating/mod.rs +++ b/src/infrastructure/templating/mod.rs @@ -16,6 +16,8 @@ //! //! - `ansible` - Ansible configuration management integration //! - `template` - Template renderers for Ansible inventory and playbooks +//! - `caddy` - Caddy TLS termination proxy configuration +//! - `template` - Template renderers for Caddyfile configuration //! - `docker_compose` - Docker Compose file management //! - `file_manager` - File manager for Docker Compose configuration files //! - `tofu` - `OpenTofu` infrastructure provisioning integration @@ -35,6 +37,7 @@ //! - Handle template validation and error reporting pub mod ansible; +pub mod caddy; pub mod docker_compose; pub mod grafana; pub mod prometheus; diff --git a/src/infrastructure/templating/prometheus/template/renderer/project_generator.rs b/src/infrastructure/templating/prometheus/template/renderer/project_generator.rs index b81e5eed..c3a45214 100644 --- a/src/infrastructure/templating/prometheus/template/renderer/project_generator.rs +++ b/src/infrastructure/templating/prometheus/template/renderer/project_generator.rs @@ -209,6 +209,8 @@ scrape_configs: http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().expect("valid address"), admin_token: "test_admin_token".to_string().into(), + domain: None, + use_tls_proxy: false, }, ..Default::default() } diff --git a/src/infrastructure/templating/tracker/template/renderer/project_generator.rs b/src/infrastructure/templating/tracker/template/renderer/project_generator.rs index de9cdee5..e4a7c883 100644 --- a/src/infrastructure/templating/tracker/template/renderer/project_generator.rs +++ b/src/infrastructure/templating/tracker/template/renderer/project_generator.rs @@ -224,13 +224,19 @@ mod tests { }], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), + domain: None, + use_tls_proxy: false, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "test_token".to_string().into(), + domain: None, + use_tls_proxy: false, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), + domain: None, + use_tls_proxy: false, }, }; @@ -274,13 +280,19 @@ mod tests { }], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), + domain: None, + use_tls_proxy: false, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "test_token".to_string().into(), + domain: None, + use_tls_proxy: false, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), + domain: None, + use_tls_proxy: false, }, }; diff --git a/src/infrastructure/templating/tracker/template/wrapper/tracker_config/context.rs b/src/infrastructure/templating/tracker/template/wrapper/tracker_config/context.rs index f143adab..523a388a 100644 --- a/src/infrastructure/templating/tracker/template/wrapper/tracker_config/context.rs +++ b/src/infrastructure/templating/tracker/template/wrapper/tracker_config/context.rs @@ -38,14 +38,18 @@ use crate::domain::environment::TrackerConfig; /// UdpTrackerConfig { bind_address: "0.0.0.0:6969".parse().unwrap() }, /// ], /// http_trackers: vec![ -/// HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap() }, +/// HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), domain: None, use_tls_proxy: false }, /// ], /// http_api: HttpApiConfig { /// bind_address: "0.0.0.0:1212".parse().unwrap(), /// admin_token: "MyToken".to_string().into(), +/// domain: None, +/// use_tls_proxy: false, /// }, /// health_check_api: HealthCheckApiConfig { /// bind_address: "127.0.0.1:1313".parse().unwrap(), +/// domain: None, +/// use_tls_proxy: false, /// }, /// }; /// let context = TrackerContext::from_config(&tracker_config); @@ -81,6 +85,16 @@ pub struct TrackerContext { /// Whether tracker is in private mode pub tracker_core_private: bool, + /// Whether the tracker is behind a reverse proxy (Caddy TLS termination) + /// + /// When `true`, the tracker expects `X-Forwarded-For` headers to determine + /// the real client IP address. This is set to `true` if ANY HTTP tracker + /// has `use_tls_proxy: true`. + /// + /// **Note**: This is a global tracker setting that affects ALL HTTP trackers. + /// See docs/external-issues/tracker/on-reverse-proxy-global-setting.md + pub on_reverse_proxy: bool, + /// UDP tracker bind addresses pub udp_trackers: Vec, @@ -89,6 +103,9 @@ pub struct TrackerContext { /// HTTP API bind address pub http_api_bind_address: String, + + /// Health check API bind address + pub health_check_api_bind_address: String, } /// UDP tracker entry for template rendering @@ -134,6 +151,7 @@ impl TrackerContext { mysql_user, mysql_password, tracker_core_private: config.core.private, + on_reverse_proxy: config.any_http_tracker_uses_tls_proxy(), udp_trackers: config .udp_trackers .iter() @@ -149,6 +167,7 @@ impl TrackerContext { }) .collect(), http_api_bind_address: config.http_api.bind_address.to_string(), + health_check_api_bind_address: config.health_check_api.bind_address.to_string(), } } @@ -171,6 +190,7 @@ impl TrackerContext { mysql_user: None, mysql_password: None, tracker_core_private: false, + on_reverse_proxy: false, // Default: no HTTP trackers use TLS proxy udp_trackers: vec![ UdpTrackerEntry { bind_address: "0.0.0.0:6868".parse().unwrap(), @@ -183,6 +203,7 @@ impl TrackerContext { bind_address: "0.0.0.0:7070".parse().unwrap(), }], http_api_bind_address: "0.0.0.0:1212".parse().unwrap(), + health_check_api_bind_address: "127.0.0.1:1313".parse().unwrap(), } } } @@ -220,13 +241,19 @@ mod tests { ], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), + domain: None, + use_tls_proxy: false, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "test_admin_token".to_string().into(), + domain: None, + use_tls_proxy: false, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), + domain: None, + use_tls_proxy: false, }, } } @@ -269,13 +296,19 @@ mod tests { }], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), + domain: None, + use_tls_proxy: false, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "test_token".to_string().into(), + domain: None, + use_tls_proxy: false, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), + domain: None, + use_tls_proxy: false, }, }; diff --git a/src/presentation/views/commands/show/environment_info/basic.rs b/src/presentation/views/commands/show/environment_info/basic.rs new file mode 100644 index 00000000..4a145206 --- /dev/null +++ b/src/presentation/views/commands/show/environment_info/basic.rs @@ -0,0 +1,85 @@ +//! Basic Environment Information View +//! +//! This module provides a view for rendering basic environment information +//! (name, state, provider, creation date). + +use chrono::{DateTime, Utc}; + +/// View for rendering basic environment information +/// +/// This view handles the display of fundamental environment properties +/// that are always available regardless of state. +pub struct BasicInfoView; + +impl BasicInfoView { + /// Render basic environment information as formatted lines + /// + /// # Arguments + /// + /// * `name` - Environment name + /// * `state` - Current state display name + /// * `provider` - Provider display name + /// * `created_at` - Creation timestamp + /// + /// # Returns + /// + /// A vector of formatted lines ready to be joined + #[must_use] + pub fn render( + name: &str, + state: &str, + provider: &str, + created_at: DateTime, + ) -> Vec { + vec![ + String::new(), // blank line + format!("Environment: {name}"), + format!("State: {state}"), + format!("Provider: {provider}"), + format!("Created: {}", created_at.format("%Y-%m-%d %H:%M:%S UTC")), + ] + } +} + +#[cfg(test)] +mod tests { + use chrono::{TimeZone, Utc}; + + use super::*; + + fn test_timestamp() -> DateTime { + Utc.with_ymd_and_hms(2025, 1, 7, 12, 30, 45).unwrap() + } + + #[test] + fn it_should_render_environment_name() { + let lines = BasicInfoView::render("my-env", "Created", "LXD", test_timestamp()); + assert!(lines.iter().any(|l| l.contains("Environment: my-env"))); + } + + #[test] + fn it_should_render_state() { + let lines = BasicInfoView::render("my-env", "Running", "LXD", test_timestamp()); + assert!(lines.iter().any(|l| l.contains("State: Running"))); + } + + #[test] + fn it_should_render_provider() { + let lines = BasicInfoView::render("my-env", "Created", "Hetzner Cloud", test_timestamp()); + assert!(lines.iter().any(|l| l.contains("Provider: Hetzner Cloud"))); + } + + #[test] + fn it_should_render_creation_date_in_utc_format() { + let lines = BasicInfoView::render("my-env", "Created", "LXD", test_timestamp()); + assert!(lines + .iter() + .any(|l| l.contains("Created: 2025-01-07 12:30:45 UTC"))); + } + + #[test] + fn it_should_start_with_blank_line() { + let lines = BasicInfoView::render("my-env", "Created", "LXD", test_timestamp()); + assert!(lines.first().is_some_and(String::is_empty)); + } +} diff --git a/src/presentation/views/commands/show/environment_info/grafana.rs b/src/presentation/views/commands/show/environment_info/grafana.rs new file mode 100644 index 00000000..afb4bafc --- /dev/null +++ b/src/presentation/views/commands/show/environment_info/grafana.rs @@ -0,0 +1,89 @@ +//! Grafana Service View +//! +//! This module provides a view for rendering Grafana visualization service information. + +use crate::application::command_handlers::show::info::GrafanaInfo; + +/// View for rendering Grafana service information +/// +/// This view handles the display of Grafana visualization service details, +/// including HTTPS status when configured with Caddy. +pub struct GrafanaView; + +impl GrafanaView { + /// Render Grafana service information as formatted lines + /// + /// # Arguments + /// + /// * `grafana` - Grafana service information + /// + /// # Returns + /// + /// A vector of formatted lines ready to be joined + #[must_use] + pub fn render(grafana: &GrafanaInfo) -> Vec { + let header = if grafana.uses_https { + "Grafana (HTTPS via Caddy):".to_string() + } else { + "Grafana:".to_string() + }; + + vec![ + String::new(), // blank line + header, + format!(" {}", grafana.url), + ] + } +} + +#[cfg(test)] +mod tests { + use url::Url; + + use super::*; + + fn http_grafana() -> GrafanaInfo { + GrafanaInfo::new( + Url::parse("http://10.0.0.1:3100").unwrap(), // DevSkim: ignore DS137138 + false, + ) + } + + fn https_grafana() -> GrafanaInfo { + GrafanaInfo::new(Url::parse("https://grafana.tracker.local").unwrap(), true) + } + + #[test] + fn it_should_render_http_grafana_header() { + let lines = GrafanaView::render(&http_grafana()); + assert!(lines.iter().any(|l| l == "Grafana:")); + } + + #[test] + fn it_should_render_https_grafana_header_with_caddy_indicator() { + let lines = GrafanaView::render(&https_grafana()); + assert!(lines + .iter() + .any(|l| l.contains("Grafana (HTTPS via Caddy):"))); + } + + #[test] + fn it_should_render_http_url() { + let lines = GrafanaView::render(&http_grafana()); + assert!(lines.iter().any(|l| l.contains("http://10.0.0.1:3100"))); // DevSkim: ignore DS137138 + } + + #[test] + fn it_should_render_https_url() { + let lines = GrafanaView::render(&https_grafana()); + assert!(lines + .iter() + .any(|l| l.contains("https://grafana.tracker.local"))); + } + + #[test] + fn it_should_start_with_blank_line() { + let lines = GrafanaView::render(&http_grafana()); + assert!(lines.first().is_some_and(String::is_empty)); + } +} diff --git a/src/presentation/views/commands/show/environment_info/https_hint.rs b/src/presentation/views/commands/show/environment_info/https_hint.rs new file mode 100644 index 00000000..2b003af4 --- /dev/null +++ b/src/presentation/views/commands/show/environment_info/https_hint.rs @@ -0,0 +1,171 @@ +//! HTTPS Hint View +//! +//! This module provides a view for rendering the /etc/hosts hint +//! when HTTPS/TLS is configured for services. + +use std::net::IpAddr; + +use crate::application::command_handlers::show::info::ServiceInfo; + +/// View for rendering HTTPS configuration hints +/// +/// This view displays helpful information about accessing TLS-enabled services, +/// including the /etc/hosts entry needed for local domains and a note about +/// internal ports not being directly accessible. +pub struct HttpsHintView; + +impl HttpsHintView { + /// Render HTTPS hint information as formatted lines + /// + /// Only renders content if there are TLS-enabled services. + /// + /// # Arguments + /// + /// * `services` - Service information containing TLS domain info + /// * `instance_ip` - Optional instance IP for /etc/hosts entry + /// + /// # Returns + /// + /// A vector of formatted lines ready to be joined. + /// Returns empty vector if no TLS is configured. + #[must_use] + pub fn render(services: &ServiceInfo, instance_ip: Option) -> Vec { + if !services.has_any_tls() { + return vec![]; + } + + let mut lines = vec![ + String::new(), // blank line + "Note: HTTPS services require domain-based access. For local domains (*.local)," + .to_string(), + "add the following to your /etc/hosts file:".to_string(), + String::new(), // blank line + ]; + + // Build /etc/hosts entry + if let Some(ip) = instance_ip { + let domains = services.tls_domain_names().join(" "); + lines.push(format!(" {ip} {domains}")); + } + + lines.push(String::new()); // blank line + + // Internal ports note + let ports: Vec = services + .unexposed_ports() + .iter() + .map(ToString::to_string) + .collect(); + lines.push(format!( + "Internal ports ({}) are not directly accessible when TLS is enabled.", + ports.join(", ") + )); + + lines + } +} + +#[cfg(test)] +mod tests { + use std::net::Ipv4Addr; + + use super::*; + use crate::application::command_handlers::show::info::TlsDomainInfo; + + fn services_without_tls() -> ServiceInfo { + ServiceInfo::new( + vec!["udp://10.0.0.1:6969/announce".to_string()], + vec![], // No HTTPS trackers + vec!["http://10.0.0.1:7070/announce".to_string()], // DevSkim: ignore DS137138 + vec![], // No localhost HTTP trackers + "http://10.0.0.1:1212/api".to_string(), // DevSkim: ignore DS137138 + false, + false, // API not localhost-only + "http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + false, // Health check doesn't use HTTPS + false, // Health check not localhost-only + vec![], // No TLS domains + ) + } + + fn services_with_tls() -> ServiceInfo { + ServiceInfo::new( + vec!["udp://10.0.0.1:6969/announce".to_string()], + vec!["https://http1.tracker.local/announce".to_string()], + vec![], + vec![], // No localhost HTTP trackers + "https://api.tracker.local/api".to_string(), + true, + false, // API not localhost-only + "http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + false, // Health check doesn't use HTTPS + false, // Health check not localhost-only + vec![ + TlsDomainInfo::new("api.tracker.local".to_string(), 1212), + TlsDomainInfo::new("http1.tracker.local".to_string(), 7070), + TlsDomainInfo::new("grafana.tracker.local".to_string(), 3000), + ], + ) + } + + #[test] + fn it_should_return_empty_when_no_tls_configured() { + let lines = HttpsHintView::render(&services_without_tls(), None); + assert!(lines.is_empty()); + } + + #[test] + fn it_should_render_note_about_domain_access() { + let ip = IpAddr::V4(Ipv4Addr::new(10, 140, 190, 214)); + let lines = HttpsHintView::render(&services_with_tls(), Some(ip)); + assert!(lines + .iter() + .any(|l| l.contains("HTTPS services require domain-based access"))); + } + + #[test] + fn it_should_render_etc_hosts_instruction() { + let ip = IpAddr::V4(Ipv4Addr::new(10, 140, 190, 214)); + let lines = HttpsHintView::render(&services_with_tls(), Some(ip)); + assert!(lines.iter().any(|l| l.contains("/etc/hosts"))); + } + + #[test] + fn it_should_render_etc_hosts_entry_with_ip_and_domains() { + let ip = IpAddr::V4(Ipv4Addr::new(10, 140, 190, 214)); + let lines = HttpsHintView::render(&services_with_tls(), Some(ip)); + assert!(lines.iter().any(|l| l.contains("10.140.190.214"))); + assert!(lines.iter().any(|l| l.contains("api.tracker.local"))); + assert!(lines.iter().any(|l| l.contains("http1.tracker.local"))); + assert!(lines.iter().any(|l| l.contains("grafana.tracker.local"))); + } + + #[test] + fn it_should_render_internal_ports_note() { + let ip = IpAddr::V4(Ipv4Addr::new(10, 140, 190, 214)); + let lines = HttpsHintView::render(&services_with_tls(), Some(ip)); + assert!(lines + .iter() + .any(|l| l.contains("Internal ports") && l.contains("not directly accessible"))); + } + + #[test] + fn it_should_list_unexposed_ports() { + let ip = IpAddr::V4(Ipv4Addr::new(10, 140, 190, 214)); + let lines = HttpsHintView::render(&services_with_tls(), Some(ip)); + let ports_line = lines.iter().find(|l| l.contains("Internal ports")).unwrap(); + assert!(ports_line.contains("1212")); + assert!(ports_line.contains("7070")); + assert!(ports_line.contains("3000")); + } + + #[test] + fn it_should_still_render_message_without_ip() { + let lines = HttpsHintView::render(&services_with_tls(), None); + assert!(lines + .iter() + .any(|l| l.contains("HTTPS services require domain-based access"))); + // But no IP in the /etc/hosts entry + assert!(!lines.iter().any(|l| l.contains("10.140"))); + } +} diff --git a/src/presentation/views/commands/show/environment_info/infrastructure.rs b/src/presentation/views/commands/show/environment_info/infrastructure.rs new file mode 100644 index 00000000..2c2898f1 --- /dev/null +++ b/src/presentation/views/commands/show/environment_info/infrastructure.rs @@ -0,0 +1,143 @@ +//! Infrastructure Information View +//! +//! This module provides a view for rendering infrastructure details +//! including IP address, SSH credentials, and connection commands. + +use crate::application::command_handlers::show::info::InfrastructureInfo; + +/// View for rendering infrastructure information +/// +/// This view handles the display of infrastructure details that become +/// available after an environment has been provisioned. +pub struct InfrastructureView; + +impl InfrastructureView { + /// Render infrastructure information as formatted lines + /// + /// # Arguments + /// + /// * `infra` - Infrastructure information containing IP, SSH details + /// + /// # Returns + /// + /// A vector of formatted lines ready to be joined + #[must_use] + pub fn render(infra: &InfrastructureInfo) -> Vec { + let mut lines = vec![ + String::new(), // blank line + "Infrastructure:".to_string(), + format!(" Instance IP: {}", infra.instance_ip), + format!(" SSH Port: {}", infra.ssh_port), + format!(" SSH User: {}", infra.ssh_user), + format!(" SSH Key: {}", infra.ssh_key_path), + String::new(), // blank line + "Connection:".to_string(), + format!(" {}", infra.ssh_command()), + ]; + + // Hint for Docker users when container path pattern detected + if Self::looks_like_container_path(&infra.ssh_key_path) { + lines.push(String::new()); // blank line + lines.push("Note: Paths shown are inside the container.".to_string()); + lines.push( + " If using Docker, translate to your host path (e.g., ~/.ssh/).".to_string(), + ); + } + + lines + } + + /// Check if a path looks like it's inside the Docker container. + /// + /// The deployer Docker image uses `/home/deployer/` as the home directory. + /// When paths contain this prefix, it indicates the environment was managed + /// from inside a container, and users need to translate paths to their host + /// equivalents. + fn looks_like_container_path(path: &str) -> bool { + path.starts_with("/home/deployer/") + } +} + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr}; + + use super::*; + + fn sample_infrastructure() -> InfrastructureInfo { + InfrastructureInfo::new( + IpAddr::V4(Ipv4Addr::new(10, 140, 190, 171)), + 22, + "torrust".to_string(), + "~/.ssh/id_rsa".to_string(), + ) + } + + #[test] + fn it_should_render_instance_ip() { + let lines = InfrastructureView::render(&sample_infrastructure()); + assert!(lines + .iter() + .any(|l| l.contains("Instance IP: 10.140.190.171"))); + } + + #[test] + fn it_should_render_ssh_port() { + let lines = InfrastructureView::render(&sample_infrastructure()); + assert!(lines.iter().any(|l| l.contains("SSH Port: 22"))); + } + + #[test] + fn it_should_render_ssh_user() { + let lines = InfrastructureView::render(&sample_infrastructure()); + assert!(lines.iter().any(|l| l.contains("SSH User: torrust"))); + } + + #[test] + fn it_should_render_ssh_key_path() { + let lines = InfrastructureView::render(&sample_infrastructure()); + assert!(lines.iter().any(|l| l.contains("SSH Key: ~/.ssh/id_rsa"))); + } + + #[test] + fn it_should_render_ssh_connection_command() { + let lines = InfrastructureView::render(&sample_infrastructure()); + assert!(lines.iter().any(|l| l.contains("ssh -i"))); + } + + #[test] + fn it_should_include_port_in_ssh_command_when_non_standard() { + let infra = InfrastructureInfo::new( + IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), + 2222, + "user".to_string(), + "/key".to_string(), + ); + + let lines = InfrastructureView::render(&infra); + assert!(lines.iter().any(|l| l.contains("-p 2222"))); + } + + #[test] + fn it_should_show_docker_hint_when_container_path_detected() { + let infra = InfrastructureInfo::new( + IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), + 22, + "torrust".to_string(), + "/home/deployer/.ssh/id_rsa".to_string(), + ); + + let lines = InfrastructureView::render(&infra); + assert!(lines + .iter() + .any(|l| l.contains("Paths shown are inside the container"))); + } + + #[test] + fn it_should_not_show_docker_hint_for_regular_paths() { + let lines = InfrastructureView::render(&sample_infrastructure()); + assert!(!lines + .iter() + .any(|l| l.contains("Paths shown are inside the container"))); + } +} diff --git a/src/presentation/views/commands/show/environment_info.rs b/src/presentation/views/commands/show/environment_info/mod.rs similarity index 56% rename from src/presentation/views/commands/show/environment_info.rs rename to src/presentation/views/commands/show/environment_info/mod.rs index f99ba1ed..6275bf63 100644 --- a/src/presentation/views/commands/show/environment_info.rs +++ b/src/presentation/views/commands/show/environment_info/mod.rs @@ -2,6 +2,33 @@ //! //! This module provides a view for rendering environment information //! with state-aware details. +//! +//! # Module Structure +//! +//! The view is composed of specialized child views for each section: +//! - `basic`: Basic environment info (name, state, provider, created) +//! - `infrastructure`: Infrastructure details (IP, SSH credentials) +//! - `tracker_services`: Tracker service endpoints +//! - `prometheus`: Prometheus metrics service +//! - `grafana`: Grafana visualization service +//! - `https_hint`: HTTPS configuration hints (/etc/hosts) +//! - `next_step`: State-aware guidance + +mod basic; +mod grafana; +mod https_hint; +mod infrastructure; +mod next_step; +mod prometheus; +mod tracker_services; + +use basic::BasicInfoView; +use grafana::GrafanaView; +use https_hint::HttpsHintView; +use infrastructure::InfrastructureView; +use next_step::NextStepGuidanceView; +use prometheus::PrometheusView; +use tracker_services::TrackerServicesView; use crate::application::command_handlers::show::info::EnvironmentInfo; @@ -12,10 +39,10 @@ use crate::application::command_handlers::show::info::EnvironmentInfo; /// /// # Design /// -/// Following MVC pattern, this view: +/// Following MVC pattern with composition, this view: /// - Receives data from the controller via the `EnvironmentInfo` DTO -/// - Formats the output for display -/// - Handles optional fields gracefully (infrastructure, services) +/// - Delegates rendering to specialized child views +/// - Composes the final output from child view results /// - Returns a string ready for output to stdout /// /// # Examples @@ -44,7 +71,8 @@ impl EnvironmentInfoView { /// Render environment information as a formatted string /// /// Takes environment info and produces a human-readable output suitable - /// for displaying to users via stdout. + /// for displaying to users via stdout. Uses composition to delegate + /// rendering to specialized child views. /// /// # Arguments /// @@ -89,130 +117,44 @@ impl EnvironmentInfoView { pub fn render(info: &EnvironmentInfo) -> String { let mut lines = Vec::new(); - // Basic information - lines.push(String::new()); // blank line - lines.push(format!("Environment: {}", info.name)); - lines.push(format!("State: {}", info.state)); - lines.push(format!("Provider: {}", info.provider)); - lines.push(format!( - "Created: {}", - info.created_at.format("%Y-%m-%d %H:%M:%S UTC") + // Basic information (always present) + lines.extend(BasicInfoView::render( + &info.name, + &info.state, + &info.provider, + info.created_at, )); // Infrastructure details (if available) if let Some(ref infra) = info.infrastructure { - lines.push(String::new()); // blank line - lines.push("Infrastructure:".to_string()); - lines.push(format!(" Instance IP: {}", infra.instance_ip)); - lines.push(format!(" SSH Port: {}", infra.ssh_port)); - lines.push(format!(" SSH User: {}", infra.ssh_user)); - lines.push(format!(" SSH Key: {}", infra.ssh_key_path)); - lines.push(String::new()); // blank line - lines.push("Connection:".to_string()); - lines.push(format!(" {}", infra.ssh_command())); - - // Hint for Docker users when container path pattern detected - if Self::looks_like_container_path(&infra.ssh_key_path) { - lines.push(String::new()); // blank line - lines.push("Note: Paths shown are inside the container.".to_string()); - lines.push( - " If using Docker, translate to your host path (e.g., ~/.ssh/)." - .to_string(), - ); - } + lines.extend(InfrastructureView::render(infra)); } - // Service information (if available) + // Tracker service information (if available) if let Some(ref services) = info.services { - lines.push(String::new()); // blank line - lines.push("Tracker Services:".to_string()); - - if !services.udp_trackers.is_empty() { - lines.push(" UDP Trackers:".to_string()); - for url in &services.udp_trackers { - lines.push(format!(" - {url}")); - } - } - - if !services.http_trackers.is_empty() { - lines.push(" HTTP Trackers:".to_string()); - for url in &services.http_trackers { - lines.push(format!(" - {url}")); - } - } - - lines.push(" API Endpoint:".to_string()); - lines.push(format!(" - {}", services.api_endpoint)); - - lines.push(" Health Check:".to_string()); - lines.push(format!(" - {}", services.health_check_url)); + lines.extend(TrackerServicesView::render(services)); } // Prometheus service (if configured) if let Some(ref prometheus) = info.prometheus { - lines.push(String::new()); // blank line - lines.push("Prometheus:".to_string()); - lines.push(format!(" {}", prometheus.access_note)); + lines.extend(PrometheusView::render(prometheus)); } // Grafana service (if configured) if let Some(ref grafana) = info.grafana { - lines.push(String::new()); // blank line - lines.push("Grafana:".to_string()); - lines.push(format!(" {}", grafana.url)); + lines.extend(GrafanaView::render(grafana)); } - // Next step guidance - lines.push(String::new()); // blank line - lines.push(Self::get_next_step_guidance(&info.state_name)); - - lines.join("\n") - } - - /// Get next step guidance based on current state - fn get_next_step_guidance(state_name: &str) -> String { - match state_name { - "created" => "Run 'provision' to create infrastructure.".to_string(), - "provisioning" => { - "Provisioning in progress. Wait for completion or check logs.".to_string() - } - "provisioned" => "Run 'configure' to set up the system.".to_string(), - "configuring" => { - "Configuration in progress. Wait for completion or check logs.".to_string() - } - "configured" => "Run 'release' to deploy the tracker software.".to_string(), - "releasing" => "Release in progress. Wait for completion or check logs.".to_string(), - "released" => "Run 'run' to start the tracker services.".to_string(), - "running" => "Services are running. Use 'test' to verify health.".to_string(), - "destroying" => "Destruction in progress. Wait for completion.".to_string(), - "destroyed" => { - "Environment has been destroyed. Create a new environment to redeploy.".to_string() - } - "provision_failed" => { - "Provisioning failed. Run 'destroy' and create a new environment.".to_string() - } - "configure_failed" => { - "Configuration failed. Run 'destroy' and create a new environment.".to_string() - } - "release_failed" => { - "Release failed. Run 'destroy' and create a new environment.".to_string() - } - "run_failed" => "Run failed. Run 'destroy' and create a new environment.".to_string(), - "destroy_failed" => { - "Destruction failed. Check error details and retry 'destroy'.".to_string() - } - _ => format!("Unknown state: {state_name}. Check environment state file."), + // HTTPS hint with /etc/hosts (if TLS is configured) + if let Some(ref services) = info.services { + let instance_ip = info.infrastructure.as_ref().map(|i| i.instance_ip); + lines.extend(HttpsHintView::render(services, instance_ip)); } - } - /// Check if a path looks like it's inside the Docker container. - /// - /// The deployer Docker image uses `/home/deployer/` as the home directory. - /// When paths contain this prefix, it indicates the environment was managed - /// from inside a container, and users need to translate paths to their host - /// equivalents. - fn looks_like_container_path(path: &str) -> bool { - path.starts_with("/home/deployer/") + // Next step guidance (always present) + lines.extend(NextStepGuidanceView::render(&info.state_name)); + + lines.join("\n") } } @@ -223,7 +165,9 @@ mod tests { use chrono::{TimeZone, Utc}; use super::*; - use crate::application::command_handlers::show::info::{InfrastructureInfo, ServiceInfo}; + use crate::application::command_handlers::show::info::{ + InfrastructureInfo, ServiceInfo, TlsDomainInfo, + }; /// Helper to create a fixed test timestamp fn test_timestamp() -> chrono::DateTime { @@ -287,9 +231,16 @@ mod tests { ) .with_services(ServiceInfo::new( vec!["udp://10.0.0.1:6969/announce".to_string()], + vec![], // No HTTPS trackers vec!["http://10.0.0.1:7070/announce".to_string()], // DevSkim: ignore DS137138 + vec![], // No localhost HTTP trackers "http://10.0.0.1:1212/api".to_string(), // DevSkim: ignore DS137138 + false, // API doesn't use HTTPS + false, // API not localhost-only "http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + false, // Health check doesn't use HTTPS + false, // Health check not localhost-only + vec![], // No TLS domains )); let output = EnvironmentInfoView::render(&info); @@ -297,7 +248,7 @@ mod tests { assert!(output.contains("Tracker Services:")); assert!(output.contains("UDP Trackers:")); assert!(output.contains("udp://10.0.0.1:6969/announce")); - assert!(output.contains("HTTP Trackers:")); + assert!(output.contains("HTTP Trackers (direct):")); assert!(output.contains("http://10.0.0.1:7070/announce")); // DevSkim: ignore DS137138 assert!(output.contains("API Endpoint:")); assert!(output.contains("http://10.0.0.1:1212/api")); // DevSkim: ignore DS137138 @@ -322,9 +273,16 @@ mod tests { )) .with_services(ServiceInfo::new( vec!["udp://192.168.1.100:6969/announce".to_string()], - vec![], + vec![], // No HTTPS trackers + vec![], // No direct trackers + vec![], // No localhost HTTP trackers "http://192.168.1.100:1212/api".to_string(), // DevSkim: ignore DS137138 + false, + false, // API not localhost-only "http://192.168.1.100:1313/health_check".to_string(), // DevSkim: ignore DS137138 + false, // Health check doesn't use HTTPS + false, // Health check not localhost-only + vec![], )); let output = EnvironmentInfoView::render(&info); @@ -336,7 +294,72 @@ mod tests { assert!(output.contains("Tracker Services:")); assert!(output.contains("UDP Trackers:")); // Should not have HTTP Trackers section when empty - assert!(!output.contains("HTTP Trackers:")); + assert!(!output.contains("HTTP Trackers")); + } + + #[test] + fn it_should_render_https_services_with_hosts_hint() { + let info = EnvironmentInfo::new( + "https-env".to_string(), + "Running".to_string(), + "LXD".to_string(), + test_timestamp(), + "running".to_string(), + ) + .with_infrastructure(InfrastructureInfo::new( + IpAddr::V4(Ipv4Addr::new(10, 140, 190, 214)), + 22, + "torrust".to_string(), + "~/.ssh/id_rsa".to_string(), + )) + .with_services(ServiceInfo::new( + vec!["udp://10.140.190.214:6969/announce".to_string()], + vec![ + "https://http1.tracker.local/announce".to_string(), + "https://http2.tracker.local/announce".to_string(), + ], + vec!["http://10.140.190.214:7072/announce".to_string()], // DevSkim: ignore DS137138 + vec![], // No localhost HTTP trackers + "https://api.tracker.local/api".to_string(), + true, // API uses HTTPS + false, // API not localhost-only + "http://10.140.190.214:1313/health_check".to_string(), // DevSkim: ignore DS137138 + false, // Health check doesn't use HTTPS + false, // Health check not localhost-only + vec![ + TlsDomainInfo::new("api.tracker.local".to_string(), 1212), + TlsDomainInfo::new("http1.tracker.local".to_string(), 7070), + TlsDomainInfo::new("http2.tracker.local".to_string(), 7071), + TlsDomainInfo::new("grafana.tracker.local".to_string(), 3000), + ], + )); + + let output = EnvironmentInfoView::render(&info); + + // Check HTTPS trackers section + assert!(output.contains("HTTP Trackers (HTTPS via Caddy):")); + assert!(output.contains("https://http1.tracker.local/announce")); + assert!(output.contains("https://http2.tracker.local/announce")); + + // Check direct HTTP trackers section + assert!(output.contains("HTTP Trackers (direct):")); + assert!(output.contains("http://10.140.190.214:7072/announce")); // DevSkim: ignore DS137138 + + // Check API shows HTTPS + assert!(output.contains("API Endpoint (HTTPS via Caddy):")); + assert!(output.contains("https://api.tracker.local/api")); + + // Check /etc/hosts hint + assert!(output.contains("Note: HTTPS services require domain-based access")); + assert!(output.contains("/etc/hosts")); + assert!(output.contains("10.140.190.214")); + assert!(output.contains("api.tracker.local")); + assert!(output.contains("http1.tracker.local")); + assert!(output.contains("grafana.tracker.local")); + + // Check unexposed ports message + assert!(output.contains("Internal ports")); + assert!(output.contains("not directly accessible when TLS is enabled")); } #[test] @@ -359,33 +382,4 @@ mod tests { assert!(output.contains("-p 2222")); } - - mod get_next_step_guidance { - use super::*; - - #[test] - fn it_should_guide_from_created_state() { - let guidance = EnvironmentInfoView::get_next_step_guidance("created"); - assert!(guidance.contains("provision")); - } - - #[test] - fn it_should_guide_from_provisioned_state() { - let guidance = EnvironmentInfoView::get_next_step_guidance("provisioned"); - assert!(guidance.contains("configure")); - } - - #[test] - fn it_should_guide_from_running_state() { - let guidance = EnvironmentInfoView::get_next_step_guidance("running"); - assert!(guidance.contains("test")); - } - - #[test] - fn it_should_handle_failed_states() { - let guidance = EnvironmentInfoView::get_next_step_guidance("provision_failed"); - assert!(guidance.contains("failed")); - assert!(guidance.contains("destroy")); - } - } } diff --git a/src/presentation/views/commands/show/environment_info/next_step.rs b/src/presentation/views/commands/show/environment_info/next_step.rs new file mode 100644 index 00000000..3a1b52ee --- /dev/null +++ b/src/presentation/views/commands/show/environment_info/next_step.rs @@ -0,0 +1,166 @@ +//! Next Step Guidance View +//! +//! This module provides a view for rendering state-aware guidance +//! about what the user should do next. + +/// View for rendering next step guidance +/// +/// This view provides context-aware guidance to users about what +/// command they should run next based on the current environment state. +pub struct NextStepGuidanceView; + +impl NextStepGuidanceView { + /// Render next step guidance as formatted lines + /// + /// # Arguments + /// + /// * `state_name` - Internal state name (e.g., "created", "provisioned") + /// + /// # Returns + /// + /// A vector of formatted lines ready to be joined + #[must_use] + pub fn render(state_name: &str) -> Vec { + vec![ + String::new(), // blank line + Self::get_guidance(state_name), + ] + } + + /// Get guidance text based on current state + fn get_guidance(state_name: &str) -> String { + match state_name { + "created" => "Run 'provision' to create infrastructure.".to_string(), + "provisioning" => { + "Provisioning in progress. Wait for completion or check logs.".to_string() + } + "provisioned" => "Run 'configure' to set up the system.".to_string(), + "configuring" => { + "Configuration in progress. Wait for completion or check logs.".to_string() + } + "configured" => "Run 'release' to deploy the tracker software.".to_string(), + "releasing" => "Release in progress. Wait for completion or check logs.".to_string(), + "released" => "Run 'run' to start the tracker services.".to_string(), + "running" => "Services are running. Use 'test' to verify health.".to_string(), + "destroying" => "Destruction in progress. Wait for completion.".to_string(), + "destroyed" => { + "Environment has been destroyed. Create a new environment to redeploy.".to_string() + } + "provision_failed" => { + "Provisioning failed. Run 'destroy' and create a new environment.".to_string() + } + "configure_failed" => { + "Configuration failed. Run 'destroy' and create a new environment.".to_string() + } + "release_failed" => { + "Release failed. Run 'destroy' and create a new environment.".to_string() + } + "run_failed" => "Run failed. Run 'destroy' and create a new environment.".to_string(), + "destroy_failed" => { + "Destruction failed. Check error details and retry 'destroy'.".to_string() + } + _ => format!("Unknown state: {state_name}. Check environment state file."), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_guide_from_created_state() { + let lines = NextStepGuidanceView::render("created"); + let text = lines.join("\n"); + assert!(text.contains("provision")); + } + + #[test] + fn it_should_guide_from_provisioned_state() { + let lines = NextStepGuidanceView::render("provisioned"); + let text = lines.join("\n"); + assert!(text.contains("configure")); + } + + #[test] + fn it_should_guide_from_configured_state() { + let lines = NextStepGuidanceView::render("configured"); + let text = lines.join("\n"); + assert!(text.contains("release")); + } + + #[test] + fn it_should_guide_from_released_state() { + let lines = NextStepGuidanceView::render("released"); + let text = lines.join("\n"); + assert!(text.contains("run")); + } + + #[test] + fn it_should_guide_from_running_state() { + let lines = NextStepGuidanceView::render("running"); + let text = lines.join("\n"); + assert!(text.contains("test")); + } + + #[test] + fn it_should_guide_from_destroyed_state() { + let lines = NextStepGuidanceView::render("destroyed"); + let text = lines.join("\n"); + assert!(text.contains("destroyed")); + assert!(text.contains("new environment")); + } + + #[test] + fn it_should_handle_provision_failed_state() { + let lines = NextStepGuidanceView::render("provision_failed"); + let text = lines.join("\n"); + assert!(text.contains("failed")); + assert!(text.contains("destroy")); + } + + #[test] + fn it_should_handle_configure_failed_state() { + let lines = NextStepGuidanceView::render("configure_failed"); + let text = lines.join("\n"); + assert!(text.contains("failed")); + assert!(text.contains("destroy")); + } + + #[test] + fn it_should_handle_release_failed_state() { + let lines = NextStepGuidanceView::render("release_failed"); + let text = lines.join("\n"); + assert!(text.contains("failed")); + assert!(text.contains("destroy")); + } + + #[test] + fn it_should_handle_run_failed_state() { + let lines = NextStepGuidanceView::render("run_failed"); + let text = lines.join("\n"); + assert!(text.contains("failed")); + assert!(text.contains("destroy")); + } + + #[test] + fn it_should_handle_destroy_failed_state() { + let lines = NextStepGuidanceView::render("destroy_failed"); + let text = lines.join("\n"); + assert!(text.contains("failed")); + assert!(text.contains("retry")); + } + + #[test] + fn it_should_handle_unknown_state() { + let lines = NextStepGuidanceView::render("unknown_state"); + let text = lines.join("\n"); + assert!(text.contains("Unknown state")); + } + + #[test] + fn it_should_start_with_blank_line() { + let lines = NextStepGuidanceView::render("created"); + assert!(lines.first().is_some_and(String::is_empty)); + } +} diff --git a/src/presentation/views/commands/show/environment_info/prometheus.rs b/src/presentation/views/commands/show/environment_info/prometheus.rs new file mode 100644 index 00000000..b82de81b --- /dev/null +++ b/src/presentation/views/commands/show/environment_info/prometheus.rs @@ -0,0 +1,57 @@ +//! Prometheus Service View +//! +//! This module provides a view for rendering Prometheus metrics service information. + +use crate::application::command_handlers::show::info::PrometheusInfo; + +/// View for rendering Prometheus service information +/// +/// This view handles the display of Prometheus metrics service details. +/// Prometheus is typically internal-only and accessed via SSH tunnel. +pub struct PrometheusView; + +impl PrometheusView { + /// Render Prometheus service information as formatted lines + /// + /// # Arguments + /// + /// * `prometheus` - Prometheus service information + /// + /// # Returns + /// + /// A vector of formatted lines ready to be joined + #[must_use] + pub fn render(prometheus: &PrometheusInfo) -> Vec { + vec![ + String::new(), // blank line + "Prometheus:".to_string(), + format!(" {}", prometheus.access_note), + ] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_render_prometheus_header() { + let prometheus = PrometheusInfo::default_internal(); + let lines = PrometheusView::render(&prometheus); + assert!(lines.iter().any(|l| l == "Prometheus:")); + } + + #[test] + fn it_should_render_access_note() { + let prometheus = PrometheusInfo::default_internal(); + let lines = PrometheusView::render(&prometheus); + assert!(lines.iter().any(|l| l.contains("Internal only"))); + } + + #[test] + fn it_should_start_with_blank_line() { + let prometheus = PrometheusInfo::default_internal(); + let lines = PrometheusView::render(&prometheus); + assert!(lines.first().is_some_and(String::is_empty)); + } +} diff --git a/src/presentation/views/commands/show/environment_info/tracker_services.rs b/src/presentation/views/commands/show/environment_info/tracker_services.rs new file mode 100644 index 00000000..c6fd954b --- /dev/null +++ b/src/presentation/views/commands/show/environment_info/tracker_services.rs @@ -0,0 +1,339 @@ +//! Tracker Services View +//! +//! This module provides a view for rendering tracker service endpoints +//! including UDP trackers, HTTP trackers (HTTPS and direct), API, and health check. + +use crate::application::command_handlers::show::info::ServiceInfo; + +/// View for rendering tracker service information +/// +/// This view handles the display of tracker service endpoints that become +/// available after services have been started (Released/Running states). +pub struct TrackerServicesView; + +impl TrackerServicesView { + /// Render tracker service information as formatted lines + /// + /// # Arguments + /// + /// * `services` - Service information containing tracker endpoints + /// + /// # Returns + /// + /// A vector of formatted lines ready to be joined + #[must_use] + pub fn render(services: &ServiceInfo) -> Vec { + let mut lines = vec![ + String::new(), // blank line + "Tracker Services:".to_string(), + ]; + + // UDP Trackers + if !services.udp_trackers.is_empty() { + lines.push(" UDP Trackers:".to_string()); + for url in &services.udp_trackers { + lines.push(format!(" - {url}")); + } + } + + // HTTPS-enabled HTTP trackers (via Caddy) + if !services.https_http_trackers.is_empty() { + lines.push(" HTTP Trackers (HTTPS via Caddy):".to_string()); + for url in &services.https_http_trackers { + lines.push(format!(" - {url}")); + } + } + + // Direct HTTP trackers (no TLS) + if !services.direct_http_trackers.is_empty() { + lines.push(" HTTP Trackers (direct):".to_string()); + for url in &services.direct_http_trackers { + lines.push(format!(" - {url}")); + } + } + + // Localhost-only HTTP trackers + if !services.localhost_http_trackers.is_empty() { + lines.push(" HTTP Trackers (internal only):".to_string()); + for tracker in &services.localhost_http_trackers { + lines.push(format!( + " - {} - localhost:{} (access via SSH tunnel)", + tracker.service_name, tracker.port + )); + } + } + + // API endpoint with HTTPS indicator and localhost-only marker + if services.api_is_localhost_only { + lines.push(" API Endpoint (internal only):".to_string()); + lines.push(format!( + " - {} (access via SSH tunnel)", + services.api_endpoint + )); + } else if services.api_uses_https { + lines.push(" API Endpoint (HTTPS via Caddy):".to_string()); + lines.push(format!(" - {}", services.api_endpoint)); + } else { + lines.push(" API Endpoint:".to_string()); + lines.push(format!(" - {}", services.api_endpoint)); + } + + // Health check with HTTPS indicator and localhost-only marker + if services.health_check_is_localhost_only { + lines.push(" Health Check (internal only):".to_string()); + lines.push(format!( + " - {} (access via SSH tunnel)", + services.health_check_url + )); + } else if services.health_check_uses_https { + lines.push(" Health Check (HTTPS via Caddy):".to_string()); + lines.push(format!(" - {}", services.health_check_url)); + } else { + lines.push(" Health Check:".to_string()); + lines.push(format!(" - {}", services.health_check_url)); + } + + lines + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::application::command_handlers::show::info::{LocalhostServiceInfo, TlsDomainInfo}; + + fn sample_http_only_services() -> ServiceInfo { + ServiceInfo::new( + vec!["udp://10.0.0.1:6969/announce".to_string()], + vec![], // No HTTPS trackers + vec!["http://10.0.0.1:7070/announce".to_string()], // DevSkim: ignore DS137138 + vec![], // No localhost HTTP trackers + "http://10.0.0.1:1212/api".to_string(), // DevSkim: ignore DS137138 + false, // API doesn't use HTTPS + false, // API not localhost-only + "http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + false, // Health check doesn't use HTTPS + false, // Health check not localhost-only + vec![], // No TLS domains + ) + } + + fn sample_https_services() -> ServiceInfo { + ServiceInfo::new( + vec!["udp://10.0.0.1:6969/announce".to_string()], + vec![ + "https://http1.tracker.local/announce".to_string(), + "https://http2.tracker.local/announce".to_string(), + ], + vec!["http://10.0.0.1:7072/announce".to_string()], // DevSkim: ignore DS137138 + vec![], // No localhost HTTP trackers + "https://api.tracker.local/api".to_string(), + true, // API uses HTTPS + false, // API not localhost-only + "http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + false, // Health check doesn't use HTTPS (yet) + false, // Health check not localhost-only + vec![ + TlsDomainInfo::new("api.tracker.local".to_string(), 1212), + TlsDomainInfo::new("http1.tracker.local".to_string(), 7070), + ], + ) + } + + #[test] + fn it_should_render_udp_trackers() { + let lines = TrackerServicesView::render(&sample_http_only_services()); + assert!(lines.iter().any(|l| l.contains("UDP Trackers:"))); + assert!(lines + .iter() + .any(|l| l.contains("udp://10.0.0.1:6969/announce"))); + } + + #[test] + fn it_should_render_direct_http_trackers() { + let lines = TrackerServicesView::render(&sample_http_only_services()); + assert!(lines.iter().any(|l| l.contains("HTTP Trackers (direct):"))); + assert!(lines + .iter() + .any(|l| l.contains("http://10.0.0.1:7070/announce"))); // DevSkim: ignore DS137138 + } + + #[test] + fn it_should_render_https_http_trackers() { + let lines = TrackerServicesView::render(&sample_https_services()); + assert!(lines + .iter() + .any(|l| l.contains("HTTP Trackers (HTTPS via Caddy):"))); + assert!(lines + .iter() + .any(|l| l.contains("https://http1.tracker.local/announce"))); + assert!(lines + .iter() + .any(|l| l.contains("https://http2.tracker.local/announce"))); + } + + #[test] + fn it_should_render_api_endpoint_without_https_indicator() { + let lines = TrackerServicesView::render(&sample_http_only_services()); + assert!(lines.iter().any(|l| l == " API Endpoint:")); + assert!(lines.iter().any(|l| l.contains("http://10.0.0.1:1212/api"))); // DevSkim: ignore DS137138 + } + + #[test] + fn it_should_render_api_endpoint_with_https_indicator() { + let lines = TrackerServicesView::render(&sample_https_services()); + assert!(lines + .iter() + .any(|l| l.contains("API Endpoint (HTTPS via Caddy):"))); + assert!(lines + .iter() + .any(|l| l.contains("https://api.tracker.local/api"))); + } + + #[test] + fn it_should_render_health_check() { + let lines = TrackerServicesView::render(&sample_http_only_services()); + assert!(lines.iter().any(|l| l.contains("Health Check:"))); + assert!(!lines + .iter() + .any(|l| l.contains("Health Check (HTTPS via Caddy):"))); + assert!(lines + .iter() + .any(|l| l.contains("http://10.0.0.1:1313/health_check"))); // DevSkim: ignore DS137138 + } + + #[test] + fn it_should_render_health_check_with_https_indicator() { + let services = ServiceInfo::new( + vec![], + vec![], + vec![], + vec![], // No localhost HTTP trackers + "http://10.0.0.1:1212/api".to_string(), // DevSkim: ignore DS137138 + false, + false, // API not localhost-only + "https://health.tracker.local/health_check".to_string(), + true, // Health check uses HTTPS + false, // Health check not localhost-only + vec![TlsDomainInfo::new("health.tracker.local".to_string(), 1313)], + ); + + let lines = TrackerServicesView::render(&services); + assert!(lines + .iter() + .any(|l| l.contains("Health Check (HTTPS via Caddy):"))); + assert!(lines + .iter() + .any(|l| l.contains("https://health.tracker.local/health_check"))); + } + + #[test] + fn it_should_not_show_empty_sections() { + let services = ServiceInfo::new( + vec!["udp://10.0.0.1:6969/announce".to_string()], + vec![], // No HTTPS trackers + vec![], // No direct HTTP trackers + vec![], // No localhost HTTP trackers + "http://10.0.0.1:1212/api".to_string(), // DevSkim: ignore DS137138 + false, + false, // API not localhost-only + "http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + false, // Health check doesn't use HTTPS + false, // Health check not localhost-only + vec![], + ); + + let lines = TrackerServicesView::render(&services); + assert!(!lines.iter().any(|l| l.contains("HTTP Trackers"))); + } + + #[test] + fn it_should_render_localhost_only_api() { + let services = ServiceInfo::new( + vec![], + vec![], + vec![], + vec![], + "http://127.0.0.1:1212/api".to_string(), // DevSkim: ignore DS137138 + false, + true, // API is localhost-only + "http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + false, + false, + vec![], + ); + + let lines = TrackerServicesView::render(&services); + assert!(lines + .iter() + .any(|l| l.contains("API Endpoint (internal only):"))); + assert!(lines.iter().any(|l| l.contains("access via SSH tunnel"))); + } + + #[test] + fn it_should_render_localhost_only_health_check() { + let services = ServiceInfo::new( + vec![], + vec![], + vec![], + vec![], + "http://10.0.0.1:1212/api".to_string(), // DevSkim: ignore DS137138 + false, + false, + "http://127.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + false, + true, // Health check is localhost-only + vec![], + ); + + let lines = TrackerServicesView::render(&services); + assert!(lines + .iter() + .any(|l| l.contains("Health Check (internal only):"))); + assert!(lines.iter().any(|l| l.contains("access via SSH tunnel"))); + } + + #[test] + fn it_should_render_localhost_only_http_trackers() { + let services = ServiceInfo::new( + vec![], + vec![], + vec![], + vec![ + LocalhostServiceInfo { + service_name: "http_tracker_1".to_string(), + port: 7070, + }, + LocalhostServiceInfo { + service_name: "http_tracker_2".to_string(), + port: 7071, + }, + ], + "http://10.0.0.1:1212/api".to_string(), // DevSkim: ignore DS137138 + false, + false, + "http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + false, + false, + vec![], + ); + + let lines = TrackerServicesView::render(&services); + assert!(lines + .iter() + .any(|l| l.contains("HTTP Trackers (internal only):"))); + assert!(lines + .iter() + .any(|l| l.contains("http_tracker_1 - localhost:7070"))); + assert!(lines + .iter() + .any(|l| l.contains("http_tracker_2 - localhost:7071"))); + assert!( + lines + .iter() + .filter(|l| l.contains("access via SSH tunnel")) + .count() + >= 2 + ); + } +} diff --git a/src/shared/domain_name.rs b/src/shared/domain_name.rs new file mode 100644 index 00000000..ea5873ae --- /dev/null +++ b/src/shared/domain_name.rs @@ -0,0 +1,463 @@ +//! Domain name type with basic validation +//! +//! This module provides a strongly-typed domain name representation that performs +//! basic validation to catch common typos and mistakes. It follows the same pattern +//! as the `Email` type, providing validation during construction while keeping DTO +//! types as primitives for serialization. +//! +//! # Design Decision +//! +//! We intentionally use **minimal validation** rather than strict RFC compliance. +//! The goal is to catch common user mistakes (typos) without rejecting valid but +//! unusual domain names. Caddy and Let's Encrypt are permissive about domain formats, +//! and strict DNS validation would add complexity without significant benefit. +//! +//! Alternative approaches considered: +//! - `addr` crate: RFC-compliant but might reject valid edge cases +//! - `publicsuffix` crate: Validates against public suffix list (overkill) +//! - `idna` crate: Handles internationalized domains (not needed) +//! - Full RFC 1035 DNS validation: Too strict for our use case +//! +//! # Validation Rules +//! +//! Minimal checks to catch typos: +//! - Not empty +//! - No whitespace +//! - Has at least one dot (TLD separator) +//! - No consecutive dots (e.g., `example..com`) +//! - Doesn't start or end with a dot +//! +//! # Architecture (DDD) +//! +//! This type lives in the shared layer rather than domain layer because: +//! - It's a generic building block used across multiple domains +//! - Similar to `Username`, `Email`, and other value objects +//! - The domain layer would use this type for business logic validation +//! +//! # Examples +//! +//! ``` +//! use torrust_tracker_deployer_lib::shared::DomainName; +//! +//! // Valid domain names +//! let domain = DomainName::new("example.com").unwrap(); +//! let api = DomainName::new("api.tracker.torrust.org").unwrap(); +//! let with_hyphen = DomainName::new("my-service.example.com").unwrap(); +//! +//! // Invalid domain names (typos) +//! assert!(DomainName::new("localhost").is_err()); // No TLD +//! assert!(DomainName::new("example..com").is_err()); // Consecutive dots +//! assert!(DomainName::new(" example.com").is_err()); // Leading space +//! ``` + +use std::fmt; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// A validated domain name +/// +/// This type guarantees that the contained string passes basic domain name +/// validation (see module docs for validation rules). It's immutable and +/// provides access to the underlying string via `as_str()`. +/// +/// # Construction +/// +/// Use `DomainName::new()` to create a validated instance. The constructor +/// validates the input and returns a `Result` with a detailed error if +/// validation fails. +/// +/// # Serialization +/// +/// Implements Serde traits with validation on deserialization. Invalid +/// domain names will cause deserialization to fail with a descriptive error. +#[derive(Debug, Clone, PartialEq, Eq, Hash, JsonSchema)] +#[serde(try_from = "String", into = "String")] +pub struct DomainName(String); + +impl DomainName { + /// Creates a new validated domain name + /// + /// # Arguments + /// + /// * `domain` - A string slice containing the domain name to validate + /// + /// # Errors + /// + /// Returns `DomainNameError` if the domain name is invalid: + /// - `EmptyDomain` - Domain is empty + /// - `InvalidFormat` - Domain has typos or formatting issues + /// + /// # Examples + /// + /// ``` + /// use torrust_tracker_deployer_lib::shared::DomainName; + /// + /// let domain = DomainName::new("tracker.torrust.org").unwrap(); + /// assert_eq!(domain.as_str(), "tracker.torrust.org"); + /// ``` + pub fn new(domain: &str) -> Result { + Self::validate(domain)?; + Ok(Self(domain.to_string())) + } + + /// Returns the domain name as a string slice + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Extracts the top-level domain (TLD) + /// + /// # Panics + /// + /// This method will not panic because `DomainName` is validated at + /// construction to always contain at least one dot (TLD separator). + /// + /// # Examples + /// + /// ``` + /// use torrust_tracker_deployer_lib::shared::DomainName; + /// + /// let domain = DomainName::new("api.tracker.torrust.org").unwrap(); + /// assert_eq!(domain.tld(), "org"); + /// ``` + #[must_use] + pub fn tld(&self) -> &str { + self.0 + .rsplit('.') + .next() + .expect("validated domain always has TLD") + } + + /// Returns all subdomains as a vector (excluding TLD) + /// + /// # Examples + /// + /// ``` + /// use torrust_tracker_deployer_lib::shared::DomainName; + /// + /// let domain = DomainName::new("api.tracker.torrust.org").unwrap(); + /// assert_eq!(domain.subdomains(), vec!["api", "tracker", "torrust"]); + /// ``` + #[must_use] + pub fn subdomains(&self) -> Vec<&str> { + let parts: Vec<&str> = self.0.split('.').collect(); + parts[..parts.len() - 1].to_vec() + } + + /// Validates a domain name string + /// + /// Uses minimal validation to catch common typos without being overly strict. + /// See module documentation for the rationale behind this approach. + fn validate(domain: &str) -> Result<(), DomainNameError> { + // Check for empty domain + if domain.is_empty() { + return Err(DomainNameError::EmptyDomain); + } + + // Check for whitespace (common typo) + if domain.chars().any(char::is_whitespace) { + return Err(DomainNameError::InvalidFormat { + domain: domain.to_string(), + reason: "domain cannot contain whitespace".to_string(), + }); + } + + // Must contain at least one dot (for TLD) + if !domain.contains('.') { + return Err(DomainNameError::InvalidFormat { + domain: domain.to_string(), + reason: "domain must have at least one dot (e.g., 'example.com')".to_string(), + }); + } + + // Cannot start or end with dot + if domain.starts_with('.') || domain.ends_with('.') { + return Err(DomainNameError::InvalidFormat { + domain: domain.to_string(), + reason: "domain cannot start or end with a dot".to_string(), + }); + } + + // Check for consecutive dots (common typo like "example..com") + if domain.contains("..") { + return Err(DomainNameError::InvalidFormat { + domain: domain.to_string(), + reason: "domain cannot have consecutive dots".to_string(), + }); + } + + Ok(()) + } +} + +impl fmt::Display for DomainName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TryFrom for DomainName { + type Error = DomainNameError; + + fn try_from(value: String) -> Result { + Self::new(&value) + } +} + +impl From for String { + fn from(domain: DomainName) -> Self { + domain.0 + } +} + +impl AsRef for DomainName { + fn as_ref(&self) -> &str { + &self.0 + } +} + +// Custom Serialize implementation that outputs the inner String +impl Serialize for DomainName { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.0.serialize(serializer) + } +} + +// Custom Deserialize implementation that validates on parse +impl<'de> Deserialize<'de> for DomainName { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + DomainName::new(&s).map_err(serde::de::Error::custom) + } +} + +/// Errors that can occur when parsing or validating a domain name +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DomainNameError { + /// The domain string is empty + EmptyDomain, + + /// The domain format is invalid (likely a typo) + InvalidFormat { + /// The invalid domain that was provided + domain: String, + /// Detailed reason for the failure + reason: String, + }, +} + +impl fmt::Display for DomainNameError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyDomain => write!(f, "domain name cannot be empty"), + Self::InvalidFormat { domain, reason } => { + write!(f, "invalid domain '{domain}': {reason}") + } + } + } +} + +impl std::error::Error for DomainNameError {} + +impl DomainNameError { + /// Returns actionable help text for this error + /// + /// Provides users with guidance on how to fix the error, including + /// valid examples and common mistakes to avoid. + #[must_use] + pub fn help(&self) -> &'static str { + match self { + Self::EmptyDomain => { + "Domain name cannot be empty.\n\ + \n\ + Provide a valid domain name like:\n\ + - example.com\n\ + - api.tracker.torrust.org\n\ + - my-service.example.com" + } + Self::InvalidFormat { .. } => { + "Domain name appears to have a typo.\n\ + \n\ + Common mistakes:\n\ + - Missing TLD: 'localhost' β†’ 'localhost.local' or use a real domain\n\ + - Extra dot: 'example..com' β†’ 'example.com'\n\ + - Space in name: 'my domain.com' β†’ 'my-domain.com'\n\ + \n\ + Valid examples:\n\ + - example.com\n\ + - api.tracker.torrust.org\n\ + - my-service.example.com" + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Valid domain tests + + #[test] + fn it_should_create_domain_when_format_is_valid() { + let domain = DomainName::new("example.com").unwrap(); + assert_eq!(domain.as_str(), "example.com"); + } + + #[test] + fn it_should_accept_domain_with_subdomain() { + let domain = DomainName::new("api.tracker.torrust.org").unwrap(); + assert_eq!(domain.as_str(), "api.tracker.torrust.org"); + } + + #[test] + fn it_should_accept_domain_with_hyphen() { + let domain = DomainName::new("my-service.example.com").unwrap(); + assert_eq!(domain.as_str(), "my-service.example.com"); + } + + #[test] + fn it_should_accept_domain_with_numbers() { + let domain = DomainName::new("api2.tracker123.org").unwrap(); + assert_eq!(domain.as_str(), "api2.tracker123.org"); + } + + #[test] + fn it_should_accept_domain_with_underscore() { + // Underscores are technically allowed in some DNS contexts (SRV records) + // We don't reject them since we use minimal validation + let domain = DomainName::new("my_service.example.com").unwrap(); + assert_eq!(domain.as_str(), "my_service.example.com"); + } + + // Invalid domain tests (typo detection) + + #[test] + fn it_should_reject_empty_domain() { + let result = DomainName::new(""); + assert!(matches!(result, Err(DomainNameError::EmptyDomain))); + } + + #[test] + fn it_should_reject_domain_without_tld() { + let result = DomainName::new("localhost"); + assert!(matches!(result, Err(DomainNameError::InvalidFormat { .. }))); + } + + #[test] + fn it_should_reject_domain_with_leading_space() { + let result = DomainName::new(" example.com"); + assert!(matches!(result, Err(DomainNameError::InvalidFormat { .. }))); + } + + #[test] + fn it_should_reject_domain_with_trailing_space() { + let result = DomainName::new("example.com "); + assert!(matches!(result, Err(DomainNameError::InvalidFormat { .. }))); + } + + #[test] + fn it_should_reject_domain_with_space_in_middle() { + let result = DomainName::new("my domain.com"); + assert!(matches!(result, Err(DomainNameError::InvalidFormat { .. }))); + } + + #[test] + fn it_should_reject_domain_starting_with_dot() { + let result = DomainName::new(".example.com"); + assert!(matches!(result, Err(DomainNameError::InvalidFormat { .. }))); + } + + #[test] + fn it_should_reject_domain_ending_with_dot() { + let result = DomainName::new("example.com."); + assert!(matches!(result, Err(DomainNameError::InvalidFormat { .. }))); + } + + #[test] + fn it_should_reject_domain_with_consecutive_dots() { + let result = DomainName::new("example..com"); + assert!(matches!(result, Err(DomainNameError::InvalidFormat { .. }))); + } + + // Utility method tests + + #[test] + fn it_should_extract_tld() { + let domain = DomainName::new("api.tracker.torrust.org").unwrap(); + assert_eq!(domain.tld(), "org"); + } + + #[test] + fn it_should_extract_subdomains() { + let domain = DomainName::new("api.tracker.torrust.org").unwrap(); + assert_eq!(domain.subdomains(), vec!["api", "tracker", "torrust"]); + } + + #[test] + fn it_should_display_domain_correctly() { + let domain = DomainName::new("example.com").unwrap(); + assert_eq!(format!("{domain}"), "example.com"); + } + + // Conversion tests + + #[test] + fn it_should_convert_to_string() { + let domain = DomainName::new("example.com").unwrap(); + let s: String = domain.into(); + assert_eq!(s, "example.com"); + } + + #[test] + fn it_should_convert_from_string() { + let domain = DomainName::try_from("example.com".to_string()).unwrap(); + assert_eq!(domain.as_str(), "example.com"); + } + + // Serialization tests + + #[test] + fn it_should_serialize_to_json() { + let domain = DomainName::new("example.com").unwrap(); + let json = serde_json::to_string(&domain).unwrap(); + assert_eq!(json, "\"example.com\""); + } + + #[test] + fn it_should_deserialize_from_json() { + let domain: DomainName = serde_json::from_str("\"example.com\"").unwrap(); + assert_eq!(domain.as_str(), "example.com"); + } + + #[test] + fn it_should_fail_deserialization_for_invalid_domain() { + let result: Result = serde_json::from_str("\"localhost\""); + assert!(result.is_err()); + } + + // Help text tests + + #[test] + fn it_should_provide_help_for_empty_domain_error() { + let error = DomainNameError::EmptyDomain; + assert!(error.help().contains("Domain name cannot be empty")); + } + + #[test] + fn it_should_provide_help_for_invalid_format_error() { + let error = DomainNameError::InvalidFormat { + domain: "localhost".to_string(), + reason: "no TLD".to_string(), + }; + assert!(error.help().contains("Common mistakes")); + } +} diff --git a/src/shared/email.rs b/src/shared/email.rs new file mode 100644 index 00000000..2ba288b6 --- /dev/null +++ b/src/shared/email.rs @@ -0,0 +1,306 @@ +//! Email Address Type +//! +//! This module provides a strongly-typed email address wrapper using the +//! `email_address` crate for RFC 5321/5322 compliant validation. +//! +//! # Usage +//! +//! ```rust +//! use torrust_tracker_deployer_lib::shared::Email; +//! +//! // Valid email +//! let email = Email::new("admin@example.com").unwrap(); +//! assert_eq!(email.as_str(), "admin@example.com"); +//! +//! // Invalid email returns error +//! let result = Email::new("invalid-email"); +//! assert!(result.is_err()); +//! ``` +//! +//! # Design Notes +//! +//! This type is placed in the `shared` module because: +//! - Email is a fundamental concept used across multiple domains +//! - It provides validation without business-specific logic +//! - Both domain and infrastructure layers may need email validation + +use std::fmt; +use std::str::FromStr; + +use email_address::EmailAddress; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// A validated email address +/// +/// This type wraps the `email_address` crate to provide RFC-compliant +/// email validation. It can be used in domain entities where email +/// addresses are required. +/// +/// # Validation +/// +/// The email address must conform to RFC 5321/5322 standards: +/// - Must have a local part and domain separated by `@` +/// - Local part can contain alphanumeric characters and some special characters +/// - Domain must be a valid domain name or IP address +/// +/// # Examples +/// +/// ```rust +/// use torrust_tracker_deployer_lib::shared::Email; +/// +/// let email = Email::new("user@example.com").unwrap(); +/// println!("Email: {}", email); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +#[serde(try_from = "String", into = "String")] +pub struct Email(String); + +impl Email { + /// Creates a new validated email address + /// + /// # Arguments + /// + /// * `email` - The email address string to validate + /// + /// # Returns + /// + /// * `Ok(Email)` - If the email is valid + /// * `Err(EmailError)` - If the email is invalid + /// + /// # Errors + /// + /// Returns `EmailError::InvalidFormat` if the email doesn't comply with + /// RFC 5321/5322 standards. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::shared::Email; + /// + /// let valid = Email::new("admin@example.com"); + /// assert!(valid.is_ok()); + /// + /// let invalid = Email::new("not-an-email"); + /// assert!(invalid.is_err()); + /// ``` + pub fn new(email: &str) -> Result { + // Use email_address crate for validation + EmailAddress::from_str(email) + .map(|_| Self(email.to_string())) + .map_err(|_| EmailError::InvalidFormat { + email: email.to_string(), + }) + } + + /// Returns the email address as a string slice + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Returns the local part of the email (before the `@`) + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::shared::Email; + /// + /// let email = Email::new("user@example.com").unwrap(); + /// assert_eq!(email.local_part(), "user"); + /// ``` + #[must_use] + pub fn local_part(&self) -> &str { + self.0.split('@').next().unwrap_or("") + } + + /// Returns the domain part of the email (after the `@`) + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::shared::Email; + /// + /// let email = Email::new("user@example.com").unwrap(); + /// assert_eq!(email.domain_part(), "example.com"); + /// ``` + #[must_use] + pub fn domain_part(&self) -> &str { + self.0.split('@').nth(1).unwrap_or("") + } +} + +impl fmt::Display for Email { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TryFrom for Email { + type Error = EmailError; + + fn try_from(value: String) -> Result { + Self::new(&value) + } +} + +impl From for String { + fn from(email: Email) -> Self { + email.0 + } +} + +impl AsRef for Email { + fn as_ref(&self) -> &str { + &self.0 + } +} + +/// Errors that can occur when creating an email address +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum EmailError { + /// The email format is invalid + #[error("invalid email format: '{email}'")] + InvalidFormat { + /// The invalid email string + email: String, + }, +} + +impl EmailError { + /// Returns actionable help for resolving the error + #[must_use] + pub fn help(&self) -> String { + match self { + Self::InvalidFormat { email } => { + format!( + "The email address '{email}' is not valid.\n\n\ + A valid email address must:\n\ + - Have a local part (before @) and domain part (after @)\n\ + - Example: admin@example.com\n\n\ + Please provide a valid email address for Let's Encrypt certificate notifications." + ) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_create_email_when_format_is_valid() { + let email = Email::new("admin@example.com"); + assert!(email.is_ok()); + assert_eq!(email.unwrap().as_str(), "admin@example.com"); + } + + #[test] + fn it_should_accept_email_with_subdomain() { + let email = Email::new("user@mail.example.com"); + assert!(email.is_ok()); + } + + #[test] + fn it_should_accept_email_with_plus_sign() { + let email = Email::new("user+tag@example.com"); + assert!(email.is_ok()); + } + + #[test] + fn it_should_accept_email_with_dots_in_local_part() { + let email = Email::new("first.last@example.com"); + assert!(email.is_ok()); + } + + #[test] + fn it_should_reject_email_without_at_sign() { + let result = Email::new("invalid-email"); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + EmailError::InvalidFormat { .. } + )); + } + + #[test] + fn it_should_reject_email_without_domain() { + let result = Email::new("user@"); + assert!(result.is_err()); + } + + #[test] + fn it_should_reject_email_without_local_part() { + let result = Email::new("@example.com"); + assert!(result.is_err()); + } + + #[test] + fn it_should_reject_empty_email() { + let result = Email::new(""); + assert!(result.is_err()); + } + + #[test] + fn it_should_extract_local_part() { + let email = Email::new("user@example.com").unwrap(); + assert_eq!(email.local_part(), "user"); + } + + #[test] + fn it_should_extract_domain_part() { + let email = Email::new("user@example.com").unwrap(); + assert_eq!(email.domain_part(), "example.com"); + } + + #[test] + fn it_should_display_email_correctly() { + let email = Email::new("admin@example.com").unwrap(); + assert_eq!(format!("{email}"), "admin@example.com"); + } + + #[test] + fn it_should_convert_from_string() { + let result: Result = "user@example.com".to_string().try_into(); + assert!(result.is_ok()); + } + + #[test] + fn it_should_convert_to_string() { + let email = Email::new("user@example.com").unwrap(); + let string: String = email.into(); + assert_eq!(string, "user@example.com"); + } + + #[test] + fn it_should_serialize_to_json() { + let email = Email::new("admin@example.com").unwrap(); + let json = serde_json::to_string(&email).unwrap(); + assert_eq!(json, "\"admin@example.com\""); + } + + #[test] + fn it_should_deserialize_from_json() { + let json = "\"admin@example.com\""; + let email: Email = serde_json::from_str(json).unwrap(); + assert_eq!(email.as_str(), "admin@example.com"); + } + + #[test] + fn it_should_fail_deserialization_for_invalid_email() { + let json = "\"invalid-email\""; + let result: Result = serde_json::from_str(json); + assert!(result.is_err()); + } + + #[test] + fn it_should_provide_help_for_invalid_format_error() { + let error = EmailError::InvalidFormat { + email: "bad".to_string(), + }; + let help = error.help(); + assert!(help.contains("not valid")); + assert!(help.contains("admin@example.com")); + } +} diff --git a/src/shared/mod.rs b/src/shared/mod.rs index b0aac034..25405d4f 100644 --- a/src/shared/mod.rs +++ b/src/shared/mod.rs @@ -6,6 +6,8 @@ pub mod clock; pub mod command; +pub mod domain_name; +pub mod email; pub mod error; pub mod secrets; pub mod username; @@ -13,6 +15,8 @@ pub mod username; // Re-export commonly used types for convenience pub use clock::{Clock, SystemClock}; pub use command::{CommandError, CommandExecutor, CommandResult}; +pub use domain_name::{DomainName, DomainNameError}; +pub use email::{Email, EmailError}; pub use error::{ErrorKind, Traceable}; pub use secrets::{ApiToken, ExposeSecret, Password, PlainApiToken, PlainPassword}; pub use username::{Username, UsernameError}; diff --git a/src/testing/e2e/tasks/run_create_command.rs b/src/testing/e2e/tasks/run_create_command.rs index ea106624..de3b6d72 100644 --- a/src/testing/e2e/tasks/run_create_command.rs +++ b/src/testing/e2e/tasks/run_create_command.rs @@ -102,6 +102,7 @@ pub fn run_create_command( TrackerSection::default(), None, None, + None, // HTTPS configuration ); // Execute the command diff --git a/src/testing/e2e/tasks/run_run_validation.rs b/src/testing/e2e/tasks/run_run_validation.rs index 3bae6fdd..d914c03d 100644 --- a/src/testing/e2e/tasks/run_run_validation.rs +++ b/src/testing/e2e/tasks/run_run_validation.rs @@ -58,7 +58,7 @@ use tracing::info; use crate::adapters::ssh::SshConfig; use crate::adapters::ssh::SshCredentials; -use crate::infrastructure::external_validators::RunningServicesValidator; +use crate::infrastructure::external_validators::{RunningServicesValidator, ServiceEndpoint}; use crate::infrastructure::remote_actions::validators::{GrafanaValidator, PrometheusValidator}; use crate::infrastructure::remote_actions::{RemoteAction, RemoteActionError}; @@ -227,8 +227,8 @@ For more information, see docs/e2e-testing/." /// /// * `socket_addr` - Socket address where the target instance can be reached /// * `ssh_credentials` - SSH credentials for connecting to the instance -/// * `tracker_api_port` - Port for the tracker API health endpoint -/// * `http_tracker_ports` - Ports for HTTP tracker health endpoints (can be empty) +/// * `tracker_api_endpoint` - Endpoint for the tracker API health check +/// * `http_tracker_endpoints` - Endpoints for HTTP tracker health checks /// * `services` - Optional service validation configuration (defaults to no optional services) /// /// # Returns @@ -245,8 +245,8 @@ For more information, see docs/e2e-testing/." pub async fn run_run_validation( socket_addr: SocketAddr, ssh_credentials: &SshCredentials, - tracker_api_port: u16, - http_tracker_ports: Vec, + tracker_api_endpoint: ServiceEndpoint, + http_tracker_endpoints: Vec, services: Option, ) -> Result<(), RunValidationError> { let services = services.unwrap_or_default(); @@ -254,8 +254,8 @@ pub async fn run_run_validation( info!( socket_addr = %socket_addr, ssh_user = %ssh_credentials.ssh_username, - tracker_api_port = tracker_api_port, - http_tracker_ports = ?http_tracker_ports, + api_uses_tls = tracker_api_endpoint.uses_tls(), + http_tracker_count = http_tracker_endpoints.len(), validate_prometheus = services.prometheus, validate_grafana = services.grafana, "Running 'run' command validation tests" @@ -264,14 +264,7 @@ pub async fn run_run_validation( let ip_addr = socket_addr.ip(); // Validate externally accessible services (tracker API, HTTP tracker) - validate_external_services( - ip_addr, - ssh_credentials, - socket_addr.port(), - tracker_api_port, - http_tracker_ports, - ) - .await?; + validate_external_services(tracker_api_endpoint, http_tracker_endpoints).await?; // Optionally validate Prometheus is running and accessible if services.prometheus { @@ -291,32 +284,41 @@ pub async fn run_run_validation( Ok(()) } -/// Validate externally accessible services on a configured instance +/// Validate externally accessible services (tracker API, HTTP tracker) /// -/// This function validates services that are exposed outside the VM and accessible -/// without SSH (e.g., tracker API, HTTP tracker). These services have firewall rules -/// allowing external access. It checks the status of services started by the `run` -/// command and verifies they are operational by connecting from outside the VM. +/// This function validates that the tracker API and HTTP tracker services +/// are running and responding to health check requests. /// -/// # Note +/// # Arguments +/// +/// * `tracker_api_endpoint` - Endpoint for the tracker API health check (includes server IP) +/// * `http_tracker_endpoints` - Endpoints for HTTP tracker health checks (include server IP) +/// +/// # Returns +/// +/// Returns `Ok(())` when all services are validated successfully. +/// +/// # Errors /// -/// Internal services like Prometheus (not exposed externally) are validated separately -/// via SSH in `validate_prometheus()`. +/// Returns an error if any service is not running or unhealthy. async fn validate_external_services( - ip_addr: IpAddr, - ssh_credentials: &SshCredentials, - port: u16, - tracker_api_port: u16, - http_tracker_ports: Vec, + tracker_api_endpoint: ServiceEndpoint, + http_tracker_endpoints: Vec, ) -> Result<(), RunValidationError> { - info!("Validating externally accessible services (tracker API, HTTP tracker)"); + info!( + api_uses_tls = tracker_api_endpoint.uses_tls(), + http_tracker_count = http_tracker_endpoints.len(), + server_ip = %tracker_api_endpoint.server_ip(), + "Validating externally accessible services (tracker API, HTTP tracker)" + ); - let ssh_config = SshConfig::new(ssh_credentials.clone(), SocketAddr::new(ip_addr, port)); + // Get server IP from endpoint for trait compatibility + let server_ip = tracker_api_endpoint.server_ip(); let services_validator = - RunningServicesValidator::new(ssh_config, tracker_api_port, http_tracker_ports); + RunningServicesValidator::new(tracker_api_endpoint, http_tracker_endpoints); services_validator - .execute(&ip_addr) + .execute(&server_ip) .await .map_err(|source| RunValidationError::RunningServicesValidationFailed { source })?; diff --git a/templates/ansible/deploy-caddy-config.yml b/templates/ansible/deploy-caddy-config.yml new file mode 100644 index 00000000..3f1263b8 --- /dev/null +++ b/templates/ansible/deploy-caddy-config.yml @@ -0,0 +1,58 @@ +--- +# Deploy Caddy Configuration +# +# This playbook deploys the Caddyfile configuration file to the remote host. +# The configuration file is copied from the local build directory to the Caddy +# configuration directory on the remote instance. +# +# Requirements: +# - Build directory must contain rendered Caddyfile +# +# Variables: +# - ansible_user: The SSH user for the remote host (set automatically) +# +# Storage Directories: +# - /opt/torrust/storage/caddy/etc/ - Caddyfile configuration +# - /opt/torrust/storage/caddy/data/ - Caddy data (certificates, etc.) +# - /opt/torrust/storage/caddy/config/ - Caddy config state + +- name: Deploy Caddy configuration + hosts: all + become: true + + tasks: + - name: Create Caddy storage directories + ansible.builtin.file: + path: "{{ item }}" + state: directory + mode: "0755" + owner: "{{ ansible_user }}" + group: "{{ ansible_user }}" + loop: + - /opt/torrust/storage/caddy + - /opt/torrust/storage/caddy/etc + - /opt/torrust/storage/caddy/data + - /opt/torrust/storage/caddy/config + + - name: Copy Caddyfile to VM + ansible.builtin.copy: + src: "{{ playbook_dir }}/../caddy/Caddyfile" + # Note: This is the host path. Inside the container, it's mounted to /etc/caddy/Caddyfile + dest: /opt/torrust/storage/caddy/etc/Caddyfile + mode: "0644" + owner: "{{ ansible_user }}" + group: "{{ ansible_user }}" + + - name: Verify Caddy configuration file exists + ansible.builtin.stat: + path: /opt/torrust/storage/caddy/etc/Caddyfile + register: caddy_config + + - name: Assert Caddy configuration was deployed + ansible.builtin.assert: + that: + - caddy_config.stat.exists + - caddy_config.stat.isreg + - caddy_config.stat.pw_name == ansible_user + fail_msg: "Caddy configuration file was not deployed properly" + success_msg: "Caddy configuration deployed successfully" diff --git a/templates/caddy/Caddyfile.tera b/templates/caddy/Caddyfile.tera new file mode 100644 index 00000000..c0aee19a --- /dev/null +++ b/templates/caddy/Caddyfile.tera @@ -0,0 +1,55 @@ +# Caddyfile for Torrust Tracker - Automatic HTTPS with Let's Encrypt +# IMPORTANT: Caddy requires TABS for indentation, not spaces. +# +# This template generates a Caddyfile based on which services have TLS configured. +# Services without TLS configuration will not have entries here (they remain HTTP-only). +# +# Header Forwarding for HTTP Trackers: +# Caddy sets X-Forwarded-For, X-Forwarded-Proto, and X-Forwarded-Host by default. +# We explicitly set X-Forwarded-For for HTTP trackers to ensure this behavior is +# maintained even if Caddy's defaults change in future versions. The tracker requires +# X-Forwarded-For when running behind a reverse proxy (on_reverse_proxy: true) to +# correctly identify the original client IP address for peer tracking. + +# Global options +{ + # Email for Let's Encrypt notifications + email {{ admin_email }} +{%- if use_staging %} + # Use Let's Encrypt staging environment (for testing, avoids rate limits) + # WARNING: Staging certificates will show browser warnings (not trusted) + acme_ca https://acme-staging-v02.api.letsencrypt.org/directory +{%- endif %} +} +{%- if tracker_api %} + +# Tracker REST API +{{ tracker_api.domain }} { + reverse_proxy tracker:{{ tracker_api.port }} +} +{%- endif %} +{%- for http_tracker in http_trackers %} + +# HTTP Tracker {{ loop.index }} +{{ http_tracker.domain }} { + reverse_proxy tracker:{{ http_tracker.port }} { + # Explicitly forward client IP - critical for peer tracking accuracy + # The tracker uses this to record the correct peer IP in the swarm + header_up X-Forwarded-For {remote_host} + } +} +{%- endfor %} +{%- if health_check_api %} + +# Health Check API +{{ health_check_api.domain }} { + reverse_proxy tracker:{{ health_check_api.port }} +} +{%- endif %} +{%- if grafana %} + +# Grafana UI with WebSocket support +{{ grafana.domain }} { + reverse_proxy grafana:3000 +} +{%- endif %} diff --git a/templates/docker-compose/docker-compose.yml.tera b/templates/docker-compose/docker-compose.yml.tera index 483b91ce..27471263 100644 --- a/templates/docker-compose/docker-compose.yml.tera +++ b/templates/docker-compose/docker-compose.yml.tera @@ -19,64 +19,99 @@ # # See ADR: docs/decisions/environment-variable-injection-in-docker-compose.md +# Common service defaults (YAML anchor for DRY configuration) +x-defaults: &defaults + tty: true + restart: unless-stopped + logging: + options: + max-size: "10m" + max-file: "10" + services: +{%- if caddy %} + # Caddy reverse proxy for automatic HTTPS with Let's Encrypt + # Placed first as it's the entry point for HTTPS traffic + caddy: + <<: *defaults + image: caddy:2.10 + container_name: caddy + # NOTE: No UFW firewall rule needed for these ports! + # Docker-published ports bypass iptables/UFW rules entirely. + # The configure-firewall.yml playbook closes all ports except SSH, + # but Docker creates its own iptables chains that take precedence. + # See: docs/user-guide/security.md for the full security model. + ports: + - "80:80" # HTTP (ACME HTTP-01 challenge) + - "443:443" # HTTPS + - "443:443/udp" # HTTP/3 (QUIC) + volumes: + - ./storage/caddy/etc/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data # TLS certificates (MUST persist!) + - caddy_config_vol:/config + networks: +{%- for network in caddy.networks %} + - {{ network }} +{%- endfor %} + healthcheck: + test: ["CMD", "caddy", "validate", "--config", "/etc/caddy/Caddyfile"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s +{%- endif %} + tracker: + <<: *defaults # TODO: Pin to stable v4.0.0 when released (currently using develop tag) # Tracking issue: https://github.com/torrust/torrust-tracker-deployer/issues/TBD # Rationale: The develop tag is mutable and introduces deployment non-reproducibility. # Pinning to a stable release ensures predictable deployments and easier rollback. image: torrust/tracker:develop container_name: tracker - tty: true - restart: unless-stopped -{% if database.driver == "mysql" %} +{%- if mysql %} depends_on: mysql: condition: service_healthy -{% endif %} +{%- endif %} environment: - USER_ID=1000 - TORRUST_TRACKER_CONFIG_OVERRIDE_CORE__DATABASE__DRIVER=${TORRUST_TRACKER_CONFIG_OVERRIDE_CORE__DATABASE__DRIVER} - TORRUST_TRACKER_CONFIG_TOML_PATH=${TORRUST_TRACKER_CONFIG_TOML_PATH} - TORRUST_TRACKER_CONFIG_OVERRIDE_HTTP_API__ACCESS_TOKENS__ADMIN=${TORRUST_TRACKER_CONFIG_OVERRIDE_HTTP_API__ACCESS_TOKENS__ADMIN} networks: -{% if prometheus_config %} - - metrics_network # Prometheus scrapes metrics from tracker -{% endif %} -{% if database.driver == "mysql" %} - - database_network # Tracker connects to MySQL -{% endif %} +{%- for network in tracker.networks %} + - {{ network }} +{%- endfor %} +{%- if tracker.needs_ports_section %} ports: - # UDP Tracker Ports (dynamically configured) -{%- for port in ports.udp_tracker_ports %} + # UDP Tracker Ports (always exposed - UDP doesn't use TLS) +{%- for port in tracker.udp_tracker_ports %} - {{ port }}:{{ port }}/udp {%- endfor %} - # HTTP Tracker Ports (dynamically configured) -{%- for port in ports.http_tracker_ports %} + # HTTP Tracker Ports (only ports without TLS - Caddy handles HTTPS) +{%- for port in tracker.http_tracker_ports_without_tls %} - {{ port }}:{{ port }} {%- endfor %} +{%- if not tracker.http_api_has_tls %} # HTTP API Port (dynamically configured) - - {{ ports.http_api_port }}:{{ ports.http_api_port }} + - {{ tracker.http_api_port }}:{{ tracker.http_api_port }} +{%- endif %} +{%- endif %} volumes: - ./storage/tracker/lib:/var/lib/torrust/tracker:Z - ./storage/tracker/log:/var/log/torrust/tracker:Z - ./storage/tracker/etc:/etc/torrust/tracker:Z - logging: - options: - max-size: "10m" - max-file: "10" +{%- if prometheus %} -{% if prometheus_config %} prometheus: + <<: *defaults image: prom/prometheus:v3.5.0 container_name: prometheus - tty: true - restart: unless-stopped networks: - - metrics_network # Scrapes metrics from tracker -{% if grafana_config %} - - visualization_network # Grafana queries Prometheus -{% endif %} +{%- for network in prometheus.networks %} + - {{ network }} +{%- endfor %} ports: - "127.0.0.1:9090:9090" # Localhost only - not exposed to external network # Grafana accesses Prometheus via Docker network: http://prometheus:9090 @@ -89,24 +124,23 @@ services: timeout: 5s retries: 5 start_period: 10s - logging: - options: - max-size: "10m" - max-file: "10" depends_on: - tracker -{% endif %} +{%- endif %} +{%- if grafana %} -{% if grafana_config %} grafana: + <<: *defaults image: grafana/grafana:12.3.1 container_name: grafana - tty: true - restart: unless-stopped networks: - - visualization_network # Queries Prometheus data source +{%- for network in grafana.networks %} + - {{ network }} +{%- endfor %} +{%- if not grafana.has_tls %} ports: - "3100:3000" +{%- endif %} environment: - GF_SECURITY_ADMIN_USER=${GF_SECURITY_ADMIN_USER} - GF_SECURITY_ADMIN_PASSWORD=${GF_SECURITY_ADMIN_PASSWORD} @@ -119,31 +153,29 @@ services: timeout: 5s retries: 5 start_period: 30s - logging: - options: - max-size: "10m" - max-file: "10" depends_on: -{% if prometheus_config %} +{%- if prometheus %} prometheus: condition: service_healthy -{% else %} +{%- else %} - tracker -{% endif %} -{% endif %} +{%- endif %} +{%- endif %} +{%- if mysql %} -{% if database.driver == "mysql" %} mysql: + <<: *defaults image: mysql:8.4 container_name: mysql - restart: unless-stopped environment: - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} - MYSQL_DATABASE=${MYSQL_DATABASE} - MYSQL_USER=${MYSQL_USER} - MYSQL_PASSWORD=${MYSQL_PASSWORD} networks: - - database_network # Only accessible by tracker +{%- for network in mysql.networks %} + - {{ network }} +{%- endfor %} ports: - "3306:3306" volumes: @@ -155,11 +187,7 @@ services: timeout: 5s retries: 5 start_period: 30s - logging: - options: - max-size: "10m" - max-file: "10" -{% endif %} +{%- endif %} # SECURITY: Three-Network Segmentation (Defense in Depth) # ========================================================= @@ -189,27 +217,38 @@ services: # See Analysis: docs/analysis/security/docker-network-segmentation-analysis.md networks: -{% if database.driver == "mysql" %} +{%- if mysql %} database_network: driver: bridge -{% endif %} -{% if prometheus_config %} +{%- endif %} +{%- if prometheus %} metrics_network: driver: bridge -{% endif %} -{% if grafana_config %} +{%- endif %} +{%- if grafana %} visualization_network: driver: bridge -{% endif %} +{%- endif %} +{%- if caddy %} + proxy_network: + driver: bridge +{%- endif %} + -{% if database.driver == "mysql" or grafana_config %} +{%- if mysql or grafana or caddy %} volumes: -{%- if database.driver == "mysql" %} +{%- if mysql %} mysql_data: driver: local {%- endif %} -{%- if grafana_config %} +{%- if grafana %} grafana_data: driver: local {%- endif %} +{%- if caddy %} + caddy_data: + driver: local + caddy_config_vol: + driver: local +{%- endif %} {% endif %} diff --git a/templates/tracker/tracker.toml.tera b/templates/tracker/tracker.toml.tera index 6fda2056..1a873c09 100644 --- a/templates/tracker/tracker.toml.tera +++ b/templates/tracker/tracker.toml.tera @@ -18,7 +18,10 @@ interval = 300 interval_min = 300 [core.net] -on_reverse_proxy = true +# Whether the tracker expects X-Forwarded-For headers from a reverse proxy. +# Set to true when ANY HTTP tracker uses Caddy TLS termination (use_tls_proxy: true). +# Note: This is a global setting - see docs/external-issues/tracker/on-reverse-proxy-global-setting.md +on_reverse_proxy = {{ on_reverse_proxy }} [core.database] driver = "{{ database_driver }}" @@ -44,3 +47,6 @@ bind_address = "{{ http_tracker.bind_address }}" {% endfor %} [http_api] bind_address = "{{ http_api_bind_address }}" + +[health_check_api] +bind_address = "{{ health_check_api_bind_address }}"