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    /// Parse lxc list JSON output, treating empty stdout as an empty array.
40    /// `lxc list --format json` returns empty stdout (not `[]`) when no containers exist.
41    fn parse_lxc_json(raw: &str) -> Result<serde_json::Value> {
42        let s = if raw.trim().is_empty() { "[]" } else { raw };
43        serde_json::from_str(s).context("Failed to parse lxc list output")
44    }
45
46    /// Return the storage pool to use: the configured one if it exists,
47    /// otherwise the first pool returned by `lxc storage list`.
48    fn resolve_storage_pool(&self) -> Result<String> {
49        let raw = self.run_lxc(&["storage", "list", "--format", "json"])?;
50        let pools: serde_json::Value = serde_json::from_str(if raw.trim().is_empty() { "[]" } else { &raw })
51            .context("Failed to parse lxc storage list output")?;
52
53        let names: Vec<String> = pools.as_array()
54            .unwrap_or(&vec![])
55            .iter()
56            .filter_map(|p| p.get("name").and_then(|n| n.as_str()).map(str::to_string))
57            .collect();
58
59        // Use configured pool if it actually exists
60        if names.contains(&self.storage_pool) {
61            return Ok(self.storage_pool.clone());
62        }
63
64        // Fall back to the first available pool
65        names.into_iter().next()
66            .ok_or_else(|| anyhow::anyhow!(
67                "No LXD storage pools found. Run `lxc storage create default dir` on the provider."
68            ))
69    }
70}
71
72#[async_trait]
73impl ComputeBackend for LxdBackend {
74    async fn find_available_id(&self, range_start: u32, range_end: u32) -> Result<u32> {
75        let raw = self.run_lxc(&["list", "--format", "json"])?;
76        let containers = Self::parse_lxc_json(&raw)?;
77
78        let existing_ids: Vec<u32> = containers.as_array()
79            .unwrap_or(&vec![])
80            .iter()
81            .filter_map(|c| c.get("name").and_then(|n| n.as_str()))
82            .filter_map(|name| {
83                if name.starts_with("paygress-") {
84                    name.replace("paygress-", "").parse::<u32>().ok()
85                } else {
86                    None
87                }
88            })
89            .collect();
90
91        for id in range_start..=range_end {
92            if !existing_ids.contains(&id) {
93                return Ok(id);
94            }
95        }
96
97        Err(anyhow::anyhow!("No available IDs in range {}-{}", range_start, range_end))
98    }
99
100    async fn create_container(&self, config: &ContainerConfig) -> Result<String> {
101        let name = format!("paygress-{}", config.id);
102
103        // 1. Launch container
104        // Resolve generic names to specific images
105        let image = match config.image.as_str() {
106            "alpine" => "images:alpine/3.19",
107            "ubuntu" => "ubuntu:22.04", // Default LTS
108            other => other,
109        };
110        
111        info!("Creating LXD container {} with image {}", name, image);
112        
113        // Limits
114        let cpu_limit = format!("limits.cpu={}", config.cpu_cores);
115        let mem_limit = format!("limits.memory={}MB", config.memory_mb);
116        
117        let pool = self.resolve_storage_pool()?;
118        info!("Using storage pool: {}", pool);
119
120        self.run_lxc(&[
121            "launch", image, &name,
122            "-s", &pool,
123            "-c", &cpu_limit,
124            "-c", &mem_limit,
125            "-c", "security.nesting=true",
126        ])?;
127
128        // 2. Set root password
129        // We always set root password so user can access regardless of default user
130        let chpasswd_cmd = format!("echo 'root:{}' | chpasswd", config.password);
131        
132        // Retry a few times as container starts up
133        for _ in 0..10 {
134            match self.run_lxc(&["exec", &name, "--", "sh", "-c", &chpasswd_cmd]) {
135                Ok(_) => break,
136                Err(_) => tokio::time::sleep(std::time::Duration::from_secs(1)).await,
137            }
138        }
139        
140        // 3. Generic SSH Setup & Hardening
141        // Attempt to install/enable SSH on various distros (Alpine, Debian, etc)
142        let setup_script = r#"
143            # Detect package manager and install SSH if missing
144            if command -v apk >/dev/null; then
145                # Alpine
146                apk add --no-cache openssh
147                rc-update add sshd default
148                service sshd start
149            elif command -v apt-get >/dev/null; then
150                # Debian/Ubuntu
151                # Usually installed, but ensure it runs
152                systemctl enable ssh
153                systemctl start ssh
154            fi
155            
156            # Configure SSH for root access with password
157            # Check if config exists
158            if [ -f /etc/ssh/sshd_config ]; then
159                # Remove cloud-init config that disables password auth
160                rm -f /etc/ssh/sshd_config.d/*-cloudimg-settings.conf
161
162                sed -i 's/#PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config
163                sed -i 's/PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config
164                sed -i 's/PasswordAuthentication no/PasswordAuthentication yes/' /etc/ssh/sshd_config
165                
166                # Restart service
167                service sshd restart || systemctl restart ssh || systemctl restart sshd
168            fi
169        "#;
170
171        let _ = self.run_lxc(&["exec", &name, "--", "sh", "-c", setup_script]);
172
173        // 4. Setup Port Forwarding
174        if let Some(port) = config.host_port {
175            info!("Setting up port forwarding: Host {} -> Container 22", port);
176            // lxc config device add <container> ssh proxy listen=tcp:0.0.0.0:<port> connect=tcp:127.0.0.1:22
177            self.run_lxc(&[
178                "config", "device", "add", &name, "ssh-proxy", "proxy",
179                &format!("listen=tcp:0.0.0.0:{}", port),
180                "connect=tcp:127.0.0.1:22",
181            ])?;
182        }
183
184        Ok(name)
185    }
186
187    async fn start_container(&self, id: u32) -> Result<()> {
188        let name = format!("paygress-{}", id);
189        self.run_lxc(&["start", &name])?;
190        Ok(())
191    }
192
193    async fn stop_container(&self, id: u32) -> Result<()> {
194        let name = format!("paygress-{}", id);
195        self.run_lxc(&["stop", &name])?;
196        Ok(())
197    }
198
199    async fn delete_container(&self, id: u32) -> Result<()> {
200        let name = format!("paygress-{}", id);
201        self.run_lxc(&["delete", &name, "--force"])?;
202        Ok(())
203    }
204
205    async fn get_node_status(&self) -> Result<NodeStatus> {
206        // Use `free -b` for memory
207        let mem_output = Command::new("free").arg("-b").output()?;
208        let mem_str = String::from_utf8_lossy(&mem_output.stdout);
209        
210        // Simple parsing of `free` output
211        //               total        used        free      shared  buff/cache   available
212        // Mem:    16723824640  1038573568 1234567890 ...
213        let mut memory_total = 0;
214        let mut memory_used = 0;
215        
216        for line in mem_str.lines() {
217            if line.starts_with("Mem:") {
218                let parts: Vec<&str> = line.split_whitespace().collect();
219                if parts.len() >= 3 {
220                    memory_total = parts[1].parse().unwrap_or(0);
221                    memory_used = parts[2].parse().unwrap_or(0);
222                }
223            }
224        }
225
226        // Use `df -B1 /` for disk
227        let disk_output = Command::new("df").args(["-B1", "/"]).output()?;
228        let disk_str = String::from_utf8_lossy(&disk_output.stdout);
229        
230        let mut disk_total = 0;
231        let mut disk_used = 0;
232        
233        for line in disk_str.lines().skip(1) { // Skip header
234            let parts: Vec<&str> = line.split_whitespace().collect();
235            if parts.len() >= 3 {
236                disk_total = parts[1].parse().unwrap_or(0);
237                disk_used = parts[2].parse().unwrap_or(0);
238                break;
239            }
240        }
241        
242        // Use /proc/loadavg for CPU
243        let loadavg = std::fs::read_to_string("/proc/loadavg").unwrap_or_default();
244        let load_1min: f64 = loadavg.split_whitespace().next().unwrap_or("0").parse().unwrap_or(0.0);
245        let cpu_cores = num_cpus::get() as f64;
246        let cpu_usage = (load_1min / cpu_cores).min(1.0);
247
248        Ok(NodeStatus {
249            cpu_usage,
250            memory_used,
251            memory_total,
252            disk_used,
253            disk_total,
254        })
255    }
256
257    async fn get_container_ip(&self, id: u32) -> Result<Option<String>> {
258        let name = format!("paygress-{}", id);
259        let raw = self.run_lxc(&["list", &name, "--format", "json"])?;
260        let containers = Self::parse_lxc_json(&raw)?;
261        
262        if let Some(container) = containers.as_array().and_then(|a| a.first()) {
263            // Traverse json to find eth0 ipv4
264            // state -> network -> eth0 -> addresses -> [family=inet] -> address
265            if let Some(networks) = container.get("state").and_then(|s| s.get("network")) {
266                if let Some(eth0) = networks.get("eth0") {
267                     if let Some(addrs) = eth0.get("addresses").and_then(|a| a.as_array()) {
268                         for addr in addrs {
269                             if addr.get("family").and_then(|f| f.as_str()) == Some("inet") {
270                                 if let Some(ip) = addr.get("address").and_then(|a| a.as_str()) {
271                                     return Ok(Some(ip.to_string()));
272                                 }
273                             }
274                         }
275                     }
276                }
277            }
278        }
279        
280        Ok(None)
281    }
282}