nix/ec-node: auto-bootstrap web bridge from local control peers
This commit is contained in:
parent
2778715304
commit
c9996dd5ad
4 changed files with 236 additions and 4 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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;
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue