nix/ec-node: auto-bootstrap web bridge from local control peers

This commit is contained in:
every.channel 2026-02-22 23:38:17 -08:00
parent 2778715304
commit c9996dd5ad
No known key found for this signature in database
4 changed files with 236 additions and 4 deletions

View file

@ -55,7 +55,9 @@ Publish (node -> Cloudflare relay):
cargo run -p ec-node -- wt-publish \
--url https://cdn.moq.dev/anon \
--name la-nbc \
--input http://<hdhr-host>/auto/v4.1
--input http://<hdhr-host>/auto/v4.1 \
--control-announce \
--control-endpoint-addr-out /tmp/la-nbc-control-endpoint.json
```
Watch (web):
@ -90,7 +92,7 @@ cargo run -p ec-node -- control-bridge-web \
--gossip-peer <node-a-endpoint-addr-json>
```
`control-announce`, `control-listen`, `control-resolve`, and `control-bridge-web` print both
`wt-publish`, `control-announce`, `control-listen`, `control-resolve`, and `control-bridge-web` print both
`control endpoint id` and `control endpoint addr` on startup. Use the `endpoint addr` JSON for
`--gossip-peer` when bootstrapping.

View file

@ -450,6 +450,10 @@ struct WtPublishArgs {
/// Gossip peers to connect to for control announcements (repeatable).
#[arg(long)]
gossip_peer: Vec<String>,
/// Optional path to write this publisher's control endpoint address JSON.
/// Useful for bootstrapping local control bridges without manual peer copy/paste.
#[arg(long)]
control_endpoint_addr_out: Option<PathBuf>,
}
#[derive(Parser, Debug)]
@ -4853,6 +4857,8 @@ async fn wt_publish(args: WtPublishArgs) -> Result<()> {
let discovery = parse_discovery(args.discovery.as_deref())?;
let endpoint = ec_iroh::build_endpoint(secret, discovery).await?;
let gossip_peers = parse_gossip_peers(args.gossip_peer.clone());
let endpoint_addr_json =
serde_json::to_string(&endpoint.addr()).unwrap_or_else(|_| endpoint.id().to_string());
let announcement = build_control_announcement(
args.name.clone(),
@ -4874,8 +4880,33 @@ async fn wt_publish(args: WtPublishArgs) -> Result<()> {
.await
{
Ok(stop_tx) => {
eprintln!("control endpoint id: {}", endpoint.id());
eprintln!("control endpoint addr: {}", endpoint_addr_json);
if let Some(path) = args.control_endpoint_addr_out.as_ref() {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| {
format!(
"failed to create control endpoint addr parent dir: {}",
parent.display()
)
})?;
}
fs::write(path, format!("{endpoint_addr_json}\n")).with_context(|| {
format!(
"failed to write control endpoint addr file: {}",
path.display()
)
})?;
tracing::info!(
path = %path.display(),
"wrote control endpoint addr file"
);
}
tracing::info!(
endpoint = %endpoint.id(),
endpoint_addr = %endpoint_addr_json,
stream = %args.name,
"control announce enabled"
);

View file

@ -0,0 +1,38 @@
# ECP-0069: NixOS Control Bridge Auto-Bootstrap
Status: Draft
## Decision
Extend the NixOS `services.every-channel.ec-node` module so web directory bridge startup is automatic and does not require manual peer copy/paste.
1. `ec-node wt-publish` gains `--control-endpoint-addr-out <path>`.
- When `--control-announce` is enabled, it writes the local control endpoint address JSON to the provided file.
- It also logs both control endpoint id and endpoint address at startup.
2. NixOS module updates:
- Publisher units pass `--control-endpoint-addr-out /run/every-channel/control-peer-<broadcast>.json`.
- New `control.bridgeWeb.*` options start a managed `every-channel-control-bridge-web` service.
- Bridge service reads endpoint-address files from running publishers and feeds them into `control-bridge-web --gossip-peer ...` automatically.
## Motivation
Browser users need `every.channel` to show active streams without manual bootstrap steps. Previously, the bridge had no stable way to discover local publishers after reboot/service restart. Writing endpoint-address files from publishers makes bridge bootstrap deterministic on one host.
## Scope
In scope:
- New `wt-publish` endpoint-address output flag.
- NixOS module wiring for endpoint file emission.
- Managed bridge service with restart-safe peer refresh.
Out of scope:
- Cross-host authenticated discovery trust model.
- Signed control announcements.
- Browser-native iroh direct transport playback.
## Rollout / Reversibility
- Additive: existing publisher behavior is unchanged when `control.bridgeWeb.enable = false`.
- Revert path: disable bridge service and/or remove endpoint-file arg.
- Failure mode: if no peer files exist, bridge waits and retries without failing system activation.

View file

@ -144,6 +144,51 @@ in
default = [ ];
description = "Optional iroh endpoint addresses to seed control gossip joins.";
};
bridgeWeb = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Run `ec-node control-bridge-web` to upsert announced streams into the web directory.";
};
directoryUrl = lib.mkOption {
type = lib.types.str;
default = "https://every.channel";
description = "Directory base URL used by control-bridge-web for `/api/stream-upsert`.";
};
authTokenFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Optional file containing bearer token for `/api/stream-upsert`.";
};
timeoutMs = lib.mkOption {
type = lib.types.ints.nonnegative;
default = 30000;
description = "Bridge run timeout; service restarts and refreshes gossip peers after this window (0 = run forever).";
};
maxAgeMs = lib.mkOption {
type = lib.types.ints.positive;
default = 60000;
description = "Maximum accepted staleness for control announcements consumed by the web bridge.";
};
streamPrefix = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Optional stream-id prefix filter for web upserts.";
};
discovery = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "dht,mdns,dns";
description = "Optional discovery mode override for the web bridge; falls back to `control.discovery` when unset.";
};
};
};
broadcasts = lib.mkOption {
@ -195,7 +240,7 @@ in
];
systemd.tmpfiles.rules = [
"d /run/every-channel 0755 root root - -"
"d /run/every-channel 1777 root root - -"
];
systemd.services =
@ -227,6 +272,7 @@ in
"cmd+=(${lib.concatStringsSep " " (map lib.escapeShellArg cfg.extraArgs)})";
explicitInputStr = if b.input == null then "" else b.input;
channelStr = if b.channel == null then "" else b.channel;
controlEndpointOutPath = "/run/every-channel/control-peer-${sanitizeUnitName b.name}.json";
controlDiscoveryStr = if cfg.control.discovery == null then "" else cfg.control.discovery;
controlIrohSecretStr = if cfg.control.irohSecret == null then "" else cfg.control.irohSecret;
controlGossipPeerLines = lib.concatMapStrings (peer: "cmd+=(--gossip-peer ${lib.escapeShellArg peer})\n") cfg.control.gossipPeers;
@ -356,6 +402,7 @@ in
if [[ -n "$control_iroh_secret" ]]; then
cmd+=(--iroh-secret "$control_iroh_secret")
fi
cmd+=(--control-endpoint-addr-out ${lib.escapeShellArg controlEndpointOutPath})
${controlGossipPeerLines}
''}
${extraArgsLine}
@ -407,11 +454,125 @@ in
RestrictSUIDSGID = true;
RestrictRealtime = true;
SystemCallArchitectures = "native";
ReadWritePaths = lib.optionals cfg.control.enable [ "/run/every-channel" ];
};
environment = cfg.environment;
};
})
cfg.broadcasts);
cfg.broadcasts)
// lib.optionalAttrs (cfg.control.enable && cfg.control.bridgeWeb.enable)
(let
bridgeUnit = "every-channel-control-bridge-web";
bridgePeerFiles = map (b: "/run/every-channel/control-peer-${sanitizeUnitName b.name}.json") cfg.broadcasts;
bridgeDiscoveryStr =
if cfg.control.bridgeWeb.discovery != null then cfg.control.bridgeWeb.discovery
else if cfg.control.discovery != null then cfg.control.discovery
else "";
bridgeStreamPrefixStr = if cfg.control.bridgeWeb.streamPrefix == null then "" else cfg.control.bridgeWeb.streamPrefix;
bridgeAuthTokenFile = if cfg.control.bridgeWeb.authTokenFile == null then "" else cfg.control.bridgeWeb.authTokenFile;
staticGossipPeerLines = lib.concatMapStrings (peer: ''
cmd+=(--gossip-peer ${lib.escapeShellArg peer})
have_peer=1
'') cfg.control.gossipPeers;
peerFileLoopLines = lib.concatMapStrings (peerFile: ''
if [[ -s ${lib.escapeShellArg peerFile} ]]; then
peer="$(tr -d '\r\n' < ${lib.escapeShellArg peerFile})"
if [[ -n "$peer" ]]; then
cmd+=(--gossip-peer "$peer")
have_peer=1
fi
fi
'') bridgePeerFiles;
bridgeRunner = pkgs.writeShellApplication {
name = bridgeUnit;
runtimeInputs = [
pkgs.coreutils
cfg.package
];
text = ''
set -euo pipefail
directory_url=${lib.escapeShellArg cfg.control.bridgeWeb.directoryUrl}
bridge_discovery=${lib.escapeShellArg bridgeDiscoveryStr}
stream_prefix=${lib.escapeShellArg bridgeStreamPrefixStr}
auth_token_file=${lib.escapeShellArg bridgeAuthTokenFile}
while true; do
cmd=(
${lib.escapeShellArg "${cfg.package}/bin/ec-node"}
control-bridge-web
--directory-url "$directory_url"
--timeout-ms ${toString cfg.control.bridgeWeb.timeoutMs}
--max-age-ms ${toString cfg.control.bridgeWeb.maxAgeMs}
)
if [[ -n "$bridge_discovery" ]]; then
cmd+=(--discovery "$bridge_discovery")
fi
if [[ -n "$stream_prefix" ]]; then
cmd+=(--stream-prefix "$stream_prefix")
fi
if [[ -n "$auth_token_file" && -r "$auth_token_file" ]]; then
token="$(tr -d '\r\n' < "$auth_token_file")"
if [[ -n "$token" ]]; then
cmd+=(--auth-token "$token")
fi
fi
have_peer=0
${peerFileLoopLines}
${staticGossipPeerLines}
if [[ "$have_peer" -eq 0 ]]; then
sleep 2
continue
fi
"''${cmd[@]}" || true
sleep 2
done
'';
};
in
{
"${bridgeUnit}" = {
description = "every.channel control bridge to web directory";
wantedBy = [ "multi-user.target" ];
after =
[ "network-online.target" ]
++ map (b: "every-channel-wt-publish-${sanitizeUnitName b.name}.service") cfg.broadcasts;
wants =
[ "network-online.target" ]
++ map (b: "every-channel-wt-publish-${sanitizeUnitName b.name}.service") cfg.broadcasts;
unitConfig = {
StartLimitIntervalSec = 0;
};
serviceConfig = {
Type = "simple";
ExecStart = "${bridgeRunner}/bin/${bridgeUnit}";
Restart = "always";
RestartSec = 2;
DynamicUser = true;
NoNewPrivileges = true;
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
LockPersonality = true;
MemoryDenyWriteExecute = true;
RestrictSUIDSGID = true;
RestrictRealtime = true;
SystemCallArchitectures = "native";
};
environment = cfg.environment;
};
});
};
}