Compare commits

..

13 Commits

Author SHA1 Message Date
syoul 874dd33a6d release: v0.1.1
Bump package + crate + tauri identifier from 0.1.0 to 0.1.1 and
rebuild the .deb. The 0.1.1 binary includes everything between
fork-init and HEAD~0:
  - custom TCP/QUIC listen ports (default 9651/9652)
  - daemon-failure banner with shortcut to Settings
  - misc UX wording

CHANGELOG split: 0.1.1 section closes the previously-Unreleased
list; 0.1.0 untouched.

release/mycellium-ui-private_0.1.0_amd64.deb removed and replaced
by 0.1.1 with refreshed SHA256SUMS.
2026-04-27 02:44:14 +02:00
syoul e4c91426be feat(config): default tcp/quic listen ports to mycelium standard 9651/9652
Pinning ephemeral ports made the existing field useful but left the
default behaviour (random port at every start) hostile to the
typical private-network setup, where the user pre-configures a
port-forward on their home router and expects mycelium to keep
using the same port.

Default to 9651 (TCP) and 9652 (QUIC), which match the public
mycelium convention. Clearing either field still falls back to
ephemeral. Help text updated; placeholder now says "leave empty
for ephemeral" instead of "ephemeral" so users understand the
field is currently filled with the default.
2026-04-27 02:31:58 +02:00
syoul a930c035c0 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.
2026-04-27 02:29:31 +02:00
syoul 8b83fc10d5 fork: initialize Mycellium UI Private from Mycell-UI@5229e2c
This repo is a hard fork of mycellium-ui dedicated to the
mycelium-private experimental track upstream. The two apps coexist
on the same machine via distinct app identifiers, polkit actions,
and binary names.

Renames
- package + crate: mycellium-ui → mycellium-ui-private
- bundle identifier: tech.threefold.mycellium-ui-private
- daemon binary: mycelium-private (separate upstream release tarball)
- bootstrap wrapper: /usr/bin/mycellium-bootstrap-private
- polkit policy file + action id

Functional changes
- SidecarConfig.network_name field (UTF-8, 2..=64 bytes)
- start() refuses to spawn without a network name AND a 32-byte
  key file at app_data_dir/network_key.bin; surfaces a clear
  error rather than letting mycelium-private fail mid-startup
- network_key_status / generate / import / export / delete
  commands; uses OS RNG (rand) and writes 0600
- empty default peers list (no Threefold seed for private overlays)
- new Settings → Private network panel: name input, key generate /
  reveal-hex / import / delete, status indicator

Adapted bootstrap script kills both `mycelium` and
`mycelium-private` orphans (cross-clash on UDP/9650 + TCP/8990).

CI workflow + sidebar branding updated. The README explains the
divergence model and how to cherry-pick upstream fixes.
2026-04-27 01:37:07 +02:00
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
syoul b5909ccb56 release: v0.1.0 .deb bundle
Bundle of all phases (P0-P5) plus the diagnostic fixes from the
end-to-end test session: ephemeral peer/metrics ports, no reqwest
connection pool, short-poll inbox, expanded sidecar path probe.

Smoke-tested by exchanging messages with a second mycelium node in
a Docker container against the public Threefold seed
tcp://188.40.132.242:9651 — bidirectional delivery confirmed.

Built on Debian 12 (bookworm), x86_64, glibc-targeted but the
embedded mycelium binary is musl static.
2026-04-26 01:18:46 +02:00
syoul a31a40a477 fix(sidecar): probe installed and dev paths for the binary
dpkg-deb -c on the bundled .deb shows mycelium at /usr/bin/mycelium
(no triple suffix), next to the app launcher. Our previous resolver
only looked for the suffixed dev name in resource_dir, so the
installed app could not find its sidecar.

Probe order is now: directory of current_exe, then resource_dir,
then CARGO_MANIFEST_DIR/binaries, then $PATH, with both the plain
"mycelium" name and the dev-style "mycelium-<triple>" alias
checked at each location.
2026-04-26 01:09:44 +02:00
syoul 939565b88a fix(poller): short-poll inbox instead of 30s long-poll
The 10s-after-healthy failure pattern was reproducing even with
the connection pool disabled. Smoking gun: the inbox loop opens
GET /messages?timeout=30 right after start_daemon returns, and
every subsequent peers/routes call timed out exactly when our
client-side reqwest timeout (10s) fired.

Concluded mycelium 0.6.1's HTTP server serialises requests: while
the long-poll connection is held, no other admin endpoint can
respond. The sidecar process kept logging routes the whole time
(seen in the in-app log buffer) — proof the daemon was alive,
just unable to serve concurrent calls.

Switch to short-poll: timeout=0 returns immediately, sleep 2s
between iterations. Per-iteration server hold time is now
millisecond-scale instead of 30s.
2026-04-26 00:32:58 +02:00
syoul 7981fc571c fix(api): disable reqwest connection pool
Direct mycelium runs and our pkexec spawns are both healthy
(sidecar logs show acquired routes streaming for 20+s). Yet our
reqwest poller can't reach 127.0.0.1:port after the first
successful request. Smoking gun: failure happens ~10s after the
first reply — exactly when an idle keep-alive connection would
have been reaped.

Disable pooling (pool_max_idle_per_host(0)) so every call opens a
fresh TCP connection. Loopback overhead is negligible (~50us per
request) and we're immune to server-side idle closes. Also pin
connect_timeout to 3s so a wedged half-open doesn't block for
the full 10s request timeout.
2026-04-26 00:18:59 +02:00
syoul 9fe24c72cb fix(ui): only show overlay when starting, not on error
The overlay covered the whole window in error phase too, blocking
access to the sidebar and Settings page where the user needs to
read the sidecar logs to diagnose the failure.

