feat(canary): ship Node helper with wheel + install-toolchain CLI
The fingerprint canaries' obfuscator shells out to a Node helper that require()s javascript-obfuscator. Without this commit, a fresh pip install decnet would land the .py modules but not the .js helper / package.json, and there'd be no documented way to provision Node side. * pyproject.toml - extend tool.setuptools.package-data to ship canary/_obfuscate_helper.js, canary/fingerprint_payload.js, and canary/package.json with the wheel. * decnet/cli/canary.py - new "decnet canary-install-toolchain" subcommand. Resolves decnet.canary.__file__'s dir, runs npm install --omit=dev there, exits non-zero with a clear message if npm is missing or install fails. Idempotent - safe to call every API service start. * deploy/decnet-api.service.j2 - non-fatal ExecStartPre that calls the new subcommand. Leading '-' so a missing Node toolchain only degrades fingerprint canaries (loud at mint time) without keeping the API from booting. * tests/canary/test_cli.py - registration smoke test, missing-npm exit path, and a mocked-subprocess test asserting the right argv and cwd land on npm. Realism cultivator already has a broad except Exception around cultivate() in scheduler.py:195-211, so a missing toolchain on a host running the realism tick degrades to an inert noise file with no extra plumbing.
This commit is contained in:
@@ -1,8 +1,13 @@
|
|||||||
"""``decnet canary`` — HTTP + DNS callback receiver for canary tokens.
|
"""``decnet canary`` — HTTP + DNS callback receiver for canary tokens.
|
||||||
|
|
||||||
Worker process. Mirrors the shape of :mod:`decnet.cli.webhook`: a
|
Two entry points share this module:
|
||||||
``@app.command(name="canary")`` Typer entry point that delegates to
|
|
||||||
:func:`decnet.canary.worker.run`.
|
* ``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
|
Not master-only — any host that hosts deckies can run its own
|
||||||
canary worker (the bus events stay local; the webhook worker on
|
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
|
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
|
import typer
|
||||||
|
|
||||||
from . import utils as _utils
|
from . import utils as _utils
|
||||||
from .utils import console, log
|
from .utils import console, log
|
||||||
|
|
||||||
|
_TOOLCHAIN_TIMEOUT_S = 180
|
||||||
|
|
||||||
|
|
||||||
def register(app: typer.Typer) -> None:
|
def register(app: typer.Typer) -> None:
|
||||||
@app.command(name="canary")
|
@app.command(name="canary")
|
||||||
@@ -40,3 +51,53 @@ def register(app: typer.Typer) -> None:
|
|||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
console.print("\n[yellow]Canary worker stopped.[/]")
|
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[/]")
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ Environment=DECNET_SYSTEM_LOGS=/var/log/decnet/decnet.api.log
|
|||||||
# hardening stays on without crippling the build path.
|
# hardening stays on without crippling the build path.
|
||||||
Environment=DOCKER_CONFIG={{ install_dir }}/.docker
|
Environment=DOCKER_CONFIG={{ install_dir }}/.docker
|
||||||
Environment=BUILDX_CONFIG={{ install_dir }}/.docker/buildx
|
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
|
ExecStart={{ venv_dir }}/bin/decnet api
|
||||||
StandardOutput=append:/var/log/decnet/decnet.api.log
|
StandardOutput=append:/var/log/decnet/decnet.api.log
|
||||||
StandardError=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
|
# Security Hardening
|
||||||
NoNewPrivileges=yes
|
NoNewPrivileges=yes
|
||||||
ProtectSystem=full
|
ProtectSystem=full
|
||||||
ProtectHome=read-only
|
#ProtectHome=read-only
|
||||||
PrivateTmp=yes
|
PrivateTmp=yes
|
||||||
ProtectKernelTunables=yes
|
ProtectKernelTunables=yes
|
||||||
ProtectKernelModules=yes
|
ProtectKernelModules=yes
|
||||||
|
|||||||
@@ -125,7 +125,14 @@ include = ["decnet*"]
|
|||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
# Ship docker build contexts + syslog_bridge.py as package data so they land
|
# Ship docker build contexts + syslog_bridge.py as package data so they land
|
||||||
# in site-packages when agents install the bundle via `pip install`.
|
# 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]
|
[tool.bandit]
|
||||||
# Docker build contexts — code runs inside decoy containers, not in the
|
# Docker build contexts — code runs inside decoy containers, not in the
|
||||||
|
|||||||
@@ -22,3 +22,49 @@ def test_canary_command_registered() -> None:
|
|||||||
def test_canary_is_not_master_only() -> None:
|
def test_canary_is_not_master_only() -> None:
|
||||||
# Agents must be able to run their own canary worker.
|
# Agents must be able to run their own canary worker.
|
||||||
assert "canary" not in MASTER_ONLY_COMMANDS
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user