Provider Hooks¶
The partyline pager never speaks Docker, Tor, MiroTalk, or any backend's protocol. It runs two scripts per provider and reads one JSON object back. This keeps the privileged half in shell an admin can audit, and makes swapping backends a hook change rather than a rewrite.
Common plumbing¶
Every provider hook shares the same rules:
- stdin is always closed for the hook
- stderr is always logged
hook_timeout(frompolicy.toml) always applies- The JSON object must be the last stdout line starting with
{
Two contracts, not four¶
All three party lines use the same contract. Which network a
room is on is decided by the [provider.*] section that ran
the hook, never by anything the hook says. A hook wired to
the wrong network's compose file is caught by validation.
Up hook contract¶
Party lines (tor, i2p, rns) |
web |
|
|---|---|---|
| stdin | The shared secret, one line. | Closed immediately. |
| env | PARTYLINEPAGER_TTL_SECS, PARTYLINEPAGER_NOTE |
PARTYLINEPAGER_WEB_BASE_URL, PARTYLINEPAGER_WEB_PATH, PARTYLINEPAGER_WEB_STATIC_SLUG, PARTYLINEPAGER_TTL_SECS, PARTYLINEPAGER_NOTE |
| stdout | {"address":"...","ttl_secs":7200} |
{"url":"...","ttl_secs":7200} |
| exit | Non-zero = room didn't come up. The daemon tells the host, sends nothing to the roster, and does not spend quota. | Same. |
ttl_secs in the response is optional; the daemon uses
room_ttl from policy if absent.
No port travels with an address. partyline.sh accepts
only a bare address, and a pasted address:port breaks its
normalization.
Down hook contract¶
Receives PARTYLINEPAGER_ROOM_ID (the address for a party
line, the room URL for web). Non-zero exit is logged and
otherwise ignored: a container that won't die must not block
the next room.
Secret delivery¶
The secret arrives on stdin rather than in argv or the
environment, because /proc/<pid>/cmdline and
/proc/<pid>/environ are readable by any process running as
the same user. A web room has no secret, so its hook gets
nothing.
Address validation¶
The daemon validates hook output before anyone sees it:
| Network | Shape | Example |
|---|---|---|
| Tor | 56 lowercase base32 chars + .onion |
abc...xyz.onion |
| I2P | 52 lowercase base32 chars + .b32.i2p |
jhqkryid...abcd.b32.i2p |
| Reticulum | exactly 32 lowercase hex chars | 3a1c9d4e07b21f88c2a04e7d612b0f4e |
Whitespace and control characters are refused. A web room URL
must start with the configured base_url and have a path of
at most 200 characters drawn from [A-Za-z0-9_/-].
A hook that prints a log line where its JSON should be, an address for the wrong network, or a URL pointing at someone else's server fails loudly.
Shipped party line hooks¶
Each pair drives one transport directory's docker compose in
relay mode. By default the transport directory is under
transports/ (published image, no checkout). Set
TOR_PARTYLINE_DIR, I2P_PARTYLINE_DIR, or
RETICULUM_PARTYLINE_DIR in .env to point at a git checkout
under party-lines/ instead.
Near-identical by design; the differences are how each network signals reachability and what is shredded for a fresh address.
Every party line hook mints nothing and stores nothing itself. The relay is a fan-out for ciphertext it has no key for.
Tor hooks¶
hooks/provider-tor.sh and hooks/teardown-tor.sh
| Variable | Default | Meaning |
|---|---|---|
TOR_PARTYLINE_DIR |
/opt/tor-party-line |
Transport directory (image or checkout) |
COMPOSE_SERVICE |
partyline |
Service to start |
WIPE_ONION |
1 |
Discard onion key first, forcing a fresh address. 0 keeps a stable address. |
BOOTSTRAP_TIMEOUT |
240 |
Seconds to wait for Tor to publish |
Readiness: appearance of hidden_service/hostname, read
through docker compose exec (the directory is 0700
debian-tor inside the container).
I2P hooks¶
hooks/provider-i2p.sh and hooks/teardown-i2p.sh
| Variable | Default | Meaning |
|---|---|---|
I2P_PARTYLINE_DIR |
/opt/i2p-party-line |
Transport directory (image or checkout) |
I2P_COMPOSE_SERVICE |
partyline |
Service to start |
I2P_DATA_DIR |
/data/.partyline |
State dir inside container |
I2P_WIPE_KEY |
1 |
Discard destination key first |
I2P_BOOTSTRAP_TIMEOUT |
240 |
Seconds to wait for reachability |
I2P_RELAY_OVERRIDE |
/opt/partylinepager/hooks/i2p-relay.override.yml |
Compose overlay dropping /dev/snd and audio socket |
Readiness is not the address file. i2pd writes an address
when the key exists, but the destination is not callable until
its LeaseSet reaches the floodfills. The hook waits for
partyline.sh to log I2P destination active, which it prints
only after self-dialling through its own SOCKS proxy. This is a
string match on human-facing output and the most fragile thing
in these hooks.
The transport compose file must set command: ["relay"].
Without it, partyline.sh blocks on a confirmation prompt when
run detached (docker compose up -d), i2pd starts in the
background but the readiness check never executes, and the
hook times out. Measured bootstrap: 18-53s on a warm NetDb.
Reticulum hooks¶
hooks/provider-rns.sh and hooks/teardown-rns.sh
| Variable | Default | Meaning |
|---|---|---|
RETICULUM_PARTYLINE_DIR |
/opt/reticulum-party-line |
Transport directory (image or checkout) |
RETICULUM_COMPOSE_PROFILE |
reflector |
Required compose profile |
RETICULUM_COMPOSE_SERVICE |
reflector |
Service to start |
RETICULUM_DATA_DIR |
/app/data |
State dir inside container |
RETICULUM_CONTAINER_UID |
1000 |
UID the image runs as |
RETICULUM_WIPE_KEY |
1 |
Discard RNS identity first |
RETICULUM_BOOTSTRAP_TIMEOUT |
120 |
Seconds to wait for announcement |
The file is the readiness signal: rns_bridge.py writes the
destination hash only once the destination exists and is
announced. The hook wipes any earlier copy before starting.
Key wiping¶
Each backend's keys normally survive restarts. Without wiping, every party in your instance's history shares one address. The cost differs:
- Tor: 1-3 minutes of bootstrap
- I2P: ~23 seconds
- Reticulum: effectively nothing (derived from stored identity, not negotiated)
This is also why the host gets an immediate acknowledgement before the hook returns for Tor and I2P, and none for Reticulum, where it would be overtaken by the broadcast.
Shipped web hooks¶
hooks/provider-web.sh and hooks/teardown-web.sh are
deliberately trivial.
Unless PARTYLINEPAGER_WEB_STATIC_SLUG is set, the up hook
reads 20 bytes from /dev/urandom, hex-encodes them, and
prints {"url":"<base_url>/<path>/<40 hex chars>"}. 160 bits
from the OS CSPRNG.
When a static slug is set, that exact string is used as the room name every time. No Docker, no root, no waiting, no state.
The down hook does nothing but log. The partyline pager cannot close a web room. The URL keeps working after the TTL. The broadcast says so.
Runtime shim: Standard vs Full¶
hooks/plp-runtime.sh abstracts transport lifecycle across two
modes:
PARTYLINEPAGER_RUNTIME=compose(default, Standard): passthrough todocker composePARTYLINEPAGER_RUNTIME=direct(Full image): local process management via PID files under$PLP_PID_DIR(default/var/run/plp)
All provider hooks call plp-runtime.sh instead of docker
compose directly. In compose mode, PLP_COMPOSE_ARGS passes
extra flags (override files, profiles). In direct mode, the
shim starts transport scripts directly and manages them with
PID files and signal-based process tree collection.