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 <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-03-13 00:13:38 +00:00
parent e74bf9bcfd
commit 98da9b2b4c
4 changed files with 152 additions and 1 deletions

View File

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

View File

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

View File

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

View File

@ -572,6 +572,52 @@
</div>
</div>
<!-- Tor Services Section -->
<div class="glass-card px-6 py-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-xl font-semibold text-white/96">Tor Services</h2>
<p class="text-sm text-white/60 mt-1">Manage hidden service addresses for your node and apps</p>
</div>
<button @click="loadTorServices" class="glass-button px-4 py-2 rounded-lg text-sm flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Refresh
</button>
</div>
<div v-if="torLoading" class="text-sm text-white/40 py-4 text-center">Loading Tor services...</div>
<div v-else-if="torServices.length === 0" class="text-sm text-white/40 py-4 text-center">No Tor services configured</div>
<div v-else class="space-y-2">
<div v-for="svc in torServices" :key="svc.name" class="bg-black/20 rounded-xl border border-white/10 p-3 flex items-center justify-between gap-3">
<div class="flex-1 min-w-0">
<p class="text-white text-sm font-medium">{{ svc.name }}</p>
<p v-if="svc.onion_address" class="text-amber-300/80 text-xs font-mono truncate">{{ svc.onion_address }}</p>
<p v-else class="text-white/30 text-xs">No .onion address</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<button
v-if="svc.name === 'archipelago'"
@click="rotateNodeAddress"
:disabled="torRotating"
class="glass-button px-3 py-1.5 rounded-lg text-xs"
>
{{ torRotating ? 'Rotating...' : 'Rotate' }}
</button>
<label class="tor-toggle-label">
<input
type="checkbox"
:checked="svc.enabled"
@change="toggleTorApp(svc.name, !svc.enabled)"
class="tor-toggle-input"
/>
<span class="tor-toggle-slider"></span>
</label>
</div>
</div>
</div>
</div>
<!-- Webhook Notifications Section -->
<div class="glass-card px-6 py-6 mb-6">
<div class="flex items-center justify-between mb-4">
@ -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<string | null>(null)
const deletingBackupId = ref<string | null>(null)
// Tor services state
interface TorServiceInfo {
name: string
local_port: number
onion_address: string | null
enabled: boolean
}
const torServices = ref<TorServiceInfo[]>([])
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 {