diff --git a/deploy/decnet-emailgen.service.j2 b/deploy/decnet-emailgen.service.j2 new file mode 100644 index 00000000..12a1728b --- /dev/null +++ b/deploy/decnet-emailgen.service.j2 @@ -0,0 +1,54 @@ +[Unit] +Description=DECNET Emailgen (LLM-driven fake corporate email into IMAP/POP3 deckies) +Documentation=https://git.resacachile.cl/anti/DECNET/wiki/Workers#emailgen +After=network-online.target decnet-bus.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.emailgen.log +# LLM backend selection + model are operator-tunable via .env.local: +# DECNET_EMAILGEN_LLM=ollama|fake (default: ollama) +# DECNET_EMAILGEN_MODEL=llama3.1 (default: llama3.1) +# DECNET_EMAILGEN_TIMEOUT=60 (LLM wall-clock cap, seconds) +# DECNET_EMAILGEN_PERSONAS=/etc/decnet/email_personas.json +# (override the global persona pool) +ExecStart={{ venv_dir }}/bin/decnet emailgen run +StandardOutput=append:/var/log/decnet/decnet.emailgen.log +StandardError=append:/var/log/decnet/decnet.emailgen.log + +# Emailgen drives `docker exec` against IMAP/POP3 decky containers to drop +# .eml files into the spool, identical to the SSH-flavoured orchestrator. +# It does NOT bind to the network, launch new containers, or write outside +# its own logs and install dir. +SupplementaryGroups=docker + +CapabilityBoundingSet= +AmbientCapabilities= + +# Security Hardening +NoNewPrivileges=yes +ProtectSystem=full +ProtectHome=read-only +PrivateTmp=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes +LockPersonality=yes +# /etc/decnet is included so `decnet emailgen import-personas` can write +# the canonical /etc/decnet/email_personas.json without the worker losing +# read access (it lives outside ReadWritePaths so writes from the worker +# itself are still blocked — only the operator-run CLI writes here). +ReadWritePaths={{ install_dir }} /var/log/decnet + +Restart=on-failure +RestartSec=5 +TimeoutStopSec=15 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/decnet.target b/deploy/decnet.target index cf3a50f8..f7b27816 100644 --- a/deploy/decnet.target +++ b/deploy/decnet.target @@ -18,7 +18,9 @@ Wants=decnet-bus.service \ decnet-enrich.service \ decnet-clusterer.service \ decnet-campaign-clusterer.service \ - decnet-webhook.service + decnet-webhook.service \ + decnet-orchestrator.service \ + decnet-emailgen.service After=decnet-bus.service [Install] diff --git a/tests/deploy/__init__.py b/tests/deploy/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/deploy/test_emailgen_unit.py b/tests/deploy/test_emailgen_unit.py new file mode 100644 index 00000000..fd64cc9e --- /dev/null +++ b/tests/deploy/test_emailgen_unit.py @@ -0,0 +1,93 @@ +"""Smoke tests for the emailgen systemd unit + decnet.target wiring. + +These don't exercise systemd (the test host wouldn't have it), they +just assert the static contents of ``deploy/decnet-emailgen.service.j2`` +and ``deploy/decnet.target`` match what ``decnet init`` will install. +A regression here would only surface on a fresh host install — cheap +to catch at CI time. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + + +REPO = Path(__file__).resolve().parent.parent.parent +DEPLOY = REPO / "deploy" + + +@pytest.fixture +def unit_text() -> str: + return (DEPLOY / "decnet-emailgen.service.j2").read_text() + + +@pytest.fixture +def target_text() -> str: + return (DEPLOY / "decnet.target").read_text() + + +# ── unit file ──────────────────────────────────────────────────────────────── + + +def test_emailgen_unit_exists(): + assert (DEPLOY / "decnet-emailgen.service.j2").exists() + + +def test_emailgen_unit_uses_run_subcommand(unit_text): + """`decnet emailgen` is a sub-app now — the unit must call `run`, + not bare `emailgen` (which still works but is implicit-default and + fragile to future changes).""" + assert "decnet emailgen run" in unit_text + + +def test_emailgen_unit_has_docker_supplementary_group(unit_text): + """Driver shells `docker exec` to drop EMLs in the spool — without + this group the worker can't reach the docker socket.""" + assert "SupplementaryGroups=docker" in unit_text + + +def test_emailgen_unit_orders_after_bus(unit_text): + """Bus must come up first so emailgen's heartbeat publishes land.""" + assert "After=network-online.target decnet-bus.service" in unit_text + assert "Wants=network-online.target decnet-bus.service" in unit_text + + +def test_emailgen_unit_has_security_hardening(unit_text): + """Same hardening shape as orchestrator.service — defence in depth.""" + for directive in ( + "NoNewPrivileges=yes", + "ProtectSystem=full", + "ProtectHome=read-only", + "PrivateTmp=yes", + "ProtectKernelTunables=yes", + "ProtectKernelModules=yes", + "ProtectControlGroups=yes", + "RestrictSUIDSGID=yes", + "LockPersonality=yes", + ): + assert directive in unit_text, f"missing {directive}" + + +def test_emailgen_unit_writes_to_log_dir(unit_text): + assert "/var/log/decnet/decnet.emailgen.log" in unit_text + assert "ReadWritePaths={{ install_dir }} /var/log/decnet" in unit_text + + +def test_emailgen_unit_restart_on_failure(unit_text): + assert "Restart=on-failure" in unit_text + + +# ── target wiring ──────────────────────────────────────────────────────────── + + +def test_target_wants_emailgen(target_text): + """A fresh `decnet init` must bring up emailgen with the rest of + the fleet.""" + assert "decnet-emailgen.service" in target_text + + +def test_target_wants_orchestrator(target_text): + """Orchestrator was an oversight historically — bundling it in here + too while we're touching the file.""" + assert "decnet-orchestrator.service" in target_text