Skip to content

Commit c2829c2

Browse files
Copilotjosecelano
andcommitted
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 <[email protected]>
1 parent 3653e1c commit c2829c2

File tree

8 files changed

+362
-3
lines changed

8 files changed

+362
-3
lines changed

src/application/command_handlers/configure/handler.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ use super::errors::ConfigureCommandHandlerError;
88
use crate::adapters::ansible::AnsibleClient;
99
use crate::application::command_handlers::common::StepResult;
1010
use crate::application::steps::{
11-
ConfigureSecurityUpdatesStep, InstallDockerComposeStep, InstallDockerStep,
11+
ConfigureFirewallStep, ConfigureSecurityUpdatesStep, InstallDockerComposeStep,
12+
InstallDockerStep,
1213
};
1314
use crate::domain::environment::repository::{EnvironmentRepository, TypedEnvironmentRepository};
1415
use crate::domain::environment::state::{ConfigureFailureContext, ConfigureStep};
@@ -24,6 +25,7 @@ use crate::shared::error::Traceable;
2425
/// 1. Install Docker
2526
/// 2. Install Docker Compose
2627
/// 3. Configure automatic security updates
28+
/// 4. Configure UFW firewall
2729
///
2830
/// # State Management
2931
///
@@ -161,6 +163,11 @@ impl ConfigureCommandHandler {
161163
.execute()
162164
.map_err(|e| (e.into(), current_step))?;
163165

166+
let current_step = ConfigureStep::ConfigureFirewall;
167+
ConfigureFirewallStep::new(Arc::clone(&self.ansible_client))
168+
.execute()
169+
.map_err(|e| (e.into(), current_step))?;
170+
164171
// Transition to Configured state
165172
let configured = environment.clone().configured();
166173

src/application/steps/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ pub use rendering::{
3636
RenderAnsibleTemplatesError, RenderAnsibleTemplatesStep, RenderOpenTofuTemplatesStep,
3737
};
3838
pub use software::{InstallDockerComposeStep, InstallDockerStep};
39-
pub use system::{ConfigureSecurityUpdatesStep, WaitForCloudInitStep};
39+
pub use system::{ConfigureFirewallStep, ConfigureSecurityUpdatesStep, WaitForCloudInitStep};
4040
pub use validation::{
4141
ValidateCloudInitCompletionStep, ValidateDockerComposeInstallationStep,
4242
ValidateDockerInstallationStep,
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
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+
37+
use std::sync::Arc;
38+
use tracing::{info, instrument, warn};
39+
40+
use crate::adapters::ansible::AnsibleClient;
41+
use crate::shared::command::CommandError;
42+
43+
/// Step that configures UFW firewall on a remote host via Ansible
44+
///
45+
/// This step configures a restrictive UFW firewall policy while ensuring
46+
/// SSH access is maintained. The SSH port is resolved during template rendering
47+
/// and embedded in the final Ansible playbook. The configuration follows the
48+
/// principle of "allow SSH BEFORE enabling firewall" to prevent lockout.
49+
pub struct ConfigureFirewallStep {
50+
ansible_client: Arc<AnsibleClient>,
51+
}
52+
53+
impl ConfigureFirewallStep {
54+
/// Create a new firewall configuration step
55+
///
56+
/// # Arguments
57+
///
58+
/// * `ansible_client` - Ansible client for running playbooks
59+
///
60+
/// # Note
61+
///
62+
/// SSH port configuration is resolved during template rendering phase,
63+
/// not at step execution time. The rendered playbook contains the
64+
/// resolved SSH port value.
65+
#[must_use]
66+
pub fn new(ansible_client: Arc<AnsibleClient>) -> Self {
67+
Self { ansible_client }
68+
}
69+
70+
/// Execute the firewall configuration
71+
///
72+
/// # Safety
73+
///
74+
/// This method is designed to prevent SSH lockout by:
75+
/// 1. Resetting UFW to clean state
76+
/// 2. Allowing SSH access BEFORE enabling firewall
77+
/// 3. Using the correct SSH port from user configuration
78+
///
79+
/// The SSH port is resolved during template rendering and embedded in the
80+
/// playbook, so this method executes a playbook with pre-configured values.
81+
///
82+
/// # Errors
83+
///
84+
/// Returns `CommandError` if:
85+
/// - Ansible playbook execution fails
86+
/// - UFW commands fail
87+
/// - SSH rules cannot be applied
88+
/// - Firewall verification fails
89+
#[instrument(
90+
name = "configure_firewall",
91+
skip_all,
92+
fields(
93+
step_type = "system",
94+
component = "firewall",
95+
method = "ansible"
96+
)
97+
)]
98+
pub fn execute(&self) -> Result<(), CommandError> {
99+
warn!(
100+
step = "configure_firewall",
101+
action = "configure_ufw",
102+
"Configuring UFW firewall - CRITICAL: SSH access will be restricted to configured port"
103+
);
104+
105+
// Run Ansible playbook (SSH port already resolved during template rendering)
106+
self.ansible_client.run_playbook("configure-firewall")?;
107+
108+
info!(
109+
step = "configure_firewall",
110+
status = "success",
111+
"UFW firewall configured successfully with SSH access preserved"
112+
);
113+
114+
Ok(())
115+
}
116+
}
117+
118+
#[cfg(test)]
119+
mod tests {
120+
use std::path::PathBuf;
121+
use std::sync::Arc;
122+
123+
use super::*;
124+
125+
#[test]
126+
fn it_should_create_configure_firewall_step() {
127+
let ansible_client = Arc::new(AnsibleClient::new(PathBuf::from("test_inventory.yml")));
128+
let step = ConfigureFirewallStep::new(ansible_client);
129+
130+
// Test that the step can be created successfully
131+
assert_eq!(
132+
std::mem::size_of_val(&step),
133+
std::mem::size_of::<Arc<AnsibleClient>>()
134+
);
135+
}
136+
}

