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

View File

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

View File

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

View File

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

View File

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