Now in error phase the sidebar status dot turns red, the start
button is back in the sidebar, and the Settings page is reachable
to inspect the in-app log buffer.
2026-04-26 00:13:47 +02:00
27 changed files with 1001 additions and 165 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/
+53
View File
@@ -0,0 +1,53 @@
# Changelog
All notable changes to this fork are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), version numbers follow [Semantic Versioning](https://semver.org/).
This fork tracks [`syoul/Mycell-UI`](https://git.open.us.org/syoul/Mycell-UI) (the public-network variant) as `upstream`. Bugfixes from upstream are cherry-picked; this changelog only records changes specific to the private-network track.
## [Unreleased]
## [0.1.1] — 2026-04-27
### 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. Defaults to mycelium's standard `9651` (TCP) and `9652` (QUIC); clearing the field falls back to ephemeral.
- Two input fields in **Settings → Daemon configuration** to expose them, with help text explaining when to override.
- App.vue surfaces a top banner when `phase === 'error'` with the daemon error message and a shortcut "Go to Settings" button when the failure mentions a network-config issue.
### Changed
- `release/mycellium-ui-private_*.deb` rebuilt against the above.
## [0.1.0] — 2026-04-27
Forked from `Mycell-UI@5229e2c` ("feat(packaging): pre-spawn cleanup wrapper for clean restarts").
### Added
- Bundling of the `mycelium-private` daemon (separate upstream release tarball — `mycelium-private-{triple}-unknown-linux-musl.tar.gz`) instead of the public `mycelium`.
- `SidecarConfig.networkName` field (UTF-8, 2..=64 bytes — public, agreed across all nodes of the same overlay).
- Network key management: `network_key_status`, `network_key_generate`, `network_key_import`, `network_key_export`, `network_key_delete` Tauri commands. Keys are 32 random bytes from the OS RNG, stored at `app_data_dir/network_key.bin` with mode `0600`.
- "Private network" section in **Settings** at the top: name input, generate / reveal-hex / import / delete buttons, configured / missing status badge.
- Pre-flight check in `sidecar::start`: refuses to spawn the daemon without a non-empty network name AND an existing key file. Surfaces a clear error rather than letting `mycelium-private` fail mid-startup.
- `mycellium-bootstrap-private` cleanup wrapper kills both `mycelium` and `mycelium-private` orphans on each spawn, since either would block UDP/9650 (multicast discovery) and TCP/8990 (hardcoded JSON-RPC port).
### Changed
- App identifier: `tech.threefold.mycellium-ui``tech.threefold.mycellium-ui-private`.
- Cargo crate name + lib name: `mycellium_ui_lib``mycellium_ui_private_lib`.
- Display: `Mycellium UI``Mycellium UI Private`. Sidebar shows "Mycellium **Private**" with an amber accent.
- `.deb` package name: `mycellium-ui``mycellium-ui-private`. Distinct binary at `/usr/bin/mycellium-ui-private`. The two apps coexist on the same machine.
- polkit action ID: `tech.threefold.mycellium-ui.bootstrap``tech.threefold.mycellium-ui-private.bootstrap`. Path annotation points at `/usr/bin/mycellium-bootstrap-private`.
- Default peer list is now empty: a private network has no Threefold-operated seed; the user must explicitly add bootstrap peers they control.
- The "Static peers" textarea hint in **Settings** changed from "tcp://188.40.132.242:9651" to "tcp://your-node.example.org:9651".
- README rewritten to focus on the private-network model, key distribution flow, and the divergence policy from upstream.
### Removed
- The two public Threefold seed peers from `SidecarConfig::default()` (TCP 188.40.132.242:9651 and QUIC [2a01:4f8:212:fa6::2]:9651).
### Inherited from upstream `Mycell-UI@5229e2c`
- Sidecar lifecycle via pkexec with elevated cleanup wrapper (`auth_admin_keep` cached per session).
- Ephemeral ports for the REST API, peer-listen TCP/QUIC, and Prometheus metrics endpoint.
- reqwest connection pool disabled (`pool_max_idle_per_host(0)`) to dodge stale-connection errors on long-running sessions.
- Short-poll inbox loop (workaround for the upstream HTTP serialization bug — see `docs-syoul/upstream-bug-http-serialization.md` in the public repo).
- Status page surfaces the full overlay IPv6 (parsed from the daemon's `Node overlay IP:` log line) in addition to the `/64` subnet.
- `pkexec | policykit-1` Depends alternative for compatibility with both Debian 12 (bookworm) and Debian 13 (trixie).
[Unreleased]: https://git.open.us.org/syoul/Mycell-UI-Private/compare/v0.1.0...HEAD
[0.1.0]: https://git.open.us.org/syoul/Mycell-UI-Private/releases/tag/v0.1.0
+83
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.
+54 -81
View File
@@ -1,37 +1,26 @@
# mycellium-ui
# mycellium-ui-private
Cross-platform desktop GUI for [Mycelium](https://github.com/threefoldtech/mycelium) — Threefold's end-to-end encrypted IPv6 overlay network.
Desktop GUI for joining a **private** [Mycelium](https://github.com/threefoldtech/mycelium) overlay network — a self-contained IPv6 mesh isolated from the public Mycelium network by a shared 32-byte key.
The app embeds the official `mycelium` binary as a Tauri sidecar and pilots it through its HTTP API on a loopback ephemeral port. Root privileges (required to create the TUN interface) are obtained via `pkexec`.
This is a fork of [`mycellium-ui`](https://git.open.us.org/syoul/Mycell-UI) (the public-network variant). Code structure is identical; only the bundled daemon (`mycelium-private` instead of `mycelium`), the default config (no public seed peer), and a new "Private network" panel in Settings differ.
The two apps are designed to **coexist** on the same machine: distinct app identifier (`tech.threefold.mycellium-ui-private`), distinct binary (`/usr/bin/mycellium-ui-private`), distinct polkit action.
## What's a private network?
Mycelium 0.6.1 ships an opt-in mode where every packet is wrapped under an additional symmetric key. Two nodes only see each other if:
1. They run the `mycelium-private` daemon (not the public `mycelium`).
2. They were both started with the same `--network-name` (UTF-8, 264 bytes — public, e.g. `acme-corp-private`).
3. They were both started with `--network-key-file` pointing at the same 32-byte secret.
Without the right name+key, peers reject each other at the handshake. There's no Threefold-operated bootstrap node for private networks — the operator distributes the key out-of-band and brings up at least one reachable peer (typically a VPS) that other nodes can dial.
## Status
v1, Linux-only. Implements the full `docs/api.yaml` surface of mycelium v0.6.1: admin, peers (CRUD), routes (selected/fallback/queried), messages (send/receive/reply/status), topics (default + whitelist + sources + forward), pubkey lookup.
v0.1, Linux-only. Same UI surface as the public variant. **Marked experimental upstream** (`docs/private_network.md` in the mycelium repo).
## Architecture
```
┌──────────────────────────────────────────────────────────────┐
│ WebView (Vue 3 + TS + Tailwind + radix-vue + Pinia) │
│ Status / Peers / Routes / Messages / Topics / Settings │
└────────────────┬─────────────────────────────────────────────┘
│ invoke() / Tauri events
┌────────────────┴─────────────────────────────────────────────┐
│ Tauri core (Rust, tokio + reqwest) │
│ • sidecar.rs — supervises mycelium via pkexec │
│ • api/* — typed REST client │
│ • poller.rs — emits peers://, stats://, routes://, messages://incoming │
└────────────────┬─────────────────────────────────────────────┘
│ HTTP loopback :ephemeral
┌────────────────┴─────────────────────────────────────────────┐
│ mycelium daemon (sidecar binary, runs as root via pkexec) │
│ TUN0 ◄─► overlay network │
└──────────────────────────────────────────────────────────────┘
```
There is no Unix socket / named pipe IPC — the daemon's own HTTP API is the integration point.
## Prerequisites (Debian / Ubuntu)
## Setup (dev)
```bash
sudo apt install -y \
@@ -40,72 +29,56 @@ sudo apt install -y \
build-essential curl wget file libssl-dev libgtk-3-dev libxdo-dev \
pkg-config policykit-1
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
```
Then Node 20+ and pnpm 10+.
## Setup
```bash
# 1. Install JS deps
pnpm install
# 2. Fetch the mycelium sidecar binary for your target triple
bash scripts/fetch-mycelium.sh # uses MYCELIUM_VERSION (default v0.6.1)
# or: MYCELIUM_VERSION=v0.6.1 bash scripts/fetch-mycelium.sh
# 3. Run in dev
bash scripts/fetch-mycelium.sh # downloads mycelium-private v0.6.1
pnpm tauri dev
```
The first start triggers a `pkexec` dialog asking you to authenticate; the polkit policy installed by the `.deb` caches the auth for the user session.
On first launch:
## Build
1. Open **Settings → Private network**.
2. Type a network name (e.g. `acme-corp-private`).
3. Click **Generate 32-byte key** (or **Import** a key already shared with you).
4. Click **Reveal hex** and share the hex string out-of-band with your other nodes.
5. Save daemon configuration (add at least one bootstrap peer).
6. Click **Start daemon** in the sidebar.
Other nodes paste the same hex into their **Import** field and use the same network name.
## Build a `.deb`
```bash
pnpm tauri build # → src-tauri/target/release/bundle/{deb,appimage}/
pnpm tauri build --bundles deb
# → src-tauri/target/release/bundle/deb/Mycellium UI Private_*.deb
```
The `.deb` declares `Depends: policykit-1` and ships the polkit policy under `/usr/share/polkit-1/actions/tech.threefold.mycellium-ui.policy`. The AppImage relies on `pkexec` being present on the host — on systems without polkit, fall back to running with `sudo` after disabling the sidecar's pkexec wrapper.
The `.deb` declares `Depends: pkexec | policykit-1` (Debian 12 + 13 covered) and ships:
- `/usr/bin/mycellium-ui-private` (GUI)
- `/usr/bin/mycelium-private` (daemon)
- `/usr/bin/mycellium-bootstrap-private` (cleanup wrapper for orphan handling)
- `/usr/share/polkit-1/actions/tech.threefold.mycellium-ui-private.policy`
## Layout
## Diverging from upstream
```
src/ # Vue 3 frontend
views/ # one file per nav item
components/ # shadcn-style UI primitives + dialogs
stores/ # Pinia: node, peers, routes, messages, topics, config
lib/ # api wrapper, events, base64 + format helpers
src-tauri/
src/
sidecar.rs # spawn + supervise mycelium
elevation.rs # pkexec command builder
poller.rs # 3 background loops (peers, routes, inbox long-poll)
api/ # REST client modules (admin, peers, routes,
# messages, topics, pubkey)
commands.rs # #[tauri::command] handlers, 1:1 with REST
error.rs # AppError + Serialize-as-string for invoke()
binaries/ # gitignored; populated by scripts/fetch-mycelium.sh
packaging/polkit/ # XML policy bundled into the .deb
scripts/fetch-mycelium.sh
.github/workflows/ci.yml # pnpm typecheck + cargo fmt/clippy/test
This fork tracks `Mycell-UI` upstream as `origin/upstream` (after running the `git remote add upstream ...` step). To pull bugfixes:
```bash
git fetch upstream
git cherry-pick <upstream-commit>
```
## Verification matrix
Most fixes will apply cleanly because the only divergence is in:
- `src-tauri/src/sidecar.rs` (network_name + key_file args)
- `src-tauri/src/commands.rs` (network_key_* commands)
- `src-tauri/packaging/` (different bootstrap script + polkit policy)
- `src-tauri/tauri.conf.json` (productName, identifier, externalBin)
- `src/views/Settings.vue` (Private network section)
- `src/stores/config.ts` (defaults)
| Test | How |
|------|-----|
| Sidecar starts under pkexec | `pnpm tauri dev`, daemon visible in `ps`, splash disappears in <10 s |
| Peers connect | Add `tcp://188.40.132.242:9651` from the Peers page; state turns to `alive` within ~10 s |
| Routes propagate | `Routes/Selected` becomes non-empty after ~30 s |
| Live event stream | Sidebar status dot tracks ready/idle, peers table updates without manual refresh |
| Bidirectional messages | Two instances on different VMs, exchange via Compose → Inbox |
| Identity regen | Settings → Regenerate; restart daemon; new IP appears on Status |
| `.deb` install | Fresh Ubuntu LTS / Debian 12; daemon spawns under polkit on first start |
## Known limitations (v0.1)
## Known limitations (v1)
- Linux only. Windows is reachable (sidecar via `runas` / Wintun driver) but not implemented.
- Auto-start at login isn't wired — the desktop entry installed by the `.deb` is the manual launcher.
- The TOML config editor in Settings only exposes `peers`, `tunName`, `noTun`. Other keys (`metricsApiAddress`, etc.) are passed-through if you edit the file directly at `~/.local/share/tech.threefold.mycellium-ui/mycelium.toml` and restart the daemon.
- `message_status` is forwarded as opaque JSON; the upstream schema isn't pinned in the spec, so we don't strongly type it.
- Linux only.
- The `mycelium-private` binary is upstream's experimental track; the API may shift in a future release.
- Network keys are stored in `$XDG_DATA_HOME/tech.threefold.mycellium-ui-private/network_key.bin` with mode `0600`. Not encrypted at rest — host-level disk encryption is the only layer.
- No multi-network support — one private overlay at a time.
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mycellium UI</title>
<title>Mycellium Private</title>
</head>
<body>
<div id="app"></div>
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "mycellium-ui",
"name": "mycellium-ui-private",
"private": true,
"version": "0.1.0",
"version": "0.1.1",
"type": "module",
"scripts": {
"dev": "vite",
+49
View File
@@ -0,0 +1,49 @@
# Releases — Mycellium UI Private
Pre-built `.deb` of the private-network desktop client. Designed to coexist with the public-network variant `mycellium-ui` on the same machine (different identifier, different binary, different polkit action).
## Install
```bash
sudo apt install ./mycellium-ui-private_0.1.1_amd64.deb
```
`apt install` with a local path resolves runtime deps (`pkexec | policykit-1`, `libwebkit2gtk-4.1-0`, `libgtk-3-0`) automatically. Plain `dpkg -i` will fail if any of those are missing.
Tested on Debian 12 (bookworm) and Debian 13 (trixie).
## Verify
```bash
sha256sum -c SHA256SUMS
```
## What's inside
| Path | Purpose |
|---|---|
| `/usr/bin/mycellium-ui-private` | GUI launcher |
| `/usr/bin/mycelium-private` | Mycelium daemon (private-network track, v0.6.1, runs as root via pkexec) |
| `/usr/bin/mycellium-bootstrap-private` | Cleanup wrapper invoked by pkexec |
| `/usr/share/polkit-1/actions/tech.threefold.mycellium-ui-private.policy` | polkit action — auth cached per session (`auth_admin_keep`) |
| `/usr/share/applications/Mycellium UI Private.desktop` | Menu entry |
## First run
1. Open the app — sidebar pastille is grey (idle).
2. Go to **Settings → Private network** at the top.
3. Type a network name (UTF-8, 264 bytes — public, agreed with your peers).
4. Click **Generate 32-byte key**, then **Reveal hex** to copy and share with the other nodes through a secure channel.
5. Other nodes paste the hex into their **Import** field.
6. In Daemon configuration, add at least one bootstrap peer.
7. Click **Start daemon** in the sidebar — pkexec prompt the first time.
Without a network name AND a key file, the daemon refuses to start (the app surfaces a clear error).
## Uninstall
```bash
sudo apt remove mycellium-ui-private
```
The user data (identity key, network key) under `~/.local/share/tech.threefold.mycellium-ui-private/` is preserved across reinstall. Remove it manually if you want a fresh start.
+1
View File
@@ -0,0 +1 @@
b70044915c695ffa3fbd32501b6d2fd7fd255f0be73d65a62cb1e940dc15e118 release/mycellium-ui-private_0.1.1_amd64.deb
Binary file not shown.
+12 -12
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
# Fetches the official mycelium release binary and places it in src-tauri/binaries/
# with the target-triple suffix expected by Tauri's externalBin bundler.
# Fetches the official mycelium-private release binary and places it in
# src-tauri/binaries/ with the target-triple suffix expected by Tauri's
# externalBin bundler.
#
# Usage: scripts/fetch-mycelium.sh [VERSION]
# VERSION defaults to MYCELIUM_VERSION below.
@@ -28,8 +29,8 @@ detect_target_triple() {
# Map our target triple to the asset name pattern used by upstream releases.
asset_for_triple() {
case "$1" in
x86_64-unknown-linux-gnu) echo "mycelium-x86_64-unknown-linux-musl.tar.gz" ;;
aarch64-unknown-linux-gnu) echo "mycelium-aarch64-unknown-linux-musl.tar.gz" ;;
x86_64-unknown-linux-gnu) echo "mycelium-private-x86_64-unknown-linux-musl.tar.gz" ;;
aarch64-unknown-linux-gnu) echo "mycelium-private-aarch64-unknown-linux-musl.tar.gz" ;;
*) echo "unsupported triple: $1" >&2; exit 1 ;;
esac
}
@@ -42,23 +43,22 @@ TMP_DIR="$(mktemp -d)"
trap 'rm -rf "${TMP_DIR}"' EXIT
echo "→ downloading ${URL}"
curl -fsSL "${URL}" -o "${TMP_DIR}/mycelium.tar.gz"
curl -fsSL "${URL}" -o "${TMP_DIR}/mycelium-private.tar.gz"
echo "→ extracting"
tar -xzf "${TMP_DIR}/mycelium.tar.gz" -C "${TMP_DIR}"
tar -xzf "${TMP_DIR}/mycelium-private.tar.gz" -C "${TMP_DIR}"
# The archive contains a single 'mycelium' binary at the root.
SRC="${TMP_DIR}/mycelium"
# The archive contains a single 'mycelium-private' binary at the root.
SRC="${TMP_DIR}/mycelium-private"
if [[ ! -f "${SRC}" ]]; then
# Some releases nest the binary; find it.
SRC="$(find "${TMP_DIR}" -name 'mycelium' -type f -executable | head -n1)"
SRC="$(find "${TMP_DIR}" -name 'mycelium-private' -type f -executable | head -n1)"
fi
if [[ -z "${SRC}" || ! -f "${SRC}" ]]; then
echo "could not locate mycelium binary in archive" >&2
echo "could not locate mycelium-private binary in archive" >&2
exit 1
fi
DEST="${DEST_DIR}/mycelium-${TRIPLE}"
DEST="${DEST_DIR}/mycelium-private-${TRIPLE}"
install -m 0755 "${SRC}" "${DEST}"
echo "✓ installed ${DEST}"
echo " version: ${MYCELIUM_VERSION}"
+4 -2
View File
@@ -1946,11 +1946,13 @@ dependencies = [
]
[[package]]
name = "mycellium-ui"
version = "0.1.0"
name = "mycellium-ui-private"
version = "0.1.1"
dependencies = [
"hex",
"parking_lot",
"portpicker",
"rand 0.8.6",
"reqwest 0.12.28",
"serde",
"serde_json",
+6 -4
View File
@@ -1,13 +1,13 @@
[package]
name = "mycellium-ui"
version = "0.1.0"
description = "Mycelium overlay network desktop client"
name = "mycellium-ui-private"
version = "0.1.1"
description = "Mycelium private network desktop client"
authors = ["syoul"]
edition = "2021"
rust-version = "1.77"
[lib]
name = "mycellium_ui_lib"
name = "mycellium_ui_private_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
@@ -27,6 +27,8 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
portpicker = "0.1"
parking_lot = "0.12"
rand = "0.8"
hex = "0.4"
[features]
custom-protocol = ["tauri/custom-protocol"]
+29
View File
@@ -0,0 +1,29 @@
#!/bin/sh
# /usr/bin/mycellium-bootstrap-private — installed by mycellium-ui-private.deb
#
# Wrapper around the mycelium-private daemon that guarantees a clean
# start every time. Without this, an orphan daemon left over from a
# previous run (which the user-space launcher cannot SIGKILL because
# it runs as root via pkexec) would block the next start with one of:
#
# * EBUSY on TUN device 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 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. We pkill both `mycelium` and
# `mycelium-private` because the public-variant orphan would clash
# on UDP/9650 and TCP/8990 just as readily.
pkill -9 -x mycelium-private 2>/dev/null || true
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-private "$@"
@@ -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-private.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-private</annotate>
</action>
</policyconfig>
+7
View File
@@ -22,8 +22,15 @@ pub struct MyceliumClient {
impl MyceliumClient {
pub fn new(base: impl Into<String>) -> Self {
// Mycelium's HTTP server seems to drop idle keep-alive connections
// around the 10s mark; reusing a pooled stale connection surfaces
// as a generic "error sending request" once `start_daemon`
// returned. Open a fresh TCP connection per request — overhead is
// negligible on loopback and immune to server-side closes.
let http = Client::builder()
.pool_max_idle_per_host(0)
.timeout(Duration::from_secs(10))
.connect_timeout(Duration::from_secs(3))
.build()
.expect("reqwest client build");
Self {
+114 -1
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(),
}
}
@@ -279,10 +285,117 @@ fn default_key_path() -> Option<std::path::PathBuf> {
dirs_like_app_data().ok().map(|d| d.join("priv_key.bin"))
}
fn network_key_path_for(app: &AppHandle) -> AppResult<std::path::PathBuf> {
use tauri::Manager;
let dir = app
.path()
.app_data_dir()
.map_err(|e| AppError::TauriPath(e.to_string()))?;
std::fs::create_dir_all(&dir)?;
Ok(dir.join("network_key.bin"))
}
// ─── Private network key ────────────────────────────────────────────────────
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NetworkKeyStatus {
pub path: String,
pub exists: bool,
}
#[tauri::command]
pub fn network_key_status(app: AppHandle) -> AppResult<NetworkKeyStatus> {
let path = network_key_path_for(&app)?;
Ok(NetworkKeyStatus {
path: path.display().to_string(),
exists: path.exists(),
})
}
/// Generate a fresh 32-byte PSK using the OS RNG and write it to the
/// canonical key location with mode 0600. Refuses to overwrite an
/// existing file unless `overwrite=true`.
#[tauri::command]
pub fn network_key_generate(app: AppHandle, overwrite: bool) -> AppResult<NetworkKeyStatus> {
use rand::RngCore;
let path = network_key_path_for(&app)?;
if path.exists() && !overwrite {
return Err(AppError::BadInput(
"network key already exists; pass overwrite=true to replace it".into(),
));
}
let mut buf = [0u8; 32];
rand::thread_rng().fill_bytes(&mut buf);
write_key_0600(&path, &buf)?;
Ok(NetworkKeyStatus {
path: path.display().to_string(),
exists: true,
})
}
#[tauri::command]
pub fn network_key_import(
app: AppHandle,
hex_key: String,
overwrite: bool,
) -> AppResult<NetworkKeyStatus> {
let bytes = hex::decode(hex_key.trim()).map_err(|e| {
AppError::BadInput(format!("invalid hex string: {e}"))
})?;
if bytes.len() != 32 {
return Err(AppError::BadInput(format!(
"network key must decode to exactly 32 bytes, got {}",
bytes.len()
)));
}
let path = network_key_path_for(&app)?;
if path.exists() && !overwrite {
return Err(AppError::BadInput(
"network key already exists; pass overwrite=true to replace it".into(),
));
}
write_key_0600(&path, &bytes)?;
Ok(NetworkKeyStatus {
path: path.display().to_string(),
exists: true,
})
}
#[tauri::command]
pub fn network_key_export(app: AppHandle) -> AppResult<String> {
let path = network_key_path_for(&app)?;
let bytes = std::fs::read(&path).map_err(AppError::from)?;
Ok(hex::encode(bytes))
}
#[tauri::command]
pub fn network_key_delete(app: AppHandle) -> AppResult<()> {
let path = network_key_path_for(&app)?;
if path.exists() {
std::fs::remove_file(&path).map_err(AppError::from)?;
}
Ok(())
}
fn write_key_0600(path: &std::path::Path, bytes: &[u8]) -> AppResult<()> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.map_err(AppError::from)?;
f.write_all(bytes).map_err(AppError::from)?;
Ok(())
}
fn dirs_like_app_data() -> std::io::Result<std::path::PathBuf> {
// We can't reach the AppHandle here, so we mirror Tauri's path:
// $XDG_DATA_HOME/<identifier>/ or $HOME/.local/share/<identifier>/.
let identifier = "tech.threefold.mycellium-ui";
let identifier = "tech.threefold.mycellium-ui-private";
if let Ok(d) = std::env::var("XDG_DATA_HOME") {
return Ok(std::path::PathBuf::from(d).join(identifier));
}
+5
View File
@@ -57,6 +57,11 @@ pub fn run() {
commands::topic_forward_remove,
commands::lookup_pubkey,
commands::regenerate_identity,
commands::network_key_status,
commands::network_key_generate,
commands::network_key_import,
commands::network_key_export,
commands::network_key_delete,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+1 -1
View File
@@ -1,5 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
mycellium_ui_lib::run();
mycellium_ui_private_lib::run();
}
+10 -6
View File
@@ -11,7 +11,7 @@ use tracing::warn;
const PEERS_INTERVAL: Duration = Duration::from_secs(3);
const ROUTES_INTERVAL: Duration = Duration::from_secs(5);
const INBOX_LONG_POLL_SECS: u64 = 30;
const INBOX_INTERVAL: Duration = Duration::from_secs(2);
const INBOX_RETRY_BACKOFF: Duration = Duration::from_secs(2);
const INBOX_CAPACITY: usize = 200;
@@ -121,18 +121,22 @@ fn spawn_inbox_loop(
) -> JoinHandle<()> {
tokio::spawn(async move {
loop {
tokio::time::sleep(INBOX_INTERVAL).await;
let Some(client) = sidecar.client() else {
break;
};
// Each iteration is a fresh long-poll. The daemon answers as
// soon as a message arrives, or returns an empty body / 204
// when the timeout window elapses.
match client.pop_message(false, INBOX_LONG_POLL_SECS, None).await {
// Short-poll: timeout=0 returns immediately if no message.
// We previously used a 30s long-poll, but mycelium 0.6.1's
// HTTP server appears to serialise requests behind a single
// worker — holding the connection for 30s starved every
// other endpoint (peers, routes, admin) until our own
// 10s reqwest timeout kicked in.
match client.pop_message(false, 0, None).await {
Ok(Some(msg)) => {
me.push_inbox(msg.clone());
let _ = app.emit("messages://incoming", &msg);
}
Ok(None) => {} // window expired, loop
Ok(None) => {}
Err(e) => {
warn!(error = %e, "inbox: pop_message failed");
tokio::time::sleep(INBOX_RETRY_BACKOFF).await;
+185 -28
View File
@@ -16,27 +16,29 @@ 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)]
/// All fields default to their natural empty/false value: a fresh app
/// install has no peers, no TUN override, TUN enabled, and no network
/// name set. The user is then guided to configure these in Settings
/// before the daemon will accept a start.
#[derive(Debug, Clone, Default, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SidecarConfig {
/// Bootstrap peers for the private overlay. Unlike the public app
/// which seeds against `tcp://188.40.132.242:9651`, a private
/// network has no Threefold-operated relay — the user must point
/// at one or more nodes they control.
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,
}
}
/// 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
@@ -48,6 +50,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 +64,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 +91,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();
@@ -101,15 +122,47 @@ impl SidecarHandle {
return Err(AppError::DaemonAlreadyRunning);
}
// A private network needs a name (1..=64 UTF-8 bytes) AND a 32-byte
// pre-shared key. We surface a clear error rather than letting the
// daemon fail with a less obvious message half-way through startup.
let network_name = config.network_name.as_deref().unwrap_or("").trim();
if network_name.is_empty() {
return Err(AppError::BadInput(
"private network name is required (Settings → Private network)".into(),
));
}
if !(2..=64).contains(&network_name.len()) {
return Err(AppError::BadInput(
"private network name must be 2..=64 UTF-8 bytes".into(),
));
}
let bin = locate_sidecar(app)?;
// In a `.deb` install, our pre-install script ships
// /usr/bin/mycellium-bootstrap-private 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)
// 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
@@ -124,6 +177,15 @@ impl SidecarHandle {
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 network_key_path = data_dir.join("network_key.bin");
if !network_key_path.exists() {
return Err(AppError::BadInput(
format!(
"network key file is missing at {} — generate or import one in Settings → Private network",
network_key_path.display()
)
));
}
let mut args = vec![
"--api-addr".to_string(),
@@ -136,6 +198,10 @@ impl SidecarHandle {
format!("127.0.0.1:{metrics_port}"),
"--key-file".to_string(),
key_path.display().to_string(),
"--network-name".to_string(),
network_name.to_string(),
"--network-key-file".to_string(),
network_key_path.display().to_string(),
];
if config_path.exists() {
args.push("--config-file".to_string());
@@ -157,11 +223,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 +329,7 @@ impl SidecarHandle {
fn cleanup(&self) {
*self.api_url.lock() = None;
*self.overlay_ip.lock() = None;
let _ = self.child.lock().take();
}
@@ -279,6 +347,57 @@ impl SidecarHandle {
}
}
/// Path of the privileged wrapper script shipped in our `.deb`. When
/// present, we invoke it instead of the mycelium-private binary
/// directly so the elevated context can clean up any orphan TUN /
/// processes from a previous crash before `exec /usr/bin/mycelium-private`.
fn bootstrap_path() -> Option<PathBuf> {
let p = PathBuf::from("/usr/bin/mycellium-bootstrap-private");
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()))
}
@@ -295,30 +414,68 @@ fn pick_port_skip(taken: &[u16]) -> AppResult<u16> {
))
}
/// Resolve the bundled `mycelium-<triple>` binary in both `tauri dev`
/// (cargo manifest) and bundled (resource_dir) modes.
/// Resolve the bundled `mycelium-private` 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-private` 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 name = format!("mycelium-{triple}");
let suffixed = format!("mycelium-private-{triple}");
let plain = "mycelium-private".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() {
let p = resource.join(&name);
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);
}
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let dev_path = manifest_dir.join("binaries").join(&name);
if dev_path.exists() {
return Ok(dev_path);
// 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);
}
}
}
tried.push(dev_path);
Err(AppError::SidecarNotFound(tried))
}
+11 -10
View File
@@ -1,8 +1,8 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Mycellium UI",
"version": "0.1.0",
"identifier": "tech.threefold.mycellium-ui",
"productName": "Mycellium UI Private",
"version": "0.1.1",
"identifier": "tech.threefold.mycellium-ui-private",
"build": {
"beforeDevCommand": "pnpm dev",
"devUrl": "http://localhost:1420",
@@ -14,7 +14,7 @@
"windows": [
{
"label": "main",
"title": "Mycellium",
"title": "Mycellium Private",
"width": 1100,
"height": 720,
"minWidth": 800,
@@ -29,20 +29,21 @@
"bundle": {
"active": true,
"targets": ["deb", "appimage"],
"category": "Network",
"shortDescription": "Mycelium overlay network client",
"longDescription": "Desktop GUI for the Mycelium end-to-end encrypted IPv6 overlay network.",
"category": "Utility",
"shortDescription": "Mycelium private network client",
"longDescription": "Desktop GUI for joining a private Mycelium overlay network — a self-contained IPv6 mesh isolated from the public Mycelium network by a shared 32-byte key.",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png"
],
"externalBin": ["binaries/mycelium"],
"externalBin": ["binaries/mycelium-private"],
"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-private.policy": "packaging/polkit/tech.threefold.mycellium-ui-private.policy",
"/usr/bin/mycellium-bootstrap-private": "packaging/mycellium-bootstrap-private"
}
}
}
+20 -4
View File
@@ -30,7 +30,7 @@ const navItems = [
{ to: "/settings", label: "Settings", icon: SettingsIcon },
];
const currentTitle = computed(() => (route.meta?.title as string) ?? "Mycellium");
const currentTitle = computed(() => (route.meta?.title as string) ?? "Mycellium Private");
onMounted(async () => {
await config.load();
@@ -60,7 +60,7 @@ async function handleStop() {
>
<aside class="flex w-56 shrink-0 flex-col border-r border-border bg-card">
<div class="flex h-14 items-center px-4 border-b border-border">
<span class="font-semibold text-base">Mycellium</span>
<span class="font-semibold text-base">Mycellium <span class="text-amber-500">Private</span></span>
<span
class="ml-auto inline-block h-2 w-2 rounded-full"
:class="
@@ -120,14 +120,30 @@ 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>
</main>
<StartupOverlay
v-if="phase !== 'ready' && phase !== 'idle'"
:phase="phase as 'starting' | 'error'"
v-if="phase === 'starting'"
phase="starting"
:error="error"
@start="handleStart"
@retry="handleStart"
+22
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 {
@@ -18,6 +20,18 @@ export interface SidecarConfig {
peers: string[];
tunName: string | null;
noTun: boolean;
/** 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 {
path: string;
exists: boolean;
}
// ─── Type-safe invoke wrappers ───────────────────────────────────────────────
@@ -165,6 +179,14 @@ export const api = {
lookupPubkey: (ip: string) => cmd<PubkeyLookup>("lookup_pubkey", { ip }),
regenerateIdentity: () => cmd<void>("regenerate_identity"),
networkKeyStatus: () => cmd<NetworkKeyStatus>("network_key_status"),
networkKeyGenerate: (overwrite: boolean) =>
cmd<NetworkKeyStatus>("network_key_generate", { overwrite }),
networkKeyImport: (hexKey: string, overwrite: boolean) =>
cmd<NetworkKeyStatus>("network_key_import", { hexKey, overwrite }),
networkKeyExport: () => cmd<string>("network_key_export"),
networkKeyDelete: () => cmd<void>("network_key_delete"),
};
/** Format the canonical peer endpoint string the API expects. */
+22 -4
View File
@@ -6,15 +6,27 @@ import type { SidecarConfig } from "@/lib/api";
const STORE_FILE = "config.json";
const KEY = "sidecar";
// A private overlay has no Threefold-operated seed peer. The user must
// declare bootstrap peers they trust (their own VPS, known friends…)
// before the daemon can usefully start. Default to mycelium's
// well-known peer-listen ports (9651 TCP, 9652 QUIC) so port-forwards
// on a home router are predictable from the first run.
const DEFAULT_CONFIG: SidecarConfig = {
peers: [
"tcp://188.40.132.242:9651",
"quic://[2a01:4f8:212:fa6::2]:9651",
],
peers: [],
tunName: null,
noTun: false,
networkName: null,
tcpListenPort: 9651,
quicListenPort: 9652,
};
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);
@@ -33,6 +45,9 @@ export const useConfigStore = defineStore("config", () => {
peers: Array.isArray(saved.peers) ? saved.peers : DEFAULT_CONFIG.peers,
tunName: saved.tunName ?? null,
noTun: !!saved.noTun,
networkName: saved.networkName ?? null,
tcpListenPort: normalizePort(saved.tcpListenPort),
quicListenPort: normalizePort(saved.quicListenPort),
};
}
loaded.value = true;
@@ -43,6 +58,9 @@ export const useConfigStore = defineStore("config", () => {
peers: next.peers.map((p) => p.trim()).filter(Boolean),
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);
+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;
});
}
+284 -3
View File
@@ -8,8 +8,13 @@ import {
KeyRound,
AlertTriangle,
TerminalSquare,
ShieldCheck,
Sparkles,
Upload,
Download,
Copy,
} from "lucide-vue-next";
import { api } from "@/lib/api";
import { api, type NetworkKeyStatus } from "@/lib/api";
import { useConfigStore } from "@/stores/config";
import { useNodeStore } from "@/stores/node";
@@ -24,6 +29,9 @@ const draft = reactive({
peers: "",
tunName: "",
noTun: false,
networkName: "",
tcpListenPort: "",
quicListenPort: "",
});
const dirty = ref(false);
@@ -31,9 +39,20 @@ function loadDraft() {
draft.peers = config.value.peers.join("\n");
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(
@@ -52,6 +71,9 @@ async function save() {
.filter(Boolean),
tunName: draft.tunName.trim() || null,
noTun: draft.noTun,
networkName: draft.networkName.trim() || null,
tcpListenPort: parsePort(draft.tcpListenPort),
quicListenPort: parsePort(draft.quicListenPort),
});
dirty.value = false;
}
@@ -101,13 +123,234 @@ async function refreshLogs() {
const isReady = computed(() => phase.value === "ready");
// Private network key
const keyStatus = ref<NetworkKeyStatus | null>(null);
const keyBusy = ref(false);
const keyError = ref<string | null>(null);
const importHex = ref("");
const exportedHex = ref<string | null>(null);
async function refreshKeyStatus() {
try {
keyStatus.value = await api.networkKeyStatus();
} catch (e) {
keyError.value = String(e);
}
}
async function generateKey() {
const overwrite = keyStatus.value?.exists ?? false;
if (
overwrite &&
!confirm(
"Replace the existing network key? Every node currently using it will be cut off until they're given the new one.",
)
) {
return;
}
keyBusy.value = true;
keyError.value = null;
exportedHex.value = null;
try {
keyStatus.value = await api.networkKeyGenerate(overwrite);
} catch (e) {
keyError.value = String(e);
} finally {
keyBusy.value = false;
}
}
async function importKey() {
const hex = importHex.value.trim();
if (!/^[0-9a-fA-F]{64}$/.test(hex)) {
keyError.value = "Network key must be exactly 64 hex characters (32 bytes).";
return;
}
const overwrite = keyStatus.value?.exists ?? false;
if (
overwrite &&
!confirm("Replace the existing network key with the one you pasted?")
) {
return;
}
keyBusy.value = true;
keyError.value = null;
try {
keyStatus.value = await api.networkKeyImport(hex, overwrite);
importHex.value = "";
} catch (e) {
keyError.value = String(e);
} finally {
keyBusy.value = false;
}
}
async function exportKey() {
keyError.value = null;
try {
exportedHex.value = await api.networkKeyExport();
} catch (e) {
keyError.value = String(e);
}
}
async function copyExported() {
if (!exportedHex.value) return;
try {
await navigator.clipboard.writeText(exportedHex.value);
} catch {
/* clipboard unavailable */
}
}
async function deleteKey() {
if (
!confirm(
"Delete the network key? You won't be able to start the daemon until you generate or import a new one.",
)
) {
return;
}
keyBusy.value = true;
keyError.value = null;
try {
await api.networkKeyDelete();
exportedHex.value = null;
await refreshKeyStatus();
} catch (e) {
keyError.value = String(e);
} finally {
keyBusy.value = false;
}
}
onMounted(async () => {
await refreshKeyStatus();
if (isReady.value) await refreshLogs();
});
</script>
<template>
<div class="grid gap-4 lg:grid-cols-2">
<!-- Private network -->
<section class="rounded-lg border border-amber-500/40 bg-amber-500/5 lg:col-span-2">
<header class="border-b border-amber-500/30 px-4 py-3">
<h2 class="flex items-center gap-2 text-sm font-medium">
<ShieldCheck class="h-4 w-4 text-amber-500" /> Private network
</h2>
<p class="mt-0.5 text-xs text-muted-foreground">
A private overlay is identified by a <strong>name</strong> (public, agreed
across nodes) and a 32-byte <strong>shared key</strong> (secret, distributed
out-of-band). Both must match exactly across every node that
should be on the same overlay.
</p>
</header>
<div class="space-y-4 px-4 py-4">
<div>
<label class="text-xs uppercase tracking-wide text-muted-foreground">
Network name (UTF-8, 264 bytes public)
</label>
<input
v-model="draft.networkName"
type="text"
spellcheck="false"
placeholder="acme-corp-private"
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"
/>
</div>
<div>
<div class="flex items-center justify-between">
<label class="text-xs uppercase tracking-wide text-muted-foreground">
Network key file
</label>
<span
class="text-xs"
:class="keyStatus?.exists ? 'text-emerald-500' : 'text-destructive'"
>
{{ keyStatus?.exists ? "configured" : "missing" }}
</span>
</div>
<div class="mt-1 break-all rounded-md border border-border bg-muted/40 p-2 font-mono text-[11px]">
{{ keyStatus?.path ?? "(unknown)" }}
</div>
</div>
<div class="flex flex-wrap gap-2">
<button
class="inline-flex items-center gap-1.5 rounded-md bg-amber-500 px-3 py-1.5 text-xs font-medium text-amber-950 hover:opacity-90 disabled:opacity-50"
:disabled="keyBusy"
@click="generateKey"
>
<Sparkles class="h-3 w-3" />
{{ keyStatus?.exists ? "Re-generate" : "Generate 32-byte key" }}
</button>
<button
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-secondary disabled:opacity-50"
:disabled="keyBusy || !keyStatus?.exists"
@click="exportKey"
>
<Download class="h-3 w-3" />
Reveal hex
</button>
<button
class="inline-flex items-center gap-1.5 rounded-md border border-destructive px-3 py-1.5 text-xs text-destructive hover:bg-destructive hover:text-destructive-foreground disabled:opacity-50"
:disabled="keyBusy || !keyStatus?.exists"
@click="deleteKey"
>
<RotateCcw class="h-3 w-3" />
Delete
</button>
</div>
<div v-if="exportedHex" class="space-y-2">
<label class="text-xs uppercase tracking-wide text-muted-foreground">
Hex export share over a secure channel only
</label>
<div class="flex gap-2">
<input
:value="exportedHex"
readonly
class="flex-1 rounded-md border border-input bg-background px-3 py-1.5 font-mono text-xs focus:outline-none"
/>
<button
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-secondary"
@click="copyExported"
>
<Copy class="h-3 w-3" />
Copy
</button>
</div>
</div>
<div class="space-y-2">
<label class="text-xs uppercase tracking-wide text-muted-foreground">
Import a key from another node (64 hex characters)
</label>
<div class="flex gap-2">
<input
v-model="importHex"
type="text"
spellcheck="false"
placeholder="0a1b2c3d…"
class="flex-1 rounded-md border border-input bg-background px-3 py-1.5 font-mono text-xs focus:outline-none focus:ring-2 focus:ring-ring"
/>
<button
class="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
:disabled="keyBusy || !importHex.trim()"
@click="importKey"
>
<Upload class="h-3 w-3" />
Import
</button>
</div>
</div>
<p v-if="keyError" class="text-xs text-destructive">{{ keyError }}</p>
</div>
</section>
<!-- Daemon configuration -->
<section class="rounded-lg border border-border bg-card">
<header class="border-b border-border px-4 py-3">
@@ -120,15 +363,20 @@ onMounted(async () => {
<div class="space-y-4 px-4 py-4">
<div>
<label class="text-xs uppercase tracking-wide text-muted-foreground">
Static peers (one per line)
Bootstrap peers (one per line)
</label>
<textarea
v-model="draft.peers"
rows="6"
spellcheck="false"
class="mt-1 w-full resize-y rounded-md border border-input bg-background px-3 py-2 font-mono text-xs focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="tcp://188.40.132.242:9651"
placeholder="tcp://your-node.example.org:9651"
/>
<p class="mt-1 text-[11px] text-muted-foreground">
Private overlays don't have a Threefold-operated seed point at
your own VPS or other trusted nodes that already share the network
name and key.
</p>
</div>
<div class="flex items-center gap-3">
<input
@@ -153,6 +401,39 @@ 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="leave empty for 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="leave empty for ephemeral"
/>
</div>
</div>
<p class="text-[11px] text-muted-foreground">
Defaults are <code class="font-mono">9651</code> (TCP) and
<code class="font-mono">9652</code> (QUIC) the conventional mycelium
peer-listen ports, predictable for port-forwarding on a home router.
Clear the field to fall back to a random 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"
+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"