Compare commits

...

4 Commits

Author SHA1 Message Date
syoul 5229e2c774 feat(packaging): pre-spawn cleanup wrapper for clean restarts
Symptom: each app restart that didn't go through Stop daemon left
an orphan mycelium running as root, claiming the TUN \"mycelium\",
UDP/9650 (multicast discovery) and TCP/8990 (JSON-RPC, hardcoded
in 0.6.1 — no flag). Subsequent starts panicked with EBUSY or
\"Address in use\" on whichever port the orphan held.

We can't SIGKILL the orphan from user-space (root process). Move
the cleanup into an elevated context that runs in the same pkexec
authentication as the daemon spawn:

  /usr/bin/mycellium-bootstrap   (new shell script in the .deb)
    pkill -9 -x mycelium
    ip link del mycelium / mycel0
    exec /usr/bin/mycelium \"\$@\"

The polkit policy now annotates this exact path with
auth_admin_keep so a single password prompt covers every
subsequent restart in the user's session.

Sidecar: when /usr/bin/mycellium-bootstrap exists (production
install) we hand pkexec that path instead of the bare daemon.
\`pnpm tauri dev\` falls back to the unwrapped binary path.
2026-04-26 02:27:07 +02:00
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
syoul 3bf3cd162b release: rebuild .deb with cross-distro pkexec dependency
On Debian 13 (trixie), policykit-1 was retired and pkexec moved
into a standalone package. The previous Depends: policykit-1
made `apt install` fail with \"none of the choices are installable\"
on a fresh trixie VM.

Switch to Depends: pkexec | policykit-1 so apt can satisfy the
constraint on both:
  - bookworm: policykit-1 (which provides /usr/bin/pkexec)
  - trixie:   pkexec (standalone)

Also updates SHA256SUMS and the install README accordingly.
2026-04-26 01:47:20 +02:00
syoul 70eb5c7b57 chore: ignore personal notes dir
docs-syoul/ holds private brainstorming and draft upstream issues
that are not part of the public repo.
2026-04-26 01:30:26 +02:00
12 changed files with 152 additions and 11 deletions
+3
View File
@@ -31,3 +31,6 @@ priv_key.bin
*.log
.env
.env.local
# Personal notes — not part of the public repo
docs-syoul/
+3 -1
View File
@@ -8,7 +8,9 @@ Pre-built `.deb` of the mycellium-ui desktop client. Tested on Debian 12 (bookwo
sudo apt install ./mycellium-ui_0.1.0_amd64.deb
```
`apt install` with a local path resolves runtime deps (`policykit-1`, `libwebkit2gtk-4.1-0`, `libgtk-3-0`) automatically. Plain `dpkg -i` will fail if any of those are missing.
`apt install` with a local path resolves runtime deps (`pkexec` or `policykit-1` depending on the distro, `libwebkit2gtk-4.1-0`, `libgtk-3-0`) automatically. Plain `dpkg -i` will fail if any of those are missing.
The `pkexec | policykit-1` alternative covers both Debian 12 (where `pkexec` ships inside `policykit-1`) and Debian 13 (where `pkexec` is a standalone package).
## Verify
+1 -1
View File
@@ -1 +1 @@
c85a2f6584949bd04a2a56b7fc3a5f7ed6e99c3291dec6c7eef99e8b28a0b2be release/mycellium-ui_0.1.0_amd64.deb
38e0a3d03797490e1d6caa2681f9d5580493908834736b0db6a7d99558bb8823 release/mycellium-ui_0.1.0_amd64.deb
Binary file not shown.
+27
View File
@@ -0,0 +1,27 @@
#!/bin/sh
# /usr/bin/mycellium-bootstrap — installed by mycellium-ui.deb
#
# Wrapper around the mycelium daemon that guarantees a clean start
# every time. Without this, an orphan mycelium left over from a
# previous run (which the user-space launcher cannot SIGKILL because
# the daemon runs as root via pkexec) would block the next start
# with one of:
#
# * EBUSY on TUN device "mycelium" creation
# * "Address in use" on the JSON-RPC port (hardcoded 8990 in 0.6.1)
# * "Failed to bind multicast discovery socket" on UDP 9650
#
# This script runs under the same elevated context as the mycelium
# daemon itself (single pkexec call), so polkit's auth_admin_keep
# caching only fires one prompt per session.
set -e
# Best-effort cleanup. Errors ignored so the exec at the end always
# runs even on a clean machine.
pkill -9 -x mycelium 2>/dev/null || true
sleep 0.3
ip link del mycelium 2>/dev/null || true
ip link del mycel0 2>/dev/null || true
exec /usr/bin/mycelium "$@"
@@ -6,7 +6,14 @@
<vendor>Threefold</vendor>
<vendor_url>https://threefold.io</vendor_url>
<action id="tech.threefold.mycellium-ui.spawn">
<!--
Bootstrap action: covers the wrapper that cleans up orphan
mycelium state and then execs the daemon. pkexec matches the
binary path against `org.freedesktop.policykit.exec.path` to
pick this action up; auth_admin_keep then caches the auth for
the user's session so subsequent restarts don't re-prompt.
-->
<action id="tech.threefold.mycellium-ui.bootstrap">
<description>Run the Mycelium overlay daemon</description>
<description xml:lang="fr">Lancer le démon de l'overlay Mycelium</description>
<message>Authentication is required to start the Mycelium overlay daemon.</message>
@@ -14,11 +21,8 @@
<defaults>
<allow_any>auth_admin</allow_any>
<allow_inactive>auth_admin</allow_inactive>
<!-- Cache the authentication for the user's session so the polkit
dialog only appears once per login (5-minute window). To allow
passwordless start for trusted desktops, change to "yes" — be
aware this lets any process on the machine spawn the daemon. -->
<allow_active>auth_admin_keep</allow_active>
</defaults>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/mycellium-bootstrap</annotate>
</action>
</policyconfig>
+6
View File
@@ -14,11 +14,16 @@ use serde::Serialize;
use tauri::{AppHandle, State};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DaemonStatus {
pub running: bool,
pub api_url: Option<String>,
pub key_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 {
@@ -28,6 +33,7 @@ fn snapshot(state: &AppState) -> DaemonStatus {
api_url: sc.client().map(|c| c.base_url().to_string()),
key_path: sc.key_path().map(|p| p.display().to_string()),
config_path: sc.config_path().map(|p| p.display().to_string()),
overlay_ip: sc.overlay_ip(),
}
}
+84 -1
View File
@@ -48,6 +48,10 @@ pub struct SidecarHandle {
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 {
@@ -58,6 +62,7 @@ impl SidecarHandle {
logs: Mutex::new(VecDeque::with_capacity(LOG_RING_CAPACITY)),
config_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()
}
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();
@@ -102,6 +121,17 @@ impl SidecarHandle {
}
let bin = locate_sidecar(app)?;
// In a `.deb` install, our pre-install script ships
// /usr/bin/mycellium-bootstrap that pkill+ip-link-del cleans
// any orphan state before exec-ing the real binary. Polkit
// is configured to auth_admin_keep that exact path, so
// subsequent starts are silent.
// Falls back to the bare binary in `tauri dev` where the
// bootstrap script isn't installed system-wide.
let elevation_target = match bootstrap_path() {
Some(p) => p,
None => bin.clone(),
};
// 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)
@@ -157,11 +187,12 @@ impl SidecarHandle {
info!(
?bin,
elevation_target = %elevation_target.display(),
api_port, tcp_port, quic_port, metrics_port,
"spawning mycelium sidecar via pkexec"
);
let mut cmd = elevation::elevated(&bin, &args);
let mut cmd = elevation::elevated(&elevation_target, &args);
cmd.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
@@ -262,6 +293,7 @@ impl SidecarHandle {
fn cleanup(&self) {
*self.api_url.lock() = None;
*self.overlay_ip.lock() = None;
let _ = self.child.lock().take();
}
@@ -279,6 +311,57 @@ impl SidecarHandle {
}
}
/// Path of the privileged wrapper script shipped in our `.deb`. When
/// present, we invoke it instead of the mycelium binary directly so
/// the elevated context can clean up any orphan TUN / processes from
/// a previous crash before `exec /usr/bin/mycelium`.
fn bootstrap_path() -> Option<PathBuf> {
let p = PathBuf::from("/usr/bin/mycellium-bootstrap");
p.exists().then_some(p)
}
/// 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()))
}
+3 -2
View File
@@ -40,9 +40,10 @@
"externalBin": ["binaries/mycelium"],
"linux": {
"deb": {
"depends": ["policykit-1"],
"depends": ["pkexec | policykit-1"],
"files": {
"/usr/share/polkit-1/actions/tech.threefold.mycellium-ui.policy": "packaging/polkit/tech.threefold.mycellium-ui.policy"
"/usr/share/polkit-1/actions/tech.threefold.mycellium-ui.policy": "packaging/polkit/tech.threefold.mycellium-ui.policy",
"/usr/bin/mycellium-bootstrap": "packaging/mycellium-bootstrap"
}
}
}
+2
View File
@@ -7,6 +7,8 @@ export interface DaemonStatus {
apiUrl: string | null;
keyPath: string | null;
configPath: string | null;
/** Full overlay IPv6 (e.g. `43d:956e:7877:d933:eecc:b305:21ff:77f9`). */
overlayIp: string | null;
}
export interface NodeInfo {
+7 -1
View File
@@ -19,7 +19,13 @@ export const useNodeStore = defineStore("node", () => {
exitedUnlisten = await on<number>(Events.SidecarExited, (e) => {
error.value = `daemon exited (code ${e.payload})`;
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;
});
}
+7
View File
@@ -23,6 +23,13 @@ async function copy(field: string, value: string) {
<template>
<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
label="Overlay subnet"
:value="info.nodeSubnet"