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.
This commit is contained in:
syoul
2026-04-27 01:35:11 +02:00
parent 5229e2c774
commit 8b83fc10d5
22 changed files with 610 additions and 183 deletions

43
CHANGELOG.md Normal file
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

135
README.md
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.

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>

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",

View File

@@ -1,16 +1,16 @@
# Releases # Releases — Mycellium UI Private
Pre-built `.deb` of the mycellium-ui desktop client. Tested on Debian 12 (bookworm); should work on any apt-based distro shipping `libwebkit2gtk-4.1-0` (Ubuntu 24.04+, Debian 12+). 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 ## Install
```bash ```bash
sudo apt install ./mycellium-ui_0.1.0_amd64.deb sudo apt install ./mycellium-ui-private_0.1.0_amd64.deb
``` ```
`apt install` with a local path resolves runtime deps (`pkexec` or `policykit-1` depending on the distro, `libwebkit2gtk-4.1-0`, `libgtk-3-0`) automatically. Plain `dpkg -i` will fail if any of those are missing. `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.
The `pkexec | policykit-1` alternative covers both Debian 12 (where `pkexec` ships inside `policykit-1`) and Debian 13 (where `pkexec` is a standalone package). Tested on Debian 12 (bookworm) and Debian 13 (trixie).
## Verify ## Verify
@@ -22,13 +22,28 @@ sha256sum -c SHA256SUMS
| Path | Purpose | | Path | Purpose |
|---|---| |---|---|
| `/usr/bin/mycellium-ui` | GUI launcher | | `/usr/bin/mycellium-ui-private` | GUI launcher |
| `/usr/bin/mycelium` | Mycelium daemon (v0.6.1, runs as root via pkexec) | | `/usr/bin/mycelium-private` | Mycelium daemon (private-network track, v0.6.1, runs as root via pkexec) |
| `/usr/share/polkit-1/actions/tech.threefold.mycellium-ui.policy` | polkit action — auth cached per session (`auth_admin_keep`) | | `/usr/bin/mycellium-bootstrap-private` | Cleanup wrapper invoked by pkexec |
| `/usr/share/applications/Mycellium UI.desktop` | Menu entry | | `/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 ## Uninstall
```bash ```bash
sudo apt remove mycellium-ui 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.

View File

@@ -1 +1 @@
38e0a3d03797490e1d6caa2681f9d5580493908834736b0db6a7d99558bb8823 release/mycellium-ui_0.1.0_amd64.deb fe9e98ff6b2ee740345a9cac5bc1fd828cf7b53c96242c0c15fdc26dd8249b4e release/mycellium-ui-private_0.1.0_amd64.deb

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}"

4
src-tauri/Cargo.lock generated
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",

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"]

View File

@@ -1,27 +0,0 @@
#!/bin/sh
# /usr/bin/mycellium-bootstrap — installed by mycellium-ui.deb
#
# Wrapper around the mycelium daemon that guarantees a clean start
# every time. Without this, an orphan mycelium left over from a
# previous run (which the user-space launcher cannot SIGKILL because
# the daemon runs as root via pkexec) would block the next start
# with one of:
#
# * EBUSY on TUN device "mycelium" creation
# * "Address in use" on the JSON-RPC port (hardcoded 8990 in 0.6.1)
# * "Failed to bind multicast discovery socket" on UDP 9650
#
# This script runs under the same elevated context as the mycelium
# daemon itself (single pkexec call), so polkit's auth_admin_keep
# caching only fires one prompt per session.
set -e
# Best-effort cleanup. Errors ignored so the exec at the end always
# runs even on a clean machine.
pkill -9 -x mycelium 2>/dev/null || true
sleep 0.3
ip link del mycelium 2>/dev/null || true
ip link del mycel0 2>/dev/null || true
exec /usr/bin/mycelium "$@"

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 "$@"

View File

@@ -13,7 +13,7 @@
pick this action up; auth_admin_keep then caches the auth for pick this action up; auth_admin_keep then caches the auth for
the user's session so subsequent restarts don't re-prompt. the user's session so subsequent restarts don't re-prompt.
--> -->
<action id="tech.threefold.mycellium-ui.bootstrap"> <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>
@@ -23,6 +23,6 @@
<allow_inactive>auth_admin</allow_inactive> <allow_inactive>auth_admin</allow_inactive>
<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</annotate> <annotate key="org.freedesktop.policykit.exec.path">/usr/bin/mycellium-bootstrap-private</annotate>
</action> </action>
</policyconfig> </policyconfig>

View File

@@ -285,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));
} }

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");

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();
} }

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
@@ -120,9 +116,24 @@ 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 // In a `.deb` install, our pre-install script ships
// /usr/bin/mycellium-bootstrap that pkill+ip-link-del cleans // /usr/bin/mycellium-bootstrap-private that pkill+ip-link-del cleans
// any orphan state before exec-ing the real binary. Polkit // any orphan state before exec-ing the real binary. Polkit
// is configured to auth_admin_keep that exact path, so // is configured to auth_admin_keep that exact path, so
// subsequent starts are silent. // subsequent starts are silent.
@@ -154,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(),
@@ -166,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());
@@ -312,11 +336,11 @@ impl SidecarHandle {
} }
/// Path of the privileged wrapper script shipped in our `.deb`. When /// Path of the privileged wrapper script shipped in our `.deb`. When
/// present, we invoke it instead of the mycelium binary directly so /// present, we invoke it instead of the mycelium-private binary
/// the elevated context can clean up any orphan TUN / processes from /// directly so the elevated context can clean up any orphan TUN /
/// a previous crash before `exec /usr/bin/mycelium`. /// processes from a previous crash before `exec /usr/bin/mycelium-private`.
fn bootstrap_path() -> Option<PathBuf> { fn bootstrap_path() -> Option<PathBuf> {
let p = PathBuf::from("/usr/bin/mycellium-bootstrap"); let p = PathBuf::from("/usr/bin/mycellium-bootstrap-private");
p.exists().then_some(p) p.exists().then_some(p)
} }
@@ -378,19 +402,20 @@ fn pick_port_skip(taken: &[u16]) -> AppResult<u16> {
)) ))
} }
/// Resolve the bundled `mycelium` sidecar across our two build modes: /// Resolve the bundled `mycelium-private` sidecar across our two build
/// modes:
/// • `tauri dev` keeps the file under `src-tauri/binaries/` with the /// • `tauri dev` keeps the file under `src-tauri/binaries/` with the
/// `-<target_triple>` suffix Tauri's externalBin convention requires. /// `-<target_triple>` suffix Tauri's externalBin convention requires.
/// • `tauri build` for a `.deb` strips the suffix and places the /// • `tauri build` for a `.deb` strips the suffix and places the
/// binary at `/usr/bin/mycelium` next to the app launcher. /// binary at `/usr/bin/mycelium-private` next to the app launcher.
/// We probe the bundled path first, then walk back to the dev location. /// 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 suffixed = format!("mycelium-{triple}"); let suffixed = format!("mycelium-private-{triple}");
let plain = "mycelium".to_string(); 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 // Bundled .deb / AppImage: the launcher lives next to the sidecar

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,
@@ -30,20 +30,20 @@
"active": true, "active": true,
"targets": ["deb", "appimage"], "targets": ["deb", "appimage"],
"category": "Utility", "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": ["pkexec | 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": "packaging/mycellium-bootstrap" "/usr/bin/mycellium-bootstrap-private": "packaging/mycellium-bootstrap-private"
} }
} }
} }

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="

View File

@@ -20,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 ───────────────────────────────────────────────
@@ -167,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. */

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);

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