src/application/steps/system/mod.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,18 @@
77
* Current steps:
88
* - Cloud-init completion waiting
99
* - Automatic security updates configuration
10+
* - UFW firewall configuration
1011
*
1112
* Future steps may include:
1213
* - User account setup and management
13-
* - Firewall configuration
1414
* - Log rotation configuration
1515
* - System service management
1616
*/
1717

18+
pub mod configure_firewall;
1819
pub mod configure_security_updates;
1920
pub mod wait_cloud_init;
2021

22+
pub use configure_firewall::ConfigureFirewallStep;
2123
pub use configure_security_updates::ConfigureSecurityUpdatesStep;
2224
pub use wait_cloud_init::WaitForCloudInitStep;

src/domain/environment/state/configure_failed.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ pub enum ConfigureStep {
4747
InstallDockerCompose,
4848
/// Configuring automatic security updates
4949
ConfigureSecurityUpdates,
50+
/// Configuring UFW firewall
51+
ConfigureFirewall,
5052
}
5153

5254
/// Error state - Application configuration failed

src/infrastructure/external_tools/ansible/template/renderer/mod.rs

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,15 @@ impl AnsibleTemplateRenderer {
198198
.render(inventory_context, &build_ansible_dir)
199199
.map_err(|source| ConfigurationTemplateError::InventoryRenderingFailed { source })?;
200200

201+
// Render dynamic firewall playbook template with SSH port variable
202+
self.render_tera_template(
203+
"configure-firewall.yml.tera",
204+
"configure-firewall.yml",
205+
inventory_context,
206+
&build_ansible_dir,
207+
)
208+
.await?;
209+
201210
// Copy static Ansible files (config and playbooks)
202211
self.copy_static_templates(&self.template_manager, &build_ansible_dir)
203212
.await?;
@@ -372,6 +381,87 @@ impl AnsibleTemplateRenderer {
372381
tracing::debug!("Successfully copied static file {}", file_name);
373382
Ok(())
374383
}
384+
385+
/// Renders a Tera template with the provided context
386+
///
387+
/// # Arguments
388+
///
389+
/// * `template_name` - Name of the template file (e.g., "configure-firewall.yml.tera")
390+
/// * `output_name` - Name of the output file (e.g., "configure-firewall.yml")
391+
/// * `context` - Template context for variable substitution
392+
/// * `destination_dir` - Directory where the rendered file will be written
393+
///
394+
/// # Returns
395+
///
396+
/// * `Result<(), ConfigurationTemplateError>` - Success or error from the template rendering operation
397+
///
398+
/// # Errors
399+
///
400+
/// Returns an error if:
401+
/// - Template file cannot be found or read
402+
/// - Template content is invalid
403+
/// - Variable substitution fails
404+
/// - Output file cannot be written
405+
async fn render_tera_template(
406+
&self,
407+
template_name: &str,
408+
output_name: &str,
409+
context: &InventoryContext,
410+
destination_dir: &Path,
411+
) -> Result<(), ConfigurationTemplateError> {
412+
tracing::debug!("Rendering Tera template: {}", template_name);
413+
414+
let template_path = Self::build_template_path(template_name);
415+
416+
// Get the template file path
417+
let source_path = self
418+
.template_manager
419+
.get_template_path(&template_path)
420+
.map_err(|source| ConfigurationTemplateError::TemplatePathFailed {
421+
file_name: template_name.to_string(),
422+
source,
423+
})?;
424+
425+
// Read template content
426+
let template_content = tokio::fs::read_to_string(&source_path)
427+
.await
428+
.map_err(|source| ConfigurationTemplateError::TeraTemplateReadFailed {
429+
file_name: template_name.to_string(),
430+
source,
431+
})?;
432+
433+
// Create File object for template processing
434+
let template_file =
435+
crate::domain::template::file::File::new(template_name, template_content).map_err(
436+
|source| ConfigurationTemplateError::FileCreationFailed {
437+
file_name: template_name.to_string(),
438+
source,
439+
},
440+
)?;
441+
442+
// Render template with context
443+
let mut engine = crate::domain::template::TemplateEngine::new();
444+
let rendered_content = engine
445+
.render(template_file.filename(), template_file.content(), context)
446+
.map_err(|source| ConfigurationTemplateError::InventoryTemplateCreationFailed {
447+
source,
448+
})?;
449+
450+
// Write rendered content to output file
451+
let output_path = destination_dir.join(output_name);
452+
crate::domain::template::write_file_with_dir_creation(&output_path, &rendered_content)
453+
.map_err(|source| ConfigurationTemplateError::InventoryTemplateRenderFailed {
454+
source,
455+
})?;
456+
457+
tracing::debug!(
458+
"Successfully rendered Tera template {} to {}",
459+
template_name,
460+
output_path.display()
461+
);
462+
463+
Ok(())
464+
}
375465
}
376466

377467
#[cfg(test)]

src/infrastructure/external_tools/ansible/template/wrappers/inventory/context/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ pub struct InventoryContext {
4242
ansible_host: AnsibleHost,
4343
ansible_ssh_private_key_file: SshPrivateKeyFile,
4444
ansible_port: AnsiblePort,
45+
/// Alias for ansible_port used in playbook templates
46+
#[serde(rename = "ssh_port")]
47+
ssh_port: AnsiblePort,
4548
}
4649

4750
/// Builder for `InventoryContext` with fluent interface
@@ -103,6 +106,7 @@ impl InventoryContextBuilder {
103106
ansible_host,
104107
ansible_ssh_private_key_file,
105108
ansible_port,
109+
ssh_port: ansible_port, // Same value for playbook templates
106110
})
107111
}
108112
}
@@ -123,6 +127,7 @@ impl InventoryContext {
123127
ansible_host,
124128
ansible_ssh_private_key_file,
125129
ansible_port,
130+
ssh_port: ansible_port, // Same value for playbook templates
126131
})
127132
}
128133

0 commit comments

Comments
 (0)