fix: ISO boot, container installs, VPN, nginx, companion input
All checks were successful
Build Archipelago ISO (dev) / build-iso (push) Successful in 30m53s

- LUKS auto-unlock: initramfs hook + systemd service + nofail fstab
- Rootfs packages: add passt, aardvark-dns, netavark, nftables for Podman 5.x
- nginx: resolver + variable proxy_pass for external domains (DNS at boot)
- Boot: loglevel=0 suppresses kernel warnings, serial console for QEMU
- Container installs: write configs before chown, sudo chown for LUKS volumes
- Container installs: build UI sidecars locally (not from registry) for auth injection
- Bitcoin UI: inject RPC auth from secrets file, --no-cache rebuild
- Secrets: chown to archipelago user in first-boot (backend needs read access)
- Podman: image_copy_tmp_dir for read-only /var/tmp in user namespace
- NostrVPN: enable service in auto-install, always include public relays
- NostrVPN: read tunnel IP from nvpn status (not just config file)
- VPN invite: v2 base64 no-pad format matching phone app
- Companion input: relay always active, kiosk skips relay listener (prevents double input)
- dev-start.sh: production build includes AIUI deployment

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-10 03:10:49 -04:00
parent 82419c52ab
commit 7393c5f158
10 changed files with 328 additions and 89 deletions

View File

