feat(systemd): decnet-canary.service unit + tests

Worker unit mirrors decnet-webhook.service shape: simple type, runs
as the decnet user/group, append-style log file, full security
hardening (NoNewPrivileges/ProtectSystem/ProtectHome/PrivateTmp/
LockPersonality + the rest). Added /var/lib/decnet to ReadWritePaths
because the API process persists operator-uploaded canary blobs there.

CAP_NET_BIND_SERVICE granted (ambient + bounded) so an operator who
overrides DECNET_CANARY_DNS_PORT to 53 or HTTP_PORT to 80/443 in
.env.local doesn't need to fight systemd. The defaults stay
unprivileged (5353 / 8088).

Added decnet-canary.service to decnet.target so 'systemctl start
decnet.target' brings it up alongside the rest of the workers.

decnet init auto-discovers deploy/decnet-*.service.j2 files (per
decnet/cli/init.py:_install_units) so no further wiring needed —
running 'decnet init' on a fresh host installs the new unit.

Static tests confirm the unit references decnet canary, depends on
the bus, carries the standard security directives, and is listed
in the master target.
This commit is contained in:
2026-04-27 13:20:47 -04:00
parent 34c85346a6
commit 53d08e01e5
3 changed files with 91 additions and 0 deletions

View File

@@ -0,0 +1,46 @@
[Unit]
Description=DECNET Canary Token Callback Receiver (HTTP + DNS)
Documentation=https://git.resacachile.cl/anti/DECNET/wiki/Workers#canary
After=network-online.target decnet-bus.service decnet-api.service
Wants=network-online.target decnet-bus.service
[Service]
Type=simple
User={{ user }}
Group={{ group }}
WorkingDirectory={{ install_dir }}
EnvironmentFile=-{{ install_dir }}/.env.local
Environment=DECNET_SYSTEM_LOGS=/var/log/decnet/decnet.canary.log
ExecStart={{ venv_dir }}/bin/decnet canary
StandardOutput=append:/var/log/decnet/decnet.canary.log
StandardError=append:/var/log/decnet/decnet.canary.log
# Bind low-numbered DNS port (53) and HTTP port (80/443) requires
# CAP_NET_BIND_SERVICE; the default DECNET_CANARY_HTTP_PORT (8088)
# and DECNET_CANARY_DNS_PORT (5353) are unprivileged, so the
# capability is granted only when an operator overrides those to
# privileged values via .env.local.
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
# Persist canary blobs (operator uploads) under /var/lib/decnet —
# the same posture the rest of the workers use for runtime data.
ReadWritePaths={{ install_dir }} /var/log/decnet /var/lib/decnet
# Security Hardening
NoNewPrivileges=yes
ProtectSystem=full
ProtectHome=read-only
PrivateTmp=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
LockPersonality=yes
Restart=on-failure
RestartSec=5
TimeoutStopSec=15
[Install]
WantedBy=multi-user.target

View File

@@ -19,6 +19,7 @@ Wants=decnet-bus.service \
decnet-clusterer.service \
decnet-campaign-clusterer.service \
decnet-webhook.service \
decnet-canary.service \
decnet-orchestrator.service \
decnet-emailgen.service
After=decnet-bus.service

View File

@@ -0,0 +1,44 @@
"""Sanity check on the decnet-canary.service unit + decnet.target.
Tests are deliberately static (no rendering, no systemd) — they just
confirm the unit file exists, references the canary CLI command, is
included in the master target, and follows the same security
hardening posture as decnet-webhook.service.
"""
from __future__ import annotations
from pathlib import Path
DEPLOY = Path(__file__).resolve().parents[2] / "deploy"
def test_canary_unit_exists() -> None:
assert (DEPLOY / "decnet-canary.service.j2").exists()
def test_canary_unit_runs_decnet_canary() -> None:
body = (DEPLOY / "decnet-canary.service.j2").read_text()
assert "{{ venv_dir }}/bin/decnet canary" in body
assert "After=" in body and "decnet-bus.service" in body
def test_canary_unit_has_security_hardening() -> None:
"""Canary handles attacker traffic — must mirror webhook's hardening."""
body = (DEPLOY / "decnet-canary.service.j2").read_text()
for required in (
"NoNewPrivileges=yes",
"ProtectSystem=full",
"ProtectHome=read-only",
"PrivateTmp=yes",
"ProtectKernelTunables=yes",
"ProtectKernelModules=yes",
"ProtectControlGroups=yes",
"RestrictSUIDSGID=yes",
"LockPersonality=yes",
):
assert required in body, f"missing hardening directive: {required}"
def test_canary_listed_in_master_target() -> None:
body = (DEPLOY / "decnet.target").read_text()
assert "decnet-canary.service" in body