mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 02:54:26 +00:00
The checker matches accepted codes by hostname, and a clearnet vhost shares only its leftmost label with its onion twin, so --accept-status mirror.<primary>=404 never applied to mirror.<host>.onion. That vhost serves the same by-design 404, so the probe called it unreachable and systemd failed the service -- with the identical message the missing flag produced, while the status had already moved from 2/INVALIDARGUMENT to 1/FAILURE. --skip-domain carried this derivation all along, in is_skipped_domain; replacing it with --accept-status dropped it. The expansion now runs on the same label rule, in the wrapper rather than the lookup, because the onion address does not exist yet when the unit is rendered -- only the wrapper sees both names, and it reads them from the nginx configs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
382 lines
12 KiB
Python
Executable File
382 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
import argparse
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
DOMAIN_FROM_FILENAME_RE = re.compile(r"^([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\.conf$")
|
|
|
|
LISTEN_443_RE = re.compile(r"^\s*listen\s+[^;]*\b443\b", re.IGNORECASE)
|
|
LISTEN_80_RE = re.compile(r"^\s*listen\s+[^;]*\b80\b", re.IGNORECASE)
|
|
LISTEN_SSL_RE = re.compile(r"^\s*listen\s+[^;]*\bssl\b", re.IGNORECASE)
|
|
|
|
|
|
def extract_domains_from_filenames(config_path: str) -> list[str] | None:
|
|
"""
|
|
Extract domain names from .conf filenames in the given directory.
|
|
|
|
Example:
|
|
baserow.<primary-domain>.conf -> baserow.<primary-domain>
|
|
"""
|
|
try:
|
|
out: list[str] = []
|
|
for fn in os.listdir(config_path):
|
|
if not fn.endswith(".conf"):
|
|
continue
|
|
if not DOMAIN_FROM_FILENAME_RE.match(fn):
|
|
continue
|
|
out.append(fn.removesuffix(".conf"))
|
|
except FileNotFoundError:
|
|
print(f"Directory {config_path} not found.", file=sys.stderr)
|
|
return None
|
|
return out
|
|
|
|
|
|
def split_onion_domains(domains: list[str]) -> tuple[list[str], list[str]]:
|
|
"""Split into (clearnet, onion): onion vhosts need a SOCKS proxy to probe."""
|
|
clearnet = [d for d in domains if not d.endswith(".onion")]
|
|
onion = [d for d in domains if d.endswith(".onion")]
|
|
return clearnet, onion
|
|
|
|
|
|
def is_skipped_domain(domain: str, skip_set: set[str], skip_labels: set[str]) -> bool:
|
|
"""Whether a domain should be excluded from the CSP probe.
|
|
|
|
A domain is skipped if it is listed explicitly in ``--skip-domain``, or if
|
|
it is the ``.onion`` sibling of a skipped clearnet vhost. Clearnet and onion
|
|
vhosts of the same app share the leftmost subdomain label (``mirror`` for
|
|
both ``mirror.<primary-domain>`` and ``mirror.<host>.onion``), so a
|
|
``--skip-domain mirror.<primary-domain>`` also drops ``mirror.<host>.onion``
|
|
— which serves the same 4xx at ``/`` and would otherwise fail the probe.
|
|
"""
|
|
if domain in skip_set:
|
|
return True
|
|
return domain.endswith(".onion") and domain.split(".", 1)[0] in skip_labels
|
|
|
|
|
|
def expand_accept_status(accept_status: list[str], domains: list[str]) -> list[str]:
|
|
"""ACCEPT_STATUS plus the same codes for every ``.onion`` sibling it covers.
|
|
|
|
The checker matches accepted codes by hostname, and a clearnet vhost and
|
|
its onion twin share only the leftmost subdomain label, so a code declared
|
|
for ``mirror.<primary-domain>`` never reaches ``mirror.<host>.onion`` —
|
|
which serves that same by-design status and would fail the probe.
|
|
|
|
Args:
|
|
accept_status: ``<domain>=<code>[,<code>]`` entries as declared.
|
|
domains: every vhost the probe covers, clearnet and onion alike.
|
|
"""
|
|
onion = [d for d in domains if d.endswith(".onion")]
|
|
expanded = list(accept_status)
|
|
for entry in accept_status:
|
|
host, _, codes = entry.partition("=")
|
|
if not codes or host.endswith(".onion"):
|
|
continue
|
|
label = host.split(".", 1)[0]
|
|
expanded.extend(
|
|
f"{sibling}={codes}"
|
|
for sibling in onion
|
|
if sibling.split(".", 1)[0] == label
|
|
)
|
|
return expanded
|
|
|
|
|
|
def detect_scheme_from_conf(conf_path: Path) -> str | None:
|
|
"""
|
|
Decide whether this conf listens on HTTP or HTTPS.
|
|
|
|
Rule:
|
|
- If HTTPS is present -> return "https"
|
|
- Else if HTTP present -> return "http"
|
|
- Else -> None (unknown)
|
|
|
|
Note:
|
|
This is intentionally simple and "best effort".
|
|
"""
|
|
try:
|
|
text = conf_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
except FileNotFoundError:
|
|
return None
|
|
except Exception as exc:
|
|
print(f"Failed to read {conf_path}: {exc}", file=sys.stderr)
|
|
return None
|
|
|
|
has_443 = False
|
|
has_80 = False
|
|
has_ssl = False
|
|
|
|
for line in text:
|
|
stripped = line.strip()
|
|
if not stripped or stripped.startswith("#"):
|
|
continue
|
|
|
|
if LISTEN_443_RE.search(line):
|
|
has_443 = True
|
|
if LISTEN_SSL_RE.search(line):
|
|
has_ssl = True
|
|
if LISTEN_80_RE.search(line):
|
|
has_80 = True
|
|
|
|
if has_443 or has_ssl:
|
|
return "https"
|
|
if has_80:
|
|
return "http"
|
|
return None
|
|
|
|
|
|
def build_urls_from_nginx_confs(config_path: str, domains: list[str]) -> list[str]:
|
|
"""
|
|
Build full URLs (http:// or https://) for each domain by inspecting its .conf file.
|
|
"""
|
|
base = Path(config_path)
|
|
urls: list[str] = []
|
|
|
|
for domain in domains:
|
|
conf = base / f"{domain}.conf"
|
|
scheme = detect_scheme_from_conf(conf)
|
|
|
|
if scheme is None:
|
|
print(
|
|
f"Warning: Could not detect scheme from {conf}. Falling back to http://{domain}/",
|
|
file=sys.stderr,
|
|
)
|
|
scheme = "http"
|
|
|
|
urls.append(f"{scheme}://{domain}/")
|
|
|
|
return urls
|
|
|
|
|
|
def build_docker_cmd(
|
|
image: str,
|
|
urls: list[str],
|
|
short_mode: bool,
|
|
ignore_network_blocks_from: list[str],
|
|
use_host_network: bool = True,
|
|
proxy: str = "",
|
|
timeout_ms: int = 0,
|
|
accept_status: list[str] | None = None,
|
|
) -> list[str]:
|
|
cmd = ["container", "run", "--rm"]
|
|
|
|
if use_host_network:
|
|
cmd.extend(["--network", "host"])
|
|
|
|
# Exception: runs as root because with-ca-trust.sh installs the CA into the
|
|
# container trust store, which an unprivileged user cannot write to.
|
|
cmd.extend(["--user", "0:0"])
|
|
|
|
cmd.append(image)
|
|
|
|
if short_mode:
|
|
cmd.append("--short")
|
|
|
|
if proxy:
|
|
cmd.extend(["--proxy", proxy])
|
|
|
|
if timeout_ms:
|
|
cmd.extend(["--timeout", str(timeout_ms)])
|
|
|
|
if ignore_network_blocks_from:
|
|
cmd.append("--ignore-network-blocks-from")
|
|
cmd.extend(ignore_network_blocks_from)
|
|
|
|
if accept_status:
|
|
cmd.append("--accept-status")
|
|
cmd.extend(accept_status)
|
|
|
|
if ignore_network_blocks_from or accept_status:
|
|
cmd.append("--")
|
|
|
|
cmd.extend(urls)
|
|
return cmd
|
|
|
|
|
|
def run_checker(
|
|
image: str,
|
|
urls: list[str],
|
|
short_mode: bool,
|
|
ignore_network_blocks_from: list[str],
|
|
always_pull: bool,
|
|
use_host_network: bool = True,
|
|
proxy: str = "",
|
|
timeout_ms: int = 0,
|
|
accept_status: list[str] | None = None,
|
|
) -> int:
|
|
"""
|
|
Runs the CSP checker container and returns its exit code.
|
|
Always uses run.
|
|
"""
|
|
if always_pull:
|
|
subprocess.run(["container", "pull", image], check=False)
|
|
|
|
cmd = build_docker_cmd(
|
|
image=image,
|
|
urls=urls,
|
|
short_mode=short_mode,
|
|
ignore_network_blocks_from=ignore_network_blocks_from,
|
|
use_host_network=use_host_network,
|
|
proxy=proxy,
|
|
timeout_ms=timeout_ms,
|
|
accept_status=accept_status,
|
|
)
|
|
|
|
try:
|
|
result = subprocess.run(cmd, check=False)
|
|
return int(result.returncode)
|
|
except FileNotFoundError:
|
|
print("run not found. Please install it.", file=sys.stderr)
|
|
return 127
|
|
except Exception as exc:
|
|
print(f"Unexpected error: {exc}", file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(
|
|
description="Extract NGINX domains and build URL(s) (http/https) from listen directives, then run CSP checker (Docker)."
|
|
)
|
|
parser.add_argument(
|
|
"--nginx-config-dir",
|
|
required=True,
|
|
help="Directory containing NGINX .conf files",
|
|
)
|
|
parser.add_argument(
|
|
"--image",
|
|
required=True,
|
|
help="Docker image to run (e.g. ghcr.io/kevinveenbirkenbach/csp-checker:stable)",
|
|
)
|
|
parser.add_argument(
|
|
"--always-pull",
|
|
action="store_true",
|
|
help="Pull the container image before running (best-effort).",
|
|
)
|
|
parser.add_argument(
|
|
"--short",
|
|
action="store_true",
|
|
help="Enable short mode (one example per policy/type).",
|
|
)
|
|
parser.add_argument(
|
|
"--ignore-network-blocks-from",
|
|
nargs="*",
|
|
default=[],
|
|
help="Optional: domains whose network block failures should be ignored",
|
|
)
|
|
parser.add_argument(
|
|
"--no-host-network",
|
|
action="store_true",
|
|
help="Disable --network host for container run (default is to use host network).",
|
|
)
|
|
parser.add_argument(
|
|
"--skip-domain",
|
|
nargs="*",
|
|
default=[],
|
|
help=(
|
|
"Domains to exclude from the CSP probe. Used for roles whose "
|
|
"server.status_codes.default permits a 4xx/5xx response at `/` "
|
|
"(e.g. federation-only apps), which the csp-checker would "
|
|
"otherwise treat as unreachable."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--accept-status",
|
|
nargs="*",
|
|
default=[],
|
|
help=(
|
|
"Per-vhost status codes the probe must treat as healthy, as "
|
|
"<domain>=<code>[,<code>]. Declared in the role's "
|
|
"server.status_codes; everything below 300 passes without being "
|
|
"listed."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--onion-timeout",
|
|
type=int,
|
|
default=0,
|
|
help=(
|
|
"Navigation budget in milliseconds for the .onion batch, which "
|
|
"reaches its vhosts over Tor and needs longer than the checker's "
|
|
"own default. 0 leaves that default in place."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--tor-proxy",
|
|
default="",
|
|
help=(
|
|
"SOCKS proxy for probing .onion vhosts (e.g. "
|
|
"socks5://127.0.0.1:9050). Without it, .onion domains are "
|
|
"skipped — the checker cannot resolve them over plain DNS."
|
|
),
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
domains = extract_domains_from_filenames(args.nginx_config_dir)
|
|
if domains is None:
|
|
sys.exit(1)
|
|
|
|
if not domains:
|
|
print("No domains found to check.")
|
|
sys.exit(0)
|
|
|
|
skip_set = {d for d in (args.skip_domain or []) if d}
|
|
if skip_set:
|
|
skip_labels = {d.split(".", 1)[0] for d in skip_set}
|
|
skipped_present = sorted(
|
|
d for d in domains if is_skipped_domain(d, skip_set, skip_labels)
|
|
)
|
|
domains = [
|
|
d for d in domains if not is_skipped_domain(d, skip_set, skip_labels)
|
|
]
|
|
if skipped_present:
|
|
print(
|
|
f"Skipping {len(skipped_present)} domain(s) per "
|
|
f"--skip-domain: {skipped_present}"
|
|
)
|
|
|
|
accept_status = expand_accept_status(list(args.accept_status or []), domains)
|
|
|
|
clearnet_domains, onion_domains = split_onion_domains(domains)
|
|
if onion_domains and not args.tor_proxy:
|
|
print(
|
|
f"Skipping {len(onion_domains)} .onion domain(s) — no --tor-proxy "
|
|
f"given: {sorted(onion_domains)}"
|
|
)
|
|
onion_domains = []
|
|
|
|
if not clearnet_domains and not onion_domains:
|
|
print("No domains left to check after applying --skip-domain.")
|
|
sys.exit(0)
|
|
|
|
rc = 0
|
|
for batch_domains, batch_proxy, batch_timeout in (
|
|
(clearnet_domains, "", 0),
|
|
(onion_domains, args.tor_proxy, args.onion_timeout),
|
|
):
|
|
if not batch_domains:
|
|
continue
|
|
urls = build_urls_from_nginx_confs(args.nginx_config_dir, batch_domains)
|
|
if not urls:
|
|
print(f"No URLs built to check for: {sorted(batch_domains)}")
|
|
continue
|
|
batch_rc = run_checker(
|
|
image=args.image,
|
|
urls=urls,
|
|
short_mode=bool(args.short),
|
|
ignore_network_blocks_from=list(args.ignore_network_blocks_from or []),
|
|
always_pull=bool(args.always_pull),
|
|
use_host_network=not bool(args.no_host_network),
|
|
proxy=batch_proxy,
|
|
timeout_ms=batch_timeout,
|
|
accept_status=accept_status,
|
|
)
|
|
rc = rc or batch_rc
|
|
sys.exit(rc)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|