P5: settings, persistence, polkit packaging, README

Backend
- regenerate_identity command stops the daemon, deletes
  priv_key.bin, leaves the user to restart for a fresh identity;
  falls back to the canonical XDG path when sidecar.key_path()
  isn't populated yet
- tauri.conf.json ships the polkit policy via deb.files mapping;
  src-tauri/packaging/polkit/tech.threefold.mycellium-ui.policy
  declares the spawn action with auth_admin_keep so the dialog
  appears once per session

Frontend
- config store persists SidecarConfig (peers, tunName, noTun)
  through tauri-plugin-store; App.vue reads it and forwards to
  start_daemon, replacing the hard-coded defaults
- Settings view: daemon-config form, identity panel with the
  destructive regenerate button, sidecar log viewer, About
- README rewritten end-to-end: HTTP-loopback architecture, polkit
  install path, build commands, verification matrix, and a
  honest "known limitations" section
This commit is contained in:
syoul
2026-04-25 23:15:35 +02:00
parent f28d0e1338
commit eb86fdd182
10 changed files with 519 additions and 15 deletions

111
README.md
View File

@@ -2,11 +2,110 @@
Cross-platform desktop GUI for [Mycelium](https://github.com/threefoldtech/mycelium) — Threefold's end-to-end encrypted IPv6 overlay network. Cross-platform desktop GUI for [Mycelium](https://github.com/threefoldtech/mycelium) — Threefold's end-to-end encrypted IPv6 overlay network.
Status: scaffolding. See the implementation plan for architecture, IPC protocol, and phasing. 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`.
## Stack (planned) ## Status
- **App**: Tauri v2 + Vue 3 + TypeScript 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.
- **Daemon**: Rust (`myceliumd`) running as system service, IPC via Unix socket / named pipe
- **Mycelium engine**: official `mycelium` binary embedded as Tauri sidecar ## Architecture
- **Targets v1**: Linux + Windows
```
┌──────────────────────────────────────────────────────────────┐
│ 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
sudo apt install -y \
libwebkit2gtk-4.1-dev libjavascriptcoregtk-4.1-dev \
libsoup-3.0-dev libayatana-appindicator3-dev librsvg2-dev \
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
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.
## Build
```bash
pnpm tauri build # → src-tauri/target/release/bundle/{deb,appimage}/
```
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.
## Layout
```
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
```
## Verification matrix
| 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 (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.

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
"https://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
<policyconfig>
<vendor>Threefold</vendor>
<vendor_url>https://threefold.io</vendor_url>
<action id="tech.threefold.mycellium-ui.spawn">
<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>
<message xml:lang="fr">Une authentification est requise pour démarrer le démon Mycelium.</message>
<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>
</action>
</policyconfig>

View File

@@ -247,3 +247,52 @@ pub async fn topic_forward_remove(state: State<'_, AppState>, topic_b64: String)
pub async fn lookup_pubkey(state: State<'_, AppState>, ip: String) -> AppResult<PubkeyLookup> { pub async fn lookup_pubkey(state: State<'_, AppState>, ip: String) -> AppResult<PubkeyLookup> {
require_client(&state)?.lookup_pubkey(&ip).await require_client(&state)?.lookup_pubkey(&ip).await
} }
// ─── Identity ────────────────────────────────────────────────────────────────
/// Stops the daemon (if running), removes the saved private key file, and
/// returns the daemon to the idle state. The caller restarts the daemon
/// to provoke regeneration.
#[tauri::command]
pub async fn regenerate_identity(state: State<'_, AppState>) -> AppResult<()> {
state.poller.stop();
let key_path = state.sidecar.key_path();
state.sidecar.stop().await;
if let Some(path) = key_path {
if path.exists() {
std::fs::remove_file(&path).map_err(AppError::from)?;
}
} else {
// Sidecar never started; resolve the canonical path via app_data_dir.
if let Some(p) = default_key_path() {
if p.exists() {
std::fs::remove_file(&p).map_err(AppError::from)?;
}
}
}
Ok(())
}
fn default_key_path() -> Option<std::path::PathBuf> {
// Best-effort: fall back to the same XDG location the sidecar uses.
dirs_like_app_data().ok().map(|d| d.join("priv_key.bin"))
}
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";
if let Ok(d) = std::env::var("XDG_DATA_HOME") {
return Ok(std::path::PathBuf::from(d).join(identifier));
}
if let Ok(home) = std::env::var("HOME") {
return Ok(std::path::PathBuf::from(home)
.join(".local/share")
.join(identifier));
}
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"no app data dir",
))
}

