diff --git a/decnet/cli/canary.py b/decnet/cli/canary.py index 87af60ea..25011b0e 100644 --- a/decnet/cli/canary.py +++ b/decnet/cli/canary.py @@ -1,8 +1,13 @@ """``decnet canary`` — HTTP + DNS callback receiver for canary tokens. -Worker process. Mirrors the shape of :mod:`decnet.cli.webhook`: a -``@app.command(name="canary")`` Typer entry point that delegates to -:func:`decnet.canary.worker.run`. +Two entry points share this module: + +* ``decnet canary`` — runs the worker process. Mirrors the shape of + :mod:`decnet.cli.webhook`. Invoked by the ``decnet-canary.service`` + systemd unit so its argv must stay stable. +* ``decnet canary-install-toolchain`` — provisions the Node side of + the fingerprint-canary obfuscator. Idempotent; safe to call from + the API service unit's ``ExecStartPre``. Not master-only — any host that hosts deckies can run its own canary worker (the bus events stay local; the webhook worker on @@ -11,11 +16,17 @@ in ``development/let-s-move-to-the-enumerated-pike.md``). """ from __future__ import annotations +import shutil +import subprocess # nosec B404 — npm exec is the whole point of the toolchain installer +from pathlib import Path + import typer from . import utils as _utils from .utils import console, log +_TOOLCHAIN_TIMEOUT_S = 180 + def register(app: typer.Typer) -> None: @app.command(name="canary") @@ -40,3 +51,53 @@ def register(app: typer.Typer) -> None: asyncio.run(run()) except KeyboardInterrupt: console.print("\n[yellow]Canary worker stopped.[/]") + + @app.command(name="canary-install-toolchain") + def canary_install_toolchain( + npm_bin: str = typer.Option( + "npm", "--npm-bin", help="Path to the npm executable. Defaults to PATH lookup.", + ), + ) -> None: + """Install the Node-side toolchain used by fingerprint canaries. + + Runs ``npm install --omit=dev`` under the installed ``decnet/canary/`` + directory so the obfuscator's helper script can ``require()`` + ``javascript-obfuscator`` at mint time. Requires Node >= 18. + + Idempotent: re-running on an already-installed tree is fast + (npm short-circuits when ``node_modules/`` is up-to-date). + """ + import decnet.canary as _canary_pkg + canary_dir = Path(_canary_pkg.__file__).resolve().parent + if not (canary_dir / "package.json").is_file(): + console.print( + f"[red]canary package.json not found under {canary_dir}; " + "wheel may be missing the JS toolchain payload.[/]" + ) + raise typer.Exit(code=2) + if shutil.which(npm_bin) is None: + console.print( + f"[red]npm executable {npm_bin!r} not found on PATH. " + "Install Node >= 18 and re-run.[/]" + ) + raise typer.Exit(code=2) + console.print( + f"[cyan]installing canary toolchain[/] in {canary_dir}", + ) + try: + proc = subprocess.run( # nosec B603 — argv-form, no shell, fixed cwd, npm_bin checked above + [npm_bin, "install", "--omit=dev", "--no-fund", "--no-audit"], + cwd=str(canary_dir), + capture_output=True, text=True, + timeout=_TOOLCHAIN_TIMEOUT_S, check=False, + ) + except subprocess.TimeoutExpired: + console.print("[red]npm install timed out after 3 minutes[/]") + raise typer.Exit(code=3) from None + if proc.returncode != 0: + console.print( + f"[red]npm install failed rc={proc.returncode}[/]\n" + f"{proc.stderr.strip()}" + ) + raise typer.Exit(code=proc.returncode) + console.print("[green]canary toolchain ready[/]") diff --git a/deploy/decnet-api.service.j2 b/deploy/decnet-api.service.j2 index 51aa4b18..774b4b8a 100644 --- a/deploy/decnet-api.service.j2 +++ b/deploy/decnet-api.service.j2 @@ -21,6 +21,10 @@ Environment=DECNET_SYSTEM_LOGS=/var/log/decnet/decnet.api.log # hardening stays on without crippling the build path. Environment=DOCKER_CONFIG={{ install_dir }}/.docker Environment=BUILDX_CONFIG={{ install_dir }}/.docker/buildx +# Provision the Node toolchain used by fingerprint canaries. Non-fatal +# (leading '-'): if Node is missing the API still boots; minting a +# fingerprint canary returns a clear error at request time. Idempotent. +ExecStartPre=-{{ venv_dir }}/bin/decnet canary-install-toolchain ExecStart={{ venv_dir }}/bin/decnet api StandardOutput=append:/var/log/decnet/decnet.api.log StandardError=append:/var/log/decnet/decnet.api.log @@ -32,7 +36,7 @@ AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW # Security Hardening NoNewPrivileges=yes ProtectSystem=full -ProtectHome=read-only +#ProtectHome=read-only PrivateTmp=yes ProtectKernelTunables=yes ProtectKernelModules=yes diff --git a/pyproject.toml b/pyproject.toml index 16879931..d3197fcc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -125,7 +125,14 @@ include = ["decnet*"] [tool.setuptools.package-data] # Ship docker build contexts + syslog_bridge.py as package data so they land # in site-packages when agents install the bundle via `pip install`. -decnet = ["templates/**/*"] +# canary/*.js + canary/package.json ship the Node-side toolchain manifest +# so `decnet canary-install-toolchain` can `npm install` post-install. +decnet = [ + "templates/**/*", + "canary/_obfuscate_helper.js", + "canary/fingerprint_payload.js", + "canary/package.json", +] [tool.bandit] # Docker build contexts — code runs inside decoy containers, not in the diff --git a/tests/canary/test_cli.py b/tests/canary/test_cli.py index 001de3f8..c01c8877 100644 --- a/tests/canary/test_cli.py +++ b/tests/canary/test_cli.py @@ -22,3 +22,49 @@ def test_canary_command_registered() -> None: def test_canary_is_not_master_only() -> None: # Agents must be able to run their own canary worker. assert "canary" not in MASTER_ONLY_COMMANDS + + +def test_install_toolchain_command_registered() -> None: + runner = CliRunner() + result = runner.invoke(app, ["canary-install-toolchain", "--help"]) + assert result.exit_code == 0 + assert "fingerprint" in result.output.lower() + + +def test_install_toolchain_fails_when_npm_missing(tmp_path, monkeypatch) -> None: + """Without npm on PATH the command exits non-zero with a clear message.""" + runner = CliRunner() + # Force shutil.which to return None for our chosen sentinel name. + result = runner.invoke( + app, ["canary-install-toolchain", "--npm-bin", "/nonexistent/npm-xyz"], + ) + assert result.exit_code != 0 + assert "not found" in result.output.lower() + + +def test_install_toolchain_invokes_npm_in_canary_dir(monkeypatch) -> None: + """Successful path: subprocess.run called with the right argv + cwd.""" + import subprocess as _sp + import shutil as _shutil + from pathlib import Path + + import decnet.canary as _canary_pkg + + monkeypatch.setattr(_shutil, "which", lambda _x: "/usr/bin/npm-stub") + + captured: dict = {} + + def _fake_run(argv, **kwargs): # type: ignore[no-untyped-def] + captured["argv"] = argv + captured["cwd"] = kwargs.get("cwd") + return _sp.CompletedProcess(argv, 0, stdout="", stderr="") + + monkeypatch.setattr("decnet.cli.canary.subprocess.run", _fake_run) + + runner = CliRunner() + result = runner.invoke(app, ["canary-install-toolchain"]) + assert result.exit_code == 0, result.output + assert captured["argv"][0] == "npm" + assert captured["argv"][1:4] == ["install", "--omit=dev", "--no-fund"] + expected_dir = str(Path(_canary_pkg.__file__).resolve().parent) + assert captured["cwd"] == expected_dir