diff --git a/crates/allmystuff-protocol/src/control.rs b/crates/allmystuff-protocol/src/control.rs index d0f9633..ff433a0 100644 --- a/crates/allmystuff-protocol/src/control.rs +++ b/crates/allmystuff-protocol/src/control.rs @@ -97,6 +97,18 @@ pub enum Request { #[serde(default)] purge: bool, }, + /// Forget **every** joined network on the daemon at once — a bulk + /// `NetworkRemove{purge:true}` that tears each down and deletes its roster + + /// signed governance state, keeping the device identity. The daemon exits + /// afterward so a fresh one reloads clean (matched by an app restart), rather + /// than a stale in-memory cache re-persisting what was just removed. Needs a + /// daemon that speaks this op; an older one replies with a parse error. + ForgetAllNetworks, + /// Factory reset — the daemon wipes its **entire** state directory + /// (`~/.myownmesh`: identity, config, every network, and the co-located + /// AllMyStuff ownership record) and exits so a fresh daemon mints a new + /// identity on empty state. The device becomes brand-new to every peer. + FactoryReset, NetworkUpdate { config: Value, }, diff --git a/gui/src-tauri/src/main.rs b/gui/src-tauri/src/main.rs index 383da7a..77211a1 100644 --- a/gui/src-tauri/src/main.rs +++ b/gui/src-tauri/src/main.rs @@ -1223,6 +1223,33 @@ async fn fleet_leave(state: State<'_, AppState>) -> Result<(), String> { Ok(()) } +/// Danger Zone: leave the fleet and forget every network the daemon holds +/// (keeps this device's identity). The daemon exits afterward; the caller +/// follows with `restart_app` so the whole stack reloads clean. +#[tauri::command] +async fn reset_networking(state: State<'_, AppState>) -> Result<(), String> { + state + .node + .request("reset_networking", json!({})) + .await + .map_err(|e| e.to_string())?; + Ok(()) +} + +/// Danger Zone: factory reset — wipe this device's entire state (identity, +/// config, all networks, fleet ownership) so it's brand-new to every peer. The +/// node clears its ownership and the daemon wipes `~/.myownmesh` and exits; the +/// caller follows with `restart_app`. +#[tauri::command] +async fn factory_reset(state: State<'_, AppState>) -> Result<(), String> { + state + .node + .request("factory_reset", json!({})) + .await + .map_err(|e| e.to_string())?; + Ok(()) +} + /// Evict a device from the fleet (owner-only; the daemon enforces it). `code` /// is the owner's custody second factor when fleet MFA is enrolled. #[tauri::command] @@ -2505,6 +2532,8 @@ fn main() { open_room_window, owned_roster, fleet_leave, + reset_networking, + factory_reset, fleet_kick, fleet_set_name, fleet_grant_role, diff --git a/gui/src/store.svelte.ts b/gui/src/store.svelte.ts index cc42254..5770c43 100644 --- a/gui/src/store.svelte.ts +++ b/gui/src/store.svelte.ts @@ -64,6 +64,8 @@ import { fileSend, fleetKick, fleetLeave, + resetNetworking, + factoryReset, fleetSetName, fleetGrantRole, fleetRevokeRole, @@ -7722,6 +7724,34 @@ class AppStore { // No toast — the Fleet pane drops to its "No fleet yet" state. } + // ---- Danger Zone (Settings → Updates) ---- + // Each level reboots the whole stack after it clears state. The mesh daemon + // is the real datastore, so a reset that only deletes state on one layer can + // be undone by another layer's in-memory cache re-persisting it — the reboot + // is what makes the wipe actually stick. `restartApp()` relaunches the app + // (and the supervised node + daemon come back on clean state), so these calls + // normally end by relaunching rather than returning. + + /** Leave the fleet, then reboot. */ + async dangerLeaveFleet() { + await this.leaveFleet(); + await restartApp(); + } + + /** Leave the fleet and forget every network (keeps the device identity), + * then reboot. */ + async dangerResetNetworking() { + await resetNetworking(); + await restartApp(); + } + + /** Factory reset — wipe identity, config, all networks and fleet ownership, + * then reboot into a brand-new device. */ + async dangerFactoryReset() { + await factoryReset(); + await restartApp(); + } + /** Evict a member from the fleet (owner-only). Routes through the governance * helper, so it prompts for the custody code when fleet MFA is enrolled. */ async kickFleetMember(device: string) { diff --git a/gui/src/tauri.ts b/gui/src/tauri.ts index 85c887d..0c7065d 100644 --- a/gui/src/tauri.ts +++ b/gui/src/tauri.ts @@ -1144,6 +1144,24 @@ export async function fleetLeave(): Promise { await invoke("fleet_leave"); } +/** Danger Zone: leave the fleet and forget every network the daemon holds, + * keeping the device identity. The daemon exits so it reloads clean — pair + * with `restartApp()`. No-op in web mode. */ +export async function resetNetworking(): Promise { + if (!isTauri()) return; + const { invoke } = await import("@tauri-apps/api/core"); + await invoke("reset_networking"); +} + +/** Danger Zone: factory reset — wipe this device's entire state (identity, + * config, all networks, fleet ownership) for a brand-new start. The daemon + * wipes `~/.myownmesh` and exits — pair with `restartApp()`. No-op in web. */ +export async function factoryReset(): Promise { + if (!isTauri()) return; + const { invoke } = await import("@tauri-apps/api/core"); + await invoke("factory_reset"); +} + /** Evict a device from the fleet (owner-only; the backend enforces it). * `code` is the owner's custody second factor when fleet MFA is enrolled. * Throws with the reason when refused. */ diff --git a/gui/src/ui/settings/UpdatesSection.svelte b/gui/src/ui/settings/UpdatesSection.svelte index cbc9b8c..6c750a7 100644 --- a/gui/src/ui/settings/UpdatesSection.svelte +++ b/gui/src/ui/settings/UpdatesSection.svelte @@ -29,6 +29,35 @@ const d = new Date(at * 1000); return d.toLocaleString(); } + + // ---- Danger Zone ---- + // Two-click arming so a reset can't fire on a stray click. Each level reboots + // the whole stack (node + daemon + app) after clearing state: the daemon is + // the real datastore, so without the reboot an in-memory cache can re-persist + // ("resurrect") what was wiped. Desktop only — it needs the local daemon and + // a relaunch. + let armed = $state(null); + let resetting = $state(null); + let resetError = $state(null); + + async function runReset(kind: "leave" | "network" | "factory") { + if (armed !== kind) { + armed = kind; // first click arms; a second confirms + return; + } + armed = null; + resetting = kind; + resetError = null; + try { + if (kind === "leave") await app.dangerLeaveFleet(); + else if (kind === "network") await app.dangerResetNetworking(); + else await app.dangerFactoryReset(); + // The app relaunches on success, so control doesn't normally return here. + } catch (e) { + resetError = String(e); + resetting = null; + } + }
@@ -161,6 +190,93 @@ {/if} {/if} + + + {#if !web && !mobile} +
+
⚠ Danger Zone
+

+ Each of these clears state and then restarts the app and the mesh + daemon, so every layer reloads from disk. Without the reboot, cached + state can quietly reappear. +

+ +
+
+
Leave the fleet
+
+ Remove this device from its fleet — drops ownership, the fleet key, + and the fleet's signed roster. Keeps your other meshes and settings. +
+
+ +
+ +
+
+
Reset networking
+
+ Leave the fleet and forget every mesh — all rosters and signed + state — keeping this device's identity. A clean networking slate. +
+
+ +
+ +
+
+
Factory reset
+
+ Erase everything — identity, config, every mesh, and fleet + ownership. This device becomes brand-new to all peers. No undo. +
+
+ +
+ + {#if armed} + + {/if} + {#if resetError} +

Reset failed: {resetError}

+ {/if} +
+ {/if}
diff --git a/node/src/mesh.rs b/node/src/mesh.rs index e2ce80f..b6734ab 100644 --- a/node/src/mesh.rs +++ b/node/src/mesh.rs @@ -7779,6 +7779,48 @@ impl Mesh { Ok(()) } + /// Danger Zone: leave the fleet **and** forget every network on the daemon — + /// clears this device's fleet membership and purges each mesh's roster + + /// signed governance state, keeping the device identity. A clean networking + /// slate for a wedged node. Leaves the fleet first (clears our ownership + + /// purges the fleet's closed network), then tells the daemon to forget the + /// rest; the daemon exits so a fresh one reloads clean, and the GUI restarts + /// the app around it. Best-effort per step — we're resetting regardless. + pub async fn reset_networking(self: &Arc) -> Result<(), String> { + let _ = self.fleet_leave().await; + if let Err(e) = self.client.request(&Request::ForgetAllNetworks).await { + // A pre-reset-op daemon can't parse it; the fleet leave above still + // did the important part. Surface it but don't fail the reset. + tracing::warn!("reset networking: daemon forget-all errored: {e}"); + } + Ok(()) + } + + /// Danger Zone: factory reset — wipe this device back to brand-new. Clears + /// our local ownership record first (so the node can't re-persist + /// `allmystuff-ownership.json` after the daemon deletes it), then tells the + /// daemon to wipe its **entire** state directory (`~/.myownmesh`: identity, + /// config, every network, and our co-located ownership file) and exit. The + /// GUI restarts the app; a fresh node + daemon come up on empty state with a + /// new identity. The daemon's response may race its own exit, so a transport + /// error after the request is treated as "reset underway", not a failure. + pub async fn factory_reset(self: &Arc) -> Result<(), String> { + // Quiesce our ownership writer so it can't rewrite the file the daemon is + // about to delete. Best-effort — the daemon wipe is the authority, and + // we're restarting the whole stack regardless. + let _ = self.ownership.leave_fleet(); + self.emit_owned().await; + match self.client.request(&Request::FactoryReset).await { + Ok(_) => Ok(()), + Err(e) => { + tracing::warn!( + "factory reset: daemon request errored (it is likely already exiting): {e}" + ); + Ok(()) + } + } + } + /// Front-end command: kick `device` out of the fleet. Only the fleet /// **owner** can — eviction is an owner-authority governance act on the /// closed network. The signed `Evict` propagates the removal to every diff --git a/node/src/node_control.rs b/node/src/node_control.rs index 0cd5b54..7f1cc92 100644 --- a/node/src/node_control.rs +++ b/node/src/node_control.rs @@ -1166,6 +1166,8 @@ pub async fn dispatch( } "owned_roster" => DispatchOut::Json(mesh.fleet_roster_value().await), "fleet_leave" => json_result(mesh.fleet_leave().await), + "reset_networking" => json_result(mesh.reset_networking().await), + "factory_reset" => json_result(mesh.factory_reset().await), "fleet_kick" => { let device: String = try_arg!(arg(a, "device")); let code: Option = try_arg!(opt(a, "code"));