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

/// Modules a minimal-but-functional httpd needs to start, serve, and proxy to
/// PHP-FPM. (name, shared-object filename).
const BASE_MODULES: &[(&str, &str)] = &[
    ("mpm_event_module", "mod_mpm_event.so"),
    ("authz_core_module", "mod_authz_core.so"),
    ("unixd_module", "mod_unixd.so"),
    ("log_config_module", "mod_log_config.so"),
    ("mime_module", "mod_mime.so"),
    ("dir_module", "mod_dir.so"),
    ("proxy_module", "mod_proxy.so"),
    ("proxy_fcgi_module", "mod_proxy_fcgi.so"),
    ("rewrite_module", "mod_rewrite.so"),
    ("headers_module", "mod_headers.so"),
    ("setenvif_module", "mod_setenvif.so"),
];
const SSL_MODULES: &[(&str, &str)] = &[
    ("ssl_module", "mod_ssl.so"),
    ("socache_shmcb_module", "mod_socache_shmcb.so"),
];

impl Apache {
    fn server_root(brew: &Brew) -> PathBuf {
        brew.opt("httpd")
    }

    fn modules_dir(brew: &Brew) -> PathBuf {
        Self::server_root(brew).join("lib/httpd/modules")
    }

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

    fn httpd_bin(brew: &Brew) -> PathBuf {
        brew.opt("httpd").join("bin/httpd")
    }

    fn render_conf(
        server: &Server,
        vhosts: &[&Vhost],
        state: &State,
        cfg: &Config,
        brew: &Brew,
    ) -> Result<String> {
        // SSL is needed if any vhost uses it, or the default site serves HTTPS.
        let needs_ssl = vhosts.iter().any(|v| v.ssl) || server.default_site;
        let mdir = Self::modules_dir(brew);

        let mut out = String::new();
        out.push_str("# Generated by reeve — do not edit by hand.\n");
        out.push_str(&format!(
            "ServerRoot {}\n",
            q(&Self::server_root(brew).display().to_string())
        ));
        out.push_str("ServerName localhost\n");
        out.push_str(&format!("Timeout {}\n", server.setting("timeout", "300")));
        // Keep connections alive between requests. Apache's from-scratch default
        // (no httpd.conf include) leaves this off, so a browser page load opens a
        // fresh TCP+TLS connection per asset — the dominant cost on HTTPS. brew's
        // default config enables it; reeve must too or it feels markedly slower.
        out.push_str("KeepAlive On\n");
        out.push_str(&format!(
            "KeepAliveTimeout {}\n",
            server.setting("keepalive_timeout", "5")
        ));
        out.push_str(&format!(
            "MaxKeepAliveRequests {}\n",
            server.setting("max_keepalive_requests", "100")
        ));
        out.push_str(&format!(
            "LimitRequestBody {}\n",
            server.setting("limit_request_body", "0")
        ));
        out.push_str(&format!(
            "PidFile {}\n",
            q(&paths::run_dir()?
                .join(format!("httpd-{}.pid", server.name))
                .display()
                .to_string())
        ));
        out.push_str(&format!("Listen {}\n", server.http_port));
        if needs_ssl {
            out.push_str(&format!("Listen {}\n", server.https_port));
        }

        // Load modules that actually exist on disk.
        let mut mods: Vec<(&str, &str)> = BASE_MODULES.to_vec();
        if needs_ssl {
            mods.extend_from_slice(SSL_MODULES);
        }
        for (name, file) in mods {
            let path = mdir.join(file);
            if path.exists() {
                out.push_str(&format!(
                    "LoadModule {name} {}\n",
                    q(&path.display().to_string())
                ));
            }
        }

        // Logs + mime.
        out.push_str(&format!(
            "ErrorLog {}\n",
            q(&paths::logs_dir()?
                .join(format!("server-{}-error.log", server.name))
                .display()
                .to_string())
        ));
        out.push_str("LogLevel warn\n");
        out.push_str("<IfModule mime_module>\n");
        out.push_str(&format!(
            "    TypesConfig {}\n",
            q(&brew.etc("httpd").join("mime.types").display().to_string())
        ));
        out.push_str("    AddType application/x-httpd-php .php\n");
        out.push_str("</IfModule>\n");

        // Deny everything by default; vhosts open their own docroots.
        out.push_str(
            "<Directory />\n    AllowOverride none\n    Require all denied\n</Directory>\n\n",
        );

        if needs_ssl {
            out.push_str("<IfModule ssl_module>\n    SSLSessionCache \"shmcb:");
            out.push_str(
                &paths::run_dir()?
                    .join(format!("ssl_scache-{}", server.name))
                    .display()
                    .to_string(),
            );
            out.push_str("(512000)\"\n</IfModule>\n\n");
        }

        for v in vhosts {
            let port = if v.ssl {
                server.https_port
            } else {
                server.http_port
            };

            out.push_str(&format!("<VirtualHost *:{port}>\n"));
            out.push_str(&format!("    ServerName {}\n", v.server_name));
            if let Some(target) = &v.proxy_target {
                // Reverse-proxy vhost: forward everything upstream.
                out.push_str("    ProxyPreserveHost On\n");
                out.push_str(&format!("    ProxyPass / {}/\n", q(target)));
                out.push_str(&format!("    ProxyPassReverse / {}/\n", q(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
                    )
                })?;
                let handler = format!("proxy:unix:{}|fcgi://localhost", php.fpm_socket);
                let docroot = v.effective_docroot();
                out.push_str(&format!("    DocumentRoot {}\n", q(&docroot)));
                out.push_str(&format!("    <Directory {}>\n", q(&docroot)));
                out.push_str("        Options Indexes FollowSymLinks\n");
                out.push_str("        AllowOverride All\n");
                out.push_str("        Require all granted\n");
                out.push_str("    </Directory>\n");
                out.push_str("    DirectoryIndex index.php index.html\n");
                out.push_str("    <FilesMatch \\.php$>\n");
                out.push_str(&format!("        SetHandler {}\n", q(&handler)));
                out.push_str("    </FilesMatch>\n");
            }
            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("    SSLEngine on\n");
                out.push_str(&format!(
                    "    SSLCertificateFile {}\n",
                    q(&cert.display().to_string())
                ));
                out.push_str(&format!(
                    "    SSLCertificateKeyFile {}\n",
                    q(&key.display().to_string())
                ));
            }
            out.push_str("</VirtualHost>\n\n");
        }

