Merge remote-tracking branch 'origin/main' into archy-hwconfig

This commit is contained in:
2026-07-23 21:40:06 +00:00
89 changed files with 3564 additions and 359 deletions
+20 -2
View File
@@ -188,7 +188,7 @@ impl ApiHandler {
}
};
let price_sats = match &item.access {
content_server::AccessControl::Paid { price_sats } => *price_sats,
content_server::AccessControl::Paid { price_sats, .. } => *price_sats,
_ => {
// Not a paid item — no invoice to issue.
return Ok(build_response(
@@ -198,6 +198,13 @@ impl ApiHandler {
));
}
};
if !content_server::method_accepted(&item.access, "lightning") {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(r#"{"error":"The seller does not accept Lightning for this item"}"#),
));
}
let memo = format!("Archipelago peer file {content_id}");
match self
@@ -315,7 +322,18 @@ impl ApiHandler {
.unwrap_or_default();
let price_sats = match catalog.items.iter().find(|i| i.id == content_id) {
Some(i) => match &i.access {
content_server::AccessControl::Paid { price_sats } => *price_sats,
content_server::AccessControl::Paid { price_sats, .. } => {
if !content_server::method_accepted(&i.access, "onchain") {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(
r#"{"error":"The seller does not accept on-chain payment for this item"}"#,
),
));
}
*price_sats
}
_ => {
return Ok(build_response(
StatusCode::BAD_REQUEST,
+115 -1
View File
@@ -179,7 +179,24 @@ impl RpcHandler {
if price == 0 {
return Err(anyhow::anyhow!("Paid content requires price_sats > 0"));
}
AccessControl::Paid { price_sats: price }
// Optional list of payment methods the sharer accepts.
// Absent/empty = all methods (backward compatible).
const KNOWN_METHODS: [&str; 4] = ["lightning", "onchain", "ecash", "fedimint"];
let accepted: Vec<String> = params
.get("accepted_methods")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|m| m.as_str())
.filter(|m| KNOWN_METHODS.contains(m))
.map(str::to_string)
.collect()
})
.unwrap_or_default();
AccessControl::Paid {
price_sats: price,
accepted,
}
}
_ => return Err(anyhow::anyhow!("Invalid access type: {}", access_type)),
};
@@ -412,6 +429,55 @@ impl RpcHandler {
return Err(anyhow::anyhow!("Invalid v3 onion address"));
}
// NEVER pay twice for content we already own (2026-07-22: a file
// shared twice produced two catalog ids for the same bytes and the
// buyer paid both). Guard BEFORE any ecash is minted, matching both
// by exact (onion, content_id) and by (onion, filename) — the latter
// catches duplicate ids pointing at the same file on the same
// seller. The owned copy is served from the local cache instead.
{
let filename = params.get("filename").and_then(|v| v.as_str());
let owned = crate::content_owned::list_owned(&self.config.data_dir).await;
let already = owned.iter().find(|o| {
o.onion == onion
&& (o.content_id == content_id
|| filename.is_some_and(|f| {
!f.is_empty()
&& o.filename.trim_start_matches('/')
== f.trim_start_matches('/')
}))
});
if let Some(o) = already {
tracing::info!(
onion,
content_id,
owned_as = %o.content_id,
"paid download: already owned — serving cached copy, NOT paying again"
);
if let Some((mime, bytes)) = crate::content_owned::read_owned(
&self.config.data_dir,
&o.onion,
&o.content_id,
)
.await
{
use base64::Engine;
return Ok(serde_json::json!({
"owned": true,
"already_owned": true,
"filename": o.filename,
"mime_type": mime,
"size_bytes": bytes.len(),
"paid_sats": 0,
"data_base64":
base64::engine::general_purpose::STANDARD.encode(&bytes),
}));
}
// Cache record exists but bytes are gone — fall through and
// repurchase rather than stranding the user.
}
}
// `method` pins the backend the user confirmed in the UI ("cashu" |
// "fedimint"); absent = auto (Cashu first, then Fedimint). The seller's
// verify_payment_token accepts either, so a node whose balance lives in
@@ -590,6 +656,54 @@ impl RpcHandler {
tracing::warn!("paid download: failed to cache purchased content (non-fatal): {e:#}");
}
// Auto-file the purchase into the user's Files area (2026-07-22):
// Photos for images/video, Music for audio, Documents otherwise —
// same buckets the Cloud view uses. The in-app viewer still plays
// from the purchase cache; this makes the file ALSO show up where
// files live, on every device, without relying on a browser
// download. Best-effort: never fail a paid download over it.
{
let folder = if mime_type.starts_with("image/") || mime_type.starts_with("video/") {
"Photos"
} else if mime_type.starts_with("audio/") {
"Music"
} else {
"Documents"
};
let base = std::path::Path::new(&filename)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("download")
.to_string();
let dir = self.config.data_dir.join("filebrowser").join(folder);
if let Err(e) = tokio::fs::create_dir_all(&dir).await {
tracing::warn!("paid download: cannot create {}: {e}", dir.display());
} else {
// Don't clobber an existing file of the same name: "x.jpg"
// → "x (2).jpg" etc.
let mut target = dir.join(&base);
let (stem, ext) = match base.rsplit_once('.') {
Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")),
_ => (base.clone(), String::new()),
};
let mut n = 2;
while target.exists() {
target = dir.join(format!("{stem} ({n}){ext}"));
n += 1;
}
match tokio::fs::write(&target, &bytes).await {
Ok(()) => tracing::info!(
"paid download: filed into {}",
target.display()
),
Err(e) => tracing::warn!(
"paid download: filing into {} failed (non-fatal): {e}",
target.display()
),
}
}
}
use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
@@ -260,6 +260,7 @@ impl RpcHandler {
"wallet.fedimint-join" => self.handle_wallet_fedimint_join(params).await,
"wallet.fedimint-leave" => self.handle_wallet_fedimint_leave(params).await,
"wallet.fedimint-balance" => self.handle_wallet_fedimint_balance().await,
"wallet.fedimint-send" => self.handle_wallet_fedimint_send(params).await,
// Ark protocol (via barkd sidecar)
"wallet.ark-status" => self.handle_wallet_ark_status().await,
+25
View File
@@ -118,6 +118,31 @@ impl RpcHandler {
Ok(serde_json::json!({ "removed": removed }))
}
/// `wallet.fedimint-send` — spend ecash notes from any joined federation
/// with sufficient balance. Returns the notes token for the recipient
/// (rendered as text + QR by the send modal — the wallet's Fedi rail,
/// split from Cashu 2026-07-22). The heavy lifting already existed in
/// `fedimint_client::spend_from_any`; it was simply never exposed.
pub(super) async fn handle_wallet_fedimint_send(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let amount_sats = params
.as_ref()
.and_then(|p| p.get("amount_sats"))
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
anyhow::ensure!(amount_sats > 0, "must be at least 1 sat");
let (token, federation_id) =
crate::wallet::fedimint_client::spend_from_any(&self.config.data_dir, amount_sats)
.await?;
Ok(serde_json::json!({
"token": token,
"federation_id": federation_id,
"amount_sats": amount_sats,
}))
}
/// `wallet.fedimint-balance` — total sats across all joined federations.
pub(super) async fn handle_wallet_fedimint_balance(&self) -> Result<serde_json::Value> {
// Soft-fail to zero when clientd isn't installed/running, so the unified
+14 -2
View File
@@ -30,9 +30,21 @@ impl RpcHandler {
// The node's seed anchors ride along so the phone can rendezvous
// through the same public mesh points when the node's LAN endpoint
// isn't directly dialable (phone away from home, node behind NAT).
let anchors = fips::anchors::load(&self.config.data_dir)
let mut anchor_list = fips::anchors::load(&self.config.data_dir)
.await
.unwrap_or_default()
.unwrap_or_default();
// Pairing must always carry a public rendezvous point: without one the
// phone is IP-bound to the LAN host it scanned and goes dark the
// moment it leaves that network. This is a pairing hint only — the
// node's own anchor file is not modified, so an operator's removal of
// the default anchors still sticks for the node itself.
if !anchor_list
.iter()
.any(|a| a.npub == fips::anchors::ARCHY_ANCHOR_NPUB)
{
anchor_list.push(fips::anchors::archy_anchor());
}
let anchors = anchor_list
.into_iter()
.map(|a| {
serde_json::json!({
+51 -4
View File
@@ -86,7 +86,7 @@ impl RpcHandler {
let did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)
.unwrap_or_default();
let version = data.server_info.version.clone();
let relays = self.config.nostr_relays.clone();
let relays = self.handshake_relays().await;
let tor_proxy = self.config.nostr_tor_proxy.clone();
tokio::spawn(async move {
if let Err(e) = nostr_handshake::publish_presence(
@@ -106,6 +106,17 @@ impl RpcHandler {
Ok(serde_json::json!({ "enabled": enabled }))
}
/// The relay set every handshake operation uses: the user-managed relay
/// list (Settings → Relays, `nostr_relays.json`) merged with the config
/// defaults. Before 2026-07-22 handshake send/poll used ONLY the two
/// hardcoded config relays (one of which is defunct) and ignored user
/// relay edits entirely — so a sender publishing where the receiver
/// never read was a routine, silent way for peer requests to vanish.
pub(super) async fn handshake_relays(&self) -> Vec<String> {
crate::nostr_relays::merged_relay_list(&self.config.data_dir, &self.config.nostr_relays)
.await
}
/// Discover discoverable nodes via Nostr presence events.
/// Returns (nostr_pubkey, npub, DID, version) only — never an onion.
pub(super) async fn handle_handshake_discover(&self) -> Result<serde_json::Value> {
@@ -113,9 +124,10 @@ impl RpcHandler {
// to query relays as long as the user is actively browsing — they're
// an anonymous observer of presence events, not publishing anything.
let identity_dir = self.config.data_dir.join("identity");
let relays = self.handshake_relays().await;
let nodes = nostr_handshake::discover_nodes(
&identity_dir,
&self.config.nostr_relays,
&relays,
self.config.nostr_tor_proxy.as_deref(),
)
.await?;
@@ -161,7 +173,7 @@ impl RpcHandler {
our_version,
our_name,
message,
&self.config.nostr_relays,
&self.handshake_relays().await,
self.config.nostr_tor_proxy.as_deref(),
)
.await?;
@@ -191,6 +203,40 @@ impl RpcHandler {
/// - `PeerReject` → mark matching outbound row as `Rejected`
///
/// Never auto-adds peers, never auto-responds, never sends our onion.
/// Background relay poll (2026-07-22): before this, `handshake.poll` ran
/// ONLY when a user opened Federation and pressed the Poll button — a
/// peer request sat on the relay until the target's operator happened to
/// click, i.e. for most nodes forever ("requests never arrive"). Runs the
/// same poll+dispatch as the RPC (the disabled gate inside still applies)
/// and nudges the websocket revision when anything new lands so open UIs
/// refresh immediately.
pub async fn background_handshake_poll(self: &std::sync::Arc<Self>) {
match self.handle_handshake_poll().await {
Ok(res) => {
let new = res
.get("new_requests")
.and_then(|v| v.as_array())
.map(|a| a.len())
.unwrap_or(0);
let applied = res
.get("applied_invites")
.and_then(|v| v.as_array())
.map(|a| a.len())
.unwrap_or(0);
if new > 0 || applied > 0 {
tracing::info!(
new_requests = new,
applied_invites = applied,
"handshake poll: inbound peer activity"
);
let (data, _) = self.state_manager.get_snapshot().await;
self.state_manager.update_data(data).await;
}
}
Err(e) => tracing::debug!("background handshake poll failed: {e:#}"),
}
}
pub(super) async fn handle_handshake_poll(&self) -> Result<serde_json::Value> {
// Runtime gate: if the user hasn't enabled discoverability, don't
// touch the relays. The poll endpoint is a hard no-op until they
@@ -207,9 +253,10 @@ impl RpcHandler {
}));
}
let identity_dir = self.config.data_dir.join("identity");
let relays = self.handshake_relays().await;
let handshakes = nostr_handshake::poll_handshakes(
&identity_dir,
&self.config.nostr_relays,
&relays,
self.config.nostr_tor_proxy.as_deref(),
None,
)
+102
View File
@@ -120,6 +120,108 @@ async fn stream_lnd_transactions(sm: &crate::state::StateManager) -> Result<()>
Ok(())
}
/// LND wedge watchdog (2026-07-22, "100% uptime"): framework-pt's LND sat
/// for 14 HOURS with its RPC answering but the server never finishing
/// startup — synced_to_chain=false, zero peers, every channel inactive —
/// and nothing noticed until a human tried to open a channel. The wedge
/// signature is precise: RPC healthy while (!synced_to_chain, or zero peers
/// with channels that need a peer) persists. A restart reliably clears it
/// (the backend-churn wedge is a known lnd+rpcpolling failure mode), so
/// after 15 consecutive bad minutes we bounce the container ourselves, with
/// a 30-minute cooldown so a genuinely broken LND can't restart-loop.
/// RPC-unreachable and locked-wallet states are deliberately NOT handled
/// here — container-down is crash-recovery's job, and unlocking needs the
/// operator.
pub(crate) fn spawn_lnd_health_watchdog() {
tokio::spawn(async move {
let mut bad_minutes: u32 = 0;
let mut last_restart: Option<tokio::time::Instant> = None;
loop {
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
let Ok(bytes) = read_lnd_admin_macaroon().await else {
bad_minutes = 0; // no LND on this node (or not set up yet)
continue;
};
let macaroon_hex = hex::encode(bytes);
let Ok(client) = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(10))
.danger_accept_invalid_certs(true)
.build()
else {
continue;
};
let Ok(resp) = client
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
else {
bad_minutes = 0; // down/locked — not the wedge signature
continue;
};
let Ok(info) = resp.json::<serde_json::Value>().await else {
bad_minutes = 0;
continue;
};
let synced = info
.get("synced_to_chain")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let peers = info.get("num_peers").and_then(|v| v.as_u64()).unwrap_or(0);
let channels = info
.get("num_active_channels")
.and_then(|v| v.as_u64())
.unwrap_or(0)
+ info
.get("num_inactive_channels")
.and_then(|v| v.as_u64())
.unwrap_or(0)
+ info
.get("num_pending_channels")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let wedged = !synced || (channels > 0 && peers == 0);
if !wedged {
bad_minutes = 0;
continue;
}
bad_minutes += 1;
if bad_minutes < 15 {
continue;
}
if last_restart
.map(|t| t.elapsed() < std::time::Duration::from_secs(1800))
.unwrap_or(false)
{
continue;
}
tracing::warn!(
synced_to_chain = synced,
num_peers = peers,
channels,
"LND wedged for {bad_minutes} minutes (RPC up, server never ready) — restarting the lnd container"
);
let out = tokio::process::Command::new("podman")
.args(["restart", "lnd"])
.output()
.await;
match out {
Ok(o) if o.status.success() => {
tracing::info!("LND watchdog restart complete");
}
Ok(o) => tracing::warn!(
"LND watchdog restart failed: {}",
String::from_utf8_lossy(&o.stderr).trim()
),
Err(e) => tracing::warn!("LND watchdog restart failed: {e}"),
}
last_restart = Some(tokio::time::Instant::now());
bad_minutes = 0;
}
});
}
impl RpcHandler {
/// Helper: create an authenticated LND REST client.
/// Returns an HTTP client configured for LND's self-signed TLS and the
@@ -62,6 +62,14 @@ impl RpcHandler {
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
// Invoices are short-lived; retrying the same one can never
// succeed, so tell the user the way out instead of just the fact.
if msg.contains("invoice expired") {
return Err(anyhow::anyhow!(
"Payment failed: this invoice has expired ({}). Ask the recipient for a fresh invoice and try again.",
msg.trim_start_matches("invoice expired. ")
));
}
return Err(anyhow::anyhow!("Payment failed: {}", msg));
}
@@ -73,6 +73,20 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
"Mempool requires",
"Container",
"Image",
// Wallet-actionable errors: masking "Insufficient balance: need 80
// sats, have 0 sats" behind "Operation failed. Check server logs."
// sent the operator to journalctl for a message that was written for
// them in the first place (ecash send, 2026-07-22).
"Insufficient balance",
"Insufficient funds",
// Lightning payment failures carry LND's reason ("invoice expired.
// Valid until …", "no route", …) — the user can act on every one of
// them, and masking sent the operator to journalctl (invoice-expired
// send, 2026-07-23).
"Payment failed",
"Invalid payment request",
"Missing 'payment_request'",
"Your Lightning node is still finishing",
"Bitcoin address",
"No router",
"No OpenWrt",
@@ -151,6 +165,25 @@ mod sanitize_tests {
}
}
#[test]
fn lightning_payment_errors_pass_through() {
// LND's payment-failure reasons are written for the payer — masking
// "invoice expired" as "Check server logs" left a user retrying a
// dead invoice (framework-pt, 2026-07-23).
for msg in [
"Payment failed: this invoice has expired (Valid until 2026-07-23 07:41:42 +0000 UTC). Ask the recipient for a fresh invoice and try again.",
"Payment failed: unable to find a path to destination",
"Invalid payment request: must be a Lightning invoice (lnbc...)",
"Missing 'payment_request' parameter",
] {
assert_ne!(
sanitize_error_message(msg),
"Operation failed. Check server logs for details.",
"masked: {msg}"
);
}
}
#[test]
fn internal_errors_stay_generic() {
assert_eq!(
+1
View File
@@ -26,6 +26,7 @@ mod node;
mod nostr;
mod openwrt;
mod package;
pub(crate) use package::wyoming_satellite_keeper;
mod peers;
mod pine_status;
mod response;
@@ -4,6 +4,7 @@ mod dependencies;
mod install;
mod lifecycle;
mod pine_ha;
pub(crate) use pine_ha::wyoming_satellite_keeper;
mod progress;
mod runtime;
mod set_config;
@@ -697,6 +697,189 @@ async fn seed_assist_pipeline(storage: &std::path::Path, claude_entity: Option<&
false
}
// ---------------------------------------------------------------------------
// Wyoming satellite keeper
// ---------------------------------------------------------------------------
/// Keep IP-pinned Wyoming satellite entries (voice speakers) reachable.
///
/// HA's zeroconf discovery stores a satellite as a fixed LAN IP. DHCP
/// renumbering — or the whole node moving to a different network — strands
/// the entry and the speaker silently drops (framework-pt 2026-07-23: entry
/// pinned to 192.168.1.241 while the LAN had become 192.168.63.0/24). HA
/// never re-resolves on its own. This keeper probes each satellite entry and,
/// when one stops answering, sweeps the node's local /24s for the same
/// Wyoming port and rewrites the entry to the address that answers.
pub(crate) async fn wyoming_satellite_keeper() {
loop {
if home_assistant_installed().await {
heal_wyoming_satellites().await;
}
tokio::time::sleep(std::time::Duration::from_secs(300)).await;
}
}
async fn tcp_alive(host: &str, port: u16, ms: u64) -> bool {
matches!(
tokio::time::timeout(
std::time::Duration::from_millis(ms),
tokio::net::TcpStream::connect((host, port)),
)
.await,
Ok(Ok(_))
)
}
/// The node's own global IPv4 addresses. Loopback/CGNAT (tailscale) ranges are
/// excluded — satellites live on real LANs.
async fn local_ipv4_addresses() -> Vec<std::net::Ipv4Addr> {
let Ok(out) = tokio::process::Command::new("ip")
.args(["-4", "-o", "addr", "show", "scope", "global"])
.output()
.await
else {
return Vec::new();
};
String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|l| {
let cidr = l.split_whitespace().nth(3)?;
let ip: std::net::Ipv4Addr = cidr.split('/').next()?.parse().ok()?;
let o = ip.octets();
// 100.64.0.0/10 — tailscale/CGNAT, never a speaker LAN.
if o[0] == 100 && (64..128).contains(&o[1]) {
return None;
}
Some(ip)
})
.collect()
}
/// Sweep the /24 of every local interface for something answering `port`.
/// First responder wins; the node's own addresses are skipped.
async fn find_satellite(port: u16) -> Option<String> {
let self_ips = local_ipv4_addresses().await;
for base in self_ips.iter().map(|ip| ip.octets()) {
let mut set = tokio::task::JoinSet::new();
for i in 1..255u8 {
let ip = std::net::Ipv4Addr::new(base[0], base[1], base[2], i);
if self_ips.contains(&ip) {
continue;
}
set.spawn(async move { tcp_alive(&ip.to_string(), port, 500).await.then(|| ip.to_string()) });
}
while let Some(res) = set.join_next().await {
if let Ok(Some(ip)) = res {
return Some(ip);
}
}
}
None
}
/// One keeper pass: re-point dead IP-pinned wyoming entries at wherever their
/// port now answers. Stops HA before editing the store (HA flushes its own
/// in-memory copy on shutdown, which would clobber a live edit) and starts it
/// again after.
async fn heal_wyoming_satellites() {
let path = std::path::Path::new(HA_STORAGE_DIR).join("core.config_entries");
let Some(store) = read_store(&path).await else {
return;
};
let Some(entries) = store
.get("data")
.and_then(|d| d.get("entries"))
.and_then(|e| e.as_array())
else {
return;
};
// (host, port, new_host) for every stranded satellite we can re-resolve.
let mut moves: Vec<(String, u16, String)> = Vec::new();
for e in entries {
if e.get("domain").and_then(Value::as_str) != Some("wyoming") {
continue;
}
let Some(host) = e
.get("data")
.and_then(|d| d.get("host"))
.and_then(Value::as_str)
else {
continue;
};
// Engines use host.containers.internal — only IP-pinned entries drift.
if host.parse::<std::net::Ipv4Addr>().is_err() {
continue;
}
let port = e
.get("data")
.and_then(|d| d.get("port"))
.and_then(Value::as_u64)
.unwrap_or(0) as u16;
if port == 0 || tcp_alive(host, port, 1500).await {
continue;
}
let Some(new_host) = find_satellite(port).await else {
info!("pine/HA keeper: satellite {host}:{port} unreachable and not found on any local /24 yet");
continue;
};
if new_host != host {
moves.push((host.to_string(), port, new_host));
}
}
if moves.is_empty() {
return;
}
// Stop HA, re-read + rewrite the store, start HA.
let _ = tokio::process::Command::new("podman")
.args(["stop", "homeassistant"])
.output()
.await;
if let Some(mut store) = read_store(&path).await {
let mut changed = false;
if let Some(entries) = store
.get_mut("data")
.and_then(|d| d.get_mut("entries"))
.and_then(|e| e.as_array_mut())
{
for e in entries.iter_mut() {
if e.get("domain").and_then(Value::as_str) != Some("wyoming") {
continue;
}
let host = e
.get("data")
.and_then(|d| d.get("host"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let port = e
.get("data")
.and_then(|d| d.get("port"))
.and_then(Value::as_u64)
.unwrap_or(0) as u16;
if let Some((_, _, new_host)) =
moves.iter().find(|(h, p, _)| *h == host && *p == port)
{
if let Some(data) = e.get_mut("data") {
data["host"] = json!(new_host);
}
e["modified_at"] = json!(ha_now());
info!("pine/HA keeper: satellite moved {host}:{port} -> {new_host}:{port}");
changed = true;
}
}
}
if changed {
write_store(&path, &store).await;
}
}
let _ = tokio::process::Command::new("podman")
.args(["start", "homeassistant"])
.output()
.await;
}
// ---------------------------------------------------------------------------
// Presence probes + HA restart
// ---------------------------------------------------------------------------
@@ -139,9 +139,17 @@ impl RpcHandler {
.await
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| "archipelago".to_string());
// LAN IPv4 rides along for the companion pairing QR: when the
// operator's browser reaches this node over Tailscale/VPN or
// localhost, that origin is useless to a phone on the LAN — the QR
// must advertise an address the phone can actually dial
// (2026-07-22: a pairing QR carried a tailnet 100.x IP and the
// companion could never connect).
let lan_ip = crate::host_ip::primary_host_ipv4().await;
Ok(serde_json::json!({
"hostname": hostname,
"mdns_hostname": format!("{hostname}.local"),
"lan_ip": lan_ip,
}))
}