feat(ui): expose full overlay IP on Status page

The /api/v1/admin endpoint only returns the /64 subnet. Users were
copy-pasting the subnet straight into Compose Message and getting
HTTP 422 from the daemon (\"invalid IP address syntax\"). The full
host part (lower 64 bits) is logged once at boot:

    INFO mycelium: Node overlay IP: 43d:956e:7877:d933:eecc:b305:21ff:77f9

Capture it from stdout, surface as a new daemonStatus.overlayIp
field, and render it on Status above the subnet card with the hint
\"use this when sending messages\".

The line carries ANSI colour escapes from tracing's compact format,
so push_log strips SGR sequences before scanning. Hand-rolled to
avoid pulling a regex crate.

Also rebuilds the .deb release artifact.
This commit is contained in:
syoul
2026-04-26 02:11:42 +02:00
parent 3bf3cd162b
commit 0c9277f687
7 changed files with 85 additions and 2 deletions

View File

@@ -1 +1 @@
a5df5c25c2fb13ff7d3486c6214371dac540ccaa70094e02e458bfd0b5e6b367 release/mycellium-ui_0.1.0_amd64.deb acab8758c4ba7f3894e5e9bce28cbd759b8ec7e08a1ee00f54f6fdcac75a4221 release/mycellium-ui_0.1.0_amd64.deb

Binary file not shown.

View File

@@ -14,11 +14,16 @@ use serde::Serialize;
use tauri::{AppHandle, State}; use tauri::{AppHandle, State};
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DaemonStatus { pub struct DaemonStatus {
pub running: bool, pub running: bool,
pub api_url: Option<String>, pub api_url: Option<String>,
pub key_path: Option<String>, pub key_path: Option<String>,
pub config_path: Option<String>, pub config_path: Option<String>,
/// Full overlay IPv6 (e.g. `43d:956e:7877:d933:eecc:b305:21ff:77f9`).
/// Surfaced separately from `nodeSubnet` so the UI can show users the
/// address they should actually use as a message destination.
pub overlay_ip: Option<String>,
} }
fn snapshot(state: &AppState) -> DaemonStatus { fn snapshot(state: &AppState) -> DaemonStatus {
@@ -28,6 +33,7 @@ fn snapshot(state: &AppState) -> DaemonStatus {
api_url: sc.client().map(|c| c.base_url().to_string()), api_url: sc.client().map(|c| c.base_url().to_string()),
key_path: sc.key_path().map(|p| p.display().to_string()), key_path: sc.key_path().map(|p| p.display().to_string()),
config_path: sc.config_path().map(|p| p.display().to_string()), config_path: sc.config_path().map(|p| p.display().to_string()),
overlay_ip: sc.overlay_ip(),
} }
} }

View File