        // Catch-all default site: the first VirtualHost for a port is Apache's
        // default, so unmatched hosts (e.g. http(s)://localhost:<port>) serve
        // the sites root instead of a 403. Rendered on both HTTP and HTTPS.
        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("    ServerName localhost\n");
                out.push_str(&format!("    DocumentRoot {}\n", q(root)));
                out.push_str(&format!("    <Directory {}>\n", q(root)));
                out.push_str("        Options Indexes FollowSymLinks\n");
                out.push_str("        AllowOverride All\n");
                out.push_str("        Require all granted\n");
                out.push_str("    </Directory>\n");
                out.push_str("    DirectoryIndex index.php index.html\n");
                if let Some(sock) = &php_sock {
                    let handler = format!("proxy:unix:{sock}|fcgi://localhost");
                    out.push_str("    <FilesMatch \\.php$>\n");
                    out.push_str(&format!("        SetHandler {}\n", q(&handler)));
                    out.push_str("    </FilesMatch>\n");
                }
            };
            // HTTP.
            out.push_str(&format!("<VirtualHost *:{}>\n", server.http_port));
            body(&mut out);
            out.push_str("</VirtualHost>\n\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(&format!("<VirtualHost *:{}>\n", server.https_port));
            body(&mut out);
            out.push_str("    SSLEngine on\n");
            out.push_str(&format!(
                "    SSLCertificateFile {}\n",
                q(&cert.display().to_string())
            ));
            out.push_str(&format!(
                "    SSLCertificateKeyFile {}\n",
                q(&key.display().to_string())
            ));
            out.push_str("</VirtualHost>\n\n");
        }
        Ok(out)
    }
}

/// Quote an Apache config 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 Apache {
    fn id(&self) -> Backend {
        Backend::Apache
    }

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

    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::httpd_bin(brew))
            .arg("-t")
            .arg("-f")
            .arg(&path)
            .output()
            .context("Failed to run `httpd -t`")?;
        if !out.status.success() {
            bail!(
                "httpd 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::httpd_bin(brew),
            args: vec![
                "-D".into(),
                "FOREGROUND".into(),
                "-f".into(),
                conf.display().to_string(),
            ],
            log: paths::logs_dir()?.join(format!("server-{}.log", server.name)),
            keep_alive: true,
            run_at_load: true,
        })
    }
}