|
1 | 1 | use std::sync::Arc; |
2 | 2 |
|
3 | | -use anyhow::Result; |
| 3 | +use anyhow::{anyhow, Context, Result}; |
4 | 4 | use futures_util::future::join_all; |
5 | 5 | use tokio::sync::mpsc; |
| 6 | +use tokio::{fs, process::Command}; |
6 | 7 | use tracing::{debug, warn}; |
7 | 8 |
|
8 | 9 | use crate::builder::BuilderCtx; |
9 | 10 | use crate::grpc::proto::{ |
10 | | - client_msg, response, server_msg, BuildResponseBody, ClientMsg, ContainerSummary, Error, |
11 | | - ListContainersResp, Response, |
| 11 | + client_msg, response, server_msg, ApplyCaddyIngressResp, BuildResponseBody, |
| 12 | + CaddyAppIngressFile, ClientMsg, ContainerSummary, Error, ListContainersResp, Response, |
| 13 | + StopCaddyIngressResp, |
12 | 14 | }; |
13 | 15 | use crate::podman::PodmanClient; |
14 | 16 |
|
@@ -37,6 +39,40 @@ pub async fn handle( |
37 | 39 | ) |
38 | 40 | .await; |
39 | 41 | } |
| 42 | + server_msg::Command::ApplyCaddyIngress(req) => { |
| 43 | + let body = match apply_caddy_ingress(req.caddyfile, req.apps, req.mesh_network).await { |
| 44 | + Ok(output) => response::Body::ApplyCaddyIngress(ApplyCaddyIngressResp { output }), |
| 45 | + Err(e) => response::Body::Error(Error { |
| 46 | + code: 500, |
| 47 | + message: format!("{e:#}"), |
| 48 | + }), |
| 49 | + }; |
| 50 | + send_response( |
| 51 | + &tx, |
| 52 | + Response { |
| 53 | + request_id, |
| 54 | + body: Some(body), |
| 55 | + }, |
| 56 | + ) |
| 57 | + .await; |
| 58 | + } |
| 59 | + server_msg::Command::StopCaddyIngress(_) => { |
| 60 | + let body = match stop_caddy_ingress().await { |
| 61 | + Ok(output) => response::Body::StopCaddyIngress(StopCaddyIngressResp { output }), |
| 62 | + Err(e) => response::Body::Error(Error { |
| 63 | + code: 500, |
| 64 | + message: format!("{e:#}"), |
| 65 | + }), |
| 66 | + }; |
| 67 | + send_response( |
| 68 | + &tx, |
| 69 | + Response { |
| 70 | + request_id, |
| 71 | + body: Some(body), |
| 72 | + }, |
| 73 | + ) |
| 74 | + .await; |
| 75 | + } |
40 | 76 | server_msg::Command::Build(req) => match builder_ctx { |
41 | 77 | Some(ctx) => ctx.dispatch(request_id, req, tx), |
42 | 78 | None => { |
@@ -80,6 +116,212 @@ async fn send_response(tx: &mpsc::Sender<ClientMsg>, response: Response) { |
80 | 116 | } |
81 | 117 | } |
82 | 118 |
|
| 119 | +async fn apply_caddy_ingress( |
| 120 | + caddyfile: String, |
| 121 | + apps: Vec<CaddyAppIngressFile>, |
| 122 | + mesh_network: String, |
| 123 | +) -> Result<String> { |
| 124 | + if caddyfile.trim().is_empty() { |
| 125 | + return Err(anyhow!("caddyfile is empty")); |
| 126 | + } |
| 127 | + |
| 128 | + if caddyfile.len() > 256 * 1024 || apps.iter().any(|app| app.caddyfile.len() > 256 * 1024) { |
| 129 | + return Err(anyhow!("caddyfile is too large")); |
| 130 | + } |
| 131 | + |
| 132 | + if !is_valid_podman_network_name(&mesh_network) { |
| 133 | + return Err(anyhow!("invalid mesh network name")); |
| 134 | + } |
| 135 | + |
| 136 | + let base_path = std::path::Path::new("/data/coolify/v5/ingress/caddy"); |
| 137 | + let apps_path = base_path.join("apps"); |
| 138 | + let caddyfile_path = base_path.join("Caddyfile"); |
| 139 | + let temp_caddyfile_path = base_path.join("Caddyfile.tmp"); |
| 140 | + |
| 141 | + fs::create_dir_all(&apps_path) |
| 142 | + .await |
| 143 | + .context("create Caddy app config directory")?; |
| 144 | + fs::create_dir_all(base_path.join("data")) |
| 145 | + .await |
| 146 | + .context("create Caddy data directory")?; |
| 147 | + fs::create_dir_all(base_path.join("config")) |
| 148 | + .await |
| 149 | + .context("create Caddy config directory")?; |
| 150 | + |
| 151 | + let mut expected_files = std::collections::HashSet::new(); |
| 152 | + for app in apps { |
| 153 | + let file_name = caddy_app_file_name(&app.name)?; |
| 154 | + expected_files.insert(file_name.clone()); |
| 155 | + fs::write(apps_path.join(file_name), app.caddyfile) |
| 156 | + .await |
| 157 | + .context("write Caddy app config")?; |
| 158 | + } |
| 159 | + |
| 160 | + let mut entries = fs::read_dir(&apps_path) |
| 161 | + .await |
| 162 | + .context("read Caddy app config directory")?; |
| 163 | + while let Some(entry) = entries |
| 164 | + .next_entry() |
| 165 | + .await |
| 166 | + .context("read Caddy app config")? |
| 167 | + { |
| 168 | + let file_name = entry.file_name().to_string_lossy().to_string(); |
| 169 | + if file_name.ends_with(".caddy") && !expected_files.contains(&file_name) { |
| 170 | + fs::remove_file(entry.path()) |
| 171 | + .await |
| 172 | + .context("remove stale Caddy app config")?; |
| 173 | + } |
| 174 | + } |
| 175 | + |
| 176 | + fs::write(&temp_caddyfile_path, caddyfile) |
| 177 | + .await |
| 178 | + .context("write temporary Caddyfile")?; |
| 179 | + |
| 180 | + run_command(Command::new("podman").args(["pull", "docker.io/library/caddy:2-alpine"])) |
| 181 | + .await |
| 182 | + .context("pull Caddy image")?; |
| 183 | + |
| 184 | + run_command(Command::new("podman").args([ |
| 185 | + "run", |
| 186 | + "--rm", |
| 187 | + "-v", |
| 188 | + &format!("{}:/etc/caddy:ro", base_path.display()), |
| 189 | + "docker.io/library/caddy:2-alpine", |
| 190 | + "caddy", |
| 191 | + "validate", |
| 192 | + "--config", |
| 193 | + "/etc/caddy/Caddyfile.tmp", |
| 194 | + ])) |
| 195 | + .await |
| 196 | + .context("validate Caddyfile")?; |
| 197 | + |
| 198 | + fs::rename(&temp_caddyfile_path, &caddyfile_path) |
| 199 | + .await |
| 200 | + .context("install Caddyfile")?; |
| 201 | + |
| 202 | + start_or_reload_caddy(base_path, &mesh_network).await |
| 203 | +} |
| 204 | + |
| 205 | +async fn start_or_reload_caddy(base_path: &std::path::Path, mesh_network: &str) -> Result<String> { |
| 206 | + if run_command(Command::new("podman").args(["container", "exists", "coolify-v5-caddy"])) |
| 207 | + .await |
| 208 | + .is_ok() |
| 209 | + { |
| 210 | + return run_command(Command::new("podman").args([ |
| 211 | + "exec", |
| 212 | + "coolify-v5-caddy", |
| 213 | + "caddy", |
| 214 | + "reload", |
| 215 | + "--config", |
| 216 | + "/etc/caddy/Caddyfile", |
| 217 | + ])) |
| 218 | + .await |
| 219 | + .map(|output| { |
| 220 | + if output.trim().is_empty() { |
| 221 | + "Caddy ingress applied.".into() |
| 222 | + } else { |
| 223 | + output |
| 224 | + } |
| 225 | + }) |
| 226 | + .context("reload Caddy ingress"); |
| 227 | + } |
| 228 | + |
| 229 | + let output = run_command(Command::new("podman").args([ |
| 230 | + "run", |
| 231 | + "-d", |
| 232 | + "--replace", |
| 233 | + "--name", |
| 234 | + "coolify-v5-caddy", |
| 235 | + "--network", |
| 236 | + mesh_network, |
| 237 | + "--restart", |
| 238 | + "unless-stopped", |
| 239 | + "-p", |
| 240 | + "80:80", |
| 241 | + "-p", |
| 242 | + "443:443", |
| 243 | + "-p", |
| 244 | + "443:443/udp", |
| 245 | + "-v", |
| 246 | + &format!( |
| 247 | + "{}:/etc/caddy/Caddyfile:ro", |
| 248 | + base_path.join("Caddyfile").display() |
| 249 | + ), |
| 250 | + "-v", |
| 251 | + &format!("{}:/etc/caddy/apps:ro", base_path.join("apps").display()), |
| 252 | + "-v", |
| 253 | + &format!("{}:/data", base_path.join("data").display()), |
| 254 | + "-v", |
| 255 | + &format!("{}:/config", base_path.join("config").display()), |
| 256 | + "docker.io/library/caddy:2-alpine", |
| 257 | + ])) |
| 258 | + .await |
| 259 | + .context("start Caddy ingress")?; |
| 260 | + |
| 261 | + Ok(if output.trim().is_empty() { |
| 262 | + "Caddy ingress applied.".into() |
| 263 | + } else { |
| 264 | + output |
| 265 | + }) |
| 266 | +} |
| 267 | + |
| 268 | +fn caddy_app_file_name(value: &str) -> Result<String> { |
| 269 | + if value.is_empty() |
| 270 | + || value.len() > 128 |
| 271 | + || !value |
| 272 | + .bytes() |
| 273 | + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) |
| 274 | + { |
| 275 | + return Err(anyhow!("invalid Caddy app file name")); |
| 276 | + } |
| 277 | + |
| 278 | + Ok(format!("{value}.caddy")) |
| 279 | +} |
| 280 | + |
| 281 | +async fn stop_caddy_ingress() -> Result<String> { |
| 282 | + let output = run_command(Command::new("podman").args(["rm", "-f", "coolify-v5-caddy"])) |
| 283 | + .await |
| 284 | + .context("stop Caddy ingress")?; |
| 285 | + |
| 286 | + Ok(if output.trim().is_empty() { |
| 287 | + "Caddy ingress stopped.".into() |
| 288 | + } else { |
| 289 | + output |
| 290 | + }) |
| 291 | +} |
| 292 | + |
| 293 | +fn is_valid_podman_network_name(value: &str) -> bool { |
| 294 | + !value.is_empty() |
| 295 | + && value.len() <= 128 |
| 296 | + && value |
| 297 | + .bytes() |
| 298 | + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) |
| 299 | +} |
| 300 | + |
| 301 | +async fn run_command(command: &mut Command) -> Result<String> { |
| 302 | + let output = command.output().await.context("run command")?; |
| 303 | + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); |
| 304 | + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); |
| 305 | + let combined = [stdout.as_str(), stderr.as_str()] |
| 306 | + .into_iter() |
| 307 | + .filter(|part| !part.is_empty()) |
| 308 | + .collect::<Vec<_>>() |
| 309 | + .join("\n"); |
| 310 | + |
| 311 | + if !output.status.success() { |
| 312 | + return Err(anyhow!( |
| 313 | + "{}", |
| 314 | + if combined.is_empty() { |
| 315 | + format!("command exited with {}", output.status) |
| 316 | + } else { |
| 317 | + combined |
| 318 | + } |
| 319 | + )); |
| 320 | + } |
| 321 | + |
| 322 | + Ok(combined) |
| 323 | +} |
| 324 | + |
83 | 325 | async fn list_containers(podman: &PodmanClient) -> Result<ListContainersResp> { |
84 | 326 | let containers = podman.list_containers().await?; |
85 | 327 |
|
|
0 commit comments