blob: d6b4afed71c8a9e0fe1104733c84399bc6d908e6 [file]
"""httpd configuration generation.
Faithful Python port of the runtime behavior of Apache::TestConfig (plus the
vhost bits of Apache::TestConfigPerl). Responsibilities:
* hold the ``vars`` table (paths, ports, tuning) with the same defaults/derivation
as ``TestConfig.pm:280-383``;
* resolve module-name tokens (``@CGI_MODULE@`` etc.) against the probed module set
(``TestConfig.pm:435-462``);
* expand ``@token@`` placeholders, dying on unknown tokens (``TestConfig.pm:1204``);
* allocate ports for ``<VirtualHost module_name>`` blocks and rewrite them, building
a vhost registry the HTTP client uses to resolve ``module("name")``
(``TestConfig.pm:1245-1351`` + ``TestConfigPerl::new_vhost``);
* generate ``t/conf/httpd.conf`` in the exact section order of
``generate_httpd_conf`` (``TestConfig.pm:1609-1690``).
Only the exercised subset is ported: no Apache 1.3, parrot, mod_perl-build, or
Win/AIX paths.
"""
from __future__ import annotations
import re
import socket
import sys
from collections.abc import Iterator
from dataclasses import dataclass, field
from pathlib import Path
from .probe import HttpdInfo
DEFAULT_PORT = 8529
def _find_perl_pods(perl: str) -> str:
"""Locate perl's 'pods' directory, like Apache::TestConfig's
find_in_inc('pods') (TestConfig.pm:297): the first 'pods' dir under @INC.
Returns the absolute path, or "" if perl is unavailable or has no pods dir
(e.g. a perl without perl-doc installed). Used for the /getfiles-perl-pod
alias that t/filter/case.t downloads from.
"""
if not perl:
return ""
import subprocess
code = "for (@INC){ if (-d qq($_/pods)){ print qq($_/pods); last } }"
try:
out = subprocess.run(
[perl, "-e", code], capture_output=True, text=True, timeout=10
).stdout.strip()
except (OSError, subprocess.SubprocessError):
return ""
return out if out and Path(out).is_dir() else ""
# @CGI_MODULE@ etc. -> first candidate that is actually loaded, else first candidate.
# Mirrors the default_module() calls in TestConfig.pm:435-440.
MODULE_NAME_CANDIDATES: dict[str, list[str]] = {
"cgi": ["mod_cgi", "mod_cgid"],
"thread": ["worker", "threaded"],
"ssl": ["mod_ssl"],
"access": ["mod_access", "mod_authz_host"],
"auth": ["mod_auth", "mod_auth_basic"],
"php": ["sapi_apache2", "mod_php4", "mod_php5", "mod_php7", "mod_php"],
}
_VHOST_RE = re.compile(
r"^(\s*)<VirtualHost\s+(?:_default_:|([^:]+):(?!:))?(.*?)\s*>\s*$",
re.IGNORECASE,
)
_TOKEN_RE = re.compile(r"@(\w+)@")
# Directives that, inside a C-module config block, are emitted straight to the
# postamble as "directive rest" rather than being treated as bare leftover args
# (TestConfigPerl.pm:328-332 %outside_container).
_OUTSIDE_CONTAINER = frozenset(
{
"Alias",
"AliasMatch",
"AddType",
"PerlChildInitHandler",
"PerlTransHandler",
"PerlPostReadRequestHandler",
"PerlSwitches",
"PerlRequire",
"PerlModule",
}
)
# Container tags whose open/close tags are dropped while their content is kept
# (TestConfigPerl.pm:334 %strip_tags). Compared lower-cased.
_STRIP_TAGS = frozenset({"base", "noautoconfig"})
# Matches a container open tag, capturing the tag word, e.g. "VirtualHost" in
# "<VirtualHost mod_x>" (TestConfigPerl.pm:369,432 m/^<(\w+)/ / m/^\s*<(\w+)/).
_CONTAINER_OPEN_RE = re.compile(r"^\s*<(\w+)")
# The base httpd.conf body, captured verbatim from the Perl __DATA__ section of
# TestConfig.pm. Tokens are expanded at generation time. Kept as a literal so the
# generated config is byte-comparable to the Perl output (modulo token values).
BASE_TEMPLATE = """\
Listen 0.0.0.0:@Port@
ServerRoot "@ServerRoot@"
DocumentRoot "@DocumentRoot@"
PidFile @t_pid_file@
ErrorLog @t_logs@/error_log
LogLevel debug
<IfModule mod_version.c>
<IfVersion > 2.4.1>
DefaultRunTimeDir "@t_logs@"
LogLevel trace8
</IfVersion>
<IfVersion > 2.4.34>
<IfDirective DefaultStateDir>
DefaultStateDir "@t_state@"
</IfDirective>
</IfVersion>
</IfModule>
<IfModule mod_log_config.c>
TransferLog @t_logs@/access_log
</IfModule>
<IfModule mod_cgid.c>
ScriptSock @t_logs@/cgisock
</IfModule>
ServerAdmin @ServerAdmin@
#needed for http/1.1 testing
KeepAlive On
HostnameLookups Off
<Directory />
Options FollowSymLinks
AllowOverride None
</Directory>
<IfModule @THREAD_MODULE@>
<IfModule mod_version.c>
<IfVersion < 2.3.4>
LockFile @t_logs@/accept.lock
</IfVersion>
</IfModule>
StartServers @StartServersThreadedMPM@
MinSpareThreads @ThreadsPerChild@
MaxSpareThreads @MaxSpareThreadedMPM@
ThreadsPerChild @ThreadsPerChild@
MaxClients @MaxClientsThreadedMPM@
MaxRequestsPerChild 0
</IfModule>
<IfModule perchild.c>
<IfModule mod_version.c>
<IfVersion < 2.3.4>
LockFile @t_logs@/accept.lock
</IfVersion>
</IfModule>
NumServers 1
StartThreads @MinClients@
MinSpareThreads 1
MaxSpareThreads @MaxSpare@
MaxThreadsPerChild @MaxClients@
MaxRequestsPerChild 0
</IfModule>
<IfModule prefork.c>
<IfModule mod_version.c>
<IfVersion < 2.3.4>
LockFile @t_logs@/accept.lock
</IfVersion>
</IfModule>
StartServers @MinClients@
MinSpareServers 1
MaxSpareServers @MaxSpare@
MaxClients @MaxClients@
MaxRequestsPerChild 0
</IfModule>
<IfDefine APACHE1>
LockFile @t_logs@/accept.lock
StartServers @MinClients@
MinSpareServers 1
MaxSpareServers @MaxSpare@
MaxClients @MaxClients@
MaxRequestsPerChild 0
</IfDefine>
<IfModule mpm_winnt.c>
ThreadsPerChild 50
MaxRequestsPerChild 0
</IfModule>
<Location /server-info>
SetHandler server-info
</Location>
<Location /server-status>
SetHandler server-status
</Location>
"""
@dataclass
class VHost:
"""A configured virtual host and its allocated port."""
module: str
port: int
servername: str
namebased: int = 0
@property
def name(self) -> str:
return f"{self.servername}:{self.port}"
@property
def hostport(self) -> str:
return self.name
class TestConfig:
"""Generates the httpd test configuration tree under a server root."""
def __init__(
self,
*,
info: HttpdInfo,
top_dir: Path,
servername: str = "localhost",
base_port: int = DEFAULT_PORT,
defines: list[str] | None = None,
apxs: Path | None = None,
fpm_port: int | None = None,
) -> None:
self.info = info
self.apxs = apxs # path to apxs, for build-time queries (apxs -q VAR)
# If set, route htdocs/php/*.php to a PHP-FPM daemon on this port via
# mod_proxy_fcgi (mod_php is not built); enables the t/php tests.
self.fpm_port = fpm_port
self.defines = defines or []
self._port_counter = base_port
self.vhosts: dict[str, VHost] = {}
# preamble/postamble accumulators flushed around the template body,
# analogous to TestConfig's add_config_last + preamble_run/postamble_run.
self.preamble: list[str] = []
self.postamble: list[str] = []
self.vars = self._build_vars(top_dir, servername, base_port)
self._resolve_module_name_tokens()
self._apply_inherited()
def _apply_inherited(self) -> None:
"""Apply TAKE1 directives inherited from the install conf (spec_apply).
* ServerAdmin -> override the serveradmin var (apply_take1).
* DocumentRoot -> set inherit_documentroot var (inherit_directive_var);
the test documentroot itself is unchanged.
* TypesConfig -> remember the inherited file so the generated TypesConfig
fallback is suppressed in favor of it (inherit_server_file).
* ServerRoot -> intentionally ignored (spec_apply maps it to a no-op).
"""
inh = self.info.inherited
if "ServerAdmin" in inh:
self.vars["serveradmin"] = inh["ServerAdmin"]
if "DocumentRoot" in inh:
self.vars["inherit_documentroot"] = inh["DocumentRoot"]
# TypesConfig: inherit_server_file resolves a relative path against the
# INSTALL ServerRoot (TestConfigParse). A bare "conf/mime.types" would
# otherwise resolve against the test ServerRoot (python/t) and not exist,
# so make it absolute here. If it can't be resolved to an existing file,
# fall back to generating our own (treat as not-inherited).
tc = inh.get("TypesConfig")
if tc and not Path(tc).is_absolute():
install_root = inh.get("ServerRoot")
if install_root:
tc = str(Path(install_root) / tc)
if tc and not Path(tc).is_file():
tc = None # can't use it; we'll generate our own mime.types instead
self._inherited_typesconfig = tc
# -- vars table -------------------------------------------------------
def _build_vars(self, top_dir: Path, servername: str, base_port: int) -> dict[str, str]:
t_dir = top_dir / "t"
serverroot = t_dir
v: dict[str, str] = {}
v["top_dir"] = str(top_dir)
v["t_dir"] = str(t_dir)
v["serverroot"] = str(serverroot).replace("\\", "/")
v["documentroot"] = v["serverroot"] + "/htdocs"
v["t_conf"] = v["serverroot"] + "/conf"
v["t_logs"] = v["serverroot"] + "/logs"
v["t_state"] = v["serverroot"] + "/state"
v["statedir"] = v["t_state"]
v["t_conf_file"] = v["t_conf"] + "/httpd.conf"
v["t_pid_file"] = v["t_logs"] + "/httpd.pid"
v["sslca"] = v["t_conf"] + "/ssl/ca"
v["sslcaorg"] = "asf"
v["sslproto"] = "all"
v["scheme"] = "http"
v["servername"] = servername
v["port"] = str(base_port)
v["serveradmin"] = f"unknown@{servername}"
v["inherit_documentroot"] = v["documentroot"]
# getfiles-* download aliases (see generate_httpd_conf %aliases). httpd
# is the probed binary; perl is whatever runs the helper scripts.
v["httpd"] = str(self.info.httpd)
from .scripts import default_perl
v["perl"] = default_perl()
# perlpod: a 'pods' dir under @INC, like Perl's find_in_inc('pods')
# (TestConfig.pm:297). Drives the /getfiles-perl-pod alias that
# t/filter/case.t (mod_alias case) downloads from. Left "" if not found,
# in which case the alias is not emitted and that subtest skips.
v["perlpod"] = _find_perl_pods(v["perl"])
# MPM tuning math (TestConfig.pm:331-373) with proxy off (no bump).
tpc = 10
minclients = 1
maxclients = minclients + 1
maxspare = 2 if minclients < 2 else minclients
if maxclients < maxspare + 1:
maxclients = maxspare + 1
def round_up(n: int) -> int:
return (n + tpc - 1) // tpc * tpc
minc_t = round_up(minclients)
maxc_t = round_up(maxclients)
maxspare_t = round_up(maxspare)
if maxspare_t < 2 * tpc:
maxspare_t = 2 * tpc
if maxc_t < maxspare_t + tpc:
maxc_t = maxspare_t + tpc
startservers_t = minc_t // tpc
v["threadsperchild"] = str(tpc)
v["minclients"] = str(minclients)
v["maxclients"] = str(maxclients)
v["maxspare"] = str(maxspare)
v["minclientsthreadedmpm"] = str(minc_t)
v["maxclientsthreadedmpm"] = str(maxc_t)
v["maxsparethreadedmpm"] = str(maxspare_t)
v["startserversthreadedmpm"] = str(startservers_t)
# No explicit maxclients was passed (CLI override); used to gate the
# proxy client bump in _bump_clients_for_proxy (check_vars).
v["maxclients_preset"] = ""
v["limitrequestline"] = "128"
v["limitrequestlinex2"] = str(2 * 128)
v["proxy"] = "off"
v["proxyssl_url"] = ""
v["defines"] = " ".join(self.defines)
return v
def _resolve_module_name_tokens(self) -> None:
"""Set ``<name>_module_name`` / ``<name>_module`` vars (TestConfig.pm:447-462)."""
for name, candidates in MODULE_NAME_CANDIDATES.items():
chosen = next((c for c in candidates if self.info.has_module(c)), candidates[0])
self.vars[f"{name}_module_name"] = chosen
self.vars[f"{name}_module"] = f"{chosen}.c"
# -- ports ------------------------------------------------------------
@staticmethod
def _port_available(port: int) -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
s.bind(("0.0.0.0", port))
except OSError:
return False
return True
def select_next_port(self) -> int:
"""Return the next free port above the counter (TestServer::select_next_port)."""
while True:
self._port_counter += 1
if self._port_available(self._port_counter):
return self._port_counter
# -- token expansion --------------------------------------------------
def expand(self, text: str, *, source: str = "") -> str:
"""Expand ``@token@`` placeholders; raise on unknown (TestConfig.pm:1204)."""
def repl(m: re.Match[str]) -> str:
key = m.group(1).lower()
if key == "nextavailableport":
return str(self.select_next_port())
if key in self.vars:
return self.vars[key]
where = f" in {source}" if source else ""
raise KeyError(f"invalid token: @{m.group(1)}@{where}")
return _TOKEN_RE.sub(repl, text)
# -- vhost allocation -------------------------------------------------
def _maybe_rewrite_vhost(self, line: str) -> str | None:
"""If ``line`` opens a ``<VirtualHost module>``, allocate a port and rewrite it.
Returns the replacement text (which may emit an outer ``Listen`` to the
postamble as a side effect) or ``None`` if the line is not a vhost open
tag (or the vhost's module is not loaded, in which case the block is kept
but no port is allocated — matching the Perl behavior of returning undef,
leaving the literal line, relying on the surrounding ``<IfModule>``).
"""
m = _VHOST_RE.match(line)
if not m:
return None
indent = m.group(1) or ""
namebased = m.group(2) or ""
module = m.group(3)
# mod_foo_ssl vhosts map to mod_foo.c presence + ssl loaded.
have = f"{module}.c"
ssl_mod = self.vars.get("ssl_module", "mod_ssl.c")
ms = re.match(r"^(mod_\w+)_ssl$", module)
if ms and have != ssl_mod:
have = f"{ms.group(1)}.c"
if not self.info.has_module(ssl_mod):
return None
# don't allocate for an unloaded mod_ vhost (relies on enclosing IfModule)
if module.startswith("mod_") and not self.info.has_module(have):
return None
port, namebased_count = self._new_vhost(module, bool(namebased))
self.vars[f"{module}_port"] = str(port)
# Apache 2.x servername_config form 2: inside the vhost, emit only
# "ServerName name:port" (the standalone "Port" directive was removed in
# 2.0). See TestConfig.pm:1234-1237.
servername = namebased or self.vars["servername"]
inner = f"{indent} ServerName {servername}:{port}"
open_tag = f"{indent}<VirtualHost {'*' if namebased else '_default_'}:{port}>"
# Listen (and, pre-2.3.11, NameVirtualHost) is emitted outside the block
# only while namebased < 2 — i.e. once per shared name-based port, or
# always for default vhosts. Mirrors parse_vhost (TestConfig.pm:1287-1296).
out: list[str] = []
if namebased_count < 2:
out.append(f"{indent}Listen 0.0.0.0:{port}")
if namebased:
out.append(
f"<IfVersion < 2.3.11>\n"
f"{indent}{indent}NameVirtualHost *:{port}\n"
f"{indent}</IfVersion>"
)
return "\n".join([*out, open_tag, inner])
def _new_vhost(self, module: str, namebased: bool) -> tuple[int, int]:
"""Allocate or reuse a vhost port; return (port, post-increment namebased count).
Name-based vhosts sharing a module name reuse one port and bump the
namebased counter (TestConfigPerl::new_vhost). Default vhosts always get
a fresh port and a namebased count of 0.
"""
existing = self.vhosts.get(module)
if namebased and existing is not None:
existing.namebased += 1
return existing.port, existing.namebased
port = self.select_next_port()
self.vhosts[module] = VHost(
module=module,
port=port,
servername=self.vars["servername"],
namebased=1 if namebased else 0,
)
return port, self.vhosts[module].namebased
# -- conf.in processing ----------------------------------------------
def process_conf_in(self, conf_in: Path) -> Path:
"""Expand one ``*.conf.in`` to ``*.conf``; allocate vhost ports in-pass."""
out_lines: list[str] = []
# Split ONLY on newline — some configs embed literal control chars such
# as a vertical tab (0x0B) inside directive values (see the http_strict
# regression-header test). Python's str.splitlines() would break on those
# and corrupt the config; the Perl `while (<$in>)` reads newline-delimited
# records only.
text = conf_in.read_text()
if text.endswith("\n"):
text = text[:-1] # avoid a spurious trailing blank line after split
for raw in text.split("\n"):
# Match Perl order: replace (token expand) THEN replace_vhost_modules.
# Some vhost open-tags carry tokens in the module name itself, e.g.
# `<VirtualHost default:@ssl_module_name@>`, so tokens must be expanded
# before the vhost line is matched/rewritten.
expanded = self.expand(raw, source=str(conf_in))
rewritten = self._maybe_rewrite_vhost(expanded)
out_lines.append(rewritten if rewritten is not None else expanded)
out_path = conf_in.with_suffix("") # strip ".in" -> ".conf"
out_path.write_text("\n".join(out_lines) + "\n", newline="\n")
return out_path
def conf_in_files(self) -> list[Path]:
"""All ``*.conf.in`` under ``t/conf``, SSL first (TestConfig.pm:1486-1493)."""
conf_dir = Path(self.vars["t_conf"])
files = sorted(conf_dir.rglob("*.conf.in"))
ssl = [f for f in files if f.name.startswith("ssl")]
rest = [f for f in files if not f.name.startswith("ssl")]
return ssl + rest
# -- supporting files -------------------------------------------------
MIME_TYPES = (
"text/html html htm\n"
"image/gif gif\n"
"image/jpeg jpeg jpg jpe\n"
"image/png png\n"
"text/plain asc txt\n"
)
def _ensure_dirs(self) -> None:
for key in ("t_conf", "t_logs", "t_state", "documentroot"):
Path(self.vars[key]).mkdir(parents=True, exist_ok=True)
def _generate_supporting_files(self) -> None:
"""mime.types + index.html, matching generate_types_config/generate_index_html."""
# generate_types_config (TestConfig.pm:1396-1408): if the install conf
# inherited a TypesConfig, re-emit it (resolved to an absolute path in
# _apply_inherited) so mod_mime finds a real file; otherwise generate our
# own mime.types. Either way we emit an explicit TypesConfig so mod_mime's
# default (conf/mime.types relative to the test ServerRoot) is overridden.
inherited_tc = getattr(self, "_inherited_typesconfig", None)
if inherited_tc:
self.postamble.append(
f'<IfModule mod_mime.c>\n TypesConfig "{inherited_tc}"\n</IfModule>'
)
else:
mime = Path(self.vars["t_conf"]) / "mime.types"
if not mime.exists():
mime.write_text(self.MIME_TYPES, newline="\n")
self.postamble.append(
f'<IfModule mod_mime.c>\n TypesConfig "{mime}"\n</IfModule>'
)
# index_html_template: "welcome to <server name>" where the server name
# is servername:port (TestConfig.pm:1364-1366 + new_test_server name).
index = Path(self.vars["documentroot"]) / "index.html"
if not index.exists():
index.write_text(
f"welcome to {self.vars['servername']}:{self.vars['port']}\n",
newline="\n",
)
def _load_module_preamble(self, name: str, so: Path) -> None:
"""Append a guarded LoadModule (find_and_load_module, TestConfig.pm:1329)."""
so_fwd = str(so).replace("\\", "/")
self.preamble.append(
f'<IfModule !mod_{name}.c>\n'
f' LoadModule {name}_module "{so_fwd}"\n'
f'</IfModule>'
)
def _find_and_load_fallback(self, mod: str) -> None:
"""Defensively LoadModule a shared mod_X.so if available but not built-in.
Mirrors find_and_load_module (TestConfig.pm:1329): emits a guarded
LoadModule into the preamble. We reuse the inherited load directive's .so
path when present; otherwise scan the install modules directory.
"""
sym = mod[len("mod_"):] if mod.startswith("mod_") else mod
cname = f"mod_{sym}.c"
for d in self.info.load_directives:
if d.cname == cname and Path(d.so).exists():
self._load_module_preamble(sym, Path(d.so))
self.info.modules.add(cname)
return
modules_dir = self.info.httpd.parent.parent / "modules"
so = modules_dir / f"mod_{sym}.so"
if so.exists():
self._load_module_preamble(sym, so)
self.info.modules.add(cname)
# %aliases map (TestConfig.pm:1604): label -> var holding the path.
_GETFILES_ALIASES = {
"perl-pod": "perlpod",
"binary-httpd": "httpd",
"binary-perl": "perl",
}
def _getfiles_aliases(self) -> str:
"""Emit the <IfModule mod_alias.c> getfiles-* download aliases (sorted)."""
lines = ["<IfModule mod_alias.c>"]
for label in sorted(self._GETFILES_ALIASES):
var = self._GETFILES_ALIASES[label]
val = self.vars.get(var)
if val:
lines.append(f' Alias /getfiles-{label} "{val}"')
lines.append("</IfModule>")
return "\n".join(lines)
def _php_fpm_config(self) -> str:
"""Route htdocs/php/*.php to PHP-FPM via mod_proxy_fcgi.
Emitted only when a PHP-FPM port is configured (mod_php is not built).
This makes the t/php tests resolve: any .php under the php docroot is
handled by the FPM backend, which reads SCRIPT_FILENAME (passed through
by ProxyFCGIBackendType FPM) to locate the script on disk.
"""
if self.fpm_port is None:
return ""
php_root = f"{self.vars['documentroot']}/php"
return (
"<IfModule mod_proxy_fcgi.c>\n"
f" <Directory \"{php_root}\">\n"
" <FilesMatch \\.php$>\n"
f" SetHandler proxy:fcgi://127.0.0.1:{self.fpm_port}\n"
" </FilesMatch>\n"
" ProxyFCGIBackendType FPM\n"
" DirectoryIndex index.php\n"
" </Directory>\n"
"</IfModule>"
)
def _check_vars(self) -> None:
"""Set late-bound vars after a conf file is parsed (check_vars).
proxyssl_url defaults to the SSL module's vhost hostport once that vhost
has been allocated; setting it also bumps the client/thread tuning to
allow for the extra backend connections proxy-over-SSL needs
(TestConfig.pm:1433-1456).
"""
if not self.vars.get("proxyssl_url"):
ssl_name = self.vars.get("ssl_module_name", "mod_ssl")
ssl_vhost = self.vhosts.get(ssl_name)
if ssl_vhost is not None:
self.vars["proxyssl_url"] = ssl_vhost.hostport
self._bump_clients_for_proxy()
def _bump_clients_for_proxy(self) -> None:
"""Bump client/thread counts for proxy-to-self (check_vars/configure_proxy).
Applied once when proxyssl_url (or proxy) is enabled, unless maxclients
was preset. Mirrors TestConfig.pm:1438-1453.
"""
if self.vars.get("maxclients_preset"):
return
tpc = int(self.vars["threadsperchild"])
def inc(key: str, by: int = 1) -> None:
self.vars[key] = str(int(self.vars[key]) + by)
inc("minclients")
inc("maxclients")
inc("maxspare")
inc("startserversthreadedmpm")
inc("minclientsthreadedmpm", tpc)
inc("maxclientsthreadedmpm", tpc)
inc("maxsparethreadedmpm", tpc)
# plus headroom for backend processes in keep-alive
inc("maxclients", 3)
def _maybe_generate_sslca(self) -> None:
"""Build the SSL CA tree if mod_ssl is loaded and t/conf/ssl exists."""
if not self.info.has_module("mod_ssl"):
return
ssl_dir = Path(self.vars["t_conf"]) / "ssl"
if not ssl_dir.is_dir():
return
from .sslca import SSLCA
SSLCA(Path(self.vars["sslca"]), self.vars["servername"]).generate()
def _emit_inherited_loadmodules(self) -> None:
"""Re-emit inherited (active) LoadModule lines (inherit_load_module).
Each is wrapped in ``<IfModule !name>`` so a module already built-in
isn't double-loaded. Only active directives reach here (the probe drops
commented ones), so MPM exclusivity is the install conf's concern — it
must keep a single MPM active, as the suite's build does.
"""
from pathlib import Path as _Path
for d in self.info.load_directives:
if not _Path(d.so).exists():
continue
so = d.so.replace("\\", "/")
self.preamble.append(
f"<IfModule !{d.cname}>\n"
f' LoadModule {d.symbol} "{so}"\n'
f"</IfModule>"
)
# -- C-module embedded config ----------------------------------------
def add_module_config(self, c_source: Path, args: list[str]) -> None:
"""Extract a C module's ``#if CONFIG_FOR_HTTPD_TEST`` block to the postamble.
Faithful port of ``add_module_config`` (TestConfigPerl.pm:337-385). The
block holds httpd configuration (typically ``<VirtualHost mod_X>`` with
directives) that is appended to ``httpd.conf`` with the same ``@token@``
expansion and ``<VirtualHost module>`` -> port-allocation rewriting that
``.conf.in`` files get.
Lines are consumed as a stream so nested-container helpers can keep
reading the same iterator (mirroring the Perl ``<$fh>`` filehandle).
Bare directives (not opening a container, not in ``%outside_container``)
are appended to ``args``, which the caller flushes once after all
modules (TestConfigC.pm:295-313).
"""
text = c_source.read_text(errors="replace")
if text.endswith("\n"):
text = text[:-1]
lines = iter(text.split("\n"))
# Skip until the start marker (TestConfigPerl.pm:342-344).
for line in lines:
if re.match(r"^(__(DATA|END)__|\#if CONFIG_FOR_HTTPD_TEST)", line):
break
else:
return # no config block in this module
# Body: read until ^#endif (TestConfigPerl.pm:348-382).
for line in lines:
if re.match(r"^\#endif", line):
break
if not re.search(r"\S", line): # skip blank lines (next unless /\S+/)
continue
line = self.expand(line.lstrip(), source=str(c_source))
if line.startswith("#"):
self.postamble.append(line) # preserve comments
continue
parts = re.split(r"\s+", line, maxsplit=1)
directive = parts[0]
rest = parts[1] if len(parts) > 1 else ""
if directive in _OUTSIDE_CONTAINER:
# postamble($directive => $rest) -> "directive rest"
self.postamble.append(" ".join(p for p in (directive, rest) if p))
elif "IfModule" in directive:
self.postamble.append(line)
elif (m := _CONTAINER_OPEN_RE.match(line)) is not None:
tag = m.group(1).lower()
self._process_container(line, lines, tag, tag in _STRIP_TAGS, "")
else:
args.extend([directive, rest])
def _process_container(
self,
first_line: str,
lines: "Iterator[str]",
directive: str,
strip_container: bool,
indent: str,
) -> None:
"""Emit a container (recursively) to the postamble.
Port of ``process_container`` (TestConfigPerl.pm:390-416): nested content
is re-indented by 4 spaces and the closing tag's first letter is
upper-cased. ``%strip_tags`` containers drop their open/close tags but
keep the body.
"""
new_indent = indent
if not strip_container:
new_indent = indent + " "
line = self.expand(first_line.lstrip(), source="<module config>")
if line.lower().startswith("<virtualhost"):
self._process_vhost_open_tag(line, indent)
else:
self.postamble.append(indent + line)
self._process_container_remainder(lines, directive, new_indent)
if not strip_container:
closing = directive[:1].upper() + directive[1:]
self.postamble.append(f"{indent}</{closing}>")
def _process_container_remainder(
self, lines: "Iterator[str]", directive: str, indent: str
) -> None:
"""Emit a container body up to (and consuming) its end tag.
Port of ``process_container_remainder`` (TestConfigPerl.pm:421-439).
"""
end_tag = f"</{directive}"
for line in lines:
if re.match(rf"^\s*{re.escape(end_tag)}", line, re.IGNORECASE):
break
line = self.expand(line.lstrip(), source="<module config>")
if (m := _CONTAINER_OPEN_RE.match(line)) is not None:
self._process_container(line, lines, m.group(1), False, indent)
else:
self.postamble.append(indent + line)
def _process_vhost_open_tag(self, line: str, indent: str) -> None:
"""Emit a ``<VirtualHost module>`` open tag with port allocation.
Port of ``process_vhost_open_tag`` (TestConfigPerl.pm:442-455): reuses
``_maybe_rewrite_vhost`` (the same parse_vhost path used for ``.conf.in``
files) so module vhosts get a Listen + ServerName + ``<VirtualHost
_default_:port>`` and the ``<module>_port`` var / ``vhosts`` registry are
updated. If the vhost's module is not loaded, the literal line is kept.
"""
rewritten = self._maybe_rewrite_vhost(line)
if rewritten is not None:
self.postamble.append(rewritten)
else:
self.postamble.append(f"{indent}{line}")
# -- main conf assembly ----------------------------------------------
def generate(self, cmodule_loads: list[tuple[str, Path]] | None = None) -> Path:
"""Generate the full config tree and return the path to httpd.conf.
:param cmodule_loads: ``(symbol, so_path)`` pairs for compiled C modules,
emitted as LoadModule lines in the preamble.
"""
self._ensure_dirs()
self._generate_supporting_files()
# Generate helper scripts from *.PL templates (CGI scripts, rewrite-map
# programs, etc.) that the config references by their generated names.
from .scripts import default_perl, generate_pl_scripts
generate_pl_scripts(Path(self.vars["t_dir"]), perl=default_perl())
# Generate the SSL CA + certs when mod_ssl is available and a ssl/ conf
# dir exists (mirrors sslca_can/sslca_generate, TestConfig.pm:1524-1554).
self._maybe_generate_sslca()
# Re-emit inherited LoadModule directives so the test server actually
# loads modules (the generated conf does not Include the install conf).
# Mirrors inherit_load_module (TestConfigParse.pm:213): wrap each in
# <IfModule !name> so built-in modules don't double-load. Among MPMs,
# only emit the one the binary reports active (they are mutually
# exclusive); other commented MPM lines are skipped.
self._emit_inherited_loadmodules()
# C module LoadModules into the preamble, and each module's embedded
# `#if CONFIG_FOR_HTTPD_TEST` block extracted to the postamble with vhost
# port allocation (TestConfigC::cmodules_httpd_conf, TestConfigC.pm:292-314).
# This runs BEFORE the .conf.in pass so module vhosts get ports allocated
# in the same relative order as Perl (cmodules_configure precedes
# generate_httpd_conf in TestRun.pm:501-502). The bare leftover directives
# accumulate across modules and are flushed once at the end.
cmodule_args: list[str] = []
for sym, so in cmodule_loads or []:
so_fwd = str(so).replace("\\", "/")
self.preamble.append(f'LoadModule {sym}_module "{so_fwd}"')
# Register the module in the modules set so <VirtualHost mod_X>
# rewriting recognizes it (TestConfigC.pm:308 $self->{modules}{$cname}=1).
self.info.modules.add(f"mod_{sym}.c")
# so is <src_dir>/.libs/mod_<sym>.so (apxs) or <prefix>/modules/ (CMake).
c_source = so.parent.parent / f"mod_{sym}.c"
if not c_source.is_file():
c_source = Path(self.vars["top_dir"]) / "c-modules" / sym / f"mod_{sym}.c"
if c_source.is_file():
self.add_module_config(c_source, cmodule_args)
if cmodule_args:
# postamble(\@args): join the flat directive/rest list with newlines
# (massage_config_args ARRAY branch, TestConfig.pm:612-614).
self.postamble.append("\n".join(cmodule_args))
# Process every .conf.in (SSL first). This allocates vhost ports and
# populates *_port vars BEFORE we assemble httpd.conf, and yields the
# list of generated files to Include. After each file we run check_vars
# so late-bound vars (e.g. proxyssl_url, derived from the ssl vhost's
# hostport once ssl.conf.in is parsed) are available to later files.
generated: list[Path] = []
for f in self.conf_in_files():
generated.append(self.process_conf_in(f))
self._check_vars()
for g in sorted(generated):
g_fwd = str(g).replace("\\", "/")
self.postamble.append(f'Include "{g_fwd}"')
# mod_mime/mod_alias may be shared and absent from the system conf; load
# them defensively. Order matches Perl: generate_types_config loads
# mod_mime first (TestConfig.pm:1394), then mod_alias (TestConfig.pm:1635).
self._find_and_load_fallback("mod_mime")
self._find_and_load_fallback("mod_alias")
# httpd-built test/experimental modules not in the default httpd.conf
for mod in ("mod_bucketeer", "mod_case_filter", "mod_case_filter_in",
"mod_echo", "mod_log_debug", "mod_reflector", "mod_data"):
self._find_and_load_fallback(mod)
# On Windows, tell mod_cgi to read the #! shebang line instead of
# using the Registry file association to find the script interpreter.
if sys.platform == "win32":
self.postamble.append(
"<IfModule mod_cgi.c>\n"
" ScriptInterpreterSource Script\n"
"</IfModule>"
)
# Assemble httpd.conf in generate_httpd_conf order (TestConfig.pm:1609-1690).
parts: list[str] = []
parts.extend(self.preamble)
# ServerName <name>:<port> (2.x servername_config form 2)
parts.append(f"ServerName {self.vars['servername']}:{self.vars['port']}")
parts.append(self.expand(BASE_TEMPLATE, source="<base template>"))
# getfiles-* aliases for downloadable files (generate_httpd_conf %aliases).
parts.append(self._getfiles_aliases())
if (php_fpm := self._php_fpm_config()):
parts.append(php_fpm)
parts.extend(self.postamble)
conf = Path(self.vars["t_conf_file"])
conf.write_text("\n".join(parts) + "\n", newline="\n")
return conf