Compare commits

...

10 Commits

Author SHA1 Message Date
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
26 changed files with 808 additions and 159 deletions
+3
View File
@@ -31,3 +31,6 @@ priv_key.bin
*.log *.log
.env .env
.env.local .env.local
# Personal notes — not part of the public repo
docs-syoul/
+43
View File
@@ -0,0 +1,43 @@
# 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.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
+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 ## 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 ## Setup (dev)
```
┌──────────────────────────────────────────────────────────────┐
│ 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)
```bash ```bash
sudo apt install -y \ sudo apt install -y \
@@ -40,72 +29,56 @@ sudo apt install -y \
build-essential curl wget file libssl-dev libgtk-3-dev libxdo-dev \ build-essential curl wget file libssl-dev libgtk-3-dev libxdo-dev \
pkg-config policykit-1 pkg-config policykit-1
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y 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 pnpm install
bash scripts/fetch-mycelium.sh # downloads mycelium-private v0.6.1
# 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
pnpm tauri dev 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 ```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
``` This fork tracks `Mycell-UI` upstream as `origin/upstream` (after running the `git remote add upstream ...` step). To pull bugfixes:
src/ # Vue 3 frontend
views/ # one file per nav item ```bash
components/ # shadcn-style UI primitives + dialogs git fetch upstream
stores/ # Pinia: node, peers, routes, messages, topics, config git cherry-pick <upstream-commit>
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
``` ```
## 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 | ## Known limitations (v0.1)
|------|-----|
| 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 (v1) - Linux only.
- The `mycelium-private` binary is upstream's experimental track; the API may shift in a future release.
- Linux only. Windows is reachable (sidecar via `runas` / Wintun driver) but not implemented. - 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.
- Auto-start at login isn't wired — the desktop entry installed by the `.deb` is the manual launcher. - No multi-network support — one private overlay at a time.
- 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.
+1 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mycellium UI</title> <title>Mycellium Private</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "mycellium-ui", "name": "mycellium-ui-private",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
+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.0_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 @@
fe9e98ff6b2ee740345a9cac5bc1fd828cf7b53c96242c0c15fdc26dd8249b4e release/mycellium-ui-private_0.1.0_amd64.deb
Binary file not shown.
+12 -12
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# Fetches the official mycelium release binary and places it in src-tauri/binaries/ # Fetches the official mycelium-private release binary and places it in
# with the target-triple suffix expected by Tauri's externalBin bundler. # src-tauri/binaries/ with the target-triple suffix expected by Tauri's
# externalBin bundler.
# #
# Usage: scripts/fetch-mycelium.sh [VERSION] # Usage: scripts/fetch-mycelium.sh [VERSION]
# VERSION defaults to MYCELIUM_VERSION below. # 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. # Map our target triple to the asset name pattern used by upstream releases.
asset_for_triple() { asset_for_triple() {
case "$1" in case "$1" in
x86_64-unknown-linux-gnu) echo "mycelium-x86_64-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-aarch64-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 ;; *) echo "unsupported triple: $1" >&2; exit 1 ;;
esac esac
} }
@@ -42,23 +43,22 @@ TMP_DIR="$(mktemp -d)"
trap 'rm -rf "${TMP_DIR}"' EXIT trap 'rm -rf "${TMP_DIR}"' EXIT
echo "→ downloading ${URL}" echo "→ downloading ${URL}"
curl -fsSL "${URL}" -o "${TMP_DIR}/mycelium.tar.gz" curl -fsSL "${URL}" -o "${TMP_DIR}/mycelium-private.tar.gz"
echo "→ extracting" 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. # The archive contains a single 'mycelium-private' binary at the root.
SRC="${TMP_DIR}/mycelium" SRC="${TMP_DIR}/mycelium-private"
if [[ ! -f "${SRC}" ]]; then if [[ ! -f "${SRC}" ]]; then
# Some releases nest the binary; find it. SRC="$(find "${TMP_DIR}" -name 'mycelium-private' -type f -executable | head -n1)"
SRC="$(find "${TMP_DIR}" -name 'mycelium' -type f -executable | head -n1)"
fi fi
if [[ -z "${SRC}" || ! -f "${SRC}" ]]; then 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 exit 1
fi fi
DEST="${DEST_DIR}/mycelium-${TRIPLE}" DEST="${DEST_DIR}/mycelium-private-${TRIPLE}"
install -m 0755 "${SRC}" "${DEST}" install -m 0755 "${SRC}" "${DEST}"
echo "✓ installed ${DEST}" echo "✓ installed ${DEST}"
echo " version: ${MYCELIUM_VERSION}" echo " version: ${MYCELIUM_VERSION}"
+3 -1
View File
@@ -1946,11 +1946,13 @@ dependencies = [
] ]
[[package]] [[package]]
name = "mycellium-ui" name = "mycellium-ui-private"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"hex",
"parking_lot", "parking_lot",
"portpicker", "portpicker",
"rand 0.8.6",
"reqwest 0.12.28", "reqwest 0.12.28",
"serde", "serde",
"serde_json", "serde_json",
+5 -3
View File
@@ -1,13 +1,13 @@
[package] [package]
name = "mycellium-ui" name = "mycellium-ui-private"
version = "0.1.0" version = "0.1.0"
description = "Mycelium overlay network desktop client" description = "Mycelium private network desktop client"
authors = ["syoul"] authors = ["syoul"]
edition = "2021" edition = "2021"
rust-version = "1.77" rust-version = "1.77"
[lib] [lib]
name = "mycellium_ui_lib" name = "mycellium_ui_private_lib"
crate-type = ["staticlib", "cdylib", "rlib"] crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies] [build-dependencies]
@@ -27,6 +27,8 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
portpicker = "0.1" portpicker = "0.1"
parking_lot = "0.12" parking_lot = "0.12"
rand = "0.8"
hex = "0.4"
[features] [features]
custom-protocol = ["tauri/custom-protocol"] 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>Threefold</vendor>
<vendor_url>https://threefold.io</vendor_url> <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>Run the Mycelium overlay daemon</description>
<description xml:lang="fr">Lancer le démon de l'overlay Mycelium</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> <message>Authentication is required to start the Mycelium overlay daemon.</message>
@@ -14,11 +21,8 @@
<defaults> <defaults>
<allow_any>auth_admin</allow_any> <allow_any>auth_admin</allow_any>
<allow_inactive>auth_admin</allow_inactive> <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> <allow_active>auth_admin_keep</allow_active>
</defaults> </defaults>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/mycellium-bootstrap-private</annotate>
</action> </action>
</policyconfig> </policyconfig>
+7
View File
@@ -22,8 +22,15 @@ pub struct MyceliumClient {
impl MyceliumClient { impl MyceliumClient {
pub fn new(base: impl Into<String>) -> Self { 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() let http = Client::builder()
.pool_max_idle_per_host(0)
.timeout(Duration::from_secs(10)) .timeout(Duration::from_secs(10))
.connect_timeout(Duration::from_secs(3))
.build() .build()
.expect("reqwest client build"); .expect("reqwest client build");
Self { Self {
+114 -1
View File
@@ -14,11 +14,16 @@ use serde::Serialize;
use tauri::{AppHandle, State}; use tauri::{AppHandle, State};
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DaemonStatus { pub struct DaemonStatus {
pub running: bool, pub running: bool,
pub api_url: Option<String>, pub api_url: Option<String>,
pub key_path: Option<String>, pub key_path: Option<String>,
pub config_path: Option<String>, pub config_path: Option<String>,
/// Full overlay IPv6 (e.g. `43d:956e:7877:d933:eecc:b305:21ff:77f9`).
/// Surfaced separately from `nodeSubnet` so the UI can show users the
/// address they should actually use as a message destination.
pub overlay_ip: Option<String>,
} }
fn snapshot(state: &AppState) -> DaemonStatus { fn snapshot(state: &AppState) -> DaemonStatus {
@@ -28,6 +33,7 @@ fn snapshot(state: &AppState) -> DaemonStatus {
api_url: sc.client().map(|c| c.base_url().to_string()), api_url: sc.client().map(|c| c.base_url().to_string()),
key_path: sc.key_path().map(|p| p.display().to_string()), key_path: sc.key_path().map(|p| p.display().to_string()),
config_path: sc.config_path().map(|p| p.display().to_string()), config_path: sc.config_path().map(|p| p.display().to_string()),
overlay_ip: sc.overlay_ip(),
} }
} }
@@ -279,10 +285,117 @@ fn default_key_path() -> Option<std::path::PathBuf> {
dirs_like_app_data().ok().map(|d| d.join("priv_key.bin")) 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> { fn dirs_like_app_data() -> std::io::Result<std::path::PathBuf> {
// We can't reach the AppHandle here, so we mirror Tauri's path: // We can't reach the AppHandle here, so we mirror Tauri's path:
// $XDG_DATA_HOME/<identifier>/ or $HOME/.local/share/<identifier>/. // $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") { if let Ok(d) = std::env::var("XDG_DATA_HOME") {
return Ok(std::path::PathBuf::from(d).join(identifier)); return Ok(std::path::PathBuf::from(d).join(identifier));
} }
+5
View File
@@ -57,6 +57,11 @@ pub fn run() {
commands::topic_forward_remove, commands::topic_forward_remove,
commands::lookup_pubkey, commands::lookup_pubkey,
commands::regenerate_identity, 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!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
+1 -1
View File
@@ -1,5 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() { 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 PEERS_INTERVAL: Duration = Duration::from_secs(3);
const ROUTES_INTERVAL: Duration = Duration::from_secs(5); 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_RETRY_BACKOFF: Duration = Duration::from_secs(2);
const INBOX_CAPACITY: usize = 200; const INBOX_CAPACITY: usize = 200;
@@ -121,18 +121,22 @@ fn spawn_inbox_loop(
) -> JoinHandle<()> { ) -> JoinHandle<()> {
tokio::spawn(async move { tokio::spawn(async move {
loop { loop {
tokio::time::sleep(INBOX_INTERVAL).await;
let Some(client) = sidecar.client() else { let Some(client) = sidecar.client() else {
break; break;
}; };
// Each iteration is a fresh long-poll. The daemon answers as // Short-poll: timeout=0 returns immediately if no message.
// soon as a message arrives, or returns an empty body / 204 // We previously used a 30s long-poll, but mycelium 0.6.1's
// when the timeout window elapses. // HTTP server appears to serialise requests behind a single
match client.pop_message(false, INBOX_LONG_POLL_SECS, None).await { // 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)) => { Ok(Some(msg)) => {
me.push_inbox(msg.clone()); me.push_inbox(msg.clone());
let _ = app.emit("messages://incoming", &msg); let _ = app.emit("messages://incoming", &msg);
} }
Ok(None) => {} // window expired, loop Ok(None) => {}
Err(e) => { Err(e) => {
warn!(error = %e, "inbox: pop_message failed"); warn!(error = %e, "inbox: pop_message failed");
tokio::time::sleep(INBOX_RETRY_BACKOFF).await; tokio::time::sleep(INBOX_RETRY_BACKOFF).await;
+171 -26
View File
@@ -16,27 +16,23 @@ const HEALTH_CHECK_TIMEOUT_SECS: u64 = 20;
const HEALTH_CHECK_INTERVAL_MS: u64 = 400; const HEALTH_CHECK_INTERVAL_MS: u64 = 400;
const LOG_RING_CAPACITY: usize = 500; 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")] #[serde(rename_all = "camelCase")]
pub struct SidecarConfig { 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 peers: Vec<String>,
pub tun_name: Option<String>, pub tun_name: Option<String>,
pub no_tun: bool, pub no_tun: bool,
} /// UTF-8 network identifier (2..=64 bytes). Public; not a secret.
/// All nodes joining the same private overlay must agree on this.
impl Default for SidecarConfig { pub network_name: Option<String>,
fn default() -> Self {
Self {
// A small set of well-known public peers from the mycelium README,
// used as bootstrap when the user hasn't configured their own.
peers: vec![
"tcp://188.40.132.242:9651".into(),
"quic://[2a01:4f8:212:fa6::2]:9651".into(),
],
tun_name: None,
no_tun: false,
}
}
} }
/// Holds the running mycelium child process plus a small in-memory log /// Holds the running mycelium child process plus a small in-memory log
@@ -48,6 +44,10 @@ pub struct SidecarHandle {
logs: Mutex<VecDeque<String>>, logs: Mutex<VecDeque<String>>,
config_path: Mutex<Option<PathBuf>>, config_path: Mutex<Option<PathBuf>>,
key_path: Mutex<Option<PathBuf>>, key_path: Mutex<Option<PathBuf>>,
/// Full overlay IPv6, parsed out of mycelium's `Node overlay IP: ...`
/// startup log line. The daemon's HTTP API only exposes the subnet,
/// so we extract the host part from the binary's stderr.
overlay_ip: Mutex<Option<String>>,
} }
impl SidecarHandle { impl SidecarHandle {
@@ -58,6 +58,7 @@ impl SidecarHandle {
logs: Mutex::new(VecDeque::with_capacity(LOG_RING_CAPACITY)), logs: Mutex::new(VecDeque::with_capacity(LOG_RING_CAPACITY)),
config_path: Mutex::new(None), config_path: Mutex::new(None),
key_path: Mutex::new(None), key_path: Mutex::new(None),
overlay_ip: Mutex::new(None),
}) })
} }
@@ -84,7 +85,21 @@ impl SidecarHandle {
self.config_path.lock().clone() self.config_path.lock().clone()
} }
pub fn overlay_ip(&self) -> Option<String> {
self.overlay_ip.lock().clone()
}
fn push_log(&self, line: String) { fn push_log(&self, line: String) {
// mycelium prefixes log lines with ANSI colour escapes; strip them
// before scanning for the overlay IP marker so the regex stays
// resilient to cosmetic changes in upstream's log format.
if self.overlay_ip.lock().is_none() {
let stripped = strip_ansi(&line);
if let Some(ip) = extract_overlay_ip(&stripped) {
tracing::info!(overlay_ip = %ip, "node overlay IP captured");
*self.overlay_ip.lock() = Some(ip);
}
}
let mut buf = self.logs.lock(); let mut buf = self.logs.lock();
if buf.len() >= LOG_RING_CAPACITY { if buf.len() >= LOG_RING_CAPACITY {
buf.pop_front(); buf.pop_front();
@@ -101,7 +116,33 @@ impl SidecarHandle {
return Err(AppError::DaemonAlreadyRunning); 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)?; 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. // Three ports: HTTP API (loopback), TCP listen, QUIC (UDP) listen.
// mycelium defaults to 9651 for both peer-listen ports, which // mycelium defaults to 9651 for both peer-listen ports, which
// collides if another instance (or a leftover from a previous test) // collides if another instance (or a leftover from a previous test)
@@ -124,6 +165,15 @@ impl SidecarHandle {
std::fs::create_dir_all(&data_dir)?; std::fs::create_dir_all(&data_dir)?;
let key_path = data_dir.join("priv_key.bin"); let key_path = data_dir.join("priv_key.bin");
let config_path = data_dir.join("mycelium.toml"); 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![ let mut args = vec![
"--api-addr".to_string(), "--api-addr".to_string(),
@@ -136,6 +186,10 @@ impl SidecarHandle {
format!("127.0.0.1:{metrics_port}"), format!("127.0.0.1:{metrics_port}"),
"--key-file".to_string(), "--key-file".to_string(),
key_path.display().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() { if config_path.exists() {
args.push("--config-file".to_string()); args.push("--config-file".to_string());
@@ -157,11 +211,12 @@ impl SidecarHandle {
info!( info!(
?bin, ?bin,
elevation_target = %elevation_target.display(),
api_port, tcp_port, quic_port, metrics_port, api_port, tcp_port, quic_port, metrics_port,
"spawning mycelium sidecar via pkexec" "spawning mycelium sidecar via pkexec"
); );
let mut cmd = elevation::elevated(&bin, &args); let mut cmd = elevation::elevated(&elevation_target, &args);
cmd.stdout(Stdio::piped()) cmd.stdout(Stdio::piped())
.stderr(Stdio::piped()) .stderr(Stdio::piped())
.kill_on_drop(true); .kill_on_drop(true);
@@ -262,6 +317,7 @@ impl SidecarHandle {
fn cleanup(&self) { fn cleanup(&self) {
*self.api_url.lock() = None; *self.api_url.lock() = None;
*self.overlay_ip.lock() = None;
let _ = self.child.lock().take(); let _ = self.child.lock().take();
} }
@@ -279,6 +335,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> { fn pick_port() -> AppResult<u16> {
portpicker::pick_unused_port().ok_or_else(|| AppError::Other("no free port available".into())) portpicker::pick_unused_port().ok_or_else(|| AppError::Other("no free port available".into()))
} }
@@ -295,30 +402,68 @@ fn pick_port_skip(taken: &[u16]) -> AppResult<u16> {
)) ))
} }
/// Resolve the bundled `mycelium-<triple>` binary in both `tauri dev` /// Resolve the bundled `mycelium-private` sidecar across our two build
/// (cargo manifest) and bundled (resource_dir) modes. /// 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> { fn locate_sidecar(app: &AppHandle) -> AppResult<PathBuf> {
let triple = std::env::var("TAURI_ENV_TARGET_TRIPLE") let triple = std::env::var("TAURI_ENV_TARGET_TRIPLE")
.ok() .ok()
.or_else(|| option_env!("TARGET").map(|s| s.to_string())) .or_else(|| option_env!("TARGET").map(|s| s.to_string()))
.unwrap_or_else(|| "x86_64-unknown-linux-gnu".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(); 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() { 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() { if p.exists() {
return Ok(p); return Ok(p);
} }
tried.push(p); tried.push(p);
} }
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); // Final fallback: trust $PATH if a system-installed mycelium is around.
let dev_path = manifest_dir.join("binaries").join(&name); if let Ok(path) = std::env::var("PATH") {
if dev_path.exists() { for entry in path.split(':') {
return Ok(dev_path); let p = PathBuf::from(entry).join(&plain);
if p.exists() {
return Ok(p);
}
}
} }
tried.push(dev_path);
Err(AppError::SidecarNotFound(tried)) Err(AppError::SidecarNotFound(tried))
} }
+10 -9
View File
@@ -1,8 +1,8 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Mycellium UI", "productName": "Mycellium UI Private",
"version": "0.1.0", "version": "0.1.0",
"identifier": "tech.threefold.mycellium-ui", "identifier": "tech.threefold.mycellium-ui-private",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",
"devUrl": "http://localhost:1420", "devUrl": "http://localhost:1420",
@@ -14,7 +14,7 @@
"windows": [ "windows": [
{ {
"label": "main", "label": "main",
"title": "Mycellium", "title": "Mycellium Private",
"width": 1100, "width": 1100,
"height": 720, "height": 720,
"minWidth": 800, "minWidth": 800,
@@ -29,20 +29,21 @@
"bundle": { "bundle": {
"active": true, "active": true,
"targets": ["deb", "appimage"], "targets": ["deb", "appimage"],
"category": "Network", "category": "Utility",
"shortDescription": "Mycelium overlay network client", "shortDescription": "Mycelium private network client",
"longDescription": "Desktop GUI for the Mycelium end-to-end encrypted IPv6 overlay network.", "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": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",
"icons/128x128@2x.png" "icons/128x128@2x.png"
], ],
"externalBin": ["binaries/mycelium"], "externalBin": ["binaries/mycelium-private"],
"linux": { "linux": {
"deb": { "deb": {
"depends": ["policykit-1"], "depends": ["pkexec | policykit-1"],
"files": { "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"
} }
} }
} }
+4 -4
View File
@@ -30,7 +30,7 @@ const navItems = [
{ to: "/settings", label: "Settings", icon: SettingsIcon }, { 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 () => { onMounted(async () => {
await config.load(); 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"> <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"> <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 <span
class="ml-auto inline-block h-2 w-2 rounded-full" class="ml-auto inline-block h-2 w-2 rounded-full"
:class=" :class="
@@ -126,8 +126,8 @@ async function handleStop() {
</main> </main>
<StartupOverlay <StartupOverlay
v-if="phase !== 'ready' && phase !== 'idle'" v-if="phase === 'starting'"
:phase="phase as 'starting' | 'error'" phase="starting"
:error="error" :error="error"
@start="handleStart" @start="handleStart"
@retry="handleStart" @retry="handleStart"
+18
View File
@@ -7,6 +7,8 @@ export interface DaemonStatus {
apiUrl: string | null; apiUrl: string | null;
keyPath: string | null; keyPath: string | null;
configPath: string | null; configPath: string | null;
/** Full overlay IPv6 (e.g. `43d:956e:7877:d933:eecc:b305:21ff:77f9`). */
overlayIp: string | null;
} }
export interface NodeInfo { export interface NodeInfo {
@@ -18,6 +20,14 @@ export interface SidecarConfig {
peers: string[]; peers: string[];
tunName: string | null; tunName: string | null;
noTun: boolean; noTun: boolean;
/** UTF-8, 2..=64 bytes. Public must match across all nodes of the
* same private overlay. */
networkName: string | null;
}
export interface NetworkKeyStatus {
path: string;
exists: boolean;
} }
// ─── Type-safe invoke wrappers ─────────────────────────────────────────────── // ─── Type-safe invoke wrappers ───────────────────────────────────────────────
@@ -165,6 +175,14 @@ export const api = {
lookupPubkey: (ip: string) => cmd<PubkeyLookup>("lookup_pubkey", { ip }), lookupPubkey: (ip: string) => cmd<PubkeyLookup>("lookup_pubkey", { ip }),
regenerateIdentity: () => cmd<void>("regenerate_identity"), 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. */ /** Format the canonical peer endpoint string the API expects. */
+7 -4
View File
@@ -6,13 +6,14 @@ import type { SidecarConfig } from "@/lib/api";
const STORE_FILE = "config.json"; const STORE_FILE = "config.json";
const KEY = "sidecar"; 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.
const DEFAULT_CONFIG: SidecarConfig = { const DEFAULT_CONFIG: SidecarConfig = {
peers: [ peers: [],
"tcp://188.40.132.242:9651",
"quic://[2a01:4f8:212:fa6::2]:9651",
],
tunName: null, tunName: null,
noTun: false, noTun: false,
networkName: null,
}; };
export const useConfigStore = defineStore("config", () => { export const useConfigStore = defineStore("config", () => {
@@ -33,6 +34,7 @@ export const useConfigStore = defineStore("config", () => {
peers: Array.isArray(saved.peers) ? saved.peers : DEFAULT_CONFIG.peers, peers: Array.isArray(saved.peers) ? saved.peers : DEFAULT_CONFIG.peers,
tunName: saved.tunName ?? null, tunName: saved.tunName ?? null,
noTun: !!saved.noTun, noTun: !!saved.noTun,
networkName: saved.networkName ?? null,
}; };
} }
loaded.value = true; loaded.value = true;
@@ -43,6 +45,7 @@ export const useConfigStore = defineStore("config", () => {
peers: next.peers.map((p) => p.trim()).filter(Boolean), peers: next.peers.map((p) => p.trim()).filter(Boolean),
tunName: next.tunName?.trim() ? next.tunName.trim() : null, tunName: next.tunName?.trim() ? next.tunName.trim() : null,
noTun: !!next.noTun, noTun: !!next.noTun,
networkName: next.networkName?.trim() ? next.networkName.trim() : null,
}; };
const s = await ensureStore(); const s = await ensureStore();
await s.set(KEY, config.value); 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) => { exitedUnlisten = await on<number>(Events.SidecarExited, (e) => {
error.value = `daemon exited (code ${e.payload})`; error.value = `daemon exited (code ${e.payload})`;
phase.value = "error"; phase.value = "error";
status.value = { running: false, apiUrl: null, keyPath: null, configPath: null }; status.value = {
running: false,
apiUrl: null,
keyPath: null,
configPath: null,
overlayIp: null,
};
info.value = null; info.value = null;
}); });
} }
+237 -3
View File
@@ -8,8 +8,13 @@ import {
KeyRound, KeyRound,
AlertTriangle, AlertTriangle,
TerminalSquare, TerminalSquare,
ShieldCheck,
Sparkles,
Upload,
Download,
Copy,
} from "lucide-vue-next"; } from "lucide-vue-next";
import { api } from "@/lib/api"; import { api, type NetworkKeyStatus } from "@/lib/api";
import { useConfigStore } from "@/stores/config"; import { useConfigStore } from "@/stores/config";
import { useNodeStore } from "@/stores/node"; import { useNodeStore } from "@/stores/node";
@@ -24,6 +29,7 @@ const draft = reactive({
peers: "", peers: "",
tunName: "", tunName: "",
noTun: false, noTun: false,
networkName: "",
}); });
const dirty = ref(false); const dirty = ref(false);
@@ -31,6 +37,7 @@ function loadDraft() {
draft.peers = config.value.peers.join("\n"); draft.peers = config.value.peers.join("\n");
draft.tunName = config.value.tunName ?? ""; draft.tunName = config.value.tunName ?? "";
draft.noTun = config.value.noTun; draft.noTun = config.value.noTun;
draft.networkName = config.value.networkName ?? "";
dirty.value = false; dirty.value = false;
} }
@@ -52,6 +59,7 @@ async function save() {
.filter(Boolean), .filter(Boolean),
tunName: draft.tunName.trim() || null, tunName: draft.tunName.trim() || null,
noTun: draft.noTun, noTun: draft.noTun,
networkName: draft.networkName.trim() || null,
}); });
dirty.value = false; dirty.value = false;
} }
@@ -101,13 +109,234 @@ async function refreshLogs() {
const isReady = computed(() => phase.value === "ready"); 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 () => { onMounted(async () => {
await refreshKeyStatus();
if (isReady.value) await refreshLogs(); if (isReady.value) await refreshLogs();
}); });
</script> </script>
<template> <template>
<div class="grid gap-4 lg:grid-cols-2"> <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 --> <!-- Daemon configuration -->
<section class="rounded-lg border border-border bg-card"> <section class="rounded-lg border border-border bg-card">
<header class="border-b border-border px-4 py-3"> <header class="border-b border-border px-4 py-3">
@@ -120,15 +349,20 @@ onMounted(async () => {
<div class="space-y-4 px-4 py-4"> <div class="space-y-4 px-4 py-4">
<div> <div>
<label class="text-xs uppercase tracking-wide text-muted-foreground"> <label class="text-xs uppercase tracking-wide text-muted-foreground">
Static peers (one per line) Bootstrap peers (one per line)
</label> </label>
<textarea <textarea
v-model="draft.peers" v-model="draft.peers"
rows="6" rows="6"
spellcheck="false" 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" 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>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<input <input
+7
View File
@@ -23,6 +23,13 @@ async function copy(field: string, value: string) {
<template> <template>
<div v-if="info" class="max-w-3xl space-y-4"> <div v-if="info" class="max-w-3xl space-y-4">
<InfoCard
v-if="status?.overlayIp"
label="Overlay IP — use this when sending messages"
:value="status.overlayIp"
:copied="copiedField === 'ip'"
@copy="copy('ip', status.overlayIp!)"
/>
<InfoCard <InfoCard
label="Overlay subnet" label="Overlay subnet"
:value="info.nodeSubnet" :value="info.nodeSubnet"