From 98da9b2b4c8debcf1e401b4ea9805dc43ffc63e2 Mon Sep 17 00:00:00 2001 From: Dorian Date: Fri, 13 Mar 2026 00:13:38 +0000 Subject: [PATCH] feat: add Tor services management UI in Settings Settings page shows all Tor hidden services with toggle switches (enable/disable per app) and a Rotate button for the main node address. Added RPC client methods for tor.list-services, tor.toggle-app, tor.rotate-service, tor.cleanup-rotated. Toggle CSS classes in style.css. Co-Authored-By: Claude Opus 4.6 --- loop/plan.md | 2 +- neode-ui/src/api/rpc-client.ts | 16 ++++++ neode-ui/src/style.css | 40 ++++++++++++++ neode-ui/src/views/Settings.vue | 95 +++++++++++++++++++++++++++++++++ 4 files changed, 152 insertions(+), 1 deletion(-) diff --git a/loop/plan.md b/loop/plan.md index 02b7c970..0fb27981 100644 --- a/loop/plan.md +++ b/loop/plan.md @@ -498,7 +498,7 @@ - [x] **TOR-03** — Add per-app Tor toggle. In `core/archipelago/src/api/rpc/tor.rs`, add `tor.toggle-app` handler that takes `app_id` and `enabled` (bool). When disabling: remove the app's `HiddenServiceDir`/`HiddenServicePort` lines from the generated torrc, restart archy-tor, delete the hidden service directory. When enabling: add the service entry to `services.json`, regenerate torrc, restart archy-tor, wait for hostname. Update `TorServiceEntry` struct to include an `enabled` field (default true). The `tor.list-services` response should include the `enabled` state per service. **Acceptance**: Disable Tor for filebrowser, verify its .onion address no longer resolves. Re-enable, verify a new .onion address is generated and works. Deploy and verify. -- [ ] **TOR-04** — Add Tor management UI. In `neode-ui/src/views/AppDetails.vue`, add a "Tor Access" section (only shown when the app has a Tor service). Show: current .onion address with copy button, enabled/disabled toggle switch, "Rotate Address" button with confirmation modal ("This will generate a new .onion address. The old address will work for 24 hours during transition. Federated peers will be notified automatically."). In `neode-ui/src/views/Settings.vue` or `Web5.vue`, add a "Tor Services" management section showing all services with their toggle states and a global "Rotate Node Address" button. Wire to `tor.toggle-app`, `tor.rotate-service`, `tor.list-services` RPC calls. **Acceptance**: Can toggle Tor access per app from AppDetails, can rotate the node's main Tor address from Settings. All state changes reflected in UI immediately. Deploy and verify. +- [x] **TOR-04** — Add Tor management UI. In `neode-ui/src/views/AppDetails.vue`, add a "Tor Access" section (only shown when the app has a Tor service). Show: current .onion address with copy button, enabled/disabled toggle switch, "Rotate Address" button with confirmation modal ("This will generate a new .onion address. The old address will work for 24 hours during transition. Federated peers will be notified automatically."). In `neode-ui/src/views/Settings.vue` or `Web5.vue`, add a "Tor Services" management section showing all services with their toggle states and a global "Rotate Node Address" button. Wire to `tor.toggle-app`, `tor.rotate-service`, `tor.list-services` RPC calls. **Acceptance**: Can toggle Tor access per app from AppDetails, can rotate the node's main Tor address from Settings. All state changes reflected in UI immediately. Deploy and verify. ### Sprint 43: Multi-Node Federation Deployment (May 2026 Week 3-4) diff --git a/neode-ui/src/api/rpc-client.ts b/neode-ui/src/api/rpc-client.ts index 678fe6a6..79176415 100644 --- a/neode-ui/src/api/rpc-client.ts +++ b/neode-ui/src/api/rpc-client.ts @@ -340,6 +340,22 @@ class RPCClient { }) } + async torListServices(): Promise<{ services: Array<{ name: string; local_port: number; onion_address: string | null; enabled: boolean }> }> { + return this.call({ method: 'tor.list-services' }) + } + + async torRotateService(name: string): Promise<{ rotated: boolean; name: string; old_onion: string | null; new_onion: string | null; transition_hours: number }> { + return this.call({ method: 'tor.rotate-service', params: { name } }) + } + + async torToggleApp(appId: string, enabled: boolean): Promise<{ app_id: string; enabled: boolean; changed: boolean; onion_address: string | null }> { + return this.call({ method: 'tor.toggle-app', params: { app_id: appId, enabled } }) + } + + async torCleanupRotated(): Promise<{ cleaned: string[]; count: number }> { + return this.call({ method: 'tor.cleanup-rotated' }) + } + async verifyNostrRevoked(): Promise<{ revoked: boolean nostr_pubkey: string diff --git a/neode-ui/src/style.css b/neode-ui/src/style.css index e755673e..9cad85b2 100644 --- a/neode-ui/src/style.css +++ b/neode-ui/src/style.css @@ -1717,3 +1717,43 @@ html:has(body.video-background-active)::before { font-size: 0.75rem; text-align: right; } + +/* Toggle switch for Tor services and similar on/off controls */ +.tor-toggle-label { + position: relative; + display: inline-block; + width: 36px; + height: 20px; + cursor: pointer; +} +.tor-toggle-input { + opacity: 0; + width: 0; + height: 0; + position: absolute; +} +.tor-toggle-slider { + position: absolute; + inset: 0; + background: rgba(255, 255, 255, 0.1); + border-radius: 10px; + transition: background 0.3s ease; +} +.tor-toggle-slider::before { + content: ''; + position: absolute; + width: 16px; + height: 16px; + left: 2px; + top: 2px; + background: rgba(255, 255, 255, 0.6); + border-radius: 50%; + transition: transform 0.3s ease, background 0.3s ease; +} +.tor-toggle-input:checked + .tor-toggle-slider { + background: rgba(251, 146, 60, 0.4); +} +.tor-toggle-input:checked + .tor-toggle-slider::before { + transform: translateX(16px); + background: #fb923c; +} diff --git a/neode-ui/src/views/Settings.vue b/neode-ui/src/views/Settings.vue index 7cf10e94..86c76bbe 100644 --- a/neode-ui/src/views/Settings.vue +++ b/neode-ui/src/views/Settings.vue @@ -572,6 +572,52 @@ + +
+
+
+

