Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b83fc10d5 | |||
| 5229e2c774 | |||
| 0c9277f687 | |||
| 3bf3cd162b | |||
| 70eb5c7b57 | |||
| b5909ccb56 | |||
| a31a40a477 | |||
| 939565b88a | |||
| 7981fc571c | |||
| 9fe24c72cb |
@@ -31,3 +31,6 @@ priv_key.bin
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Personal notes — not part of the public repo
|
||||
docs-syoul/
|
||||
|
||||
@@ -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
|
||||
@@ -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, 2–64 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
@@ -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>
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "mycellium-ui",
|
||||
"name": "mycellium-ui-private",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -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, 2–64 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.
|
||||
@@ -0,0 +1 @@
|
||||
fe9e98ff6b2ee740345a9cac5bc1fd828cf7b53c96242c0c15fdc26dd8249b4e release/mycellium-ui-private_0.1.0_amd64.deb
|
||||
Binary file not shown.
+12
-12
@@ -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}"
|
||||
|
||||
Generated
+3
-1
@@ -1946,11 +1946,13 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mycellium-ui"
|
||||
name = "mycellium-ui-private"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"hex",
|
||||
"parking_lot",
|
||||
"portpicker",
|
||||
"rand 0.8.6",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
[package]
|
||||
name = "mycellium-ui"
|
||||
name = "mycellium-ui-private"
|
||||
version = "0.1.0"
|
||||
description = "Mycelium overlay network desktop client"
|
||||
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
@@ -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 "$@"
|
||||
+9
-5
@@ -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>
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
mycellium_ui_lib::run();
|
||||
mycellium_ui_private_lib::run();
|
||||
}
|
||||
|
||||
+10
-6
@@ -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;
|
||||
|
||||
+171
-26
@@ -16,27 +16,23 @@ 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>,
|
||||
}
|
||||
|
||||
/// Holds the running mycelium child process plus a small in-memory log
|
||||
@@ -48,6 +44,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 +58,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 +85,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,7 +116,33 @@ 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)
|
||||
@@ -124,6 +165,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 +186,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 +211,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 +317,7 @@ impl SidecarHandle {
|
||||
|
||||
fn cleanup(&self) {
|
||||
*self.api_url.lock() = None;
|
||||
*self.overlay_ip.lock() = None;
|
||||
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> {
|
||||
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`
|
||||
/// (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))
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Mycellium UI",
|
||||
"productName": "Mycellium UI Private",
|
||||
"version": "0.1.0",
|
||||
"identifier": "tech.threefold.mycellium-ui",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -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="
|
||||
@@ -126,8 +126,8 @@ async function handleStop() {
|
||||
</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"
|
||||
|
||||
@@ -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,14 @@ 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;
|
||||
}
|
||||
|
||||
export interface NetworkKeyStatus {
|
||||
path: string;
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
// ─── Type-safe invoke wrappers ───────────────────────────────────────────────
|
||||
@@ -165,6 +175,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. */
|
||||
|
||||
@@ -6,13 +6,14 @@ 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.
|
||||
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,
|
||||
};
|
||||
|
||||
export const useConfigStore = defineStore("config", () => {
|
||||
@@ -33,6 +34,7 @@ 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,
|
||||
};
|
||||
}
|
||||
loaded.value = true;
|
||||
@@ -43,6 +45,7 @@ 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,
|
||||
};
|
||||
const s = await ensureStore();
|
||||
await s.set(KEY, config.value);
|
||||
|
||||
+7
-1
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
+237
-3
@@ -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,7 @@ const draft = reactive({
|
||||
peers: "",
|
||||
tunName: "",
|
||||
noTun: false,
|
||||
networkName: "",
|
||||
});
|
||||
const dirty = ref(false);
|
||||
|
||||
@@ -31,6 +37,7 @@ function loadDraft() {
|
||||
draft.peers = config.value.peers.join("\n");
|
||||
draft.tunName = config.value.tunName ?? "";
|
||||
draft.noTun = config.value.noTun;
|
||||
draft.networkName = config.value.networkName ?? "";
|
||||
dirty.value = false;
|
||||
}
|
||||
|
||||
@@ -52,6 +59,7 @@ async function save() {
|
||||
.filter(Boolean),
|
||||
tunName: draft.tunName.trim() || null,
|
||||
noTun: draft.noTun,
|
||||
networkName: draft.networkName.trim() || null,
|
||||
});
|
||||
dirty.value = false;
|
||||
}
|
||||
@@ -101,13 +109,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, 2–64 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 +349,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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user