Files
Mycelium-ui-private/src-tauri/src/sidecar.rs
syoul 0c9277f687 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.
2026-04-26 02:11:42 +02:00

424 lines
15 KiB
Rust

use crate::api::MyceliumClient;
use crate::elevation;
use crate::error::{AppError, AppResult};
use parking_lot::Mutex;
use std::collections::VecDeque;
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter, Manager};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Child;
use tracing::{info, warn};
const HEALTH_CHECK_TIMEOUT_SECS: u64 = 20;
const HEALTH_CHECK_INTERVAL_MS: u64 = 400;
const LOG_RING_CAPACITY: usize = 500;
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SidecarConfig {
pub peers: Vec<String>,
pub tun_name: Option<String>,
pub no_tun: bool,
}
impl Default for SidecarConfig {
fn default() -> Self {
Self {
// A small set of well-known public peers from the mycelium README,
// used as bootstrap when the user hasn't configured their own.
peers: vec![
"tcp://188.40.132.242:9651".into(),
"quic://[2a01:4f8:212:fa6::2]:9651".into(),
],
tun_name: None,
no_tun: false,
}
}
}
/// Holds the running mycelium child process plus a small in-memory log
/// buffer so the Settings page can show recent stderr/stdout without
/// reading from disk.
pub struct SidecarHandle {
child: Mutex<Option<Child>>,
api_url: Mutex<Option<String>>,
logs: Mutex<VecDeque<String>>,
config_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 {
pub fn new() -> Arc<Self> {
Arc::new(Self {
child: Mutex::new(None),
api_url: Mutex::new(None),
logs: Mutex::new(VecDeque::with_capacity(LOG_RING_CAPACITY)),
config_path: Mutex::new(None),
key_path: Mutex::new(None),
overlay_ip: Mutex::new(None),
})
}
pub fn is_running(&self) -> bool {
self.api_url.lock().is_some()
}
pub fn client(&self) -> Option<MyceliumClient> {
self.api_url
.lock()
.as_ref()
.map(|u| MyceliumClient::new(u.clone()))
}
pub fn logs_snapshot(&self) -> Vec<String> {
self.logs.lock().iter().cloned().collect()
}
pub fn key_path(&self) -> Option<PathBuf> {
self.key_path.lock().clone()
}
pub fn config_path(&self) -> Option<PathBuf> {
self.config_path.lock().clone()
}
pub fn overlay_ip(&self) -> Option<String> {
self.overlay_ip.lock().clone()
}
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();
if buf.len() >= LOG_RING_CAPACITY {
buf.pop_front();
}
buf.push_back(line);
}
pub async fn start(
self: &Arc<Self>,
app: &AppHandle,
config: &SidecarConfig,
) -> AppResult<String> {
if self.is_running() {
return Err(AppError::DaemonAlreadyRunning);
}
let bin = locate_sidecar(app)?;
// Three ports: HTTP API (loopback), TCP listen, QUIC (UDP) listen.
// mycelium defaults to 9651 for both peer-listen ports, which
// collides if another instance (or a leftover from a previous test)
// is already up. Always picking ephemeral ports avoids that at the
// cost of inbound peers needing the actual port number.
let api_port = pick_port()?;
let tcp_port = pick_port_skip(&[api_port])?;
let quic_port = pick_port_skip(&[api_port, tcp_port])?;
// mycelium also opens an internal JSON-RPC / metrics endpoint on
// 127.0.0.1:8990 by default; if 8990 is already taken (e.g. by an
// orphan from a previous run we couldn't SIGKILL because it ran as
// root) the new instance dies a few seconds after start. Pin this
// to a fresh ephemeral port too.
let metrics_port = pick_port_skip(&[api_port, tcp_port, quic_port])?;
let data_dir = app
.path()
.app_data_dir()
.map_err(|e| AppError::TauriPath(e.to_string()))?;
std::fs::create_dir_all(&data_dir)?;
let key_path = data_dir.join("priv_key.bin");
let config_path = data_dir.join("mycelium.toml");
let mut args = vec![
"--api-addr".to_string(),
format!("127.0.0.1:{api_port}"),
"--tcp-listen-port".to_string(),
tcp_port.to_string(),
"--quic-listen-port".to_string(),
quic_port.to_string(),
"--metrics-api-address".to_string(),
format!("127.0.0.1:{metrics_port}"),
"--key-file".to_string(),
key_path.display().to_string(),
];
if config_path.exists() {
args.push("--config-file".to_string());
args.push(config_path.display().to_string());
}
if config.no_tun {
args.push("--no-tun".to_string());
}
if let Some(name) = &config.tun_name {
args.push("--tun-name".to_string());
args.push(name.clone());
}
if !config.peers.is_empty() {
args.push("--peers".to_string());
for p in &config.peers {
args.push(p.clone());
}
}
info!(
?bin,
api_port, tcp_port, quic_port, metrics_port,
"spawning mycelium sidecar via pkexec"
);
let mut cmd = elevation::elevated(&bin, &args);
cmd.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child = cmd.spawn()?;
let stdout = child.stdout.take();
let stderr = child.stderr.take();
// Stash before we await the health check, so a slow daemon
// doesn't leave us with a zombie process if anything panics.
let api_url = format!("http://127.0.0.1:{api_port}");
*self.child.lock() = Some(child);
*self.api_url.lock() = Some(api_url.clone());
*self.config_path.lock() = Some(config_path);
*self.key_path.lock() = Some(key_path);
if let Some(out) = stdout {
let me = Arc::clone(self);
tokio::spawn(async move {
let mut lines = BufReader::new(out).lines();
while let Ok(Some(line)) = lines.next_line().await {
me.push_log(format!("[stdout] {line}"));
}
});
}
if let Some(err) = stderr {
let me = Arc::clone(self);
tokio::spawn(async move {
let mut lines = BufReader::new(err).lines();
while let Ok(Some(line)) = lines.next_line().await {
me.push_log(format!("[stderr] {line}"));
}
});
}
// Background watcher: polls every 2s and emits `sidecar://exited`
// when the child dies after the start sequence has succeeded.
{
let me = Arc::clone(self);
let app = app.clone();
tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_secs(2)).await;
if !me.is_running() {
break;
}
if let Some(code) = me.child_exit_status() {
me.handle_exit(&app, code);
break;
}
}
});
}
// Health-check loop. The pkexec dialog can take several seconds, so
// give the daemon a generous window to come up before failing.
let client = MyceliumClient::new(&api_url);
let started = Instant::now();
loop {
// Bail early if the child died (auth cancel, missing TUN cap, etc.).
if let Some(code) = self.child_exit_status() {
self.cleanup();
if elevation::is_auth_failure(code) {
return Err(AppError::ElevationCancelled);
}
return Err(AppError::SidecarExited(format!("exit code {code}")));
}
if client.is_alive().await {
info!(api_url = %api_url, "mycelium sidecar healthy");
let _ = app.emit("sidecar://ready", &api_url);
return Ok(api_url);
}
if started.elapsed() > Duration::from_secs(HEALTH_CHECK_TIMEOUT_SECS) {
self.stop().await;
return Err(AppError::HealthCheckTimeout(HEALTH_CHECK_TIMEOUT_SECS));
}
tokio::time::sleep(Duration::from_millis(HEALTH_CHECK_INTERVAL_MS)).await;
}
}
fn child_exit_status(&self) -> Option<i32> {
let mut lock = self.child.lock();
let child = lock.as_mut()?;
match child.try_wait() {
Ok(Some(s)) => Some(s.code().unwrap_or(-1)),
_ => None,
}
}
fn handle_exit(&self, app: &AppHandle, code: i32) {
warn!(code, "mycelium sidecar exited");
self.cleanup();
let _ = app.emit("sidecar://exited", code);
}
fn cleanup(&self) {
*self.api_url.lock() = None;
*self.overlay_ip.lock() = None;
let _ = self.child.lock().take();
}
pub async fn stop(&self) {
// Take the child by value so the parking_lot guard isn't held across
// the await on `wait()`. `kill_on_drop(true)` is set, but pkexec runs
// mycelium as root, so we still ask politely first; the polkit agent
// reaps the elevated child.
let child_opt = self.child.lock().take();
if let Some(mut child) = child_opt {
let _ = child.start_kill();
let _ = child.wait().await;
}
self.cleanup();
}
}
/// 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> {
portpicker::pick_unused_port().ok_or_else(|| AppError::Other("no free port available".into()))
}
fn pick_port_skip(taken: &[u16]) -> AppResult<u16> {
for _ in 0..16 {
let p = pick_port()?;
if !taken.contains(&p) {
return Ok(p);
}
}
Err(AppError::Other(
"could not find a unique free port".into(),
))
}
/// Resolve the bundled `mycelium` sidecar across our two build modes:
/// • `tauri dev` keeps the file under `src-tauri/binaries/` with the
/// `-<target_triple>` suffix Tauri's externalBin convention requires.
/// • `tauri build` for a `.deb` strips the suffix and places the
/// binary at `/usr/bin/mycelium` next to the app launcher.
/// We probe the bundled path first, then walk back to the dev location.
fn locate_sidecar(app: &AppHandle) -> AppResult<PathBuf> {
let triple = std::env::var("TAURI_ENV_TARGET_TRIPLE")
.ok()
.or_else(|| option_env!("TARGET").map(|s| s.to_string()))
.unwrap_or_else(|| "x86_64-unknown-linux-gnu".to_string());
let suffixed = format!("mycelium-{triple}");
let plain = "mycelium".to_string();
let mut tried: Vec<PathBuf> = Vec::new();
// Bundled .deb / AppImage: the launcher lives next to the sidecar
// under /usr/bin/. Resolve relative to the running executable.
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
for name in [&plain, &suffixed] {
let p = dir.join(name);
if p.exists() {
return Ok(p);
}
tried.push(p);
}
}
}
// Tauri's resource_dir() — used when externalBin is treated as a
// resource (older bundles, or when the user moves things around).
if let Ok(resource) = app.path().resource_dir() {
for name in [&plain, &suffixed] {
let p = resource.join(name);
if p.exists() {
return Ok(p);
}
tried.push(p);
}
}
// Dev mode: `pnpm tauri dev` runs the binary out of target/debug/ so
// current_exe() is far from src-tauri/binaries/. Use the manifest dir.
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
for name in [&suffixed, &plain] {
let p = manifest_dir.join("binaries").join(name);
if p.exists() {
return Ok(p);
}
tried.push(p);
}
// Final fallback: trust $PATH if a system-installed mycelium is around.
if let Ok(path) = std::env::var("PATH") {
for entry in path.split(':') {
let p = PathBuf::from(entry).join(&plain);
if p.exists() {
return Ok(p);
}
}
}
Err(AppError::SidecarNotFound(tried))
}