From 900aeacb5e010a3d4e0e822b5a1b656315175810 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 13 Jan 2026 17:32:56 +0000 Subject: [PATCH 01/36] docs: [#272] clarify context data preparation pattern for Tera templates - Add Context Data Preparation Pattern section to template architecture docs - Explain why templates receive pre-processed data instead of raw domain objects - Document port extraction example: handled in Rust, not Tera filters - Update issue spec to use port-as-integer pattern in Caddyfile template - Remove extract_port Tera filter from implementation plan (not needed) This follows existing patterns (e.g., PrometheusContext receives api_port: u16) and maintains consistency with the codebase's approach to template rendering. --- .../templates/template-system-architecture.md | 137 ++++++++++++++++++ .../272-add-https-support-with-caddy.md | 67 +++++---- 2 files changed, 170 insertions(+), 34 deletions(-) diff --git a/docs/contributing/templates/template-system-architecture.md b/docs/contributing/templates/template-system-architecture.md index 27d35630..fbc90419 100644 --- a/docs/contributing/templates/template-system-architecture.md +++ b/docs/contributing/templates/template-system-architecture.md @@ -245,6 +245,143 @@ 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` + ## ⚠️ Important Behaviors ### Template Persistence diff --git a/docs/issues/272-add-https-support-with-caddy.md b/docs/issues/272-add-https-support-with-caddy.md index 77fe3c20..d0268377 100644 --- a/docs/issues/272-add-https-support-with-caddy.md +++ b/docs/issues/272-add-https-support-with-caddy.md @@ -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): @@ -646,20 +644,21 @@ Add link to HTTPS setup guide. ### 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) +- [ ] Create `CaddyProjectGenerator` following Project Generator pattern +- [ ] Create `CaddyContext` with pre-processed data (following [Context Data Preparation Pattern](../contributing/templates/template-system-architecture.md#-context-data-preparation-pattern)): + - [ ] `admin_email: String` - extracted from config + - [ ] `use_staging: bool` - extracted from config + - [ ] `http_api_service: Option` - only if TLS configured, with pre-extracted port + - [ ] `http_tracker_services: Vec` - only TLS-enabled trackers, with pre-extracted ports + - [ ] `grafana_service: Option` - only if TLS configured, with pre-extracted port +- [ ] Create `CaddyService` struct with `domain: String` and `port: u16` +- [ ] Implement port extraction in Rust (from `SocketAddr`) when building context - [ ] Handle conditional rendering in templates: - - [ ] `needs_caddy` variable checks if any service has `tls.is_some()` + - [ ] `needs_caddy` variable checks if any service list is non-empty - [ ] 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 + - [ ] `{% if http_api_service %}` for API service block in Caddyfile + - [ ] `{% for service in http_tracker_services %}` for tracker iteration in Caddyfile + - [ ] `{% if grafana_service %}` for Grafana service block in Caddyfile - [ ] Update `ReleaseCommand` to include Caddy template generation - [ ] Test template generation with various scenarios: - [ ] All services HTTPS From df194019a43f7b8308f99fa394ad2be64a8385da Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 13 Jan 2026 17:49:53 +0000 Subject: [PATCH 02/36] feat: [#272] add Caddy templates for HTTPS support - Add Caddyfile.tera template with conditional service blocks - Update docker-compose.yml.tera with Caddy service configuration - Add proxy_network and caddy volumes - Add caddy.md documentation for template usage - Update template-system-architecture.md with directory organization rule - Update issue progress tracking --- docs/contributing/templates/caddy.md | 133 ++++++++++++++++++ .../templates/template-system-architecture.md | 35 +++++ .../272-add-https-support-with-caddy.md | 6 +- templates/caddy/Caddyfile.tera | 38 +++++ .../docker-compose/docker-compose.yml.tera | 48 ++++++- 5 files changed, 256 insertions(+), 4 deletions(-) create mode 100644 docs/contributing/templates/caddy.md create mode 100644 templates/caddy/Caddyfile.tera 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 fbc90419..184b2237 100644 --- a/docs/contributing/templates/template-system-architecture.md +++ b/docs/contributing/templates/template-system-architecture.md @@ -382,6 +382,41 @@ impl CaddyContext { 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/issues/272-add-https-support-with-caddy.md b/docs/issues/272-add-https-support-with-caddy.md index d0268377..ff7c42f6 100644 --- a/docs/issues/272-add-https-support-with-caddy.md +++ b/docs/issues/272-add-https-support-with-caddy.md @@ -615,9 +615,9 @@ 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 +- [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 - [ ] Register Caddyfile in appropriate ProjectGenerator (likely new `CaddyProjectGenerator`) - [ ] Test template rendering manually with sample data diff --git a/templates/caddy/Caddyfile.tera b/templates/caddy/Caddyfile.tera new file mode 100644 index 00000000..b3a66695 --- /dev/null +++ b/templates/caddy/Caddyfile.tera @@ -0,0 +1,38 @@ +# 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). + +# 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 }} +} +{% endfor %} +{% 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..afc0f978 100644 --- a/templates/docker-compose/docker-compose.yml.tera +++ b/templates/docker-compose/docker-compose.yml.tera @@ -20,6 +20,36 @@ # See ADR: docs/decisions/environment-variable-injection-in-docker-compose.md services: +{% if caddy_config %} + # Caddy reverse proxy for automatic HTTPS with Let's Encrypt + # Placed first as it's the entry point for HTTPS traffic + caddy: + image: caddy:2.10 + container_name: caddy + tty: true + restart: unless-stopped + 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:/config + networks: + - proxy_network # Connects to services that need TLS termination + healthcheck: + test: ["CMD", "caddy", "validate", "--config", "/etc/caddy/Caddyfile"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + logging: + options: + max-size: "10m" + max-file: "10" + +{% endif %} tracker: # TODO: Pin to stable v4.0.0 when released (currently using develop tag) # Tracking issue: https://github.com/torrust/torrust-tracker-deployer/issues/TBD @@ -45,6 +75,9 @@ services: {% endif %} {% if database.driver == "mysql" %} - database_network # Tracker connects to MySQL +{% endif %} +{% if caddy_config %} + - proxy_network # Caddy reverse proxies to tracker {% endif %} ports: # UDP Tracker Ports (dynamically configured) @@ -105,6 +138,9 @@ services: restart: unless-stopped networks: - visualization_network # Queries Prometheus data source +{% if caddy_config %} + - proxy_network # Caddy reverse proxies to Grafana +{% endif %} ports: - "3100:3000" environment: @@ -201,8 +237,12 @@ networks: visualization_network: driver: bridge {% endif %} +{% if caddy_config %} + proxy_network: + driver: bridge +{% endif %} -{% if database.driver == "mysql" or grafana_config %} +{% if database.driver == "mysql" or grafana_config or caddy_config %} volumes: {%- if database.driver == "mysql" %} mysql_data: @@ -212,4 +252,10 @@ volumes: grafana_data: driver: local {%- endif %} +{%- if caddy_config %} + caddy_data: + driver: local + caddy_config: + driver: local +{%- endif %} {% endif %} From 3bbaea19409b9a6884c60aa756f099a1f38119f7 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 13 Jan 2026 18:11:04 +0000 Subject: [PATCH 03/36] feat: [#272] add Caddy template rendering infrastructure - Create CaddyProjectGenerator following Project Generator pattern - Create CaddyContext with pre-processed data for template rendering - Create CaddyService struct with domain and port fields - Create CaddyfileRenderer for Caddyfile template processing - Add 14 unit tests covering all HTTPS scenarios - Update issue progress tracking (Phase 3 mostly complete) --- .../272-add-https-support-with-caddy.md | 44 +-- src/infrastructure/templating/caddy/mod.rs | 18 ++ .../templating/caddy/template/mod.rs | 10 + .../caddy/template/renderer/caddyfile.rs | 277 ++++++++++++++++++ .../templating/caddy/template/renderer/mod.rs | 9 + .../template/renderer/project_generator.rs | 276 +++++++++++++++++ .../template/wrapper/caddyfile/context.rs | 259 ++++++++++++++++ .../caddy/template/wrapper/caddyfile/mod.rs | 7 + .../templating/caddy/template/wrapper/mod.rs | 7 + src/infrastructure/templating/mod.rs | 3 + 10 files changed, 888 insertions(+), 22 deletions(-) create mode 100644 src/infrastructure/templating/caddy/mod.rs create mode 100644 src/infrastructure/templating/caddy/template/mod.rs create mode 100644 src/infrastructure/templating/caddy/template/renderer/caddyfile.rs create mode 100644 src/infrastructure/templating/caddy/template/renderer/mod.rs create mode 100644 src/infrastructure/templating/caddy/template/renderer/project_generator.rs create mode 100644 src/infrastructure/templating/caddy/template/wrapper/caddyfile/context.rs create mode 100644 src/infrastructure/templating/caddy/template/wrapper/caddyfile/mod.rs create mode 100644 src/infrastructure/templating/caddy/template/wrapper/mod.rs diff --git a/docs/issues/272-add-https-support-with-caddy.md b/docs/issues/272-add-https-support-with-caddy.md index ff7c42f6..5a623702 100644 --- a/docs/issues/272-add-https-support-with-caddy.md +++ b/docs/issues/272-add-https-support-with-caddy.md @@ -618,8 +618,8 @@ Add link to HTTPS setup guide. - [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 -- [ ] Register Caddyfile in appropriate ProjectGenerator (likely new `CaddyProjectGenerator`) -- [ ] Test template rendering manually with sample data +- [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) @@ -644,27 +644,27 @@ Add link to HTTPS setup guide. ### Phase 3: Template Rendering Integration (3-4 hours) -- [ ] Create `CaddyProjectGenerator` following Project Generator pattern -- [ ] Create `CaddyContext` with pre-processed data (following [Context Data Preparation Pattern](../contributing/templates/template-system-architecture.md#-context-data-preparation-pattern)): - - [ ] `admin_email: String` - extracted from config - - [ ] `use_staging: bool` - extracted from config - - [ ] `http_api_service: Option` - only if TLS configured, with pre-extracted port - - [ ] `http_tracker_services: Vec` - only TLS-enabled trackers, with pre-extracted ports - - [ ] `grafana_service: Option` - only if TLS configured, with pre-extracted port -- [ ] Create `CaddyService` struct with `domain: String` and `port: u16` -- [ ] Implement port extraction in Rust (from `SocketAddr`) when building context -- [ ] Handle conditional rendering in templates: - - [ ] `needs_caddy` variable checks if any service list is non-empty - - [ ] Only include Caddy service in docker-compose if `needs_caddy` is true - - [ ] `{% if http_api_service %}` for API service block in Caddyfile - - [ ] `{% for service in http_tracker_services %}` for tracker iteration in Caddyfile - - [ ] `{% if grafana_service %}` for Grafana service block in Caddyfile +- [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 - [ ] 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] 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) 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..3eb6a72c --- /dev/null +++ b/src/infrastructure/templating/caddy/template/wrapper/caddyfile/context.rs @@ -0,0 +1,259 @@ +//! 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)), +/// }; +/// ``` +/// +/// # 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 configured + /// + /// Contains only trackers that have `tls` configuration. + /// Trackers without TLS are served directly over HTTP, not through Caddy. + pub http_trackers: Vec, + + /// 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(), + 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 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.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 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.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/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; From 7882917019404c612df49b3f95039d619e4ecb6c Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 13 Jan 2026 22:04:21 +0000 Subject: [PATCH 04/36] feat: [#272] add HTTPS support with Caddy for all HTTP services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement Caddy reverse proxy with TLS termination for automatic HTTPS: Phase 1 - Template Creation: - Create Caddyfile.tera template with conditional service blocks - Create caddy.md documentation for template variables - Update docker-compose.yml.tera with Caddy service block - Register templates in CaddyProjectGenerator with 14 unit tests Phase 2 - Configuration DTOs: - Add HttpsSection DTO with admin_email and use_staging fields - Add TlsSection DTO with domain field for service-specific TLS - Extend HttpApiSection, HttpTrackerSection, GrafanaSection with optional tls - Implement validation (has_any_tls_configured, https/tls consistency) - Add Email type in src/shared/email.rs for email format validation - Add DomainName type in src/shared/domain_name.rs for domain validation Phase 3 - Template Rendering Integration: - Create RenderCaddyTemplatesStep for template rendering - Create DeployCaddyConfigStep for Ansible deployment - Create deploy-caddy-config.yml Ansible playbook - Add RenderCaddyTemplates and DeployCaddyConfigToRemote to ReleaseStep enum - Integrate CaddyContext into Docker Compose template rendering - Add CaddyConfigDeployment error variant with actionable help text Manual E2E testing verified: - HTTPS endpoints working for API, Grafana, and HTTP trackers - HTTPβ†’HTTPS redirect (308 Permanent Redirect) - HTTP/2 and HTTP/3 enabled - Caddy Local CA for .local domains Work in progress - remaining phases: - Phase 4: Security workflow updates - Phase 5: Documentation - Phase 6: Automated E2E tests - Phase 7: Schema generation - Phase 8: ADR creation --- Cargo.toml | 1 + .../272-add-https-support-with-caddy.md | 129 +++-- project-words.txt | 9 +- .../create/config/environment_config.rs | 361 ++++++++++++++ .../command_handlers/create/config/errors.rs | 141 ++++++ .../command_handlers/create/config/grafana.rs | 51 +- .../command_handlers/create/config/https.rs | 378 ++++++++++++++ .../command_handlers/create/config/mod.rs | 4 +- .../create/config/tracker/http_api_section.rs | 32 +- .../config/tracker/http_tracker_section.rs | 33 +- .../create/config/tracker/tracker_section.rs | 14 + .../command_handlers/create/handler.rs | 4 + .../command_handlers/create/mod.rs | 1 + .../command_handlers/create/tests/builders.rs | 1 + .../create/tests/integration.rs | 2 + .../command_handlers/release/errors.rs | 39 +- .../command_handlers/release/handler.rs | 113 ++++- .../steps/application/deploy_caddy_config.rs | 116 +++++ src/application/steps/application/mod.rs | 2 + .../steps/rendering/caddy_templates.rs | 238 +++++++++ .../rendering/docker_compose_templates.rs | 47 ++ src/application/steps/rendering/mod.rs | 2 + src/domain/environment/context.rs | 4 +- src/domain/environment/mod.rs | 5 +- .../environment/state/release_failed.rs | 6 + src/domain/environment/testing.rs | 1 + src/domain/environment/user_inputs.rs | 15 +- src/domain/grafana/config.rs | 36 ++ src/domain/https/config.rs | 179 +++++++ src/domain/https/mod.rs | 17 + src/domain/mod.rs | 2 + src/domain/tls/config.rs | 97 ++++ src/domain/tls/mod.rs | 18 + src/domain/tracker/config/http.rs | 11 + src/domain/tracker/config/http_api.rs | 10 + src/domain/tracker/config/mod.rs | 58 ++- src/domain/tracker/mod.rs | 3 +- .../template/renderer/project_generator.rs | 3 +- .../template/wrappers/variables/context.rs | 5 + .../docker_compose/context/builder.rs | 18 + .../wrappers/docker_compose/context/mod.rs | 13 + .../template/renderer/project_generator.rs | 1 + .../template/renderer/project_generator.rs | 4 + .../wrapper/tracker_config/context.rs | 7 +- src/shared/domain_name.rs | 463 ++++++++++++++++++ src/shared/email.rs | 306 ++++++++++++ src/shared/mod.rs | 4 + src/testing/e2e/tasks/run_create_command.rs | 1 + templates/ansible/deploy-caddy-config.yml | 58 +++ templates/caddy/Caddyfile.tera | 17 +- 50 files changed, 3009 insertions(+), 71 deletions(-) create mode 100644 src/application/command_handlers/create/config/https.rs create mode 100644 src/application/steps/application/deploy_caddy_config.rs create mode 100644 src/application/steps/rendering/caddy_templates.rs create mode 100644 src/domain/https/config.rs create mode 100644 src/domain/https/mod.rs create mode 100644 src/domain/tls/config.rs create mode 100644 src/domain/tls/mod.rs create mode 100644 src/shared/domain_name.rs create mode 100644 src/shared/email.rs create mode 100644 templates/ansible/deploy-caddy-config.yml 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/issues/272-add-https-support-with-caddy.md b/docs/issues/272-add-https-support-with-caddy.md index 5a623702..a60a5baa 100644 --- a/docs/issues/272-add-https-support-with-caddy.md +++ b/docs/issues/272-add-https-support-with-caddy.md @@ -19,10 +19,10 @@ 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 +- [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 - [ ] Implement security scanning for Caddy in CI/CD - [ ] Document HTTPS setup in user guide - [ ] Add E2E tests for HTTPS functionality @@ -623,24 +623,24 @@ Add link to HTTPS setup guide. ### 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) @@ -659,7 +659,15 @@ Add link to HTTPS setup guide. - [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 -- [ ] Update `ReleaseCommand` to include Caddy template generation +- [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 @@ -722,27 +730,76 @@ 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) - [ ] Compare with production templates to ensure consistency - [ ] Document manual test procedure in `docs/e2e-testing/manual-https-testing.md` +**Manual Test Results** (2026-01-13): + +| 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 | + +**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 | + ### Phase 7: Schema Generation (30 minutes) - [ ] Regenerate JSON schema from Rust DTOs: diff --git a/project-words.txt b/project-words.txt index 64dfc31d..bed92b2a 100644 --- a/project-words.txt +++ b/project-words.txt @@ -28,8 +28,6 @@ Hillsboro Hostnames LUKS Liskov -smallstep -letsencrypt MAAACBA MLKEM MVVM @@ -155,6 +153,7 @@ hexdump hotfixes htdocs hugepages +idna impls incompletei intervali @@ -170,6 +169,7 @@ kutca larstobi leecher leechers +letsencrypt libc libldap libpam @@ -243,6 +243,7 @@ println procps promtool publickey +publicsuffix pytest readlink realpath @@ -255,6 +256,7 @@ resolv rgba rootpass rpcinterface +rsplit rstest runbooks runcmd @@ -273,6 +275,7 @@ selectattr serde serverurl shellcheck +smallstep smorimoto spki spΓ«cial @@ -324,6 +327,7 @@ unergonomic unittests unrepresentable unsubscription +userexample usermod useroutput userpass @@ -338,6 +342,7 @@ vulns webservers writeln wrongpassword +yourdomain youruser zeroize Γ‰mojis diff --git a/src/application/command_handlers/create/config/environment_config.rs b/src/application/command_handlers/create/config/environment_config.rs index 0883e9cb..036ce868 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.tls.is_some() { + return true; + } + + // Check HTTP trackers + for http_tracker in &self.tracker.http_trackers { + if http_tracker.tls.is_some() { + return true; + } + } + + // Check Grafana + if let Some(ref grafana) = self.grafana { + if grafana.tls.is_some() { + 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,18 @@ impl EnvironmentCreationConfig { }], http_trackers: vec![super::tracker::HttpTrackerSection { bind_address: "0.0.0.0:7070".to_string(), + tls: None, }], http_api: super::tracker::HttpApiSection { bind_address: "0.0.0.0:1212".to_string(), admin_token: "MyAccessToken".to_string(), + tls: 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 +624,7 @@ mod tests { TrackerSection::default(), None, None, + None, ); assert_eq!(config.environment.name, "dev"); @@ -678,6 +774,7 @@ mod tests { TrackerSection::default(), None, None, + None, ); let json = serde_json::to_string(&config).unwrap(); @@ -704,6 +801,7 @@ mod tests { TrackerSection::default(), None, None, + None, ); let result = config.to_environment_params(); @@ -718,6 +816,7 @@ mod tests { _tracker, _prometheus, _grafana, + _https, ) = result.unwrap(); assert_eq!(name.as_str(), "dev"); @@ -745,6 +844,7 @@ mod tests { TrackerSection::default(), None, None, + None, ); let result = config.to_environment_params(); @@ -759,6 +859,7 @@ mod tests { _tracker, _prometheus, _grafana, + _https, ) = result.unwrap(); assert_eq!(name.as_str(), "prod"); @@ -783,6 +884,7 @@ mod tests { TrackerSection::default(), None, None, + None, ); let result = config.to_environment_params(); @@ -814,6 +916,7 @@ mod tests { TrackerSection::default(), None, None, + None, ); let result = config.to_environment_params(); @@ -848,6 +951,7 @@ mod tests { TrackerSection::default(), None, None, + None, ); let result = config.to_environment_params(); @@ -884,6 +988,7 @@ mod tests { TrackerSection::default(), None, None, + None, ); let result = config.to_environment_params(); @@ -919,6 +1024,7 @@ mod tests { TrackerSection::default(), None, None, + None, ); let result = config.to_environment_params(); @@ -954,6 +1060,7 @@ mod tests { TrackerSection::default(), None, None, + None, ); let result = config.to_environment_params(); @@ -987,6 +1094,7 @@ mod tests { TrackerSection::default(), None, None, + None, ); let ( @@ -998,6 +1106,7 @@ mod tests { _tracker, _prometheus, _grafana, + _https, ) = config.to_environment_params().unwrap(); let environment = Environment::new( name.clone(), @@ -1031,6 +1140,7 @@ mod tests { TrackerSection::default(), None, None, + None, ); let json = serde_json::to_string_pretty(&original).unwrap(); @@ -1121,6 +1231,7 @@ mod tests { TrackerSection::default(), None, None, + None, ); // Both should serialize to same structure (different values) @@ -1252,4 +1363,254 @@ 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::https::TlsSection; + 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(), + tls: None, + }], + http_api: HttpApiSection { + bind_address: "0.0.0.0:1212".to_string(), + admin_token: "MyAccessToken".to_string(), + tls: Some(TlsSection { + domain: "api.tracker.example.com".to_string(), + }), + }, + 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::https::TlsSection; + 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(), + tls: Some(TlsSection { + domain: "tracker.example.com".to_string(), + }), + }], + http_api: HttpApiSection { + bind_address: "0.0.0.0:1212".to_string(), + admin_token: "MyAccessToken".to_string(), + tls: 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, TlsSection, + }; + 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(), + tls: Some(TlsSection { + domain: "tracker.example.com".to_string(), + }), + }], + http_api: HttpApiSection { + bind_address: "0.0.0.0:1212".to_string(), + admin_token: "MyAccessToken".to_string(), + tls: 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..191cb543 100644 --- a/src/application/command_handlers/create/config/errors.rs +++ b/src/application/command_handlers/create/config/errors.rs @@ -110,6 +110,32 @@ 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, } impl CreateConfigError { @@ -450,6 +476,121 @@ 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." + } } } } diff --git a/src/application/command_handlers/create/config/grafana.rs b/src/application/command_handlers/create/config/grafana.rs index 0c703da4..bf0e0ca7 100644 --- a/src/application/command_handlers/create/config/grafana.rs +++ b/src/application/command_handlers/create/config/grafana.rs @@ -8,9 +8,13 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::application::command_handlers::create::config::errors::CreateConfigError; +use crate::application::command_handlers::create::config::https::TlsSection; use crate::domain::grafana::GrafanaConfig; +use crate::domain::tls::TlsConfig; 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 +34,17 @@ use crate::shared::secrets::PlainPassword; /// "admin_password": "admin" /// } /// ``` +/// +/// With TLS configuration: +/// ```json +/// { +/// "admin_user": "admin", +/// "admin_password": "admin", +/// "tls": { +/// "domain": "grafana.example.com" +/// } +/// } +/// ``` #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct GrafanaSection { /// Grafana admin username @@ -40,6 +55,13 @@ 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, + + /// Optional TLS configuration for HTTPS + /// + /// When present, Grafana will be proxied through Caddy with HTTPS enabled. + /// The domain specified will be used for Let's Encrypt certificate acquisition. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tls: Option, } impl Default for GrafanaSection { @@ -48,6 +70,7 @@ impl Default for GrafanaSection { Self { admin_user: default_config.admin_user().to_string(), admin_password: default_config.admin_password().expose_secret().to_string(), + tls: None, } } } @@ -61,13 +84,26 @@ 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 TLS domain is invalid. pub fn to_grafana_config(&self) -> Result { - Ok(GrafanaConfig::new( - self.admin_user.clone(), - self.admin_password.clone(), - )) + let config = match &self.tls { + Some(tls_section) => { + tls_section.validate()?; + let domain = DomainName::new(&tls_section.domain).map_err(|e| { + CreateConfigError::InvalidDomain { + domain: tls_section.domain.clone(), + reason: e.to_string(), + } + })?; + GrafanaConfig::with_tls( + self.admin_user.clone(), + self.admin_password.clone(), + TlsConfig::new(domain), + ) + } + None => GrafanaConfig::new(self.admin_user.clone(), self.admin_password.clone()), + }; + Ok(config) } } @@ -80,6 +116,7 @@ mod tests { let section = GrafanaSection::default(); assert_eq!(section.admin_user, "admin"); assert_eq!(section.admin_password, "admin"); + assert!(section.tls.is_none()); } #[test] @@ -87,6 +124,7 @@ mod tests { let section = GrafanaSection { admin_user: "custom_admin".to_string(), admin_password: "secure_password".to_string(), + tls: None, }; let result = section.to_grafana_config(); @@ -112,6 +150,7 @@ mod tests { let section = GrafanaSection { admin_user: "admin".to_string(), admin_password: "secret_password".to_string(), + tls: None, }; let config = section.to_grafana_config().unwrap(); 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..e99eca76 --- /dev/null +++ b/src/application/command_handlers/create/config/https.rs @@ -0,0 +1,378 @@ +//! 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::{DomainName, 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(()) + } +} + +/// Service-specific TLS configuration +/// +/// Embedded in each service that supports HTTPS. The presence of this +/// configuration indicates that TLS should be enabled for the service. +/// +/// # Domain Requirements +/// +/// The domain must: +/// - Point to the deployment server's IP via DNS +/// - Be owned/controlled by the deployer +/// - Be configured before deployment (for HTTP-01 challenge) +/// +/// # Examples +/// +/// ```json +/// { +/// "tls": { +/// "domain": "api.example.com" +/// } +/// } +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct TlsSection { + /// Domain name for this service + /// + /// This domain will be used for: + /// - HTTPS certificate acquisition (Let's Encrypt HTTP-01 challenge) + /// - Caddy reverse proxy routing + /// - SNI-based TLS termination + pub domain: String, +} + +impl TlsSection { + /// Creates a new TLS configuration section + #[must_use] + pub fn new(domain: String) -> Self { + Self { domain } + } + + /// Validates the TLS configuration + /// + /// Uses the domain-level `DomainName` type for DNS-compliant validation. + /// + /// # Errors + /// + /// Returns `CreateConfigError::InvalidDomain` if the domain format is invalid. + pub fn validate(&self) -> Result<(), CreateConfigError> { + // Validate domain using the domain type for DNS-compliant validation + DomainName::new(&self.domain).map_err(|e| CreateConfigError::InvalidDomain { + domain: self.domain.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); + } + } + + mod tls_section_tests { + use super::*; + + #[test] + fn it_should_create_tls_section() { + let section = TlsSection::new("api.example.com".to_string()); + assert_eq!(section.domain, "api.example.com"); + } + + #[test] + fn it_should_validate_valid_domain() { + let section = TlsSection::new("api.example.com".to_string()); + assert!(section.validate().is_ok()); + } + + #[test] + fn it_should_validate_subdomain() { + let section = TlsSection::new("sub.api.example.com".to_string()); + assert!(section.validate().is_ok()); + } + + #[test] + fn it_should_reject_empty_domain() { + let section = TlsSection::new(String::new()); + assert!(section.validate().is_err()); + } + + #[test] + fn it_should_reject_domain_without_tld() { + let section = TlsSection::new("localhost".to_string()); + assert!(section.validate().is_err()); + } + + #[test] + fn it_should_reject_domain_starting_with_dot() { + let section = TlsSection::new(".example.com".to_string()); + assert!(section.validate().is_err()); + } + + #[test] + fn it_should_reject_domain_ending_with_dot() { + let section = TlsSection::new("example.com.".to_string()); + assert!(section.validate().is_err()); + } + + #[test] + fn it_should_reject_domain_with_consecutive_dots() { + let section = TlsSection::new("example..com".to_string()); + assert!(section.validate().is_err()); + } + + #[test] + fn it_should_reject_domain_with_whitespace() { + let section = TlsSection::new("my domain.com".to_string()); + assert!(section.validate().is_err()); + } + + #[test] + fn it_should_accept_domain_with_hyphen() { + // Hyphens are allowed in domain names + let section = TlsSection::new("my-service.example.com".to_string()); + assert!(section.validate().is_ok()); + } + + #[test] + fn it_should_accept_domain_with_underscore() { + // Underscores are allowed with minimal validation + // (they're valid in some DNS contexts like SRV records) + let section = TlsSection::new("my_service.example.com".to_string()); + assert!(section.validate().is_ok()); + } + + #[test] + fn it_should_deserialize_from_json() { + let json = r#"{"domain": "api.torrust.com"}"#; + let section: TlsSection = serde_json::from_str(json).unwrap(); + assert_eq!(section.domain, "api.torrust.com"); + } + } + + /// 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 { .. }) + )); + } + } + + /// Tests for domain validation in TLS context + /// + /// Note: Comprehensive domain format validation tests are in `src/shared/domain_name.rs`. + /// These tests verify the integration of the `DomainName` type with `TlsSection`. + mod domain_validation_integration_tests { + use super::*; + + #[test] + fn it_should_accept_dns_compliant_domain() { + let section = TlsSection::new("example.com".to_string()); + assert!(section.validate().is_ok()); + } + + #[test] + fn it_should_reject_dns_non_compliant_domain() { + let section = TlsSection::new("localhost".to_string()); + let result = section.validate(); + assert!(matches!( + result, + Err(CreateConfigError::InvalidDomain { .. }) + )); + } + } +} diff --git a/src/application/command_handlers/create/config/mod.rs b/src/application/command_handlers/create/config/mod.rs index e732ae95..992a7c62 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, TlsSection}; 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/http_api_section.rs b/src/application/command_handlers/create/config/tracker/http_api_section.rs index e7c89ce1..354582a5 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 @@ -4,13 +4,23 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::application::command_handlers::create::config::errors::CreateConfigError; +use crate::application::command_handlers::create::config::https::TlsSection; +use crate::domain::tls::TlsConfig; 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, + + /// Optional TLS configuration for HTTPS + /// + /// When present, this service will be proxied through Caddy with HTTPS enabled. + /// The domain specified will be used for Let's Encrypt certificate acquisition. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tls: Option, } impl HttpApiSection { @@ -20,6 +30,7 @@ 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 TLS domain is invalid. 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 +47,25 @@ impl HttpApiSection { }); } - // Domain type now uses SocketAddr (Step 0.7 completed) + // Convert TLS section to domain type with validation + let tls = match &self.tls { + Some(tls_section) => { + tls_section.validate()?; + let domain = DomainName::new(&tls_section.domain).map_err(|e| { + CreateConfigError::InvalidDomain { + domain: tls_section.domain.clone(), + reason: e.to_string(), + } + })?; + Some(TlsConfig::new(domain)) + } + None => None, + }; + Ok(HttpApiConfig { bind_address, admin_token: self.admin_token.clone().into(), + tls, }) } } @@ -53,6 +79,7 @@ mod tests { let section = HttpApiSection { bind_address: "0.0.0.0:1212".to_string(), admin_token: "MyAccessToken".to_string(), + tls: None, }; let result = section.to_http_api_config(); @@ -71,6 +98,7 @@ mod tests { let section = HttpApiSection { bind_address: "invalid-address".to_string(), admin_token: "token".to_string(), + tls: None, }; let result = section.to_http_api_config(); @@ -88,6 +116,7 @@ mod tests { let section = HttpApiSection { bind_address: "0.0.0.0:0".to_string(), admin_token: "token".to_string(), + tls: None, }; let result = section.to_http_api_config(); @@ -105,6 +134,7 @@ mod tests { let section = HttpApiSection { bind_address: "0.0.0.0:1212".to_string(), admin_token: "MyAccessToken".to_string(), + tls: None, }; let json = serde_json::to_string(§ion).unwrap(); 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..c2a736d9 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 @@ -4,11 +4,21 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::application::command_handlers::create::config::errors::CreateConfigError; +use crate::application::command_handlers::create::config::https::TlsSection; +use crate::domain::tls::TlsConfig; use crate::domain::tracker::HttpTrackerConfig; +use crate::shared::DomainName; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] pub struct HttpTrackerSection { pub bind_address: String, + + /// Optional TLS configuration for HTTPS + /// + /// When present, this HTTP tracker will be proxied through Caddy with HTTPS enabled. + /// The domain specified will be used for Let's Encrypt certificate acquisition. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tls: Option, } impl HttpTrackerSection { @@ -18,6 +28,7 @@ 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 TLS domain is invalid. 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 +45,22 @@ impl HttpTrackerSection { }); } - // Domain type now uses SocketAddr (Step 0.7 completed) - Ok(HttpTrackerConfig { bind_address }) + // Convert TLS section to domain type with validation + let tls = match &self.tls { + Some(tls_section) => { + tls_section.validate()?; + let domain = DomainName::new(&tls_section.domain).map_err(|e| { + CreateConfigError::InvalidDomain { + domain: tls_section.domain.clone(), + reason: e.to_string(), + } + })?; + Some(TlsConfig::new(domain)) + } + None => None, + }; + + Ok(HttpTrackerConfig { bind_address, tls }) } } @@ -47,6 +72,7 @@ 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(), + tls: None, }; let result = section.to_http_tracker_config(); @@ -63,6 +89,7 @@ mod tests { fn it_should_fail_for_invalid_bind_address() { let section = HttpTrackerSection { bind_address: "not-valid".to_string(), + tls: None, }; let result = section.to_http_tracker_config(); @@ -79,6 +106,7 @@ mod tests { fn it_should_reject_port_zero() { let section = HttpTrackerSection { bind_address: "0.0.0.0:0".to_string(), + tls: None, }; let result = section.to_http_tracker_config(); @@ -95,6 +123,7 @@ mod tests { fn it_should_be_serializable() { let section = HttpTrackerSection { bind_address: "0.0.0.0:7070".to_string(), + tls: None, }; let json = serde_json::to_string(§ion).unwrap(); 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..2bc86e67 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,12 @@ impl Default for TrackerSection { }], http_trackers: vec![HttpTrackerSection { bind_address: "0.0.0.0:7070".to_string(), + tls: None, }], http_api: HttpApiSection { bind_address: "0.0.0.0:1212".to_string(), admin_token: "MyAccessToken".to_string(), + tls: None, }, health_check_api: HealthCheckApiSection::default(), } @@ -160,10 +162,12 @@ mod tests { }], http_trackers: vec![HttpTrackerSection { bind_address: "0.0.0.0:7070".to_string(), + tls: None, }], http_api: HttpApiSection { bind_address: "0.0.0.0:1212".to_string(), admin_token: "MyAccessToken".to_string(), + tls: None, }, health_check_api: HealthCheckApiSection::default(), }; @@ -205,14 +209,17 @@ mod tests { http_trackers: vec![ HttpTrackerSection { bind_address: "0.0.0.0:7070".to_string(), + tls: None, }, HttpTrackerSection { bind_address: "0.0.0.0:7071".to_string(), + tls: None, }, ], http_api: HttpApiSection { bind_address: "0.0.0.0:1212".to_string(), admin_token: "MyAccessToken".to_string(), + tls: None, }, health_check_api: HealthCheckApiSection::default(), }; @@ -239,6 +246,7 @@ mod tests { http_api: HttpApiSection { bind_address: "0.0.0.0:1212".to_string(), admin_token: "MyAccessToken".to_string(), + tls: None, }, health_check_api: HealthCheckApiSection::default(), }; @@ -266,10 +274,12 @@ mod tests { }], http_trackers: vec![HttpTrackerSection { bind_address: "0.0.0.0:7070".to_string(), + tls: None, }], http_api: HttpApiSection { bind_address: "0.0.0.0:1212".to_string(), admin_token: "MyAccessToken".to_string(), + tls: None, }, health_check_api: HealthCheckApiSection::default(), }; @@ -326,10 +336,12 @@ mod tests { udp_trackers: vec![], http_trackers: vec![HttpTrackerSection { bind_address: "0.0.0.0:7070".to_string(), + tls: None, }], http_api: HttpApiSection { bind_address: "0.0.0.0:7070".to_string(), admin_token: "token".to_string(), + tls: None, }, health_check_api: HealthCheckApiSection::default(), }; @@ -357,10 +369,12 @@ mod tests { }], http_trackers: vec![HttpTrackerSection { bind_address: "0.0.0.0:7070".to_string(), + tls: None, }], http_api: HttpApiSection { bind_address: "0.0.0.0:1212".to_string(), admin_token: "token".to_string(), + tls: 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/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..83d266e1 --- /dev/null +++ b/src/application/steps/rendering/caddy_templates.rs @@ -0,0 +1,238 @@ +//! 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 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..6b4ee269 100644 --- a/src/application/steps/rendering/docker_compose_templates.rs +++ b/src/application/steps/rendering/docker_compose_templates.rs @@ -32,6 +32,7 @@ use tracing::{info, instrument}; use crate::domain::environment::Environment; use crate::domain::template::TemplateManager; use crate::domain::tracker::{DatabaseConfig, TrackerConfig}; +use crate::infrastructure::templating::caddy::{CaddyContext, CaddyService}; use crate::infrastructure::templating::docker_compose::template::wrappers::docker_compose::{ DockerComposeContext, DockerComposeContextBuilder, MysqlSetupConfig, TrackerPorts, }; @@ -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 @@ -227,6 +231,49 @@ 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; + + let mut caddy_context = + CaddyContext::new(https_config.admin_email(), https_config.use_staging()); + + // Add Tracker HTTP API if TLS configured + if let Some(tls_domain) = tracker.http_api_tls_domain() { + let port = tracker.http_api_port(); + caddy_context = caddy_context.with_tracker_api(CaddyService::new(tls_domain, port)); + } + + // Add HTTP Trackers with TLS configured + for (domain, port) in tracker.http_trackers_with_tls() { + caddy_context = caddy_context.with_http_tracker(CaddyService::new(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 + caddy_context = caddy_context.with_grafana(CaddyService::new(tls_domain, 3000)); + } + } + + // Only add Caddy if at least one service has TLS + if caddy_context.has_any_tls() { + builder.with_caddy(caddy_context) + } 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( 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/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/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..6de3a720 100644 --- a/src/domain/grafana/config.rs +++ b/src/domain/grafana/config.rs @@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize}; +use crate::domain::tls::TlsConfig; use crate::shared::secrets::Password; /// Grafana metrics visualization configuration @@ -20,6 +21,13 @@ pub struct GrafanaConfig { /// - Memory zeroing when the value is dropped /// - Explicit `.expose_secret()` calls required to access plaintext admin_password: Password, + + /// TLS configuration for HTTPS termination via Caddy (optional) + /// + /// When present, Grafana will be accessible via HTTPS through + /// the Caddy reverse proxy. + #[serde(skip_serializing_if = "Option::is_none")] + tls: Option, } impl GrafanaConfig { @@ -38,6 +46,17 @@ impl GrafanaConfig { Self { admin_user, admin_password: Password::new(admin_password), + tls: None, + } + } + + /// Creates a new Grafana configuration with TLS + #[must_use] + pub fn with_tls(admin_user: String, admin_password: String, tls: TlsConfig) -> Self { + Self { + admin_user, + admin_password: Password::new(admin_password), + tls: Some(tls), } } @@ -52,6 +71,18 @@ impl GrafanaConfig { pub fn admin_password(&self) -> &Password { &self.admin_password } + + /// Returns the TLS domain if configured + #[must_use] + pub fn tls_domain(&self) -> Option<&str> { + self.tls.as_ref().map(TlsConfig::domain) + } + + /// Returns the TLS configuration if present + #[must_use] + pub fn tls(&self) -> Option<&TlsConfig> { + self.tls.as_ref() + } } impl Default for GrafanaConfig { @@ -59,6 +90,7 @@ impl Default for GrafanaConfig { Self { admin_user: "admin".to_string(), admin_password: Password::new("admin"), + tls: None, } } } @@ -80,6 +112,7 @@ mod tests { let config = GrafanaConfig { admin_user: "custom_admin".to_string(), admin_password: Password::new("custom_pass"), + tls: None, }; assert_eq!(config.admin_user, "custom_admin"); @@ -91,6 +124,7 @@ mod tests { let config = GrafanaConfig { admin_user: "admin".to_string(), admin_password: Password::new("secret123"), + tls: None, }; let json = serde_json::to_string(&config).expect("Failed to serialize"); @@ -114,6 +148,7 @@ mod tests { let config = GrafanaConfig { admin_user: "admin".to_string(), admin_password: Password::new("super_secret"), + tls: None, }; let debug_output = format!("{config:?}"); @@ -128,6 +163,7 @@ mod tests { let config = GrafanaConfig { admin_user: "admin".to_string(), admin_password: Password::new("password"), + tls: None, }; 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..a40d7822 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -15,11 +15,13 @@ pub mod environment; pub mod grafana; +pub mod https; pub mod instance_name; pub mod profile_name; pub mod prometheus; pub mod provider; pub mod template; +pub mod tls; pub mod tracker; // Re-export commonly used domain types for convenience diff --git a/src/domain/tls/config.rs b/src/domain/tls/config.rs new file mode 100644 index 00000000..040aa383 --- /dev/null +++ b/src/domain/tls/config.rs @@ -0,0 +1,97 @@ +//! TLS configuration domain types +//! +//! This module provides domain-level TLS configuration used in tracker +//! and Grafana services for HTTPS termination via Caddy. + +use serde::{Deserialize, Serialize}; + +use crate::shared::DomainName; + +/// Service-specific TLS configuration (domain level) +/// +/// Contains the domain name for Let's Encrypt certificate acquisition. +/// Present on services that should be accessible via HTTPS. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TlsConfig { + /// 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. + domain: DomainName, +} + +impl TlsConfig { + /// Creates a new TLS configuration + /// + /// # Arguments + /// + /// * `domain` - The validated domain name for TLS certificate + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::domain::tls::TlsConfig; + /// use torrust_tracker_deployer_lib::shared::DomainName; + /// + /// let domain = DomainName::new("api.example.com").unwrap(); + /// let tls = TlsConfig::new(domain); + /// assert_eq!(tls.domain(), "api.example.com"); + /// ``` + #[must_use] + pub fn new(domain: DomainName) -> Self { + Self { domain } + } + + /// Returns the domain name as a string slice + #[must_use] + pub fn domain(&self) -> &str { + self.domain.as_str() + } + + /// Returns the domain name type + #[must_use] + pub fn domain_name(&self) -> &DomainName { + &self.domain + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_create_tls_config() { + let domain = DomainName::new("api.tracker.example.com").unwrap(); + let tls = TlsConfig::new(domain); + + assert_eq!(tls.domain(), "api.tracker.example.com"); + } + + #[test] + fn it_should_serialize_to_json() { + let domain = DomainName::new("api.example.com").unwrap(); + let tls = TlsConfig::new(domain); + + let json = serde_json::to_string(&tls).expect("serialization should succeed"); + + assert!(json.contains("\"domain\":\"api.example.com\"")); + } + + #[test] + fn it_should_deserialize_from_json() { + let json = r#"{"domain":"api.example.com"}"#; + + let tls: TlsConfig = serde_json::from_str(json).expect("deserialization should succeed"); + + assert_eq!(tls.domain(), "api.example.com"); + } + + #[test] + fn it_should_be_cloneable() { + let domain = DomainName::new("api.example.com").unwrap(); + let tls = TlsConfig::new(domain); + let cloned = tls.clone(); + + assert_eq!(tls, cloned); + } +} diff --git a/src/domain/tls/mod.rs b/src/domain/tls/mod.rs new file mode 100644 index 00000000..6cfba2d6 --- /dev/null +++ b/src/domain/tls/mod.rs @@ -0,0 +1,18 @@ +//! TLS domain types +//! +//! This module contains domain types for TLS configuration on services. +//! +//! ## Purpose +//! +//! The `TlsConfig` type represents validated TLS settings that are stored +//! in service configurations and used for Caddy reverse proxy setup. +//! +//! ## See Also +//! +//! - Application layer DTOs: `src/application/command_handlers/create/config/https.rs` +//! - Caddy template context: `src/infrastructure/templating/caddy/` +//! - HTTPS domain config: `src/domain/https/` + +pub mod config; + +pub use config::TlsConfig; diff --git a/src/domain/tracker/config/http.rs b/src/domain/tracker/config/http.rs index 2bb1a946..c44fd238 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::domain::tls::TlsConfig; + /// HTTP tracker bind configuration #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct HttpTrackerConfig { @@ -13,6 +15,13 @@ pub struct HttpTrackerConfig { deserialize_with = "crate::domain::tracker::config::deserialize_socket_addr" )] pub bind_address: SocketAddr, + + /// TLS configuration for HTTPS termination via Caddy (optional) + /// + /// When present, this HTTP tracker will be accessible via HTTPS + /// through the Caddy reverse proxy. + #[serde(skip_serializing_if = "Option::is_none")] + pub tls: Option, } #[cfg(test)] @@ -23,6 +32,7 @@ mod tests { fn it_should_create_http_tracker_config() { let config = HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), + tls: None, }; assert_eq!( @@ -35,6 +45,7 @@ mod tests { fn it_should_serialize_http_tracker_config() { let json = serde_json::to_value(&HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), + tls: None, }) .unwrap(); diff --git a/src/domain/tracker/config/http_api.rs b/src/domain/tracker/config/http_api.rs index f920ff7a..fb85e3f5 100644 --- a/src/domain/tracker/config/http_api.rs +++ b/src/domain/tracker/config/http_api.rs @@ -4,6 +4,7 @@ use std::net::SocketAddr; use serde::{Deserialize, Serialize}; +use crate::domain::tls::TlsConfig; use crate::shared::ApiToken; /// HTTP API configuration @@ -18,6 +19,13 @@ pub struct HttpApiConfig { /// Admin access token for HTTP API authentication pub admin_token: ApiToken, + + /// TLS configuration for HTTPS termination via Caddy (optional) + /// + /// When present, the HTTP API will be accessible via HTTPS + /// through the Caddy reverse proxy. + #[serde(skip_serializing_if = "Option::is_none")] + pub tls: Option, } #[cfg(test)] @@ -29,6 +37,7 @@ mod tests { let config = HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "test_token".to_string().into(), + tls: None, }; assert_eq!( @@ -43,6 +52,7 @@ mod tests { let config = HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "token123".to_string().into(), + tls: None, }; let json = serde_json::to_value(&config).unwrap(); diff --git a/src/domain/tracker/config/mod.rs b/src/domain/tracker/config/mod.rs index a3b1c20a..9c07ea15 100644 --- a/src/domain/tracker/config/mod.rs +++ b/src/domain/tracker/config/mod.rs @@ -10,6 +10,7 @@ use std::net::SocketAddr; use serde::{Deserialize, Serialize}; use super::{BindingAddress, Protocol}; +use crate::domain::tls::TlsConfig; mod core; mod health_check_api; @@ -47,11 +48,12 @@ 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(), tls: None }, /// ], /// http_api: HttpApiConfig { /// bind_address: "0.0.0.0:1212".parse().unwrap(), /// admin_token: "MyAccessToken".to_string().into(), +/// tls: None, /// }, /// health_check_api: HealthCheckApiConfig { /// bind_address: "127.0.0.1:1313".parse().unwrap(), @@ -196,11 +198,12 @@ 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(), tls: None }, /// ], /// http_api: HttpApiConfig { /// bind_address: "0.0.0.0:1212".parse().unwrap(), /// admin_token: "MyAccessToken".to_string().into(), + /// tls: None, /// }, /// health_check_api: HealthCheckApiConfig { /// bind_address: "127.0.0.1:1313".parse().unwrap(), @@ -314,6 +317,35 @@ 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.as_ref().map(TlsConfig::domain) + } + + /// Returns the HTTP API port number + #[must_use] + pub fn http_api_port(&self) -> u16 { + self.http_api.bind_address.port() + } + + /// Returns HTTP trackers that have TLS configured + /// + /// Returns a vector of tuples containing (domain, port) for each + /// HTTP tracker that has TLS configuration. + #[must_use] + pub fn http_trackers_with_tls(&self) -> Vec<(&str, u16)> { + self.http_trackers + .iter() + .filter_map(|tracker| { + tracker + .tls + .as_ref() + .map(|tls| (tls.domain(), tracker.bind_address.port())) + }) + .collect() + } } /// Trait for types that have a bind address @@ -360,10 +392,12 @@ impl Default for TrackerConfig { }], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().expect("valid address"), + tls: None, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().expect("valid address"), admin_token: "MyAccessToken".to_string().into(), + tls: None, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().expect("valid address"), @@ -405,10 +439,12 @@ mod tests { }], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), + tls: None, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "test_token".to_string().into(), + tls: None, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), @@ -435,6 +471,7 @@ mod tests { http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "token123".to_string().into(), + tls: None, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), @@ -496,10 +533,12 @@ mod tests { }], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), + tls: None, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "token".to_string().into(), + tls: None, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), @@ -530,6 +569,7 @@ mod tests { http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "token".to_string().into(), + tls: None, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), @@ -568,14 +608,17 @@ mod tests { http_trackers: vec![ HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), + tls: None, }, HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), + tls: None, }, ], http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "token".to_string().into(), + tls: None, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), @@ -611,10 +654,12 @@ mod tests { udp_trackers: vec![], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), + tls: None, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), admin_token: "token".to_string().into(), + tls: None, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), @@ -652,10 +697,12 @@ mod tests { udp_trackers: vec![], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:1313".parse().unwrap(), + tls: None, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "token".to_string().into(), + tls: None, }, health_check_api: HealthCheckApiConfig { bind_address: "0.0.0.0:1313".parse().unwrap(), @@ -696,10 +743,12 @@ mod tests { }], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), + tls: None, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "token".to_string().into(), + tls: None, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), @@ -722,14 +771,17 @@ mod tests { http_trackers: vec![ HttpTrackerConfig { bind_address: "192.168.1.10:7070".parse().unwrap(), + tls: None, }, HttpTrackerConfig { bind_address: "192.168.1.20:7070".parse().unwrap(), + tls: None, }, ], http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "token".to_string().into(), + tls: None, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), @@ -751,10 +803,12 @@ mod tests { udp_trackers: vec![], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), + tls: None, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), admin_token: "token".to_string().into(), + tls: None, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), diff --git a/src/domain/tracker/mod.rs b/src/domain/tracker/mod.rs index aa9c9f0a..82324917 100644 --- a/src/domain/tracker/mod.rs +++ b/src/domain/tracker/mod.rs @@ -34,11 +34,12 @@ //! 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(), tls: None }, //! ], //! http_api: HttpApiConfig { //! bind_address: "0.0.0.0:1212".parse().unwrap(), //! admin_token: "MyToken".to_string().into(), +//! tls: None, //! }, //! health_check_api: HealthCheckApiConfig { //! bind_address: "127.0.0.1:1313".parse().unwrap(), 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..d23a1914 100644 --- a/src/infrastructure/templating/ansible/template/wrappers/variables/context.rs +++ b/src/infrastructure/templating/ansible/template/wrappers/variables/context.rs @@ -205,10 +205,12 @@ mod tests { ], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), + tls: None, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "MyAccessToken".to_string().into(), + tls: None, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), @@ -240,6 +242,7 @@ mod tests { http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "Token123".to_string().into(), + tls: None, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), @@ -277,10 +280,12 @@ mod tests { ], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), // Valid address + tls: None, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "Token".to_string().into(), + tls: None, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), 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..e5a15cfb 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 @@ -3,6 +3,7 @@ // Internal crate use crate::domain::grafana::GrafanaConfig; use crate::domain::prometheus::PrometheusConfig; +use crate::infrastructure::templating::caddy::CaddyContext; use super::database::{DatabaseConfig, MysqlSetupConfig, DRIVER_MYSQL, DRIVER_SQLITE}; use super::{DockerComposeContext, TrackerPorts}; @@ -16,6 +17,7 @@ pub struct DockerComposeContextBuilder { database: DatabaseConfig, prometheus_config: Option, grafana_config: Option, + caddy_config: Option, } impl DockerComposeContextBuilder { @@ -29,6 +31,7 @@ impl DockerComposeContextBuilder { }, prometheus_config: None, grafana_config: None, + caddy_config: None, } } @@ -68,6 +71,20 @@ impl DockerComposeContextBuilder { self } + /// Adds Caddy TLS proxy configuration + /// + /// When Caddy is configured, it provides automatic HTTPS with Let's Encrypt + /// certificates for services that have TLS enabled. + /// + /// # Arguments + /// + /// * `caddy_config` - Caddy configuration with services to proxy + #[must_use] + pub fn with_caddy(mut self, caddy_config: CaddyContext) -> Self { + self.caddy_config = Some(caddy_config); + self + } + /// Builds the `DockerComposeContext` #[must_use] pub fn build(self) -> DockerComposeContext { @@ -76,6 +93,7 @@ impl DockerComposeContextBuilder { ports: self.ports, prometheus_config: self.prometheus_config, grafana_config: self.grafana_config, + caddy_config: self.caddy_config, } } } 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..f9ff7cd0 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 @@ -9,6 +9,7 @@ use serde::Serialize; // Internal crate use crate::domain::grafana::GrafanaConfig; use crate::domain::prometheus::PrometheusConfig; +use crate::infrastructure::templating::caddy::CaddyContext; // Submodules mod builder; @@ -35,6 +36,12 @@ pub struct DockerComposeContext { /// Grafana configuration (optional) #[serde(skip_serializing_if = "Option::is_none")] pub grafana_config: Option, + /// Caddy TLS proxy configuration (optional) + /// + /// When present, Caddy reverse proxy is deployed for TLS termination. + /// When absent, services are exposed directly over HTTP. + #[serde(skip_serializing_if = "Option::is_none")] + pub caddy_config: Option, } impl DockerComposeContext { @@ -103,6 +110,12 @@ impl DockerComposeContext { pub fn grafana_config(&self) -> Option<&GrafanaConfig> { self.grafana_config.as_ref() } + + /// Get the Caddy TLS proxy configuration if present + #[must_use] + pub fn caddy_config(&self) -> Option<&CaddyContext> { + self.caddy_config.as_ref() + } } #[cfg(test)] diff --git a/src/infrastructure/templating/prometheus/template/renderer/project_generator.rs b/src/infrastructure/templating/prometheus/template/renderer/project_generator.rs index b81e5eed..4e605d70 100644 --- a/src/infrastructure/templating/prometheus/template/renderer/project_generator.rs +++ b/src/infrastructure/templating/prometheus/template/renderer/project_generator.rs @@ -209,6 +209,7 @@ scrape_configs: http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().expect("valid address"), admin_token: "test_admin_token".to_string().into(), + tls: None, }, ..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..43e14111 100644 --- a/src/infrastructure/templating/tracker/template/renderer/project_generator.rs +++ b/src/infrastructure/templating/tracker/template/renderer/project_generator.rs @@ -224,10 +224,12 @@ mod tests { }], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), + tls: None, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "test_token".to_string().into(), + tls: None, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), @@ -274,10 +276,12 @@ mod tests { }], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), + tls: None, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "test_token".to_string().into(), + tls: None, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), 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..417e86e5 100644 --- a/src/infrastructure/templating/tracker/template/wrapper/tracker_config/context.rs +++ b/src/infrastructure/templating/tracker/template/wrapper/tracker_config/context.rs @@ -38,11 +38,12 @@ 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(), tls: None }, /// ], /// http_api: HttpApiConfig { /// bind_address: "0.0.0.0:1212".parse().unwrap(), /// admin_token: "MyToken".to_string().into(), +/// tls: None, /// }, /// health_check_api: HealthCheckApiConfig { /// bind_address: "127.0.0.1:1313".parse().unwrap(), @@ -220,10 +221,12 @@ mod tests { ], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), + tls: None, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "test_admin_token".to_string().into(), + tls: None, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), @@ -269,10 +272,12 @@ mod tests { }], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), + tls: None, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "test_token".to_string().into(), + tls: None, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), 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/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 index b3a66695..2bffac8e 100644 --- a/templates/caddy/Caddyfile.tera +++ b/templates/caddy/Caddyfile.tera @@ -8,31 +8,30 @@ { # Email for Let's Encrypt notifications email {{ admin_email }} -{% if 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 %} +{%- endif %} } -{% if tracker_api %} +{%- if tracker_api %} # Tracker REST API {{ tracker_api.domain }} { reverse_proxy tracker:{{ tracker_api.port }} } -{% endif %} -{% for http_tracker in http_trackers %} +{%- endif %} +{%- for http_tracker in http_trackers %} # HTTP Tracker {{ loop.index }} {{ http_tracker.domain }} { reverse_proxy tracker:{{ http_tracker.port }} } -{% endfor %} -{% if grafana %} +{%- endfor %} +{%- if grafana %} # Grafana UI with WebSocket support {{ grafana.domain }} { reverse_proxy grafana:3000 } -{% endif %} +{%- endif %} From ef906fcf31d1451ce57e2db0940c5922a9deaa53 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 14 Jan 2026 10:10:03 +0000 Subject: [PATCH 05/36] refactor: [#272] simplify docker-compose template by pre-computing TLS flags in Rust - Refactor TrackerPorts to use constructor with pre-computed flags - Remove HttpTrackerPort struct, use Vec for ports without TLS - Add needs_ports_section flag to simplify template conditionals - Add http_api_has_tls and grafana_has_tls flags - Filter TLS-enabled ports in Rust instead of Tera template - Simplify docker-compose.yml.tera with cleaner conditionals - Update all tests to use TrackerPorts::new() constructor - Document manual E2E testing procedure in issue spec --- .../272-add-https-support-with-caddy.md | 97 ++++++++++++++++++- .../rendering/docker_compose_templates.rs | 54 ++++++++--- .../template/renderer/docker_compose.rs | 35 +++---- .../template/renderer/project_generator.rs | 11 ++- .../docker_compose/context/builder.rs | 13 +++ .../wrappers/docker_compose/context/mod.rs | 86 ++++++---------- .../wrappers/docker_compose/context/ports.rs | 50 +++++++++- .../wrappers/docker_compose/template.rs | 34 +++---- .../docker-compose/docker-compose.yml.tera | 12 ++- 9 files changed, 274 insertions(+), 118 deletions(-) diff --git a/docs/issues/272-add-https-support-with-caddy.md b/docs/issues/272-add-https-support-with-caddy.md index a60a5baa..7146de5d 100644 --- a/docs/issues/272-add-https-support-with-caddy.md +++ b/docs/issues/272-add-https-support-with-caddy.md @@ -761,10 +761,11 @@ Add link to HTTPS setup guide. - [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` -**Manual Test Results** (2026-01-13): +**Manual Test Results** (2026-01-14): | Test | Status | Notes | | ------------------------------- | ------- | ------------------------------------------ | @@ -780,6 +781,7 @@ Add link to HTTPS setup guide. | 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): @@ -800,6 +802,99 @@ Add to `/etc/hosts` (replace IP with your LXD VM IP): | 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 | +| ------------------ | -------------------------------------- | ----------------------- | +| Caddyfile | N/A (bind mount) | `/etc/caddy/Caddyfile` | +| docker-compose.yml | `/home/torrust/app/docker-compose.yml` | N/A | +| Tracker config | Bind mount from host | `/etc/torrust/tracker/` | +| Caddy certificates | Docker volume `caddy_data` | `/data/` | + +**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: Schema Generation (30 minutes) - [ ] Regenerate JSON schema from Rust DTOs: diff --git a/src/application/steps/rendering/docker_compose_templates.rs b/src/application/steps/rendering/docker_compose_templates.rs index 6b4ee269..1f71c8c1 100644 --- a/src/application/steps/rendering/docker_compose_templates.rs +++ b/src/application/steps/rendering/docker_compose_templates.rs @@ -29,6 +29,7 @@ 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}; @@ -157,14 +158,17 @@ impl RenderDockerComposeTemplatesStep { fn build_tracker_ports(&self) -> TrackerPorts { 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); - TrackerPorts { + TrackerPorts::new( udp_tracker_ports, - http_tracker_ports, + http_tracker_ports_without_tls, http_api_port, - } + http_api_has_tls, + ) } fn create_sqlite_contexts( @@ -258,6 +262,12 @@ impl RenderDockerComposeTemplatesStep { caddy_context = caddy_context.with_http_tracker(CaddyService::new(domain, port)); } + // Check if Grafana has TLS configured + let grafana_has_tls = user_inputs + .grafana + .as_ref() + .is_some_and(|g| g.tls_domain().is_some()); + // Add Grafana if TLS configured if let Some(ref grafana) = user_inputs.grafana { if let Some(tls_domain) = grafana.tls_domain() { @@ -268,7 +278,9 @@ impl RenderDockerComposeTemplatesStep { // Only add Caddy if at least one service has TLS if caddy_context.has_any_tls() { - builder.with_caddy(caddy_context) + builder + .with_caddy(caddy_context) + .with_grafana_tls(grafana_has_tls) } else { builder } @@ -285,25 +297,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/infrastructure/templating/docker_compose/template/renderer/docker_compose.rs b/src/infrastructure/templating/docker_compose/template/renderer/docker_compose.rs index 16e0eb91..cfbcbd47 100644 --- a/src/infrastructure/templating/docker_compose/template/renderer/docker_compose.rs +++ b/src/infrastructure/templating/docker_compose/template/renderer/docker_compose.rs @@ -192,6 +192,16 @@ mod tests { DockerComposeContext, MysqlSetupConfig, TrackerPorts, }; + /// Helper to create `TrackerPorts` for tests (no TLS) + fn test_tracker_ports() -> TrackerPorts { + TrackerPorts::new( + vec![6868, 6969], // UDP ports + vec![7070], // HTTP ports without TLS + 1212, // API port + false, // API has no TLS + ) + } + #[test] fn it_should_create_renderer_with_template_manager() { let temp_dir = TempDir::new().unwrap(); @@ -214,11 +224,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 ports = test_tracker_ports(); let mysql_config = MysqlSetupConfig { root_password: "rootpass123".to_string(), database: "tracker_db".to_string(), @@ -304,11 +310,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 ports = test_tracker_ports(); let sqlite_context = DockerComposeContext::builder(ports).build(); let renderer = DockerComposeRenderer::new(template_manager); @@ -347,11 +349,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 ports = test_tracker_ports(); let prometheus_config = PrometheusConfig::new(std::num::NonZeroU32::new(15).expect("15 is non-zero")); let context = DockerComposeContext::builder(ports) @@ -419,12 +417,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_ports()).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..af687ef5 100644 --- a/src/infrastructure/templating/docker_compose/template/renderer/project_generator.rs +++ b/src/infrastructure/templating/docker_compose/template/renderer/project_generator.rs @@ -207,11 +207,12 @@ 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, - }; + let ports = TrackerPorts::new( + vec![6969], // UDP ports + vec![7070], // HTTP ports without TLS + 1212, // API port + false, // API has no TLS + ); DockerComposeContext::builder(ports).build() } 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 e5a15cfb..7ffb0d9f 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 @@ -18,6 +18,7 @@ pub struct DockerComposeContextBuilder { prometheus_config: Option, grafana_config: Option, caddy_config: Option, + grafana_has_tls: bool, } impl DockerComposeContextBuilder { @@ -32,6 +33,7 @@ impl DockerComposeContextBuilder { prometheus_config: None, grafana_config: None, caddy_config: None, + grafana_has_tls: false, } } @@ -71,6 +73,16 @@ impl DockerComposeContextBuilder { self } + /// Sets whether Grafana has TLS enabled + /// + /// When true, Grafana port will not be exposed directly in Docker Compose + /// (traffic goes through Caddy on port 443 instead). + #[must_use] + pub fn with_grafana_tls(mut self, has_tls: bool) -> Self { + self.grafana_has_tls = has_tls; + self + } + /// Adds Caddy TLS proxy configuration /// /// When Caddy is configured, it provides automatic HTTPS with Let's Encrypt @@ -94,6 +106,7 @@ impl DockerComposeContextBuilder { prometheus_config: self.prometheus_config, grafana_config: self.grafana_config, caddy_config: self.caddy_config, + grafana_has_tls: self.grafana_has_tls, } } } 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 f9ff7cd0..3a074901 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 @@ -42,6 +42,9 @@ pub struct DockerComposeContext { /// When absent, services are exposed directly over HTTP. #[serde(skip_serializing_if = "Option::is_none")] pub caddy_config: Option, + /// Whether Grafana has TLS enabled (port should not be exposed if true) + #[serde(default)] + pub grafana_has_tls: bool, } impl DockerComposeContext { @@ -59,11 +62,12 @@ impl DockerComposeContext { /// ```rust /// use torrust_tracker_deployer_lib::infrastructure::templating::docker_compose::template::wrappers::docker_compose::{DockerComposeContext, TrackerPorts, MysqlSetupConfig}; /// - /// let ports = TrackerPorts { - /// udp_tracker_ports: vec![6868, 6969], - /// http_tracker_ports: vec![7070], - /// http_api_port: 1212, - /// }; + /// let ports = TrackerPorts::new( + /// vec![6868, 6969], // UDP ports (always exposed) + /// vec![7070], // HTTP ports without TLS + /// 1212, // API port + /// false, // API has no TLS + /// ); /// /// // SQLite (default) /// let context = DockerComposeContext::builder(ports.clone()).build(); @@ -122,29 +126,31 @@ impl DockerComposeContext { mod tests { use super::*; + /// Helper to create `TrackerPorts` for tests + fn test_tracker_ports() -> TrackerPorts { + TrackerPorts::new( + vec![6868, 6969], // UDP ports + vec![7070], // HTTP ports without TLS + 1212, // API port + false, // API has no TLS + ) + } + #[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 ports = test_tracker_ports(); let context = DockerComposeContext::builder(ports).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_tracker_ports_without_tls, vec![7070]); assert_eq!(context.ports().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 ports = test_tracker_ports(); let mysql_config = MysqlSetupConfig { root_password: "root123".to_string(), database: "tracker".to_string(), @@ -167,31 +173,23 @@ mod tests { 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_tracker_ports_without_tls, vec![7070]); assert_eq!(context.ports().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 ports = test_tracker_ports(); let context = DockerComposeContext::builder(ports).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 ports = test_tracker_ports(); let mysql_config = MysqlSetupConfig { root_password: "root".to_string(), database: "db".to_string(), @@ -207,18 +205,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 ports = test_tracker_ports(); let mysql_config = MysqlSetupConfig { root_password: "root".to_string(), database: "db".to_string(), @@ -236,11 +230,7 @@ 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 ports = test_tracker_ports(); let context = DockerComposeContext::builder(ports).build(); assert!(context.prometheus_config().is_none()); @@ -248,11 +238,7 @@ mod tests { #[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, - }; + let ports = test_tracker_ports(); let prometheus_config = PrometheusConfig::new(std::num::NonZeroU32::new(30).expect("30 is non-zero")); let context = DockerComposeContext::builder(ports) @@ -271,11 +257,7 @@ mod tests { #[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 ports = test_tracker_ports(); let context = DockerComposeContext::builder(ports).build(); let serialized = serde_json::to_string(&context).unwrap(); @@ -284,11 +266,7 @@ mod tests { #[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, - }; + let ports = test_tracker_ports(); let prometheus_config = PrometheusConfig::new(std::num::NonZeroU32::new(20).expect("20 is non-zero")); let context = DockerComposeContext::builder(ports) 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 index 772fcc70..4be9d622 100644 --- 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 @@ -4,12 +4,56 @@ use serde::Serialize; /// Tracker port configuration +/// +/// Contains all port information needed for Docker Compose port mappings. +/// Includes TLS status and pre-computed flags for template rendering. #[derive(Serialize, Debug, Clone)] pub struct TrackerPorts { - /// UDP tracker ports + /// UDP tracker ports (always exposed - UDP doesn't use TLS termination via Caddy) pub udp_tracker_ports: Vec, - /// HTTP tracker ports - pub http_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, +} + +impl TrackerPorts { + /// Creates a new `TrackerPorts` 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) + #[must_use] + pub fn new( + udp_tracker_ports: Vec, + http_tracker_ports_without_tls: Vec, + http_api_port: u16, + http_api_has_tls: bool, + ) -> Self { + let needs_ports_section = !udp_tracker_ports.is_empty() + || !http_tracker_ports_without_tls.is_empty() + || !http_api_has_tls; + + Self { + udp_tracker_ports, + http_tracker_ports_without_tls, + http_api_port, + http_api_has_tls, + needs_ports_section, + } + } } 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..597677f0 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 @@ -84,6 +84,16 @@ mod tests { use super::super::context::{MysqlSetupConfig, TrackerPorts}; use super::*; + /// Helper to create `TrackerPorts` for tests (no TLS) + fn test_tracker_ports() -> TrackerPorts { + TrackerPorts::new( + vec![6868, 6969], // UDP ports + vec![7070], // HTTP ports without TLS + 1212, // API port + false, // API has no TLS + ) + } + #[test] fn it_should_create_docker_compose_template_with_sqlite() { let template_content = r#" @@ -99,11 +109,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 ports = test_tracker_ports(); let context = DockerComposeContext::builder(ports).build(); let template = DockerComposeTemplate::new(&template_file, context).unwrap(); @@ -129,11 +135,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 ports = test_tracker_ports(); let mysql_config = MysqlSetupConfig { root_password: "root123".to_string(), database: "tracker".to_string(), @@ -163,11 +165,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 ports = test_tracker_ports(); let context = DockerComposeContext::builder(ports).build(); let template = DockerComposeTemplate::new(&template_file, context).unwrap(); @@ -192,11 +190,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 ports = test_tracker_ports(); let context = DockerComposeContext::builder(ports).build(); let result = DockerComposeTemplate::new(&template_file, context); diff --git a/templates/docker-compose/docker-compose.yml.tera b/templates/docker-compose/docker-compose.yml.tera index afc0f978..35758776 100644 --- a/templates/docker-compose/docker-compose.yml.tera +++ b/templates/docker-compose/docker-compose.yml.tera @@ -79,17 +79,21 @@ services: {% if caddy_config %} - proxy_network # Caddy reverse proxies to tracker {% endif %} +{%- if ports.needs_ports_section %} ports: - # UDP Tracker Ports (dynamically configured) + # UDP Tracker Ports (always exposed - UDP doesn't use TLS) {%- for port in ports.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 ports.http_tracker_ports_without_tls %} - {{ port }}:{{ port }} {%- endfor %} +{%- if not ports.http_api_has_tls %} # HTTP API Port (dynamically configured) - {{ ports.http_api_port }}:{{ ports.http_api_port }} +{%- endif %} +{%- endif %} volumes: - ./storage/tracker/lib:/var/lib/torrust/tracker:Z - ./storage/tracker/log:/var/log/torrust/tracker:Z @@ -141,8 +145,10 @@ services: {% if caddy_config %} - proxy_network # Caddy reverse proxies to Grafana {% endif %} +{%- 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} From 180f364dfbc0cf7f6473cf33d280870101b291c8 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 14 Jan 2026 10:27:11 +0000 Subject: [PATCH 06/36] refactor: [#272] use YAML anchors in docker-compose template for DRY config - Add x-defaults anchor with common service settings (tty, restart, logging) - Apply anchor to all services: caddy, tracker, prometheus, grafana, mysql - Remove ~30 lines of duplicated configuration --- .../docker-compose/docker-compose.yml.tera | 43 ++++++------------- 1 file changed, 14 insertions(+), 29 deletions(-) diff --git a/templates/docker-compose/docker-compose.yml.tera b/templates/docker-compose/docker-compose.yml.tera index 35758776..9c1f19af 100644 --- a/templates/docker-compose/docker-compose.yml.tera +++ b/templates/docker-compose/docker-compose.yml.tera @@ -19,15 +19,23 @@ # # 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_config %} # 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 - tty: true - restart: unless-stopped ports: - "80:80" # HTTP (ACME HTTP-01 challenge) - "443:443" # HTTPS @@ -44,21 +52,16 @@ services: timeout: 5s retries: 5 start_period: 10s - logging: - options: - max-size: "10m" - max-file: "10" {% 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" %} depends_on: mysql: @@ -98,17 +101,12 @@ services: - ./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_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 %} @@ -126,20 +124,15 @@ services: timeout: 5s retries: 5 start_period: 10s - logging: - options: - max-size: "10m" - max-file: "10" depends_on: - tracker {% endif %} {% 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 {% if caddy_config %} @@ -161,10 +154,6 @@ services: timeout: 5s retries: 5 start_period: 30s - logging: - options: - max-size: "10m" - max-file: "10" depends_on: {% if prometheus_config %} prometheus: @@ -176,9 +165,9 @@ services: {% 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} @@ -197,10 +186,6 @@ services: timeout: 5s retries: 5 start_period: 30s - logging: - options: - max-size: "10m" - max-file: "10" {% endif %} # SECURITY: Three-Network Segmentation (Defense in Depth) From 1b69af4cd5d31415ae52f98d8628a745f5575576 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 14 Jan 2026 11:03:24 +0000 Subject: [PATCH 07/36] refactor: [#272] move tracker networks logic from template to Rust Move conditional network selection from Tera template to pre-computed Rust values. This follows the project pattern of keeping templates simple by computing all logic in Rust. Changes: - Rename TrackerPorts to TrackerServiceConfig (more descriptive) - Add networks: Vec field to TrackerServiceConfig - Add compute_networks() method for metrics/database/proxy networks - Add has_prometheus, has_mysql, has_caddy parameters to constructor - Update template to iterate over tracker.networks list - Rename context field from 'ports' to 'tracker' - Keep TrackerPorts type alias for backward compatibility - Update all tests to use new 7-argument constructor --- .../rendering/docker_compose_templates.rs | 54 ++++++++++--- .../template/renderer/docker_compose.rs | 34 +++++--- .../template/renderer/project_generator.rs | 9 ++- .../docker_compose/context/builder.rs | 10 +-- .../wrappers/docker_compose/context/mod.rs | 80 ++++++++++--------- .../wrappers/docker_compose/context/ports.rs | 49 ++++++++++-- .../template/wrappers/docker_compose/mod.rs | 1 + .../wrappers/docker_compose/template.rs | 27 ++++--- .../docker-compose/docker-compose.yml.tera | 22 ++--- 9 files changed, 187 insertions(+), 99 deletions(-) diff --git a/src/application/steps/rendering/docker_compose_templates.rs b/src/application/steps/rendering/docker_compose_templates.rs index 1f71c8c1..7c5d588e 100644 --- a/src/application/steps/rendering/docker_compose_templates.rs +++ b/src/application/steps/rendering/docker_compose_templates.rs @@ -35,7 +35,7 @@ use crate::domain::template::TemplateManager; use crate::domain::tracker::{DatabaseConfig, TrackerConfig}; use crate::infrastructure::templating::caddy::{CaddyContext, CaddyService}; 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::{ @@ -109,15 +109,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(), @@ -156,34 +156,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 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); - TrackerPorts::new( + // 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(); + + TrackerServiceConfig::new( udp_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, @@ -208,7 +242,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) } 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 cfbcbd47..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,16 +189,19 @@ mod tests { use tempfile::TempDir; use crate::infrastructure::templating::docker_compose::template::wrappers::docker_compose::{ - DockerComposeContext, MysqlSetupConfig, TrackerPorts, + DockerComposeContext, MysqlSetupConfig, TrackerServiceConfig, }; - /// Helper to create `TrackerPorts` for tests (no TLS) - fn test_tracker_ports() -> TrackerPorts { - TrackerPorts::new( + /// 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 ) } @@ -224,7 +227,7 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); - let ports = test_tracker_ports(); + let tracker = test_tracker_config(); let mysql_config = MysqlSetupConfig { root_password: "rootpass123".to_string(), database: "tracker_db".to_string(), @@ -232,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(); @@ -310,8 +313,8 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); - let ports = test_tracker_ports(); - 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(); @@ -349,10 +352,19 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); - let ports = test_tracker_ports(); + // 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(); @@ -417,7 +429,7 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); - let context = DockerComposeContext::builder(test_tracker_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 af687ef5..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,13 +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::new( + 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(ports).build() + 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 7ffb0d9f..9430ed6d 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 @@ -6,14 +6,14 @@ use crate::domain::prometheus::PrometheusConfig; use crate::infrastructure::templating::caddy::CaddyContext; use super::database::{DatabaseConfig, MysqlSetupConfig, DRIVER_MYSQL, DRIVER_SQLITE}; -use super::{DockerComposeContext, TrackerPorts}; +use super::{DockerComposeContext, TrackerServiceConfig}; /// Builder for `DockerComposeContext` /// /// Provides a fluent API for constructing Docker Compose contexts with optional features. /// Defaults to `SQLite` database configuration. pub struct DockerComposeContextBuilder { - ports: TrackerPorts, + tracker: TrackerServiceConfig, database: DatabaseConfig, prometheus_config: Option, grafana_config: Option, @@ -23,9 +23,9 @@ pub struct DockerComposeContextBuilder { 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, @@ -102,7 +102,7 @@ impl DockerComposeContextBuilder { pub fn build(self) -> DockerComposeContext { DockerComposeContext { database: self.database, - ports: self.ports, + tracker: self.tracker, prometheus_config: self.prometheus_config, grafana_config: self.grafana_config, caddy_config: self.caddy_config, 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 3a074901..c705f6a8 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 @@ -19,7 +19,7 @@ mod ports; // Re-exports pub use builder::DockerComposeContextBuilder; pub use database::{DatabaseConfig, MysqlSetupConfig}; -pub use ports::TrackerPorts; +pub use ports::{TrackerPorts, TrackerServiceConfig}; /// Context for rendering the docker-compose.yml template /// @@ -28,8 +28,8 @@ pub use ports::TrackerPorts; pub struct DockerComposeContext { /// Database configuration pub database: DatabaseConfig, - /// Tracker port configuration - pub ports: TrackerPorts, + /// Tracker service configuration (ports, networks) + pub tracker: TrackerServiceConfig, /// Prometheus configuration (optional) #[serde(skip_serializing_if = "Option::is_none")] pub prometheus_config: Option, @@ -60,17 +60,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::new( + /// 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 @@ -81,7 +84,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"); @@ -97,10 +100,10 @@ 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 @@ -126,31 +129,34 @@ impl DockerComposeContext { mod tests { use super::*; - /// Helper to create `TrackerPorts` for tests - fn test_tracker_ports() -> TrackerPorts { - TrackerPorts::new( + /// 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 = test_tracker_ports(); - 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_without_tls, 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 = test_tracker_ports(); + let tracker = test_tracker_config(); let mysql_config = MysqlSetupConfig { root_password: "root123".to_string(), database: "tracker".to_string(), @@ -158,7 +164,7 @@ mod tests { password: "pass456".to_string(), port: 3306, }; - let context = DockerComposeContext::builder(ports) + let context = DockerComposeContext::builder(tracker) .with_mysql(mysql_config) .build(); @@ -172,15 +178,15 @@ 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_without_tls, 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 = test_tracker_ports(); - 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")); @@ -189,7 +195,7 @@ mod tests { #[test] fn it_should_be_serializable_with_mysql() { - let ports = test_tracker_ports(); + let tracker = test_tracker_config(); let mysql_config = MysqlSetupConfig { root_password: "root".to_string(), database: "db".to_string(), @@ -197,7 +203,7 @@ mod tests { password: "pass".to_string(), port: 3306, }; - let context = DockerComposeContext::builder(ports) + let context = DockerComposeContext::builder(tracker) .with_mysql(mysql_config) .build(); @@ -212,7 +218,7 @@ mod tests { #[test] fn it_should_be_cloneable() { - let ports = test_tracker_ports(); + let tracker = test_tracker_config(); let mysql_config = MysqlSetupConfig { root_password: "root".to_string(), database: "db".to_string(), @@ -220,7 +226,7 @@ mod tests { password: "pass".to_string(), port: 3306, }; - let context = DockerComposeContext::builder(ports) + let context = DockerComposeContext::builder(tracker) .with_mysql(mysql_config) .build(); @@ -230,18 +236,18 @@ mod tests { #[test] fn it_should_not_include_prometheus_config_by_default() { - let ports = test_tracker_ports(); - let context = DockerComposeContext::builder(ports).build(); + let tracker = test_tracker_config(); + let context = DockerComposeContext::builder(tracker).build(); assert!(context.prometheus_config().is_none()); } #[test] fn it_should_include_prometheus_config_when_added() { - let ports = test_tracker_ports(); + 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(); @@ -257,8 +263,8 @@ mod tests { #[test] fn it_should_not_serialize_prometheus_config_when_absent() { - let ports = test_tracker_ports(); - 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("prometheus_config")); @@ -266,10 +272,10 @@ mod tests { #[test] fn it_should_serialize_prometheus_config_when_present() { - let ports = test_tracker_ports(); + 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(); 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 index 4be9d622..fc5e81ba 100644 --- 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 @@ -1,14 +1,15 @@ -//! Tracker port configuration for Docker Compose +//! Tracker service configuration for Docker Compose // External crates use serde::Serialize; -/// Tracker port configuration +/// Tracker service configuration for Docker Compose /// -/// Contains all port information needed for Docker Compose port mappings. -/// Includes TLS status and pre-computed flags for template rendering. +/// 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 TrackerPorts { +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) @@ -26,10 +27,14 @@ pub struct TrackerPorts { /// 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 TrackerPorts { - /// Creates a new `TrackerPorts` with pre-computed flags +impl TrackerServiceConfig { + /// Creates a new `TrackerServiceConfig` with pre-computed flags /// /// # Arguments /// @@ -37,23 +42,53 @@ impl TrackerPorts { /// * `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 597677f0..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,16 +81,19 @@ impl DockerComposeTemplate { #[cfg(test)] mod tests { - use super::super::context::{MysqlSetupConfig, TrackerPorts}; + use super::super::context::{MysqlSetupConfig, TrackerServiceConfig}; use super::*; - /// Helper to create `TrackerPorts` for tests (no TLS) - fn test_tracker_ports() -> TrackerPorts { - TrackerPorts::new( + /// 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 ) } @@ -109,8 +112,8 @@ services: let template_file = File::new("docker-compose.yml.tera", template_content.to_string()).unwrap(); - let ports = test_tracker_ports(); - 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"); @@ -135,7 +138,7 @@ services: let template_file = File::new("docker-compose.yml.tera", template_content.to_string()).unwrap(); - let ports = test_tracker_ports(); + let tracker = test_tracker_config(); let mysql_config = MysqlSetupConfig { root_password: "root123".to_string(), database: "tracker".to_string(), @@ -143,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(); @@ -165,8 +168,8 @@ services: "; let template_file = File::new("docker-compose.yml.tera", template_content.to_string()).unwrap(); - let ports = test_tracker_ports(); - 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 @@ -190,8 +193,8 @@ services: let template_file = File::new("docker-compose.yml.tera", template_content.to_string()).unwrap(); - let ports = test_tracker_ports(); - 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/templates/docker-compose/docker-compose.yml.tera b/templates/docker-compose/docker-compose.yml.tera index 9c1f19af..ce6f794c 100644 --- a/templates/docker-compose/docker-compose.yml.tera +++ b/templates/docker-compose/docker-compose.yml.tera @@ -73,28 +73,22 @@ services: - 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 %} -{% if caddy_config %} - - proxy_network # Caddy reverse proxies to tracker -{% endif %} -{%- if ports.needs_ports_section %} +{% for network in tracker.networks %} + - {{ network }} +{% endfor %} +{%- if tracker.needs_ports_section %} ports: # UDP Tracker Ports (always exposed - UDP doesn't use TLS) -{%- for port in ports.udp_tracker_ports %} +{%- for port in tracker.udp_tracker_ports %} - {{ port }}:{{ port }}/udp {%- endfor %} # HTTP Tracker Ports (only ports without TLS - Caddy handles HTTPS) -{%- for port in ports.http_tracker_ports_without_tls %} +{%- for port in tracker.http_tracker_ports_without_tls %} - {{ port }}:{{ port }} {%- endfor %} -{%- if not ports.http_api_has_tls %} +{%- 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: From a93596cfc7114e36eab2b1804f9923db505ffefe Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 14 Jan 2026 11:29:18 +0000 Subject: [PATCH 08/36] docs: [#272] add CLI command HTTPS compatibility phase and fix VM file locations --- .../272-add-https-support-with-caddy.md | 183 +++++++++++++++++- 1 file changed, 175 insertions(+), 8 deletions(-) diff --git a/docs/issues/272-add-https-support-with-caddy.md b/docs/issues/272-add-https-support-with-caddy.md index 7146de5d..38447f4b 100644 --- a/docs/issues/272-add-https-support-with-caddy.md +++ b/docs/issues/272-add-https-support-with-caddy.md @@ -872,12 +872,23 @@ ssh -i fixtures/testing_rsa -o StrictHostKeyChecking=no torrust@ "docker **5. Key file locations on the VM**: -| File | Location on VM | Location in Container | -| ------------------ | -------------------------------------- | ----------------------- | -| Caddyfile | N/A (bind mount) | `/etc/caddy/Caddyfile` | -| docker-compose.yml | `/home/torrust/app/docker-compose.yml` | N/A | -| Tracker config | Bind mount from host | `/etc/torrust/tracker/` | -| Caddy certificates | Docker volume `caddy_data` | `/data/` | +| 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**: @@ -895,7 +906,149 @@ 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: Schema Generation (30 minutes) +### 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 + +**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**: + +- [ ] Detect if a service has TLS enabled from environment configuration +- [ ] For TLS-enabled services: + - [ ] Show HTTPS URL with configured domain: `https://api.tracker.local` + - [ ] Show HTTP redirect URL: `http://api.tracker.local` (redirects to HTTPS) + - [ ] Add note: "Direct IP access not available when TLS is enabled" +- [ ] For non-TLS services: + - [ ] Show direct IP URL as before: `http://10.140.190.214:7072` +- [ ] Add informational section explaining: + - [ ] "Services with TLS enabled must be accessed via their configured domain" + - [ ] "For local domains (\*.local), add entries to /etc/hosts pointing to the VM IP" + - [ ] "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. +``` + +### Phase 8: Schema Generation (30 minutes) - [ ] Regenerate JSON schema from Rust DTOs: @@ -908,7 +1061,7 @@ caddy 80/tcp, 443/tcp, 443/udp # Entry point for HTTPS - [ ] Test schema with example HTTPS-enabled environment file - [ ] Commit updated schema file -### Phase 8: Create ADR (1 hour) +### Phase 9: Create ADR (1 hour) - [ ] Create `docs/decisions/caddy-for-tls-termination.md` - [ ] Document decision rationale (reference #270 evaluation) @@ -977,6 +1130,20 @@ caddy 80/tcp, 443/tcp, 443/udp # Entry point for HTTPS - [ ] 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 From 02a83bee9eeae598132ef32a67e635a8e143b8fb Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 14 Jan 2026 11:49:58 +0000 Subject: [PATCH 09/36] refactor: [#272] move Prometheus and Grafana networks logic from Tera to Rust --- .../rendering/docker_compose_templates.rs | 10 +- .../docker_compose/context/builder.rs | 44 ++++--- .../docker_compose/context/grafana.rs | 94 +++++++++++++ .../wrappers/docker_compose/context/mod.rs | 123 +++++++++++++----- .../docker_compose/context/prometheus.rs | 74 +++++++++++ .../docker-compose/docker-compose.yml.tera | 30 ++--- 6 files changed, 305 insertions(+), 70 deletions(-) create mode 100644 src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/grafana.rs create mode 100644 src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/prometheus.rs diff --git a/src/application/steps/rendering/docker_compose_templates.rs b/src/application/steps/rendering/docker_compose_templates.rs index 7c5d588e..05fc03cb 100644 --- a/src/application/steps/rendering/docker_compose_templates.rs +++ b/src/application/steps/rendering/docker_compose_templates.rs @@ -296,12 +296,6 @@ impl RenderDockerComposeTemplatesStep { caddy_context = caddy_context.with_http_tracker(CaddyService::new(domain, port)); } - // Check if Grafana has TLS configured - let grafana_has_tls = user_inputs - .grafana - .as_ref() - .is_some_and(|g| g.tls_domain().is_some()); - // Add Grafana if TLS configured if let Some(ref grafana) = user_inputs.grafana { if let Some(tls_domain) = grafana.tls_domain() { @@ -312,9 +306,7 @@ impl RenderDockerComposeTemplatesStep { // Only add Caddy if at least one service has TLS if caddy_context.has_any_tls() { - builder - .with_caddy(caddy_context) - .with_grafana_tls(grafana_has_tls) + builder.with_caddy(caddy_context) } else { builder } 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 9430ed6d..861db675 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 @@ -6,19 +6,23 @@ use crate::domain::prometheus::PrometheusConfig; use crate::infrastructure::templating::caddy::CaddyContext; use super::database::{DatabaseConfig, MysqlSetupConfig, DRIVER_MYSQL, DRIVER_SQLITE}; +use super::grafana::GrafanaServiceConfig; +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 { tracker: TrackerServiceConfig, database: DatabaseConfig, prometheus_config: Option, grafana_config: Option, caddy_config: Option, - grafana_has_tls: bool, } impl DockerComposeContextBuilder { @@ -33,7 +37,6 @@ impl DockerComposeContextBuilder { prometheus_config: None, grafana_config: None, caddy_config: None, - grafana_has_tls: false, } } @@ -73,16 +76,6 @@ impl DockerComposeContextBuilder { self } - /// Sets whether Grafana has TLS enabled - /// - /// When true, Grafana port will not be exposed directly in Docker Compose - /// (traffic goes through Caddy on port 443 instead). - #[must_use] - pub fn with_grafana_tls(mut self, has_tls: bool) -> Self { - self.grafana_has_tls = has_tls; - self - } - /// Adds Caddy TLS proxy configuration /// /// When Caddy is configured, it provides automatic HTTPS with Let's Encrypt @@ -98,15 +91,36 @@ impl DockerComposeContextBuilder { } /// 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.caddy_config.is_some(); + + // 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.tls().is_some(); + GrafanaServiceConfig::new( + config.admin_user().to_string(), + config.admin_password().clone(), + has_tls, + has_caddy, + ) + }); + DockerComposeContext { database: self.database, tracker: self.tracker, - prometheus_config: self.prometheus_config, - grafana_config: self.grafana_config, + prometheus, + grafana, caddy_config: self.caddy_config, - grafana_has_tls: self.grafana_has_tls, } } } 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 c705f6a8..344efe89 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 @@ -7,19 +7,21 @@ use serde::Serialize; // Internal crate -use crate::domain::grafana::GrafanaConfig; -use crate::domain::prometheus::PrometheusConfig; use crate::infrastructure::templating::caddy::CaddyContext; // Submodules mod builder; mod database; +mod grafana; mod ports; +mod prometheus; // Re-exports pub use builder::DockerComposeContextBuilder; pub use database::{DatabaseConfig, MysqlSetupConfig}; +pub use grafana::GrafanaServiceConfig; pub use ports::{TrackerPorts, TrackerServiceConfig}; +pub use prometheus::PrometheusServiceConfig; /// Context for rendering the docker-compose.yml template /// @@ -30,21 +32,18 @@ pub struct DockerComposeContext { pub database: DatabaseConfig, /// Tracker service configuration (ports, networks) pub tracker: TrackerServiceConfig, - /// Prometheus configuration (optional) + /// Prometheus service configuration (optional) #[serde(skip_serializing_if = "Option::is_none")] - pub prometheus_config: Option, - /// Grafana configuration (optional) + pub prometheus: Option, + /// Grafana service configuration (optional) #[serde(skip_serializing_if = "Option::is_none")] - pub grafana_config: Option, + pub grafana: Option, /// Caddy TLS proxy configuration (optional) /// /// When present, Caddy reverse proxy is deployed for TLS termination. /// When absent, services are exposed directly over HTTP. #[serde(skip_serializing_if = "Option::is_none")] pub caddy_config: Option, - /// Whether Grafana has TLS enabled (port should not be exposed if true) - #[serde(default)] - pub grafana_has_tls: bool, } impl DockerComposeContext { @@ -106,16 +105,16 @@ impl DockerComposeContext { &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 configuration if present @@ -127,6 +126,8 @@ impl DockerComposeContext { #[cfg(test)] mod tests { + use crate::domain::prometheus::PrometheusConfig; + use super::*; /// Helper to create `TrackerServiceConfig` for tests (no TLS, no networks) @@ -235,15 +236,15 @@ mod tests { } #[test] - fn it_should_not_include_prometheus_config_by_default() { + 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() { + 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")); @@ -251,27 +252,21 @@ mod tests { .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() { + 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() { + 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")); @@ -280,7 +275,75 @@ mod tests { .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()); + 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()); + 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; + use crate::infrastructure::templating::caddy::CaddyContext; + + let tracker = test_tracker_config(); + let grafana_config = GrafanaConfig::new("admin".to_string(), "password".to_string()); + let caddy_config = CaddyContext::new("admin@example.com".to_string(), false); + let context = DockerComposeContext::builder(tracker) + .with_grafana(grafana_config) + .with_caddy(caddy_config) + .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/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/templates/docker-compose/docker-compose.yml.tera b/templates/docker-compose/docker-compose.yml.tera index ce6f794c..2ece409e 100644 --- a/templates/docker-compose/docker-compose.yml.tera +++ b/templates/docker-compose/docker-compose.yml.tera @@ -96,16 +96,15 @@ services: - ./storage/tracker/log:/var/log/torrust/tracker:Z - ./storage/tracker/etc:/etc/torrust/tracker:Z -{% if prometheus_config %} +{% if prometheus %} prometheus: <<: *defaults image: prom/prometheus:v3.5.0 container_name: prometheus 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 @@ -122,17 +121,16 @@ services: - tracker {% endif %} -{% if grafana_config %} +{% if grafana %} grafana: <<: *defaults image: grafana/grafana:12.3.1 container_name: grafana networks: - - visualization_network # Queries Prometheus data source -{% if caddy_config %} - - proxy_network # Caddy reverse proxies to Grafana -{% endif %} -{%- if not grafana_has_tls %} +{% for network in grafana.networks %} + - {{ network }} +{% endfor %} +{%- if not grafana.has_tls %} ports: - "3100:3000" {%- endif %} @@ -149,7 +147,7 @@ services: retries: 5 start_period: 30s depends_on: -{% if prometheus_config %} +{% if prometheus %} prometheus: condition: service_healthy {% else %} @@ -214,11 +212,11 @@ networks: database_network: driver: bridge {% endif %} -{% if prometheus_config %} +{% if prometheus %} metrics_network: driver: bridge {% endif %} -{% if grafana_config %} +{% if grafana %} visualization_network: driver: bridge {% endif %} @@ -227,13 +225,13 @@ networks: driver: bridge {% endif %} -{% if database.driver == "mysql" or grafana_config or caddy_config %} +{% if database.driver == "mysql" or grafana or caddy_config %} volumes: {%- if database.driver == "mysql" %} mysql_data: driver: local {%- endif %} -{%- if grafana_config %} +{%- if grafana %} grafana_data: driver: local {%- endif %} From 526e7ab69687f56b3d3488511e6455baa1a0b0e1 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 14 Jan 2026 11:59:55 +0000 Subject: [PATCH 10/36] refactor: [#272] rename ports module to tracker for service consistency --- .../template/wrappers/docker_compose/context/mod.rs | 4 ++-- .../wrappers/docker_compose/context/{ports.rs => tracker.rs} | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/{ports.rs => tracker.rs} (100%) 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 344efe89..0a171e26 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 @@ -13,15 +13,15 @@ use crate::infrastructure::templating::caddy::CaddyContext; mod builder; mod database; mod grafana; -mod ports; mod prometheus; +mod tracker; // Re-exports pub use builder::DockerComposeContextBuilder; pub use database::{DatabaseConfig, MysqlSetupConfig}; pub use grafana::GrafanaServiceConfig; -pub use ports::{TrackerPorts, TrackerServiceConfig}; pub use prometheus::PrometheusServiceConfig; +pub use tracker::{TrackerPorts, TrackerServiceConfig}; /// Context for rendering the docker-compose.yml template /// 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/tracker.rs similarity index 100% rename from src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/ports.rs rename to src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/tracker.rs From d508cc77523fb8dd74fa6b4e88c0585fa5ddb0b1 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 14 Jan 2026 12:27:33 +0000 Subject: [PATCH 11/36] refactor: [#272] separate Caddy contexts for Caddyfile and docker-compose templates - Create CaddyServiceConfig in docker-compose context module for docker-compose.yml.tera - CaddyContext remains in caddy module for Caddyfile.tera - Simplify builder to use boolean flag instead of full CaddyContext - Update docker-compose template to use new caddy variable with network iteration - Rename caddy_config volume to caddy_config_vol to avoid naming conflict --- .../rendering/docker_compose_templates.rs | 36 +++----- .../docker_compose/context/builder.rs | 29 +++--- .../wrappers/docker_compose/context/caddy.rs | 90 +++++++++++++++++++ .../wrappers/docker_compose/context/mod.rs | 22 ++--- .../docker-compose/docker-compose.yml.tera | 16 ++-- 5 files changed, 140 insertions(+), 53 deletions(-) create mode 100644 src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/caddy.rs diff --git a/src/application/steps/rendering/docker_compose_templates.rs b/src/application/steps/rendering/docker_compose_templates.rs index 05fc03cb..b5eeba06 100644 --- a/src/application/steps/rendering/docker_compose_templates.rs +++ b/src/application/steps/rendering/docker_compose_templates.rs @@ -33,7 +33,6 @@ 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::caddy::{CaddyContext, CaddyService}; use crate::infrastructure::templating::docker_compose::template::wrappers::docker_compose::{ DockerComposeContext, DockerComposeContextBuilder, MysqlSetupConfig, TrackerServiceConfig, }; @@ -282,31 +281,24 @@ impl RenderDockerComposeTemplatesStep { let tracker = &user_inputs.tracker; - let mut caddy_context = - CaddyContext::new(https_config.admin_email(), https_config.use_staging()); - - // Add Tracker HTTP API if TLS configured - if let Some(tls_domain) = tracker.http_api_tls_domain() { - let port = tracker.http_api_port(); - caddy_context = caddy_context.with_tracker_api(CaddyService::new(tls_domain, port)); - } + // 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()); - // Add HTTP Trackers with TLS configured - for (domain, port) in tracker.http_trackers_with_tls() { - caddy_context = caddy_context.with_http_tracker(CaddyService::new(domain, port)); - } + let has_any_tls = has_tracker_api_tls || has_http_tracker_tls || has_grafana_tls; - // 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 - caddy_context = caddy_context.with_grafana(CaddyService::new(tls_domain, 3000)); - } - } + // 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 caddy_context.has_any_tls() { - builder.with_caddy(caddy_context) + if has_any_tls { + builder.with_caddy() } else { builder } 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 861db675..4ed0b3cb 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 @@ -3,8 +3,8 @@ // Internal crate use crate::domain::grafana::GrafanaConfig; use crate::domain::prometheus::PrometheusConfig; -use crate::infrastructure::templating::caddy::CaddyContext; +use super::caddy::CaddyServiceConfig; use super::database::{DatabaseConfig, MysqlSetupConfig, DRIVER_MYSQL, DRIVER_SQLITE}; use super::grafana::GrafanaServiceConfig; use super::prometheus::PrometheusServiceConfig; @@ -22,7 +22,7 @@ pub struct DockerComposeContextBuilder { database: DatabaseConfig, prometheus_config: Option, grafana_config: Option, - caddy_config: Option, + has_caddy: bool, } impl DockerComposeContextBuilder { @@ -36,7 +36,7 @@ impl DockerComposeContextBuilder { }, prometheus_config: None, grafana_config: None, - caddy_config: None, + has_caddy: false, } } @@ -76,17 +76,13 @@ impl DockerComposeContextBuilder { self } - /// Adds Caddy TLS proxy configuration + /// Enables Caddy TLS proxy /// - /// When Caddy is configured, it provides automatic HTTPS with Let's Encrypt + /// When Caddy is enabled, it provides automatic HTTPS with Let's Encrypt /// certificates for services that have TLS enabled. - /// - /// # Arguments - /// - /// * `caddy_config` - Caddy configuration with services to proxy #[must_use] - pub fn with_caddy(mut self, caddy_config: CaddyContext) -> Self { - self.caddy_config = Some(caddy_config); + pub fn with_caddy(mut self) -> Self { + self.has_caddy = true; self } @@ -97,7 +93,7 @@ impl DockerComposeContextBuilder { #[must_use] pub fn build(self) -> DockerComposeContext { let has_grafana = self.grafana_config.is_some(); - let has_caddy = self.caddy_config.is_some(); + let has_caddy = self.has_caddy; // Build Prometheus service config if enabled let prometheus = self.prometheus_config.map(|config| { @@ -115,12 +111,19 @@ impl DockerComposeContextBuilder { ) }); + // Build Caddy service config if enabled + let caddy = if has_caddy { + Some(CaddyServiceConfig::new()) + } else { + None + }; + DockerComposeContext { database: self.database, tracker: self.tracker, prometheus, grafana, - caddy_config: self.caddy_config, + caddy, } } } 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/mod.rs b/src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/mod.rs index 0a171e26..ffd2ec41 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,11 +6,9 @@ // External crates use serde::Serialize; -// Internal crate -use crate::infrastructure::templating::caddy::CaddyContext; - // Submodules mod builder; +mod caddy; mod database; mod grafana; mod prometheus; @@ -18,6 +16,7 @@ mod tracker; // Re-exports pub use builder::DockerComposeContextBuilder; +pub use caddy::CaddyServiceConfig; pub use database::{DatabaseConfig, MysqlSetupConfig}; pub use grafana::GrafanaServiceConfig; pub use prometheus::PrometheusServiceConfig; @@ -38,12 +37,15 @@ pub struct DockerComposeContext { /// Grafana service configuration (optional) #[serde(skip_serializing_if = "Option::is_none")] pub grafana: Option, - /// Caddy TLS proxy configuration (optional) + /// 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 caddy_config: Option, + pub caddy: Option, } impl DockerComposeContext { @@ -117,10 +119,10 @@ impl DockerComposeContext { self.grafana.as_ref() } - /// Get the Caddy TLS proxy configuration if present + /// Get the Caddy TLS proxy service configuration if present #[must_use] - pub fn caddy_config(&self) -> Option<&CaddyContext> { - self.caddy_config.as_ref() + pub fn caddy(&self) -> Option<&CaddyServiceConfig> { + self.caddy.as_ref() } } @@ -330,14 +332,12 @@ mod tests { #[test] fn it_should_compute_grafana_networks_with_caddy() { use crate::domain::grafana::GrafanaConfig; - use crate::infrastructure::templating::caddy::CaddyContext; let tracker = test_tracker_config(); let grafana_config = GrafanaConfig::new("admin".to_string(), "password".to_string()); - let caddy_config = CaddyContext::new("admin@example.com".to_string(), false); let context = DockerComposeContext::builder(tracker) .with_grafana(grafana_config) - .with_caddy(caddy_config) + .with_caddy() .build(); let grafana = context.grafana().unwrap(); diff --git a/templates/docker-compose/docker-compose.yml.tera b/templates/docker-compose/docker-compose.yml.tera index 2ece409e..4faa9389 100644 --- a/templates/docker-compose/docker-compose.yml.tera +++ b/templates/docker-compose/docker-compose.yml.tera @@ -29,7 +29,7 @@ x-defaults: &defaults max-file: "10" services: -{% if caddy_config %} +{% if caddy %} # Caddy reverse proxy for automatic HTTPS with Let's Encrypt # Placed first as it's the entry point for HTTPS traffic caddy: @@ -43,9 +43,11 @@ services: volumes: - ./storage/caddy/etc/Caddyfile:/etc/caddy/Caddyfile:ro - caddy_data:/data # TLS certificates (MUST persist!) - - caddy_config:/config + - caddy_config_vol:/config networks: - - proxy_network # Connects to services that need TLS termination +{% for network in caddy.networks %} + - {{ network }} +{% endfor %} healthcheck: test: ["CMD", "caddy", "validate", "--config", "/etc/caddy/Caddyfile"] interval: 10s @@ -220,12 +222,12 @@ networks: visualization_network: driver: bridge {% endif %} -{% if caddy_config %} +{% if caddy %} proxy_network: driver: bridge {% endif %} -{% if database.driver == "mysql" or grafana or caddy_config %} +{% if database.driver == "mysql" or grafana or caddy %} volumes: {%- if database.driver == "mysql" %} mysql_data: @@ -235,10 +237,10 @@ volumes: grafana_data: driver: local {%- endif %} -{%- if caddy_config %} +{%- if caddy %} caddy_data: driver: local - caddy_config: + caddy_config_vol: driver: local {%- endif %} {% endif %} From 56ad1dc0397e34c99bcf9581131e1315f3342515 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 14 Jan 2026 13:47:12 +0000 Subject: [PATCH 12/36] refactor: [#272] add MysqlServiceConfig for MySQL service network configuration - Create MysqlServiceConfig in docker-compose context module - Follow same pattern as CaddyServiceConfig (networks field only) - Update docker-compose template to use mysql variable for network iteration - Ensures consistency across all service configurations --- .../docker_compose/context/builder.rs | 9 ++ .../wrappers/docker_compose/context/mod.rs | 14 +++ .../wrappers/docker_compose/context/mysql.rs | 91 +++++++++++++++++++ .../docker-compose/docker-compose.yml.tera | 14 +-- 4 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 src/infrastructure/templating/docker_compose/template/wrappers/docker_compose/context/mysql.rs 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 4ed0b3cb..39641046 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 @@ -7,6 +7,7 @@ use crate::domain::prometheus::PrometheusConfig; use super::caddy::CaddyServiceConfig; use super::database::{DatabaseConfig, MysqlSetupConfig, DRIVER_MYSQL, DRIVER_SQLITE}; use super::grafana::GrafanaServiceConfig; +use super::mysql::MysqlServiceConfig; use super::prometheus::PrometheusServiceConfig; use super::{DockerComposeContext, TrackerServiceConfig}; @@ -118,12 +119,20 @@ impl DockerComposeContextBuilder { None }; + // Build MySQL service config if enabled + let mysql = if self.database.driver == DRIVER_MYSQL { + Some(MysqlServiceConfig::new()) + } else { + None + }; + DockerComposeContext { database: self.database, tracker: self.tracker, prometheus, grafana, caddy, + mysql, } } } 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 ffd2ec41..663ad596 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 @@ -11,6 +11,7 @@ mod builder; mod caddy; mod database; mod grafana; +mod mysql; mod prometheus; mod tracker; @@ -19,6 +20,7 @@ pub use builder::DockerComposeContextBuilder; pub use caddy::CaddyServiceConfig; pub use database::{DatabaseConfig, MysqlSetupConfig}; pub use grafana::GrafanaServiceConfig; +pub use mysql::MysqlServiceConfig; pub use prometheus::PrometheusServiceConfig; pub use tracker::{TrackerPorts, TrackerServiceConfig}; @@ -46,6 +48,12 @@ pub struct DockerComposeContext { /// This type only contains the docker-compose service definition data. #[serde(skip_serializing_if = "Option::is_none")] 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 mysql: Option, } impl DockerComposeContext { @@ -124,6 +132,12 @@ impl DockerComposeContext { 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)] 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/templates/docker-compose/docker-compose.yml.tera b/templates/docker-compose/docker-compose.yml.tera index 4faa9389..e7f767b2 100644 --- a/templates/docker-compose/docker-compose.yml.tera +++ b/templates/docker-compose/docker-compose.yml.tera @@ -64,7 +64,7 @@ services: # Pinning to a stable release ensures predictable deployments and easier rollback. image: torrust/tracker:develop container_name: tracker -{% if database.driver == "mysql" %} +{% if mysql %} depends_on: mysql: condition: service_healthy @@ -157,7 +157,7 @@ services: {% endif %} {% endif %} -{% if database.driver == "mysql" %} +{% if mysql %} mysql: <<: *defaults image: mysql:8.4 @@ -168,7 +168,9 @@ services: - 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: @@ -210,7 +212,7 @@ services: # See Analysis: docs/analysis/security/docker-network-segmentation-analysis.md networks: -{% if database.driver == "mysql" %} +{% if mysql %} database_network: driver: bridge {% endif %} @@ -227,9 +229,9 @@ networks: driver: bridge {% endif %} -{% if database.driver == "mysql" or grafana or caddy %} +{% if mysql or grafana or caddy %} volumes: -{%- if database.driver == "mysql" %} +{%- if mysql %} mysql_data: driver: local {%- endif %} From 011cd8c8849fabeef388c0a23098c95b8817d292 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 14 Jan 2026 17:02:12 +0000 Subject: [PATCH 13/36] refactor: [#272] improve docker-compose template whitespace handling - Use Tera whitespace trimming ({%- and -%}) to reduce excessive blank lines - Generated docker-compose.yml now has cleaner, more readable formatting - Single blank lines between services, compact network/volume sections --- .../docker-compose/docker-compose.yml.tera | 65 ++++++++++--------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/templates/docker-compose/docker-compose.yml.tera b/templates/docker-compose/docker-compose.yml.tera index e7f767b2..6424e9d0 100644 --- a/templates/docker-compose/docker-compose.yml.tera +++ b/templates/docker-compose/docker-compose.yml.tera @@ -29,7 +29,7 @@ x-defaults: &defaults max-file: "10" services: -{% if caddy %} +{%- if caddy %} # Caddy reverse proxy for automatic HTTPS with Let's Encrypt # Placed first as it's the entry point for HTTPS traffic caddy: @@ -45,17 +45,17 @@ services: - caddy_data:/data # TLS certificates (MUST persist!) - caddy_config_vol:/config networks: -{% for network in caddy.networks %} +{%- for network in caddy.networks %} - {{ network }} -{% endfor %} +{%- endfor %} healthcheck: test: ["CMD", "caddy", "validate", "--config", "/etc/caddy/Caddyfile"] interval: 10s timeout: 5s retries: 5 start_period: 10s +{%- endif %} -{% endif %} tracker: <<: *defaults # TODO: Pin to stable v4.0.0 when released (currently using develop tag) @@ -64,20 +64,20 @@ services: # Pinning to a stable release ensures predictable deployments and easier rollback. image: torrust/tracker:develop container_name: tracker -{% if 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: -{% for network in tracker.networks %} +{%- for network in tracker.networks %} - {{ network }} -{% endfor %} +{%- endfor %} {%- if tracker.needs_ports_section %} ports: # UDP Tracker Ports (always exposed - UDP doesn't use TLS) @@ -97,16 +97,16 @@ services: - ./storage/tracker/lib:/var/lib/torrust/tracker:Z - ./storage/tracker/log:/var/log/torrust/tracker:Z - ./storage/tracker/etc:/etc/torrust/tracker:Z +{%- if prometheus %} -{% if prometheus %} prometheus: <<: *defaults image: prom/prometheus:v3.5.0 container_name: prometheus networks: -{% for network in prometheus.networks %} +{%- for network in prometheus.networks %} - {{ network }} -{% endfor %} +{%- endfor %} ports: - "127.0.0.1:9090:9090" # Localhost only - not exposed to external network # Grafana accesses Prometheus via Docker network: http://prometheus:9090 @@ -121,17 +121,17 @@ services: start_period: 10s depends_on: - tracker -{% endif %} +{%- endif %} +{%- if grafana %} -{% if grafana %} grafana: <<: *defaults image: grafana/grafana:12.3.1 container_name: grafana networks: -{% for network in grafana.networks %} +{%- for network in grafana.networks %} - {{ network }} -{% endfor %} +{%- endfor %} {%- if not grafana.has_tls %} ports: - "3100:3000" @@ -149,15 +149,15 @@ services: retries: 5 start_period: 30s depends_on: -{% if prometheus %} +{%- if prometheus %} prometheus: condition: service_healthy -{% else %} +{%- else %} - tracker -{% endif %} -{% endif %} +{%- endif %} +{%- endif %} +{%- if mysql %} -{% if mysql %} mysql: <<: *defaults image: mysql:8.4 @@ -168,9 +168,9 @@ services: - MYSQL_USER=${MYSQL_USER} - MYSQL_PASSWORD=${MYSQL_PASSWORD} networks: -{% for network in mysql.networks %} +{%- for network in mysql.networks %} - {{ network }} -{% endfor %} +{%- endfor %} ports: - "3306:3306" volumes: @@ -182,7 +182,7 @@ services: timeout: 5s retries: 5 start_period: 30s -{% endif %} +{%- endif %} # SECURITY: Three-Network Segmentation (Defense in Depth) # ========================================================= @@ -212,24 +212,25 @@ services: # See Analysis: docs/analysis/security/docker-network-segmentation-analysis.md networks: -{% if mysql %} +{%- if mysql %} database_network: driver: bridge -{% endif %} -{% if prometheus %} +{%- endif %} +{%- if prometheus %} metrics_network: driver: bridge -{% endif %} -{% if grafana %} +{%- endif %} +{%- if grafana %} visualization_network: driver: bridge -{% endif %} -{% if caddy %} +{%- endif %} +{%- if caddy %} proxy_network: driver: bridge -{% endif %} +{%- endif %} + -{% if mysql or grafana or caddy %} +{%- if mysql or grafana or caddy %} volumes: {%- if mysql %} mysql_data: From c8236eb52aa8e364f81d3a1ab345758b0f5954b0 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 14 Jan 2026 17:13:48 +0000 Subject: [PATCH 14/36] chore: [#272] add rustc-ice files to gitignore Prevent accidental commits of Rust compiler ICE dump files --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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/ From 704f153dbc7b0964bbd76dfdcce73015af6577d7 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Wed, 14 Jan 2026 17:26:46 +0000 Subject: [PATCH 15/36] feat: [#272] add Caddy to Docker security scan workflow - Add caddy:2.10 to third-party images matrix in CI - Add SARIF upload step for Caddy vulnerability scanning - Create security scan documentation for Caddy image - Document 4 known vulnerabilities (3 HIGH, 1 CRITICAL) in Go dependencies --- .github/workflows/docker-security-scan.yml | 9 +++ .../272-add-https-support-with-caddy.md | 10 +-- docs/security/docker/scans/README.md | 4 +- docs/security/docker/scans/caddy.md | 68 +++++++++++++++++++ project-words.txt | 1 + 5 files changed, 86 insertions(+), 6 deletions(-) create mode 100644 docs/security/docker/scans/caddy.md 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/docs/issues/272-add-https-support-with-caddy.md b/docs/issues/272-add-https-support-with-caddy.md index 38447f4b..38f1b811 100644 --- a/docs/issues/272-add-https-support-with-caddy.md +++ b/docs/issues/272-add-https-support-with-caddy.md @@ -676,11 +676,11 @@ Add link to HTTPS setup guide. ### 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) 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/project-words.txt b/project-words.txt index bed92b2a..f51f9ce8 100644 --- a/project-words.txt +++ b/project-words.txt @@ -45,6 +45,7 @@ QUIC RAII RUSTDOCFLAGS Repomix +Rescan Rustdoc SARIF SCRIPTDIR From c1c194aaf8c58f32a60228f80fb8a19afc3e8ce1 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 15 Jan 2026 13:39:15 +0000 Subject: [PATCH 16/36] feat: [#272] update show command to display HTTPS-enabled services - Show HTTPS URLs with configured domains for TLS-enabled services - Separate HTTP trackers into HTTPS (via Caddy) and direct access groups - Display API endpoint with HTTPS indicator when TLS is configured - Add /etc/hosts hint with all TLS domains and instance IP - Show note about internal ports not being directly accessible with TLS - Add Grafana HTTPS URL when TLS is configured --- .../272-add-https-support-with-caddy.md | 22 +- .../command_handlers/show/handler.rs | 20 +- .../command_handlers/show/info/grafana.rs | 71 ++++- .../command_handlers/show/info/mod.rs | 2 +- .../command_handlers/show/info/tracker.rs | 251 +++++++++++++++-- src/domain/environment/state/mod.rs | 14 + .../commands/show/environment_info/basic.rs | 85 ++++++ .../commands/show/environment_info/grafana.rs | 89 ++++++ .../show/environment_info/https_hint.rs | 163 +++++++++++ .../show/environment_info/infrastructure.rs | 143 ++++++++++ .../mod.rs} | 266 ++++++++---------- .../show/environment_info/next_step.rs | 166 +++++++++++ .../show/environment_info/prometheus.rs | 57 ++++ .../show/environment_info/tracker_services.rs | 180 ++++++++++++ 14 files changed, 1338 insertions(+), 191 deletions(-) create mode 100644 src/presentation/views/commands/show/environment_info/basic.rs create mode 100644 src/presentation/views/commands/show/environment_info/grafana.rs create mode 100644 src/presentation/views/commands/show/environment_info/https_hint.rs create mode 100644 src/presentation/views/commands/show/environment_info/infrastructure.rs rename src/presentation/views/commands/show/{environment_info.rs => environment_info/mod.rs} (56%) create mode 100644 src/presentation/views/commands/show/environment_info/next_step.rs create mode 100644 src/presentation/views/commands/show/environment_info/prometheus.rs create mode 100644 src/presentation/views/commands/show/environment_info/tracker_services.rs diff --git a/docs/issues/272-add-https-support-with-caddy.md b/docs/issues/272-add-https-support-with-caddy.md index 38f1b811..249693ed 100644 --- a/docs/issues/272-add-https-support-with-caddy.md +++ b/docs/issues/272-add-https-support-with-caddy.md @@ -998,17 +998,17 @@ Services are running. Use 'test' to verify health. **Required Changes**: -- [ ] Detect if a service has TLS enabled from environment configuration -- [ ] For TLS-enabled services: - - [ ] Show HTTPS URL with configured domain: `https://api.tracker.local` - - [ ] Show HTTP redirect URL: `http://api.tracker.local` (redirects to HTTPS) - - [ ] Add note: "Direct IP access not available when TLS is enabled" -- [ ] For non-TLS services: - - [ ] Show direct IP URL as before: `http://10.140.190.214:7072` -- [ ] Add informational section explaining: - - [ ] "Services with TLS enabled must be accessed via their configured domain" - - [ ] "For local domains (\*.local), add entries to /etc/hosts pointing to the VM IP" - - [ ] "Internal ports are not directly accessible when TLS is enabled" +- [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**: 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..0d6d966a 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,40 @@ 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::domain::tls::TlsConfig; + use crate::shared::domain_name::DomainName; + + let domain = DomainName::new("grafana.tracker.local").unwrap(); + let config = GrafanaConfig::with_tls( + "admin".to_string(), + "pass".to_string(), + TlsConfig::new(domain), + ); + 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()); + 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..03764c35 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::{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..e65f0dcb 100644 --- a/src/application/command_handlers/show/info/tracker.rs +++ b/src/application/command_handlers/show/info/tracker.rs @@ -5,6 +5,7 @@ use std::net::IpAddr; use crate::domain::environment::runtime_outputs::ServiceEndpoints; +use crate::domain::grafana::GrafanaConfig; use crate::domain::tracker::TrackerConfig; /// Tracker service information for display purposes @@ -16,14 +17,43 @@ 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 API endpoint URL (e.g., `http://10.0.0.1:1212/api` or `https://api.tracker.local/api`) pub api_endpoint: String, + /// Whether the API endpoint uses HTTPS via Caddy + pub api_uses_https: bool, + /// Health check API URL (e.g., `http://10.0.0.1:1313/health_check`) pub health_check_url: String, + + /// Domains configured for TLS services (for /etc/hosts hint) + pub tls_domains: Vec, +} + +/// 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 { @@ -31,47 +61,97 @@ impl ServiceInfo { #[must_use] pub fn new( udp_trackers: Vec, - http_trackers: Vec, + https_http_trackers: Vec, + direct_http_trackers: Vec, api_endpoint: String, + api_uses_https: bool, health_check_url: String, + tls_domains: Vec, ) -> Self { Self { udp_trackers, - http_trackers, + https_http_trackers, + direct_http_trackers, api_endpoint, + api_uses_https, health_check_url, + 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) and direct HTTP access 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 + let mut https_http_trackers = Vec::new(); + let mut direct_http_trackers = Vec::new(); + let mut tls_domains = Vec::new(); + + for http in &tracker_config.http_trackers { + if let Some(tls) = &http.tls { + // TLS-enabled tracker - use HTTPS domain URL + https_http_trackers.push(format!("https://{}/announce", tls.domain())); + tls_domains.push(TlsDomainInfo { + domain: tls.domain().to_string(), + internal_port: http.bind_address.port(), + }); + } else { + // Non-TLS tracker - use direct IP URL + direct_http_trackers.push(format!( "http://{}:{}/announce", // DevSkim: ignore DS137138 instance_ip, http.bind_address.port() - ) - }) - .collect(); + )); + } + } - let api_endpoint = format!( - "http://{}:{}/api", // DevSkim: ignore DS137138 - instance_ip, - tracker_config.http_api.bind_address.port() - ); + // Build API endpoint based on TLS configuration + let (api_endpoint, api_uses_https) = if let Some(tls) = &tracker_config.http_api.tls { + tls_domains.push(TlsDomainInfo { + domain: tls.domain().to_string(), + internal_port: tracker_config.http_api.bind_address.port(), + }); + (format!("https://{}/api", tls.domain()), true) + } else { + ( + format!( + "http://{}:{}/api", // DevSkim: ignore DS137138 + instance_ip, + tracker_config.http_api.bind_address.port() + ), + false, + ) + }; + + // 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 @@ -79,13 +159,24 @@ impl ServiceInfo { tracker_config.health_check_api.bind_address.port() ); - Self::new(udp_trackers, http_trackers, api_endpoint, health_check_url) + Self::new( + udp_trackers, + https_http_trackers, + direct_http_trackers, + api_endpoint, + api_uses_https, + health_check_url, + 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 +185,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 information) + let direct_http_trackers = endpoints .http_trackers .iter() .map(ToString::to_string) @@ -110,7 +203,33 @@ 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, + api_endpoint, + false, // Legacy endpoints don't have TLS info + health_check_url, + 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 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 +241,94 @@ 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 "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, + "http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + 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.health_check_url.contains("1313")); + assert!(services.has_any_tls()); + } + + #[test] + fn it_should_return_tls_domain_names() { + let services = ServiceInfo::new( + vec![], + vec!["https://api.tracker.local/announce".to_string()], + vec![], + "https://api.tracker.local/api".to_string(), + true, + "http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + 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![], + "https://api.tracker.local/api".to_string(), + true, + String::new(), + 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 + "http://10.0.0.1:1212/api".to_string(), // DevSkim: ignore DS137138 + false, + "http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + vec![], + ); + + assert!(!services.has_any_tls()); + assert!(services.tls_domain_names().is_empty()); + assert!(services.unexposed_ports().is_empty()); } } 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/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..6b70f0b3 --- /dev/null +++ b/src/presentation/views/commands/show/environment_info/https_hint.rs @@ -0,0 +1,163 @@ +//! 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 + "http://10.0.0.1:1212/api".to_string(), // DevSkim: ignore DS137138 + false, + "http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + 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![], + "https://api.tracker.local/api".to_string(), + true, + "http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + 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..13b58e87 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,12 @@ 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 "http://10.0.0.1:1212/api".to_string(), // DevSkim: ignore DS137138 + false, // API doesn't use HTTPS "http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + vec![], // No TLS domains )); let output = EnvironmentInfoView::render(&info); @@ -297,7 +244,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 +269,12 @@ 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 "http://192.168.1.100:1212/api".to_string(), // DevSkim: ignore DS137138 + false, "http://192.168.1.100:1313/health_check".to_string(), // DevSkim: ignore DS137138 + vec![], )); let output = EnvironmentInfoView::render(&info); @@ -336,7 +286,68 @@ 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 + "https://api.tracker.local/api".to_string(), + true, // API uses HTTPS + "http://10.140.190.214:1313/health_check".to_string(), // DevSkim: ignore DS137138 + 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 +370,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..e08c0db8 --- /dev/null +++ b/src/presentation/views/commands/show/environment_info/tracker_services.rs @@ -0,0 +1,180 @@ +//! 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}")); + } + } + + // API endpoint with HTTPS indicator + if services.api_uses_https { + lines.push(" API Endpoint (HTTPS via Caddy):".to_string()); + } else { + lines.push(" API Endpoint:".to_string()); + } + lines.push(format!(" - {}", services.api_endpoint)); + + // Health check + 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::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 + "http://10.0.0.1:1212/api".to_string(), // DevSkim: ignore DS137138 + false, // API doesn't use HTTPS + "http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + 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 + "https://api.tracker.local/api".to_string(), + true, // API uses HTTPS + "http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + 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("http://10.0.0.1:1313/health_check"))); // DevSkim: ignore DS137138 + } + + #[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 + "http://10.0.0.1:1212/api".to_string(), // DevSkim: ignore DS137138 + false, + "http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + vec![], + ); + + let lines = TrackerServicesView::render(&services); + assert!(!lines.iter().any(|l| l.contains("HTTP Trackers"))); + } +} From 18550589e2ee6ff31e7adeae6ffe4859a9a7df7a Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 15 Jan 2026 14:10:09 +0000 Subject: [PATCH 17/36] docs: [#272] add tasks 7.3 and 7.4 for health check TLS and localhost handling - Task 7.3: Add TLS support for health check API - Task 7.4: Handle localhost-bound services (validation + show command) - Mark task 7.2 as complete --- .../272-add-https-support-with-caddy.md | 92 ++++++++++++++++++- 1 file changed, 90 insertions(+), 2 deletions(-) diff --git a/docs/issues/272-add-https-support-with-caddy.md b/docs/issues/272-add-https-support-with-caddy.md index 249693ed..ce2bcf57 100644 --- a/docs/issues/272-add-https-support-with-caddy.md +++ b/docs/issues/272-add-https-support-with-caddy.md @@ -948,7 +948,7 @@ 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 +#### 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: @@ -1001,7 +1001,7 @@ Services are running. Use 'test' to verify health. - [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)* + - [ ] 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` @@ -1048,6 +1048,94 @@ add the following to your /etc/hosts file: Internal ports (1212, 7070, 7071, 3000) are not directly accessible when TLS is enabled. ``` +#### 7.3: Add TLS Support for Health Check API + +**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**: + +- [ ] Add `tls: Option` to health check API domain model +- [ ] Add `tls: Option` to health check API DTOs +- [ ] Update Caddyfile template to include health check when TLS is configured +- [ ] 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 + +> **Note**: JSON schema regeneration deferred to Phase 8. + +#### 7.4: Handle Localhost-Bound Services in Show Command and Validation + +**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 +``` + +##### Part B: Show Command for Localhost Services (without TLS) + +For services bound to localhost WITHOUT TLS, display: + +```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 Scope**: + +- [ ] Add validation in create command to reject localhost + TLS combinations +- [ ] Update show command to detect localhost-bound services +- [ ] Display appropriate message for internal-only services +- [ ] Apply to all configurable HTTP services (health check, HTTP trackers, API, Grafana) + ### Phase 8: Schema Generation (30 minutes) - [ ] Regenerate JSON schema from Rust DTOs: From f143303dbc9ec791e57504d8c1b41ee711733938 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 15 Jan 2026 17:23:53 +0000 Subject: [PATCH 18/36] feat: [#272] add HTTPS/TLS support for health check API Task 7.3: Health Check API TLS Support - Add TLS configuration to HealthCheckApiConfig domain model - Add HealthCheckApiSection DTO with TLS support - Update ServiceInfo to include health_check_uses_https flag - Add health_check_api service to CaddyContext - Update Caddyfile.tera template for health check reverse proxy - Add TrackerContext health_check_api_bind_address field - Update tracker.toml.tera with [health_check_api] section - Update show command views to display HTTPS indicator for health check - Add tests for health check TLS configuration --- .../272-add-https-support-with-caddy.md | 12 +-- .../tracker/health_check_api_section.rs | 74 ++++++++++++++++++- .../command_handlers/show/info/tracker.rs | 37 ++++++++-- .../steps/rendering/caddy_templates.rs | 6 ++ src/domain/tracker/config/health_check_api.rs | 62 ++++++++++++++++ src/domain/tracker/config/mod.rs | 25 +++++++ src/domain/tracker/mod.rs | 1 + .../template/wrappers/variables/context.rs | 3 + .../template/wrapper/caddyfile/context.rs | 25 ++++++- .../template/renderer/project_generator.rs | 2 + .../wrapper/tracker_config/context.rs | 8 ++ .../show/environment_info/https_hint.rs | 2 + .../commands/show/environment_info/mod.rs | 3 + .../show/environment_info/tracker_services.rs | 34 ++++++++- templates/caddy/Caddyfile.tera | 7 ++ templates/tracker/tracker.toml.tera | 3 + 16 files changed, 289 insertions(+), 15 deletions(-) diff --git a/docs/issues/272-add-https-support-with-caddy.md b/docs/issues/272-add-https-support-with-caddy.md index ce2bcf57..5d1d038c 100644 --- a/docs/issues/272-add-https-support-with-caddy.md +++ b/docs/issues/272-add-https-support-with-caddy.md @@ -1048,7 +1048,7 @@ add the following to your /etc/hosts file: Internal ports (1212, 7070, 7071, 3000) are not directly accessible when TLS is enabled. ``` -#### 7.3: Add TLS Support for Health Check API +#### 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). @@ -1073,11 +1073,11 @@ Internal ports (1212, 7070, 7071, 3000) are not directly accessible when TLS is **Implementation Scope**: -- [ ] Add `tls: Option` to health check API domain model -- [ ] Add `tls: Option` to health check API DTOs -- [ ] Update Caddyfile template to include health check when TLS is configured -- [ ] 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 +- [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. 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..3aa1a3c9 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 @@ -4,11 +4,22 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::application::command_handlers::create::config::errors::CreateConfigError; +use crate::application::command_handlers::create::config::https::TlsSection; +use crate::domain::tls::TlsConfig; use crate::domain::tracker::HealthCheckApiConfig; +use crate::shared::DomainName; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] pub struct HealthCheckApiSection { pub bind_address: String, + + /// Optional TLS configuration for HTTPS + /// + /// When present, this service will be proxied through Caddy with HTTPS enabled. + /// The domain specified will be used for Let's Encrypt certificate acquisition. + /// This is useful for exposing health checks to external monitoring systems. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tls: Option, } impl HealthCheckApiSection { @@ -18,6 +29,7 @@ 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 TLS domain is invalid. 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 +46,22 @@ impl HealthCheckApiSection { }); } - Ok(HealthCheckApiConfig { bind_address }) + // Convert TLS section to domain type with validation + let tls = match &self.tls { + Some(tls_section) => { + tls_section.validate()?; + let domain = DomainName::new(&tls_section.domain).map_err(|e| { + CreateConfigError::InvalidDomain { + domain: tls_section.domain.clone(), + reason: e.to_string(), + } + })?; + Some(TlsConfig::new(domain)) + } + None => None, + }; + + Ok(HealthCheckApiConfig { bind_address, tls }) } } @@ -42,6 +69,7 @@ impl Default for HealthCheckApiSection { fn default() -> Self { Self { bind_address: "127.0.0.1:1313".to_string(), + tls: None, } } } @@ -54,6 +82,7 @@ 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(), + tls: None, }; let config = section.to_health_check_api_config().unwrap(); @@ -62,12 +91,33 @@ mod tests { config.bind_address, "127.0.0.1:1313".parse::().unwrap() ); + assert!(config.tls.is_none()); + } + + #[test] + fn it_should_convert_to_domain_config_with_tls() { + let section = HealthCheckApiSection { + bind_address: "0.0.0.0:1313".to_string(), + tls: Some(TlsSection { + domain: "health.tracker.local".to_string(), + }), + }; + + let config = section.to_health_check_api_config().unwrap(); + + assert_eq!( + config.bind_address, + "0.0.0.0:1313".parse::().unwrap() + ); + assert!(config.tls.is_some()); + 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(), + tls: None, }; let result = section.to_health_check_api_config(); @@ -83,6 +133,7 @@ mod tests { fn it_should_reject_dynamic_port_assignment() { let section = HealthCheckApiSection { bind_address: "0.0.0.0:0".to_string(), + tls: None, }; let result = section.to_health_check_api_config(); @@ -98,6 +149,7 @@ mod tests { fn it_should_allow_ipv6_addresses() { let section = HealthCheckApiSection { bind_address: "[::1]:1313".to_string(), + tls: None, }; let result = section.to_health_check_api_config(); @@ -109,6 +161,7 @@ mod tests { fn it_should_allow_any_port_except_zero() { let section = HealthCheckApiSection { bind_address: "127.0.0.1:8080".to_string(), + tls: None, }; let result = section.to_health_check_api_config(); @@ -121,5 +174,24 @@ mod tests { let section = HealthCheckApiSection::default(); assert_eq!(section.bind_address, "127.0.0.1:1313"); + assert!(section.tls.is_none()); + } + + #[test] + fn it_should_fail_when_tls_domain_is_invalid() { + let section = HealthCheckApiSection { + bind_address: "0.0.0.0:1313".to_string(), + tls: Some(TlsSection { + domain: "invalid domain with spaces".to_string(), + }), + }; + + let result = section.to_health_check_api_config(); + + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + CreateConfigError::InvalidDomain { .. } + )); } } diff --git a/src/application/command_handlers/show/info/tracker.rs b/src/application/command_handlers/show/info/tracker.rs index e65f0dcb..01658d27 100644 --- a/src/application/command_handlers/show/info/tracker.rs +++ b/src/application/command_handlers/show/info/tracker.rs @@ -29,9 +29,12 @@ pub struct ServiceInfo { /// Whether the API endpoint uses HTTPS via Caddy pub api_uses_https: bool, - /// Health check API URL (e.g., `http://10.0.0.1:1313/health_check`) + /// 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, + /// Domains configured for TLS services (for /etc/hosts hint) pub tls_domains: Vec, } @@ -59,6 +62,7 @@ impl TlsDomainInfo { impl ServiceInfo { /// Create a new `ServiceInfo` #[must_use] + #[allow(clippy::too_many_arguments)] pub fn new( udp_trackers: Vec, https_http_trackers: Vec, @@ -66,6 +70,7 @@ impl ServiceInfo { api_endpoint: String, api_uses_https: bool, health_check_url: String, + health_check_uses_https: bool, tls_domains: Vec, ) -> Self { Self { @@ -75,6 +80,7 @@ impl ServiceInfo { api_endpoint, api_uses_https, health_check_url, + health_check_uses_https, tls_domains, } } @@ -153,11 +159,24 @@ impl ServiceInfo { } } - 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 + let (health_check_url, health_check_uses_https) = + if let Some(tls) = &tracker_config.health_check_api.tls { + tls_domains.push(TlsDomainInfo { + domain: tls.domain().to_string(), + internal_port: tracker_config.health_check_api.bind_address.port(), + }); + (format!("https://{}/health_check", tls.domain()), true) + } else { + ( + format!( + "http://{}:{}/health_check", // DevSkim: ignore DS137138 + instance_ip, + tracker_config.health_check_api.bind_address.port() + ), + false, + ) + }; Self::new( udp_trackers, @@ -166,6 +185,7 @@ impl ServiceInfo { api_endpoint, api_uses_https, health_check_url, + health_check_uses_https, tls_domains, ) } @@ -210,6 +230,7 @@ impl ServiceInfo { api_endpoint, false, // Legacy endpoints don't have TLS info health_check_url, + false, // Legacy endpoints don't have health check TLS info Vec::new(), // No TLS domains from legacy endpoints ) } @@ -246,6 +267,7 @@ mod tests { "http://10.0.0.1:1212/api".to_string(), // DevSkim: ignore DS137138 false, "http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + false, // Health check doesn't use HTTPS vec![TlsDomainInfo { domain: "http1.tracker.local".to_string(), internal_port: 7070, @@ -270,6 +292,7 @@ mod tests { "https://api.tracker.local/api".to_string(), true, "http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + false, // Health check doesn't use HTTPS vec![ TlsDomainInfo { domain: "api.tracker.local".to_string(), @@ -297,6 +320,7 @@ mod tests { "https://api.tracker.local/api".to_string(), true, String::new(), + false, // Health check doesn't use HTTPS vec![ TlsDomainInfo { domain: "api.tracker.local".to_string(), @@ -324,6 +348,7 @@ mod tests { "http://10.0.0.1:1212/api".to_string(), // DevSkim: ignore DS137138 false, "http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + false, // Health check doesn't use HTTPS vec![], ); diff --git a/src/application/steps/rendering/caddy_templates.rs b/src/application/steps/rendering/caddy_templates.rs index 83d266e1..b137fdbb 100644 --- a/src/application/steps/rendering/caddy_templates.rs +++ b/src/application/steps/rendering/caddy_templates.rs @@ -172,6 +172,12 @@ impl RenderCaddyTemplatesStep { 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() { diff --git a/src/domain/tracker/config/health_check_api.rs b/src/domain/tracker/config/health_check_api.rs index 2647e5bb..69aa7cb3 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::domain::tls::TlsConfig; + /// Health Check API configuration /// /// The Health Check API is a minimal HTTP endpoint used by Docker and container @@ -18,32 +20,79 @@ pub struct HealthCheckApiConfig { deserialize_with = "crate::domain::tracker::config::deserialize_socket_addr" )] pub bind_address: SocketAddr, + + /// TLS configuration for HTTPS termination via Caddy (optional) + /// + /// When present, the Health Check API will be accessible via HTTPS + /// through the Caddy reverse proxy. This is useful when exposing + /// health checks to external monitoring systems or load balancers. + #[serde(skip_serializing_if = "Option::is_none")] + pub tls: Option, +} + +impl HealthCheckApiConfig { + /// Returns the TLS domain if configured + #[must_use] + pub fn tls_domain(&self) -> Option<&str> { + self.tls.as_ref().map(TlsConfig::domain) + } } #[cfg(test)] mod tests { use super::*; + use crate::shared::domain_name::DomainName; #[test] fn it_should_create_health_check_api_config() { let config = HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), + tls: None, }; assert_eq!( config.bind_address, "127.0.0.1:1313".parse::().unwrap() ); + assert!(config.tls.is_none()); + } + + #[test] + fn it_should_create_health_check_api_config_with_tls() { + let domain = DomainName::new("health.tracker.local").unwrap(); + let config = HealthCheckApiConfig { + bind_address: "0.0.0.0:1313".parse().unwrap(), + tls: Some(TlsConfig::new(domain)), + }; + + assert!(config.tls.is_some()); + 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(), + tls: None, }; let json = serde_json::to_value(&config).unwrap(); assert_eq!(json["bind_address"], "127.0.0.1:1313"); + // tls should not be serialized when None + assert!(json.get("tls").is_none()); + } + + #[test] + fn it_should_serialize_health_check_api_config_with_tls() { + let domain = DomainName::new("health.tracker.local").unwrap(); + let config = HealthCheckApiConfig { + bind_address: "0.0.0.0:1313".parse().unwrap(), + tls: Some(TlsConfig::new(domain)), + }; + + let json = serde_json::to_value(&config).unwrap(); + assert_eq!(json["bind_address"], "0.0.0.0:1313"); + assert_eq!(json["tls"]["domain"], "health.tracker.local"); } #[test] @@ -55,5 +104,18 @@ mod tests { config.bind_address, "127.0.0.1:1313".parse::().unwrap() ); + assert!(config.tls.is_none()); + } + + #[test] + fn it_should_deserialize_health_check_api_config_with_tls() { + let json = r#"{"bind_address": "0.0.0.0:1313", "tls": {"domain": "health.tracker.local"}}"#; + 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")); } } diff --git a/src/domain/tracker/config/mod.rs b/src/domain/tracker/config/mod.rs index 9c07ea15..5956b3e8 100644 --- a/src/domain/tracker/config/mod.rs +++ b/src/domain/tracker/config/mod.rs @@ -57,6 +57,7 @@ pub use udp::UdpTrackerConfig; /// }, /// health_check_api: HealthCheckApiConfig { /// bind_address: "127.0.0.1:1313".parse().unwrap(), +/// tls: None, /// }, /// }; /// ``` @@ -207,6 +208,7 @@ impl TrackerConfig { /// }, /// health_check_api: HealthCheckApiConfig { /// bind_address: "127.0.0.1:1313".parse().unwrap(), + /// tls: None, /// }, /// }; /// @@ -330,6 +332,18 @@ impl TrackerConfig { 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.as_ref().map(TlsConfig::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 configured /// /// Returns a vector of tuples containing (domain, port) for each @@ -401,6 +415,7 @@ impl Default for TrackerConfig { }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().expect("valid address"), + tls: None, }, } } @@ -448,6 +463,7 @@ mod tests { }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), + tls: None, }, }; @@ -475,6 +491,7 @@ mod tests { }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), + tls: None, }, }; @@ -542,6 +559,7 @@ mod tests { }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), + tls: None, }, }; @@ -573,6 +591,7 @@ mod tests { }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), + tls: None, }, }; @@ -622,6 +641,7 @@ mod tests { }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), + tls: None, }, }; @@ -663,6 +683,7 @@ mod tests { }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), + tls: None, }, }; @@ -706,6 +727,7 @@ mod tests { }, health_check_api: HealthCheckApiConfig { bind_address: "0.0.0.0:1313".parse().unwrap(), + tls: None, }, }; @@ -752,6 +774,7 @@ mod tests { }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), + tls: None, }, }; @@ -785,6 +808,7 @@ mod tests { }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), + tls: None, }, }; @@ -812,6 +836,7 @@ mod tests { }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), + tls: None, }, }; diff --git a/src/domain/tracker/mod.rs b/src/domain/tracker/mod.rs index 82324917..97789270 100644 --- a/src/domain/tracker/mod.rs +++ b/src/domain/tracker/mod.rs @@ -43,6 +43,7 @@ //! }, //! health_check_api: HealthCheckApiConfig { //! bind_address: "127.0.0.1:1313".parse().unwrap(), +//! tls: None, //! }, //! }; //! ``` diff --git a/src/infrastructure/templating/ansible/template/wrappers/variables/context.rs b/src/infrastructure/templating/ansible/template/wrappers/variables/context.rs index d23a1914..0842a3bf 100644 --- a/src/infrastructure/templating/ansible/template/wrappers/variables/context.rs +++ b/src/infrastructure/templating/ansible/template/wrappers/variables/context.rs @@ -214,6 +214,7 @@ mod tests { }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), + tls: None, }, }; @@ -246,6 +247,7 @@ mod tests { }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), + tls: None, }, }; @@ -289,6 +291,7 @@ mod tests { }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), + tls: None, }, }; diff --git a/src/infrastructure/templating/caddy/template/wrapper/caddyfile/context.rs b/src/infrastructure/templating/caddy/template/wrapper/caddyfile/context.rs index 3eb6a72c..3aece400 100644 --- a/src/infrastructure/templating/caddy/template/wrapper/caddyfile/context.rs +++ b/src/infrastructure/templating/caddy/template/wrapper/caddyfile/context.rs @@ -86,6 +86,7 @@ impl CaddyService { /// CaddyService::new("http2.example.com", 7071), /// ], /// grafana: Some(CaddyService::new("grafana.example.com", 3000)), +/// health_check_api: None, /// }; /// ``` /// @@ -120,6 +121,12 @@ pub struct CaddyContext { /// Trackers without TLS are served directly over HTTP, not through Caddy. pub http_trackers: Vec, + /// Health Check API service (if TLS configured) + /// + /// Present only if `tracker.health_check_api.tls` is configured. + /// 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. @@ -141,6 +148,7 @@ impl CaddyContext { use_staging, tracker_api: None, http_trackers: Vec::new(), + health_check_api: None, grafana: None, } } @@ -159,6 +167,13 @@ impl CaddyContext { 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 { @@ -171,7 +186,10 @@ impl CaddyContext { /// 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.grafana.is_some() + self.tracker_api.is_some() + || !self.http_trackers.is_empty() + || self.health_check_api.is_some() + || self.grafana.is_some() } } @@ -215,6 +233,10 @@ mod tests { .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()); @@ -228,6 +250,7 @@ mod tests { 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()); } diff --git a/src/infrastructure/templating/tracker/template/renderer/project_generator.rs b/src/infrastructure/templating/tracker/template/renderer/project_generator.rs index 43e14111..4afeeb7e 100644 --- a/src/infrastructure/templating/tracker/template/renderer/project_generator.rs +++ b/src/infrastructure/templating/tracker/template/renderer/project_generator.rs @@ -233,6 +233,7 @@ mod tests { }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), + tls: None, }, }; @@ -285,6 +286,7 @@ mod tests { }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), + tls: None, }, }; 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 417e86e5..9abe6ace 100644 --- a/src/infrastructure/templating/tracker/template/wrapper/tracker_config/context.rs +++ b/src/infrastructure/templating/tracker/template/wrapper/tracker_config/context.rs @@ -47,6 +47,7 @@ use crate::domain::environment::TrackerConfig; /// }, /// health_check_api: HealthCheckApiConfig { /// bind_address: "127.0.0.1:1313".parse().unwrap(), +/// tls: None, /// }, /// }; /// let context = TrackerContext::from_config(&tracker_config); @@ -90,6 +91,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 @@ -150,6 +154,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(), } } @@ -184,6 +189,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(), } } } @@ -230,6 +236,7 @@ mod tests { }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), + tls: None, }, } } @@ -281,6 +288,7 @@ mod tests { }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), + tls: None, }, }; diff --git a/src/presentation/views/commands/show/environment_info/https_hint.rs b/src/presentation/views/commands/show/environment_info/https_hint.rs index 6b70f0b3..d4524eba 100644 --- a/src/presentation/views/commands/show/environment_info/https_hint.rs +++ b/src/presentation/views/commands/show/environment_info/https_hint.rs @@ -80,6 +80,7 @@ mod tests { "http://10.0.0.1:1212/api".to_string(), // DevSkim: ignore DS137138 false, "http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + false, // Health check doesn't use HTTPS vec![], // No TLS domains ) } @@ -92,6 +93,7 @@ mod tests { "https://api.tracker.local/api".to_string(), true, "http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + false, // Health check doesn't use HTTPS vec![ TlsDomainInfo::new("api.tracker.local".to_string(), 1212), TlsDomainInfo::new("http1.tracker.local".to_string(), 7070), diff --git a/src/presentation/views/commands/show/environment_info/mod.rs b/src/presentation/views/commands/show/environment_info/mod.rs index 13b58e87..cd4c2d1b 100644 --- a/src/presentation/views/commands/show/environment_info/mod.rs +++ b/src/presentation/views/commands/show/environment_info/mod.rs @@ -236,6 +236,7 @@ mod tests { "http://10.0.0.1:1212/api".to_string(), // DevSkim: ignore DS137138 false, // API doesn't use HTTPS "http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + false, // Health check doesn't use HTTPS vec![], // No TLS domains )); @@ -274,6 +275,7 @@ mod tests { "http://192.168.1.100:1212/api".to_string(), // DevSkim: ignore DS137138 false, "http://192.168.1.100:1313/health_check".to_string(), // DevSkim: ignore DS137138 + false, // Health check doesn't use HTTPS vec![], )); @@ -314,6 +316,7 @@ mod tests { "https://api.tracker.local/api".to_string(), true, // API uses HTTPS "http://10.140.190.214:1313/health_check".to_string(), // DevSkim: ignore DS137138 + false, // Health check doesn't use HTTPS vec![ TlsDomainInfo::new("api.tracker.local".to_string(), 1212), TlsDomainInfo::new("http1.tracker.local".to_string(), 7070), diff --git a/src/presentation/views/commands/show/environment_info/tracker_services.rs b/src/presentation/views/commands/show/environment_info/tracker_services.rs index e08c0db8..d525265b 100644 --- a/src/presentation/views/commands/show/environment_info/tracker_services.rs +++ b/src/presentation/views/commands/show/environment_info/tracker_services.rs @@ -61,7 +61,11 @@ impl TrackerServicesView { lines.push(format!(" - {}", services.api_endpoint)); // Health check - lines.push(" Health Check:".to_string()); + if services.health_check_uses_https { + lines.push(" Health Check (HTTPS via Caddy):".to_string()); + } else { + lines.push(" Health Check:".to_string()); + } lines.push(format!(" - {}", services.health_check_url)); lines @@ -81,6 +85,7 @@ mod tests { "http://10.0.0.1:1212/api".to_string(), // DevSkim: ignore DS137138 false, // API doesn't use HTTPS "http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + false, // Health check doesn't use HTTPS vec![], // No TLS domains ) } @@ -96,6 +101,7 @@ mod tests { "https://api.tracker.local/api".to_string(), true, // API uses HTTPS "http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + false, // Health check doesn't use HTTPS (yet) vec![ TlsDomainInfo::new("api.tracker.local".to_string(), 1212), TlsDomainInfo::new("http1.tracker.local".to_string(), 7070), @@ -157,11 +163,36 @@ mod tests { 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![], + "http://10.0.0.1:1212/api".to_string(), // DevSkim: ignore DS137138 + false, + "https://health.tracker.local/health_check".to_string(), + true, // Health check uses HTTPS + 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( @@ -171,6 +202,7 @@ mod tests { "http://10.0.0.1:1212/api".to_string(), // DevSkim: ignore DS137138 false, "http://10.0.0.1:1313/health_check".to_string(), // DevSkim: ignore DS137138 + false, // Health check doesn't use HTTPS vec![], ); diff --git a/templates/caddy/Caddyfile.tera b/templates/caddy/Caddyfile.tera index 2bffac8e..4cb2932f 100644 --- a/templates/caddy/Caddyfile.tera +++ b/templates/caddy/Caddyfile.tera @@ -28,6 +28,13 @@ reverse_proxy tracker:{{ http_tracker.port }} } {%- 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 diff --git a/templates/tracker/tracker.toml.tera b/templates/tracker/tracker.toml.tera index 6fda2056..925283f6 100644 --- a/templates/tracker/tracker.toml.tera +++ b/templates/tracker/tracker.toml.tera @@ -44,3 +44,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 }}" From d21f313b0a6726c19d9ad2a03644ee76db7ee65c Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 15 Jan 2026 17:41:16 +0000 Subject: [PATCH 19/36] docs: [#272] clarify task 7.4 implementation notes - Validation occurs in domain layer during DTO-to-domain conversion - Grafana excluded (bind address hardcoded at port 3000) - Localhost detection: 127.0.0.1 and ::1 only - Add is_localhost_only field to ServiceInfo (not message in URL) - Show 'Internal only' for localhost services (never hide) --- .../272-add-https-support-with-caddy.md | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/docs/issues/272-add-https-support-with-caddy.md b/docs/issues/272-add-https-support-with-caddy.md index 5d1d038c..3114cf66 100644 --- a/docs/issues/272-add-https-support-with-caddy.md +++ b/docs/issues/272-add-https-support-with-caddy.md @@ -1113,6 +1113,14 @@ Error: Invalid configuration for health_check_api - 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: @@ -1129,12 +1137,19 @@ 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**: -- [ ] Add validation in create command to reject localhost + TLS combinations +- [ ] Add validation in domain layer to reject localhost + TLS combinations (during DTO-to-domain conversion) - [ ] Update show command to detect localhost-bound services -- [ ] Display appropriate message for internal-only services -- [ ] Apply to all configurable HTTP services (health check, HTTP trackers, API, Grafana) +- [ ] Add `is_localhost_only` field to `ServiceInfo` for health check, API, and HTTP trackers +- [ ] Display "Internal only" message for internal-only services +- [ ] Apply to: health check API, HTTP API, HTTP trackers (Grafana excluded - hardcoded port) ### Phase 8: Schema Generation (30 minutes) From 5334429efcc28fb4521173b551b82cc7b0edb7da Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Thu, 15 Jan 2026 18:45:57 +0000 Subject: [PATCH 20/36] feat: [#272] Handle localhost-bound services in show command and validation (Task 7.4) - Add LocalhostWithTls error to reject localhost + TLS combinations in domain layer - Add is_localhost helper function for detecting 127.0.0.1 and ::1 - Add is_localhost_only fields to ServiceInfo for API, health check, HTTP trackers - Update show command to display "Internal only" for localhost services - Add SSH tunnel hint for localhost-only services - Validation logic in TrackerConfig::validate() to avoid duplication --- .../272-add-https-support-with-caddy.md | 12 +- .../command_handlers/create/config/errors.rs | 2 +- .../tracker/health_check_api_section.rs | 3 + .../create/config/tracker/http_api_section.rs | 20 + .../config/tracker/http_tracker_section.rs | 19 + .../command_handlers/show/info/mod.rs | 2 +- .../command_handlers/show/info/tracker.rs | 148 +++++++- src/domain/tracker/config/mod.rs | 345 +++++++++++++++++- src/domain/tracker/mod.rs | 7 +- .../show/environment_info/https_hint.rs | 6 + .../commands/show/environment_info/mod.rs | 9 + .../show/environment_info/tracker_services.rs | 143 +++++++- 12 files changed, 689 insertions(+), 27 deletions(-) diff --git a/docs/issues/272-add-https-support-with-caddy.md b/docs/issues/272-add-https-support-with-caddy.md index 3114cf66..ad3b1a66 100644 --- a/docs/issues/272-add-https-support-with-caddy.md +++ b/docs/issues/272-add-https-support-with-caddy.md @@ -1081,7 +1081,7 @@ Internal ports (1212, 7070, 7071, 3000) are not directly accessible when TLS is > **Note**: JSON schema regeneration deferred to Phase 8. -#### 7.4: Handle Localhost-Bound Services in Show Command and Validation +#### 7.4: Handle Localhost-Bound Services in Show Command and Validation βœ… COMPLETE **Current State**: @@ -1145,11 +1145,11 @@ Health Check: **Implementation Scope**: -- [ ] Add validation in domain layer to reject localhost + TLS combinations (during DTO-to-domain conversion) -- [ ] Update show command to detect localhost-bound services -- [ ] Add `is_localhost_only` field to `ServiceInfo` for health check, API, and HTTP trackers -- [ ] Display "Internal only" message for internal-only services -- [ ] Apply to: health check API, HTTP API, HTTP trackers (Grafana excluded - hardcoded port) +- [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) ### Phase 8: Schema Generation (30 minutes) diff --git a/src/application/command_handlers/create/config/errors.rs b/src/application/command_handlers/create/config/errors.rs index 191cb543..8d2d6794 100644 --- a/src/application/command_handlers/create/config/errors.rs +++ b/src/application/command_handlers/create/config/errors.rs @@ -675,7 +675,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/tracker/health_check_api_section.rs b/src/application/command_handlers/create/config/tracker/health_check_api_section.rs index 3aa1a3c9..39f7b15b 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 @@ -30,6 +30,9 @@ 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 TLS domain is invalid. + /// + /// 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| { 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 354582a5..4ddecea9 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 @@ -31,6 +31,9 @@ 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 TLS domain is invalid. + /// + /// 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| { @@ -151,4 +154,21 @@ mod tests { assert_eq!(section.bind_address, "0.0.0.0:1212"); assert_eq!(section.admin_token, "MyAccessToken"); } + + #[test] + fn it_should_allow_non_localhost_with_tls() { + let section = HttpApiSection { + bind_address: "0.0.0.0:1212".to_string(), + admin_token: "token".to_string(), + tls: Some(TlsSection { + domain: "api.tracker.local".to_string(), + }), + }; + + let result = section.to_http_api_config(); + + assert!(result.is_ok()); + let config = result.unwrap(); + assert!(config.tls.is_some()); + } } 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 c2a736d9..a2d8be4b 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 @@ -29,6 +29,9 @@ 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 TLS domain is invalid. + /// + /// 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| { @@ -137,4 +140,20 @@ mod tests { let section: HttpTrackerSection = serde_json::from_str(json).unwrap(); assert_eq!(section.bind_address, "0.0.0.0:7070"); } + + #[test] + fn it_should_allow_non_localhost_with_tls() { + let section = HttpTrackerSection { + bind_address: "0.0.0.0:7070".to_string(), + tls: Some(TlsSection { + domain: "tracker.local".to_string(), + }), + }; + + let result = section.to_http_tracker_config(); + + assert!(result.is_ok()); + let config = result.unwrap(); + assert!(config.tls.is_some()); + } } diff --git a/src/application/command_handlers/show/info/mod.rs b/src/application/command_handlers/show/info/mod.rs index 03764c35..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, TlsDomainInfo}; +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 01658d27..87912212 100644 --- a/src/application/command_handlers/show/info/tracker.rs +++ b/src/application/command_handlers/show/info/tracker.rs @@ -6,6 +6,7 @@ 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 @@ -13,6 +14,7 @@ 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, @@ -23,22 +25,40 @@ pub struct ServiceInfo { /// 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, /// 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 { @@ -63,24 +83,31 @@ 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, 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, 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, } } @@ -89,7 +116,7 @@ impl ServiceInfo { /// /// This method constructs service URLs by combining the configured bind /// addresses with the actual instance IP address. It separates HTTP trackers - /// into HTTPS-enabled (via Caddy) and direct HTTP access groups. + /// into HTTPS-enabled (via Caddy), direct HTTP access, and localhost-only groups. /// /// # Arguments /// @@ -108,21 +135,30 @@ impl ServiceInfo { .map(|udp| format!("udp://{}:{}/announce", instance_ip, udp.bind_address.port())) .collect(); - // Separate HTTP trackers by TLS configuration + // 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 http in &tracker_config.http_trackers { + for (index, http) in tracker_config.http_trackers.iter().enumerate() { if let Some(tls) = &http.tls { // 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", tls.domain())); tls_domains.push(TlsDomainInfo { domain: tls.domain().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 tracker - use direct IP URL + // Non-TLS, non-localhost tracker - use direct IP URL direct_http_trackers.push(format!( "http://{}:{}/announce", // DevSkim: ignore DS137138 instance_ip, @@ -131,7 +167,8 @@ impl ServiceInfo { } } - // Build API endpoint based on TLS configuration + // 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 let Some(tls) = &tracker_config.http_api.tls { tls_domains.push(TlsDomainInfo { domain: tls.domain().to_string(), @@ -159,7 +196,9 @@ impl ServiceInfo { } } - // Build health check URL based on TLS configuration + // 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(tls) = &tracker_config.health_check_api.tls { tls_domains.push(TlsDomainInfo { @@ -182,10 +221,13 @@ impl ServiceInfo { 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, ) } @@ -206,7 +248,7 @@ impl ServiceInfo { .collect(); // For backward compatibility, all HTTP trackers go to direct access - // (stored endpoints don't have TLS information) + // (stored endpoints don't have TLS or localhost information) let direct_http_trackers = endpoints .http_trackers .iter() @@ -227,10 +269,13 @@ impl ServiceInfo { 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 ) } @@ -241,6 +286,14 @@ impl ServiceInfo { !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> { @@ -264,10 +317,13 @@ mod tests { vec!["udp://10.0.0.1:6969/announce".to_string()], 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 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, @@ -279,8 +335,10 @@ mod tests { 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] @@ -289,10 +347,13 @@ mod tests { 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(), @@ -317,10 +378,13 @@ mod tests { 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(), @@ -345,15 +409,85 @@ mod tests { 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/domain/tracker/config/mod.rs b/src/domain/tracker/config/mod.rs index 5956b3e8..a315f03b 100644 --- a/src/domain/tracker/config/mod.rs +++ b/src/domain/tracker/config/mod.rs @@ -5,7 +5,7 @@ use std::collections::HashMap; use std::fmt; -use std::net::SocketAddr; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use serde::{Deserialize, Serialize}; @@ -24,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 @@ -91,6 +113,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 { @@ -112,6 +146,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" + ) + } } } } @@ -162,6 +206,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 } } @@ -215,10 +291,54 @@ impl TrackerConfig { /// 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.tls.is_some() && 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.tls.is_some() && 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.tls.is_some() && 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 @@ -440,6 +560,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 { @@ -862,4 +1023,186 @@ 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(), + tls: None, + }, + health_check_api: HealthCheckApiConfig { + bind_address: "127.0.0.1:1313".parse().unwrap(), + tls: None, + }, + } + } + + #[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(), + tls: Some(TlsConfig::new(domain)), + }; + + 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(), + tls: Some(TlsConfig::new(domain)), + }; + + 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(), + tls: Some(TlsConfig::new(domain)), + }; + + 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(), + tls: Some(TlsConfig::new(domain)), + }]; + + 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(), + tls: Some(TlsConfig::new(domain)), + }; + + 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(), + tls: Some(TlsConfig::new(domain)), + }; + + 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 97789270..e8470df6 100644 --- a/src/domain/tracker/mod.rs +++ b/src/domain/tracker/mod.rs @@ -49,12 +49,13 @@ //! ``` 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/presentation/views/commands/show/environment_info/https_hint.rs b/src/presentation/views/commands/show/environment_info/https_hint.rs index d4524eba..2b003af4 100644 --- a/src/presentation/views/commands/show/environment_info/https_hint.rs +++ b/src/presentation/views/commands/show/environment_info/https_hint.rs @@ -77,10 +77,13 @@ mod tests { 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 ) } @@ -90,10 +93,13 @@ mod tests { 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), diff --git a/src/presentation/views/commands/show/environment_info/mod.rs b/src/presentation/views/commands/show/environment_info/mod.rs index cd4c2d1b..6275bf63 100644 --- a/src/presentation/views/commands/show/environment_info/mod.rs +++ b/src/presentation/views/commands/show/environment_info/mod.rs @@ -233,10 +233,13 @@ mod tests { 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 )); @@ -272,10 +275,13 @@ mod tests { vec!["udp://192.168.1.100:6969/announce".to_string()], 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![], )); @@ -313,10 +319,13 @@ mod tests { "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), diff --git a/src/presentation/views/commands/show/environment_info/tracker_services.rs b/src/presentation/views/commands/show/environment_info/tracker_services.rs index d525265b..c6fd954b 100644 --- a/src/presentation/views/commands/show/environment_info/tracker_services.rs +++ b/src/presentation/views/commands/show/environment_info/tracker_services.rs @@ -52,21 +52,46 @@ impl TrackerServicesView { } } - // API endpoint with HTTPS indicator - if services.api_uses_https { + // 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)); } - lines.push(format!(" - {}", services.api_endpoint)); - // Health check - if services.health_check_uses_https { + // 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.push(format!(" - {}", services.health_check_url)); lines } @@ -75,17 +100,20 @@ impl TrackerServicesView { #[cfg(test)] mod tests { use super::*; - use crate::application::command_handlers::show::info::TlsDomainInfo; + 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 ) } @@ -98,10 +126,13 @@ mod tests { "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), @@ -177,10 +208,13 @@ mod tests { 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 + true, // Health check uses HTTPS + false, // Health check not localhost-only vec![TlsDomainInfo::new("health.tracker.local".to_string(), 1313)], ); @@ -199,14 +233,107 @@ mod tests { 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 + ); + } } From a857e076262cf01f63c94fdf26387ecc0acd80a3 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 16 Jan 2026 18:48:12 +0000 Subject: [PATCH 21/36] docs: [#272] Add subtask 7.5 for use_tls_proxy configuration refactor - Add subtask 7.5 to fix on_reverse_proxy tracker config bug - Replace misleading 'tls' object with 'domain' + 'use_tls_proxy' fields - Document incremental implementation plan (one service at a time) - Add before/after examples using full manual-https-test.json - Document dependency rule: use_tls_proxy implies on_reverse_proxy - Document future compatibility with per-tracker on_reverse_proxy --- .../272-add-https-support-with-caddy.md | 302 ++++++++++++++++++ 1 file changed, 302 insertions(+) diff --git a/docs/issues/272-add-https-support-with-caddy.md b/docs/issues/272-add-https-support-with-caddy.md index ad3b1a66..60c5a841 100644 --- a/docs/issues/272-add-https-support-with-caddy.md +++ b/docs/issues/272-add-https-support-with-caddy.md @@ -1151,6 +1151,308 @@ Health Check: - [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. + +**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 + +- [ ] Add `domain: Option` and `use_tls_proxy: Option` to `HttpTrackerSection` DTO +- [ ] Update `HttpTrackerConfig` domain type to include `use_tls_proxy` and `domain` +- [ ] Add validation: `use_tls_proxy: true` requires `domain` to be present +- [ ] Add validation: `use_tls_proxy: true` with localhost bind address β†’ reject +- [ ] 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` +- [ ] Update Caddy template (`templates/caddy/Caddyfile.tera`) to check `use_tls_proxy` for HTTP trackers +- [ ] Update show command `ServiceInfo` for HTTP trackers +- [ ] Update `envs/manual-https-test.json` for HTTP trackers only +- [ ] Remove `TlsSection` from HTTP trackers (keep in other services temporarily) +- [ ] Add unit tests for HTTP tracker validation +- [ ] Run E2E tests to verify HTTP trackers work + +##### Step 7.5.2: Tracker REST API + +- [ ] Add `domain: Option` and `use_tls_proxy: Option` to `HttpApiSection` DTO +- [ ] Update `HttpApiConfig` domain type +- [ ] Add validation rules (same as HTTP trackers) +- [ ] Update Caddy template for API +- [ ] Update show command `ServiceInfo` for API +- [ ] Update `envs/manual-https-test.json` for API +- [ ] Remove `TlsSection` from API +- [ ] Add unit tests for API validation +- [ ] Run E2E tests + +##### Step 7.5.3: Tracker Health Check API + +- [ ] Add `domain: Option` and `use_tls_proxy: Option` to `HealthCheckApiSection` DTO +- [ ] Update `HealthCheckApiConfig` domain type +- [ ] Add validation rules +- [ ] Update Caddy template for health check +- [ ] Update show command `ServiceInfo` for health check +- [ ] Update `envs/manual-https-test.json` for health check +- [ ] Remove `TlsSection` from health check +- [ ] Add unit tests +- [ ] Run E2E tests + +##### Step 7.5.4: Grafana + +- [ ] Add `domain: Option` and `use_tls_proxy: Option` to `GrafanaSection` DTO +- [ ] Update `GrafanaConfig` domain type +- [ ] Add validation rules (note: Grafana has no configurable bind address, so localhost validation not needed) +- [ ] Update Caddy template for Grafana +- [ ] Update show command `ServiceInfo` for Grafana +- [ ] Update `envs/manual-https-test.json` for Grafana +- [ ] Remove `TlsSection` from Grafana +- [ ] Add unit tests +- [ ] Run E2E tests + +##### Step 7.5.5: Cleanup and Final Verification + +- [ ] Remove `TlsSection` type completely (should be unused after all services migrated) +- [ ] Run full E2E test suite +- [ ] Run all linters +- [ ] Manual verification with `envs/manual-https-test.json` + ### Phase 8: Schema Generation (30 minutes) - [ ] Regenerate JSON schema from Rust DTOs: From bf7322709066cf07143f36654712af2601b6704f Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 16 Jan 2026 18:57:05 +0000 Subject: [PATCH 22/36] docs: [#272] Document tracker on_reverse_proxy global setting limitation - Document issue discovered during HTTPS/Caddy implementation - Explain why per-HTTP-tracker on_reverse_proxy is needed - Propose solution with backward-compatible configuration - Reference deployer workaround (all-or-nothing proxy rule) --- .../on-reverse-proxy-global-setting.md | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 docs/external-issues/tracker/on-reverse-proxy-global-setting.md 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) From d796db75d3bc8ce73bdb7d057f5ea08592f884be Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 16 Jan 2026 19:06:36 +0000 Subject: [PATCH 23/36] docs: [#272] Add reproduction steps for on_reverse_proxy issue - Add 'How to Reproduce' section to task 7.5 - Document step-by-step verification of the problem - Include actual error message from tracker - Link to upstream issue torrust/torrust-tracker#1640 --- .../272-add-https-support-with-caddy.md | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/docs/issues/272-add-https-support-with-caddy.md b/docs/issues/272-add-https-support-with-caddy.md index 60c5a841..e5aab777 100644 --- a/docs/issues/272-add-https-support-with-caddy.md +++ b/docs/issues/272-add-https-support-with-caddy.md @@ -1179,6 +1179,39 @@ The `on_reverse_proxy` option is **global** (in `[core.net]`), not per-tracker. 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. From 80b4b327e021435b21e3076afda1ff3c566fd637 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 19 Jan 2026 12:48:59 +0000 Subject: [PATCH 24/36] refactor: [#272] replace TlsSection with domain + use_tls_proxy for HTTP trackers Step 7.5.1 of HTTPS support implementation: - Replace tls: Option with domain: Option + use_tls_proxy: Option in HttpTrackerSection DTO - Update HttpTrackerConfig domain type with domain: Option and use_tls_proxy: bool - Add validation: use_tls_proxy: true requires domain to be present (TlsProxyWithoutDomain error) - Add validation: use_tls_proxy: true with localhost bind address is rejected - Update tracker.toml.tera template to use dynamic on_reverse_proxy based on any_http_tracker_uses_tls_proxy() - Update Caddy template context to filter HTTP trackers by use_tls_proxy - Update show command ServiceInfo for HTTP tracker display - Update envs/manual-https-test.json for HTTP trackers only - Add unit tests for new validation logic The on_reverse_proxy setting is now conditional: true only when ANY HTTP tracker has use_tls_proxy: true, false otherwise. This fixes the hardcoded value that was always true. --- .../272-add-https-support-with-caddy.md | 24 ++- .../create/config/environment_config.rs | 23 ++- .../command_handlers/create/config/errors.rs | 32 ++++ .../config/tracker/http_tracker_section.rs | 137 ++++++++++++++---- .../create/config/tracker/tracker_section.rs | 21 ++- .../command_handlers/show/info/tracker.rs | 20 +-- src/domain/tracker/config/http.rs | 66 +++++++-- src/domain/tracker/config/mod.rs | 60 +++++--- src/domain/tracker/mod.rs | 2 +- .../template/wrappers/variables/context.rs | 6 +- .../template/renderer/project_generator.rs | 6 +- .../wrapper/tracker_config/context.rs | 20 ++- templates/tracker/tracker.toml.tera | 5 +- 13 files changed, 316 insertions(+), 106 deletions(-) diff --git a/docs/issues/272-add-https-support-with-caddy.md b/docs/issues/272-add-https-support-with-caddy.md index e5aab777..84205f6f 100644 --- a/docs/issues/272-add-https-support-with-caddy.md +++ b/docs/issues/272-add-https-support-with-caddy.md @@ -1403,13 +1403,11 @@ The name `use_tls_proxy` accurately describes what our Caddy proxy does: **TLS t **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 @@ -1431,17 +1429,17 @@ The implementation is split into incremental steps, one service type at a time, ##### Step 7.5.1: HTTP Trackers -- [ ] Add `domain: Option` and `use_tls_proxy: Option` to `HttpTrackerSection` DTO -- [ ] Update `HttpTrackerConfig` domain type to include `use_tls_proxy` and `domain` -- [ ] Add validation: `use_tls_proxy: true` requires `domain` to be present -- [ ] Add validation: `use_tls_proxy: true` with localhost bind address β†’ reject -- [ ] 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` -- [ ] Update Caddy template (`templates/caddy/Caddyfile.tera`) to check `use_tls_proxy` for HTTP trackers -- [ ] Update show command `ServiceInfo` for HTTP trackers -- [ ] Update `envs/manual-https-test.json` for HTTP trackers only -- [ ] Remove `TlsSection` from HTTP trackers (keep in other services temporarily) -- [ ] Add unit tests for HTTP tracker validation -- [ ] Run E2E tests to verify HTTP trackers work +- [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 diff --git a/src/application/command_handlers/create/config/environment_config.rs b/src/application/command_handlers/create/config/environment_config.rs index 036ce868..78390c03 100644 --- a/src/application/command_handlers/create/config/environment_config.rs +++ b/src/application/command_handlers/create/config/environment_config.rs @@ -403,7 +403,7 @@ impl EnvironmentCreationConfig { // Check HTTP trackers for http_tracker in &self.tracker.http_trackers { - if http_tracker.tls.is_some() { + if http_tracker.use_tls_proxy == Some(true) { return true; } } @@ -507,7 +507,8 @@ impl EnvironmentCreationConfig { }], http_trackers: vec![super::tracker::HttpTrackerSection { bind_address: "0.0.0.0:7070".to_string(), - tls: None, + domain: None, + use_tls_proxy: None, }], http_api: super::tracker::HttpApiSection { bind_address: "0.0.0.0:1212".to_string(), @@ -1409,7 +1410,8 @@ mod tests { }], http_trackers: vec![HttpTrackerSection { bind_address: "0.0.0.0:7070".to_string(), - tls: None, + domain: None, + use_tls_proxy: None, }], http_api: HttpApiSection { bind_address: "0.0.0.0:1212".to_string(), @@ -1467,7 +1469,6 @@ mod tests { #[test] fn it_should_fail_validation_when_tls_without_https_section() { - use crate::application::command_handlers::create::config::https::TlsSection; use crate::application::command_handlers::create::config::tracker::{ DatabaseSection, HealthCheckApiSection, HttpApiSection, HttpTrackerSection, TrackerCoreSection, TrackerSection, UdpTrackerSection, @@ -1485,9 +1486,8 @@ mod tests { }], http_trackers: vec![HttpTrackerSection { bind_address: "0.0.0.0:7070".to_string(), - tls: Some(TlsSection { - domain: "tracker.example.com".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(), @@ -1558,9 +1558,7 @@ mod tests { #[test] fn it_should_pass_validation_when_https_section_with_tls() { - use crate::application::command_handlers::create::config::https::{ - HttpsSection, TlsSection, - }; + use crate::application::command_handlers::create::config::https::HttpsSection; use crate::application::command_handlers::create::config::tracker::{ DatabaseSection, HealthCheckApiSection, HttpApiSection, HttpTrackerSection, TrackerCoreSection, TrackerSection, UdpTrackerSection, @@ -1578,9 +1576,8 @@ mod tests { }], http_trackers: vec![HttpTrackerSection { bind_address: "0.0.0.0:7070".to_string(), - tls: Some(TlsSection { - domain: "tracker.example.com".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(), diff --git a/src/application/command_handlers/create/config/errors.rs b/src/application/command_handlers/create/config/errors.rs index 8d2d6794..4c3429a4 100644 --- a/src/application/command_handlers/create/config/errors.rs +++ b/src/application/command_handlers/create/config/errors.rs @@ -136,6 +136,15 @@ pub enum CreateConfigError { /// 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 { @@ -591,6 +600,29 @@ impl CreateConfigError { \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." + } } } } 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 a2d8be4b..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 @@ -4,8 +4,6 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::application::command_handlers::create::config::errors::CreateConfigError; -use crate::application::command_handlers::create::config::https::TlsSection; -use crate::domain::tls::TlsConfig; use crate::domain::tracker::HttpTrackerConfig; use crate::shared::DomainName; @@ -13,12 +11,27 @@ use crate::shared::DomainName; pub struct HttpTrackerSection { pub bind_address: String, - /// Optional TLS configuration for HTTPS + /// Domain name for HTTPS certificate acquisition /// - /// When present, this HTTP tracker will be proxied through Caddy with HTTPS enabled. - /// The domain specified will be used for Let's Encrypt 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 tls: Option, + 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 { @@ -28,7 +41,8 @@ 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 TLS domain is invalid. + /// 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. @@ -48,22 +62,34 @@ impl HttpTrackerSection { }); } - // Convert TLS section to domain type with validation - let tls = match &self.tls { - Some(tls_section) => { - tls_section.validate()?; - let domain = DomainName::new(&tls_section.domain).map_err(|e| { - CreateConfigError::InvalidDomain { - domain: tls_section.domain.clone(), + 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(TlsConfig::new(domain)) + })?; + Some(domain) } None => None, }; - Ok(HttpTrackerConfig { bind_address, tls }) + Ok(HttpTrackerConfig { + bind_address, + domain, + use_tls_proxy, + }) } } @@ -75,7 +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(), - tls: None, + domain: None, + use_tls_proxy: None, }; let result = section.to_http_tracker_config(); @@ -86,13 +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(), - tls: None, + domain: None, + use_tls_proxy: None, }; let result = section.to_http_tracker_config(); @@ -109,7 +138,8 @@ mod tests { fn it_should_reject_port_zero() { let section = HttpTrackerSection { bind_address: "0.0.0.0:0".to_string(), - tls: None, + domain: None, + use_tls_proxy: None, }; let result = section.to_http_tracker_config(); @@ -126,7 +156,8 @@ mod tests { fn it_should_be_serializable() { let section = HttpTrackerSection { bind_address: "0.0.0.0:7070".to_string(), - tls: None, + domain: None, + use_tls_proxy: None, }; let json = serde_json::to_string(§ion).unwrap(); @@ -139,21 +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() { + fn it_should_allow_non_localhost_with_tls_proxy() { let section = HttpTrackerSection { bind_address: "0.0.0.0:7070".to_string(), - tls: Some(TlsSection { - domain: "tracker.local".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.tls.is_some()); + 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 2bc86e67..770ab296 100644 --- a/src/application/command_handlers/create/config/tracker/tracker_section.rs +++ b/src/application/command_handlers/create/config/tracker/tracker_section.rs @@ -128,7 +128,8 @@ impl Default for TrackerSection { }], http_trackers: vec![HttpTrackerSection { bind_address: "0.0.0.0:7070".to_string(), - tls: None, + domain: None, + use_tls_proxy: None, }], http_api: HttpApiSection { bind_address: "0.0.0.0:1212".to_string(), @@ -162,7 +163,8 @@ mod tests { }], http_trackers: vec![HttpTrackerSection { bind_address: "0.0.0.0:7070".to_string(), - tls: None, + domain: None, + use_tls_proxy: None, }], http_api: HttpApiSection { bind_address: "0.0.0.0:1212".to_string(), @@ -209,11 +211,13 @@ mod tests { http_trackers: vec![ HttpTrackerSection { bind_address: "0.0.0.0:7070".to_string(), - tls: None, + domain: None, + use_tls_proxy: None, }, HttpTrackerSection { bind_address: "0.0.0.0:7071".to_string(), - tls: None, + domain: None, + use_tls_proxy: None, }, ], http_api: HttpApiSection { @@ -274,7 +278,8 @@ mod tests { }], http_trackers: vec![HttpTrackerSection { bind_address: "0.0.0.0:7070".to_string(), - tls: None, + domain: None, + use_tls_proxy: None, }], http_api: HttpApiSection { bind_address: "0.0.0.0:1212".to_string(), @@ -336,7 +341,8 @@ mod tests { udp_trackers: vec![], http_trackers: vec![HttpTrackerSection { bind_address: "0.0.0.0:7070".to_string(), - tls: None, + domain: None, + use_tls_proxy: None, }], http_api: HttpApiSection { bind_address: "0.0.0.0:7070".to_string(), @@ -369,7 +375,8 @@ mod tests { }], http_trackers: vec![HttpTrackerSection { bind_address: "0.0.0.0:7070".to_string(), - tls: None, + domain: None, + use_tls_proxy: None, }], http_api: HttpApiSection { bind_address: "0.0.0.0:1212".to_string(), diff --git a/src/application/command_handlers/show/info/tracker.rs b/src/application/command_handlers/show/info/tracker.rs index 87912212..4eda50ba 100644 --- a/src/application/command_handlers/show/info/tracker.rs +++ b/src/application/command_handlers/show/info/tracker.rs @@ -142,15 +142,17 @@ impl ServiceInfo { let mut tls_domains = Vec::new(); for (index, http) in tracker_config.http_trackers.iter().enumerate() { - if let Some(tls) = &http.tls { - // 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", tls.domain())); - tls_domains.push(TlsDomainInfo { - domain: tls.domain().to_string(), - internal_port: http.bind_address.port(), - }); + 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 { diff --git a/src/domain/tracker/config/http.rs b/src/domain/tracker/config/http.rs index c44fd238..82087265 100644 --- a/src/domain/tracker/config/http.rs +++ b/src/domain/tracker/config/http.rs @@ -4,7 +4,7 @@ use std::net::SocketAddr; use serde::{Deserialize, Serialize}; -use crate::domain::tls::TlsConfig; +use crate::shared::DomainName; /// HTTP tracker bind configuration #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -16,12 +16,39 @@ pub struct HttpTrackerConfig { )] pub bind_address: SocketAddr, - /// TLS configuration for HTTPS termination via Caddy (optional) + /// Domain name for HTTPS certificate acquisition (optional) /// - /// When present, this HTTP tracker will be accessible via HTTPS - /// through the Caddy reverse proxy. + /// 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 tls: Option, + 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)] @@ -29,37 +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(), - tls: None, + 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(), - tls: None, + 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/mod.rs b/src/domain/tracker/config/mod.rs index a315f03b..14184069 100644 --- a/src/domain/tracker/config/mod.rs +++ b/src/domain/tracker/config/mod.rs @@ -70,7 +70,7 @@ pub fn is_localhost(addr: &SocketAddr) -> bool { /// UdpTrackerConfig { bind_address: "0.0.0.0:6969".parse().unwrap() }, /// ], /// http_trackers: vec![ -/// HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), tls: None }, +/// 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(), @@ -275,7 +275,7 @@ 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(), tls: None }, + /// 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(), @@ -328,7 +328,7 @@ impl TrackerConfig { // Check HTTP trackers for (i, tracker) in self.http_trackers.iter().enumerate() { - if tracker.tls.is_some() && is_localhost(&tracker.bind_address) { + 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, @@ -464,22 +464,32 @@ impl TrackerConfig { self.health_check_api.bind_address.port() } - /// Returns HTTP trackers that have TLS configured + /// Returns HTTP trackers that have TLS proxy enabled /// /// Returns a vector of tuples containing (domain, port) for each - /// HTTP tracker that has TLS configuration. + /// 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 - .tls + .domain .as_ref() - .map(|tls| (tls.domain(), tracker.bind_address.port())) + .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 @@ -526,7 +536,8 @@ impl Default for TrackerConfig { }], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().expect("valid address"), - tls: None, + domain: None, + use_tls_proxy: false, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().expect("valid address"), @@ -615,7 +626,8 @@ mod tests { }], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), - tls: None, + domain: None, + use_tls_proxy: false, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), @@ -711,7 +723,8 @@ mod tests { }], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), - tls: None, + domain: None, + use_tls_proxy: false, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), @@ -788,11 +801,13 @@ mod tests { http_trackers: vec![ HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), - tls: None, + domain: None, + use_tls_proxy: false, }, HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), - tls: None, + domain: None, + use_tls_proxy: false, }, ], http_api: HttpApiConfig { @@ -835,7 +850,8 @@ mod tests { udp_trackers: vec![], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), - tls: None, + domain: None, + use_tls_proxy: false, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), @@ -879,7 +895,8 @@ mod tests { udp_trackers: vec![], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:1313".parse().unwrap(), - tls: None, + domain: None, + use_tls_proxy: false, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), @@ -926,7 +943,8 @@ mod tests { }], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), - tls: None, + domain: None, + use_tls_proxy: false, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), @@ -955,11 +973,13 @@ mod tests { http_trackers: vec![ HttpTrackerConfig { bind_address: "192.168.1.10:7070".parse().unwrap(), - tls: None, + domain: None, + use_tls_proxy: false, }, HttpTrackerConfig { bind_address: "192.168.1.20:7070".parse().unwrap(), - tls: None, + domain: None, + use_tls_proxy: false, }, ], http_api: HttpApiConfig { @@ -988,7 +1008,8 @@ mod tests { udp_trackers: vec![], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), - tls: None, + domain: None, + use_tls_proxy: false, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), @@ -1135,7 +1156,8 @@ mod tests { let mut config = base_config(); config.http_trackers = vec![HttpTrackerConfig { bind_address: "127.0.0.1:7070".parse().unwrap(), - tls: Some(TlsConfig::new(domain)), + domain: Some(domain), + use_tls_proxy: true, }]; let result = config.validate(); diff --git a/src/domain/tracker/mod.rs b/src/domain/tracker/mod.rs index e8470df6..39d2b569 100644 --- a/src/domain/tracker/mod.rs +++ b/src/domain/tracker/mod.rs @@ -34,7 +34,7 @@ //! UdpTrackerConfig { bind_address: "0.0.0.0:6868".parse().unwrap() }, //! ], //! http_trackers: vec![ -//! HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), tls: None }, +//! 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(), diff --git a/src/infrastructure/templating/ansible/template/wrappers/variables/context.rs b/src/infrastructure/templating/ansible/template/wrappers/variables/context.rs index 0842a3bf..01f7099a 100644 --- a/src/infrastructure/templating/ansible/template/wrappers/variables/context.rs +++ b/src/infrastructure/templating/ansible/template/wrappers/variables/context.rs @@ -205,7 +205,8 @@ mod tests { ], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), - tls: None, + domain: None, + use_tls_proxy: false, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), @@ -282,7 +283,8 @@ mod tests { ], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), // Valid address - tls: None, + domain: None, + use_tls_proxy: false, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), diff --git a/src/infrastructure/templating/tracker/template/renderer/project_generator.rs b/src/infrastructure/templating/tracker/template/renderer/project_generator.rs index 4afeeb7e..2306ca6c 100644 --- a/src/infrastructure/templating/tracker/template/renderer/project_generator.rs +++ b/src/infrastructure/templating/tracker/template/renderer/project_generator.rs @@ -224,7 +224,8 @@ mod tests { }], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), - tls: None, + domain: None, + use_tls_proxy: false, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), @@ -277,7 +278,8 @@ mod tests { }], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), - tls: None, + domain: None, + use_tls_proxy: false, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), 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 9abe6ace..af55466d 100644 --- a/src/infrastructure/templating/tracker/template/wrapper/tracker_config/context.rs +++ b/src/infrastructure/templating/tracker/template/wrapper/tracker_config/context.rs @@ -38,7 +38,7 @@ 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(), tls: None }, +/// 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(), @@ -83,6 +83,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, @@ -139,6 +149,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() @@ -177,6 +188,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(), @@ -227,7 +239,8 @@ mod tests { ], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), - tls: None, + domain: None, + use_tls_proxy: false, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), @@ -279,7 +292,8 @@ mod tests { }], http_trackers: vec![HttpTrackerConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), - tls: None, + domain: None, + use_tls_proxy: false, }], http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), diff --git a/templates/tracker/tracker.toml.tera b/templates/tracker/tracker.toml.tera index 925283f6..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 }}" From 3662aff8bd5277866f9aeb74ce852959eb6657e2 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 19 Jan 2026 13:18:26 +0000 Subject: [PATCH 25/36] refactor: [#272] Replace tls with domain+use_tls_proxy for HTTP API Step 7.5.2: Apply the same TLS configuration pattern to the Tracker REST API (HttpApiSection/HttpApiConfig) as was done for HTTP trackers in Step 7.5.1. Changes: - Replace `tls: Option` with `domain: Option` and `use_tls_proxy: Option` in HttpApiSection DTO - Update HttpApiConfig domain type with `domain: Option` and `use_tls_proxy: bool` fields - Add `uses_tls_proxy()` and `tls_domain()` helper methods to HttpApiConfig - Update TrackerConfig::check_localhost_with_tls() validation - Update show command to use new fields for API endpoint display - Update all test code and doc comments to use new structure - Update envs/manual-https-test.json with new HTTP API configuration This is part of the incremental migration to remove the TlsSection type and use explicit domain + use_tls_proxy fields for clearer semantics. --- .../272-add-https-support-with-caddy.md | 18 +-- .../create/config/environment_config.rs | 17 +-- .../create/config/tracker/http_api_section.rs | 135 ++++++++++++++---- .../create/config/tracker/tracker_section.rs | 21 ++- .../command_handlers/show/info/tracker.rs | 24 +++- src/domain/tracker/config/http_api.rs | 68 +++++++-- src/domain/tracker/config/mod.rs | 59 +++++--- src/domain/tracker/mod.rs | 3 +- .../template/wrappers/variables/context.rs | 9 +- .../template/renderer/project_generator.rs | 3 +- .../template/renderer/project_generator.rs | 6 +- .../wrapper/tracker_config/context.rs | 9 +- 12 files changed, 275 insertions(+), 97 deletions(-) diff --git a/docs/issues/272-add-https-support-with-caddy.md b/docs/issues/272-add-https-support-with-caddy.md index 84205f6f..2e5ead2a 100644 --- a/docs/issues/272-add-https-support-with-caddy.md +++ b/docs/issues/272-add-https-support-with-caddy.md @@ -1443,15 +1443,15 @@ The implementation is split into incremental steps, one service type at a time, ##### Step 7.5.2: Tracker REST API -- [ ] Add `domain: Option` and `use_tls_proxy: Option` to `HttpApiSection` DTO -- [ ] Update `HttpApiConfig` domain type -- [ ] Add validation rules (same as HTTP trackers) -- [ ] Update Caddy template for API -- [ ] Update show command `ServiceInfo` for API -- [ ] Update `envs/manual-https-test.json` for API -- [ ] Remove `TlsSection` from API -- [ ] Add unit tests for API validation -- [ ] Run E2E tests +- [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 diff --git a/src/application/command_handlers/create/config/environment_config.rs b/src/application/command_handlers/create/config/environment_config.rs index 78390c03..fe56133a 100644 --- a/src/application/command_handlers/create/config/environment_config.rs +++ b/src/application/command_handlers/create/config/environment_config.rs @@ -397,7 +397,7 @@ impl EnvironmentCreationConfig { #[must_use] pub fn has_any_tls_configured(&self) -> bool { // Check HTTP API - if self.tracker.http_api.tls.is_some() { + if self.tracker.http_api.use_tls_proxy == Some(true) { return true; } @@ -513,7 +513,8 @@ impl EnvironmentCreationConfig { http_api: super::tracker::HttpApiSection { bind_address: "0.0.0.0:1212".to_string(), admin_token: "MyAccessToken".to_string(), - tls: None, + domain: None, + use_tls_proxy: None, }, health_check_api: super::tracker::HealthCheckApiSection::default(), }, @@ -1392,7 +1393,6 @@ mod tests { #[test] fn it_should_return_true_for_has_any_tls_configured_when_http_api_has_tls() { - use crate::application::command_handlers::create::config::https::TlsSection; use crate::application::command_handlers::create::config::tracker::{ DatabaseSection, HealthCheckApiSection, HttpApiSection, HttpTrackerSection, TrackerCoreSection, TrackerSection, UdpTrackerSection, @@ -1416,9 +1416,8 @@ mod tests { http_api: HttpApiSection { bind_address: "0.0.0.0:1212".to_string(), admin_token: "MyAccessToken".to_string(), - tls: Some(TlsSection { - domain: "api.tracker.example.com".to_string(), - }), + domain: Some("api.tracker.example.com".to_string()), + use_tls_proxy: Some(true), }, health_check_api: HealthCheckApiSection::default(), }; @@ -1492,7 +1491,8 @@ mod tests { http_api: HttpApiSection { bind_address: "0.0.0.0:1212".to_string(), admin_token: "MyAccessToken".to_string(), - tls: None, + domain: None, + use_tls_proxy: None, }, health_check_api: HealthCheckApiSection::default(), }; @@ -1582,7 +1582,8 @@ mod tests { http_api: HttpApiSection { bind_address: "0.0.0.0:1212".to_string(), admin_token: "MyAccessToken".to_string(), - tls: None, + domain: None, + use_tls_proxy: None, }, health_check_api: HealthCheckApiSection::default(), }; 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 4ddecea9..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 @@ -4,8 +4,6 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::application::command_handlers::create::config::errors::CreateConfigError; -use crate::application::command_handlers::create::config::https::TlsSection; -use crate::domain::tls::TlsConfig; use crate::domain::tracker::HttpApiConfig; use crate::shared::secrets::PlainApiToken; use crate::shared::DomainName; @@ -15,12 +13,26 @@ pub struct HttpApiSection { pub bind_address: String, pub admin_token: PlainApiToken, - /// Optional TLS configuration for HTTPS + /// Domain name for HTTPS certificate acquisition /// - /// When present, this service will be proxied through Caddy with HTTPS enabled. - /// The domain specified will be used for Let's Encrypt 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 tls: Option, + 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 { @@ -30,7 +42,8 @@ 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 TLS domain is invalid. + /// 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. @@ -50,17 +63,25 @@ impl HttpApiSection { }); } - // Convert TLS section to domain type with validation - let tls = match &self.tls { - Some(tls_section) => { - tls_section.validate()?; - let domain = DomainName::new(&tls_section.domain).map_err(|e| { - CreateConfigError::InvalidDomain { - domain: tls_section.domain.clone(), + 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(TlsConfig::new(domain)) + })?; + Some(domain) } None => None, }; @@ -68,7 +89,8 @@ impl HttpApiSection { Ok(HttpApiConfig { bind_address, admin_token: self.admin_token.clone().into(), - tls, + domain, + use_tls_proxy, }) } } @@ -82,7 +104,8 @@ mod tests { let section = HttpApiSection { bind_address: "0.0.0.0:1212".to_string(), admin_token: "MyAccessToken".to_string(), - tls: None, + domain: None, + use_tls_proxy: None, }; let result = section.to_http_api_config(); @@ -94,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] @@ -101,7 +125,8 @@ mod tests { let section = HttpApiSection { bind_address: "invalid-address".to_string(), admin_token: "token".to_string(), - tls: None, + domain: None, + use_tls_proxy: None, }; let result = section.to_http_api_config(); @@ -119,7 +144,8 @@ mod tests { let section = HttpApiSection { bind_address: "0.0.0.0:0".to_string(), admin_token: "token".to_string(), - tls: None, + domain: None, + use_tls_proxy: None, }; let result = section.to_http_api_config(); @@ -137,7 +163,8 @@ mod tests { let section = HttpApiSection { bind_address: "0.0.0.0:1212".to_string(), admin_token: "MyAccessToken".to_string(), - tls: None, + domain: None, + use_tls_proxy: None, }; let json = serde_json::to_string(§ion).unwrap(); @@ -153,22 +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_allow_non_localhost_with_tls() { + 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(), - tls: Some(TlsSection { - domain: "api.tracker.local".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.tls.is_some()); + 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/tracker_section.rs b/src/application/command_handlers/create/config/tracker/tracker_section.rs index 770ab296..9e8b8f86 100644 --- a/src/application/command_handlers/create/config/tracker/tracker_section.rs +++ b/src/application/command_handlers/create/config/tracker/tracker_section.rs @@ -134,7 +134,8 @@ impl Default for TrackerSection { http_api: HttpApiSection { bind_address: "0.0.0.0:1212".to_string(), admin_token: "MyAccessToken".to_string(), - tls: None, + domain: None, + use_tls_proxy: None, }, health_check_api: HealthCheckApiSection::default(), } @@ -169,7 +170,8 @@ mod tests { http_api: HttpApiSection { bind_address: "0.0.0.0:1212".to_string(), admin_token: "MyAccessToken".to_string(), - tls: None, + domain: None, + use_tls_proxy: None, }, health_check_api: HealthCheckApiSection::default(), }; @@ -223,7 +225,8 @@ mod tests { http_api: HttpApiSection { bind_address: "0.0.0.0:1212".to_string(), admin_token: "MyAccessToken".to_string(), - tls: None, + domain: None, + use_tls_proxy: None, }, health_check_api: HealthCheckApiSection::default(), }; @@ -250,7 +253,8 @@ mod tests { http_api: HttpApiSection { bind_address: "0.0.0.0:1212".to_string(), admin_token: "MyAccessToken".to_string(), - tls: None, + domain: None, + use_tls_proxy: None, }, health_check_api: HealthCheckApiSection::default(), }; @@ -284,7 +288,8 @@ mod tests { http_api: HttpApiSection { bind_address: "0.0.0.0:1212".to_string(), admin_token: "MyAccessToken".to_string(), - tls: None, + domain: None, + use_tls_proxy: None, }, health_check_api: HealthCheckApiSection::default(), }; @@ -347,7 +352,8 @@ mod tests { http_api: HttpApiSection { bind_address: "0.0.0.0:7070".to_string(), admin_token: "token".to_string(), - tls: None, + domain: None, + use_tls_proxy: None, }, health_check_api: HealthCheckApiSection::default(), }; @@ -381,7 +387,8 @@ mod tests { http_api: HttpApiSection { bind_address: "0.0.0.0:1212".to_string(), admin_token: "token".to_string(), - tls: None, + domain: None, + use_tls_proxy: None, }, health_check_api: HealthCheckApiSection::default(), }; diff --git a/src/application/command_handlers/show/info/tracker.rs b/src/application/command_handlers/show/info/tracker.rs index 4eda50ba..f1952700 100644 --- a/src/application/command_handlers/show/info/tracker.rs +++ b/src/application/command_handlers/show/info/tracker.rs @@ -171,12 +171,24 @@ impl ServiceInfo { // 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 let Some(tls) = &tracker_config.http_api.tls { - tls_domains.push(TlsDomainInfo { - domain: tls.domain().to_string(), - internal_port: tracker_config.http_api.bind_address.port(), - }); - (format!("https://{}/api", tls.domain()), true) + 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, + ) + } } else { ( format!( diff --git a/src/domain/tracker/config/http_api.rs b/src/domain/tracker/config/http_api.rs index fb85e3f5..dad86c92 100644 --- a/src/domain/tracker/config/http_api.rs +++ b/src/domain/tracker/config/http_api.rs @@ -4,8 +4,7 @@ use std::net::SocketAddr; use serde::{Deserialize, Serialize}; -use crate::domain::tls::TlsConfig; -use crate::shared::ApiToken; +use crate::shared::{ApiToken, DomainName}; /// HTTP API configuration #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -20,12 +19,38 @@ pub struct HttpApiConfig { /// Admin access token for HTTP API authentication pub admin_token: ApiToken, - /// TLS configuration for HTTPS termination via Caddy (optional) + /// Domain name for HTTPS certificate acquisition (optional) /// - /// When present, the HTTP API will be accessible via HTTPS - /// through the Caddy reverse proxy. + /// 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 tls: Option, + 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)] @@ -33,11 +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(), - tls: None, + domain: None, + use_tls_proxy: false, }; assert_eq!( @@ -45,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] @@ -52,17 +96,20 @@ mod tests { let config = HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "token123".to_string().into(), - tls: None, + 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!( @@ -70,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 14184069..8868a485 100644 --- a/src/domain/tracker/config/mod.rs +++ b/src/domain/tracker/config/mod.rs @@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize}; use super::{BindingAddress, Protocol}; use crate::domain::tls::TlsConfig; +use crate::shared::DomainName; mod core; mod health_check_api; @@ -75,7 +76,8 @@ pub fn is_localhost(addr: &SocketAddr) -> bool { /// http_api: HttpApiConfig { /// bind_address: "0.0.0.0:1212".parse().unwrap(), /// admin_token: "MyAccessToken".to_string().into(), -/// tls: None, +/// domain: None, +/// use_tls_proxy: false, /// }, /// health_check_api: HealthCheckApiConfig { /// bind_address: "127.0.0.1:1313".parse().unwrap(), @@ -280,7 +282,8 @@ impl TrackerConfig { /// http_api: HttpApiConfig { /// bind_address: "0.0.0.0:1212".parse().unwrap(), /// admin_token: "MyAccessToken".to_string().into(), - /// tls: None, + /// domain: None, + /// use_tls_proxy: false, /// }, /// health_check_api: HealthCheckApiConfig { /// bind_address: "127.0.0.1:1313".parse().unwrap(), @@ -310,7 +313,7 @@ impl TrackerConfig { /// both a localhost binding and TLS configuration. fn check_localhost_with_tls(&self) -> Result<(), TrackerConfigError> { // Check HTTP API - if self.http_api.tls.is_some() && is_localhost(&self.http_api.bind_address) { + 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, @@ -443,7 +446,7 @@ impl TrackerConfig { /// Returns the HTTP API TLS domain if configured #[must_use] pub fn http_api_tls_domain(&self) -> Option<&str> { - self.http_api.tls.as_ref().map(TlsConfig::domain) + self.http_api.tls_domain().map(DomainName::as_str) } /// Returns the HTTP API port number @@ -542,7 +545,8 @@ impl Default for TrackerConfig { http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().expect("valid address"), admin_token: "MyAccessToken".to_string().into(), - tls: None, + domain: None, + use_tls_proxy: false, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().expect("valid address"), @@ -632,7 +636,8 @@ mod tests { http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "test_token".to_string().into(), - tls: None, + domain: None, + use_tls_proxy: false, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), @@ -660,7 +665,8 @@ mod tests { http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "token123".to_string().into(), - tls: None, + domain: None, + use_tls_proxy: false, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), @@ -729,7 +735,8 @@ mod tests { http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "token".to_string().into(), - tls: None, + domain: None, + use_tls_proxy: false, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), @@ -761,7 +768,8 @@ mod tests { http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "token".to_string().into(), - tls: None, + domain: None, + use_tls_proxy: false, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), @@ -813,7 +821,8 @@ mod tests { http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "token".to_string().into(), - tls: None, + domain: None, + use_tls_proxy: false, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), @@ -856,7 +865,8 @@ mod tests { http_api: HttpApiConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), admin_token: "token".to_string().into(), - tls: None, + domain: None, + use_tls_proxy: false, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), @@ -901,7 +911,8 @@ mod tests { http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "token".to_string().into(), - tls: None, + domain: None, + use_tls_proxy: false, }, health_check_api: HealthCheckApiConfig { bind_address: "0.0.0.0:1313".parse().unwrap(), @@ -949,7 +960,8 @@ mod tests { http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "token".to_string().into(), - tls: None, + domain: None, + use_tls_proxy: false, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), @@ -985,7 +997,8 @@ mod tests { http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "token".to_string().into(), - tls: None, + domain: None, + use_tls_proxy: false, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), @@ -1014,7 +1027,8 @@ mod tests { http_api: HttpApiConfig { bind_address: "0.0.0.0:7070".parse().unwrap(), admin_token: "token".to_string().into(), - tls: None, + domain: None, + use_tls_proxy: false, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), @@ -1061,7 +1075,8 @@ mod tests { http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "token".to_string().into(), - tls: None, + domain: None, + use_tls_proxy: false, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), @@ -1077,7 +1092,8 @@ mod tests { config.http_api = HttpApiConfig { bind_address: "127.0.0.1:1212".parse().unwrap(), admin_token: "token".to_string().into(), - tls: Some(TlsConfig::new(domain)), + domain: Some(domain), + use_tls_proxy: true, }; let result = config.validate(); @@ -1105,7 +1121,8 @@ mod tests { config.http_api = HttpApiConfig { bind_address: "[::1]:1212".parse().unwrap(), admin_token: "token".to_string().into(), - tls: Some(TlsConfig::new(domain)), + domain: Some(domain), + use_tls_proxy: true, }; let result = config.validate(); @@ -1192,7 +1209,8 @@ mod tests { config.http_api = HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "token".to_string().into(), - tls: Some(TlsConfig::new(domain)), + domain: Some(domain), + use_tls_proxy: true, }; assert!(config.validate().is_ok()); @@ -1205,7 +1223,8 @@ mod tests { config.http_api = HttpApiConfig { bind_address: "127.0.0.1:1212".parse().unwrap(), admin_token: "token".to_string().into(), - tls: Some(TlsConfig::new(domain)), + domain: Some(domain), + use_tls_proxy: true, }; let error = config.validate().unwrap_err(); diff --git a/src/domain/tracker/mod.rs b/src/domain/tracker/mod.rs index 39d2b569..19b175ed 100644 --- a/src/domain/tracker/mod.rs +++ b/src/domain/tracker/mod.rs @@ -39,7 +39,8 @@ //! http_api: HttpApiConfig { //! bind_address: "0.0.0.0:1212".parse().unwrap(), //! admin_token: "MyToken".to_string().into(), -//! tls: None, +//! domain: None, +//! use_tls_proxy: false, //! }, //! health_check_api: HealthCheckApiConfig { //! bind_address: "127.0.0.1:1313".parse().unwrap(), diff --git a/src/infrastructure/templating/ansible/template/wrappers/variables/context.rs b/src/infrastructure/templating/ansible/template/wrappers/variables/context.rs index 01f7099a..bf93f420 100644 --- a/src/infrastructure/templating/ansible/template/wrappers/variables/context.rs +++ b/src/infrastructure/templating/ansible/template/wrappers/variables/context.rs @@ -211,7 +211,8 @@ mod tests { http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "MyAccessToken".to_string().into(), - tls: None, + domain: None, + use_tls_proxy: false, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), @@ -244,7 +245,8 @@ mod tests { http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "Token123".to_string().into(), - tls: None, + domain: None, + use_tls_proxy: false, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), @@ -289,7 +291,8 @@ mod tests { http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "Token".to_string().into(), - tls: None, + domain: None, + use_tls_proxy: false, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), diff --git a/src/infrastructure/templating/prometheus/template/renderer/project_generator.rs b/src/infrastructure/templating/prometheus/template/renderer/project_generator.rs index 4e605d70..c3a45214 100644 --- a/src/infrastructure/templating/prometheus/template/renderer/project_generator.rs +++ b/src/infrastructure/templating/prometheus/template/renderer/project_generator.rs @@ -209,7 +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(), - tls: None, + 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 2306ca6c..b78cb671 100644 --- a/src/infrastructure/templating/tracker/template/renderer/project_generator.rs +++ b/src/infrastructure/templating/tracker/template/renderer/project_generator.rs @@ -230,7 +230,8 @@ mod tests { http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "test_token".to_string().into(), - tls: None, + domain: None, + use_tls_proxy: false, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), @@ -284,7 +285,8 @@ mod tests { http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "test_token".to_string().into(), - tls: None, + domain: None, + use_tls_proxy: false, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), 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 af55466d..fd41508a 100644 --- a/src/infrastructure/templating/tracker/template/wrapper/tracker_config/context.rs +++ b/src/infrastructure/templating/tracker/template/wrapper/tracker_config/context.rs @@ -43,7 +43,8 @@ use crate::domain::environment::TrackerConfig; /// http_api: HttpApiConfig { /// bind_address: "0.0.0.0:1212".parse().unwrap(), /// admin_token: "MyToken".to_string().into(), -/// tls: None, +/// domain: None, +/// use_tls_proxy: false, /// }, /// health_check_api: HealthCheckApiConfig { /// bind_address: "127.0.0.1:1313".parse().unwrap(), @@ -245,7 +246,8 @@ mod tests { http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "test_admin_token".to_string().into(), - tls: None, + domain: None, + use_tls_proxy: false, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), @@ -298,7 +300,8 @@ mod tests { http_api: HttpApiConfig { bind_address: "0.0.0.0:1212".parse().unwrap(), admin_token: "test_token".to_string().into(), - tls: None, + domain: None, + use_tls_proxy: false, }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), From 55dfa945e2572a06d27aa0e8978ef4f3a0e22f3e Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 19 Jan 2026 13:36:20 +0000 Subject: [PATCH 26/36] refactor: [#272] Replace tls with domain+use_tls_proxy for Health Check API This commit migrates the Health Check API from the old `tls: Option` pattern to the new `domain: Option` + `use_tls_proxy: Option` pattern, following the same approach used for HTTP trackers and HTTP API. Changes: - Update HealthCheckApiSection DTO with new fields - Update HealthCheckApiConfig domain type with domain and use_tls_proxy - Add validation that use_tls_proxy requires a domain - Update TrackerConfig localhost+TLS validation to use use_tls_proxy - Update show command to use tls_domain() method - Update Caddy template context documentation - Update all test fixtures and doc examples - Update envs/manual-https-test.json with new format This is part of the incremental migration to remove the TlsSection type and standardize on the simpler domain + use_tls_proxy pattern across all services. Next: Grafana (Step 7.5.4). --- .../272-add-https-support-with-caddy.md | 18 +-- .../tracker/health_check_api_section.rs | 129 +++++++++++++----- .../command_handlers/show/info/tracker.rs | 6 +- src/domain/tracker/config/health_check_api.rs | 86 ++++++++---- src/domain/tracker/config/mod.rs | 50 ++++--- src/domain/tracker/mod.rs | 3 +- .../template/wrappers/variables/context.rs | 9 +- .../template/wrapper/caddyfile/context.rs | 10 +- .../template/renderer/project_generator.rs | 6 +- .../wrapper/tracker_config/context.rs | 9 +- 10 files changed, 221 insertions(+), 105 deletions(-) diff --git a/docs/issues/272-add-https-support-with-caddy.md b/docs/issues/272-add-https-support-with-caddy.md index 2e5ead2a..692bb668 100644 --- a/docs/issues/272-add-https-support-with-caddy.md +++ b/docs/issues/272-add-https-support-with-caddy.md @@ -1455,15 +1455,15 @@ The implementation is split into incremental steps, one service type at a time, ##### Step 7.5.3: Tracker Health Check API -- [ ] Add `domain: Option` and `use_tls_proxy: Option` to `HealthCheckApiSection` DTO -- [ ] Update `HealthCheckApiConfig` domain type -- [ ] Add validation rules -- [ ] Update Caddy template for health check -- [ ] Update show command `ServiceInfo` for health check -- [ ] Update `envs/manual-https-test.json` for health check -- [ ] Remove `TlsSection` from health check -- [ ] Add unit tests -- [ ] Run E2E tests +- [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 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 39f7b15b..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 @@ -4,8 +4,6 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::application::command_handlers::create::config::errors::CreateConfigError; -use crate::application::command_handlers::create::config::https::TlsSection; -use crate::domain::tls::TlsConfig; use crate::domain::tracker::HealthCheckApiConfig; use crate::shared::DomainName; @@ -13,13 +11,21 @@ use crate::shared::DomainName; pub struct HealthCheckApiSection { pub bind_address: String, - /// Optional TLS configuration for HTTPS + /// Domain name for HTTPS access via Caddy reverse proxy /// - /// When present, this service will be proxied through Caddy with HTTPS enabled. - /// The domain specified will be used for Let's Encrypt certificate acquisition. + /// 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 tls: Option, + pub use_tls_proxy: Option, } impl HealthCheckApiSection { @@ -29,7 +35,8 @@ 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 TLS domain is invalid. + /// 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. @@ -49,22 +56,33 @@ impl HealthCheckApiSection { }); } - // Convert TLS section to domain type with validation - let tls = match &self.tls { - Some(tls_section) => { - tls_section.validate()?; - let domain = DomainName::new(&tls_section.domain).map_err(|e| { + 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: tls_section.domain.clone(), + domain: domain_str.clone(), reason: e.to_string(), } - })?; - Some(TlsConfig::new(domain)) - } - None => None, - }; - - Ok(HealthCheckApiConfig { bind_address, tls }) + })?), + None => None, + }; + + Ok(HealthCheckApiConfig { + bind_address, + domain, + use_tls_proxy, + }) } } @@ -72,7 +90,8 @@ impl Default for HealthCheckApiSection { fn default() -> Self { Self { bind_address: "127.0.0.1:1313".to_string(), - tls: None, + domain: None, + use_tls_proxy: None, } } } @@ -85,7 +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(), - tls: None, + domain: None, + use_tls_proxy: None, }; let config = section.to_health_check_api_config().unwrap(); @@ -94,16 +114,16 @@ mod tests { config.bind_address, "127.0.0.1:1313".parse::().unwrap() ); - assert!(config.tls.is_none()); + assert!(!config.use_tls_proxy); + assert!(config.domain.is_none()); } #[test] - fn it_should_convert_to_domain_config_with_tls() { + fn it_should_convert_to_domain_config_with_tls_proxy() { let section = HealthCheckApiSection { bind_address: "0.0.0.0:1313".to_string(), - tls: Some(TlsSection { - domain: "health.tracker.local".to_string(), - }), + domain: Some("health.tracker.local".to_string()), + use_tls_proxy: Some(true), }; let config = section.to_health_check_api_config().unwrap(); @@ -112,7 +132,7 @@ mod tests { config.bind_address, "0.0.0.0:1313".parse::().unwrap() ); - assert!(config.tls.is_some()); + assert!(config.use_tls_proxy); assert_eq!(config.tls_domain(), Some("health.tracker.local")); } @@ -120,7 +140,8 @@ mod tests { fn it_should_fail_when_bind_address_is_invalid() { let section = HealthCheckApiSection { bind_address: "invalid".to_string(), - tls: None, + domain: None, + use_tls_proxy: None, }; let result = section.to_health_check_api_config(); @@ -136,7 +157,8 @@ mod tests { fn it_should_reject_dynamic_port_assignment() { let section = HealthCheckApiSection { bind_address: "0.0.0.0:0".to_string(), - tls: None, + domain: None, + use_tls_proxy: None, }; let result = section.to_health_check_api_config(); @@ -152,7 +174,8 @@ mod tests { fn it_should_allow_ipv6_addresses() { let section = HealthCheckApiSection { bind_address: "[::1]:1313".to_string(), - tls: None, + domain: None, + use_tls_proxy: None, }; let result = section.to_health_check_api_config(); @@ -164,7 +187,8 @@ mod tests { fn it_should_allow_any_port_except_zero() { let section = HealthCheckApiSection { bind_address: "127.0.0.1:8080".to_string(), - tls: None, + domain: None, + use_tls_proxy: None, }; let result = section.to_health_check_api_config(); @@ -177,16 +201,16 @@ mod tests { let section = HealthCheckApiSection::default(); assert_eq!(section.bind_address, "127.0.0.1:1313"); - assert!(section.tls.is_none()); + assert!(section.domain.is_none()); + assert!(section.use_tls_proxy.is_none()); } #[test] - fn it_should_fail_when_tls_domain_is_invalid() { + fn it_should_fail_when_domain_is_invalid() { let section = HealthCheckApiSection { bind_address: "0.0.0.0:1313".to_string(), - tls: Some(TlsSection { - domain: "invalid domain with spaces".to_string(), - }), + domain: Some("invalid domain with spaces".to_string()), + use_tls_proxy: Some(true), }; let result = section.to_health_check_api_config(); @@ -197,4 +221,35 @@ mod tests { 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/show/info/tracker.rs b/src/application/command_handlers/show/info/tracker.rs index f1952700..b3df62dc 100644 --- a/src/application/command_handlers/show/info/tracker.rs +++ b/src/application/command_handlers/show/info/tracker.rs @@ -214,12 +214,12 @@ impl ServiceInfo { 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(tls) = &tracker_config.health_check_api.tls { + if let Some(domain) = tracker_config.health_check_api.tls_domain() { tls_domains.push(TlsDomainInfo { - domain: tls.domain().to_string(), + domain: domain.to_string(), internal_port: tracker_config.health_check_api.bind_address.port(), }); - (format!("https://{}/health_check", tls.domain()), true) + (format!("https://{domain}/health_check"), true) } else { ( format!( diff --git a/src/domain/tracker/config/health_check_api.rs b/src/domain/tracker/config/health_check_api.rs index 69aa7cb3..1c951b5f 100644 --- a/src/domain/tracker/config/health_check_api.rs +++ b/src/domain/tracker/config/health_check_api.rs @@ -4,7 +4,7 @@ use std::net::SocketAddr; use serde::{Deserialize, Serialize}; -use crate::domain::tls::TlsConfig; +use crate::shared::domain_name::DomainName; /// Health Check API configuration /// @@ -21,51 +21,71 @@ pub struct HealthCheckApiConfig { )] pub bind_address: SocketAddr, - /// TLS configuration for HTTPS termination via Caddy (optional) + /// Domain name for external HTTPS access (optional) /// - /// When present, the Health Check API will be accessible via HTTPS - /// through the Caddy reverse proxy. This is useful when exposing - /// health checks to external monitoring systems or load balancers. + /// 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 tls: Option, + 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 the TLS domain if configured + /// 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> { - self.tls.as_ref().map(TlsConfig::domain) + if self.use_tls_proxy { + self.domain.as_ref().map(DomainName::as_str) + } else { + None + } } } #[cfg(test)] mod tests { use super::*; - use crate::shared::domain_name::DomainName; #[test] fn it_should_create_health_check_api_config() { let config = HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), - tls: None, + domain: None, + use_tls_proxy: false, }; assert_eq!( config.bind_address, "127.0.0.1:1313".parse::().unwrap() ); - assert!(config.tls.is_none()); + assert!(!config.use_tls_proxy); + assert!(config.domain.is_none()); } #[test] - fn it_should_create_health_check_api_config_with_tls() { + 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(), - tls: Some(TlsConfig::new(domain)), + domain: Some(domain), + use_tls_proxy: true, }; - assert!(config.tls.is_some()); + assert!(config.uses_tls_proxy()); assert_eq!(config.tls_domain(), Some("health.tracker.local")); } @@ -73,43 +93,48 @@ mod tests { fn it_should_serialize_health_check_api_config() { let config = HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), - tls: None, + domain: None, + use_tls_proxy: false, }; let json = serde_json::to_value(&config).unwrap(); assert_eq!(json["bind_address"], "127.0.0.1:1313"); - // tls should not be serialized when None - assert!(json.get("tls").is_none()); + // 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() { + 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(), - tls: Some(TlsConfig::new(domain)), + 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["tls"]["domain"], "health.tracker.local"); + 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.tls.is_none()); + assert!(!config.use_tls_proxy); } #[test] - fn it_should_deserialize_health_check_api_config_with_tls() { - let json = r#"{"bind_address": "0.0.0.0:1313", "tls": {"domain": "health.tracker.local"}}"#; + 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!( @@ -118,4 +143,17 @@ mod tests { ); 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/mod.rs b/src/domain/tracker/config/mod.rs index 8868a485..3c877679 100644 --- a/src/domain/tracker/config/mod.rs +++ b/src/domain/tracker/config/mod.rs @@ -10,7 +10,6 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use serde::{Deserialize, Serialize}; use super::{BindingAddress, Protocol}; -use crate::domain::tls::TlsConfig; use crate::shared::DomainName; mod core; @@ -81,7 +80,8 @@ pub fn is_localhost(addr: &SocketAddr) -> bool { /// }, /// health_check_api: HealthCheckApiConfig { /// bind_address: "127.0.0.1:1313".parse().unwrap(), -/// tls: None, +/// domain: None, +/// use_tls_proxy: false, /// }, /// }; /// ``` @@ -287,7 +287,8 @@ impl TrackerConfig { /// }, /// health_check_api: HealthCheckApiConfig { /// bind_address: "127.0.0.1:1313".parse().unwrap(), - /// tls: None, + /// domain: None, + /// use_tls_proxy: false, /// }, /// }; /// @@ -321,7 +322,7 @@ impl TrackerConfig { } // Check Health Check API - if self.health_check_api.tls.is_some() && is_localhost(&self.health_check_api.bind_address) + 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(), @@ -458,7 +459,7 @@ impl TrackerConfig { /// 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.as_ref().map(TlsConfig::domain) + self.health_check_api.tls_domain() } /// Returns the Health Check API port number @@ -550,7 +551,8 @@ impl Default for TrackerConfig { }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().expect("valid address"), - tls: None, + domain: None, + use_tls_proxy: false, }, } } @@ -641,7 +643,8 @@ mod tests { }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), - tls: None, + domain: None, + use_tls_proxy: false, }, }; @@ -670,7 +673,8 @@ mod tests { }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), - tls: None, + domain: None, + use_tls_proxy: false, }, }; @@ -740,7 +744,8 @@ mod tests { }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), - tls: None, + domain: None, + use_tls_proxy: false, }, }; @@ -773,7 +778,8 @@ mod tests { }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), - tls: None, + domain: None, + use_tls_proxy: false, }, }; @@ -826,7 +832,8 @@ mod tests { }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), - tls: None, + domain: None, + use_tls_proxy: false, }, }; @@ -870,7 +877,8 @@ mod tests { }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), - tls: None, + domain: None, + use_tls_proxy: false, }, }; @@ -916,7 +924,8 @@ mod tests { }, health_check_api: HealthCheckApiConfig { bind_address: "0.0.0.0:1313".parse().unwrap(), - tls: None, + domain: None, + use_tls_proxy: false, }, }; @@ -965,7 +974,8 @@ mod tests { }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), - tls: None, + domain: None, + use_tls_proxy: false, }, }; @@ -1002,7 +1012,8 @@ mod tests { }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), - tls: None, + domain: None, + use_tls_proxy: false, }, }; @@ -1032,7 +1043,8 @@ mod tests { }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), - tls: None, + domain: None, + use_tls_proxy: false, }, }; @@ -1080,7 +1092,8 @@ mod tests { }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), - tls: None, + domain: None, + use_tls_proxy: false, }, } } @@ -1146,7 +1159,8 @@ mod tests { let mut config = base_config(); config.health_check_api = HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), - tls: Some(TlsConfig::new(domain)), + domain: Some(domain), + use_tls_proxy: true, }; let result = config.validate(); diff --git a/src/domain/tracker/mod.rs b/src/domain/tracker/mod.rs index 19b175ed..a7751728 100644 --- a/src/domain/tracker/mod.rs +++ b/src/domain/tracker/mod.rs @@ -44,7 +44,8 @@ //! }, //! health_check_api: HealthCheckApiConfig { //! bind_address: "127.0.0.1:1313".parse().unwrap(), -//! tls: None, +//! domain: None, +//! use_tls_proxy: false, //! }, //! }; //! ``` diff --git a/src/infrastructure/templating/ansible/template/wrappers/variables/context.rs b/src/infrastructure/templating/ansible/template/wrappers/variables/context.rs index bf93f420..e55407a8 100644 --- a/src/infrastructure/templating/ansible/template/wrappers/variables/context.rs +++ b/src/infrastructure/templating/ansible/template/wrappers/variables/context.rs @@ -216,7 +216,8 @@ mod tests { }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), - tls: None, + domain: None, + use_tls_proxy: false, }, }; @@ -250,7 +251,8 @@ mod tests { }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), - tls: None, + domain: None, + use_tls_proxy: false, }, }; @@ -296,7 +298,8 @@ mod tests { }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), - tls: None, + domain: None, + use_tls_proxy: false, }, }; diff --git a/src/infrastructure/templating/caddy/template/wrapper/caddyfile/context.rs b/src/infrastructure/templating/caddy/template/wrapper/caddyfile/context.rs index 3aece400..b41e717a 100644 --- a/src/infrastructure/templating/caddy/template/wrapper/caddyfile/context.rs +++ b/src/infrastructure/templating/caddy/template/wrapper/caddyfile/context.rs @@ -115,15 +115,15 @@ pub struct CaddyContext { /// Present only if `tracker.http_api.tls` is configured. pub tracker_api: Option, - /// HTTP Tracker services with TLS configured + /// HTTP Tracker services with TLS proxy configured /// - /// Contains only trackers that have `tls` configuration. - /// Trackers without TLS are served directly over HTTP, not through Caddy. + /// 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 configured) + /// Health Check API service (if TLS proxy configured) /// - /// Present only if `tracker.health_check_api.tls` is 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, diff --git a/src/infrastructure/templating/tracker/template/renderer/project_generator.rs b/src/infrastructure/templating/tracker/template/renderer/project_generator.rs index b78cb671..e4a7c883 100644 --- a/src/infrastructure/templating/tracker/template/renderer/project_generator.rs +++ b/src/infrastructure/templating/tracker/template/renderer/project_generator.rs @@ -235,7 +235,8 @@ mod tests { }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), - tls: None, + domain: None, + use_tls_proxy: false, }, }; @@ -290,7 +291,8 @@ mod tests { }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), - tls: None, + 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 fd41508a..523a388a 100644 --- a/src/infrastructure/templating/tracker/template/wrapper/tracker_config/context.rs +++ b/src/infrastructure/templating/tracker/template/wrapper/tracker_config/context.rs @@ -48,7 +48,8 @@ use crate::domain::environment::TrackerConfig; /// }, /// health_check_api: HealthCheckApiConfig { /// bind_address: "127.0.0.1:1313".parse().unwrap(), -/// tls: None, +/// domain: None, +/// use_tls_proxy: false, /// }, /// }; /// let context = TrackerContext::from_config(&tracker_config); @@ -251,7 +252,8 @@ mod tests { }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), - tls: None, + domain: None, + use_tls_proxy: false, }, } } @@ -305,7 +307,8 @@ mod tests { }, health_check_api: HealthCheckApiConfig { bind_address: "127.0.0.1:1313".parse().unwrap(), - tls: None, + domain: None, + use_tls_proxy: false, }, }; From 01da06e8cbb382a920f624fc0d74a24b8cf7d2d8 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 19 Jan 2026 13:51:13 +0000 Subject: [PATCH 27/36] refactor: [#272] Replace tls with domain+use_tls_proxy for Grafana Step 7.5.4 of configuration restructuring: - Replace tls: Option with domain: Option + use_tls_proxy - Update GrafanaSection DTO with new fields and validation - Update GrafanaConfig domain type with new constructor signature - Update docker compose context builder to use use_tls_proxy() - Update show command tests for Grafana - Update envs/manual-https-test.json to use new configuration format - Update spec document marking Step 7.5.4 complete This change simplifies the configuration model for Grafana, making TLS proxy enablement explicit rather than inferred from the presence of a tls section. Note: Grafana has no configurable bind address, so no localhost+TLS validation is needed. --- .../272-add-https-support-with-caddy.md | 18 +- .../create/config/environment_config.rs | 2 +- .../command_handlers/create/config/grafana.rs | 154 ++++++++++++++---- .../command_handlers/show/info/grafana.rs | 10 +- src/domain/grafana/config.rs | 117 +++++++++---- .../docker_compose/context/builder.rs | 2 +- .../wrappers/docker_compose/context/mod.rs | 9 +- 7 files changed, 232 insertions(+), 80 deletions(-) diff --git a/docs/issues/272-add-https-support-with-caddy.md b/docs/issues/272-add-https-support-with-caddy.md index 692bb668..bc93f2b4 100644 --- a/docs/issues/272-add-https-support-with-caddy.md +++ b/docs/issues/272-add-https-support-with-caddy.md @@ -1467,15 +1467,15 @@ The implementation is split into incremental steps, one service type at a time, ##### Step 7.5.4: Grafana -- [ ] Add `domain: Option` and `use_tls_proxy: Option` to `GrafanaSection` DTO -- [ ] Update `GrafanaConfig` domain type -- [ ] Add validation rules (note: Grafana has no configurable bind address, so localhost validation not needed) -- [ ] Update Caddy template for Grafana -- [ ] Update show command `ServiceInfo` for Grafana -- [ ] Update `envs/manual-https-test.json` for Grafana -- [ ] Remove `TlsSection` from Grafana -- [ ] Add unit tests -- [ ] Run E2E tests +- [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 diff --git a/src/application/command_handlers/create/config/environment_config.rs b/src/application/command_handlers/create/config/environment_config.rs index fe56133a..8bab794c 100644 --- a/src/application/command_handlers/create/config/environment_config.rs +++ b/src/application/command_handlers/create/config/environment_config.rs @@ -410,7 +410,7 @@ impl EnvironmentCreationConfig { // Check Grafana if let Some(ref grafana) = self.grafana { - if grafana.tls.is_some() { + if grafana.use_tls_proxy == Some(true) { return true; } } diff --git a/src/application/command_handlers/create/config/grafana.rs b/src/application/command_handlers/create/config/grafana.rs index bf0e0ca7..3a18bfd7 100644 --- a/src/application/command_handlers/create/config/grafana.rs +++ b/src/application/command_handlers/create/config/grafana.rs @@ -8,9 +8,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::application::command_handlers::create::config::errors::CreateConfigError; -use crate::application::command_handlers::create::config::https::TlsSection; use crate::domain::grafana::GrafanaConfig; -use crate::domain::tls::TlsConfig; use crate::shared::secrets::PlainPassword; use crate::shared::DomainName; @@ -35,14 +33,13 @@ use crate::shared::DomainName; /// } /// ``` /// -/// With TLS configuration: +/// With TLS proxy configuration: /// ```json /// { /// "admin_user": "admin", /// "admin_password": "admin", -/// "tls": { -/// "domain": "grafana.example.com" -/// } +/// "domain": "grafana.example.com", +/// "use_tls_proxy": true /// } /// ``` #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -56,12 +53,21 @@ pub struct GrafanaSection { /// to prevent accidental exposure in logs or debug output. pub admin_password: PlainPassword, - /// Optional TLS configuration for HTTPS + /// Domain name for external HTTPS access (optional) /// - /// When present, Grafana will be proxied through Caddy with HTTPS enabled. - /// The domain specified will be used for Let's Encrypt certificate acquisition. + /// 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 tls: Option, + 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 { @@ -70,7 +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(), - tls: None, + domain: None, + use_tls_proxy: None, } } } @@ -84,26 +91,38 @@ impl GrafanaSection { /// /// # Errors /// - /// Returns `CreateConfigError::InvalidDomain` if the TLS domain is invalid. + /// 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 config = match &self.tls { - Some(tls_section) => { - tls_section.validate()?; - let domain = DomainName::new(&tls_section.domain).map_err(|e| { + 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: tls_section.domain.clone(), + domain: domain_str.clone(), reason: e.to_string(), } - })?; - GrafanaConfig::with_tls( - self.admin_user.clone(), - self.admin_password.clone(), - TlsConfig::new(domain), - ) - } - None => GrafanaConfig::new(self.admin_user.clone(), self.admin_password.clone()), - }; - Ok(config) + })?), + None => None, + }; + + Ok(GrafanaConfig::new( + self.admin_user.clone(), + self.admin_password.clone(), + domain, + use_tls_proxy, + )) } } @@ -116,7 +135,8 @@ mod tests { let section = GrafanaSection::default(); assert_eq!(section.admin_user, "admin"); assert_eq!(section.admin_password, "admin"); - assert!(section.tls.is_none()); + assert!(section.domain.is_none()); + assert!(section.use_tls_proxy.is_none()); } #[test] @@ -124,7 +144,8 @@ mod tests { let section = GrafanaSection { admin_user: "custom_admin".to_string(), admin_password: "secure_password".to_string(), - tls: None, + domain: None, + use_tls_proxy: None, }; let result = section.to_grafana_config(); @@ -150,7 +171,8 @@ mod tests { let section = GrafanaSection { admin_user: "admin".to_string(), admin_password: "secret_password".to_string(), - tls: None, + domain: None, + use_tls_proxy: None, }; let config = section.to_grafana_config().unwrap(); @@ -160,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/show/info/grafana.rs b/src/application/command_handlers/show/info/grafana.rs index 0d6d966a..959b876a 100644 --- a/src/application/command_handlers/show/info/grafana.rs +++ b/src/application/command_handlers/show/info/grafana.rs @@ -91,15 +91,11 @@ mod tests { #[test] fn it_should_create_grafana_info_with_https_from_config() { use crate::domain::grafana::GrafanaConfig; - use crate::domain::tls::TlsConfig; use crate::shared::domain_name::DomainName; let domain = DomainName::new("grafana.tracker.local").unwrap(); - let config = GrafanaConfig::with_tls( - "admin".to_string(), - "pass".to_string(), - TlsConfig::new(domain), - ); + 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); @@ -111,7 +107,7 @@ mod tests { #[test] fn it_should_create_grafana_info_with_http_from_config_without_tls() { - let config = GrafanaConfig::new("admin".to_string(), "pass".to_string()); + 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); diff --git a/src/domain/grafana/config.rs b/src/domain/grafana/config.rs index 6de3a720..cba5a215 100644 --- a/src/domain/grafana/config.rs +++ b/src/domain/grafana/config.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; -use crate::domain::tls::TlsConfig; +use crate::shared::domain_name::DomainName; use crate::shared::secrets::Password; /// Grafana metrics visualization configuration @@ -22,12 +22,18 @@ pub struct GrafanaConfig { /// - Explicit `.expose_secret()` calls required to access plaintext admin_password: Password, - /// TLS configuration for HTTPS termination via Caddy (optional) + /// Domain name for the service (optional) /// - /// When present, Grafana will be accessible via HTTPS through - /// the Caddy reverse proxy. + /// When present, defines the hostname for accessing Grafana. + /// Can be used with or without TLS proxy. #[serde(skip_serializing_if = "Option::is_none")] - tls: Option, + 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 { @@ -38,25 +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 { - Self { - admin_user, - admin_password: Password::new(admin_password), - tls: None, - } - } - - /// Creates a new Grafana configuration with TLS - #[must_use] - pub fn with_tls(admin_user: String, admin_password: String, tls: TlsConfig) -> 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), - tls: Some(tls), + domain, + use_tls_proxy, } } @@ -72,16 +79,29 @@ impl GrafanaConfig { &self.admin_password } - /// Returns the TLS domain if configured + /// 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> { - self.tls.as_ref().map(TlsConfig::domain) + if self.use_tls_proxy { + self.domain.as_ref().map(DomainName::as_str) + } else { + None + } } - /// Returns the TLS configuration if present + /// Returns whether TLS proxy is enabled #[must_use] - pub fn tls(&self) -> Option<&TlsConfig> { - self.tls.as_ref() + pub fn use_tls_proxy(&self) -> bool { + self.use_tls_proxy } } @@ -90,7 +110,8 @@ impl Default for GrafanaConfig { Self { admin_user: "admin".to_string(), admin_password: Password::new("admin"), - tls: None, + domain: None, + use_tls_proxy: false, } } } @@ -105,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] @@ -112,35 +135,69 @@ mod tests { let config = GrafanaConfig { admin_user: "custom_admin".to_string(), admin_password: Password::new("custom_pass"), - tls: None, + 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"), - tls: None, + 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] @@ -148,7 +205,8 @@ mod tests { let config = GrafanaConfig { admin_user: "admin".to_string(), admin_password: Password::new("super_secret"), - tls: None, + domain: None, + use_tls_proxy: false, }; let debug_output = format!("{config:?}"); @@ -163,7 +221,8 @@ mod tests { let config = GrafanaConfig { admin_user: "admin".to_string(), admin_password: Password::new("password"), - tls: None, + domain: None, + use_tls_proxy: false, }; let cloned = config.clone(); 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 39641046..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 @@ -103,7 +103,7 @@ impl DockerComposeContextBuilder { // Build Grafana service config if enabled let grafana = self.grafana_config.map(|config| { - let has_tls = config.tls().is_some(); + let has_tls = config.use_tls_proxy(); GrafanaServiceConfig::new( config.admin_user().to_string(), config.admin_password().clone(), 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 663ad596..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 @@ -315,7 +315,8 @@ mod tests { 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()); + 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) @@ -333,7 +334,8 @@ mod tests { use crate::domain::grafana::GrafanaConfig; let tracker = test_tracker_config(); - let grafana_config = GrafanaConfig::new("admin".to_string(), "password".to_string()); + let grafana_config = + GrafanaConfig::new("admin".to_string(), "password".to_string(), None, false); let context = DockerComposeContext::builder(tracker) .with_grafana(grafana_config) .build(); @@ -348,7 +350,8 @@ mod tests { use crate::domain::grafana::GrafanaConfig; let tracker = test_tracker_config(); - let grafana_config = GrafanaConfig::new("admin".to_string(), "password".to_string()); + let grafana_config = + GrafanaConfig::new("admin".to_string(), "password".to_string(), None, false); let context = DockerComposeContext::builder(tracker) .with_grafana(grafana_config) .with_caddy() From c7e0dc461f0ce7bc6ee2b2c437200dbb1e3e5718 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 19 Jan 2026 15:41:46 +0000 Subject: [PATCH 28/36] refactor: [#272] Remove unused TlsSection and domain::tls module Step 7.5.5: Cleanup and Final Verification After migrating all services (HTTP Trackers, Tracker REST API, Health Check API, and Grafana) from tls: Option to domain: Option + use_tls_proxy: Option, the TlsSection type and domain::tls module are no longer used. This cleanup removes: - TlsSection struct and impl from application layer DTOs - TlsSection tests - domain::tls module (TlsConfig type) - TlsSection re-export from config module All 1848 tests pass after removal. --- .../272-add-https-support-with-caddy.md | 7 +- .../command_handlers/create/config/https.rs | 162 +----------------- .../command_handlers/create/config/mod.rs | 2 +- src/domain/mod.rs | 1 - src/domain/tls/config.rs | 97 ----------- src/domain/tls/mod.rs | 18 -- 6 files changed, 6 insertions(+), 281 deletions(-) delete mode 100644 src/domain/tls/config.rs delete mode 100644 src/domain/tls/mod.rs diff --git a/docs/issues/272-add-https-support-with-caddy.md b/docs/issues/272-add-https-support-with-caddy.md index bc93f2b4..4e3b24cc 100644 --- a/docs/issues/272-add-https-support-with-caddy.md +++ b/docs/issues/272-add-https-support-with-caddy.md @@ -1479,9 +1479,10 @@ The implementation is split into incremental steps, one service type at a time, ##### Step 7.5.5: Cleanup and Final Verification -- [ ] Remove `TlsSection` type completely (should be unused after all services migrated) -- [ ] Run full E2E test suite -- [ ] Run all linters +- [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 - [ ] Manual verification with `envs/manual-https-test.json` ### Phase 8: Schema Generation (30 minutes) diff --git a/src/application/command_handlers/create/config/https.rs b/src/application/command_handlers/create/config/https.rs index e99eca76..de06ccad 100644 --- a/src/application/command_handlers/create/config/https.rs +++ b/src/application/command_handlers/create/config/https.rs @@ -33,7 +33,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use super::errors::CreateConfigError; -use crate::shared::{DomainName, Email}; +use crate::shared::Email; /// Common HTTPS configuration (top-level) /// @@ -126,62 +126,6 @@ impl HttpsSection { } } -/// Service-specific TLS configuration -/// -/// Embedded in each service that supports HTTPS. The presence of this -/// configuration indicates that TLS should be enabled for the service. -/// -/// # Domain Requirements -/// -/// The domain must: -/// - Point to the deployment server's IP via DNS -/// - Be owned/controlled by the deployer -/// - Be configured before deployment (for HTTP-01 challenge) -/// -/// # Examples -/// -/// ```json -/// { -/// "tls": { -/// "domain": "api.example.com" -/// } -/// } -/// ``` -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -pub struct TlsSection { - /// Domain name for this service - /// - /// This domain will be used for: - /// - HTTPS certificate acquisition (Let's Encrypt HTTP-01 challenge) - /// - Caddy reverse proxy routing - /// - SNI-based TLS termination - pub domain: String, -} - -impl TlsSection { - /// Creates a new TLS configuration section - #[must_use] - pub fn new(domain: String) -> Self { - Self { domain } - } - - /// Validates the TLS configuration - /// - /// Uses the domain-level `DomainName` type for DNS-compliant validation. - /// - /// # Errors - /// - /// Returns `CreateConfigError::InvalidDomain` if the domain format is invalid. - pub fn validate(&self) -> Result<(), CreateConfigError> { - // Validate domain using the domain type for DNS-compliant validation - DomainName::new(&self.domain).map_err(|e| CreateConfigError::InvalidDomain { - domain: self.domain.clone(), - reason: e.to_string(), - })?; - Ok(()) - } -} - #[cfg(test)] mod tests { use super::*; @@ -248,86 +192,6 @@ mod tests { } } - mod tls_section_tests { - use super::*; - - #[test] - fn it_should_create_tls_section() { - let section = TlsSection::new("api.example.com".to_string()); - assert_eq!(section.domain, "api.example.com"); - } - - #[test] - fn it_should_validate_valid_domain() { - let section = TlsSection::new("api.example.com".to_string()); - assert!(section.validate().is_ok()); - } - - #[test] - fn it_should_validate_subdomain() { - let section = TlsSection::new("sub.api.example.com".to_string()); - assert!(section.validate().is_ok()); - } - - #[test] - fn it_should_reject_empty_domain() { - let section = TlsSection::new(String::new()); - assert!(section.validate().is_err()); - } - - #[test] - fn it_should_reject_domain_without_tld() { - let section = TlsSection::new("localhost".to_string()); - assert!(section.validate().is_err()); - } - - #[test] - fn it_should_reject_domain_starting_with_dot() { - let section = TlsSection::new(".example.com".to_string()); - assert!(section.validate().is_err()); - } - - #[test] - fn it_should_reject_domain_ending_with_dot() { - let section = TlsSection::new("example.com.".to_string()); - assert!(section.validate().is_err()); - } - - #[test] - fn it_should_reject_domain_with_consecutive_dots() { - let section = TlsSection::new("example..com".to_string()); - assert!(section.validate().is_err()); - } - - #[test] - fn it_should_reject_domain_with_whitespace() { - let section = TlsSection::new("my domain.com".to_string()); - assert!(section.validate().is_err()); - } - - #[test] - fn it_should_accept_domain_with_hyphen() { - // Hyphens are allowed in domain names - let section = TlsSection::new("my-service.example.com".to_string()); - assert!(section.validate().is_ok()); - } - - #[test] - fn it_should_accept_domain_with_underscore() { - // Underscores are allowed with minimal validation - // (they're valid in some DNS contexts like SRV records) - let section = TlsSection::new("my_service.example.com".to_string()); - assert!(section.validate().is_ok()); - } - - #[test] - fn it_should_deserialize_from_json() { - let json = r#"{"domain": "api.torrust.com"}"#; - let section: TlsSection = serde_json::from_str(json).unwrap(); - assert_eq!(section.domain, "api.torrust.com"); - } - } - /// Tests for email validation in HTTPS context /// /// Note: Comprehensive email format validation tests are in `src/shared/email.rs`. @@ -351,28 +215,4 @@ mod tests { )); } } - - /// Tests for domain validation in TLS context - /// - /// Note: Comprehensive domain format validation tests are in `src/shared/domain_name.rs`. - /// These tests verify the integration of the `DomainName` type with `TlsSection`. - mod domain_validation_integration_tests { - use super::*; - - #[test] - fn it_should_accept_dns_compliant_domain() { - let section = TlsSection::new("example.com".to_string()); - assert!(section.validate().is_ok()); - } - - #[test] - fn it_should_reject_dns_non_compliant_domain() { - let section = TlsSection::new("localhost".to_string()); - let result = section.validate(); - assert!(matches!( - result, - Err(CreateConfigError::InvalidDomain { .. }) - )); - } - } } diff --git a/src/application/command_handlers/create/config/mod.rs b/src/application/command_handlers/create/config/mod.rs index 992a7c62..62e95283 100644 --- a/src/application/command_handlers/create/config/mod.rs +++ b/src/application/command_handlers/create/config/mod.rs @@ -143,7 +143,7 @@ pub mod tracker; pub use environment_config::{EnvironmentCreationConfig, EnvironmentSection}; pub use errors::CreateConfigError; pub use grafana::GrafanaSection; -pub use https::{HttpsSection, TlsSection}; +pub use https::HttpsSection; pub use prometheus::PrometheusSection; pub use provider::{HetznerProviderSection, LxdProviderSection, ProviderSection}; pub use ssh_credentials_config::SshCredentialsConfig; diff --git a/src/domain/mod.rs b/src/domain/mod.rs index a40d7822..ce2541da 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -21,7 +21,6 @@ pub mod profile_name; pub mod prometheus; pub mod provider; pub mod template; -pub mod tls; pub mod tracker; // Re-export commonly used domain types for convenience diff --git a/src/domain/tls/config.rs b/src/domain/tls/config.rs deleted file mode 100644 index 040aa383..00000000 --- a/src/domain/tls/config.rs +++ /dev/null @@ -1,97 +0,0 @@ -//! TLS configuration domain types -//! -//! This module provides domain-level TLS configuration used in tracker -//! and Grafana services for HTTPS termination via Caddy. - -use serde::{Deserialize, Serialize}; - -use crate::shared::DomainName; - -/// Service-specific TLS configuration (domain level) -/// -/// Contains the domain name for Let's Encrypt certificate acquisition. -/// Present on services that should be accessible via HTTPS. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct TlsConfig { - /// 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. - domain: DomainName, -} - -impl TlsConfig { - /// Creates a new TLS configuration - /// - /// # Arguments - /// - /// * `domain` - The validated domain name for TLS certificate - /// - /// # Examples - /// - /// ```rust - /// use torrust_tracker_deployer_lib::domain::tls::TlsConfig; - /// use torrust_tracker_deployer_lib::shared::DomainName; - /// - /// let domain = DomainName::new("api.example.com").unwrap(); - /// let tls = TlsConfig::new(domain); - /// assert_eq!(tls.domain(), "api.example.com"); - /// ``` - #[must_use] - pub fn new(domain: DomainName) -> Self { - Self { domain } - } - - /// Returns the domain name as a string slice - #[must_use] - pub fn domain(&self) -> &str { - self.domain.as_str() - } - - /// Returns the domain name type - #[must_use] - pub fn domain_name(&self) -> &DomainName { - &self.domain - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn it_should_create_tls_config() { - let domain = DomainName::new("api.tracker.example.com").unwrap(); - let tls = TlsConfig::new(domain); - - assert_eq!(tls.domain(), "api.tracker.example.com"); - } - - #[test] - fn it_should_serialize_to_json() { - let domain = DomainName::new("api.example.com").unwrap(); - let tls = TlsConfig::new(domain); - - let json = serde_json::to_string(&tls).expect("serialization should succeed"); - - assert!(json.contains("\"domain\":\"api.example.com\"")); - } - - #[test] - fn it_should_deserialize_from_json() { - let json = r#"{"domain":"api.example.com"}"#; - - let tls: TlsConfig = serde_json::from_str(json).expect("deserialization should succeed"); - - assert_eq!(tls.domain(), "api.example.com"); - } - - #[test] - fn it_should_be_cloneable() { - let domain = DomainName::new("api.example.com").unwrap(); - let tls = TlsConfig::new(domain); - let cloned = tls.clone(); - - assert_eq!(tls, cloned); - } -} diff --git a/src/domain/tls/mod.rs b/src/domain/tls/mod.rs deleted file mode 100644 index 6cfba2d6..00000000 --- a/src/domain/tls/mod.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! TLS domain types -//! -//! This module contains domain types for TLS configuration on services. -//! -//! ## Purpose -//! -//! The `TlsConfig` type represents validated TLS settings that are stored -//! in service configurations and used for Caddy reverse proxy setup. -//! -//! ## See Also -//! -//! - Application layer DTOs: `src/application/command_handlers/create/config/https.rs` -//! - Caddy template context: `src/infrastructure/templating/caddy/` -//! - HTTPS domain config: `src/domain/https/` - -pub mod config; - -pub use config::TlsConfig; From 3815f5529ef499b3f7f343a87fa82f00aaf29da8 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 19 Jan 2026 16:51:49 +0000 Subject: [PATCH 29/36] feat: [#272] Add HTTPS support to test command with ServiceEndpoint type - Create ServiceEndpoint type for HTTP/HTTPS endpoint configuration - Update RunningServicesValidator to use ServiceEndpoint instead of ports - Add domain resolution for HTTPS (like curl --resolve) - Accept self-signed certificates for .local domains - Update test command handler to build endpoints from tracker config - Update E2E tests to use ServiceEndpoint pattern When TLS is enabled via Caddy, the test command now: - Uses HTTPS URLs with configured domains - Resolves domains locally to VM IP (no DNS needed) - Accepts self-signed certs for .local domains --- .../command_handlers/test/handler.rs | 84 ++-- src/bin/e2e_deployment_workflow_tests.rs | 14 +- src/infrastructure/external_validators/mod.rs | 5 +- .../external_validators/running_services.rs | 404 ++++++++---------- .../external_validators/service_endpoint.rs | 181 ++++++++ src/testing/e2e/tasks/run_run_validation.rs | 62 +-- 6 files changed, 456 insertions(+), 294 deletions(-) create mode 100644 src/infrastructure/external_validators/service_endpoint.rs diff --git a/src/application/command_handlers/test/handler.rs b/src/application/command_handlers/test/handler.rs index 2773eb21..acd5dc12 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,17 +42,17 @@ //! 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; +use crate::shared::domain_name::DomainName; /// `TestCommandHandler` orchestrates smoke testing for running Torrust Tracker services /// @@ -124,33 +133,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 + let tracker_api_endpoint = Self::build_api_endpoint(&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(Self::build_http_tracker_endpoint) .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().map(DomainName::as_str), + 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 +165,34 @@ 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(config: &HttpApiConfig) -> ServiceEndpoint { + let port = config.bind_address.port(); + let path = "/api/health_check"; + + if let Some(domain) = config.tls_domain() { + ServiceEndpoint::https(port, path, domain.clone()) + } else { + ServiceEndpoint::http(port, path) + } + } + + /// Build a `ServiceEndpoint` from the HTTP Tracker configuration + fn build_http_tracker_endpoint(config: &HttpTrackerConfig) -> ServiceEndpoint { + let port = config.bind_address.port(); + let path = "/health_check"; + + if let Some(domain) = config.tls_domain() { + ServiceEndpoint::https(port, path, domain.clone()) + } else { + ServiceEndpoint::http(port, path) + } } /// Load environment from storage diff --git a/src/bin/e2e_deployment_workflow_tests.rs b/src/bin/e2e_deployment_workflow_tests.rs index 5210a212..fa384e8a 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,24 @@ 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 api_endpoint = ServiceEndpoint::http( + runtime_env.container_ports.http_api_port, + "/api/health_check".to_string(), + ); + let http_tracker_endpoint = ServiceEndpoint::http( + runtime_env.container_ports.http_tracker_port, + "/health_check".to_string(), + ); 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/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..8c076487 100644 --- a/src/infrastructure/external_validators/running_services.rs +++ b/src/infrastructure/external_validators/running_services.rs @@ -20,201 +20,155 @@ //! - **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 crate::adapters::ssh::SshConfig; +use reqwest::ClientBuilder; +use tracing::{info, instrument, warn}; + +use super::service_endpoint::ServiceEndpoint; use crate::infrastructure::remote_actions::{RemoteAction, RemoteActionError}; +use crate::shared::domain_name::DomainName; /// 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) + /// Validate external accessibility of all configured endpoints async fn validate_external_accessibility( &self, server_ip: &IpAddr, - tracker_api_port: u16, - http_tracker_ports: &[u16], ) -> Result<(), RemoteActionError> { // Check tracker API (required) - self.check_tracker_api_external(server_ip, tracker_api_port) + self.check_endpoint(server_ip, &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(server_ip, 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(server_ip); + + if endpoint.uses_tls() { + info!( + action = "running_services_validation", + check = "service_external", + service = service_name, + url = %url, + domain = ?endpoint.domain().map(DomainName::as_str), + resolve_to = %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(server_ip, endpoint, &url).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 +177,75 @@ 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, + url: &str, + ) -> Result { + 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) + let socket_addr = std::net::SocketAddr::new(*server_ip, endpoint.effective_port()); + client_builder = client_builder.resolve(domain.as_str(), 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.as_str(), + "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).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 {server_ip} for testing.", + endpoint.domain().map_or("unknown", DomainName::as_str) + ) + } 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, + } + }) } } @@ -310,14 +271,7 @@ impl RemoteAction for RunningServicesValidator { "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(server_ip).await?; info!( action = "running_services_validation", @@ -333,20 +287,10 @@ impl RemoteAction for RunningServicesValidator { mod tests { 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()) - } - #[test] fn it_should_use_default_deploy_dir_when_not_specified() { assert_eq!(DEFAULT_DEPLOY_DIR, "/opt/torrust"); @@ -354,64 +298,64 @@ 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(1212, "/api/health_check"); + let tracker_endpoints = vec![ServiceEndpoint::http(7070, "/health_check")]; + + 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(1212, "/api/health_check", domain); + 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(1212, "/api/health_check", domain); + let tracker_endpoints = vec![ + ServiceEndpoint::http(7070, "/health_check"), + ServiceEndpoint::http(7071, "/health_check"), + ]; + + 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(1212, "/api/health_check"); + 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(1212, "/api/health_check"); 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..fcc85d28 --- /dev/null +++ b/src/infrastructure/external_validators/service_endpoint.rs @@ -0,0 +1,181 @@ +//! 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; + +use crate::shared::DomainName; + +/// Represents a service endpoint for external validation testing +/// +/// When TLS is enabled, the endpoint uses HTTPS with the configured domain. +/// The domain is resolved locally to the server IP using reqwest's resolve +/// feature (equivalent to curl's `--resolve` flag), allowing tests to work +/// without DNS configuration while still being realistic. +#[derive(Debug, Clone, PartialEq)] +pub struct ServiceEndpoint { + /// The port the service listens on internally + pub port: u16, + + /// The health check path (e.g., `/api/health_check` or `/health_check`) + pub path: String, + + /// TLS configuration if HTTPS is enabled + pub tls: Option, +} + +/// TLS configuration for an endpoint +#[derive(Debug, Clone, PartialEq)] +pub struct TlsEndpointConfig { + /// Domain name for HTTPS access + pub domain: DomainName, +} + +impl ServiceEndpoint { + /// Create a new HTTP endpoint (no TLS) + #[must_use] + pub fn http(port: u16, path: impl Into) -> Self { + Self { + port, + path: path.into(), + tls: None, + } + } + + /// Create a new HTTPS endpoint with TLS + #[must_use] + pub fn https(port: u16, path: impl Into, domain: DomainName) -> Self { + Self { + port, + path: path.into(), + tls: Some(TlsEndpointConfig { domain }), + } + } + + /// Returns true if this endpoint uses TLS + #[must_use] + pub fn uses_tls(&self) -> bool { + self.tls.is_some() + } + + /// Get the domain if TLS is enabled + #[must_use] + pub fn domain(&self) -> Option<&DomainName> { + self.tls.as_ref().map(|t| &t.domain) + } + + /// Build the URL for this endpoint + /// + /// - For HTTP: `http://{server_ip}:{port}{path}` + /// - For HTTPS: `https://{domain}{path}` (port 443 implied) + #[must_use] + pub fn url(&self, server_ip: &IpAddr) -> String { + if let Some(tls) = &self.tls { + // HTTPS uses domain, port 443 is implied + format!("https://{}{}", tls.domain.as_str(), self.path) + } else { + // HTTP uses IP and port directly + format!("http://{}:{}{}", server_ip, self.port, self.path) // DevSkim: ignore DS137138 + } + } + + /// Check if the domain ends with .local (for self-signed cert handling) + #[must_use] + pub fn is_local_domain(&self) -> bool { + self.tls.as_ref().is_some_and(|t| { + std::path::Path::new(t.domain.as_str()) + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("local")) + }) + } + + /// Returns the HTTPS port (443 for TLS, or the configured port for HTTP) + #[must_use] + pub fn effective_port(&self) -> u16 { + if self.uses_tls() { + 443 + } else { + self.port + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_create_http_endpoint() { + let endpoint = ServiceEndpoint::http(1212, "/api/health_check"); + + assert_eq!(endpoint.port, 1212); + assert_eq!(endpoint.path, "/api/health_check"); + 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(1212, "/api/health_check", domain); + + assert_eq!(endpoint.port, 1212); + assert_eq!(endpoint.path, "/api/health_check"); + assert!(endpoint.uses_tls()); + assert_eq!(endpoint.domain().unwrap().as_str(), "api.tracker.local"); + } + + #[test] + fn it_should_build_http_url() { + let endpoint = ServiceEndpoint::http(1212, "/api/health_check"); + let ip: IpAddr = "10.0.0.1".parse().unwrap(); + + assert_eq!(endpoint.url(&ip), "http://10.0.0.1:1212/api/health_check"); + } + + #[test] + fn it_should_build_https_url() { + let domain = DomainName::new("api.tracker.local").unwrap(); + let endpoint = ServiceEndpoint::https(1212, "/api/health_check", domain); + let ip: IpAddr = "10.0.0.1".parse().unwrap(); + + // HTTPS uses domain, not IP + assert_eq!( + endpoint.url(&ip), + "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(1212, "/api/health_check", domain); + + 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(1212, "/api/health_check", domain); + + assert!(!endpoint.is_local_domain()); + } + + #[test] + fn it_should_return_effective_port_443_for_tls() { + let domain = DomainName::new("api.tracker.local").unwrap(); + let endpoint = ServiceEndpoint::https(1212, "/api/health_check", domain); + + assert_eq!(endpoint.effective_port(), 443); + } + + #[test] + fn it_should_return_configured_port_for_http() { + let endpoint = ServiceEndpoint::http(1212, "/api/health_check"); + + assert_eq!(endpoint.effective_port(), 1212); + } +} diff --git a/src/testing/e2e/tasks/run_run_validation.rs b/src/testing/e2e/tasks/run_run_validation.rs index 3bae6fdd..8f382565 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(ip_addr, tracker_api_endpoint, http_tracker_endpoints).await?; // Optionally validate Prometheus is running and accessible if services.prometheus { @@ -291,30 +284,37 @@ 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 +/// +/// * `ip_addr` - IP address of the target instance +/// * `tracker_api_endpoint` - Endpoint for the tracker API health check +/// * `http_tracker_endpoints` - Endpoints for HTTP tracker health checks +/// +/// # 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)"); - - let ssh_config = SshConfig::new(ssh_credentials.clone(), SocketAddr::new(ip_addr, port)); + info!( + api_uses_tls = tracker_api_endpoint.uses_tls(), + http_tracker_count = http_tracker_endpoints.len(), + "Validating externally accessible services (tracker API, HTTP tracker)" + ); 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) .await From 242665096036e990e61e8a49ae51eaf7f28e138a Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 19 Jan 2026 17:55:03 +0000 Subject: [PATCH 30/36] refactor: [#272] Refactor ServiceEndpoint to store validated URL and server IP - Store validated Url instead of port/path/tls fields - http() takes SocketAddr and returns Result for URL validation - https() takes (domain, path, server_ip) and returns Result - Add InvalidServiceEndpointUrl error type - url() returns &Url reference instead of building string - Add socket_addr() method for convenience - domain() returns Option<&str> instead of Option<&DomainName> - Remove TlsEndpointConfig struct (no longer needed) - Update RunningServicesValidator to get server_ip from endpoint - Update TestCommandHandler to pass server_ip when building endpoints --- .../command_handlers/test/handler.rs | 30 +- src/bin/e2e_deployment_workflow_tests.rs | 15 +- .../external_validators/running_services.rs | 71 +++-- .../external_validators/service_endpoint.rs | 266 +++++++++++++----- src/testing/e2e/tasks/run_run_validation.rs | 14 +- 5 files changed, 267 insertions(+), 129 deletions(-) diff --git a/src/application/command_handlers/test/handler.rs b/src/application/command_handlers/test/handler.rs index acd5dc12..30381d61 100644 --- a/src/application/command_handlers/test/handler.rs +++ b/src/application/command_handlers/test/handler.rs @@ -52,7 +52,6 @@ use crate::domain::tracker::config::{HttpApiConfig, HttpTrackerConfig}; use crate::domain::EnvironmentName; use crate::infrastructure::external_validators::{RunningServicesValidator, ServiceEndpoint}; use crate::infrastructure::remote_actions::RemoteAction; -use crate::shared::domain_name::DomainName; /// `TestCommandHandler` orchestrates smoke testing for running Torrust Tracker services /// @@ -136,12 +135,12 @@ impl TestCommandHandler { // Extract tracker config let tracker_config = any_env.tracker_config(); - // Build service endpoints from configuration - let tracker_api_endpoint = Self::build_api_endpoint(&tracker_config.http_api); + // 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(Self::build_http_tracker_endpoint) + .map(|config| Self::build_http_tracker_endpoint(instance_ip, config)) .collect(); // Log endpoint information @@ -150,7 +149,7 @@ impl TestCommandHandler { environment = %env_name, instance_ip = ?instance_ip, api_endpoint_tls = tracker_api_endpoint.uses_tls(), - api_endpoint_domain = ?tracker_api_endpoint.domain().map(DomainName::as_str), + api_endpoint_domain = ?tracker_api_endpoint.domain(), http_tracker_count = http_tracker_endpoints.len(), "Starting service health checks" ); @@ -172,26 +171,35 @@ impl TestCommandHandler { } /// Build a `ServiceEndpoint` from the HTTP API configuration - fn build_api_endpoint(config: &HttpApiConfig) -> ServiceEndpoint { + 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(port, path, domain.clone()) + ServiceEndpoint::https(domain, path, server_ip) + .expect("Valid TLS domain should produce valid HTTPS URL") } else { - ServiceEndpoint::http(port, path) + 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(config: &HttpTrackerConfig) -> ServiceEndpoint { + 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(port, path, domain.clone()) + ServiceEndpoint::https(domain, path, server_ip) + .expect("Valid TLS domain should produce valid HTTPS URL") } else { - ServiceEndpoint::http(port, path) + ServiceEndpoint::http(socket_addr, path) + .expect("Valid socket address should produce valid HTTP URL") } } diff --git a/src/bin/e2e_deployment_workflow_tests.rs b/src/bin/e2e_deployment_workflow_tests.rs index fa384e8a..94231ffd 100644 --- a/src/bin/e2e_deployment_workflow_tests.rs +++ b/src/bin/e2e_deployment_workflow_tests.rs @@ -309,14 +309,17 @@ async fn run_deployer_workflow( prometheus: true, grafana: true, }; + let server_ip = socket_addr.ip(); let api_endpoint = ServiceEndpoint::http( - runtime_env.container_ports.http_api_port, - "/api/health_check".to_string(), - ); + 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( - runtime_env.container_ports.http_tracker_port, - "/health_check".to_string(), - ); + 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, diff --git a/src/infrastructure/external_validators/running_services.rs b/src/infrastructure/external_validators/running_services.rs index 8c076487..3868fc52 100644 --- a/src/infrastructure/external_validators/running_services.rs +++ b/src/infrastructure/external_validators/running_services.rs @@ -52,7 +52,6 @@ use tracing::{info, instrument, warn}; use super::service_endpoint::ServiceEndpoint; use crate::infrastructure::remote_actions::{RemoteAction, RemoteActionError}; -use crate::shared::domain_name::DomainName; /// Default deployment directory for Docker Compose files const DEFAULT_DEPLOY_DIR: &str = "/opt/torrust"; @@ -112,18 +111,15 @@ impl RunningServicesValidator { } /// Validate external accessibility of all configured endpoints - async fn validate_external_accessibility( - &self, - server_ip: &IpAddr, - ) -> Result<(), RemoteActionError> { + async fn validate_external_accessibility(&self) -> Result<(), RemoteActionError> { // Check tracker API (required) - self.check_endpoint(server_ip, &self.tracker_api_endpoint, "Tracker API") + self.check_endpoint(&self.tracker_api_endpoint, "Tracker API") .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(server_ip, endpoint, &name).await?; + self.check_endpoint(endpoint, &name).await?; } Ok(()) @@ -136,11 +132,10 @@ impl RunningServicesValidator { /// - Accepts self-signed certs for `.local` domains async fn check_endpoint( &self, - server_ip: &IpAddr, endpoint: &ServiceEndpoint, service_name: &str, ) -> Result<(), RemoteActionError> { - let url = endpoint.url(server_ip); + let url = endpoint.url(); if endpoint.uses_tls() { info!( @@ -148,8 +143,8 @@ impl RunningServicesValidator { check = "service_external", service = service_name, url = %url, - domain = ?endpoint.domain().map(DomainName::as_str), - resolve_to = %server_ip, + domain = ?endpoint.domain(), + resolve_to = %endpoint.server_ip(), "Testing HTTPS endpoint (resolving domain to IP locally)" ); } else { @@ -162,7 +157,7 @@ impl RunningServicesValidator { ); } - let response = self.make_request(server_ip, endpoint, &url).await?; + let response = self.make_request(endpoint).await?; if !response.status().is_success() { return Err(RemoteActionError::ValidationFailed { @@ -194,24 +189,22 @@ impl RunningServicesValidator { /// - Accepts self-signed certificates for `.local` domains async fn make_request( &self, - server_ip: &IpAddr, endpoint: &ServiceEndpoint, - url: &str, ) -> 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) - let socket_addr = std::net::SocketAddr::new(*server_ip, endpoint.effective_port()); - client_builder = client_builder.resolve(domain.as_str(), socket_addr); + 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.as_str(), + domain = domain, "Accepting self-signed certificates for .local domain" ); client_builder = client_builder.danger_accept_invalid_certs(true); @@ -225,19 +218,20 @@ impl RunningServicesValidator { message: format!("Failed to build HTTP client: {e}"), })?; - client.get(url).send().await.map_err(|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 {server_ip} for testing.", - endpoint.domain().map_or("unknown", DomainName::as_str) + 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 + endpoint.port() ) }; @@ -265,13 +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" ); - self.validate_external_accessibility(server_ip).await?; + self.validate_external_accessibility().await?; info!( action = "running_services_validation", @@ -285,12 +283,21 @@ impl RemoteAction for RunningServicesValidator { #[cfg(test)] mod tests { + use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; use crate::shared::DomainName; 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_use_default_deploy_dir_when_not_specified() { assert_eq!(DEFAULT_DEPLOY_DIR, "/opt/torrust"); @@ -303,8 +310,10 @@ mod tests { #[test] fn it_should_create_validator_with_http_endpoints() { - let api_endpoint = ServiceEndpoint::http(1212, "/api/health_check"); - let tracker_endpoints = vec![ServiceEndpoint::http(7070, "/health_check")]; + 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); @@ -315,7 +324,7 @@ mod tests { #[test] fn it_should_create_validator_with_https_endpoints() { let domain = DomainName::new("api.tracker.local").unwrap(); - let api_endpoint = ServiceEndpoint::https(1212, "/api/health_check", domain); + let api_endpoint = ServiceEndpoint::https(&domain, "/api/health_check", test_ip()).unwrap(); let tracker_endpoints = vec![]; let validator = RunningServicesValidator::new(api_endpoint.clone(), tracker_endpoints); @@ -326,10 +335,10 @@ mod tests { #[test] fn it_should_create_validator_with_mixed_endpoints() { let domain = DomainName::new("api.tracker.local").unwrap(); - let api_endpoint = ServiceEndpoint::https(1212, "/api/health_check", domain); + let api_endpoint = ServiceEndpoint::https(&domain, "/api/health_check", test_ip()).unwrap(); let tracker_endpoints = vec![ - ServiceEndpoint::http(7070, "/health_check"), - ServiceEndpoint::http(7071, "/health_check"), + 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); @@ -341,7 +350,8 @@ mod tests { #[test] fn it_should_accept_empty_tracker_endpoints() { - let api_endpoint = ServiceEndpoint::http(1212, "/api/health_check"); + 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_endpoints.len(), 0); @@ -349,7 +359,8 @@ mod tests { #[test] fn it_should_use_custom_deploy_dir() { - let api_endpoint = ServiceEndpoint::http(1212, "/api/health_check"); + let api_endpoint = + ServiceEndpoint::http(test_socket_addr(1212), "/api/health_check").unwrap(); let validator = RunningServicesValidator::with_deploy_dir( PathBuf::from("/custom/path"), api_endpoint, diff --git a/src/infrastructure/external_validators/service_endpoint.rs b/src/infrastructure/external_validators/service_endpoint.rs index fcc85d28..11f1a826 100644 --- a/src/infrastructure/external_validators/service_endpoint.rs +++ b/src/infrastructure/external_validators/service_endpoint.rs @@ -4,114 +4,193 @@ //! 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; +use std::net::{IpAddr, SocketAddr}; + +use url::Url; use crate::shared::DomainName; -/// Represents a service endpoint for external validation testing -/// -/// When TLS is enabled, the endpoint uses HTTPS with the configured domain. -/// The domain is resolved locally to the server IP using reqwest's resolve -/// feature (equivalent to curl's `--resolve` flag), allowing tests to work -/// without DNS configuration while still being realistic. +/// Error when creating a `ServiceEndpoint` with an invalid URL #[derive(Debug, Clone, PartialEq)] -pub struct ServiceEndpoint { - /// The port the service listens on internally - pub port: u16, - - /// The health check path (e.g., `/api/health_check` or `/health_check`) - pub path: String, +pub struct InvalidServiceEndpointUrl { + /// The URL string that failed to parse + pub url_string: String, + /// The parse error message + pub reason: String, +} - /// TLS configuration if HTTPS is enabled - pub tls: Option, +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 + ) + } } -/// TLS configuration for an endpoint +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 TlsEndpointConfig { - /// Domain name for HTTPS access - pub domain: DomainName, +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) - #[must_use] - pub fn http(port: u16, path: impl Into) -> Self { - Self { - port, - path: path.into(), - tls: None, - } + /// + /// # 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 https(port: u16, path: impl Into, domain: DomainName) -> Self { - Self { - port, - path: path.into(), - tls: Some(TlsEndpointConfig { domain }), - } + pub fn url(&self) -> &Url { + &self.url } - /// Returns true if this endpoint uses TLS + /// Returns true if this endpoint uses TLS (HTTPS) #[must_use] pub fn uses_tls(&self) -> bool { - self.tls.is_some() + self.url.scheme() == "https" } - /// Get the domain if TLS is enabled + /// Returns the server IP address #[must_use] - pub fn domain(&self) -> Option<&DomainName> { - self.tls.as_ref().map(|t| &t.domain) + pub fn server_ip(&self) -> IpAddr { + self.server_ip } - /// Build the URL for this endpoint + /// Returns the port for this endpoint /// - /// - For HTTP: `http://{server_ip}:{port}{path}` - /// - For HTTPS: `https://{domain}{path}` (port 443 implied) + /// For HTTP: the configured port from the URL. + /// For HTTPS: 443 (the default HTTPS port). #[must_use] - pub fn url(&self, server_ip: &IpAddr) -> String { - if let Some(tls) = &self.tls { - // HTTPS uses domain, port 443 is implied - format!("https://{}{}", tls.domain.as_str(), self.path) + 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 { - // HTTP uses IP and port directly - format!("http://{}:{}{}", server_ip, self.port, self.path) // DevSkim: ignore DS137138 + None } } - /// Check if the domain ends with .local (for self-signed cert handling) + /// Check if the domain ends with `.local` (for self-signed cert handling) #[must_use] pub fn is_local_domain(&self) -> bool { - self.tls.as_ref().is_some_and(|t| { - std::path::Path::new(t.domain.as_str()) + self.domain().is_some_and(|d| { + std::path::Path::new(d) .extension() .is_some_and(|ext| ext.eq_ignore_ascii_case("local")) }) } - /// Returns the HTTPS port (443 for TLS, or the configured port for HTTP) + /// Returns the socket address for connecting to this endpoint + /// + /// Combines the server IP with the port from the URL. #[must_use] - pub fn effective_port(&self) -> u16 { - if self.uses_tls() { - 443 - } else { - self.port - } + 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(1212, "/api/health_check"); + let endpoint = ServiceEndpoint::http(test_socket_addr(1212), "/api/health_check").unwrap(); - assert_eq!(endpoint.port, 1212); - assert_eq!(endpoint.path, "/api/health_check"); + assert_eq!(endpoint.server_ip(), test_ip()); + assert_eq!(endpoint.port(), 1212); assert!(!endpoint.uses_tls()); assert!(endpoint.domain().is_none()); } @@ -119,31 +198,32 @@ mod tests { #[test] fn it_should_create_https_endpoint() { let domain = DomainName::new("api.tracker.local").unwrap(); - let endpoint = ServiceEndpoint::https(1212, "/api/health_check", domain); + let endpoint = ServiceEndpoint::https(&domain, "/api/health_check", test_ip()).unwrap(); - assert_eq!(endpoint.port, 1212); - assert_eq!(endpoint.path, "/api/health_check"); + assert_eq!(endpoint.server_ip(), test_ip()); + assert_eq!(endpoint.port(), 443); assert!(endpoint.uses_tls()); - assert_eq!(endpoint.domain().unwrap().as_str(), "api.tracker.local"); + assert_eq!(endpoint.domain().unwrap(), "api.tracker.local"); } #[test] fn it_should_build_http_url() { - let endpoint = ServiceEndpoint::http(1212, "/api/health_check"); - let ip: IpAddr = "10.0.0.1".parse().unwrap(); + let endpoint = ServiceEndpoint::http(test_socket_addr(1212), "/api/health_check").unwrap(); - assert_eq!(endpoint.url(&ip), "http://10.0.0.1:1212/api/health_check"); + 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(1212, "/api/health_check", domain); - let ip: IpAddr = "10.0.0.1".parse().unwrap(); + let endpoint = ServiceEndpoint::https(&domain, "/api/health_check", test_ip()).unwrap(); // HTTPS uses domain, not IP assert_eq!( - endpoint.url(&ip), + endpoint.url().as_str(), "https://api.tracker.local/api/health_check" ); } @@ -151,7 +231,7 @@ mod tests { #[test] fn it_should_detect_local_domain() { let domain = DomainName::new("api.tracker.local").unwrap(); - let endpoint = ServiceEndpoint::https(1212, "/api/health_check", domain); + let endpoint = ServiceEndpoint::https(&domain, "/api/health_check", test_ip()).unwrap(); assert!(endpoint.is_local_domain()); } @@ -159,23 +239,57 @@ mod tests { #[test] fn it_should_not_detect_non_local_domain_as_local() { let domain = DomainName::new("api.tracker.example.com").unwrap(); - let endpoint = ServiceEndpoint::https(1212, "/api/health_check", domain); + let endpoint = ServiceEndpoint::https(&domain, "/api/health_check", test_ip()).unwrap(); assert!(!endpoint.is_local_domain()); } #[test] - fn it_should_return_effective_port_443_for_tls() { + fn it_should_return_port_443_for_https() { let domain = DomainName::new("api.tracker.local").unwrap(); - let endpoint = ServiceEndpoint::https(1212, "/api/health_check", domain); + let endpoint = ServiceEndpoint::https(&domain, "/api/health_check", test_ip()).unwrap(); - assert_eq!(endpoint.effective_port(), 443); + assert_eq!(endpoint.port(), 443); } #[test] fn it_should_return_configured_port_for_http() { - let endpoint = ServiceEndpoint::http(1212, "/api/health_check"); + 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(); - assert_eq!(endpoint.effective_port(), 1212); + // 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/testing/e2e/tasks/run_run_validation.rs b/src/testing/e2e/tasks/run_run_validation.rs index 8f382565..d914c03d 100644 --- a/src/testing/e2e/tasks/run_run_validation.rs +++ b/src/testing/e2e/tasks/run_run_validation.rs @@ -264,7 +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, tracker_api_endpoint, http_tracker_endpoints).await?; + validate_external_services(tracker_api_endpoint, http_tracker_endpoints).await?; // Optionally validate Prometheus is running and accessible if services.prometheus { @@ -291,9 +291,8 @@ pub async fn run_run_validation( /// /// # Arguments /// -/// * `ip_addr` - IP address of the target instance -/// * `tracker_api_endpoint` - Endpoint for the tracker API health check -/// * `http_tracker_endpoints` - Endpoints for HTTP tracker health checks +/// * `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 /// @@ -303,20 +302,23 @@ pub async fn run_run_validation( /// /// Returns an error if any service is not running or unhealthy. async fn validate_external_services( - ip_addr: IpAddr, tracker_api_endpoint: ServiceEndpoint, http_tracker_endpoints: Vec, ) -> Result<(), RunValidationError> { 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)" ); + // Get server IP from endpoint for trait compatibility + let server_ip = tracker_api_endpoint.server_ip(); + let services_validator = RunningServicesValidator::new(tracker_api_endpoint, http_tracker_endpoints); services_validator - .execute(&ip_addr) + .execute(&server_ip) .await .map_err(|source| RunValidationError::RunningServicesValidationFailed { source })?; From bf5df9077a5d6bbfbf202a3b424407b93ceb13f6 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Mon, 19 Jan 2026 19:49:22 +0000 Subject: [PATCH 31/36] docs: [#272] Add HTTPS user documentation - Create docs/user-guide/services/https.md with complete HTTPS setup guide - Document domain + use_tls_proxy configuration pattern for each service - Add Let's Encrypt production vs staging documentation - Add configuration examples for various HTTPS patterns - Add troubleshooting section for common certificate issues - Update services README to include HTTPS as an available service - Update main user guide README with HTTPS service reference - Update grafana.md with TLS proxy configuration fields - Regenerate JSON schema to reflect current implementation --- .../272-add-https-support-with-caddy.md | 55 ++- docs/user-guide/README.md | 7 +- docs/user-guide/services/README.md | 10 +- docs/user-guide/services/grafana.md | 60 ++- docs/user-guide/services/https.md | 442 ++++++++++++++++++ project-words.txt | 1 + schemas/environment-config.json | 90 +++- .../docker-compose/docker-compose.yml.tera | 5 + 8 files changed, 612 insertions(+), 58 deletions(-) create mode 100644 docs/user-guide/services/https.md diff --git a/docs/issues/272-add-https-support-with-caddy.md b/docs/issues/272-add-https-support-with-caddy.md index 4e3b24cc..b78cd660 100644 --- a/docs/issues/272-add-https-support-with-caddy.md +++ b/docs/issues/272-add-https-support-with-caddy.md @@ -23,8 +23,8 @@ Production deployment at `/opt/torrust/` on Hetzner server (46.224.206.37) serve - [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 -- [ ] Implement security scanning for Caddy in CI/CD -- [ ] Document HTTPS setup in user guide +- [x] Implement security scanning for Caddy in CI/CD +- [x] Document HTTPS setup in user guide - [ ] Add E2E tests for HTTPS functionality ## πŸ—οΈ Architecture Requirements @@ -684,27 +684,24 @@ Add link to HTTPS setup guide. ### 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) @@ -1483,20 +1480,20 @@ The implementation is split into incremental steps, one service type at a time, - [x] Remove `domain::tls` module completely (unused after migration) - [x] Run full E2E test suite - [x] Run all linters -- [ ] Manual verification with `envs/manual-https-test.json` +- [x] Manual verification with `envs/manual-https-test.json` ### Phase 8: Schema Generation (30 minutes) -- [ ] Regenerate JSON schema from Rust DTOs: +- [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 +- [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 ADR (1 hour) 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 f51f9ce8..a232b0dc 100644 --- a/project-words.txt +++ b/project-words.txt @@ -351,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/templates/docker-compose/docker-compose.yml.tera b/templates/docker-compose/docker-compose.yml.tera index 6424e9d0..27471263 100644 --- a/templates/docker-compose/docker-compose.yml.tera +++ b/templates/docker-compose/docker-compose.yml.tera @@ -36,6 +36,11 @@ services: <<: *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 From 310f93fada403de787e5ffacda59605f2c084d2b Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 20 Jan 2026 10:09:29 +0000 Subject: [PATCH 32/36] docs: [#272] Add revised Phase 6 implementation plan for HTTPS E2E testing --- .../272-add-https-support-with-caddy.md | 78 ++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/docs/issues/272-add-https-support-with-caddy.md b/docs/issues/272-add-https-support-with-caddy.md index b78cd660..011dbb6e 100644 --- a/docs/issues/272-add-https-support-with-caddy.md +++ b/docs/issues/272-add-https-support-with-caddy.md @@ -705,7 +705,83 @@ Add link to HTTPS setup guide. ### 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. + +Implementation Plan: + +- **Step 1: Add smoke test execution to E2E workflow** + - [ ] Add `run_smoke_tests()` method to `E2eTestRunner` in `src/testing/e2e/tasks/black_box/test_runner.rs` + - [ ] Execute `cargo run --bin torrust-tracker-deployer -- test ` via `ProcessRunner` + - [ ] The existing `test` command already supports HTTPS via `ServiceEndpoint::https()` with domain resolution + - [ ] Call `test_runner.run_smoke_tests()` in `run_deployer_workflow()` after `run_services()` + - [ ] Verify E2E tests pass on GitHub Actions (may require runner changes) + - [ ] Commit and push to verify CI passes + +- **Step 2: Enable HTTPS in E2E test configuration** + - [ ] Modify `E2eConfigEnvironment::to_json_config()` in `src/testing/e2e/containers/tracker_ports.rs`: + - [ ] Add `domain` and `use_tls_proxy: true` for each HTTP tracker + - [ ] Add `domain` and `use_tls_proxy: true` for HTTP API + - [ ] Add `domain` and `use_tls_proxy: true` for Grafana + - [ ] Add `https` section with `admin_email` and `use_staging: true` + - [ ] Use `.local` domains (e.g., `api.tracker.local`, `http1.tracker.local`) + - [ ] Caddy's internal CA automatically handles `.local` domain certificates + - [ ] Wait for Caddy certificate acquisition after `run_services()` (add brief delay or retry logic) + +- **Step 3: Verify HTTPS E2E tests pass** + - [ ] Run E2E tests locally: `cargo run --bin e2e-deployment-workflow-tests` + - [ ] Verify `test` command validates HTTPS endpoints correctly + - [ ] Verify Caddy logs show successful certificate acquisition + - [ ] Run all linters and pre-commit checks + - [ ] Push to GitHub and verify CI passes + +**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 From 69483421a064f84a2b3dd206ff5570910291d5cb Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 20 Jan 2026 11:46:01 +0000 Subject: [PATCH 33/36] docs: [#272] Explain why test command cannot work in Docker-based E2E tests --- .../272-add-https-support-with-caddy.md | 71 ++++++++++++------- 1 file changed, 45 insertions(+), 26 deletions(-) diff --git a/docs/issues/272-add-https-support-with-caddy.md b/docs/issues/272-add-https-support-with-caddy.md index 011dbb6e..e47a3124 100644 --- a/docs/issues/272-add-https-support-with-caddy.md +++ b/docs/issues/272-add-https-support-with-caddy.md @@ -717,32 +717,51 @@ HTTPS while others use HTTP simultaneously. Instead, we'll take a simpler, more This approach provides comprehensive HTTPS coverage while leveraging existing infrastructure. -Implementation Plan: - -- **Step 1: Add smoke test execution to E2E workflow** - - [ ] Add `run_smoke_tests()` method to `E2eTestRunner` in `src/testing/e2e/tasks/black_box/test_runner.rs` - - [ ] Execute `cargo run --bin torrust-tracker-deployer -- test ` via `ProcessRunner` - - [ ] The existing `test` command already supports HTTPS via `ServiceEndpoint::https()` with domain resolution - - [ ] Call `test_runner.run_smoke_tests()` in `run_deployer_workflow()` after `run_services()` - - [ ] Verify E2E tests pass on GitHub Actions (may require runner changes) - - [ ] Commit and push to verify CI passes - -- **Step 2: Enable HTTPS in E2E test configuration** - - [ ] Modify `E2eConfigEnvironment::to_json_config()` in `src/testing/e2e/containers/tracker_ports.rs`: - - [ ] Add `domain` and `use_tls_proxy: true` for each HTTP tracker - - [ ] Add `domain` and `use_tls_proxy: true` for HTTP API - - [ ] Add `domain` and `use_tls_proxy: true` for Grafana - - [ ] Add `https` section with `admin_email` and `use_staging: true` - - [ ] Use `.local` domains (e.g., `api.tracker.local`, `http1.tracker.local`) - - [ ] Caddy's internal CA automatically handles `.local` domain certificates - - [ ] Wait for Caddy certificate acquisition after `run_services()` (add brief delay or retry logic) - -- **Step 3: Verify HTTPS E2E tests pass** - - [ ] Run E2E tests locally: `cargo run --bin e2e-deployment-workflow-tests` - - [ ] Verify `test` command validates HTTPS endpoints correctly - - [ ] Verify Caddy logs show successful certificate acquisition - - [ ] Run all linters and pre-commit checks - - [ ] Push to GitHub and verify CI passes +**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): From 8b6e36119662ae3478403d7b128421321ef399b4 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 20 Jan 2026 13:51:24 +0000 Subject: [PATCH 34/36] docs: [#272] Add ADRs for HTTPS implementation decisions (Phase 9) Phase 9: Documentation ADRs created: - caddy-for-tls-termination.md: Why Caddy v2.10 was chosen over Pingoo/nginx - per-service-tls-configuration.md: Why domain+use_tls_proxy pattern vs nested tls section - uniform-http-tracker-tls-requirement.md: Why all HTTP trackers must use same TLS setting Key rationale documented: - Caddy: WebSocket support (Pingoo failed), automatic HTTPS, simple config - use_tls_proxy naming: Avoids confusion with tracker's native TslConfig - Uniform TLS: Tracker's on_reverse_proxy is global, not per-tracker This completes issue #272 - all 9 phases are now done. --- docs/decisions/README.md | 3 + docs/decisions/caddy-for-tls-termination.md | 140 ++++++++++ .../per-service-tls-configuration.md | 255 ++++++++++++++++++ .../uniform-http-tracker-tls-requirement.md | 206 ++++++++++++++ .../272-add-https-support-with-caddy.md | 25 +- 5 files changed, 621 insertions(+), 8 deletions(-) create mode 100644 docs/decisions/caddy-for-tls-termination.md create mode 100644 docs/decisions/per-service-tls-configuration.md create mode 100644 docs/decisions/uniform-http-tracker-tls-requirement.md 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/issues/272-add-https-support-with-caddy.md b/docs/issues/272-add-https-support-with-caddy.md index e47a3124..6503ec19 100644 --- a/docs/issues/272-add-https-support-with-caddy.md +++ b/docs/issues/272-add-https-support-with-caddy.md @@ -1590,13 +1590,19 @@ The implementation is split into incremental steps, one service type at a time, - [x] Test schema with example HTTPS-enabled environment file - [x] Commit updated schema file -### Phase 9: 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 +### 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 @@ -1639,7 +1645,10 @@ The implementation is split into incremental steps, one service type at a time, - [ ] 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**: From e464aca65930b7127871fe1094e5f3b9164f1d23 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 20 Jan 2026 14:21:44 +0000 Subject: [PATCH 35/36] feat: [#272] Explicitly set X-Forwarded-For header in Caddy reverse proxy Add explicit header_up X-Forwarded-For configuration to Caddyfile.tera instead of relying on Caddy's default behavior. Rationale: - While Caddy sets X-Forwarded-For by default, we explicitly configure it to guard against future changes in Caddy's default behavior - This header is critical for the tracker's on_reverse_proxy mode to correctly identify client IPs for peer tracking in BitTorrent swarms - Explicit configuration makes the intent clear and self-documenting The X-Forwarded-For header is required by the tracker when running behind a reverse proxy to record the correct peer IP addresses. --- templates/caddy/Caddyfile.tera | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/templates/caddy/Caddyfile.tera b/templates/caddy/Caddyfile.tera index 4cb2932f..c13f24bc 100644 --- a/templates/caddy/Caddyfile.tera +++ b/templates/caddy/Caddyfile.tera @@ -3,6 +3,13 @@ # # 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: +# Caddy sets X-Forwarded-For, X-Forwarded-Proto, and X-Forwarded-Host by default. +# We explicitly set X-Forwarded-For 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 { @@ -18,27 +25,38 @@ # Tracker REST API {{ tracker_api.domain }} { - reverse_proxy tracker:{{ tracker_api.port }} + reverse_proxy tracker:{{ tracker_api.port }} { + # Explicitly forward client IP - required for tracker's on_reverse_proxy mode + header_up X-Forwarded-For {remote_host} + } } {%- endif %} {%- for http_tracker in http_trackers %} # HTTP Tracker {{ loop.index }} {{ http_tracker.domain }} { - reverse_proxy tracker:{{ http_tracker.port }} + 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 }} + reverse_proxy tracker:{{ health_check_api.port }} { + header_up X-Forwarded-For {remote_host} + } } {%- endif %} {%- if grafana %} # Grafana UI with WebSocket support {{ grafana.domain }} { - reverse_proxy grafana:3000 + reverse_proxy grafana:3000 { + header_up X-Forwarded-For {remote_host} + } } {%- endif %} From 28336e0880126bb88b5cf4fe7725365d4d29084f Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Tue, 20 Jan 2026 15:25:55 +0000 Subject: [PATCH 36/36] refactor: [#272] Set X-Forwarded-For header only for HTTP trackers The X-Forwarded-For header is critical only for HTTP trackers where the tracker needs the real client IP to record correct peer addresses in the swarm. Other services (API, health check, Grafana) don't require explicit header configuration as Caddy's default forwarding is sufficient. This change: - Keeps explicit header_up X-Forwarded-For {remote_host} for HTTP trackers - Removes explicit header for API, health check, and Grafana endpoints - Updates header comment to clarify it's for HTTP trackers specifically --- templates/caddy/Caddyfile.tera | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/templates/caddy/Caddyfile.tera b/templates/caddy/Caddyfile.tera index c13f24bc..c0aee19a 100644 --- a/templates/caddy/Caddyfile.tera +++ b/templates/caddy/Caddyfile.tera @@ -4,12 +4,12 @@ # 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: +# 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 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. +# 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 { @@ -25,10 +25,7 @@ # Tracker REST API {{ tracker_api.domain }} { - reverse_proxy tracker:{{ tracker_api.port }} { - # Explicitly forward client IP - required for tracker's on_reverse_proxy mode - header_up X-Forwarded-For {remote_host} - } + reverse_proxy tracker:{{ tracker_api.port }} } {%- endif %} {%- for http_tracker in http_trackers %} @@ -46,17 +43,13 @@ # Health Check API {{ health_check_api.domain }} { - reverse_proxy tracker:{{ health_check_api.port }} { - header_up X-Forwarded-For {remote_host} - } + reverse_proxy tracker:{{ health_check_api.port }} } {%- endif %} {%- if grafana %} # Grafana UI with WebSocket support {{ grafana.domain }} { - reverse_proxy grafana:3000 { - header_up X-Forwarded-For {remote_host} - } + reverse_proxy grafana:3000 } {%- endif %}