fix: Tor management system, bug fixes, federation name sync

Major changes:
- Full Tor hidden service management via systemd path unit pattern
  (tor-helper.sh + archipelago-tor-helper.path/service) — respects
  NoNewPrivileges=yes, no sudo needed from backend
- Container doctor: prefer system Tor over container, remove archy-tor
- Deploy script: fix torrc generation (read correct services.json path),
  web apps map port 80→local port, enable both tor and tor@default
- Federation: server rename pushes name to peers via background sync
- Server name: fix root-owned file, optimistic store update
- Mesh: local echo for sent messages, sendingArch loading state
- Web5: Message button → Mesh redirect, node name lookup in messages
- PeerFiles: show DID not onion in header
- Connected Nodes: flex-1 instead of fixed max-h
- Toast notifications route to Mesh
- Deploy script: fix single-quote syntax in SSH block

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-20 02:59:29 +00:00
co-authored by Claude Opus 4.6
parent f7872e2914
commit cffcc9f665
15 changed files with 904 additions and 250 deletions
+220 -25
View File
@@ -275,7 +275,7 @@
</div>
</div>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-6">
<div class="grid grid-cols-1 2xl:grid-cols-2 gap-6 mb-6">
<!-- Network Interfaces -->
<div class="glass-card p-6">
<div class="flex items-center justify-between mb-4">
@@ -326,8 +326,8 @@
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="relative shrink-0">
<div class="w-3 h-3 rounded-full" :class="torRunning ? 'bg-green-400' : 'bg-red-400'"></div>
<div v-if="torRunning" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
<div class="w-3 h-3 rounded-full" :class="torDaemonRunning ? 'bg-green-400' : 'bg-red-400'"></div>
<div v-if="torDaemonRunning" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
</div>
<div>
<h2 class="text-xl font-semibold text-white/96">Tor Services</h2>
@@ -335,17 +335,17 @@
</div>
</div>
<div class="flex items-center gap-2">
<button @click="cleanupRotatedServices" :disabled="torCleaning" 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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
{{ torCleaning ? 'Cleaning...' : 'Cleanup Old' }}
</button>
<button @click="loadTorServices" class="glass-button px-4 py-2 rounded-lg text-sm flex items-center gap-2">
<button @click="restartTor" :disabled="torRestarting" class="glass-button px-3 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
{{ torRestarting ? 'Restarting...' : 'Restart Tor' }}
</button>
<button @click="showAddServiceModal = true" class="glass-button px-3 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="M12 4v16m8-8H4" />
</svg>
Add Service
</button>
</div>
</div>
@@ -354,9 +354,16 @@
<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>
<div class="flex items-center gap-2">
<p class="text-white text-sm font-medium">{{ svc.name }}</p>
<span class="text-white/30 text-xs">:{{ svc.local_port }}</span>
<span v-if="svc.protocol" class="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400">protocol</span>
<span v-else-if="!svc.unauthenticated" class="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">auth</span>
<span v-else class="text-xs px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400">open</span>
</div>
<p v-if="svc.onion_address" class="text-amber-300/80 text-xs font-mono truncate cursor-pointer" :title="svc.onion_address" @click="copyTorAddress(svc.onion_address)">{{ svc.onion_address }}</p>
<p v-else class="text-white/30 text-xs">No .onion address</p>
<p v-else-if="svc.enabled" class="text-white/30 text-xs">Waiting for .onion address...</p>
<p v-else class="text-white/30 text-xs">Disabled</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<button
@@ -367,11 +374,116 @@
>
{{ torRotating === svc.name ? 'Rotating...' : 'Rotate' }}
</button>
<button
v-if="svc.name !== 'archipelago'"
@click="deleteService(svc.name)"
:disabled="torDeleting === svc.name"
class="glass-button px-2 py-1.5 rounded-lg text-xs text-red-400 hover:text-red-300"
:title="'Delete ' + svc.name + ' hidden service'"
>
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
<ToggleSwitch :model-value="svc.enabled" @update:model-value="toggleTorApp(svc.name, $event)" />
</div>
</div>
</div>
</div>
<!-- Add Tor Service Modal -->
<Teleport to="body">
<div v-if="showAddServiceModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="showAddServiceModal = false" @keydown.escape="showAddServiceModal = false">
<div class="glass-card p-6 max-w-md w-full">
<h3 class="text-lg font-semibold text-white mb-4">Add Tor Hidden Service</h3>
<!-- Tabs: Installed Apps | Manual -->
<div class="flex gap-1 mb-4 border-b border-white/10">
<button
@click="addServiceTab = 'apps'"
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
:class="addServiceTab === 'apps' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
>
Installed Apps
</button>
<button
@click="addServiceTab = 'manual'"
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
:class="addServiceTab === 'manual' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
>
Manual
</button>
</div>
<!-- Installed Apps tab -->
<div v-if="addServiceTab === 'apps'">
<p class="text-white/60 text-sm mb-3">Select an installed app to create a .onion address for it.</p>
<div v-if="availableAppsForTor.length === 0" class="p-4 text-center text-white/40 text-sm">
All installed apps already have Tor services.
</div>
<div v-else class="space-y-2 max-h-64 overflow-y-auto">
<button
v-for="app in availableAppsForTor"
:key="app.id"
@click="createServiceForApp(app.id)"
:disabled="addingService"
class="w-full flex items-center justify-between p-3 bg-white/5 rounded-lg hover:bg-white/10 transition-colors text-left"
>
<div class="min-w-0">
<p class="text-sm font-medium text-white">{{ app.title }}</p>
<p class="text-xs text-white/40">{{ app.id }}</p>
</div>
<span class="text-xs text-orange-400 shrink-0">+ Add</span>
</button>
</div>
</div>
<!-- Manual tab -->
<div v-if="addServiceTab === 'manual'">
<p class="text-white/60 text-sm mb-3">Create a .onion address for any local service.</p>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-white/80 mb-1">Service Name</label>
<input
v-model="newServiceName"
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
placeholder="my-app"
maxlength="64"
/>
</div>
<div>
<label class="block text-sm font-medium text-white/80 mb-1">Local Port</label>
<input
v-model.number="newServicePort"
type="number"
min="1"
max="65535"
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
placeholder="8080"
/>
</div>
</div>
<div class="flex gap-3 mt-6">
<button
@click="createService"
:disabled="!newServiceName.trim() || !newServicePort || addingService"
class="flex-1 px-4 py-2 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{{ addingService ? 'Creating...' : 'Create Service' }}
</button>
<button
@click="showAddServiceModal = false"
class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors"
>
Cancel
</button>
</div>
</div>
<p v-if="addServiceError" class="mt-3 text-sm text-red-400">{{ addServiceError }}</p>
</div>
</div>
</Teleport>
</div>
<!-- WiFi Scan Modal -->
@@ -511,8 +623,11 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import { useAppStore } from '@/stores/app'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
const appStore = useAppStore()
// Connected nodes
const connectedNodes = ref(0)
@@ -797,28 +912,52 @@ interface TorServiceInfo {
local_port: number
onion_address: string | null
enabled: boolean
unauthenticated: boolean
protocol: boolean
}
const torServices = ref<TorServiceInfo[]>([])
const torServicesLoading = ref(false)
const torCleaning = ref(false)
const torDaemonRunning = ref(false)
const torRestarting = ref(false)
const torRotating = ref<string | false>(false)
const torDeleting = ref<string | false>(false)
const torRunning = computed(() => torServices.value.length > 0 && torServices.value.some(s => s.onion_address))
// Add service modal
const showAddServiceModal = ref(false)
const addServiceTab = ref<'apps' | 'manual'>('apps')
const newServiceName = ref('')
const newServicePort = ref<number | null>(null)
const addingService = ref(false)
const addServiceError = ref('')
// Installed apps that don't already have a Tor service
const availableAppsForTor = computed(() => {
const existingNames = new Set(torServices.value.map(s => s.name))
const pkgs = appStore.packages
return Object.entries(pkgs)
.filter(([id]) => !existingNames.has(id))
.map(([id, pkg]) => ({
id,
title: (pkg as { manifest?: { title?: string } })?.manifest?.title || id,
}))
.sort((a, b) => a.title.localeCompare(b.title))
})
async function loadTorServices() {
torServicesLoading.value = true
try {
const res = await rpcClient.call<{ services: TorServiceInfo[] }>({ method: 'tor.list-services' })
const res = await rpcClient.call<{ services: TorServiceInfo[]; tor_running: boolean }>({ method: 'tor.list-services' })
torServices.value = res.services || []
torDaemonRunning.value = res.tor_running ?? false
} catch {
torServices.value = []
torDaemonRunning.value = false
} finally {
torServicesLoading.value = false
}
}
const torRotating = ref<string | false>(false)
function copyTorAddress(address: string) {
navigator.clipboard.writeText(address)
logsToast.value = 'Onion address copied to clipboard'
@@ -827,7 +966,7 @@ function copyTorAddress(address: string) {
async function toggleTorApp(appId: string, enabled: boolean) {
try {
await rpcClient.call({ method: 'tor.toggle-app', params: { app_id: appId, enabled } })
await rpcClient.call({ method: 'tor.toggle-app', params: { app_id: appId, enabled }, timeout: 90000 })
await loadTorServices()
} catch (e) {
if (import.meta.env.DEV) console.warn('Failed to toggle Tor app:', e)
@@ -837,7 +976,7 @@ async function toggleTorApp(appId: string, enabled: boolean) {
async function rotateService(name: string) {
torRotating.value = name
try {
await rpcClient.call({ method: 'tor.rotate-service', params: { name } })
await rpcClient.call({ method: 'tor.rotate-service', params: { name }, timeout: 90000 })
await loadTorServices()
} catch (e) {
if (import.meta.env.DEV) console.warn('Failed to rotate Tor address:', e)
@@ -846,15 +985,71 @@ async function rotateService(name: string) {
}
}
async function cleanupRotatedServices() {
torCleaning.value = true
async function restartTor() {
torRestarting.value = true
try {
await rpcClient.call({ method: 'tor.cleanup-rotated' })
await rpcClient.call({ method: 'tor.restart', timeout: 90000 })
await loadTorServices()
logsToast.value = 'Tor restarted successfully'
setTimeout(() => { logsToast.value = '' }, 3000)
} catch (e) {
if (import.meta.env.DEV) console.warn('Failed to cleanup rotated Tor services:', e)
logsToast.value = 'Failed to restart Tor'
setTimeout(() => { logsToast.value = '' }, 5000)
if (import.meta.env.DEV) console.warn('Failed to restart Tor:', e)
} finally {
torCleaning.value = false
torRestarting.value = false
}
}
async function createServiceForApp(appId: string) {
addServiceError.value = ''
addingService.value = true
try {
// Backend knows the port from known_service_port() — pass 0 to let it auto-detect
await rpcClient.call({ method: 'tor.create-service', params: { name: appId, local_port: 0 }, timeout: 90000 })
showAddServiceModal.value = false
await loadTorServices()
logsToast.value = `Tor service for "${appId}" created`
setTimeout(() => { logsToast.value = '' }, 3000)
} catch (e) {
addServiceError.value = e instanceof Error ? e.message : 'Failed to create service'
} finally {
addingService.value = false
}
}
async function createService() {
const name = newServiceName.value.trim()
const port = newServicePort.value
if (!name || !port) return
addServiceError.value = ''
addingService.value = true
try {
await rpcClient.call({ method: 'tor.create-service', params: { name, local_port: port }, timeout: 90000 })
showAddServiceModal.value = false
newServiceName.value = ''
newServicePort.value = null
await loadTorServices()
logsToast.value = `Tor service "${name}" created`
setTimeout(() => { logsToast.value = '' }, 3000)
} catch (e) {
addServiceError.value = e instanceof Error ? e.message : 'Failed to create service'
} finally {
addingService.value = false
}
}
async function deleteService(name: string) {
torDeleting.value = name
try {
await rpcClient.call({ method: 'tor.delete-service', params: { name }, timeout: 90000 })
await loadTorServices()
logsToast.value = `Tor service "${name}" deleted`
setTimeout(() => { logsToast.value = '' }, 3000)
} catch (e) {
if (import.meta.env.DEV) console.warn('Failed to delete Tor service:', e)
} finally {
torDeleting.value = false
}
}