torrust_tracker_deployer_lib/application/steps/system/configure_firewall.rs
1//! UFW firewall configuration step
2//!
3//! This module provides the `ConfigureFirewallStep` which handles configuration
4//! of UFW (Uncomplicated Firewall) on remote hosts via Ansible playbooks.
5//! This step ensures that the firewall is configured with restrictive default
6//! policies while maintaining SSH access to prevent lockout.
7//!
8//! ## Key Features
9//!
10//! - Configures UFW with restrictive default policies (deny incoming, allow outgoing)
11//! - Preserves SSH access on the configured port
12//! - Uses Tera template for dynamic SSH port resolution
13//! - Comprehensive SSH lockout prevention measures
14//! - Verification steps to ensure firewall is active and SSH is accessible
15//!
16//! ## Configuration Process
17//!
18//! The step executes the "configure-firewall" Ansible playbook which handles:
19//! - UFW installation and setup
20//! - Reset UFW to clean state
21//! - Set restrictive default policies
22//! - Allow SSH access BEFORE enabling firewall (critical for preventing lockout)
23//! - Enable UFW firewall
24//! - Verify firewall status and SSH access
25//!
26//! ## SSH Lockout Prevention
27//!
28//! This is a **high-risk operation** that could result in SSH lockout if not
29//! handled correctly. Safety measures include:
30//!
31//! 1. **Correct Sequencing**: SSH rules are added BEFORE enabling firewall
32//! 2. **Dual SSH Protection**: Both port-specific and service-name rules
33//! 3. **Port Configuration**: Uses actual SSH port from user configuration
34//! 4. **Verification Steps**: Ansible tasks verify SSH access is preserved
35//! 5. **Comprehensive Logging**: Detailed logging of each firewall step
36
37use std::sync::Arc;
38use tracing::{info, instrument, warn};
39
40use crate::adapters::ansible::AnsibleClient;
41use crate::application::traits::CommandProgressListener;
42use crate::shared::command::CommandError;
43
44/// Step that configures UFW firewall on a remote host via Ansible
45///
46/// This step configures a restrictive UFW firewall policy while ensuring
47/// SSH access is maintained. The SSH port is resolved during template rendering
48/// and embedded in the final Ansible playbook. The configuration follows the
49/// principle of "allow SSH BEFORE enabling firewall" to prevent lockout.
50pub struct ConfigureFirewallStep {
51 ansible_client: Arc<AnsibleClient>,
52}
53
54impl ConfigureFirewallStep {
55 /// Create a new firewall configuration step
56 ///
57 /// # Arguments
58 ///
59 /// * `ansible_client` - Ansible client for running playbooks
60 ///
61 /// # Note
62 ///
63 /// SSH port configuration is resolved during template rendering phase,
64 /// not at step execution time. The rendered playbook contains the
65 /// resolved SSH port value.
66 #[must_use]
67 pub fn new(ansible_client: Arc<AnsibleClient>) -> Self {
68 Self { ansible_client }
69 }
70
71 /// Execute the firewall configuration
72 ///
73 /// # Arguments
74 ///
75 /// * `listener` - Optional progress listener for reporting step-level details.
76 /// When provided, reports debug information (Ansible commands, working directory)
77 /// and detail information (firewall policies, SSH access preservation, status).
78 ///
79 /// # Safety
80 ///
81 /// This method is designed to prevent SSH lockout by:
82 /// 1. Resetting UFW to clean state
83 /// 2. Allowing SSH access BEFORE enabling firewall
84 /// 3. Using the correct SSH port from user configuration
85 ///
86 /// The SSH port is resolved during template rendering and embedded in the
87 /// playbook, so this method executes a playbook with pre-configured values.
88 ///
89 /// # Errors
90 ///
91 /// Returns `CommandError` if:
92 /// - Ansible playbook execution fails
93 /// - UFW commands fail
94 /// - SSH rules cannot be applied
95 /// - Firewall verification fails
96 #[instrument(
97 name = "configure_firewall",
98 skip_all,
99 fields(step_type = "system", component = "firewall", method = "ansible")
100 )]
101 pub fn execute(
102 &self,
103 listener: Option<&dyn CommandProgressListener>,
104 ) -> Result<(), CommandError> {
105 warn!(
106 step = "configure_firewall",
107 action = "configure_ufw",
108 "Configuring UFW firewall with variables from variables.yml"
109 );
110
111 // Report debug information about Ansible execution
112 if let Some(l) = listener {
113 l.on_debug(&format!(
114 "Ansible working directory: {}",
115 self.ansible_client.working_dir().display()
116 ));
117 l.on_debug("Executing playbook: ansible-playbook configure-firewall.yml -e @variables.yml -i inventory.ini");
118 }
119
120 // Run Ansible playbook with variables file
121 // Note: The @ symbol in Ansible means "load variables from this file"
122 // Equivalent to: ansible-playbook -e @variables.yml configure-firewall.yml
123 match self
124 .ansible_client
125 .run_playbook("configure-firewall", &["-e", "@variables.yml"])
126 {
127 Ok(_) => {
128 // Report configuration success with details
129 if let Some(l) = listener {
130 l.on_detail("Configuring UFW with restrictive default policies");
131 l.on_detail("Allowing SSH access before enabling firewall");
132 l.on_detail("Firewall status: active");
133 }
134
135 info!(
136 step = "configure_firewall",
137 status = "success",
138 "UFW firewall configured successfully with SSH access preserved"
139 );
140 Ok(())
141 }
142 Err(e) => {
143 // Propagate errors to the caller. Tests that run in container environments
144 // should explicitly opt-out of running this step (for example via an
145 // environment variable) instead of relying on runtime error detection.
146 Err(e)
147 }
148 }
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use std::path::PathBuf;
155 use std::sync::Arc;
156
157 use super::*;
158
159 #[test]
160 fn it_should_create_configure_firewall_step() {
161 let ansible_client = Arc::new(AnsibleClient::new(PathBuf::from("test_inventory.yml")));
162 let step = ConfigureFirewallStep::new(ansible_client);
163
164 // Test that the step can be created successfully
165 assert_eq!(
166 std::mem::size_of_val(&step),
167 std::mem::size_of::<Arc<AnsibleClient>>()
168 );
169 }
170}