feat: pin TCP/QUIC listen ports + visible error banner

Two related improvements requested while testing the private fork:

1. Custom TCP/QUIC listen ports (SidecarConfig.tcpListenPort,
   .quicListenPort). With ephemeral ports, the daemon's peer-listen
   port changes at every restart, which makes port-forwarding on a
   home router useless after the first daemon stop. Pinning these
   keeps the same port across restarts so other private-network
   nodes can dial in reliably.

   Backend uses the configured port if Some, falls back to
   pick_port_skip otherwise. Frontend exposes two inputs in
   Settings → Daemon configuration with a help line explaining
   when to fill them.

2. Daemon-failure banner in App.vue. The previous behaviour was
   silent: a click on \"Start daemon\" that hit a backend error
   only flipped the sidebar dot to red, with no message visible.
   Now an inline banner at the top of the main content area shows
   the error, plus a \"Go to Settings\" shortcut when the message
   mentions a network config issue.
This commit is contained in:
syoul
2026-04-27 02:29:31 +02:00
parent 8b83fc10d5
commit a930c035c0
7 changed files with 183 additions and 2 deletions

View File

@@ -6,6 +6,11 @@ This fork tracks [`syoul/Mycell-UI`](https://git.open.us.org/syoul/Mycell-UI) (t
## [Unreleased]
### Added
- `SidecarConfig.tcpListenPort` and `SidecarConfig.quicListenPort` (Option<u16>): pin the daemon's peer-listen ports across restarts so they can be reliably port-forwarded on a home router. `None` keeps the previous ephemeral behaviour.
- Two input fields in **Settings → Daemon configuration** to expose them, with an explanation of when to set them.
- App.vue surfaces a top banner when `phase === 'error'` with the daemon error message and a shortcut "Go to Settings" button.
## [0.1.0] — 2026-04-27
Forked from `Mycell-UI@5229e2c` ("feat(packaging): pre-spawn cleanup wrapper for clean restarts").

83
CLAUDE.md Normal file
View File

@@ -0,0 +1,83 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Common commands
Frontend (run from repo root):
- `pnpm install` — install JS deps (pnpm, not npm/yarn).
- `pnpm typecheck``vue-tsc --noEmit`. The CI gate.
- `pnpm build` — typecheck + `vite build` into `dist/`. Tauri runs this as `beforeBuildCommand`.
- `pnpm dev` — Vite alone on port 1420. Mostly only useful through `tauri dev`.
Tauri / desktop:
- `bash scripts/fetch-mycelium.sh [VERSION]` — downloads the `mycelium-private` release tarball from `threefoldtech/mycelium` and installs it as `src-tauri/binaries/mycelium-private-<target-triple>` (the suffix Tauri's `externalBin` requires). Default version pinned in the script (currently `v0.6.1`). **Must be run before the first `tauri dev` / `tauri build` on a fresh checkout.**
- `pnpm tauri dev` — full dev cycle (Vite + cargo + window).
- `pnpm tauri build --bundles deb` — produces `src-tauri/target/release/bundle/deb/Mycellium UI Private_*.deb`.
Backend (run from `src-tauri/`):
- `cargo fmt --all -- --check`
- `cargo clippy --all-targets --locked -- -D warnings`
- `cargo test --locked`
- `cargo test --locked <name>` — run a single test by name substring.
CI (`.github/workflows/ci.yml`) gates on: `pnpm typecheck`, `cargo fmt --check`, `cargo clippy -D warnings`, `cargo test`. There is no JS test runner configured.
## Fork relationship
This is the **private-network** fork of [`syoul/Mycell-UI`](https://git.open.us.org/syoul/Mycell-UI) (public variant). Tracked as `origin/upstream`; bugfixes are cherry-picked. The two apps are designed to coexist on the same machine — distinct identifier, binary, polkit action, `.deb` package.
The only divergence files (per `README.md`):
- `src-tauri/src/sidecar.rs` — adds `--network-name` + `--network-key-file` args, refuses to start without them.
- `src-tauri/src/commands.rs` — adds `network_key_*` command family.
- `src-tauri/packaging/``mycellium-bootstrap-private` cleanup wrapper, polkit policy with new action ID.
- `src-tauri/tauri.conf.json``productName`, `identifier`, `externalBin`.
- `src/views/Settings.vue` — "Private network" section.
- `src/stores/config.ts` — empty default peer list (no Threefold-operated seed exists for private).
Everything else should match upstream byte-for-byte; cherry-picks usually apply cleanly.
## Architecture
Two-layer Tauri 2 desktop app. Rust supervises a `mycelium-private` daemon child process and exposes it to the Vue frontend via Tauri commands + events.
### Backend (`src-tauri/src/`)
- `lib.rs::run()` — Tauri builder. Registers store/shell/dialog plugins, manages `AppState`, declares every `commands::*` handler.
- `state.rs``AppState { sidecar: Arc<SidecarHandle>, poller: Arc<Poller> }`, single instance via `app.manage()`.
- `sidecar.rs`**the core lifecycle**. Spawns `mycelium-private` as a child via `pkexec`, runs a 20s health-check loop against the daemon's HTTP API, captures stdout/stderr into a 500-line ring buffer, watches for child exit and emits `sidecar://exited`. Pre-flight refuses to start without a non-empty `network_name` (2..=64 UTF-8 bytes) AND an existing `network_key.bin` — surfaces clear errors before pkexec fires.
- `elevation.rs` — thin `pkexec` wrapper. `is_auth_failure(code)` distinguishes pkexec dialog cancel/deny (`126`/`127`) from daemon crashes.
- `poller.rs` — three independent tokio loops on a started sidecar: peers (3s), routes (5s), inbox (2s short-poll). Each emits its own event; the inbox loop is **a short-poll workaround** for an upstream bug (mycelium 0.6.1's HTTP server serialises requests behind one worker, so a 30s long-poll starves every other endpoint).
- `commands.rs` — every `#[tauri::command]`. The frontend does not talk to mycelium HTTP directly; commands either delegate to `MyceliumClient` (`require_client(state)`) or manage local state (network key files, sidecar logs).
- `api/` — typed `MyceliumClient` over `reqwest`. **`pool_max_idle_per_host(0)` is intentional** — mycelium drops idle keep-alives around 10s, and reusing a stale pooled connection surfaces as a generic send error after `start_daemon` already returned. Each submodule (`admin`, `peers`, `routes`, `messages`, `topics`, `pubkey`) wraps one API surface.
- `error.rs``AppError` is `Serialize`-as-string so `invoke()` rejects with the bare message. Frontend matches on substring or shows it raw.
### Sidecar startup details
- Four ephemeral ports are picked per spawn (`api`, `tcp-listen`, `quic-listen`, `metrics`). Pinning `--metrics-api-address` to a fresh port matters: mycelium 0.6.1 hardcodes the JSON-RPC/metrics endpoint to `127.0.0.1:8990` by default, so an orphan from a previous run kills the new instance a few seconds in.
- In a `.deb` install, the elevation target is `/usr/bin/mycellium-bootstrap-private`, not the daemon directly — the wrapper `pkill`s any orphan `mycelium` / `mycelium-private` and `ip link del`s leftover TUN devices before `exec`ing the daemon. Because both the wrapper and daemon run under one pkexec call, polkit's `auth_admin_keep` shows only **one** dialog per session. In `tauri dev` (where the wrapper isn't installed) the bare binary is used and orphan cleanup must be handled manually.
- The sidecar binary is located by probing, in order: dir of `current_exe()``app.path().resource_dir()``CARGO_MANIFEST_DIR/binaries/``$PATH`. Both the suffixed name (`mycelium-private-<triple>`) and the bare name are tried, since `tauri build` strips the suffix when bundling into `/usr/bin/`.
- The daemon's HTTP API only exposes the overlay subnet, so `sidecar.rs` parses the full IPv6 host portion out of the daemon's `Node overlay IP: ...` log line. The scanner strips ANSI SGR escapes (no `regex` crate dependency) and stores the result in `SidecarHandle::overlay_ip` — the Status page surfaces both subnet and full IP.
### Frontend (`src/`)
- `main.ts` mounts Vue 3 + Pinia + Vue Router (`createWebHashHistory`).
- `App.vue` is the only persistent shell — sidebar nav + start/stop daemon button + `StartupOverlay` for the `phase === "starting"` state. The amber accent on "Mycellium **Private**" distinguishes the fork visually.
- `lib/api.ts`**typed `invoke()` wrappers, one per Rust command.** All TS types here mirror Rust structs (`#[serde(rename_all = "camelCase")]` on the Rust side, camelCase fields on the TS side). When you add or change a `#[tauri::command]`, update this file in lockstep — it is the only source of truth for the frontend's view of the backend surface.
- `lib/events.ts` — typed `listen()` over the six emitted events (`sidecar://ready`, `sidecar://exited`, `peers://updated`, `stats://updated`, `routes://updated`, `messages://incoming`). The `Events` constant must stay in sync with `app.emit()` calls in `sidecar.rs` and `poller.rs`.
- `stores/` — Pinia setup-style stores. `node.ts` owns the `Phase` machine (`idle | starting | ready | error`) and bridges sidecar events to UI state. `config.ts` persists `SidecarConfig` via `@tauri-apps/plugin-store` (`config.json`); private fork's `DEFAULT_CONFIG.peers` is `[]` (no seed). `peers/routes/messages/topics` are screen-specific stores.
- `views/` — one component per route (Status, Peers, Routes, Messages, Topics, Settings). `Settings.vue` is the only place that touches the network-key commands.
- `components/` — leaf widgets only; no business logic.
### Path alias
`@/*` resolves to `src/*` in both `tsconfig.json` and `vite.config.ts`. Use `@/lib/api`, `@/stores/node`, etc.
## Cross-cutting conventions
- **Adding a new daemon-backed command** requires four edits in lockstep: `src-tauri/src/api/<area>.rs` (HTTP wrapper) → `src-tauri/src/commands.rs` (`#[tauri::command]`) → `lib.rs::run()` `invoke_handler!` macro → `src/lib/api.ts` (TS wrapper + types). Forgetting the `invoke_handler!` registration is the most common silent failure — the command compiles but `invoke()` rejects with "command not found".
- **Serde casing**: every Rust type crossing into JS uses `#[serde(rename_all = "camelCase")]`; TS types use camelCase. Don't add snake_case TS types or you'll get runtime undefined fields.
- **No `unwrap()` in command paths.** Errors flow through `AppError` so the frontend gets a clean string. `panic!` will tear down the Tauri runtime.
- **One private overlay at a time.** No multi-network support. The network key lives at `$XDG_DATA_HOME/tech.threefold.mycellium-ui-private/network_key.bin` (mode `0600`, raw 32 bytes). It is not encrypted at rest — host disk encryption is the only layer.
- **`*.bin` is gitignored.** Never commit the dev network key or daemon identity. `docs-syoul/` (personal notes) is also gitignored — don't add files there expecting CI to see them.
- **Linux-only.** No macOS/Windows code paths. `pkexec` and the polkit action ID are wired into the architecture; do not add `cfg(target_os = "...")` branches without thinking about the elevation model.

View File

@@ -33,6 +33,12 @@ pub struct SidecarConfig {
/// UTF-8 network identifier (2..=64 bytes). Public; not a secret.
/// All nodes joining the same private overlay must agree on this.
pub network_name: Option<String>,
/// Pin the TCP listen port for inbound peer connections. Required
/// when the user port-forwards a fixed port on their router so
/// other nodes can reliably dial in. `None` ⇒ ephemeral port.
pub tcp_listen_port: Option<u16>,
/// Same as above for QUIC (UDP). `None` ⇒ ephemeral port.
pub quic_listen_port: Option<u16>,
}
/// Holds the running mycelium child process plus a small in-memory log
@@ -149,8 +155,14 @@ impl SidecarHandle {
// 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])?;
let tcp_port = match config.tcp_listen_port {
Some(p) if p != 0 => p,
_ => pick_port_skip(&[api_port])?,
};
let quic_port = match config.quic_listen_port {
Some(p) if p != 0 => p,
_ => 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

View File

@@ -120,6 +120,22 @@ async function handleStop() {
<header class="flex h-14 items-center border-b border-border px-6 shrink-0">
<h1 class="text-lg font-semibold">{{ currentTitle }}</h1>
</header>
<div
v-if="phase === 'error' && error"
class="border-b border-destructive/40 bg-destructive/10 px-6 py-3"
>
<div class="flex items-start gap-3 text-sm">
<span class="font-medium text-destructive">Daemon failed to start </span>
<span class="flex-1 break-words">{{ error }}</span>
<button
v-if="error.toLowerCase().includes('network')"
class="shrink-0 rounded-md border border-destructive px-2 py-0.5 text-xs text-destructive hover:bg-destructive hover:text-destructive-foreground"
@click="$router.push('/settings')"
>
Go to Settings
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto p-6">
<RouterView />
</div>

View File

@@ -23,6 +23,10 @@ export interface SidecarConfig {
/** UTF-8, 2..=64 bytes. Public — must match across all nodes of the
* same private overlay. */
networkName: string | null;
/** Pin TCP/QUIC listen ports so they survive a daemon restart and can
* be port-forwarded reliably. `null` ⇒ ephemeral port at each start. */
tcpListenPort: number | null;
quicListenPort: number | null;
}
export interface NetworkKeyStatus {

View File

@@ -14,8 +14,17 @@ const DEFAULT_CONFIG: SidecarConfig = {
tunName: null,
noTun: false,
networkName: null,
tcpListenPort: null,
quicListenPort: null,
};
function normalizePort(p: unknown): number | null {
if (p === null || p === undefined || p === "" || p === 0) return null;
const n = typeof p === "number" ? p : Number(p);
if (!Number.isInteger(n) || n < 1 || n > 65535) return null;
return n;
}
export const useConfigStore = defineStore("config", () => {
const config = ref<SidecarConfig>({ ...DEFAULT_CONFIG });
const loaded = ref(false);
@@ -35,6 +44,8 @@ export const useConfigStore = defineStore("config", () => {
tunName: saved.tunName ?? null,
noTun: !!saved.noTun,
networkName: saved.networkName ?? null,
tcpListenPort: normalizePort(saved.tcpListenPort),
quicListenPort: normalizePort(saved.quicListenPort),
};
}
loaded.value = true;
@@ -46,6 +57,8 @@ export const useConfigStore = defineStore("config", () => {
tunName: next.tunName?.trim() ? next.tunName.trim() : null,
noTun: !!next.noTun,
networkName: next.networkName?.trim() ? next.networkName.trim() : null,
tcpListenPort: normalizePort(next.tcpListenPort),
quicListenPort: normalizePort(next.quicListenPort),
};
const s = await ensureStore();
await s.set(KEY, config.value);

View File

@@ -30,6 +30,8 @@ const draft = reactive({
tunName: "",
noTun: false,
networkName: "",
tcpListenPort: "",
quicListenPort: "",
});
const dirty = ref(false);
@@ -38,9 +40,19 @@ function loadDraft() {
draft.tunName = config.value.tunName ?? "";
draft.noTun = config.value.noTun;
draft.networkName = config.value.networkName ?? "";
draft.tcpListenPort = config.value.tcpListenPort?.toString() ?? "";
draft.quicListenPort = config.value.quicListenPort?.toString() ?? "";
dirty.value = false;
}
function parsePort(s: string): number | null {
const t = s.trim();
if (!t) return null;
const n = Number(t);
if (!Number.isInteger(n) || n < 1 || n > 65535) return null;
return n;
}
watch(config, loadDraft, { immediate: true });
watch(
@@ -60,6 +72,8 @@ async function save() {
tunName: draft.tunName.trim() || null,
noTun: draft.noTun,
networkName: draft.networkName.trim() || null,
tcpListenPort: parsePort(draft.tcpListenPort),
quicListenPort: parsePort(draft.quicListenPort),
});
dirty.value = false;
}
@@ -387,6 +401,40 @@ onMounted(async () => {
:disabled="draft.noTun"
/>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div>
<label class="text-xs uppercase tracking-wide text-muted-foreground">
TCP listen port
</label>
<input
v-model="draft.tcpListenPort"
type="text"
inputmode="numeric"
class="mt-1 w-full rounded-md border border-input bg-background px-3 py-1.5 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="ephemeral"
/>
</div>
<div>
<label class="text-xs uppercase tracking-wide text-muted-foreground">
QUIC listen port
</label>
<input
v-model="draft.quicListenPort"
type="text"
inputmode="numeric"
class="mt-1 w-full rounded-md border border-input bg-background px-3 py-1.5 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="ephemeral"
/>
</div>
</div>
<p class="text-[11px] text-muted-foreground">
Pin these to a fixed value (e.g. <code class="font-mono">9651</code> for
TCP, <code class="font-mono">9652</code> for QUIC) when you port-forward
a stable port on your router so other private-network nodes can dial in
across daemon restarts. Leave empty to keep using a different ephemeral
port at every start.
</p>
<div class="flex justify-end gap-2 pt-2">
<button
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-secondary"