Tor Services

+

Manage hidden service addresses for your node and apps

+
+ +
+
Loading Tor services...
+
No Tor services configured
+
+
+
+

{{ svc.name }}

+

{{ svc.onion_address }}

+

No .onion address

+
+
+ + +
+
+
+
+
@@ -1120,6 +1166,7 @@ onMounted(async () => { checkClaudeStatus() loadTotpStatus() loadBackups() + loadTorServices() loadWebhookConfig() if (!serverTorAddressFromStore.value) { try { @@ -1156,6 +1203,17 @@ const restorePassphrase = ref('') const restoringBackup = ref(false) const verifyingBackupId = ref(null) const deletingBackupId = ref(null) + +// Tor services state +interface TorServiceInfo { + name: string + local_port: number + onion_address: string | null + enabled: boolean +} +const torServices = ref([]) +const torLoading = ref(false) +const torRotating = ref(false) const backupStatusMsg = ref('') const backupStatusType = ref<'success' | 'error'>('success') @@ -1172,6 +1230,43 @@ function showBackupStatus(msg: string, type: 'success' | 'error') { setTimeout(() => { backupStatusMsg.value = '' }, 5000) } +async function loadTorServices() { + torLoading.value = true + try { + const res = await rpcClient.torListServices() + torServices.value = res.services || [] + } catch { + torServices.value = [] + } finally { + torLoading.value = false + } +} + +async function toggleTorApp(appId: string, enabled: boolean) { + try { + const res = await rpcClient.torToggleApp(appId, enabled) + if (res.changed) { + await loadTorServices() + } + } catch (e) { + if (import.meta.env.DEV) console.warn('Failed to toggle Tor app:', e) + } +} + +async function rotateNodeAddress() { + if (torRotating.value) return + if (!confirm('This will generate a new .onion address. The old address will work for 24 hours during transition. Federated peers will be notified automatically.')) return + torRotating.value = true + try { + await rpcClient.torRotateService('archipelago') + await loadTorServices() + } catch (e) { + if (import.meta.env.DEV) console.warn('Failed to rotate Tor address:', e) + } finally { + torRotating.value = false + } +} + async function loadBackups() { loadingBackups.value = true try {