reeve-cli 0.2.8

Localhost web dev stack manager: web servers, per-vhost PHP versions, SSL, and DNS — RunCloud, scaled down.
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 {
    /// Generated Caddyfile path for a given server instance.
    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> {
        // admin off → no localhost:2019 admin port to collide across instances;
        // we manage lifecycle through launchd instead of the admin API.
        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 {
                // Reverse-proxy vhost: forward everything upstream, no PHP/files.
                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 {
                // mkcert-minted cert pair (wired in build step 4).
                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");
        }

        // Catch-all default site: any host not matched above (e.g. plain
        // http(s)://localhost:<port>) serves the sites root. Rendered on both
        // the HTTP port and the HTTPS port (with a localhost cert).
        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");
            };
            // HTTP.
            out.push_str(&format!(":{} {{\n", server.http_port));
            body(&mut out);
            out.push_str("}\n\n");
            // HTTPS with the localhost cert.
            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")
    }
}

/// Quote a Caddyfile token if it contains whitespace (the config root lives
/// under "Application Support", which has a space).
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<()> {
        // Re-ensure each referenced PHP version's FPM master is up.
        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<()> {
        // With admin off, restart the launchd service to pick up changes.
        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();
        // Laravel docroot points at the public/ subdir + php_fastcgi.
        assert!(out.contains("root * /Sites/app/public"));
        assert!(out.contains("php_fastcgi unix//run/php83.sock"));
        // Proxy vhost forwards upstream and emits no PHP for itself.
        assert!(out.contains("reverse_proxy http://localhost:5173"));
    }
}