@@ -185,7 +185,7 @@ impl ApiHandler {
continue; // silently drop
}
// Relay to connected browsers (best-effort, ignore if no receivers)
// Always relay to browser clients (remote browser sessions)
let _ = relay_tx.send(text.clone());
match handle_input(&text).await {

View File

@@ -265,15 +265,22 @@ impl RpcHandler {
run_args.push("--device=/dev/net/tun");
}
// Create data directories
self.create_data_dirs(package_id, &volumes).await;
// Create data directories (mkdir only — chown happens AFTER config files are written)
for volume in &volumes {
if let Some(host_path) = volume.split(':').next() {
if host_path.starts_with("/var/lib/archipelago/") {
if let Err(e) = std::fs::create_dir_all(host_path) {
tracing::warn!("Failed to create directory {}: {}", host_path, e);
}
}
}
}
// Pre-install: bitcoin.conf with rpcauth
// Pre-install: write config files BEFORE chown (dir is still owned by archipelago user)
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
self.write_bitcoin_conf(&rpc_user, &rpc_pass).await?;
}
// Pre-install: lnd.conf with Bitcoin RPC credentials
if package_id == "lnd" {
self.write_lnd_conf(&rpc_user, &rpc_pass).await?;
}
@@ -328,6 +335,9 @@ impl RpcHandler {
}
}
// NOW chown data directories to container UID (after all config files are written)
self.create_data_dirs(package_id, &volumes).await;
// Port mappings (skip for host-network containers)
if !is_tailscale {
for port in &ports {
@@ -637,22 +647,31 @@ impl RpcHandler {
}
// Set ownership to the mapped UID for rootless podman.
// This needs elevated privileges — use podman unshare to run
// chown inside the user namespace where UIDs are mapped.
let chown_result = tokio::process::Command::new("podman")
.args(["unshare", "chown", "-R", &uid_str, host_path])
// Try sudo chown first (works on LUKS), fall back to podman unshare.
let host_uid = format!("{}:{}", uid, uid);
let sudo_result = tokio::process::Command::new("sudo")
.args(["chown", "-R", &host_uid, host_path])
.output()
.await;
match chown_result {
Ok(out) if !out.status.success() => {
tracing::warn!(
"podman unshare chown failed for {}: {}",
host_path,
String::from_utf8_lossy(&out.stderr)
);
let sudo_ok = sudo_result.as_ref().map_or(false, |o| o.status.success());
if !sudo_ok {
// Fallback: podman unshare (works on non-LUKS ext4)
let container_uid = uid - 100000;
let container_uid_str = format!("{}:{}", container_uid, container_uid);
let chown_result = tokio::process::Command::new("podman")
.args(["unshare", "chown", "-R", &container_uid_str, host_path])
.output()
.await;
match chown_result {
Ok(out) if !out.status.success() => {
tracing::warn!(
"chown failed for {} (both sudo and podman unshare)",
host_path,
);
}
Err(e) => tracing::warn!("Failed to chown {}: {}", host_path, e),
_ => {}
}
Err(e) => tracing::warn!("Failed to chown {}: {}", host_path, e),
_ => {}
}
}
}
@@ -917,11 +936,21 @@ autopilot.active=false\n",
for dir in ["/opt/archipelago/docker/bitcoin-ui", "/home/archipelago/archy/docker/bitcoin-ui"] {
let conf_path = format!("{}/nginx.conf", dir);
if let Ok(content) = tokio::fs::read_to_string(&conf_path).await {
if content.contains("__BITCOIN_RPC_AUTH__") {
let updated = content.replace("__BITCOIN_RPC_AUTH__", &auth_b64);
let _ = tokio::fs::write(&conf_path, updated).await;
info!("Injected Bitcoin RPC auth into {}", conf_path);
}
// Replace placeholder or previously-injected auth (regex: Basic followed by base64 or placeholder)
let updated = content
.replace("__BITCOIN_RPC_AUTH__", &auth_b64)
.lines()
.map(|line| {
if line.contains("proxy_set_header Authorization") && line.contains("Basic") {
format!(" proxy_set_header Authorization \"Basic {}\";", auth_b64)
} else {
line.to_string()
}
})
.collect::<Vec<_>>()
.join("\n");
let _ = tokio::fs::write(&conf_path, format!("{}\n", updated)).await;
info!("Injected Bitcoin RPC auth into {}", conf_path);
}
}
}
@@ -949,7 +978,14 @@ autopilot.active=false\n",
for (name, ui_dir, image_base) in ui_builds {
let name = name.to_string();
let ui_dir = ui_dir.to_string();
// Check multiple paths: /opt (production), project tree (dev)
let ui_dir = [
ui_dir.to_string(),
format!("/home/archipelago/archy/docker/{}", image_base),
format!("/home/archipelago/Projects/archy/docker/{}", image_base),
].into_iter()
.find(|d| std::path::Path::new(d).join("Dockerfile").exists())
.unwrap_or_else(|| ui_dir.to_string());
let image_base = image_base.to_string();
let registry = "80.71.235.15:3000/archipelago";
let registry_image = format!("{}/{}:latest", registry, image_base);
@@ -961,19 +997,13 @@ autopilot.active=false\n",
.output()
.await;
// Try registry image first, fall back to local build
// Build locally first (templates may have injected credentials),
// fall back to registry only if no local Dockerfile exists.
let image = {
let pull = tokio::process::Command::new("podman")
.args(["pull", &registry_image])
.output()
.await;
if pull.map_or(false, |o| o.status.success()) {
info!("Pulled {} UI from registry", name);
registry_image.clone()
} else if std::path::Path::new(&ui_dir).exists() {
info!("Registry pull failed, building {} from {}", name, ui_dir);
if std::path::Path::new(&ui_dir).exists() {
info!("Building {} locally from {}", name, ui_dir);
let build = tokio::process::Command::new("podman")
.args(["build", "-t", &local_image, &ui_dir])
.args(["build", "--no-cache", "-t", &local_image, &ui_dir])
.output()
.await;
match build {
@@ -989,8 +1019,18 @@ autopilot.active=false\n",
}
}
} else {
warn!("No registry image or source for {} — skipping", name);
return;
// No local Dockerfile — try pulling from registry
let pull = tokio::process::Command::new("podman")
.args(["pull", &registry_image])
.output()
.await;
if pull.map_or(false, |o| o.status.success()) {
info!("Pulled {} UI from registry", name);
registry_image.clone()
} else {
warn!("No local source or registry image for {} — skipping", name);
return;
}
}
};

View File

@@ -269,19 +269,33 @@ impl RpcHandler {
let network_id = vpn::read_nvpn_config_list_entry("networks", "network_id").await
.unwrap_or_else(|| "nostr-vpn".to_string());
// Read relays from config
// Read relays from config — filter out localhost relays (unreachable from phone)
let relays = vpn::read_nvpn_config_list("nostr", "relays").await;
let relay_csv = if relays.is_empty() {
"wss://relay.damus.io,wss://relay.primal.net".to_string()
let reachable: Vec<String> = relays.iter()
.filter(|r| !r.contains("127.0.0.1") && !r.contains("localhost"))
.cloned()
.collect();
let invite_relays = if reachable.is_empty() {
vec!["wss://relay.damus.io".to_string(), "wss://relay.primal.net".to_string()]
} else {
relays.join(",")
reachable
};
// Build invite URL: nvpn://invite/<network_id>?npub=<npub>&relays=<csv>
let invite_url = format!(
"nvpn://invite/{}?npub={}&relays={}",
network_id, npub, relay_csv
);
// Build invite as base64-encoded JSON (nvpn v2 format, no padding)
use base64::Engine;
let invite_payload = serde_json::json!({
"v": 2,
"networkName": network_id,
"networkId": network_id,
"inviterNpub": npub,
"inviterNodeName": "archipelago",
"admins": [npub],
"participants": [npub],
"relays": invite_relays,
});
let invite_b64 = base64::engine::general_purpose::STANDARD_NO_PAD
.encode(invite_payload.to_string().as_bytes());
let invite_url = format!("nvpn://invite/{}", invite_b64);
// Generate QR code
let qr = qrcode::QrCode::new(invite_url.as_bytes())
@@ -295,11 +309,7 @@ impl RpcHandler {
"qr_svg": svg,
"npub": npub,
"network_id": network_id,
"relays": if relays.is_empty() {
vec!["wss://relay.damus.io".to_string(), "wss://relay.primal.net".to_string()]
} else {
relays
},
"relays": invite_relays,
}))
}

View File

@@ -322,8 +322,48 @@ async fn get_nostr_vpn_status() -> Result<VpnStatus> {
anyhow::bail!("nostr-vpn service not running");
}
// Quick IP check: read from nvpn config (TOML)
let ip = read_nvpn_config_value("node", "tunnel_ip").await;
// Get tunnel IP: try nvpn status first, fall back to config, then interface
let ip = {
// Method 1: nvpn status (most accurate)
let status_ip = tokio::process::Command::new("nvpn")
.args(["status"])
.env("HOME", "/var/lib/archipelago/nostr-vpn")
.env("XDG_CONFIG_HOME", "/var/lib/archipelago/nostr-vpn/.config")
.output()
.await
.ok()
.and_then(|o| {
let out = String::from_utf8_lossy(&o.stdout).to_string();
out.lines()
.find(|l| l.starts_with("tunnel_ip:"))
.map(|l| l.split(':').nth(1).unwrap_or("").trim().to_string())
})
.filter(|s| !s.is_empty());
if status_ip.is_some() {
status_ip
} else {
// Method 2: config file
let cfg_ip = read_nvpn_config_value("node", "tunnel_ip").await;
if cfg_ip.is_some() {
cfg_ip
} else {
// Method 3: interface IP
tokio::process::Command::new("ip")
.args(["-4", "addr", "show", "nvpn0"])
.output()
.await
.ok()
.and_then(|o| {
let out = String::from_utf8_lossy(&o.stdout).to_string();
out.lines()
.find(|l| l.contains("inet "))
.and_then(|l| l.split_whitespace().nth(1))
.map(|s| s.to_string())
})
}
}
};
Ok(VpnStatus {
connected: svc_state == "active",
@@ -424,17 +464,18 @@ pub async fn configure_nostr_vpn(data_dir: &Path) -> Result<()> {
if should_write {
// Gather relay URLs for the config
let (relay_onion, relay_direct) = get_relay_urls().await;
let mut relays = Vec::new();
// Always include public relays so peers can discover each other.
// Local/onion relays are added as extras for direct connectivity.
let mut relays = vec![
"\"wss://relay.damus.io\"".to_string(),
"\"wss://relay.primal.net\"".to_string(),
];
if let Some(ref onion) = relay_onion {
relays.push(format!("\"{}\"", onion));
}
if let Some(ref direct) = relay_direct {
relays.push(format!("\"{}\"", direct));
}
if relays.is_empty() {
relays.push("\"wss://relay.damus.io\"".to_string());
relays.push("\"wss://relay.primal.net\"".to_string());
}
let config_toml = format!(
"[nostr]\npublic_key = \"{npub}\"\nsecret_key = \"{nsec}\"\nrelays = [{relays}]\n\n[[networks]]\nnetwork_id = \"archipelago\"\nparticipants = []\n",