View File

@@ -57,6 +57,7 @@ pub fn run() {
commands::topic_forward_set, commands::topic_forward_set,
commands::topic_forward_remove, commands::topic_forward_remove,
commands::lookup_pubkey, commands::lookup_pubkey,
commands::regenerate_identity,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@@ -40,7 +40,10 @@
"externalBin": ["binaries/mycelium"], "externalBin": ["binaries/mycelium"],
"linux": { "linux": {
"deb": { "deb": {
"depends": ["policykit-1"] "depends": ["policykit-1"],
"files": {
"/usr/share/polkit-1/actions/tech.threefold.mycellium-ui.policy": "packaging/polkit/tech.threefold.mycellium-ui.policy"
}
} }
} }
} }

View File

@@ -13,10 +13,12 @@ import {
} from "lucide-vue-next"; } from "lucide-vue-next";
import StartupOverlay from "@/components/StartupOverlay.vue"; import StartupOverlay from "@/components/StartupOverlay.vue";
import { useNodeStore } from "@/stores/node"; import { useNodeStore } from "@/stores/node";
import { useConfigStore } from "@/stores/config";
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
const route = useRoute(); const route = useRoute();
const node = useNodeStore(); const node = useNodeStore();
const config = useConfigStore();
const { phase, info, error } = storeToRefs(node); const { phase, info, error } = storeToRefs(node);
const navItems = [ const navItems = [
@@ -30,8 +32,9 @@ const navItems = [
const currentTitle = computed(() => (route.meta?.title as string) ?? "Mycellium"); const currentTitle = computed(() => (route.meta?.title as string) ?? "Mycellium");
onMounted(() => { onMounted(async () => {
node.bootstrap(); await config.load();
await node.bootstrap();
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -40,7 +43,7 @@ onBeforeUnmount(() => {
async function handleStart() { async function handleStart() {
try { try {
await node.start(); await node.start(config.config);
} catch { } catch {
// error already in store // error already in store
} }

View File

@@ -164,6 +164,7 @@ export const api = {
cmd<void>("topic_forward_remove", { topicB64 }), cmd<void>("topic_forward_remove", { topicB64 }),
lookupPubkey: (ip: string) => cmd<PubkeyLookup>("lookup_pubkey", { ip }), lookupPubkey: (ip: string) => cmd<PubkeyLookup>("lookup_pubkey", { ip }),
regenerateIdentity: () => cmd<void>("regenerate_identity"),
}; };
/** Format the canonical peer endpoint string the API expects. */ /** Format the canonical peer endpoint string the API expects. */

57
src/stores/config.ts Normal file
View File

@@ -0,0 +1,57 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { Store as TauriStore } from "@tauri-apps/plugin-store";
import type { SidecarConfig } from "@/lib/api";
const STORE_FILE = "config.json";
const KEY = "sidecar";
const DEFAULT_CONFIG: SidecarConfig = {
peers: [
"tcp://188.40.132.242:9651",
"quic://[2a01:4f8:212:fa6::2]:9651",
],
tunName: null,
noTun: false,
};
export const useConfigStore = defineStore("config", () => {
const config = ref<SidecarConfig>({ ...DEFAULT_CONFIG });
const loaded = ref(false);
let store: TauriStore | null = null;
async function ensureStore() {
if (!store) store = await TauriStore.load(STORE_FILE);
return store;
}
async function load() {
const s = await ensureStore();
const saved = await s.get<SidecarConfig>(KEY);
if (saved) {
config.value = {
peers: Array.isArray(saved.peers) ? saved.peers : DEFAULT_CONFIG.peers,
tunName: saved.tunName ?? null,
noTun: !!saved.noTun,
};
}
loaded.value = true;
}
async function save(next: SidecarConfig) {
config.value = {
peers: next.peers.map((p) => p.trim()).filter(Boolean),
tunName: next.tunName?.trim() ? next.tunName.trim() : null,
noTun: !!next.noTun,
};
const s = await ensureStore();
await s.set(KEY, config.value);
await s.save();
}
function reset() {
return save({ ...DEFAULT_CONFIG });
}
return { config, loaded, load, save, reset };
});

View File

@@ -1,6 +1,6 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { ref } from "vue"; import { ref } from "vue";
import { api, type DaemonStatus, type NodeInfo } from "@/lib/api"; import { api, type DaemonStatus, type NodeInfo, type SidecarConfig } from "@/lib/api";
import { Events, on } from "@/lib/events"; import { Events, on } from "@/lib/events";
export type Phase = "idle" | "starting" | "ready" | "error"; export type Phase = "idle" | "starting" | "ready" | "error";
@@ -41,11 +41,11 @@ export const useNodeStore = defineStore("node", () => {
} }
} }
async function start() { async function start(config?: SidecarConfig) {
phase.value = "starting"; phase.value = "starting";
error.value = null; error.value = null;
try { try {
const s = await api.startDaemon(); const s = await api.startDaemon(config);
status.value = s; status.value = s;
info.value = await api.nodeInfo(); info.value = await api.nodeInfo();
phase.value = "ready"; phase.value = "ready";

View File

@@ -1,5 +1,272 @@
<script setup lang="ts"></script> <script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { storeToRefs } from "pinia";
import {
Save,
RotateCcw,
RefreshCw,
KeyRound,
AlertTriangle,
TerminalSquare,
} from "lucide-vue-next";
import { api } from "@/lib/api";
import { useConfigStore } from "@/stores/config";
import { useNodeStore } from "@/stores/node";
const configStore = useConfigStore();
const node = useNodeStore();
const { config } = storeToRefs(configStore);
const { phase, status } = storeToRefs(node);
// Local working copy for the form. We mirror the canonical config and
// synchronise on every write to the store.
const draft = reactive({
peers: "",
tunName: "",
noTun: false,
});
const dirty = ref(false);
function loadDraft() {
draft.peers = config.value.peers.join("\n");
draft.tunName = config.value.tunName ?? "";
draft.noTun = config.value.noTun;
dirty.value = false;
}
watch(config, loadDraft, { immediate: true });
watch(
draft,
() => {
dirty.value = true;
},
{ deep: true },
);
async function save() {
await configStore.save({
peers: draft.peers
.split(/\r?\n/)
.map((s) => s.trim())
.filter(Boolean),
tunName: draft.tunName.trim() || null,
noTun: draft.noTun,
});
dirty.value = false;
}
async function reset() {
if (!confirm("Restore default daemon configuration?")) return;
await configStore.reset();
}
// ─── Identity ───────────────────────────────────────────────────────────────
const regenBusy = ref(false);
const regenError = ref<string | null>(null);
async function regenerate() {
if (
!confirm(
"Regenerate identity? This deletes the current private key. Your overlay IPv6 and public key will change. Anyone with peers configured by your old IP/key will have to update them.",
)
) {
return;
}
regenBusy.value = true;
regenError.value = null;
try {
await api.regenerateIdentity();
} catch (e) {
regenError.value = String(e);
} finally {
regenBusy.value = false;
}
}
// ─── Logs ───────────────────────────────────────────────────────────────────
const logs = ref<string[]>([]);
const logsLoading = ref(false);
async function refreshLogs() {
logsLoading.value = true;
try {
logs.value = await api.sidecarLogs();
} finally {
logsLoading.value = false;
}
}
const isReady = computed(() => phase.value === "ready");
onMounted(async () => {
if (isReady.value) await refreshLogs();
});
</script>
<template> <template>
<p class="text-sm text-muted-foreground">Settings view wired in P5.</p> <div class="grid gap-4 lg:grid-cols-2">
<!-- Daemon configuration -->
<section class="rounded-lg border border-border bg-card">
<header class="border-b border-border px-4 py-3">
<h2 class="text-sm font-medium">Daemon configuration</h2>
<p class="mt-0.5 text-xs text-muted-foreground">
Applied next time the daemon starts. Currently running daemon
isn't reconfigured live.
</p>
</header>
<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)
</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"
/>
</div>
<div class="flex items-center gap-3">
<input
id="no-tun"
v-model="draft.noTun"
type="checkbox"
class="h-4 w-4"
/>
<label for="no-tun" class="text-sm">
Disable TUN interface (<code class="font-mono text-xs">--no-tun</code>)
</label>
</div>
<div>
<label class="text-xs uppercase tracking-wide text-muted-foreground">
TUN interface name (optional)
</label>
<input
v-model="draft.tunName"
type="text"
class="mt-1 w-full rounded-md border border-input bg-background px-3 py-1.5 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="mycelium0"
:disabled="draft.noTun"
/>
</div>
<div class="flex justify-end gap-2 pt-2">
<button
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-secondary"
@click="reset"
>
<RotateCcw class="h-3 w-3" />
Defaults
</button>
<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="!dirty"
@click="save"
>
<Save class="h-3 w-3" />
Save
</button>
</div>
</div>
</section>
<!-- Identity ───────────────────────────────────────────────────────── -->
<section class="rounded-lg border border-border bg-card">
<header class="border-b border-border px-4 py-3">
<h2 class="flex items-center gap-2 text-sm font-medium">
<KeyRound class="h-4 w-4" /> Identity
</h2>
</header>
<div class="space-y-3 px-4 py-4 text-sm">
<div>
<div class="text-xs uppercase tracking-wide text-muted-foreground">
Key file
</div>
<div class="mt-1 break-all font-mono text-xs">
{{ status?.keyPath ?? "(unknown — start the daemon at least once)" }}
</div>
</div>
<div class="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-xs">
<div class="flex items-start gap-2">
<AlertTriangle class="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<div class="space-y-2">
<p>
Regenerating discards your current private key, which means a
new overlay IPv6 and public key. This is irreversible.
</p>
<button
class="inline-flex items-center gap-1.5 rounded-md border border-destructive px-3 py-1 text-destructive hover:bg-destructive hover:text-destructive-foreground disabled:opacity-50"
:disabled="regenBusy"
@click="regenerate"
>
<RotateCcw class="h-3 w-3" />
Regenerate identity
</button>
<p v-if="regenError" class="text-destructive">{{ regenError }}</p>
</div>
</div>
</div>
</div>
</section>
<!-- Logs ──────────────────────────────────────────────────────────── -->
<section class="rounded-lg border border-border bg-card lg:col-span-2">
<header class="flex items-center justify-between border-b border-border px-4 py-3">
<h2 class="flex items-center gap-2 text-sm font-medium">
<TerminalSquare class="h-4 w-4" /> Sidecar logs
</h2>
<button
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1 text-xs hover:bg-secondary disabled:opacity-50"
:disabled="logsLoading"
@click="refreshLogs"
>
<RefreshCw
class="h-3 w-3"
:class="logsLoading ? 'animate-spin' : ''"
/>
Refresh
</button>
</header>
<div
class="max-h-72 overflow-y-auto bg-background px-4 py-2 font-mono text-[11px]"
>
<p v-if="!logs.length" class="py-4 text-center text-muted-foreground">
No log entries — start the daemon to begin capturing.
</p>
<pre v-else class="whitespace-pre-wrap break-all">{{ logs.join("\n") }}</pre>
</div>
</section>
<!-- About ────────────────────────────────────────────────────────── -->
<section class="rounded-lg border border-border bg-card lg:col-span-2">
<header class="border-b border-border px-4 py-3">
<h2 class="text-sm font-medium">About</h2>
</header>
<dl class="grid grid-cols-1 gap-4 px-4 py-4 text-sm sm:grid-cols-3">
<div>
<dt class="text-xs uppercase tracking-wide text-muted-foreground">
App
</dt>
<dd class="mt-1 font-mono text-xs">mycellium-ui 0.1.0</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wide text-muted-foreground">
Mycelium daemon
</dt>
<dd class="mt-1 font-mono text-xs">v0.6.1 (bundled)</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wide text-muted-foreground">
API endpoint
</dt>
<dd class="mt-1 font-mono text-xs">
{{ status?.apiUrl ?? "" }}
</dd>
</div>
</dl>
</section>
</div>
</template> </template>