diff --git a/README.md b/README.md index 8d48d10..b70a28b 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,110 @@ 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 -- **Daemon**: Rust (`myceliumd`) running as system service, IPC via Unix socket / named pipe -- **Mycelium engine**: official `mycelium` binary embedded as Tauri sidecar -- **Targets v1**: Linux + Windows +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. + +## 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) + +```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. diff --git a/src-tauri/packaging/polkit/tech.threefold.mycellium-ui.policy b/src-tauri/packaging/polkit/tech.threefold.mycellium-ui.policy new file mode 100644 index 0000000..944ad64 --- /dev/null +++ b/src-tauri/packaging/polkit/tech.threefold.mycellium-ui.policy @@ -0,0 +1,24 @@ + + + + Threefold + https://threefold.io + + + Run the Mycelium overlay daemon + Lancer le démon de l'overlay Mycelium + Authentication is required to start the Mycelium overlay daemon. + Une authentification est requise pour démarrer le démon Mycelium. + + auth_admin + auth_admin + + auth_admin_keep + + + diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index ec82733..35e8e2f 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -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 { 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 { + // 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 { + // We can't reach the AppHandle here, so we mirror Tauri's path: + // $XDG_DATA_HOME// or $HOME/.local/share//. + 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", + )) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7ecdfab..6b5cfae 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -57,6 +57,7 @@ pub fn run() { commands::topic_forward_set, commands::topic_forward_remove, commands::lookup_pubkey, + commands::regenerate_identity, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d1742b3..5dc0964 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -40,7 +40,10 @@ "externalBin": ["binaries/mycelium"], "linux": { "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" + } } } } diff --git a/src/App.vue b/src/App.vue index fd32c60..3034d35 100644 --- a/src/App.vue +++ b/src/App.vue @@ -13,10 +13,12 @@ import { } from "lucide-vue-next"; import StartupOverlay from "@/components/StartupOverlay.vue"; import { useNodeStore } from "@/stores/node"; +import { useConfigStore } from "@/stores/config"; import { storeToRefs } from "pinia"; const route = useRoute(); const node = useNodeStore(); +const config = useConfigStore(); const { phase, info, error } = storeToRefs(node); const navItems = [ @@ -30,8 +32,9 @@ const navItems = [ const currentTitle = computed(() => (route.meta?.title as string) ?? "Mycellium"); -onMounted(() => { - node.bootstrap(); +onMounted(async () => { + await config.load(); + await node.bootstrap(); }); onBeforeUnmount(() => { @@ -40,7 +43,7 @@ onBeforeUnmount(() => { async function handleStart() { try { - await node.start(); + await node.start(config.config); } catch { // error already in store } diff --git a/src/lib/api.ts b/src/lib/api.ts index ffc666c..16d0e35 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -164,6 +164,7 @@ export const api = { cmd("topic_forward_remove", { topicB64 }), lookupPubkey: (ip: string) => cmd("lookup_pubkey", { ip }), + regenerateIdentity: () => cmd("regenerate_identity"), }; /** Format the canonical peer endpoint string the API expects. */ diff --git a/src/stores/config.ts b/src/stores/config.ts new file mode 100644 index 0000000..610ded6 --- /dev/null +++ b/src/stores/config.ts @@ -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({ ...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(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 }; +}); diff --git a/src/stores/node.ts b/src/stores/node.ts index 0fb6be9..ed070fa 100644 --- a/src/stores/node.ts +++ b/src/stores/node.ts @@ -1,6 +1,6 @@ import { defineStore } from "pinia"; 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"; 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"; error.value = null; try { - const s = await api.startDaemon(); + const s = await api.startDaemon(config); status.value = s; info.value = await api.nodeInfo(); phase.value = "ready"; diff --git a/src/views/Settings.vue b/src/views/Settings.vue index d1286f2..7a519d0 100644 --- a/src/views/Settings.vue +++ b/src/views/Settings.vue @@ -1,5 +1,272 @@ - +