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 Nginx;

impl Nginx {
    fn conffile(server: &Server) -> Result<PathBuf> {
        Ok(paths::generated_dir()?
            .join("nginx")
            .join(format!("{}.conf", server.name)))
    }

    fn nginx_bin(brew: &Brew) -> PathBuf {
        brew.bin("nginx")
    }

    fn render_conf(
        server: &Server,
        vhosts: &[&Vhost],
        state: &State,
        cfg: &Config,
        brew: &Brew,
    ) -> Result<String> {
        let run = paths::run_dir()?;
        let logs = paths::logs_dir()?;
        let etc = brew.etc("nginx");

        let mut out = String::new();
        out.push_str("# Generated by reeve — do not edit by hand.\n");
        out.push_str(&format!(
            "error_log {} warn;\n",
            q(&logs
                .join(format!("server-{}-error.log", server.name))
                .display()
                .to_string())
        ));
        out.push_str(&format!(
            "pid {};\n",
            q(&run
                .join(format!("nginx-{}.pid", server.name))
                .display()
                .to_string())
        ));
        out.push_str(&format!(
            "events {{ worker_connections {}; }}\n",
            server.setting("worker_connections", "256")
        ));
        out.push_str("http {\n");
        out.push_str(&format!(
            "    include {};\n",
            q(&etc.join("mime.types").display().to_string())
        ));
        out.push_str("    default_type application/octet-stream;\n");
        out.push_str(&format!(
            "    client_max_body_size {};\n",
            server.setting("client_max_body_size", "64m")
        ));
        // Keep all writable temp paths under our space-free run dir.
        let tmp = |suffix: &str| {
            q(&run
                .join(format!("nginx-{}-{}", server.name, suffix))
                .display()
                .to_string())
        };
        out.push_str(&format!("    client_body_temp_path {};\n", tmp("body")));
        out.push_str(&format!("    proxy_temp_path {};\n", tmp("proxy")));
        out.push_str(&format!("    fastcgi_temp_path {};\n", tmp("fcgi")));
        out.push_str(&format!("    uwsgi_temp_path {};\n", tmp("uwsgi")));
        out.push_str(&format!("    scgi_temp_path {};\n", tmp("scgi")));
        out.push_str(&format!(
            "    access_log {};\n",
            q(&logs
                .join(format!("server-{}-access.log", server.name))
                .display()
                .to_string())
        ));

        let fastcgi_conf = etc.join("fastcgi.conf");

        for v in vhosts {
            out.push_str("    server {\n");
            if v.ssl {
                out.push_str(&format!("        listen {} ssl;\n", server.https_port));
                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!(
                    "        ssl_certificate {};\n",
                    q(&cert.display().to_string())
                ));
                out.push_str(&format!(
                    "        ssl_certificate_key {};\n",
                    q(&key.display().to_string())
                ));
            } else {
                out.push_str(&format!("        listen {};\n", server.http_port));
            }
            out.push_str(&format!("        server_name {};\n", v.server_name));
            if let Some(target) = &v.proxy_target {
                // Reverse-proxy vhost: forward everything upstream.
                out.push_str("        location / {\n");
                out.push_str(&format!("            proxy_pass {target};\n"));
                out.push_str("            proxy_set_header Host $host;\n");
                out.push_str(
                    "            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n",
                );
                out.push_str("            proxy_set_header X-Forwarded-Proto $scheme;\n");
                out.push_str("        }\n");
                out.push_str("    }\n");
                continue;
            }
            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!("        root {};\n", q(&v.effective_docroot())));
            out.push_str("        index index.php index.html;\n");
            // Preset security locations FIRST (nginx takes the first matching
            // regex location), so e.g. /user/accounts/* is denied before the
            // generic PHP handler can run it.
            out.push_str(crate::preset::nginx_security(v.preset));
            out.push_str(&format!(
                "        location / {{ try_files {}; }}\n",
                crate::preset::nginx_try_files(v.preset)
            ));
            out.push_str("        location ~ \\.php$ {\n");
            out.push_str(&format!(
                "            include {};\n",
                q(&fastcgi_conf.display().to_string())
            ));
            out.push_str(&format!(
                "            fastcgi_pass unix:{};\n",
                php.fpm_socket
            ));
            out.push_str("        }\n");
            out.push_str("    }\n");
        }

        // Catch-all default site: unmatched hosts (e.g. plain
        // http(s)://localhost:<port>) serve the sites root, on both ports.
        if server.default_site {
            let root = q(server.effective_default_root(&cfg.sites_root));
            let php_sock = super::default_php_socket(state, cfg);
            let body = |out: &mut String| {
                out.push_str("        server_name _;\n");
                out.push_str(&format!("        root {};\n", root));
                out.push_str("        index index.php index.html;\n");
                out.push_str(crate::preset::nginx_security(server.default_preset));
                out.push_str(&format!(
                    "        location / {{ try_files {}; }}\n",
                    crate::preset::nginx_try_files(server.default_preset)
                ));
                if let Some(sock) = &php_sock {
                    out.push_str("        location ~ \\.php$ {\n");
                    out.push_str(&format!(
                        "            include {};\n",
                        q(&fastcgi_conf.display().to_string())
                    ));
                    out.push_str(&format!("            fastcgi_pass unix:{};\n", sock));
                    out.push_str("        }\n");
                }
            };
            // HTTP.
            out.push_str("    server {\n");
            out.push_str(&format!(
                "        listen {} default_server;\n",
                server.http_port
            ));
            body(&mut out);
            out.push_str("    }\n");
            // HTTPS with the localhost cert.
            let cert = paths::certs_dir()?.join(format!("{}.pem", super::DEFAULT_SITE_HOST));
            let key = paths::certs_dir()?.join(format!("{}-key.pem", super::DEFAULT_SITE_HOST));
            out.push_str("    server {\n");
            out.push_str(&format!(
                "        listen {} ssl default_server;\n",
                server.https_port
            ));
            out.push_str(&format!(
                "        ssl_certificate {};\n",
                q(&cert.display().to_string())
            ));
            out.push_str(&format!(
                "        ssl_certificate_key {};\n",
                q(&key.display().to_string())
            ));
            body(&mut out);
            out.push_str("    }\n");
        }
        out.push_str("}\n");
        Ok(out)
    }
}

/// Quote an nginx token if it contains whitespace.
fn q(s: &str) -> String {
    if s.chars().any(|c| c.is_whitespace()) {
        format!("\"{s}\"")
    } else {
        s.to_string()
    }
}

impl WebServerBackend for Nginx {
    fn id(&self) -> Backend {
        Backend::Nginx
    }

    fn formula(&self) -> &'static str {
        "nginx"
    }

    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<()> {
        let content = Self::render_conf(server, vhosts, state, cfg, brew)?;
        let path = Self::conffile(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::conffile(server)?;
        if !path.exists() {
            bail!(
                "No generated config for '{}'. Run `reeve apply` first.",
                server.name
            );
        }
        let out = Command::new(Self::nginx_bin(brew))
            .arg("-t")
            .arg("-c")
            .arg(&path)
            .output()
            .context("Failed to run `nginx -t`")?;
        if !out.status.success() {
            bail!(
                "nginx config test 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 conf = Self::conffile(server)?;
        Ok(ServiceSpec {
            service: super::server_service_id(server),
            program: Self::nginx_bin(brew),
            args: vec![
                "-c".into(),
                conf.display().to_string(),
                "-g".into(),
                "daemon off;".into(),
            ],
            log: paths::logs_dir()?.join(format!("server-{}.log", server.name)),
            keep_alive: true,
            run_at_load: true,
        })
    }
}