host.daemon¶
Sentinel-tagged daemons on remote hosts: launch, discover, reap.
launch_command wraps an argv in bash -c 'exec -a "$1" "${@:2}"' so the
process’s argv[0] IS the sentinel (discoverable via ps -eo args=), launched
under systemd-run --user with a setsid fallback. Extracted from
otto.tunnel.socat (#2b), renamed from otto.host.detached
(2026-07-11): the module owns the daemon lifecycle vocabulary — launch,
discover (ps scan), reap — shared by tunnels and link-impairment timers
without a tunnel<->link import edge.
- otto.host.daemon.launch_command(sentinel: str, argv: list[str]) str¶
Build the
host.execline for a detached, tagged, session-surviving daemon.bash -c 'exec -a "$1" "${@:2}"' _ <sentinel> <program argv…>sets the process’sargv[0]to the sentinel (exec -a— a bash builtin; bash is required on the target host).argvis the FULL program argv (its first element is the program to run, e.g."socat"), so the template must NOT hardcode a program name — hardcoding one runsprog prog <args…>and dies on the bogus duplicate.Surviving the ssh session is the subtle part (found via live-bed e2e). On a systemd host, a process left in the ssh session’s scope is killed when that session ends, and
setsiddoes NOT escape the session cgroup — so we launch it in the USER manager’s scope viasystemd-run --user(no sudo, no root; the transient unit is--collect``ed on exit). On a non-systemd host (older distros — the portability floor), ``systemd-runis absent and a plainsetsid-detached background process survives normally, so we fall back to that.systemd-runbeing present onPATHdoesn’t guarantee it works (found via live-bed e2e against a centos:7 container): the binary ships in the base image even though nothing runs an actual systemd/dbus user session inside the container, so invoking it fails fast with “Failed to create bus connection: Connection refused” rather than being absent. The&&folds that real invocation into theifcondition itself (not just acommand -vexistence probe), so any failure — missing binary or a present-but-unusable one — falls through to thesetsidbranch. The real invocation is additionally bounded by_SYSTEMD_RUN_PROBE_TIMEOUTso a hang-shaped breakage (not just a fast-failing one) still folds through.The whole
if/then/else/ficonditional is wrapped in an outerbash -c '<body>'so the returned string is one opaque, quoted word — safe for a caller to compose into a larger command by naive textual prefixing (found via live-bed e2e:otto.host.privilege.PosixPrivilege ._elevatebuildsf"sudo -S -p 'x' {cmd}"; bash only recognizesifas the reserved word that opens a conditional in command-start position, so splicing it in assudo’s trailing argument list demotesifto a literal word and leaves the laterthen/else/fias syntax errors with no matchingif. bash then refuses to parse the entire input line — not just this fragment — so not even the caller’s own sentinel echoes run, and the caller hangs until its own outer timeout. This is whatotto.link’s expire-timer launch (the onlysudo=Truecaller of this function) was actually hitting, not a systemd-run/dbus stall: reproduced 3/3 unpatched (12s bound, never completes) and 3/3 resolved by this wrap (sub-20ms, exit 0) against the live bed. A single outerbash -clayer is harmless to the survival mechanics — it only dispatches the real branch (systemd-run --user --collector the backgroundedsetsidsubshell) and exits; the finalexec -a’d process is unaffected either way).
- otto.host.daemon.ps_scan_command(prefix: str) str¶
Portable
psscan for daemons whose argv[0] starts with<prefix>:.Each field is its own
-eoflag rather than one comma-joined-eo pid=,etime=,args=(found via live-bed e2e against a centos:7 container): procps-ng 3.3.10 silently mis-parses the comma-combined form (columns bleed into each other), while the separate-flag form produces identical output on modern procps (4.x) too. Formattedetime(notetimes) keeps 2.6.32-era userland working;|| trueso a no-match grep (exit 1) is not a command failure.\grep(backslash-escaped) is load-bearing: sessions that run an interactive login shell (telnet terms) apply the distro aliasgrep='grep --color=auto', and with the session PTY as stdout the ANSI color codes it injects corrupt the sentinel tokens — the scan goes blind on exactly those hosts while ssh (non-interactive) stays clean (live-bed finding 2026-07-11). The backslash suppresses alias expansion in every POSIX shell and is a no-op where no alias exists.prefix must match
[A-Za-z0-9][A-Za-z0-9-]*: it lands inside a single-quoted grep BRE, where a quote would break the shell line, a regex metacharacter would change match semantics, and the|| truetail would mask the resulting grep failure as an empty (falsely clean) scan.- Raises:¶
ValueError – If prefix contains characters outside that safe set.
- otto.host.daemon.parse_etime(text: str) int¶
Procps
etime([[DD-]HH:]MM:SSor bareSS) → seconds.Returns
0for anything unparseable rather than raising — one host emitting a malformedetimemust not take down a whole scan.
- class otto.host.daemon.DaemonProcess(pid: int, age_seconds: int, token: str)¶
Bases:
objectOne sentinel-tagged daemon seen in a ps scan (token not yet decoded).
- otto.host.daemon.parse_ps_output(output: str, prefix: str) list[DaemonProcess]¶
Reconstruct tagged daemons from
ps_scan_command()output.Domain modules decode each
DaemonProcess.tokenwith their own sentinel parser; anything undecodable is theirs to skip.
- otto.host.daemon.kill_command(pids: Iterable[int]) str¶
kill <sorted pids>line for reaping tagged daemons on one host.- Raises:¶
ValueError – If pids is empty — a bare
killis a usage error on the host, so an empty reap set must be handled by the caller.
- otto.host.daemon.enc(value: str | int | None) str¶
Percent-encode one sentinel segment (
safe="");None→ empty.
- otto.host.daemon.encode_token(prefix: str, version: str, segments: Sequence[str]) str¶
<prefix>:<version>:<payload segments joined with ':'>.segments are the payload only and must be FINAL strings (already percent-encoded as the domain codec requires) — framing never re-encodes, so a domain is free to double-encode a compound segment.
- otto.host.daemon.split_token(token: str, prefix: str, version: str, count: int) list[str] | None¶
Split a wire token;
Nonefor non-matching / other-version / malformed.count is the expected PAYLOAD segment count (prefix and version are checked separately). Unknown versions parse to
None, never an error — the stability contract lets old parsers ignore newer wire formats.