feat(wallet,content,seed): Fedimint dual-ecash, paid content streaming, seed ceremony
- Fedimint ecash alongside Cashu: fedimint-clientd (fmcd) HTTP bridge, fedimint_client, fedimint RPC, wallet wiring - Paid peer content: content invoices + streaming content server + content RPCs - Seed-phrase ceremony/reveal RPCs and CLI ceremony tool - LND wallet, mesh status/messaging, app-stack (netbird HTTPS), and decoupled-update wiring; Fedimint Client core app in catalog Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c10f2ac22e
commit
bd567cd165
@@ -1804,14 +1804,22 @@ impl RpcHandler {
|
||||
let host_ip = detect_netbird_public_host_ip()
|
||||
.await
|
||||
.unwrap_or_else(|| self.config.host_ip.clone());
|
||||
write_netbird_config_files(&host_ip).await?;
|
||||
|
||||
// Create the network FIRST so we can read back the gateway it was
|
||||
// assigned — that gateway is Podman's aardvark DNS, which the proxy's
|
||||
// nginx needs as an explicit `resolver` to re-resolve container names
|
||||
// (issue #15: without it nginx caches a container IP and 502s forever
|
||||
// once that IP changes on restart/reboot).
|
||||
let _ = podman_stack_status(
|
||||
&["network", "create", "netbird-net"],
|
||||
PODMAN_STACK_PROBE_TIMEOUT,
|
||||
)
|
||||
.await;
|
||||
|
||||
let resolver_ip = netbird_net_resolver_ip().await;
|
||||
write_netbird_config_files(&host_ip, &self.config.host_ip, &resolver_ip).await?;
|
||||
ensure_netbird_tls_cert(&host_ip).await?;
|
||||
|
||||
let mut server_cmd = tokio::process::Command::new("podman");
|
||||
server_cmd.args([
|
||||
"run",
|
||||
@@ -1849,6 +1857,10 @@ impl RpcHandler {
|
||||
"netbird-dashboard",
|
||||
"--network",
|
||||
"netbird-net",
|
||||
// Explicit alias so the proxy can always resolve `netbird-dashboard`
|
||||
// via Podman DNS — don't rely on implicit container-name aliasing.
|
||||
"--network-alias",
|
||||
"netbird-dashboard",
|
||||
"--restart=unless-stopped",
|
||||
"--env-file",
|
||||
"/var/lib/archipelago/netbird/dashboard.env",
|
||||
@@ -1865,10 +1877,16 @@ impl RpcHandler {
|
||||
"--network",
|
||||
"netbird-net",
|
||||
"--restart=unless-stopped",
|
||||
// 8087 publishes the TLS listener — netbird's dashboard requires a
|
||||
// secure context (window.crypto.subtle / OIDC PKCE), issue #15.
|
||||
"-p",
|
||||
"8087:80",
|
||||
"8087:443",
|
||||
"-v",
|
||||
"/var/lib/archipelago/netbird/nginx.conf:/etc/nginx/conf.d/default.conf:ro",
|
||||
"-v",
|
||||
"/var/lib/archipelago/netbird/tls.crt:/etc/nginx/tls.crt:ro",
|
||||
"-v",
|
||||
"/var/lib/archipelago/netbird/tls.key:/etc/nginx/tls.key:ro",
|
||||
NETBIRD_PROXY_IMAGE,
|
||||
]);
|
||||
run_required_stack_command("netbird", "create unified proxy", &mut proxy_cmd).await?;
|
||||
@@ -1913,9 +1931,108 @@ async fn read_or_generate_b64_secret(name: &str) -> String {
|
||||
secret
|
||||
}
|
||||
|
||||
async fn write_netbird_config_files(host_ip: &str) -> Result<()> {
|
||||
let public_origin = format!("http://{}:8087", host_ip);
|
||||
/// Read the gateway of the `netbird-net` bridge. Podman runs its aardvark DNS
|
||||
/// resolver on this address, so nginx can use it as an explicit `resolver` to
|
||||
/// re-resolve container names at request time. Falls back to Podman's usual
|
||||
/// first-pool gateway if the inspect fails (best effort — config is rewritten
|
||||
/// on every (re)install).
|
||||
async fn netbird_net_resolver_ip() -> String {
|
||||
let out = tokio::process::Command::new("podman")
|
||||
.args([
|
||||
"network",
|
||||
"inspect",
|
||||
"netbird-net",
|
||||
"--format",
|
||||
"{{range .Subnets}}{{.Gateway}}{{end}}",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
if let Ok(o) = out {
|
||||
let gw = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
||||
if !gw.is_empty() && gw.parse::<std::net::IpAddr>().is_ok() {
|
||||
return gw;
|
||||
}
|
||||
}
|
||||
"10.89.0.1".to_string()
|
||||
}
|
||||
|
||||
/// Generate a self-signed TLS cert for the netbird proxy if absent. The
|
||||
/// dashboard needs a secure context (window.crypto.subtle / OIDC PKCE), so the
|
||||
/// proxy serves HTTPS; a self-signed cert is sufficient (the user accepts it
|
||||
/// once when opening netbird in a tab). SAN covers the LAN IP plus
|
||||
/// localhost/127.0.0.1 so it's valid however the box is reached locally.
|
||||
async fn ensure_netbird_tls_cert(host_ip: &str) -> Result<()> {
|
||||
let dir = "/var/lib/archipelago/netbird";
|
||||
let crt = format!("{dir}/tls.crt");
|
||||
let key = format!("{dir}/tls.key");
|
||||
if tokio::fs::metadata(&crt).await.is_ok() && tokio::fs::metadata(&key).await.is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
let _ = tokio::fs::create_dir_all(dir).await;
|
||||
let san = format!("subjectAltName=IP:{host_ip},IP:127.0.0.1,DNS:localhost");
|
||||
let status = tokio::process::Command::new("openssl")
|
||||
.args([
|
||||
"req",
|
||||
"-x509",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-nodes",
|
||||
"-keyout",
|
||||
&key,
|
||||
"-out",
|
||||
&crt,
|
||||
"-days",
|
||||
"3650",
|
||||
"-subj",
|
||||
&format!("/CN={host_ip}"),
|
||||
"-addext",
|
||||
&san,
|
||||
])
|
||||
.status()
|
||||
.await
|
||||
.context("failed to run openssl for netbird TLS cert")?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("openssl failed to generate netbird TLS cert");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_netbird_config_files(
|
||||
host_ip: &str,
|
||||
lan_ip: &str,
|
||||
resolver_ip: &str,
|
||||
) -> Result<()> {
|
||||
// netbird's dashboard uses window.crypto.subtle (OIDC PKCE), which browsers
|
||||
// only expose in a SECURE context — so the proxy serves HTTPS and every
|
||||
// origin here is https (issue #15: over plain http the dashboard threw
|
||||
// "window.crypto.subtle is unavailable" and never reached login).
|
||||
let public_origin = format!("https://{}:8087", host_ip);
|
||||
let server_origin = format!("http://{}:8086", host_ip);
|
||||
// A single box is reached via several addresses. Allow the OIDC login flow
|
||||
// to redirect back to whichever origin the user actually used, otherwise
|
||||
// post-login lands on the wrong host and the dashboard shows
|
||||
// "Unauthenticated" (issue #15). The browser-side CORS is handled in the
|
||||
// nginx proxy; this covers the redirect-URI allow-list.
|
||||
let lan_origin = format!("https://{}:8087", lan_ip);
|
||||
let mut redirect_origins = vec![public_origin.clone()];
|
||||
if lan_origin != public_origin {
|
||||
redirect_origins.push(lan_origin);
|
||||
}
|
||||
let dashboard_redirect_uris = redirect_origins
|
||||
.iter()
|
||||
.flat_map(|o| {
|
||||
[
|
||||
format!(" - \"{o}/nb-auth\""),
|
||||
format!(" - \"{o}/nb-silent-auth\""),
|
||||
]
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let dashboard_logout_uris = redirect_origins
|
||||
.iter()
|
||||
.map(|o| format!(" - \"{o}/\""))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let relay_secret = read_or_generate_b64_secret("netbird-relay-auth-secret").await;
|
||||
let encryption_key = read_or_generate_b64_secret("netbird-store-encryption-key").await;
|
||||
let config = format!(
|
||||
@@ -1935,10 +2052,9 @@ async fn write_netbird_config_files(host_ip: &str) -> Result<()> {
|
||||
localAuthDisabled: false
|
||||
signKeyRefreshEnabled: false
|
||||
dashboardRedirectURIs:
|
||||
- "{public_origin}/nb-auth"
|
||||
- "{public_origin}/nb-silent-auth"
|
||||
{dashboard_redirect_uris}
|
||||
dashboardPostLogoutRedirectURIs:
|
||||
- "{public_origin}/"
|
||||
{dashboard_logout_uris}
|
||||
cliRedirectURIs:
|
||||
- "http://localhost:53000/"
|
||||
store:
|
||||
@@ -1972,12 +2088,23 @@ LETSENCRYPT_DOMAIN=none
|
||||
|
||||
let nginx_conf = format!(
|
||||
r#"server {{
|
||||
listen 80;
|
||||
listen 443 ssl;
|
||||
server_name _;
|
||||
|
||||
# Route browser API/auth through the host-published server port. Rootless
|
||||
# Podman can give netbird-server a new container IP on restart while nginx
|
||||
# keeps an old resolved address, which breaks login with 502s.
|
||||
# netbird's dashboard needs a secure context (window.crypto.subtle for OIDC
|
||||
# PKCE), so the proxy terminates TLS with a self-signed cert (issue #15).
|
||||
ssl_certificate /etc/nginx/tls.crt;
|
||||
ssl_certificate_key /etc/nginx/tls.key;
|
||||
|
||||
# Rootless Podman can hand a container a new IP across restarts/reboots.
|
||||
# nginx resolves a literal upstream name ONCE at startup and caches it, so
|
||||
# after the IP moves every request 502s with "host unreachable" (issue #15,
|
||||
# observed live on .198: nginx pinned to a dead netbird-dashboard IP). Fix:
|
||||
# point `resolver` at the netbird-net gateway (Podman's aardvark DNS) and
|
||||
# use VARIABLE upstreams, which forces nginx to re-resolve the container
|
||||
# names at request time. Everything is reached container-to-container by
|
||||
# name so nothing depends on host-published ports either.
|
||||
resolver {resolver_ip} valid=10s ipv6=off;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -1986,24 +2113,60 @@ LETSENCRYPT_DOMAIN=none
|
||||
proxy_http_version 1.1;
|
||||
|
||||
location ~ ^/(relay|ws-proxy/) {{
|
||||
proxy_pass http://host.containers.internal:8086;
|
||||
set $nb_server netbird-server;
|
||||
proxy_pass http://$nb_server:80;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 1d;
|
||||
}}
|
||||
|
||||
location ~ ^/(api|oauth2)(/|$) {{
|
||||
proxy_pass http://host.containers.internal:8086;
|
||||
# The dashboard is a SPA whose API/OIDC base URL is baked at build time
|
||||
# to one host:port. A single box is reached via several addresses (LAN
|
||||
# IP, Tailscale 100.x, hostname), so those fetches are cross-origin and
|
||||
# the browser blocks them with no Access-Control-Allow-Origin (issue
|
||||
# #15, observed live on .198). Reflect the caller's Origin so the
|
||||
# self-hosted management/OIDC API is reachable from any of them, and
|
||||
# answer the CORS preflight here.
|
||||
if ($request_method = OPTIONS) {{
|
||||
add_header Access-Control-Allow-Origin $http_origin always;
|
||||
add_header Access-Control-Allow-Credentials true always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept" always;
|
||||
add_header Access-Control-Max-Age 86400 always;
|
||||
add_header Content-Length 0;
|
||||
return 204;
|
||||
}}
|
||||
add_header Access-Control-Allow-Origin $http_origin always;
|
||||
add_header Access-Control-Allow-Credentials true always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept" always;
|
||||
set $nb_server netbird-server;
|
||||
proxy_pass http://$nb_server:80;
|
||||
}}
|
||||
|
||||
location ~ ^/(signalexchange\.SignalExchange|management\.ManagementService|management\.ProxyService)/ {{
|
||||
grpc_pass grpc://netbird-server:80;
|
||||
set $nb_server netbird-server;
|
||||
grpc_pass grpc://$nb_server:80;
|
||||
grpc_read_timeout 1d;
|
||||
grpc_send_timeout 1d;
|
||||
}}
|
||||
|
||||
# OIDC callback routes are client-side SPA routes with NO prebuilt page in
|
||||
# the dashboard bundle, so proxying them straight through 404s — which
|
||||
# crashes the dashboard's auth init and shows "Unauthenticated" with dead
|
||||
# buttons (issue #15, confirmed live on .198: /nb-auth + /nb-silent-auth
|
||||
# returned 404). Serve the dashboard's index.html at these paths (URL
|
||||
# unchanged) so react-oidc boots and completes the login / silent-SSO.
|
||||
location ~ ^/(nb-auth|nb-silent-auth) {{
|
||||
set $nb_dashboard netbird-dashboard;
|
||||
rewrite ^.*$ /index.html break;
|
||||
proxy_pass http://$nb_dashboard:80;
|
||||
}}
|
||||
|
||||
location / {{
|
||||
proxy_pass http://netbird-dashboard:80;
|
||||
set $nb_dashboard netbird-dashboard;
|
||||
proxy_pass http://$nb_dashboard:80;
|
||||
}}
|
||||
}}
|
||||
|
||||
@@ -2024,10 +2187,29 @@ async fn detect_netbird_public_host_ip() -> Option<String> {
|
||||
.await
|
||||
.ok()?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
stdout
|
||||
.split_whitespace()
|
||||
.find(|ip| ip.starts_with("100.") && ip.contains('.'))
|
||||
.map(str::to_string)
|
||||
let ips: Vec<&str> = stdout.split_whitespace().filter(|s| s.contains('.')).collect();
|
||||
|
||||
// Prefer the LAN address as the canonical origin — that's what users browse
|
||||
// to on the local network. Baking the Tailscale 100.x address here broke
|
||||
// LAN access with cross-origin/redirect mismatches (issue #15). Tailscale
|
||||
// (100.64.0.0/10 CGNAT) is only a fallback for nodes with no LAN IP.
|
||||
let is_private_lan = |ip: &str| {
|
||||
ip.starts_with("192.168.")
|
||||
|| ip.starts_with("10.")
|
||||
|| (ip.starts_with("172.")
|
||||
&& ip
|
||||
.split('.')
|
||||
.nth(1)
|
||||
.and_then(|o| o.parse::<u8>().ok())
|
||||
.map(|o| (16..=31).contains(&o))
|
||||
.unwrap_or(false))
|
||||
};
|
||||
if let Some(lan) = ips.iter().find(|ip| is_private_lan(ip)) {
|
||||
return Some(lan.to_string());
|
||||
}
|
||||
ips.iter()
|
||||
.find(|ip| ip.starts_with("100."))
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in New Issue
Block a user