diff --git a/PARITY.md b/PARITY.md index 7161982..987e941 100644 --- a/PARITY.md +++ b/PARITY.md @@ -28,4 +28,30 @@ action to its bash source and the behaviors that must match. - No arg; identity from FQDN + node.env. - restic core service gains read-only /data/@tier1,2 mounts (makes existing backup command functional). - ufw markers self-heal (delete ROOT + APEX blocks before inject). -- envsubst -> string.Template.safe_substitute; wget/curl download -> urllib. +- envsubst -> string.Template.safe_substitute; wget/curl download -> urllib. Unset template + vars stay LITERAL (envsubst emptied them) — typos surface instead of silently blanking. +- crowdsec install auto-confirms (`apt-get install -y`; bash ran promptful `apt install`, + which blocked on a tty and aborted non-interactive runs). +- pre-commit staged-compose pathspec widened to `*docker-compose.yml` — the old + `*.docker-compose.yml`/root-anchored patterns never matched nested compose files. +- anchor-IP validation in utils/lint-docker-compose is a planned addition (SP3 §6.2). +- new opt-in `--dry-run` flags on configure/* have no bash counterpart; the default + (flag absent) path is the ported behavior. +- ufw/crowdsec deploys keep a timestamped `.bak` next to overwritten system files + (bash: none / `/tmp`) as a rollback net; tiers/useradd `.env` rewrites deliberately do + NOT (no secret-bearing copies). +- non-conventional tool exit codes normalize to 1 (bash sometimes propagated e.g. + apt-get's 100); 64/65/66 conventions preserved. + +## Core-compose consolidation deltas (beyond the rename) +- icarus: traefik no longer publishes 53/tcp+udp (AdGuard DNS) from the shared file — + restored via an icarus-only override layered in the node repo's include (Phase 2). +- icarus: ACME propagation-tuning flags and DEBUG log level drop to the shared defaults; + traefik now runs noroot (APEX_UID) with file provider + HTTP/3 like daedalus. +- morpheus: gains apex-xray (design: xray is a core service on every node) — a config + must exist at `${APEX_TIER1}/xray/data/config.jsonc` before cutover or it crash-loops. +- morpheus: dashboard auth changes basicauth (TRAEFIK_INTERNAL_API_AUTH) -> enclave IP + allowlist; the adguard router labels exist without a backend (502, was 404, on /adguard). +- restic: per-node selective tier2 mounts replaced by whole-tier-root RO mounts + (superset; snapshot paths preserved); RESTIC_COMPRESSION per-node via + `APEX_RESTIC_COMPRESSION` (default auto). diff --git a/actions/backup/run.py b/actions/backup/run.py index 327c6a9..420624b 100644 --- a/actions/backup/run.py +++ b/actions/backup/run.py @@ -40,7 +40,8 @@ def run(ctx, args): "--keep-monthly", "12", check=False).returncode != 0: ctx.notify.error(TITLE, url, "restic forget/prune failed", node); raise SystemExit(1) log.success("Restic backup completed.") - ctx.notify.success(TITLE, url, f"Restic backup + retention succeeded for {node}", node) + if not ctx.notify.success(TITLE, url, f"Restic backup + retention succeeded for {node}", node): + raise SystemExit(1) # bash exited 1 when the success telegram failed to send except SystemExit: raise except Exception as e: diff --git a/actions/compose.py b/actions/compose.py index 36bc9aa..de716f5 100644 --- a/actions/compose.py +++ b/actions/compose.py @@ -12,6 +12,11 @@ METADATA = Meta(summary="Orchestrate docker compose across the node (apex core f def run(ctx, args): log, comp, action = ctx.log, ctx.paths.compositions, args.action + # argparse.REMAINDER swallows flags placed after the action, so strip --dry-run + # out of the passthrough args like bash did ("apex compose up --dry-run" must + # preview, not hand docker compose its own --dry-run). + extra = [a for a in (args.extra or []) if a != "--dry-run"] + dry_run = args.dry_run or len(extra) != len(args.extra or []) env = dict(os.environ) env.update({k: v for k, v in ctx.vars().items() if k.startswith("APEX_")}) @@ -30,8 +35,8 @@ def run(ctx, args): for ef in (".env", "apex.env"): if os.path.isfile(os.path.join(dirpath, ef)): base += ["--env-file", ef] - cmd = base + (["up", "-d"] if action == "up" else [action]) + list(args.extra or []) - if args.dry_run: + cmd = base + (["up", "-d"] if action == "up" else [action]) + extra + if dry_run: log.info(f"[DRY-RUN] (cd {dirpath} && {' '.join(cmd)})") else: log.info(f"{action} {name}...") diff --git a/actions/configure/crowdsec.py b/actions/configure/crowdsec.py index eda34eb..c94d7ed 100644 --- a/actions/configure/crowdsec.py +++ b/actions/configure/crowdsec.py @@ -20,5 +20,5 @@ def run(ctx, args): if os.path.isfile(bouncer) and not ctx.sys.ok( ["bash", "-c", "dpkg -l | grep -q crowdsec-firewall-bouncer-iptables"]): ctx.log.info("Installing crowdsec-firewall-bouncer-iptables...") - ctx.sys.sudo(["apt-get", "install", "-y", "crowdsec-firewall-bouncer-iptables"]) + ctx.sys.install_packages(["crowdsec-firewall-bouncer-iptables"]) ctx.log.success("Configured crowdsec.") diff --git a/actions/configure/ufw.py b/actions/configure/ufw.py index f357bf4..bdd47d3 100644 --- a/actions/configure/ufw.py +++ b/actions/configure/ufw.py @@ -13,7 +13,12 @@ def run(ctx, args): docker_dir = os.path.join(cfg, "docker") host_dir = os.path.join(cfg, "host") ctx.log.info("Configuring UFW rules...") - if not os.path.isdir(docker_dir): + # Mirrors the bash branch structure: host dir is passed unconditionally (a missing + # one exits 66 inside apply); both dirs missing = warn + success, like bash. + if os.path.isdir(docker_dir): + ctx.ufw.apply(docker_dir, host_dir, dry_run=args.dry_run) + else: ctx.log.warn(f"Docker rules dir not found at {docker_dir}. Skipping ufw-docker rules.") - ctx.ufw.apply(docker_dir, host_dir if os.path.isdir(host_dir) else None, dry_run=args.dry_run) + if os.path.isdir(host_dir): + ctx.ufw.apply(docker_dir, host_dir, dry_run=args.dry_run) ctx.log.success("Configured ufw.") diff --git a/actions/sync/packages.py b/actions/sync/packages.py index f463b19..678efcd 100644 --- a/actions/sync/packages.py +++ b/actions/sync/packages.py @@ -9,8 +9,20 @@ TITLE = "Node Security Updates" def run(ctx, args): log, node, url, s = ctx.log, ctx.node.name, args.telegram_bot_url, ctx.sys + try: + _check(ctx, args, log, node, url, s) + except SystemExit: + raise + except Exception as e: # bash ERR trap parity: unhandled failures page via Telegram + log.error(f"{type(e).__name__}: {e}") + ctx.notify.error(TITLE, url, f"Critical error: {e}", node) + raise SystemExit(1) + +def _check(ctx, args, log, node, url, s): if not os.path.isfile("/etc/debian_version"): - log.error("Requires a Debian-based system."); raise SystemExit(1) + log.error("Requires a Debian-based system.") + ctx.notify.error(TITLE, url, "Requires a Debian-based system.", node) + raise SystemExit(1) log.info("Updating package lists...") if s.ok(["sudo", "apt-get", "update"]): @@ -53,5 +65,7 @@ def run(ctx, args): outdated.append(disp) images = (f"*Images ({len(outdated)}):* " + ", ".join(f"`{i}`" for i in outdated)) if outdated else "*Images:* _none_" - ctx.notify.info(TITLE, url, f"*APT:*\n{packages}\n\n*Skopeo:*\n{images}", node) + # A failed send exits 1 (bash: telegram_info's return 1 tripped set -e). + if not ctx.notify.info(TITLE, url, f"*APT:*\n{packages}\n\n*Skopeo:*\n{images}", node): + raise SystemExit(1) log.success("Security update check completed.") diff --git a/actions/sync/repository.py b/actions/sync/repository.py index 05e8cc6..ea45801 100644 --- a/actions/sync/repository.py +++ b/actions/sync/repository.py @@ -23,6 +23,11 @@ def run(ctx, args): if (cp.stdout or "").strip(): log.error(f"Commons submodule at {commons} has uncommitted changes. Aborting.") raise SystemExit(1) + if not os.path.isdir(ctx.paths.compositions): + # Without this, `git add -A` would stage a deleted node tree and an + # unattended cron run would commit + force-push the deletion. + log.error(f"Directory '{ctx.paths.compositions}' does not exist. Cannot sync.") + raise SystemExit(1) branch = f"sync/{node}" ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") if ctx.sys.ok(["git", "-C", repo, "rev-parse", "--verify", branch]): @@ -45,7 +50,8 @@ def run(ctx, args): git("submodule", "update", "--init", "--recursive") git("push", "--force-with-lease", "origin", branch) log.success(f"Pushed {branch}.") - ctx.notify.success(TITLE, url, f"Sync to '{branch}' completed.", node) + if not ctx.notify.success(TITLE, url, f"Sync to '{branch}' completed.", node): + raise SystemExit(1) except SystemExit: raise except Exception as e: diff --git a/actions/tiers/chown.py b/actions/tiers/chown.py index 08632de..262a100 100644 --- a/actions/tiers/chown.py +++ b/actions/tiers/chown.py @@ -22,9 +22,11 @@ def _env_val(path, key): def run(ctx, args): log, s = ctx.log, ctx.sys comp = ctx.paths.compositions - tiers = [os.path.realpath(args.tier1_path or ctx.paths.tier1), - os.path.realpath(args.tier2_path or ctx.paths.tier2), - os.path.realpath(args.tier3_path or ctx.paths.tier3)] + # Defaults are the TIER ROOTS (compositions/@tierN, bash $ROOT_TIERn) — NOT + # ctx.paths.tierN, which is the apex core project's data dir one level deeper. + tiers = [os.path.realpath(args.tier1_path or ctx.paths.root_tier1), + os.path.realpath(args.tier2_path or ctx.paths.root_tier2), + os.path.realpath(args.tier3_path or ctx.paths.root_tier3)] cp = s.run(["getent", "group", SHARED_GROUP], check=False, capture=True) if cp.returncode != 0: @@ -33,10 +35,12 @@ def run(ctx, args): log.info("Ensuring base structures owned by root...") for tp in tiers: s.sudo(["chown", "root:root", tp]) - for sub in ("shared", "node-infra"): - p = os.path.join(tp, sub) - if os.path.isdir(p): - s.sudo(["chown", "-R", "root:root", p]) + shared = os.path.join(tp, "shared") + if os.path.isdir(shared): # dir inode only — recursing would clobber + s.sudo(["chown", "root:root", shared]) # skipped projects' shared subtrees + infra = os.path.join(tp, "node-infra") + if os.path.isdir(infra): + s.sudo(["chown", "-R", "root:root", infra]) for proj in sorted(os.listdir(comp)): full = os.path.join(comp, proj) diff --git a/actions/tiers/link.py b/actions/tiers/link.py index 1260d82..8df405b 100644 --- a/actions/tiers/link.py +++ b/actions/tiers/link.py @@ -16,7 +16,10 @@ def run(ctx, args): os.path.realpath(args.tier3_path)] def confirm(msg): - return input(f"{msg} [y/N]: ").strip().lower() in ("y", "yes") + try: + return input(f"{msg} [y/N]: ").strip().lower() in ("y", "yes") + except EOFError: # non-interactive stdin = decline, like bash `read` at EOF + return False def safe_remove(link, desc): if os.path.islink(link): diff --git a/actions/tiers/useradd.py b/actions/tiers/useradd.py index 3116ed4..689bdee 100644 --- a/actions/tiers/useradd.py +++ b/actions/tiers/useradd.py @@ -70,5 +70,7 @@ def _inject_env(ctx, env_path, uid, gid, shared_gid): new = re.sub(re.escape(START) + r".*?" + re.escape(END) + r"\n", block, text, flags=re.S) else: new = block + "\n" + text - ctx.host.write_file(actual, new, backup=True, sudo=True) + # backup=False: bash never left copies; a timestamped .bak would pile up a + # secret-bearing .env duplicate per run next to the bind-mounted project dir. + ctx.host.write_file(actual, new, backup=False, sudo=True) ctx.log.success(f"Updated {actual} with APEX_UID/GID/SHARED_GID") diff --git a/actions/utils/extract-traefik-certs.py b/actions/utils/extract-traefik-certs.py index 4f51dfa..ae11feb 100644 --- a/actions/utils/extract-traefik-certs.py +++ b/actions/utils/extract-traefik-certs.py @@ -15,10 +15,11 @@ def run(ctx, args): log.error(f"Source file not found: {args.source_acme}"); raise SystemExit(1) abs_src = os.path.realpath(args.source_acme) src_dir, src_file = os.path.dirname(abs_src), os.path.basename(abs_src) - ctx.sys.sudo(["mkdir", "-p", args.dest_dir]) - abs_dest = os.path.realpath(args.dest_dir) - log.info(f"Extracting certs: {abs_src} -> {abs_dest}") try: + # Inside the notify envelope: bash armed its Telegram ERR trap before this mkdir. + ctx.sys.sudo(["mkdir", "-p", args.dest_dir]) + abs_dest = os.path.realpath(args.dest_dir) + log.info(f"Extracting certs: {abs_src} -> {abs_dest}") ctx.sys.run(["docker", "run", "--rm", "-v", f"{src_dir}:/data", "-v", f"{abs_dest}:/output", "ldez/traefik-certs-dumper:v2.9.3", "file", "--version", "v3", "--source", f"/data/{src_file}", "--dest", "/output", "--domain-subdir=true", diff --git a/actions/utils/generate-happ-subscriptions.py b/actions/utils/generate-happ-subscriptions.py index 07d1d6c..edbf156 100644 --- a/actions/utils/generate-happ-subscriptions.py +++ b/actions/utils/generate-happ-subscriptions.py @@ -1,6 +1,7 @@ # actions/utils/generate-happ-subscriptions.py """Port of utils/generate-happ-subscriptions — build + run the happ generator image.""" import os +import shutil from engine.descriptor import Meta, Arg METADATA = Meta(summary="Generate .psub files from a JSON config via the happ generator (docker).", @@ -11,6 +12,16 @@ def run(ctx, args): log = ctx.log src_dir = os.path.join(ctx.paths.commons, "source", "happ-subscription-generator") inp, out = os.path.realpath(args.input_json), os.path.realpath(args.output_dir) + # GNU realpath (bash) fails unless all but the last component exist — without this + # guard a mistyped path reaches `docker run -v`, which creates it root-owned. + for label, p in (("input_json", inp), ("output_dir", out)): + parent = os.path.dirname(p) + if not os.path.isdir(parent): + log.error(f"Cannot resolve {label}: no such directory: {parent}") + raise SystemExit(1) + if not shutil.which("docker"): + log.error("docker is not installed or not in PATH.") + raise SystemExit(1) log.info(f"Building {IMAGE}...") ctx.sys.run(["docker", "build", "-t", IMAGE, src_dir], check=True) log.info(f"Running generator: {inp} -> {out}") diff --git a/actions/utils/lint-docker-compose.py b/actions/utils/lint-docker-compose.py index 5e32588..08fea2d 100644 --- a/actions/utils/lint-docker-compose.py +++ b/actions/utils/lint-docker-compose.py @@ -50,12 +50,22 @@ def _validate_anchor_ips(ctx) -> int: f = os.path.join(comp, proj, "docker-compose.yml") if not os.path.isfile(f): continue - for m in re.finditer(r"ipv4_address:\s*([0-9.]+)", open(f).read()): + try: + text = open(f).read() + except OSError as e: + ctx.log.error(f"Cannot read {f}: {e}"); rc = 1 + continue + for m in re.finditer(r"ipv4_address:\s*([0-9.]+)", text): ip = m.group(1) + try: + addr = ipaddress.ip_address(ip) + except ValueError: # e.g. "10.0.0." — a lint error, not a crash + ctx.log.error(f"Malformed anchor IP {ip!r} in {proj}"); rc = 1 + continue if ip in seen: ctx.log.error(f"Duplicate anchor IP {ip} in {proj} and {seen[ip]}"); rc = 1 seen[ip] = proj - if subnet is not None and ipaddress.ip_address(ip) not in subnet: + if subnet is not None and addr not in subnet: ctx.log.error(f"Anchor IP {ip} ({proj}) not in node subnet {subnet}"); rc = 1 if rc == 0: ctx.log.info("Anchor IP validation passed.") diff --git a/compositions/apex/docker-compose.yml b/compositions/apex/docker-compose.yml index 45cca94..d37e9dc 100644 --- a/compositions/apex/docker-compose.yml +++ b/compositions/apex/docker-compose.yml @@ -38,13 +38,15 @@ services: - ${APEX_TIER_ROOT2}:/data/@tier2:ro environment: <<: *shared-environment - RESTIC_REPOSITORY: "${APEX_RESTIC_REPOSITORY}" + RESTIC_REPOSITORY: "${APEX_RESTIC_REPOSITORY?error}" RESTIC_PASSWORD: "${APEX_RESTIC_PASSWORD?error}" AWS_ACCESS_KEY_ID: "${APEX_RESTIC_AWS_ACCESS_KEY_ID?error}" AWS_SECRET_ACCESS_KEY: "${APEX_RESTIC_AWS_SECRET_ACCESS_KEY?error}" AWS_REGION: "ru-central1" AWS_ENDPOINT_URL: "https://storage.yandexcloud.net/" - RESTIC_COMPRESSION: "auto" + # per-node override (icarus historically ran "max" for manual invocations; + # scheduled backups always force --compression auto on the CLI) + RESTIC_COMPRESSION: "${APEX_RESTIC_COMPRESSION:-auto}" RESTIC_CACHE_DIR: /var/restic-cache network_mode: "host" entrypoint: ["restic"] diff --git a/engine/lib/crowdsec.py b/engine/lib/crowdsec.py index ce55fc3..ab0f4b7 100644 --- a/engine/lib/crowdsec.py +++ b/engine/lib/crowdsec.py @@ -13,7 +13,8 @@ class Crowdsec: if shutil.which("cscli"): return self.log.info("Installing CrowdSec via official script...") - self.sys.run(["bash", "-c", "curl -s https://install.crowdsec.net | sudo sh"], check=True) + self.sys.run(["bash", "-c", "set -o pipefail; curl -s https://install.crowdsec.net | sudo sh"], + check=True) self.sys.sudo(["apt-get", "install", "-y", "crowdsec"]) def deploy(self, configs_subdir: str, vars: dict, dry_run: bool = False) -> None: diff --git a/engine/lib/host.py b/engine/lib/host.py index 1ac5d37..c5341bb 100644 --- a/engine/lib/host.py +++ b/engine/lib/host.py @@ -90,6 +90,10 @@ class Host: tmp = tempfile.NamedTemporaryFile("w", delete=False) tmp.write(content) tmp.close() + # 0644 like the bash renders (umask-022 redirect); mkstemp's 0600 would make + # freshly created units/configs owner-only. Overwrites via sudo cp keep the + # destination's existing mode either way. + os.chmod(tmp.name, 0o644) if sudo: self.sys.sudo(["mkdir", "-p", os.path.dirname(path)]) self.sys.sudo(["cp", tmp.name, path]) diff --git a/engine/lib/sys.py b/engine/lib/sys.py index 4d5d11f..9f49b5d 100644 --- a/engine/lib/sys.py +++ b/engine/lib/sys.py @@ -31,7 +31,8 @@ class Sys: def service_exists(self, name: str) -> bool: cp = self.run(["systemctl", "list-unit-files", "--type=service"], check=False, capture=True) - return f"{name}.service" in (cp.stdout or "") + return any(line.startswith(f"{name}.service") + for line in (cp.stdout or "").splitlines()) def service_is_active(self, name: str) -> bool: return self.ok(["systemctl", "is-active", "--quiet", name]) diff --git a/engine/lib/systemd.py b/engine/lib/systemd.py index 41fcf09..f1b26ab 100644 --- a/engine/lib/systemd.py +++ b/engine/lib/systemd.py @@ -9,7 +9,10 @@ class Systemd: self.log, self.sys, self.tpl, self.host = log, sys_, tpl, host def deploy_units(self, configs_subdir: str, vars: dict, dry_run: bool = False) -> None: - rendered = self.tpl.render_dir(configs_subdir, vars) + # Top-level, non-hidden files only — the bash glob ("$CONFIG_DIR"/* + [[ -f ]]) + # never recursed nor picked up dotfiles like .gitkeep. + rendered = {rel: c for rel, c in self.tpl.render_dir(configs_subdir, vars).items() + if "/" not in rel and not rel.startswith(".")} if not rendered: self.log.warn(f"No systemd units found under configs/{configs_subdir}.") return diff --git a/engine/lib/tpl.py b/engine/lib/tpl.py index 1e893d4..d1a9b25 100644 --- a/engine/lib/tpl.py +++ b/engine/lib/tpl.py @@ -1,8 +1,9 @@ # engine/lib/tpl.py """ctx.tpl — stdlib string.Template rendering (envsubst successor, SP2 decision #3). -safe_substitute matches envsubst's forgiving behavior: known $VAR/${VAR} replaced, -unknown left literal, and shell $(...) command-substitution survives untouched. +safe_substitute replaces known $VAR/${VAR} and leaves unknown ones literal (deliberate +delta: envsubst emptied them — literal survivors surface typos instead of silently +blanking). Shell $(...) command-substitution survives untouched. Loops/conditionals live in the python action, never in the template file. """ from __future__ import annotations diff --git a/engine/lib/ufw.py b/engine/lib/ufw.py index 232ebfb..191bc17 100644 --- a/engine/lib/ufw.py +++ b/engine/lib/ufw.py @@ -26,12 +26,22 @@ class Ufw: def _inject_file(self, target: str, source_file: str, dry_run: bool): if not os.path.isfile(source_file): return + if not self.sys.ok(["sudo", "test", "-f", target]): + self.log.error(f"Target file does not exist: {target}") + raise SystemExit(1) content = "\n".join(clean_rules(open(source_file).read())) self.host.inject_block(target, content, APEX_START, APEX_END, anchor="# End required lines", old_pairs=OLD_PAIRS, dry_run=dry_run, sudo=True) def apply(self, rules_dir: str, config_dir=None, dry_run: bool = False) -> None: + # EX_NOINPUT guards first, tools second — same order/codes as root_template_ufw. + if not os.path.isdir(rules_dir): + self.log.error(f"Rules directory does not exist: {rules_dir}") + raise SystemExit(66) + if config_dir and not os.path.isdir(config_dir): + self.log.error(f"Config directory does not exist: {config_dir}") + raise SystemExit(66) if not self.sys.ok(["sudo", "ufw", "--version"]): self.log.error("ufw not installed/working via sudo. Run configure/base first.") raise SystemExit(1) @@ -51,16 +61,17 @@ class Ufw: self._inject_file("/etc/ufw/before.rules", os.path.join(config_dir, "before.rules"), dry_run) self._inject_file("/etc/ufw/after.rules", os.path.join(config_dir, "after.rules"), dry_run) - if os.path.isdir(rules_dir): - for fn in sorted(os.listdir(rules_dir)): - if not fn.endswith(".rules"): - continue - for rule in clean_rules(open(os.path.join(rules_dir, fn)).read()): - self.log.info(f"Applying ufw-docker rule ({fn}): {rule}") - if dry_run: - self.log.info(f"[DRY-RUN] sudo {UFW_DOCKER_BIN} {rule}") - else: - self.sys.run(["sudo", UFW_DOCKER_BIN, *rule.split()], check=True) + rule_files = [fn for fn in sorted(os.listdir(rules_dir)) + if fn.endswith(".rules") and not fn.startswith(".")] + if not rule_files: + self.log.warn(f"No .rules files found in {rules_dir}, nothing to apply.") + for fn in rule_files: + for rule in clean_rules(open(os.path.join(rules_dir, fn)).read()): + self.log.info(f"Applying ufw-docker rule ({fn}): {rule}") + if dry_run: + self.log.info(f"[DRY-RUN] sudo {UFW_DOCKER_BIN} {rule}") + else: + self.sys.run(["sudo", UFW_DOCKER_BIN, *rule.split()], check=True) self.log.success("All ufw rules applied.") self.log.warn("Run `sudo systemctl restart ufw` to apply. Ensure ssh is allowed via plain ufw!")