Skip to main content

paygress/
lxd.rs

1// LXD Backend
2//
3// Implements ComputeBackend using the 'lxc' command line tool.
4// This is suitable for single-node setups like a VPS.
5
6use std::process::Command;
7use anyhow::{Context, Result};
8use async_trait::async_trait;
9use tracing::{info, warn};
10use crate::compute::{ComputeBackend, ContainerConfig, NodeStatus};
11
12pub struct LxdBackend {
13    storage_pool: String,
14    network_device: String,
15}
16
17impl LxdBackend {
18    pub fn new(storage_pool: &str, network_device: &str) -> Self {
19        Self {
20            storage_pool: storage_pool.to_string(),
21            network_device: network_device.to_string(),
22        }
23    }
24
25    fn run_lxc(&self, args: &[&str]) -> Result<String> {
26        let output = Command::new("lxc")
27            .args(args)
28            .output()
29            .context("Failed to execute lxc command")?;
30
31        if !output.status.success() {
32            let stderr = String::from_utf8_lossy(&output.stderr);
33            return Err(anyhow::anyhow!("lxc command failed: {}", stderr));
34        }
35
36        Ok(String::from_utf8_lossy(&output.stdout).to_string())
37    }
38}
39
40#[async_trait]
41impl ComputeBackend for LxdBackend {
42    async fn find_available_id(&self, range_start: u32, range_end: u32) -> Result<u32> {
43        // List all containers
44        let output = self.run_lxc(&["list", "--format", "json"])?;
45        let containers: serde_json::Value = serde_json::from_str(&output)?;
46        
47        let existing_ids: Vec<u32> = containers.as_array()
48            .unwrap_or(&vec![])
49            .iter()
50            .filter_map(|c| c.get("name").and_then(|n| n.as_str()))
51            .filter_map(|name| {
52                if name.starts_with("paygress-") {
53                    name.replace("paygress-", "").parse::<u32>().ok()
54                } else {
55                    None
56                }
57            })
58            .collect();
59
60        for id in range_start..=range_end {
61            if !existing_ids.contains(&id) {
62                return Ok(id);
63            }
64        }
65
66        Err(anyhow::anyhow!("No available IDs in range {}-{}", range_start, range_end))
67    }
68
69    async fn create_container(&self, config: &ContainerConfig) -> Result<String> {
70        let name = format!("paygress-{}", config.id);
71
72        // 1. Launch container
73        // Resolve generic names to specific images
74        let image = match config.image.as_str() {
75            "alpine" => "images:alpine/3.19",
76            "ubuntu" => "ubuntu:22.04", // Default LTS
77            other => other,
78        };
79        
80        info!("Creating LXD container {} with image {}", name, image);
81        
82        // Limits
83        let cpu_limit = format!("limits.cpu={}", config.cpu_cores);
84        let mem_limit = format!("limits.memory={}MB", config.memory_mb);
85        
86        self.run_lxc(&[
87            "launch", image, &name,
88            "-c", &cpu_limit,
89            "-c", &mem_limit,
90            "-c", "security.nesting=true",
91        ])?;
92
93        // 2. Set root password
94        // We always set root password so user can access regardless of default user
95        let chpasswd_cmd = format!("echo 'root:{}' | chpasswd", config.password);
96        
97        // Retry a few times as container starts up
98        for _ in 0..10 {
99            match self.run_lxc(&["exec", &name, "--", "sh", "-c", &chpasswd_cmd]) {
100                Ok(_) => break,
101                Err(_) => tokio::time::sleep(std::time::Duration::from_secs(1)).await,
102            }
103        }
104        
105        // 3. Generic SSH Setup & Hardening
106        // Attempt to install/enable SSH on various distros (Alpine, Debian, etc)
107        let setup_script = r#"
108            # Detect package manager and install SSH if missing
109            if command -v apk >/dev/null; then
110                # Alpine
111                apk add --no-cache openssh
112                rc-update add sshd default
113                service sshd start
114            elif command -v apt-get >/dev/null; then
115                # Debian/Ubuntu
116                # Usually installed, but ensure it runs
117                systemctl enable ssh
118                systemctl start ssh
119            fi
120            
121            # Configure SSH for root access with password
122            # Check if config exists
123            if [ -f /etc/ssh/sshd_config ]; then
124                # Remove cloud-init config that disables password auth
125                rm -f /etc/ssh/sshd_config.d/*-cloudimg-settings.conf
126
127                sed -i 's/#PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config
128                sed -i 's/PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config
129                sed -i 's/PasswordAuthentication no/PasswordAuthentication yes/' /etc/ssh/sshd_config
130                
131                # Restart service
132                service sshd restart || systemctl restart ssh || systemctl restart sshd
133            fi
134        "#;
135
136        let _ = self.run_lxc(&["exec", &name, "--", "sh", "-c", setup_script]);
137
138        // 4. Setup Port Forwarding
139        if let Some(port) = config.host_port {
140            info!("Setting up port forwarding: Host {} -> Container 22", port);
141            // lxc config device add <container> ssh proxy listen=tcp:0.0.0.0:<port> connect=tcp:127.0.0.1:22
142            self.run_lxc(&[
143                "config", "device", "add", &name, "ssh-proxy", "proxy",
144                &format!("listen=tcp:0.0.0.0:{}", port),
145                "connect=tcp:127.0.0.1:22",
146            ])?;
147        }
148
149        Ok(name)
150    }
151
152    async fn start_container(&self, id: u32) -> Result<()> {
153        let name = format!("paygress-{}", id);
154        self.run_lxc(&["start", &name])?;
155        Ok(())
156    }
157
158    async fn stop_container(&self, id: u32) -> Result<()> {
159        let name = format!("paygress-{}", id);
160        self.run_lxc(&["stop", &name])?;
161        Ok(())
162    }
163
164    async fn delete_container(&self, id: u32) -> Result<()> {
165        let name = format!("paygress-{}", id);
166        self.run_lxc(&["delete", &name, "--force"])?;
167        Ok(())
168    }
169
170    async fn get_node_status(&self) -> Result<NodeStatus> {
171        // Use `free -b` for memory
172        let mem_output = Command::new("free").arg("-b").output()?;
173        let mem_str = String::from_utf8_lossy(&mem_output.stdout);
174        
175        // Simple parsing of `free` output
176        //               total        used        free      shared  buff/cache   available
177        // Mem:    16723824640  1038573568 1234567890 ...
178        let mut memory_total = 0;
179        let mut memory_used = 0;
180        
181        for line in mem_str.lines() {
182            if line.starts_with("Mem:") {
183                let parts: Vec<&str> = line.split_whitespace().collect();
184                if parts.len() >= 3 {
185                    memory_total = parts[1].parse().unwrap_or(0);
186                    memory_used = parts[2].parse().unwrap_or(0);
187                }
188            }
189        }
190
191        // Use `df -B1 /` for disk
192        let disk_output = Command::new("df").args(["-B1", "/"]).output()?;
193        let disk_str = String::from_utf8_lossy(&disk_output.stdout);
194        
195        let mut disk_total = 0;
196        let mut disk_used = 0;
197        
198        for line in disk_str.lines().skip(1) { // Skip header
199            let parts: Vec<&str> = line.split_whitespace().collect();
200            if parts.len() >= 3 {
201                disk_total = parts[1].parse().unwrap_or(0);
202                disk_used = parts[2].parse().unwrap_or(0);
203                break;
204            }
205        }
206        
207        // Use `uptime` or `mpstat` for CPU? Or just 0.5 as placeholder since it's hard to get instantaneous usage portably
208        // Let's use /proc/loadavg
209        let loadavg = std::fs::read_to_string("/proc/loadavg").unwrap_or_default();
210        let load_1min: f64 = loadavg.split_whitespace().next().unwrap_or("0").parse().unwrap_or(0.0);
211        let cpu_cores = num_cpus::get() as f64;
212        let cpu_usage = (load_1min / cpu_cores).min(1.0);
213
214        Ok(NodeStatus {
215            cpu_usage,
216            memory_used,
217            memory_total,
218            disk_used,
219            disk_total,
220        })
221    }
222
223    async fn get_container_ip(&self, id: u32) -> Result<Option<String>> {
224        let name = format!("paygress-{}", id);
225        let output = self.run_lxc(&["list", &name, "--format", "json"])?;
226        let containers: serde_json::Value = serde_json::from_str(&output)?;
227        
228        if let Some(container) = containers.as_array().and_then(|a| a.first()) {
229            // Traverse json to find eth0 ipv4
230            // state -> network -> eth0 -> addresses -> [family=inet] -> address
231            if let Some(networks) = container.get("state").and_then(|s| s.get("network")) {
232                if let Some(eth0) = networks.get("eth0") {
233                     if let Some(addrs) = eth0.get("addresses").and_then(|a| a.as_array()) {
234                         for addr in addrs {
235                             if addr.get("family").and_then(|f| f.as_str()) == Some("inet") {
236                                 if let Some(ip) = addr.get("address").and_then(|a| a.as_str()) {
237                                     return Ok(Some(ip.to_string()));
238                                 }
239                             }
240                         }
241                     }
242                }
243            }
244        }
245        
246        Ok(None)
247    }
248}