From 3653e1c045cb6283a4481666e87fa2ce8925ddfe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Oct 2025 16:39:56 +0000 Subject: [PATCH 1/6] Initial plan From c2829c25dcfa3ad8365ec37920e2b4a8b1d7af08 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Oct 2025 17:02:21 +0000 Subject: [PATCH 2/6] feat: add UFW firewall configuration step - Add ConfigureFirewall variant to ConfigureStep enum - Create configure-firewall.yml.tera template with SSH port variable - Add ssh_port field to InventoryContext for template rendering - Create ConfigureFirewallStep in application layer - Add firewall template rendering to AnsibleTemplateRenderer - Integrate firewall step into ConfigureCommandHandler - Update module exports for new firewall step Co-authored-by: josecelano <58816+josecelano@users.noreply.github.com> --- .../command_handlers/configure/handler.rs | 9 +- src/application/steps/mod.rs | 2 +- .../steps/system/configure_firewall.rs | 136 ++++++++++++++++++ src/application/steps/system/mod.rs | 4 +- .../environment/state/configure_failed.rs | 2 + .../ansible/template/renderer/mod.rs | 90 ++++++++++++ .../wrappers/inventory/context/mod.rs | 5 + templates/ansible/configure-firewall.yml.tera | 117 +++++++++++++++ 8 files changed, 362 insertions(+), 3 deletions(-) create mode 100644 src/application/steps/system/configure_firewall.rs create mode 100644 templates/ansible/configure-firewall.yml.tera diff --git a/src/application/command_handlers/configure/handler.rs b/src/application/command_handlers/configure/handler.rs index 1c40880a..819efa00 100644 --- a/src/application/command_handlers/configure/handler.rs +++ b/src/application/command_handlers/configure/handler.rs @@ -8,7 +8,8 @@ use super::errors::ConfigureCommandHandlerError; use crate::adapters::ansible::AnsibleClient; use crate::application::command_handlers::common::StepResult; use crate::application::steps::{ - ConfigureSecurityUpdatesStep, InstallDockerComposeStep, InstallDockerStep, + ConfigureFirewallStep, ConfigureSecurityUpdatesStep, InstallDockerComposeStep, + InstallDockerStep, }; use crate::domain::environment::repository::{EnvironmentRepository, TypedEnvironmentRepository}; use crate::domain::environment::state::{ConfigureFailureContext, ConfigureStep}; @@ -24,6 +25,7 @@ use crate::shared::error::Traceable; /// 1. Install Docker /// 2. Install Docker Compose /// 3. Configure automatic security updates +/// 4. Configure UFW firewall /// /// # State Management /// @@ -161,6 +163,11 @@ impl ConfigureCommandHandler { .execute() .map_err(|e| (e.into(), current_step))?; + let current_step = ConfigureStep::ConfigureFirewall; + ConfigureFirewallStep::new(Arc::clone(&self.ansible_client)) + .execute() + .map_err(|e| (e.into(), current_step))?; + // Transition to Configured state let configured = environment.clone().configured(); diff --git a/src/application/steps/mod.rs b/src/application/steps/mod.rs index 7e65ab5e..e797df54 100644 --- a/src/application/steps/mod.rs +++ b/src/application/steps/mod.rs @@ -36,7 +36,7 @@ pub use rendering::{ RenderAnsibleTemplatesError, RenderAnsibleTemplatesStep, RenderOpenTofuTemplatesStep, }; pub use software::{InstallDockerComposeStep, InstallDockerStep}; -pub use system::{ConfigureSecurityUpdatesStep, WaitForCloudInitStep}; +pub use system::{ConfigureFirewallStep, ConfigureSecurityUpdatesStep, WaitForCloudInitStep}; pub use validation::{ ValidateCloudInitCompletionStep, ValidateDockerComposeInstallationStep, ValidateDockerInstallationStep, diff --git a/src/application/steps/system/configure_firewall.rs b/src/application/steps/system/configure_firewall.rs new file mode 100644 index 00000000..9a930289 --- /dev/null +++ b/src/application/steps/system/configure_firewall.rs @@ -0,0 +1,136 @@ +//! UFW firewall configuration step +//! +//! This module provides the `ConfigureFirewallStep` which handles configuration +//! of UFW (Uncomplicated Firewall) on remote hosts via Ansible playbooks. +//! This step ensures that the firewall is configured with restrictive default +//! policies while maintaining SSH access to prevent lockout. +//! +//! ## Key Features +//! +//! - Configures UFW with restrictive default policies (deny incoming, allow outgoing) +//! - Preserves SSH access on the configured port +//! - Uses Tera template for dynamic SSH port resolution +//! - Comprehensive SSH lockout prevention measures +//! - Verification steps to ensure firewall is active and SSH is accessible +//! +//! ## Configuration Process +//! +//! The step executes the "configure-firewall" Ansible playbook which handles: +//! - UFW installation and setup +//! - Reset UFW to clean state +//! - Set restrictive default policies +//! - Allow SSH access BEFORE enabling firewall (critical for preventing lockout) +//! - Enable UFW firewall +//! - Verify firewall status and SSH access +//! +//! ## SSH Lockout Prevention +//! +//! This is a **high-risk operation** that could result in SSH lockout if not +//! handled correctly. Safety measures include: +//! +//! 1. **Correct Sequencing**: SSH rules are added BEFORE enabling firewall +//! 2. **Dual SSH Protection**: Both port-specific and service-name rules +//! 3. **Port Configuration**: Uses actual SSH port from user configuration +//! 4. **Verification Steps**: Ansible tasks verify SSH access is preserved +//! 5. **Comprehensive Logging**: Detailed logging of each firewall step + +use std::sync::Arc; +use tracing::{info, instrument, warn}; + +use crate::adapters::ansible::AnsibleClient; +use crate::shared::command::CommandError; + +/// Step that configures UFW firewall on a remote host via Ansible +/// +/// This step configures a restrictive UFW firewall policy while ensuring +/// SSH access is maintained. The SSH port is resolved during template rendering +/// and embedded in the final Ansible playbook. The configuration follows the +/// principle of "allow SSH BEFORE enabling firewall" to prevent lockout. +pub struct ConfigureFirewallStep { + ansible_client: Arc, +} + +impl ConfigureFirewallStep { + /// Create a new firewall configuration step + /// + /// # Arguments + /// + /// * `ansible_client` - Ansible client for running playbooks + /// + /// # Note + /// + /// SSH port configuration is resolved during template rendering phase, + /// not at step execution time. The rendered playbook contains the + /// resolved SSH port value. + #[must_use] + pub fn new(ansible_client: Arc) -> Self { + Self { ansible_client } + } + + /// Execute the firewall configuration + /// + /// # Safety + /// + /// This method is designed to prevent SSH lockout by: + /// 1. Resetting UFW to clean state + /// 2. Allowing SSH access BEFORE enabling firewall + /// 3. Using the correct SSH port from user configuration + /// + /// The SSH port is resolved during template rendering and embedded in the + /// playbook, so this method executes a playbook with pre-configured values. + /// + /// # Errors + /// + /// Returns `CommandError` if: + /// - Ansible playbook execution fails + /// - UFW commands fail + /// - SSH rules cannot be applied + /// - Firewall verification fails + #[instrument( + name = "configure_firewall", + skip_all, + fields( + step_type = "system", + component = "firewall", + method = "ansible" + ) + )] + pub fn execute(&self) -> Result<(), CommandError> { + warn!( + step = "configure_firewall", + action = "configure_ufw", + "Configuring UFW firewall - CRITICAL: SSH access will be restricted to configured port" + ); + + // Run Ansible playbook (SSH port already resolved during template rendering) + self.ansible_client.run_playbook("configure-firewall")?; + + info!( + step = "configure_firewall", + status = "success", + "UFW firewall configured successfully with SSH access preserved" + ); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + use std::sync::Arc; + + use super::*; + + #[test] + fn it_should_create_configure_firewall_step() { + let ansible_client = Arc::new(AnsibleClient::new(PathBuf::from("test_inventory.yml"))); + let step = ConfigureFirewallStep::new(ansible_client); + + // Test that the step can be created successfully + assert_eq!( + std::mem::size_of_val(&step), + std::mem::size_of::>() + ); + } +} diff --git a/src/application/steps/system/mod.rs b/src/application/steps/system/mod.rs index 8627a929..1543b35f 100644 --- a/src/application/steps/system/mod.rs +++ b/src/application/steps/system/mod.rs @@ -7,16 +7,18 @@ * Current steps: * - Cloud-init completion waiting * - Automatic security updates configuration + * - UFW firewall configuration * * Future steps may include: * - User account setup and management - * - Firewall configuration * - Log rotation configuration * - System service management */ +pub mod configure_firewall; pub mod configure_security_updates; pub mod wait_cloud_init; +pub use configure_firewall::ConfigureFirewallStep; pub use configure_security_updates::ConfigureSecurityUpdatesStep; pub use wait_cloud_init::WaitForCloudInitStep; diff --git a/src/domain/environment/state/configure_failed.rs b/src/domain/environment/state/configure_failed.rs index 10295b95..9815d7c0 100644 --- a/src/domain/environment/state/configure_failed.rs +++ b/src/domain/environment/state/configure_failed.rs @@ -47,6 +47,8 @@ pub enum ConfigureStep { InstallDockerCompose, /// Configuring automatic security updates ConfigureSecurityUpdates, + /// Configuring UFW firewall + ConfigureFirewall, } /// Error state - Application configuration failed diff --git a/src/infrastructure/external_tools/ansible/template/renderer/mod.rs b/src/infrastructure/external_tools/ansible/template/renderer/mod.rs index 02c32b76..4fa7b66b 100644 --- a/src/infrastructure/external_tools/ansible/template/renderer/mod.rs +++ b/src/infrastructure/external_tools/ansible/template/renderer/mod.rs @@ -198,6 +198,15 @@ impl AnsibleTemplateRenderer { .render(inventory_context, &build_ansible_dir) .map_err(|source| ConfigurationTemplateError::InventoryRenderingFailed { source })?; + // Render dynamic firewall playbook template with SSH port variable + self.render_tera_template( + "configure-firewall.yml.tera", + "configure-firewall.yml", + inventory_context, + &build_ansible_dir, + ) + .await?; + // Copy static Ansible files (config and playbooks) self.copy_static_templates(&self.template_manager, &build_ansible_dir) .await?; @@ -372,6 +381,87 @@ impl AnsibleTemplateRenderer { tracing::debug!("Successfully copied static file {}", file_name); Ok(()) } + + /// Renders a Tera template with the provided context + /// + /// # Arguments + /// + /// * `template_name` - Name of the template file (e.g., "configure-firewall.yml.tera") + /// * `output_name` - Name of the output file (e.g., "configure-firewall.yml") + /// * `context` - Template context for variable substitution + /// * `destination_dir` - Directory where the rendered file will be written + /// + /// # Returns + /// + /// * `Result<(), ConfigurationTemplateError>` - Success or error from the template rendering operation + /// + /// # Errors + /// + /// Returns an error if: + /// - Template file cannot be found or read + /// - Template content is invalid + /// - Variable substitution fails + /// - Output file cannot be written + async fn render_tera_template( + &self, + template_name: &str, + output_name: &str, + context: &InventoryContext, + destination_dir: &Path, + ) -> Result<(), ConfigurationTemplateError> { + tracing::debug!("Rendering Tera template: {}", template_name); + + let template_path = Self::build_template_path(template_name); + + // Get the template file path + let source_path = self + .template_manager + .get_template_path(&template_path) + .map_err(|source| ConfigurationTemplateError::TemplatePathFailed { + file_name: template_name.to_string(), + source, + })?; + + // Read template content + let template_content = tokio::fs::read_to_string(&source_path) + .await + .map_err(|source| ConfigurationTemplateError::TeraTemplateReadFailed { + file_name: template_name.to_string(), + source, + })?; + + // Create File object for template processing + let template_file = + crate::domain::template::file::File::new(template_name, template_content).map_err( + |source| ConfigurationTemplateError::FileCreationFailed { + file_name: template_name.to_string(), + source, + }, + )?; + + // Render template with context + let mut engine = crate::domain::template::TemplateEngine::new(); + let rendered_content = engine + .render(template_file.filename(), template_file.content(), context) + .map_err(|source| ConfigurationTemplateError::InventoryTemplateCreationFailed { + source, + })?; + + // Write rendered content to output file + let output_path = destination_dir.join(output_name); + crate::domain::template::write_file_with_dir_creation(&output_path, &rendered_content) + .map_err(|source| ConfigurationTemplateError::InventoryTemplateRenderFailed { + source, + })?; + + tracing::debug!( + "Successfully rendered Tera template {} to {}", + template_name, + output_path.display() + ); + + Ok(()) + } } #[cfg(test)] diff --git a/src/infrastructure/external_tools/ansible/template/wrappers/inventory/context/mod.rs b/src/infrastructure/external_tools/ansible/template/wrappers/inventory/context/mod.rs index d36fa2c2..8c5fb89b 100644 --- a/src/infrastructure/external_tools/ansible/template/wrappers/inventory/context/mod.rs +++ b/src/infrastructure/external_tools/ansible/template/wrappers/inventory/context/mod.rs @@ -42,6 +42,9 @@ pub struct InventoryContext { ansible_host: AnsibleHost, ansible_ssh_private_key_file: SshPrivateKeyFile, ansible_port: AnsiblePort, + /// Alias for ansible_port used in playbook templates + #[serde(rename = "ssh_port")] + ssh_port: AnsiblePort, } /// Builder for `InventoryContext` with fluent interface @@ -103,6 +106,7 @@ impl InventoryContextBuilder { ansible_host, ansible_ssh_private_key_file, ansible_port, + ssh_port: ansible_port, // Same value for playbook templates }) } } @@ -123,6 +127,7 @@ impl InventoryContext { ansible_host, ansible_ssh_private_key_file, ansible_port, + ssh_port: ansible_port, // Same value for playbook templates }) } diff --git a/templates/ansible/configure-firewall.yml.tera b/templates/ansible/configure-firewall.yml.tera new file mode 100644 index 00000000..3b53f967 --- /dev/null +++ b/templates/ansible/configure-firewall.yml.tera @@ -0,0 +1,117 @@ +--- +# Configure UFW Firewall with Safe SSH Access +# This playbook configures UFW with restrictive policies while preserving SSH access. +# CRITICAL: SSH access is allowed BEFORE enabling firewall to prevent lockout. + +- name: Configure UFW firewall safely + hosts: torrust_servers + become: yes + gather_facts: yes + + tasks: + - name: Install UFW (should already be present on Ubuntu) + ansible.builtin.apt: + name: ufw + state: present + update_cache: yes + tags: + - security + - firewall + - packages + + - name: Reset UFW to clean state + community.general.ufw: + state: reset + tags: + - security + - firewall + - reset + + - name: Set UFW default policy - deny incoming + community.general.ufw: + default: deny + direction: incoming + tags: + - security + - firewall + - policy + + - name: Set UFW default policy - allow outgoing + community.general.ufw: + default: allow + direction: outgoing + tags: + - security + - firewall + - policy + + # CRITICAL: Allow SSH BEFORE enabling firewall to prevent lockout + - name: Allow SSH access on configured port (BEFORE enabling firewall) + community.general.ufw: + rule: allow + port: "{{ssh_port}}" + proto: tcp + comment: "SSH access (configured port {{ssh_port}})" + tags: + - security + - firewall + - ssh + + - name: Allow SSH service by name (additional safety measure) + community.general.ufw: + rule: allow + name: ssh + comment: "SSH service (standard SSH)" + tags: + - security + - firewall + - ssh + + - name: Enable UFW firewall (AFTER SSH rules are in place) + community.general.ufw: + state: enabled + tags: + - security + - firewall + - enable + + - name: Verify UFW status + ansible.builtin.command: + cmd: ufw status numbered + register: ufw_status + changed_when: false + tags: + - security + - firewall + - verification + + - name: Display UFW status + ansible.builtin.debug: + var: ufw_status.stdout_lines + tags: + - security + - firewall + - verification + + - name: Verify SSH port is allowed + ansible.builtin.shell: + cmd: "ufw status | grep -E '{{ssh_port}}/tcp.*ALLOW'" + register: ssh_port_check + changed_when: false + failed_when: ssh_port_check.rc != 0 + tags: + - security + - firewall + - verification + - ssh + + - name: Confirm firewall configuration complete + ansible.builtin.debug: + msg: + - "UFW firewall configured successfully" + - "SSH access preserved on port {{ssh_port}}" + - "Default policy: deny incoming, allow outgoing" + - "Active rules protect against unauthorized access" + tags: + - security + - firewall From 7d91d18b27fbf57126fb1f61ad2b3ecb4f5837c9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Oct 2025 17:12:47 +0000 Subject: [PATCH 3/6] fix: apply code formatting and fix clippy warnings - Fix doc markdown clippy warning in InventoryContext - Apply cargo fmt formatting to all modified files Co-authored-by: josecelano <58816+josecelano@users.noreply.github.com> --- .../steps/system/configure_firewall.rs | 6 +---- .../ansible/template/renderer/mod.rs | 22 ++++++++++--------- .../wrappers/inventory/context/mod.rs | 2 +- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src/application/steps/system/configure_firewall.rs b/src/application/steps/system/configure_firewall.rs index 9a930289..938e0636 100644 --- a/src/application/steps/system/configure_firewall.rs +++ b/src/application/steps/system/configure_firewall.rs @@ -89,11 +89,7 @@ impl ConfigureFirewallStep { #[instrument( name = "configure_firewall", skip_all, - fields( - step_type = "system", - component = "firewall", - method = "ansible" - ) + fields(step_type = "system", component = "firewall", method = "ansible") )] pub fn execute(&self) -> Result<(), CommandError> { warn!( diff --git a/src/infrastructure/external_tools/ansible/template/renderer/mod.rs b/src/infrastructure/external_tools/ansible/template/renderer/mod.rs index 4fa7b66b..05767e6b 100644 --- a/src/infrastructure/external_tools/ansible/template/renderer/mod.rs +++ b/src/infrastructure/external_tools/ansible/template/renderer/mod.rs @@ -425,10 +425,12 @@ impl AnsibleTemplateRenderer { // Read template content let template_content = tokio::fs::read_to_string(&source_path) .await - .map_err(|source| ConfigurationTemplateError::TeraTemplateReadFailed { - file_name: template_name.to_string(), - source, - })?; + .map_err( + |source| ConfigurationTemplateError::TeraTemplateReadFailed { + file_name: template_name.to_string(), + source, + }, + )?; // Create File object for template processing let template_file = @@ -443,16 +445,16 @@ impl AnsibleTemplateRenderer { let mut engine = crate::domain::template::TemplateEngine::new(); let rendered_content = engine .render(template_file.filename(), template_file.content(), context) - .map_err(|source| ConfigurationTemplateError::InventoryTemplateCreationFailed { - source, - })?; + .map_err( + |source| ConfigurationTemplateError::InventoryTemplateCreationFailed { source }, + )?; // Write rendered content to output file let output_path = destination_dir.join(output_name); crate::domain::template::write_file_with_dir_creation(&output_path, &rendered_content) - .map_err(|source| ConfigurationTemplateError::InventoryTemplateRenderFailed { - source, - })?; + .map_err( + |source| ConfigurationTemplateError::InventoryTemplateRenderFailed { source }, + )?; tracing::debug!( "Successfully rendered Tera template {} to {}", diff --git a/src/infrastructure/external_tools/ansible/template/wrappers/inventory/context/mod.rs b/src/infrastructure/external_tools/ansible/template/wrappers/inventory/context/mod.rs index 8c5fb89b..45e59d4f 100644 --- a/src/infrastructure/external_tools/ansible/template/wrappers/inventory/context/mod.rs +++ b/src/infrastructure/external_tools/ansible/template/wrappers/inventory/context/mod.rs @@ -42,7 +42,7 @@ pub struct InventoryContext { ansible_host: AnsibleHost, ansible_ssh_private_key_file: SshPrivateKeyFile, ansible_port: AnsiblePort, - /// Alias for ansible_port used in playbook templates + /// Alias for `ansible_port` used in playbook templates #[serde(rename = "ssh_port")] ssh_port: AnsiblePort, } From 91224b5a6601558b677db179a3eca3c7900ac1a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Oct 2025 19:00:20 +0000 Subject: [PATCH 4/6] fix: refactor firewall template to follow established architecture pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: Restructured firewall playbook template rendering - Create proper two-layer template architecture for firewall playbook - Add FirewallPlaybookContext with type-safe SSH port validation - Add FirewallPlaybookTemplate wrapper for template validation - Add FirewallPlaybookTemplateRenderer following inventory pattern - Remove generic render_tera_template method from AnsibleTemplateRenderer - Remove ssh_port field from InventoryContext (use dedicated context) - Fix hosts pattern in configure-firewall.yml.tera (torrust_servers → all) This change ensures consistency with the established template architecture pattern used for inventory.yml.tera, providing better type safety, testability, and maintainability. Addresses all 3 issues from PR review: - Issue #1: Template architecture violation (fixed) - Issue #2: Incorrect Ansible host pattern (fixed) - Issue #3: UFW not active (should be fixed by issue #2) Co-authored-by: josecelano <58816+josecelano@users.noreply.github.com> --- .../template/renderer/firewall_playbook.rs | 347 ++++++++++++++++++ .../ansible/template/renderer/mod.rs | 131 +++---- .../wrappers/firewall_playbook/context.rs | 160 ++++++++ .../wrappers/firewall_playbook/mod.rs | 168 +++++++++ .../wrappers/inventory/context/mod.rs | 5 - .../ansible/template/wrappers/mod.rs | 2 + templates/ansible/configure-firewall.yml.tera | 2 +- 7 files changed, 731 insertions(+), 84 deletions(-) create mode 100644 src/infrastructure/external_tools/ansible/template/renderer/firewall_playbook.rs create mode 100644 src/infrastructure/external_tools/ansible/template/wrappers/firewall_playbook/context.rs create mode 100644 src/infrastructure/external_tools/ansible/template/wrappers/firewall_playbook/mod.rs diff --git a/src/infrastructure/external_tools/ansible/template/renderer/firewall_playbook.rs b/src/infrastructure/external_tools/ansible/template/renderer/firewall_playbook.rs new file mode 100644 index 00000000..80f4ece2 --- /dev/null +++ b/src/infrastructure/external_tools/ansible/template/renderer/firewall_playbook.rs @@ -0,0 +1,347 @@ +//! # Firewall Playbook Template Renderer +//! +//! This module handles rendering of the `configure-firewall.yml.tera` template +//! with SSH port configuration. It's responsible for creating the Ansible playbook +//! that configures UFW firewall while preserving SSH access. +//! +//! ## Responsibilities +//! +//! - Load the `configure-firewall.yml.tera` template file +//! - Process template with SSH port configuration +//! - Render final `configure-firewall.yml` file for Ansible consumption +//! +//! ## Usage +//! +//! ```rust +//! # use std::sync::Arc; +//! # use tempfile::TempDir; +//! use torrust_tracker_deployer_lib::infrastructure::external_tools::ansible::template::renderer::firewall_playbook::FirewallPlaybookTemplateRenderer; +//! use torrust_tracker_deployer_lib::domain::template::TemplateManager; +//! use torrust_tracker_deployer_lib::infrastructure::external_tools::ansible::template::wrappers::firewall_playbook::FirewallPlaybookContext; +//! use torrust_tracker_deployer_lib::infrastructure::external_tools::ansible::template::wrappers::inventory::context::AnsiblePort; +//! +//! # async fn example() -> Result<(), Box> { +//! let temp_dir = TempDir::new()?; +//! let template_manager = Arc::new(TemplateManager::new("/path/to/templates")); +//! let renderer = FirewallPlaybookTemplateRenderer::new(template_manager); +//! +//! let ssh_port = AnsiblePort::new(22)?; +//! let firewall_context = FirewallPlaybookContext::new(ssh_port)?; +//! renderer.render(&firewall_context, temp_dir.path())?; +//! # Ok(()) +//! # } +//! ``` + +use std::path::Path; +use std::sync::Arc; +use thiserror::Error; + +use crate::domain::template::file::File; +use crate::domain::template::{FileOperationError, TemplateManager, TemplateManagerError}; +use crate::infrastructure::external_tools::ansible::template::wrappers::firewall_playbook::{ + FirewallPlaybookContext, FirewallPlaybookTemplate, +}; + +/// Errors that can occur during firewall playbook template rendering +#[derive(Error, Debug)] +pub enum FirewallPlaybookTemplateError { + /// Failed to get template path from template manager + #[error("Failed to get template path for '{file_name}': {source}")] + TemplatePathFailed { + file_name: String, + #[source] + source: TemplateManagerError, + }, + + /// Failed to read Tera template file content + #[error("Failed to read Tera template file '{file_name}': {source}")] + TeraTemplateReadFailed { + file_name: String, + #[source] + source: std::io::Error, + }, + + /// Failed to create File object from template content + #[error("Failed to create File object for '{file_name}': {source}")] + FileCreationFailed { + file_name: String, + #[source] + source: crate::domain::template::file::Error, + }, + + /// Failed to create firewall playbook template with provided context + #[error("Failed to create FirewallPlaybookTemplate: {source}")] + FirewallPlaybookTemplateCreationFailed { + #[source] + source: crate::domain::template::TemplateEngineError, + }, + + /// Failed to render firewall playbook template to output file + #[error("Failed to render firewall playbook template to file: {source}")] + FirewallPlaybookTemplateRenderFailed { + #[source] + source: FileOperationError, + }, +} + +/// Handles rendering of the configure-firewall.yml.tera template for Ansible deployments +/// +/// This collaborator is responsible for all firewall playbook template-specific operations: +/// - Loading the configure-firewall.yml.tera template +/// - Processing it with SSH port configuration +/// - Rendering the final configure-firewall.yml file for Ansible consumption +pub struct FirewallPlaybookTemplateRenderer { + template_manager: Arc, +} + +impl FirewallPlaybookTemplateRenderer { + /// Template filename for the firewall playbook Tera template + const FIREWALL_TEMPLATE_FILE: &'static str = "configure-firewall.yml.tera"; + + /// Output filename for the rendered firewall playbook file + const FIREWALL_OUTPUT_FILE: &'static str = "configure-firewall.yml"; + + /// Creates a new firewall playbook template renderer + /// + /// # Arguments + /// + /// * `template_manager` - The template manager to source templates from + #[must_use] + pub fn new(template_manager: Arc) -> Self { + Self { template_manager } + } + + /// Renders the configure-firewall.yml.tera template with the provided context + /// + /// This method: + /// 1. Loads the configure-firewall.yml.tera template from the template manager + /// 2. Reads the template content + /// 3. Creates a File object for template processing + /// 4. Creates a `FirewallPlaybookTemplate` with the SSH port context + /// 5. Renders the template to configure-firewall.yml in the output directory + /// + /// # Arguments + /// + /// * `firewall_context` - The context containing SSH port configuration + /// * `output_dir` - The directory where configure-firewall.yml should be written + /// + /// # Returns + /// + /// * `Result<(), FirewallPlaybookTemplateError>` - Success or error from the template rendering operation + /// + /// # Errors + /// + /// Returns an error if: + /// - Template file cannot be found or read + /// - Template content is invalid + /// - Variable substitution fails + /// - Output file cannot be written + pub fn render( + &self, + firewall_context: &FirewallPlaybookContext, + output_dir: &Path, + ) -> Result<(), FirewallPlaybookTemplateError> { + tracing::debug!("Rendering firewall playbook template with SSH port configuration"); + + // Get the firewall playbook template path + let firewall_template_path = self + .template_manager + .get_template_path(&Self::build_template_path()) + .map_err(|source| FirewallPlaybookTemplateError::TemplatePathFailed { + file_name: Self::FIREWALL_TEMPLATE_FILE.to_string(), + source, + })?; + + // Read template content + let firewall_template_content = + std::fs::read_to_string(&firewall_template_path).map_err(|source| { + FirewallPlaybookTemplateError::TeraTemplateReadFailed { + file_name: Self::FIREWALL_TEMPLATE_FILE.to_string(), + source, + } + })?; + + // Create File object for template processing + let firewall_template_file = + File::new(Self::FIREWALL_TEMPLATE_FILE, firewall_template_content).map_err( + |source| FirewallPlaybookTemplateError::FileCreationFailed { + file_name: Self::FIREWALL_TEMPLATE_FILE.to_string(), + source, + }, + )?; + + // Create FirewallPlaybookTemplate with SSH port context + let firewall_template = + FirewallPlaybookTemplate::new(&firewall_template_file, firewall_context.clone()) + .map_err(|source| { + FirewallPlaybookTemplateError::FirewallPlaybookTemplateCreationFailed { source } + })?; + + // Render to output file + let firewall_output_path = output_dir.join(Self::FIREWALL_OUTPUT_FILE); + firewall_template + .render(&firewall_output_path) + .map_err(|source| { + FirewallPlaybookTemplateError::FirewallPlaybookTemplateRenderFailed { source } + })?; + + tracing::debug!( + "Successfully rendered firewall playbook template to {}", + firewall_output_path.display() + ); + + Ok(()) + } + + /// Builds the full template path for the firewall playbook template + /// + /// # Returns + /// + /// * `String` - The complete template path for configure-firewall.yml.tera + fn build_template_path() -> String { + format!("ansible/{}", Self::FIREWALL_TEMPLATE_FILE) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::infrastructure::external_tools::ansible::template::wrappers::inventory::context::AnsiblePort; + use std::fs; + use tempfile::TempDir; + + /// Helper function to create a test firewall context + fn create_test_firewall_context() -> FirewallPlaybookContext { + let ssh_port = AnsiblePort::new(22).expect("Failed to create SSH port"); + FirewallPlaybookContext::builder() + .with_ssh_port(ssh_port) + .build() + .expect("Failed to build firewall context") + } + + /// Helper function to create a test template directory with configure-firewall.yml.tera + fn create_test_templates(temp_dir: &Path) -> std::io::Result<()> { + let ansible_dir = temp_dir.join("ansible"); + fs::create_dir_all(&ansible_dir)?; + + let template_content = r#"--- +- name: Configure UFW firewall + hosts: all + become: yes + tasks: + - name: Allow SSH on port {{ssh_port}} + community.general.ufw: + rule: allow + port: "{{ssh_port}}" + proto: tcp +"#; + + fs::write( + ansible_dir.join("configure-firewall.yml.tera"), + template_content, + )?; + + Ok(()) + } + + #[test] + fn it_should_create_firewall_renderer_with_template_manager() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); + + let renderer = FirewallPlaybookTemplateRenderer::new(template_manager.clone()); + + assert!(Arc::ptr_eq(&renderer.template_manager, &template_manager)); + } + + #[test] + fn it_should_build_correct_template_path() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let template_manager = Arc::new(TemplateManager::new(temp_dir.path())); + let _renderer = FirewallPlaybookTemplateRenderer::new(template_manager); + + let template_path = FirewallPlaybookTemplateRenderer::build_template_path(); + + assert_eq!(template_path, "ansible/configure-firewall.yml.tera"); + } + + #[test] + fn it_should_render_firewall_template_successfully() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let template_dir = temp_dir.path().join("templates"); + let output_dir = temp_dir.path().join("output"); + + // Create template directory and files + create_test_templates(&template_dir).expect("Failed to create test templates"); + fs::create_dir_all(&output_dir).expect("Failed to create output directory"); + + // Setup template manager and renderer + let template_manager = Arc::new(TemplateManager::new(&template_dir)); + template_manager + .ensure_templates_dir() + .expect("Failed to ensure templates directory"); + + let renderer = FirewallPlaybookTemplateRenderer::new(template_manager); + let firewall_context = create_test_firewall_context(); + + // Render template + let result = renderer.render(&firewall_context, &output_dir); + + assert!(result.is_ok(), "Template rendering should succeed"); + + // Verify output file exists + let output_file = output_dir.join("configure-firewall.yml"); + assert!( + output_file.exists(), + "configure-firewall.yml should be created" + ); + + // Verify output content contains expected values + let output_content = fs::read_to_string(&output_file).expect("Failed to read output file"); + assert!( + output_content.contains("22"), + "Output should contain the SSH port" + ); + assert!( + output_content.contains("hosts: all"), + "Output should contain hosts: all" + ); + assert!( + !output_content.contains("{{ssh_port}}"), + "Output should not contain template variables" + ); + } + + #[test] + fn it_should_render_with_custom_ssh_port() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let template_dir = temp_dir.path().join("templates"); + let output_dir = temp_dir.path().join("output"); + + create_test_templates(&template_dir).expect("Failed to create test templates"); + fs::create_dir_all(&output_dir).expect("Failed to create output directory"); + + let template_manager = Arc::new(TemplateManager::new(&template_dir)); + template_manager + .ensure_templates_dir() + .expect("Failed to ensure templates directory"); + + let renderer = FirewallPlaybookTemplateRenderer::new(template_manager); + + // Use custom SSH port + let ssh_port = AnsiblePort::new(2222).expect("Failed to create SSH port"); + let firewall_context = + FirewallPlaybookContext::new(ssh_port).expect("Failed to create context"); + + let result = renderer.render(&firewall_context, &output_dir); + + assert!(result.is_ok()); + + let output_file = output_dir.join("configure-firewall.yml"); + let output_content = fs::read_to_string(&output_file).expect("Failed to read output file"); + assert!( + output_content.contains("2222"), + "Output should contain custom SSH port 2222" + ); + } +} diff --git a/src/infrastructure/external_tools/ansible/template/renderer/mod.rs b/src/infrastructure/external_tools/ansible/template/renderer/mod.rs index 05767e6b..32205362 100644 --- a/src/infrastructure/external_tools/ansible/template/renderer/mod.rs +++ b/src/infrastructure/external_tools/ansible/template/renderer/mod.rs @@ -53,8 +53,10 @@ use crate::domain::template::{FileOperationError, TemplateManager, TemplateManag use crate::infrastructure::external_tools::ansible::template::renderer::inventory::InventoryTemplateError; use crate::infrastructure::external_tools::ansible::template::wrappers::inventory::InventoryContext; +pub mod firewall_playbook; pub mod inventory; +pub use firewall_playbook::FirewallPlaybookTemplateRenderer; pub use inventory::InventoryTemplateRenderer; /// Errors that can occur during configuration template rendering @@ -120,6 +122,13 @@ pub enum ConfigurationTemplateError { #[source] source: InventoryTemplateError, }, + + /// Failed to render firewall playbook template using collaborator + #[error("Failed to render firewall playbook template: {source}")] + FirewallPlaybookRenderingFailed { + #[source] + source: firewall_playbook::FirewallPlaybookTemplateError, + }, } /// Renders `Ansible` configuration templates to a build directory @@ -131,6 +140,7 @@ pub struct AnsibleTemplateRenderer { build_dir: PathBuf, template_manager: Arc, inventory_renderer: InventoryTemplateRenderer, + firewall_playbook_renderer: FirewallPlaybookTemplateRenderer, } impl AnsibleTemplateRenderer { @@ -149,11 +159,14 @@ impl AnsibleTemplateRenderer { #[must_use] pub fn new>(build_dir: P, template_manager: Arc) -> Self { let inventory_renderer = InventoryTemplateRenderer::new(template_manager.clone()); + let firewall_playbook_renderer = + FirewallPlaybookTemplateRenderer::new(template_manager.clone()); Self { build_dir: build_dir.as_ref().to_path_buf(), template_manager, inventory_renderer, + firewall_playbook_renderer, } } @@ -198,14 +211,13 @@ impl AnsibleTemplateRenderer { .render(inventory_context, &build_ansible_dir) .map_err(|source| ConfigurationTemplateError::InventoryRenderingFailed { source })?; - // Render dynamic firewall playbook template with SSH port variable - self.render_tera_template( - "configure-firewall.yml.tera", - "configure-firewall.yml", - inventory_context, - &build_ansible_dir, - ) - .await?; + // Render dynamic firewall playbook template with SSH port variable using collaborator + let firewall_context = Self::create_firewall_context(inventory_context)?; + self.firewall_playbook_renderer + .render(&firewall_context, &build_ansible_dir) + .map_err( + |source| ConfigurationTemplateError::FirewallPlaybookRenderingFailed { source }, + )?; // Copy static Ansible files (config and playbooks) self.copy_static_templates(&self.template_manager, &build_ansible_dir) @@ -382,87 +394,50 @@ impl AnsibleTemplateRenderer { Ok(()) } - /// Renders a Tera template with the provided context + /// Creates a `FirewallPlaybookContext` from an `InventoryContext` + /// + /// Extracts the SSH port from the inventory context to create + /// a firewall-specific context for template rendering. /// /// # Arguments /// - /// * `template_name` - Name of the template file (e.g., "configure-firewall.yml.tera") - /// * `output_name` - Name of the output file (e.g., "configure-firewall.yml") - /// * `context` - Template context for variable substitution - /// * `destination_dir` - Directory where the rendered file will be written + /// * `inventory_context` - The inventory context containing SSH port information /// /// # Returns /// - /// * `Result<(), ConfigurationTemplateError>` - Success or error from the template rendering operation + /// * `Result` - The firewall context or an error /// /// # Errors /// - /// Returns an error if: - /// - Template file cannot be found or read - /// - Template content is invalid - /// - Variable substitution fails - /// - Output file cannot be written - async fn render_tera_template( - &self, - template_name: &str, - output_name: &str, - context: &InventoryContext, - destination_dir: &Path, - ) -> Result<(), ConfigurationTemplateError> { - tracing::debug!("Rendering Tera template: {}", template_name); - - let template_path = Self::build_template_path(template_name); - - // Get the template file path - let source_path = self - .template_manager - .get_template_path(&template_path) - .map_err(|source| ConfigurationTemplateError::TemplatePathFailed { - file_name: template_name.to_string(), - source, - })?; - - // Read template content - let template_content = tokio::fs::read_to_string(&source_path) - .await - .map_err( - |source| ConfigurationTemplateError::TeraTemplateReadFailed { - file_name: template_name.to_string(), - source, + /// Returns an error if the SSH port cannot be extracted or validated + fn create_firewall_context( + inventory_context: &InventoryContext, + ) -> Result< + crate::infrastructure::external_tools::ansible::template::wrappers::firewall_playbook::FirewallPlaybookContext, + ConfigurationTemplateError, + >{ + use crate::infrastructure::external_tools::ansible::template::wrappers::firewall_playbook::FirewallPlaybookContext; + use crate::infrastructure::external_tools::ansible::template::wrappers::inventory::context::AnsiblePort; + + // Extract SSH port from inventory context + let ssh_port = AnsiblePort::new(inventory_context.ansible_port()).map_err(|e| { + ConfigurationTemplateError::TemplatePathFailed { + file_name: "configure-firewall.yml.tera".to_string(), + source: TemplateManagerError::TemplateNotFound { + relative_path: format!("Invalid SSH port: {e}"), }, - )?; - - // Create File object for template processing - let template_file = - crate::domain::template::file::File::new(template_name, template_content).map_err( - |source| ConfigurationTemplateError::FileCreationFailed { - file_name: template_name.to_string(), - source, + } + })?; + + // Create firewall context + FirewallPlaybookContext::new(ssh_port).map_err(|e| { + ConfigurationTemplateError::TemplatePathFailed { + file_name: "configure-firewall.yml.tera".to_string(), + source: TemplateManagerError::TemplateNotFound { + relative_path: format!("Failed to create firewall context: {e}"), }, - )?; - - // Render template with context - let mut engine = crate::domain::template::TemplateEngine::new(); - let rendered_content = engine - .render(template_file.filename(), template_file.content(), context) - .map_err( - |source| ConfigurationTemplateError::InventoryTemplateCreationFailed { source }, - )?; - - // Write rendered content to output file - let output_path = destination_dir.join(output_name); - crate::domain::template::write_file_with_dir_creation(&output_path, &rendered_content) - .map_err( - |source| ConfigurationTemplateError::InventoryTemplateRenderFailed { source }, - )?; - - tracing::debug!( - "Successfully rendered Tera template {} to {}", - template_name, - output_path.display() - ); - - Ok(()) + } + }) } } diff --git a/src/infrastructure/external_tools/ansible/template/wrappers/firewall_playbook/context.rs b/src/infrastructure/external_tools/ansible/template/wrappers/firewall_playbook/context.rs new file mode 100644 index 00000000..c218923c --- /dev/null +++ b/src/infrastructure/external_tools/ansible/template/wrappers/firewall_playbook/context.rs @@ -0,0 +1,160 @@ +//! Context for firewall playbook template rendering +//! +//! This module provides the type-safe context for rendering the +//! `configure-firewall.yml.tera` template with validated SSH port configuration. + +use serde::Serialize; +use thiserror::Error; + +use crate::infrastructure::external_tools::ansible::template::wrappers::inventory::context::{ + AnsiblePort, AnsiblePortError, +}; + +/// Errors that can occur when creating a `FirewallPlaybookContext` +#[derive(Debug, Error)] +pub enum FirewallPlaybookContextError { + /// Invalid SSH port + #[error("Invalid SSH port: {0}")] + InvalidSshPort(#[from] AnsiblePortError), + + /// Missing SSH port in context + #[error("Missing SSH port - must be set before building")] + MissingSshPort, +} + +/// Context for rendering the firewall playbook template +/// +/// This context contains the SSH port configuration needed to render +/// the `configure-firewall.yml.tera` template with proper SSH access rules. +#[derive(Serialize, Debug, Clone)] +pub struct FirewallPlaybookContext { + /// SSH port to allow through the firewall + ssh_port: AnsiblePort, +} + +/// Builder for `FirewallPlaybookContext` with fluent interface +#[derive(Debug, Default)] +pub struct FirewallPlaybookContextBuilder { + ssh_port: Option, +} + +impl FirewallPlaybookContextBuilder { + /// Creates a new empty builder + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Sets the SSH port for the builder + #[must_use] + pub fn with_ssh_port(mut self, ssh_port: AnsiblePort) -> Self { + self.ssh_port = Some(ssh_port); + self + } + + /// Builds the `FirewallPlaybookContext` + /// + /// # Errors + /// + /// Returns an error if the SSH port is missing + pub fn build(self) -> Result { + let ssh_port = self + .ssh_port + .ok_or(FirewallPlaybookContextError::MissingSshPort)?; + + Ok(FirewallPlaybookContext { ssh_port }) + } +} + +impl FirewallPlaybookContext { + /// Creates a new `FirewallPlaybookContext` with the specified SSH port + /// + /// # Errors + /// + /// This method cannot fail with the current implementation since it takes + /// already validated types, but returns Result for consistency with builder pattern + pub fn new(ssh_port: AnsiblePort) -> Result { + Ok(Self { ssh_port }) + } + + /// Creates a new builder for `FirewallPlaybookContext` with fluent interface + #[must_use] + pub fn builder() -> FirewallPlaybookContextBuilder { + FirewallPlaybookContextBuilder::new() + } + + /// Get the SSH port + #[must_use] + pub fn ssh_port(&self) -> u16 { + self.ssh_port.as_u16() + } + + /// Get the SSH port as a string + #[must_use] + pub fn ssh_port_string(&self) -> String { + self.ssh_port.as_str() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_create_firewall_context_with_builder() { + let ssh_port = AnsiblePort::new(22).unwrap(); + let context = FirewallPlaybookContext::builder() + .with_ssh_port(ssh_port) + .build() + .unwrap(); + + assert_eq!(context.ssh_port(), 22); + } + + #[test] + fn it_should_create_firewall_context_directly() { + let ssh_port = AnsiblePort::new(2222).unwrap(); + let context = FirewallPlaybookContext::new(ssh_port).unwrap(); + + assert_eq!(context.ssh_port(), 2222); + } + + #[test] + fn it_should_fail_without_ssh_port() { + let result = FirewallPlaybookContext::builder().build(); + + assert!(result.is_err()); + match result { + Err(FirewallPlaybookContextError::MissingSshPort) => {} + _ => panic!("Expected MissingSshPort error"), + } + } + + #[test] + fn it_should_serialize_context_to_json() { + let ssh_port = AnsiblePort::new(22).unwrap(); + let context = FirewallPlaybookContext::new(ssh_port).unwrap(); + + let json = serde_json::to_string(&context).unwrap(); + assert!(json.contains("22")); + assert!(json.contains("ssh_port")); + } + + #[test] + fn it_should_support_custom_ssh_ports() { + let ssh_port = AnsiblePort::new(2222).unwrap(); + let context = FirewallPlaybookContext::new(ssh_port).unwrap(); + + assert_eq!(context.ssh_port(), 2222); + assert_eq!(context.ssh_port_string(), "2222"); + } + + #[test] + fn it_should_clone_context() { + let ssh_port = AnsiblePort::new(22).unwrap(); + let context1 = FirewallPlaybookContext::new(ssh_port).unwrap(); + let context2 = context1.clone(); + + assert_eq!(context1.ssh_port(), context2.ssh_port()); + } +} diff --git a/src/infrastructure/external_tools/ansible/template/wrappers/firewall_playbook/mod.rs b/src/infrastructure/external_tools/ansible/template/wrappers/firewall_playbook/mod.rs new file mode 100644 index 00000000..0b681430 --- /dev/null +++ b/src/infrastructure/external_tools/ansible/template/wrappers/firewall_playbook/mod.rs @@ -0,0 +1,168 @@ +//! Template wrapper for templates/ansible/configure-firewall.yml.tera +//! +//! This template configures UFW firewall with SSH access preservation. +//! It requires the SSH port to be provided at construction time. + +pub mod context; + +use crate::domain::template::file::File; +use crate::domain::template::{ + write_file_with_dir_creation, FileOperationError, TemplateEngineError, +}; +use anyhow::Result; +use std::path::Path; + +pub use context::{ + FirewallPlaybookContext, FirewallPlaybookContextBuilder, FirewallPlaybookContextError, +}; + +/// Wrapper for the firewall playbook template +/// +/// This wrapper validates the template syntax at construction time +/// and provides a type-safe way to render the firewall configuration +/// playbook with the correct SSH port. +#[derive(Debug)] +pub struct FirewallPlaybookTemplate { + context: FirewallPlaybookContext, + content: String, +} + +impl FirewallPlaybookTemplate { + /// Creates a new `FirewallPlaybookTemplate`, validating the template content and variable substitution + /// + /// # Errors + /// + /// Returns an error if: + /// - Template syntax is invalid + /// - Required variables cannot be substituted + /// - Template validation fails + /// + /// # Panics + /// + /// This method will panic if cloning the already validated `FirewallPlaybookContext` fails, + /// which should never happen under normal circumstances. + pub fn new( + template_file: &File, + firewall_context: FirewallPlaybookContext, + ) -> Result { + let mut engine = crate::domain::template::TemplateEngine::new(); + + let validated_content = engine.render( + template_file.filename(), + template_file.content(), + &firewall_context, + )?; + + Ok(Self { + context: firewall_context, + content: validated_content, + }) + } + + /// Get the SSH port value + #[must_use] + pub fn ssh_port(&self) -> u16 { + self.context.ssh_port() + } + + /// Render the template to a file at the specified output path + /// + /// # Errors + /// + /// Returns `FileOperationError::DirectoryCreation` if the parent directory cannot be created, + /// or `FileOperationError::FileWrite` if the file cannot be written + pub fn render(&self, output_path: &Path) -> Result<(), FileOperationError> { + write_file_with_dir_creation(output_path, &self.content) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::infrastructure::external_tools::ansible::template::wrappers::inventory::context::AnsiblePort; + + /// Helper function to create a `FirewallPlaybookContext` with the given SSH port + fn create_firewall_context(ssh_port: u16) -> FirewallPlaybookContext { + let port = AnsiblePort::new(ssh_port).unwrap(); + FirewallPlaybookContext::builder() + .with_ssh_port(port) + .build() + .unwrap() + } + + /// Helper function to create a minimal valid firewall template file + fn create_minimal_template() -> File { + let content = r#"--- +- name: Configure UFW firewall + hosts: all + tasks: + - name: Allow SSH on port {{ssh_port}} + community.general.ufw: + rule: allow + port: "{{ssh_port}}" +"#; + File::new("configure-firewall.yml.tera", content.to_string()).unwrap() + } + + #[test] + fn it_should_create_firewall_template_with_context() { + let context = create_firewall_context(22); + let template_file = create_minimal_template(); + + let template = FirewallPlaybookTemplate::new(&template_file, context); + + assert!(template.is_ok()); + let template = template.unwrap(); + assert_eq!(template.ssh_port(), 22); + } + + #[test] + fn it_should_render_template_with_ssh_port() { + let context = create_firewall_context(2222); + let template_file = create_minimal_template(); + let template = FirewallPlaybookTemplate::new(&template_file, context).unwrap(); + + // The rendered content should have the port substituted + assert!(template.content.contains("2222")); + assert!(!template.content.contains("{{ssh_port}}")); + } + + #[test] + fn it_should_fail_with_invalid_template_syntax() { + let context = create_firewall_context(22); + let invalid_template = File::new( + "configure-firewall.yml.tera", + "{{ unclosed_variable".to_string(), + ) + .unwrap(); + + let result = FirewallPlaybookTemplate::new(&invalid_template, context); + + assert!(result.is_err()); + } + + #[test] + fn it_should_fail_with_missing_variable_in_context() { + let context = create_firewall_context(22); + // Template references a variable that doesn't exist in context + let template_with_missing_var = File::new( + "configure-firewall.yml.tera", + "Port: {{ssh_port}} and {{nonexistent_var}}".to_string(), + ) + .unwrap(); + + let result = FirewallPlaybookTemplate::new(&template_with_missing_var, context); + + assert!(result.is_err()); + } + + #[test] + fn it_should_support_custom_ssh_ports() { + let context = create_firewall_context(8022); + let template_file = create_minimal_template(); + let template = FirewallPlaybookTemplate::new(&template_file, context).unwrap(); + + assert_eq!(template.ssh_port(), 8022); + assert!(template.content.contains("8022")); + } +} diff --git a/src/infrastructure/external_tools/ansible/template/wrappers/inventory/context/mod.rs b/src/infrastructure/external_tools/ansible/template/wrappers/inventory/context/mod.rs index 45e59d4f..d36fa2c2 100644 --- a/src/infrastructure/external_tools/ansible/template/wrappers/inventory/context/mod.rs +++ b/src/infrastructure/external_tools/ansible/template/wrappers/inventory/context/mod.rs @@ -42,9 +42,6 @@ pub struct InventoryContext { ansible_host: AnsibleHost, ansible_ssh_private_key_file: SshPrivateKeyFile, ansible_port: AnsiblePort, - /// Alias for `ansible_port` used in playbook templates - #[serde(rename = "ssh_port")] - ssh_port: AnsiblePort, } /// Builder for `InventoryContext` with fluent interface @@ -106,7 +103,6 @@ impl InventoryContextBuilder { ansible_host, ansible_ssh_private_key_file, ansible_port, - ssh_port: ansible_port, // Same value for playbook templates }) } } @@ -127,7 +123,6 @@ impl InventoryContext { ansible_host, ansible_ssh_private_key_file, ansible_port, - ssh_port: ansible_port, // Same value for playbook templates }) } diff --git a/src/infrastructure/external_tools/ansible/template/wrappers/mod.rs b/src/infrastructure/external_tools/ansible/template/wrappers/mod.rs index fa54244f..dc4ad6d1 100644 --- a/src/infrastructure/external_tools/ansible/template/wrappers/mod.rs +++ b/src/infrastructure/external_tools/ansible/template/wrappers/mod.rs @@ -2,7 +2,9 @@ //! //! Contains wrappers only for template files that actually need variable substitution //! and have the `.tera` extension. Static playbooks and config files are copied directly. +pub mod firewall_playbook; pub mod inventory; // Re-export the main template structs for easier access +pub use firewall_playbook::FirewallPlaybookTemplate; pub use inventory::InventoryTemplate; diff --git a/templates/ansible/configure-firewall.yml.tera b/templates/ansible/configure-firewall.yml.tera index 3b53f967..b1b7a393 100644 --- a/templates/ansible/configure-firewall.yml.tera +++ b/templates/ansible/configure-firewall.yml.tera @@ -4,7 +4,7 @@ # CRITICAL: SSH access is allowed BEFORE enabling firewall to prevent lockout. - name: Configure UFW firewall safely - hosts: torrust_servers + hosts: all become: yes gather_facts: yes From d51b156a02393858a73f83ede048048f4b262165 Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 31 Oct 2025 07:33:01 +0000 Subject: [PATCH 5/6] fix: [#18] remove redundant UFW app profile task that fails on Ubuntu VMs The 'Allow SSH service by name' task was using a non-existent UFW application profile 'ssh' which caused the playbook to fail before enabling the firewall. This task was redundant because SSH is already explicitly allowed by port number in the previous task. Fixes Issue #4 identified in PR review - UFW fails with 'Could not find a profile matching ssh' --- templates/ansible/configure-firewall.yml.tera | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/templates/ansible/configure-firewall.yml.tera b/templates/ansible/configure-firewall.yml.tera index b1b7a393..e3870e11 100644 --- a/templates/ansible/configure-firewall.yml.tera +++ b/templates/ansible/configure-firewall.yml.tera @@ -57,16 +57,6 @@ - firewall - ssh - - name: Allow SSH service by name (additional safety measure) - community.general.ufw: - rule: allow - name: ssh - comment: "SSH service (standard SSH)" - tags: - - security - - firewall - - ssh - - name: Enable UFW firewall (AFTER SSH rules are in place) community.general.ufw: state: enabled From b8679fe4190629ba73fd72d6d83e662d9c7f3e7c Mon Sep 17 00:00:00 2001 From: Jose Celano Date: Fri, 31 Oct 2025 08:53:41 +0000 Subject: [PATCH 6/6] fix: [#18] skip firewall configuration in container-based E2E tests - Remove iptables permission detection fallback from ConfigureFirewallStep - Add TORRUST_TD_SKIP_FIREWALL_IN_CONTAINER env var to explicitly skip firewall step - Accepts only "true" or "false" (case-sensitive, lowercase) for type safety - E2E config tests automatically set the env var to skip firewall playbook - Update documentation in templates/ansible/README.md UFW/iptables requires kernel capabilities (CAP_NET_ADMIN, CAP_NET_RAW) not available in unprivileged Docker containers. Container-based E2E tests now explicitly skip the firewall configuration step while VM-based tests continue to run it normally. --- .../command_handlers/configure/handler.rs | 22 +++++++++++++--- .../steps/system/configure_firewall.rs | 25 ++++++++++++------- src/bin/e2e_config_tests.rs | 4 +++ templates/ansible/README.md | 23 ++++++++++++++++- 4 files changed, 61 insertions(+), 13 deletions(-) diff --git a/src/application/command_handlers/configure/handler.rs b/src/application/command_handlers/configure/handler.rs index 819efa00..2b52b1f8 100644 --- a/src/application/command_handlers/configure/handler.rs +++ b/src/application/command_handlers/configure/handler.rs @@ -164,9 +164,25 @@ impl ConfigureCommandHandler { .map_err(|e| (e.into(), current_step))?; let current_step = ConfigureStep::ConfigureFirewall; - ConfigureFirewallStep::new(Arc::clone(&self.ansible_client)) - .execute() - .map_err(|e| (e.into(), current_step))?; + // Allow tests or CI to explicitly skip the firewall configuration step + // (useful for container-based test runs where iptables/ufw require + // elevated kernel capabilities not available in unprivileged containers). + let skip_firewall = std::env::var("TORRUST_TD_SKIP_FIREWALL_IN_CONTAINER") + .map(|v| v == "true") + .unwrap_or(false); + + if skip_firewall { + info!( + command = "configure", + step = "configure_firewall", + status = "skipped", + "Skipping UFW firewall configuration due to TORRUST_TD_SKIP_FIREWALL_IN_CONTAINER" + ); + } else { + ConfigureFirewallStep::new(Arc::clone(&self.ansible_client)) + .execute() + .map_err(|e| (e.into(), current_step))?; + } // Transition to Configured state let configured = environment.clone().configured(); diff --git a/src/application/steps/system/configure_firewall.rs b/src/application/steps/system/configure_firewall.rs index 938e0636..65afd2a0 100644 --- a/src/application/steps/system/configure_firewall.rs +++ b/src/application/steps/system/configure_firewall.rs @@ -99,15 +99,22 @@ impl ConfigureFirewallStep { ); // Run Ansible playbook (SSH port already resolved during template rendering) - self.ansible_client.run_playbook("configure-firewall")?; - - info!( - step = "configure_firewall", - status = "success", - "UFW firewall configured successfully with SSH access preserved" - ); - - Ok(()) + match self.ansible_client.run_playbook("configure-firewall") { + Ok(_) => { + info!( + step = "configure_firewall", + status = "success", + "UFW firewall configured successfully with SSH access preserved" + ); + Ok(()) + } + Err(e) => { + // Propagate errors to the caller. Tests that run in container environments + // should explicitly opt-out of running this step (for example via an + // environment variable) instead of relying on runtime error detection. + Err(e) + } + } } } diff --git a/src/bin/e2e_config_tests.rs b/src/bin/e2e_config_tests.rs index 48885754..0f558e18 100644 --- a/src/bin/e2e_config_tests.rs +++ b/src/bin/e2e_config_tests.rs @@ -112,6 +112,10 @@ struct CliArgs { pub async fn main() -> Result<()> { let cli = CliArgs::parse(); + // Set environment variable to skip firewall configuration in container-based tests + // UFW/iptables requires kernel capabilities not available in unprivileged containers + std::env::set_var("TORRUST_TD_SKIP_FIREWALL_IN_CONTAINER", "true"); + // Initialize logging with production log location for E2E tests using the builder pattern LoggingBuilder::new(std::path::Path::new("./data/logs")) .with_format(cli.log_format.clone()) diff --git a/templates/ansible/README.md b/templates/ansible/README.md index 55c10207..c84eb183 100644 --- a/templates/ansible/README.md +++ b/templates/ansible/README.md @@ -22,7 +22,21 @@ This directory contains Ansible playbook templates for the Torrust Tracker Deplo - **`wait-cloud-init.yml`** - Waits for cloud-init to complete on newly provisioned VMs -### Configuration Files +### System Configuration + +- **`configure-security-updates.yml`** - Configures automatic security updates + + - Sets up unattended-upgrades for automatic security patches + +- **`configure-firewall.yml.tera`** - Configures UFW (Uncomplicated Firewall) with SSH lockout prevention + + - ⚠️ **Critical**: This playbook configures restrictive firewall rules + - Automatically preserves SSH access on the configured port to prevent lockout + - **Container Limitation**: Requires kernel capabilities (CAP_NET_ADMIN, CAP_NET_RAW) not available in unprivileged containers + - **Automatic Skip**: Container-based E2E tests automatically skip this step via `TORRUST_TD_SKIP_FIREWALL_IN_CONTAINER` environment variable + - Accepted values: `"true"` or `"false"` (case-sensitive, lowercase only) + - Example: `TORRUST_TD_SKIP_FIREWALL_IN_CONTAINER=true` + - **VM-only**: This playbook is only executed in VM-based deployments and tests### Configuration Files - **`ansible.cfg`** - Ansible configuration - **`inventory.yml.tera`** - Inventory template file (processed by Tera templating engine) @@ -35,12 +49,19 @@ For a typical deployment: 2. **`update-apt-cache.yml`** - Update package cache (if needed, skip in CI) 3. **`install-docker.yml`** - Install Docker 4. **`install-docker-compose.yml`** - Install Docker Compose (optional) +5. **`configure-security-updates.yml`** - Configure automatic security updates +6. **`configure-firewall.yml.tera`** - Configure UFW firewall (VM-only, skipped in containers) ## CI/Testing Considerations - The `update-apt-cache.yml` playbook is separated from installation playbooks to avoid CI issues - In E2E tests, you can skip the cache update step to avoid network timeouts - The installation playbooks assume the cache is already up-to-date or will handle missing packages gracefully +- **Firewall configuration** is automatically skipped in container-based E2E tests because: + - UFW/iptables require kernel-level capabilities (`CAP_NET_ADMIN`, `CAP_NET_RAW`) + - Docker containers run unprivileged by default and lack these capabilities + - The deployer sets `TORRUST_TD_SKIP_FIREWALL_IN_CONTAINER=true` for container tests (accepts `"true"` or `"false"` only) + - VM-based tests (LXD) have full kernel access and run the firewall playbook normally ## Template Processing