fix: first-boot container creation, remote input relay, ISO packages

Critical first-boot fixes (root cause: ALL 25 containers failed on install):
- Fix image-versions.sh sourcing: multi-path fallback for /opt/archipelago/scripts/
- Fix --add-host host-gateway: resolve actual gateway IP (podman 4.3 compat)
- Fix disk size detection: check /var/lib/archipelago not / (was forcing prune on 428GB disk)
- Fix Bitcoin health check: expand $RPC vars at creation, not inside container
- Add --network-alias to all containers (aardvark-dns reliability)
- Add --network-alias to backend RPC install handler

ISO build:
- Add apache2-utils for htpasswd (Fedimint gateway password hashing)

Remote input:
- Add broadcast relay channel for companion app → browser input forwarding
- Add /ws/remote-relay WebSocket endpoint
- Android: NES controller improvements, server connect flow updates

Container images:
- Fix lnd-ui Dockerfile: listen on 8080, run as root user (rootless compat)
- Fix bitcoin-ui, electrs-ui Dockerfiles: root user for rootless podman

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-02 10:34:58 +01:00
parent 8de5db6518
commit 5ec4a7285a
13 changed files with 238 additions and 71 deletions

View File

@@ -13,6 +13,7 @@ use crate::state::StateManager;
use anyhow::Result;
use hyper::{Method, Request, Response, StatusCode};
use std::sync::Arc;
use tokio::sync::broadcast;
use tracing::debug;
/// Build an HTTP response without unwrap. Falls back to a plain 500 if builder fails.
@@ -32,6 +33,8 @@ pub struct ApiHandler {
state_manager: Arc<StateManager>,
metrics_store: Arc<MetricsStore>,
session_store: SessionStore,
/// Broadcast channel for relaying companion app input to remote browsers.
input_relay_tx: broadcast::Sender<String>,
}
impl ApiHandler {
@@ -50,6 +53,7 @@ impl ApiHandler {
)
.await?,
);
let (input_relay_tx, _) = broadcast::channel(64);
Ok(Self {
config,
@@ -57,6 +61,7 @@ impl ApiHandler {
state_manager,
metrics_store,
session_store,
input_relay_tx,
})
}
@@ -147,7 +152,16 @@ impl ApiHandler {
tracing::warn!("401 WebSocket /ws/remote-input — session invalid or missing");
return Ok(Self::unauthorized());
}
return Self::handle_remote_input(req).await;
return Self::handle_remote_input(req, self.input_relay_tx.clone()).await;
}
// Remote relay WebSocket — browser receives companion input events
if method == Method::GET && path == "/ws/remote-relay" {
if !self.is_authenticated(req.headers()).await {
tracing::warn!("401 WebSocket /ws/remote-relay — session invalid or missing");
return Ok(Self::unauthorized());
}
return Self::handle_remote_relay(req, self.input_relay_tx.subscribe()).await;
}
// Convert body to bytes for non-WS routes

View File

@@ -5,6 +5,7 @@ use hyper_ws_listener::WsStream;
use serde::Deserialize;
use std::time::Instant;
use tokio::process::Command;
use tokio::sync::broadcast;
use tokio_tungstenite::tungstenite::Message;
use tracing::{debug, info, warn};
@@ -121,6 +122,7 @@ async fn handle_input(msg: &str) -> Result<Option<String>> {
impl ApiHandler {
pub(super) async fn handle_remote_input(
req: Request<hyper::Body>,
relay_tx: broadcast::Sender<String>,
) -> Result<Response<hyper::Body>> {
let (response, ws_fut_opt) = hyper_ws_listener::create_ws(req)
.map_err(|e| anyhow::anyhow!("WebSocket upgrade failed: {}", e))?;
@@ -183,6 +185,9 @@ impl ApiHandler {
continue; // silently drop
}
// Relay to connected browsers (best-effort, ignore if no receivers)
let _ = relay_tx.send(text.clone());
match handle_input(&text).await {
Ok(Some(reply)) => {
let _ = tx.send(Message::Text(reply)).await;
@@ -220,4 +225,88 @@ impl ApiHandler {
Ok(response)
}
/// Browser relay — receives input events from the broadcast channel and forwards to the browser.
pub(super) async fn handle_remote_relay(
req: Request<hyper::Body>,
mut relay_rx: broadcast::Receiver<String>,
) -> Result<Response<hyper::Body>> {
let (response, ws_fut_opt) = hyper_ws_listener::create_ws(req)
.map_err(|e| anyhow::anyhow!("WebSocket upgrade failed: {}", e))?;
if let Some(ws_fut) = ws_fut_opt {
tokio::spawn(async move {
let ws_stream: WsStream = match ws_fut.await {
Ok(Ok(s)) => s,
Ok(Err(e)) => {
debug!("Remote relay WS handshake failed: {}", e);
return;
}
Err(e) => {
debug!("Remote relay WS task join failed: {}", e);
return;
}
};
info!("Remote relay browser connected");
let (mut tx, mut rx) = ws_stream.split();
let _ = tx.send(Message::Text(r#"{"t":"ok"}"#.to_string())).await;
let ping_interval = tokio::time::interval(tokio::time::Duration::from_secs(30));
tokio::pin!(ping_interval);
let mut last_activity = Instant::now();
const INACTIVITY_TIMEOUT: u64 = 300;
loop {
tokio::select! {
_ = ping_interval.tick() => {
if last_activity.elapsed().as_secs() >= INACTIVITY_TIMEOUT {
info!("Remote relay inactive, closing");
let _ = tx.send(Message::Close(None)).await;
break;
}
if tx.send(Message::Ping(vec![])).await.is_err() {
break;
}
}
input = relay_rx.recv() => {
match input {
Ok(text) => {
if tx.send(Message::Text(text)).await.is_err() {
break;
}
}
Err(broadcast::error::RecvError::Lagged(n)) => {
debug!("Remote relay lagged, skipped {} messages", n);
}
Err(broadcast::error::RecvError::Closed) => break,
}
}
msg = rx.next() => {
match msg {
Some(Ok(Message::Pong(_))) | Some(Ok(Message::Text(_))) => {
last_activity = Instant::now();
}
Some(Ok(Message::Ping(data))) => {
last_activity = Instant::now();
let _ = tx.send(Message::Pong(data)).await;
}
Some(Ok(Message::Close(_))) | None => break,
Some(Ok(_)) => { last_activity = Instant::now(); }
Some(Err(e)) => {
debug!("Remote relay stream error: {}", e);
break;
}
}
}
}
}
info!("Remote relay browser disconnected");
});
}
Ok(response)
}
}

View File

@@ -149,6 +149,8 @@ impl RpcHandler {
];
let is_tailscale = package_id == "tailscale";
// Explicit DNS alias for aardvark-dns (must outlive run_args)
let network_alias_flag = format!("--network-alias={}", container_name);
// Network mode
if is_tailscale {
@@ -182,6 +184,7 @@ impl RpcHandler {
.await;
if net_check.map(|s| s.success()).unwrap_or(false) {
run_args.push("--network=archy-net");
run_args.push(&network_alias_flag);
} else {
tracing::error!(
"archy-net network does not exist — {} will use default network. \