@@ -48,6 +48,10 @@ pub struct SidecarHandle {
logs: Mutex<VecDeque<String>>, logs: Mutex<VecDeque<String>>,
config_path: Mutex<Option<PathBuf>>, config_path: Mutex<Option<PathBuf>>,
key_path: Mutex<Option<PathBuf>>, key_path: Mutex<Option<PathBuf>>,
/// Full overlay IPv6, parsed out of mycelium's `Node overlay IP: ...`
/// startup log line. The daemon's HTTP API only exposes the subnet,
/// so we extract the host part from the binary's stderr.
overlay_ip: Mutex<Option<String>>,
} }
impl SidecarHandle { impl SidecarHandle {
@@ -58,6 +62,7 @@ impl SidecarHandle {
logs: Mutex::new(VecDeque::with_capacity(LOG_RING_CAPACITY)), logs: Mutex::new(VecDeque::with_capacity(LOG_RING_CAPACITY)),
config_path: Mutex::new(None), config_path: Mutex::new(None),
key_path: Mutex::new(None), key_path: Mutex::new(None),
overlay_ip: Mutex::new(None),
}) })
} }
@@ -84,7 +89,21 @@ impl SidecarHandle {
self.config_path.lock().clone() self.config_path.lock().clone()
} }
pub fn overlay_ip(&self) -> Option<String> {
self.overlay_ip.lock().clone()
}
fn push_log(&self, line: String) { fn push_log(&self, line: String) {
// mycelium prefixes log lines with ANSI colour escapes; strip them
// before scanning for the overlay IP marker so the regex stays
// resilient to cosmetic changes in upstream's log format.
if self.overlay_ip.lock().is_none() {
let stripped = strip_ansi(&line);
if let Some(ip) = extract_overlay_ip(&stripped) {
tracing::info!(overlay_ip = %ip, "node overlay IP captured");
*self.overlay_ip.lock() = Some(ip);
}
}
let mut buf = self.logs.lock(); let mut buf = self.logs.lock();
if buf.len() >= LOG_RING_CAPACITY { if buf.len() >= LOG_RING_CAPACITY {
buf.pop_front(); buf.pop_front();
@@ -262,6 +281,7 @@ impl SidecarHandle {
fn cleanup(&self) { fn cleanup(&self) {
*self.api_url.lock() = None; *self.api_url.lock() = None;
*self.overlay_ip.lock() = None;
let _ = self.child.lock().take(); let _ = self.child.lock().take();
} }
@@ -279,6 +299,48 @@ impl SidecarHandle {
} }
} }
/// Mycelium logs the assigned overlay IPv6 once at startup like:
/// `INFO mycelium: Node overlay IP: 43d:956e:7877:d933:eecc:b305:21ff:77f9`
/// We don't pull a regex crate just for this — a hand-rolled parser is
/// resilient enough.
fn extract_overlay_ip(line: &str) -> Option<String> {
const NEEDLE: &str = "Node overlay IP:";
let idx = line.find(NEEDLE)?;
let rest = line[idx + NEEDLE.len()..].trim();
// The IP is the first whitespace-bounded token; rejection criteria
// (must contain `:`, must not contain `/`) keep us from accidentally
// capturing the subnet.
let token = rest.split_whitespace().next()?;
if !token.contains(':') || token.contains('/') {
return None;
}
Some(token.to_string())
}
/// Strip ANSI SGR escape sequences (`ESC [ ... m`) from a log line so the
/// overlay-IP scanner doesn't choke on tracing's coloured output.
fn strip_ansi(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = String::with_capacity(s.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == 0x1B && i + 1 < bytes.len() && bytes[i + 1] == b'[' {
// Skip until 'm' or any letter terminator.
i += 2;
while i < bytes.len() && !bytes[i].is_ascii_alphabetic() {
i += 1;
}
if i < bytes.len() {
i += 1;
}
} else {
out.push(bytes[i] as char);
i += 1;
}
}
out
}
fn pick_port() -> AppResult<u16> { fn pick_port() -> AppResult<u16> {
portpicker::pick_unused_port().ok_or_else(|| AppError::Other("no free port available".into())) portpicker::pick_unused_port().ok_or_else(|| AppError::Other("no free port available".into()))
} }

View File

@@ -7,6 +7,8 @@ export interface DaemonStatus {
apiUrl: string | null; apiUrl: string | null;
keyPath: string | null; keyPath: string | null;
configPath: string | null; configPath: string | null;
/** Full overlay IPv6 (e.g. `43d:956e:7877:d933:eecc:b305:21ff:77f9`). */
overlayIp: string | null;
} }
export interface NodeInfo { export interface NodeInfo {

View File

@@ -19,7 +19,13 @@ export const useNodeStore = defineStore("node", () => {
exitedUnlisten = await on<number>(Events.SidecarExited, (e) => { exitedUnlisten = await on<number>(Events.SidecarExited, (e) => {
error.value = `daemon exited (code ${e.payload})`; error.value = `daemon exited (code ${e.payload})`;
phase.value = "error"; phase.value = "error";
status.value = { running: false, apiUrl: null, keyPath: null, configPath: null }; status.value = {
running: false,
apiUrl: null,
keyPath: null,
configPath: null,
overlayIp: null,
};
info.value = null; info.value = null;
}); });
} }

View File

@@ -23,6 +23,13 @@ async function copy(field: string, value: string) {
<template> <template>
<div v-if="info" class="max-w-3xl space-y-4"> <div v-if="info" class="max-w-3xl space-y-4">
<InfoCard
v-if="status?.overlayIp"
label="Overlay IP — use this when sending messages"
:value="status.overlayIp"
:copied="copiedField === 'ip'"
@copy="copy('ip', status.overlayIp!)"
/>
<InfoCard <InfoCard
label="Overlay subnet" label="Overlay subnet"
:value="info.nodeSubnet" :value="info.nodeSubnet"