diff --git a/README.md b/README.md index 32ccf38..2e83cdd 100644 --- a/README.md +++ b/README.md @@ -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:///auto/v4.1 + --input http:///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 ``` -`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. diff --git a/crates/ec-node/src/main.rs b/crates/ec-node/src/main.rs index 2e127ee..4d2e9ae 100644 --- a/crates/ec-node/src/main.rs +++ b/crates/ec-node/src/main.rs @@ -450,6 +450,10 @@ struct WtPublishArgs { /// Gossip peers to connect to for control announcements (repeatable). #[arg(long)] gossip_peer: Vec, + /// 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, } #[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" ); diff --git a/evolution/proposals/ECP-0069-nixos-control-bridge-autobootstrap.md b/evolution/proposals/ECP-0069-nixos-control-bridge-autobootstrap.md new file mode 100644 index 0000000..0f4e220 --- /dev/null +++ b/evolution/proposals/ECP-0069-nixos-control-bridge-autobootstrap.md @@ -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 `. +- 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-.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. diff --git a/nix/modules/ec-node.nix b/nix/modules/ec-node.nix index 2e426f1..50b0804 100644 --- a/nix/modules/ec-node.nix +++ b/nix/modules/ec-node.nix @@ -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; + }; + }); }; }