From ba76c35b1957750ec9d93ac7036fca5f80539ec4 Mon Sep 17 00:00:00 2001 From: mdheller <21163552+mdheller@users.noreply.github.com> Date: Sun, 19 Apr 2026 20:33:30 -0400 Subject: [PATCH 1/8] systemd: add sourceos-gate-egress socket unit --- systemd/sourceos-gate-egress.socket | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 systemd/sourceos-gate-egress.socket diff --git a/systemd/sourceos-gate-egress.socket b/systemd/sourceos-gate-egress.socket new file mode 100644 index 0000000..d792707 --- /dev/null +++ b/systemd/sourceos-gate-egress.socket @@ -0,0 +1,12 @@ +[Unit] +Description=SourceOS egress gate socket (Unix) + +[Socket] +ListenStream=/run/sourceos/gate-egress.sock +SocketMode=0660 +SocketUser=root +SocketGroup=sourceos +RemoveOnStop=true + +[Install] +WantedBy=sockets.target From 8ffb19c3b3df1e5de4d8358013151bd45e52de8b Mon Sep 17 00:00:00 2001 From: mdheller <21163552+mdheller@users.noreply.github.com> Date: Sun, 19 Apr 2026 20:34:22 -0400 Subject: [PATCH 2/8] systemd: add sourceos-gate-egress service unit --- systemd/sourceos-gate-egress.service | 33 ++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 systemd/sourceos-gate-egress.service diff --git a/systemd/sourceos-gate-egress.service b/systemd/sourceos-gate-egress.service new file mode 100644 index 0000000..eca95a1 --- /dev/null +++ b/systemd/sourceos-gate-egress.service @@ -0,0 +1,33 @@ +[Unit] +Description=SourceOS egress gate daemon (Unix socket) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple + +# Store root for state/audit. +Environment=SOURCEOS_STORE_ROOT=/var/lib/sourceos + +# Systemd socket activation passes the listening FD (LISTEN_FDS) to the service. +Environment=LISTEN_PID=%p + +# Run daemon; it will use systemd socket activation when available. +ExecStart=/usr/bin/env python3 /usr/lib/sourceos/tools/sourceos_gate_egressd.py --store-root ${SOURCEOS_STORE_ROOT} + +Restart=on-failure +RestartSec=2 + +# Hardening +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=yes +ReadWritePaths=/var/lib/sourceos /run/sourceos + +# Needs nft mutation when applying; run as root (socket perms gate callers). +User=root +Group=root + +[Install] +WantedBy=multi-user.target From 5cff441770fd519a895d4018f71fe909cf3ac585 Mon Sep 17 00:00:00 2001 From: mdheller <21163552+mdheller@users.noreply.github.com> Date: Sun, 19 Apr 2026 20:35:42 -0400 Subject: [PATCH 3/8] gate(daemon): support systemd socket activation (LISTEN_FDS) --- src/sourceos_gate/daemon.py | 47 ++++++++++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/src/sourceos_gate/daemon.py b/src/sourceos_gate/daemon.py index 9c87db0..c30d2ad 100644 --- a/src/sourceos_gate/daemon.py +++ b/src/sourceos_gate/daemon.py @@ -4,15 +4,20 @@ This is intended for host-local orchestration (systemd socket activation or static socket path), and is not exposed to the network. + +Security model: +- Authentication is by filesystem permissions on the unix socket. +- This daemon must not listen on TCP. """ from __future__ import annotations import asyncio import json +import os +import socket as pysocket from dataclasses import dataclass from pathlib import Path -from typing import Any from .egress import EgressGate from .protocol import err_response, map_error, ok_response, require_fields @@ -26,7 +31,6 @@ class DaemonConfig: async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, gate: EgressGate) -> None: - peer = writer.get_extra_info("peername") try: while not reader.at_eof(): line = await reader.readline() @@ -89,15 +93,46 @@ async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWrit pass +def _systemd_listen_fds() -> list[int]: + """Return systemd-passed listening fds, if present. + + Systemd socket activation (sd_listen_fds) passes sockets starting at fd 3. + We implement the minimum required logic to avoid adding dependencies. + """ + + try: + listen_pid = int(os.environ.get("LISTEN_PID", "0")) + listen_fds = int(os.environ.get("LISTEN_FDS", "0")) + except ValueError: + return [] + + if listen_pid != os.getpid() or listen_fds <= 0: + return [] + + return list(range(3, 3 + listen_fds)) + + async def serve(cfg: DaemonConfig) -> None: + gate = EgressGate.for_root(cfg.store_root) + gate.init() + + fds = _systemd_listen_fds() + if fds: + # Prefer systemd socket activation. Use the first fd. + fd = fds[0] + sock = pysocket.socket(fileno=fd) + sock.setblocking(False) + + server = await asyncio.start_unix_server(lambda r, w: handle_client(r, w, gate), sock=sock) + async with server: + await server.serve_forever() + return + + # Fallback: create a unix socket at cfg.socket_path. cfg.socket_path.parent.mkdir(parents=True, exist_ok=True) if cfg.socket_path.exists(): cfg.socket_path.unlink() - gate = EgressGate.for_root(cfg.store_root) - gate.init() - server = await asyncio.start_unix_server(lambda r, w: handle_client(r, w, gate), path=str(cfg.socket_path)) - async with server: await server.serve_forever() From 38f593f1ad7bad2b151c09df40955ab5f4446835 Mon Sep 17 00:00:00 2001 From: mdheller <21163552+mdheller@users.noreply.github.com> Date: Sun, 19 Apr 2026 20:36:59 -0400 Subject: [PATCH 4/8] gate(tools): add egress gate daemon client (egressctl) --- tools/sourceos_gate_egressctl.py | 102 +++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 tools/sourceos_gate_egressctl.py diff --git a/tools/sourceos_gate_egressctl.py b/tools/sourceos_gate_egressctl.py new file mode 100644 index 0000000..189fa9c --- /dev/null +++ b/tools/sourceos_gate_egressctl.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Client for the SourceOS egress gate daemon. + +Talks to a local unix socket using line-delimited JSON requests. + +Examples: + python tools/sourceos_gate_egressctl.py --socket /run/sourceos/gate-egress.sock health + python tools/sourceos_gate_egressctl.py snapshot + python tools/sourceos_gate_egressctl.py grant --token-id tok --nonce n1 --exp 9999999999 --proto tcp --target 1.2.3.4/32 --port 443 --apply +""" + +from __future__ import annotations + +import argparse +import json +import os +import socket +import sys +import uuid +from pathlib import Path + + +def send(sock_path: str, msg: dict) -> dict: + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.connect(sock_path) + try: + wire = json.dumps(msg, sort_keys=True).encode("utf-8") + b"\n" + s.sendall(wire) + buf = b"" + while not buf.endswith(b"\n"): + chunk = s.recv(4096) + if not chunk: + break + buf += chunk + if not buf: + raise RuntimeError("no response") + return json.loads(buf.decode("utf-8")) + finally: + s.close() + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--socket", default="/run/sourceos/gate-egress.sock") + + sub = ap.add_subparsers(dest="cmd", required=True) + + sub.add_parser("health") + sub.add_parser("snapshot") + sub.add_parser("apply") + sub.add_parser("verify") + + p_prune = sub.add_parser("prune") + p_prune.add_argument("--apply", action="store_true") + + p_grant = sub.add_parser("grant") + p_grant.add_argument("--token-id", required=True) + p_grant.add_argument("--nonce", required=True) + p_grant.add_argument("--exp", required=True, type=int) + p_grant.add_argument("--proto", default="tcp", choices=["tcp", "udp"]) + p_grant.add_argument("--target", action="append", default=[], required=True) + p_grant.add_argument("--port", action="append", default=[], type=int) + p_grant.add_argument("--apply", action="store_true") + + args = ap.parse_args() + + req_id = uuid.uuid4().hex + if args.cmd == "health": + req = {"id": req_id, "method": "health", "params": {}} + elif args.cmd == "snapshot": + req = {"id": req_id, "method": "snapshot", "params": {}} + elif args.cmd == "apply": + req = {"id": req_id, "method": "apply", "params": {}} + elif args.cmd == "verify": + req = {"id": req_id, "method": "verify", "params": {}} + elif args.cmd == "prune": + req = {"id": req_id, "method": "prune", "params": {"apply": bool(args.apply)}} + elif args.cmd == "grant": + ports = args.port or ([443] if args.proto == "tcp" else [53]) + req = { + "id": req_id, + "method": "grant.install", + "params": { + "token_id": args.token_id, + "nonce": args.nonce, + "exp": int(args.exp), + "targets": [str(t) for t in args.target], + "ports": [int(p) for p in ports], + "proto": args.proto, + "apply": bool(args.apply), + }, + } + else: + raise SystemExit("ERR: unknown cmd") + + resp = send(args.socket, req) + print(json.dumps(resp, indent=2, sort_keys=True)) + return 0 if resp.get("ok") else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) From 176635952bc8def204fc9cb8ef81b617af580d20 Mon Sep 17 00:00:00 2001 From: mdheller <21163552+mdheller@users.noreply.github.com> Date: Sun, 19 Apr 2026 20:37:52 -0400 Subject: [PATCH 5/8] tests: add daemon protocol smoke test (health/snapshot) --- tests/test_gate_daemon.py | 59 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tests/test_gate_daemon.py diff --git a/tests/test_gate_daemon.py b/tests/test_gate_daemon.py new file mode 100644 index 0000000..c4c9f53 --- /dev/null +++ b/tests/test_gate_daemon.py @@ -0,0 +1,59 @@ +import asyncio +import json +import tempfile +import unittest +from pathlib import Path +import sys + +REPO_ROOT = Path(__file__).resolve().parents[1] +SRC_DIR = REPO_ROOT / "src" +if str(SRC_DIR) not in sys.path: + sys.path.insert(0, str(SRC_DIR)) + +from sourceos_gate.daemon import DaemonConfig, serve + + +async def _send(sock_path: str, msg: dict) -> dict: + reader, writer = await asyncio.open_unix_connection(sock_path) + writer.write((json.dumps(msg) + "\n").encode("utf-8")) + await writer.drain() + line = await reader.readline() + writer.close() + await writer.wait_closed() + return json.loads(line.decode("utf-8")) + + +class GateDaemonTests(unittest.TestCase): + def test_health_and_snapshot(self): + async def run(): + with tempfile.TemporaryDirectory() as td: + root = Path(td) + sock = root / "gate.sock" + + cfg = DaemonConfig(socket_path=sock, store_root=root) + task = asyncio.create_task(serve(cfg)) + + # wait for socket + for _ in range(50): + if sock.exists(): + break + await asyncio.sleep(0.02) + + resp = await _send(str(sock), {"id": "1", "method": "health", "params": {}}) + self.assertTrue(resp.get("ok")) + + snap = await _send(str(sock), {"id": "2", "method": "snapshot", "params": {}}) + self.assertTrue(snap.get("ok")) + self.assertIn("active", snap.get("result", {})) + + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + asyncio.run(run()) + + +if __name__ == "__main__": + unittest.main() From 07193e4f8bc96e803833c590480e702acda58055 Mon Sep 17 00:00:00 2001 From: mdheller <21163552+mdheller@users.noreply.github.com> Date: Sun, 19 Apr 2026 20:41:37 -0400 Subject: [PATCH 6/8] docs: add daemon mode + egressctl to truth-plane runbook --- docs/TRUTH_PLANE_RUNBOOK.md | 85 +++++++++++++++---------------------- 1 file changed, 35 insertions(+), 50 deletions(-) diff --git a/docs/TRUTH_PLANE_RUNBOOK.md b/docs/TRUTH_PLANE_RUNBOOK.md index d03c212..eded09e 100644 --- a/docs/TRUTH_PLANE_RUNBOOK.md +++ b/docs/TRUTH_PLANE_RUNBOOK.md @@ -3,6 +3,8 @@ This runbook assumes the v0 tools exist in `tools/`: - `tools/sourceos_gate_egress.py` +- `tools/sourceos_gate_egressd.py` +- `tools/sourceos_gate_egressctl.py` - `tools/sourceos_truth_surface.py` - `tools/sourceos_delta_surface.py` - `tools/sourceos_incident.py` @@ -60,7 +62,37 @@ sudo python tools/sourceos_truth_plane_smoke.py --store-root /tmp/sourceos-smoke --- -## 0b) Tick orchestrator (periodic surfaces + delta) +## 0b) Daemon mode (recommended for host operation) + +The egress gate can run as a host-local unix socket daemon. + +### Start the daemon (dev) + +```bash +sudo python tools/sourceos_gate_egressd.py --store-root /var/lib/sourceos --socket /run/sourceos/gate-egress.sock +``` + +### Talk to the daemon + +```bash +python tools/sourceos_gate_egressctl.py --socket /run/sourceos/gate-egress.sock health +python tools/sourceos_gate_egressctl.py snapshot +python tools/sourceos_gate_egressctl.py grant --token-id tok --nonce n1 --exp 9999999999 --proto tcp --target 1.2.3.4/32 --port 443 --apply +python tools/sourceos_gate_egressctl.py verify +``` + +### systemd socket activation (packaging lane) + +If deployed via systemd socket activation, use: + +- `systemd/sourceos-gate-egress.socket` +- `systemd/sourceos-gate-egress.service` + +The daemon will use `LISTEN_FDS` when present. + +--- + +## 0c) Tick orchestrator (periodic surfaces + delta) The tick orchestrator is the unit of periodic work intended for systemd timers. It: @@ -109,60 +141,13 @@ Then the gate can apply allowlist changes by mutating only the allow sets. --- -## 3) Install a dry-run egress grant - -```bash -# exp is epoch seconds -python tools/sourceos_gate_egress.py grant --store-root /tmp/sourceos \ - --token-id tok_demo --nonce n_0001 --exp 1893456000 \ - --target 1.2.3.4/32 --port 443 -``` - -Expected: - -- updates allowlist.state.json -- rejects replay of the same token+nonce - ---- - -## 3a) Install and apply a TCP grant (requires root) +## 3) CLI mode (direct) ```bash +python tools/sourceos_gate_egress.py init --store-root /tmp/sourceos sudo python tools/sourceos_gate_egress.py grant --apply --proto tcp --store-root /tmp/sourceos \ --token-id tok_demo --nonce n_0002 --exp 1893456000 \ --target 1.2.3.4/32 --port 443 -``` - -Expected: - -- updates allowlist.state.json -- applies allowlist sets to nft (no ruleset flush) -- writes an audit line under `/tmp/sourceos/audit/events//gate.egress.ndjson` - ---- - -## 3a-udp) Install and apply a UDP grant (DNS example) - -```bash -sudo python tools/sourceos_gate_egress.py grant --apply --proto udp --store-root /tmp/sourceos \ - --token-id tok_dns --nonce n_dns --exp 1893456000 \ - --target 1.1.1.1/32 --port 53 -``` - ---- - -## 3b) Prune expired grants (and optionally apply) - -```bash -python tools/sourceos_gate_egress.py prune --store-root /tmp/sourceos -sudo python tools/sourceos_gate_egress.py prune --apply --store-root /tmp/sourceos -``` - ---- - -## 3c) Verify nft allow sets match allowlist state - -```bash sudo python tools/sourceos_gate_egress.py verify --store-root /tmp/sourceos ``` From 8f3b563f90e057a296efe828800e698acfb42b3b Mon Sep 17 00:00:00 2001 From: mdheller <21163552+mdheller@users.noreply.github.com> Date: Sun, 19 Apr 2026 21:52:40 -0400 Subject: [PATCH 7/8] systemd: remove bogus LISTEN_PID env and add runtime dir + address family restriction --- systemd/sourceos-gate-egress.service | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/systemd/sourceos-gate-egress.service b/systemd/sourceos-gate-egress.service index eca95a1..030f033 100644 --- a/systemd/sourceos-gate-egress.service +++ b/systemd/sourceos-gate-egress.service @@ -9,8 +9,9 @@ Type=simple # Store root for state/audit. Environment=SOURCEOS_STORE_ROOT=/var/lib/sourceos -# Systemd socket activation passes the listening FD (LISTEN_FDS) to the service. -Environment=LISTEN_PID=%p +# Create /run/sourceos for the socket path if needed. +RuntimeDirectory=sourceos +RuntimeDirectoryMode=0755 # Run daemon; it will use systemd socket activation when available. ExecStart=/usr/bin/env python3 /usr/lib/sourceos/tools/sourceos_gate_egressd.py --store-root ${SOURCEOS_STORE_ROOT} @@ -25,6 +26,9 @@ ProtectSystem=strict ProtectHome=yes ReadWritePaths=/var/lib/sourceos /run/sourceos +# The daemon must not listen on TCP; it only needs unix sockets + netlink (nft). +RestrictAddressFamilies=AF_UNIX AF_NETLINK + # Needs nft mutation when applying; run as root (socket perms gate callers). User=root Group=root From 06fad20621c850c2147bfdab361f5058869ae71e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:01:28 +0000 Subject: [PATCH 8/8] docs: fix markdownlint MD032 in releases notes Agent-Logs-Url: https://github.com/SociOS-Linux/SourceOS/sessions/5df924d4-455d-4eb8-9669-3e03efa9da8d Co-authored-by: mdheller <21163552+mdheller@users.noreply.github.com> --- docs/RELEASES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/RELEASES.md b/docs/RELEASES.md index 49de3e1..a81fc68 100644 --- a/docs/RELEASES.md +++ b/docs/RELEASES.md @@ -48,6 +48,7 @@ We consider **Truth Plane v0.1** achieved when: - A compatibility alias remains for at least one minor cycle. Example: + - `_nft_set_elements_json_from_obj` is a compatibility alias for `parse_nft_set_elements_json` and will be removed after v0.1. ---