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;
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> {
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")));
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));
}
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())
));
}
}
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(
"LogFormat \"%{%Y-%m-%dT%H:%M:%S%z}t %v %m \\\"%U%q\\\" %>s %B %{ms}Tms %a\" reeve\n",
);
out.push_str(&format!(
"CustomLog {} reeve\n",
q(&paths::logs_dir()?
.join(format!("server-{}-access.log", server.name))
.display()
.to_string())
));
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");
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 {
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");
}
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");
}
};
out.push_str(&format!("<VirtualHost *:{}>\n", server.http_port));
body(&mut out);
out.push_str("</VirtualHost>\n\n");
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");
}
for v in vhosts {
if !v.ssl {
continue;
}
let authority = super::https_authority(&v.server_name, server.https_port);
out.push_str(&format!("<VirtualHost *:{}>\n", server.http_port));
out.push_str(&format!(" ServerName {}\n", v.server_name));
out.push_str(" RewriteEngine On\n");
out.push_str(&format!(
" RewriteRule ^ https://{authority}%{{REQUEST_URI}} [R=301,L]\n"
));
out.push_str("</VirtualHost>\n\n");
}
Ok(out)
}
}
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,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::{Framework, PhpVersion};
fn server() -> Server {
Server {
name: "apache".into(),
backend: Backend::Apache,
http_port: 80,
https_port: 443,
enabled: true,
default_site: false,
default_preset: Framework::Generic,
default_root: None,
settings: Default::default(),
}
}
fn state_with_php() -> State {
let mut state = State::default();
state.php_versions.push(PhpVersion {
version: "8.3".into(),
fpm_socket: "/run/php83.sock".into(),
..Default::default()
});
state
}
fn ssl_vhost() -> Vhost {
Vhost {
server_name: "app.test".into(),
server: "apache".into(),
docroot: "/Sites/app".into(),
php_version: "8.3".into(),
ssl: true,
preset: Framework::Generic,
proxy_target: None,
}
}
#[test]
fn ssl_vhost_gets_http_redirect_vhost() {
let brew = Brew {
prefix: "/opt/homebrew".into(),
};
let state = state_with_php();
let cfg = Config::default();
let v = ssl_vhost();
let out = Apache::render_conf(&server(), &[&v], &state, &cfg, &brew).unwrap();
assert!(out.contains("<VirtualHost *:443>"));
assert!(out.contains("<VirtualHost *:80>"));
assert!(out.contains("RewriteRule ^ https://app.test%{REQUEST_URI} [R=301,L]"));
let mut alt = server();
alt.http_port = 8080;
alt.https_port = 8443;
let out = Apache::render_conf(&alt, &[&v], &state, &cfg, &brew).unwrap();
assert!(out.contains("<VirtualHost *:8080>"));
assert!(out.contains("RewriteRule ^ https://app.test:8443%{REQUEST_URI} [R=301,L]"));
}
#[test]
fn access_log_uses_reeve_format() {
let brew = Brew {
prefix: "/opt/homebrew".into(),
};
let state = state_with_php();
let cfg = Config::default();
let v = ssl_vhost();
let out = Apache::render_conf(&server(), &[&v], &state, &cfg, &brew).unwrap();
assert!(out.contains(
r#"LogFormat "%{%Y-%m-%dT%H:%M:%S%z}t %v %m \"%U%q\" %>s %B %{ms}Tms %a" reeve"#
));
assert!(out.contains("server-apache-access.log"));
assert!(out.contains("CustomLog"));
}
#[test]
fn non_ssl_vhost_has_no_redirect() {
let brew = Brew {
prefix: "/opt/homebrew".into(),
};
let state = state_with_php();
let cfg = Config::default();
let mut v = ssl_vhost();
v.ssl = false;
let out = Apache::render_conf(&server(), &[&v], &state, &cfg, &brew).unwrap();
assert!(!out.contains("RewriteRule ^ https://"));
}
#[test]
fn default_site_stays_first_port80_vhost_before_redirects() {
let brew = Brew {
prefix: "/opt/homebrew".into(),
};
let state = state_with_php();
let cfg = Config::default();
let mut srv = server();
srv.default_site = true;
let v = ssl_vhost();
let out = Apache::render_conf(&srv, &[&v], &state, &cfg, &brew).unwrap();
let default_at = out.find("ServerName localhost\n DocumentRoot").unwrap();
let redirect_at = out.find("RewriteRule ^ https://").unwrap();
assert!(
default_at < redirect_at,
"default-site catch-all must render before the redirect vhost"
);
}
}