commonware_deployer/ec2/
utils.rs

1//! Utility functions for interacting with EC2 instances
2
3use crate::ec2::Error;
4use tokio::{
5    process::Command,
6    time::{sleep, Duration},
7};
8use tracing::warn;
9
10/// Maximum number of SSH connection attempts before failing
11pub const MAX_SSH_ATTEMPTS: usize = 30;
12
13/// Maximum number of polling attempts for service status
14pub const MAX_POLL_ATTEMPTS: usize = 30;
15
16/// Interval between retries
17pub const RETRY_INTERVAL: Duration = Duration::from_secs(10);
18
19/// Protocol for deployer ingress
20pub const DEPLOYER_PROTOCOL: &str = "tcp";
21
22/// Minimum port for deployer ingress
23pub const DEPLOYER_MIN_PORT: i32 = 0;
24
25/// Maximum port for deployer ingress
26pub const DEPLOYER_MAX_PORT: i32 = 65535;
27
28/// Fetch the current machine's public IPv4 address
29pub async fn get_public_ip() -> Result<String, Error> {
30    // icanhazip.com is maintained by Cloudflare as of 6/6/2021 (https://major.io/p/a-new-future-for-icanhazip/)
31    let result = reqwest::get("https://ipv4.icanhazip.com")
32        .await?
33        .text()
34        .await?
35        .trim()
36        .to_string();
37    Ok(result)
38}
39
40/// Copies a local file to a remote instance via rsync with retries
41pub async fn rsync_file(
42    key_file: &str,
43    local_path: &str,
44    ip: &str,
45    remote_path: &str,
46) -> Result<(), Error> {
47    for _ in 0..MAX_SSH_ATTEMPTS {
48        let output = Command::new("rsync")
49            .arg("-az")
50            .arg("-e")
51            .arg(format!(
52                "ssh -i {key_file} -o ServerAliveInterval=600 -o StrictHostKeyChecking=no"
53            ))
54            .arg(local_path)
55            .arg(format!("ubuntu@{ip}:{remote_path}"))
56            .output()
57            .await?;
58        if output.status.success() {
59            return Ok(());
60        }
61        warn!(error = ?String::from_utf8_lossy(&output.stderr), "SCP failed");
62        sleep(RETRY_INTERVAL).await;
63    }
64    Err(Error::ScpFailed)
65}
66
67/// Executes a command on a remote instance via SSH with retries
68pub async fn ssh_execute(key_file: &str, ip: &str, command: &str) -> Result<(), Error> {
69    for _ in 0..MAX_SSH_ATTEMPTS {
70        let output = Command::new("ssh")
71            .arg("-i")
72            .arg(key_file)
73            .arg("-o")
74            .arg("ServerAliveInterval=600")
75            .arg("-o")
76            .arg("StrictHostKeyChecking=no")
77            .arg(format!("ubuntu@{ip}"))
78            .arg(command)
79            .output()
80            .await?;
81        if output.status.success() {
82            return Ok(());
83        }
84        warn!(error = ?String::from_utf8_lossy(&output.stderr), "SSH failed");
85        sleep(RETRY_INTERVAL).await;
86    }
87    Err(Error::SshFailed)
88}
89
90/// Polls the status of a systemd service on a remote instance until active
91pub async fn poll_service_active(key_file: &str, ip: &str, service: &str) -> Result<(), Error> {
92    for _ in 0..MAX_POLL_ATTEMPTS {
93        let output = Command::new("ssh")
94            .arg("-i")
95            .arg(key_file)
96            .arg("-o")
97            .arg("ServerAliveInterval=600")
98            .arg("-o")
99            .arg("StrictHostKeyChecking=no")
100            .arg(format!("ubuntu@{ip}"))
101            .arg(format!("systemctl is-active {service}"))
102            .output()
103            .await?;
104        let parsed = String::from_utf8_lossy(&output.stdout);
105        let parsed = parsed.trim();
106        if parsed == "active" {
107            return Ok(());
108        }
109        if service == "binary" && parsed == "failed" {
110            warn!(service, "service failed to start (check logs and update)");
111            return Ok(());
112        }
113        warn!(error = ?String::from_utf8_lossy(&output.stderr), service, "active status check failed");
114        sleep(RETRY_INTERVAL).await;
115    }
116    Err(Error::ServiceTimeout(ip.to_string(), service.to_string()))
117}
118
119/// Polls the status of a systemd service on a remote instance until it becomes inactive
120pub async fn poll_service_inactive(key_file: &str, ip: &str, service: &str) -> Result<(), Error> {
121    for _ in 0..MAX_POLL_ATTEMPTS {
122        let output = Command::new("ssh")
123            .arg("-i")
124            .arg(key_file)
125            .arg("-o")
126            .arg("ServerAliveInterval=600")
127            .arg("-o")
128            .arg("StrictHostKeyChecking=no")
129            .arg(format!("ubuntu@{ip}"))
130            .arg(format!("systemctl is-active {service}"))
131            .output()
132            .await?;
133        let parsed = String::from_utf8_lossy(&output.stdout);
134        let parsed = parsed.trim();
135        if parsed == "inactive" {
136            return Ok(());
137        }
138        if service == "binary" && parsed == "failed" {
139            warn!(service, "service was never active");
140            return Ok(());
141        }
142        warn!(error = ?String::from_utf8_lossy(&output.stderr), service, "inactive status check failed");
143        sleep(RETRY_INTERVAL).await;
144    }
145    Err(Error::ServiceTimeout(ip.to_string(), service.to_string()))
146}
147
148/// Enables BBR on a remote instance by copying and applying sysctl settings.
149pub async fn enable_bbr(key_file: &str, ip: &str, bbr_conf_local_path: &str) -> Result<(), Error> {
150    rsync_file(
151        key_file,
152        bbr_conf_local_path,
153        ip,
154        "/home/ubuntu/99-bbr.conf",
155    )
156    .await?;
157    ssh_execute(
158        key_file,
159        ip,
160        "sudo mv /home/ubuntu/99-bbr.conf /etc/sysctl.d/99-bbr.conf",
161    )
162    .await?;
163    ssh_execute(key_file, ip, "sudo sysctl -p /etc/sysctl.d/99-bbr.conf").await?;
164    Ok(())
165}
166
167/// Converts an IP address to a CIDR block
168pub fn exact_cidr(ip: &str) -> String {
169    format!("{ip}/32")
170}