use super::WebServerBackend;
use crate::brew::Brew;
use crate::config::Config;
use crate::daemon::ServiceSpec;
use crate::paths;
use crate::state::{Backend, Server, State, Vhost};
use anyhow::{bail, Context, Result};
use std::path::PathBuf;
use std::process::Command;
pub struct Caddy;
impl Caddy {
fn caddyfile(server: &Server) -> Result<PathBuf> {
Ok(paths::generated_dir()?
.join("caddy")
.join(format!("{}.caddyfile", server.name)))
}
fn render_caddyfile(
server: &Server,
vhosts: &[&Vhost],
state: &State,
cfg: &Config,
) -> Result<String> {
let mut out = String::from(
"# Generated by reeve — do not edit by hand.\n{\n\tadmin off\n\tauto_https off\n}\n\n",
);
for v in vhosts {
let scheme_port = if v.ssl {
format!("https://{}:{}", v.server_name, server.https_port)
} else {
format!("http://{}:{}", v.server_name, server.http_port)
};
out.push_str(&format!("{scheme_port} {{\n"));
if let Some(target) = &v.proxy_target {
out.push_str(&format!("\treverse_proxy {}\n", quote(target)));
} else {
let php = state.get_php(&v.php_version).ok_or_else(|| {
anyhow::anyhow!(
"vhost '{}' references uninstalled PHP {}",
v.server_name,
v.php_version
)
})?;
out.push_str(&format!("\troot * {}\n", quote(&v.effective_docroot())));
out.push_str(crate::preset::caddy_security(v.preset));
out.push_str(&format!(
"\tphp_fastcgi {}\n",
quote(&format!("unix/{}", php.fpm_socket))
));
out.push_str("\tfile_server\n");
out.push_str(&format!(
"\trequest_body {{\n\t\tmax_size {}\n\t}}\n",
server.setting("max_body", "100MB")
));
}
if v.ssl {
let cert = paths::certs_dir()?.join(format!("{}.pem", v.server_name));
let key = paths::certs_dir()?.join(format!("{}-key.pem", v.server_name));
out.push_str(&format!(
"\ttls {} {}\n",
quote(&cert.display().to_string()),
quote(&key.display().to_string())
));
}
out.push_str("}\n\n");
}
if server.default_site {
let root = server.effective_default_root(&cfg.sites_root);
let php_sock = super::default_php_socket(state, cfg);
let body = |out: &mut String| {
out.push_str(&format!("\troot * {}\n", quote(root)));
out.push_str(crate::preset::caddy_security(server.default_preset));
if let Some(sock) = &php_sock {
out.push_str(&format!(
"\tphp_fastcgi {}\n",
quote(&format!("unix/{sock}"))
));
}
out.push_str("\tfile_server browse\n");
};
out.push_str(&format!(":{} {{\n", server.http_port));
body(&mut out);
out.push_str("}\n\n");
let (cert, key) = (
paths::certs_dir()?.join(format!("{}.pem", super::DEFAULT_SITE_HOST)),
paths::certs_dir()?.join(format!("{}-key.pem", super::DEFAULT_SITE_HOST)),
);
out.push_str(&format!("https://:{} {{\n", server.https_port));
body(&mut out);
out.push_str(&format!(
"\ttls {} {}\n",
quote(&cert.display().to_string()),
quote(&key.display().to_string())
));
out.push_str("}\n\n");
}
Ok(out)
}
fn caddy_bin(brew: &Brew) -> PathBuf {
brew.bin("caddy")
}
}
fn quote(s: &str) -> String {
if s.chars().any(|c| c.is_whitespace()) {
format!("\"{s}\"")
} else {
s.to_string()
}
}
impl WebServerBackend for Caddy {
fn id(&self) -> Backend {
Backend::Caddy
}
fn formula(&self) -> &'static str {
"caddy"
}
fn ensure_installed(&self, brew: &Brew) -> Result<()> {
if !brew.is_installed(self.formula()) {
brew.install(self.formula())?;
}
Ok(())
}
fn render(
&self,
server: &Server,
vhosts: &[&Vhost],
state: &State,
cfg: &Config,
_brew: &Brew,
) -> Result<()> {
for v in vhosts {
if state.get_php(&v.php_version).is_none() {
bail!(
"vhost '{}' needs PHP {} — run `reeve php install {}`",
v.server_name,
v.php_version,
v.php_version
);
}
}
let content = Self::render_caddyfile(server, vhosts, state, cfg)?;
let path = Self::caddyfile(server)?;
std::fs::write(&path, content)
.with_context(|| format!("Failed to write {}", path.display()))?;
Ok(())
}
fn validate(&self, server: &Server, brew: &Brew) -> Result<()> {
let path = Self::caddyfile(server)?;
if !path.exists() {
bail!(
"No generated Caddyfile for '{}'. Run `reeve apply` first.",
server.name
);
}
let out = Command::new(Self::caddy_bin(brew))
.args(["validate", "--adapter", "caddyfile", "--config"])
.arg(&path)
.output()
.context("Failed to run `caddy validate`")?;
if !out.status.success() {
bail!(
"caddy validate failed for '{}':\n{}",
server.name,
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}
fn reload(&self, server: &Server, _brew: &Brew) -> Result<()> {
crate::daemon::restart(&super::server_service_id(server))
}
fn service_spec(&self, server: &Server, brew: &Brew) -> Result<ServiceSpec> {
let caddyfile = Caddy::caddyfile(server)?;
Ok(ServiceSpec {
service: super::server_service_id(server),
program: Caddy::caddy_bin(brew),
args: vec![
"run".into(),
"--adapter".into(),
"caddyfile".into(),
"--config".into(),
caddyfile.display().to_string(),
],
log: paths::logs_dir()?.join(format!("server-{}.log", server.name)),
keep_alive: true,
run_at_load: true,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::{Framework, PhpVersion};
fn server() -> Server {
Server {
name: "caddy".into(),
backend: Backend::Caddy,
http_port: 80,
https_port: 443,
enabled: true,
default_site: false,
default_preset: Framework::Generic,
default_root: None,
settings: Default::default(),
}
}
#[test]
fn preset_uses_public_subdir_and_proxy_skips_php() {
let mut state = State::default();
state.php_versions.push(PhpVersion {
version: "8.3".into(),
fpm_socket: "/run/php83.sock".into(),
..Default::default()
});
let laravel = Vhost {
server_name: "app.test".into(),
server: "caddy".into(),
docroot: "/Sites/app".into(),
php_version: "8.3".into(),
ssl: false,
preset: Framework::Laravel,
proxy_target: None,
};
let proxy = Vhost {
server_name: "vite.test".into(),
server: "caddy".into(),
docroot: String::new(),
php_version: String::new(),
ssl: false,
preset: Framework::Generic,
proxy_target: Some("http://localhost:5173".into()),
};
let cfg = Config::default();
let vhosts = vec![&laravel, &proxy];
let out = Caddy::render_caddyfile(&server(), &vhosts, &state, &cfg).unwrap();
assert!(out.contains("root * /Sites/app/public"));
assert!(out.contains("php_fastcgi unix//run/php83.sock"));
assert!(out.contains("reverse_proxy http://localhost:5173"));
}
}