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
parent 4c0c8a83a9
commit fc1120338d
15 changed files with 904 additions and 250 deletions

View File

@@ -60,9 +60,12 @@ async function loadArchMessages() {
} catch { /* silent */ }
}
const sendingArch = ref(false)
async function sendArchMessage() {
if (!messageText.value.trim()) return
sendError.value = ''
sendingArch.value = true
try {
// Broadcast to all federated peers over Tor
const nodes = await rpcClient.federationListNodes()
@@ -74,12 +77,20 @@ async function sendArchMessage() {
sent++
} catch { /* some peers may be offline */ }
}
// Local echo — show the sent message immediately
archMessages.value.push({
from_pubkey: 'me',
message: msg,
timestamp: new Date().toISOString(),
})
messageText.value = ''
if (sent === 0) sendError.value = 'No peers reachable'
// Reload to see the message
setTimeout(loadArchMessages, 2000)
if (sent === 0) sendError.value = 'No peers reachable — message may arrive when they come online'
// Also reload in background to pick up any replies
setTimeout(loadArchMessages, 5000)
} catch (e) {
sendError.value = e instanceof Error ? e.message : 'Send failed'
} finally {
sendingArch.value = false
}
}
@@ -314,8 +325,8 @@ const chatMessages = computed(() => {
return archMessages.value.map((m, i) => ({
id: i,
peer_contact_id: -99,
peer_name: m.from_pubkey.slice(0, 12) + '...',
direction: 'received' as const,
peer_name: m.from_pubkey === 'me' ? 'You' : (m.from_pubkey.slice(0, 12) + '...'),
direction: (m.from_pubkey === 'me' ? 'sent' : 'received') as 'sent' | 'received',
plaintext: m.message,
timestamp: m.timestamp,
delivered: true,
@@ -747,10 +758,10 @@ function truncatePubkey(hex: string | null): string {
/>
<button
class="glass-button mesh-chat-send-btn"
:disabled="!messageText.trim() || mesh.sending"
:disabled="!messageText.trim() || mesh.sending || sendingArch"
@click="handleSendMessage"
>
{{ mesh.sending ? '...' : 'Send' }}
{{ (mesh.sending || sendingArch) ? '...' : 'Send' }}
</button>
</div>
</div>

View File

@@ -31,7 +31,8 @@
</div>
<div class="hidden md:block">
<h1 class="text-2xl font-bold text-white">{{ peerDisplayName }}</h1>
<p class="text-sm text-white/50">{{ currentPeer?.onion || 'Peer files' }}</p>
<p v-if="currentPeer?.did" class="text-sm text-white/50 font-mono truncate max-w-md" :title="currentPeer.did">{{ currentPeer.did }}</p>
<p v-else class="text-sm text-white/50">Peer files</p>
</div>
</div>
</div>

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

View File

@@ -1125,6 +1125,8 @@ async function saveServerName() {
}
try {
await rpcClient.call({ method: 'server.set-name', params: { name } })
// Optimistically update the store so UI reflects the change immediately
store.updateServerName(name)
} catch (e) {
if (import.meta.env.DEV) console.error('Failed to rename server:', e)
}

View File

@@ -156,7 +156,7 @@
Nodes
</button>
<button
@click="showSendMessageModal = true"
@click="router.push('/dashboard/mesh')"
class="flex-1 px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
Message
@@ -678,7 +678,7 @@
</div>
<!-- Peers tab -->
<div v-show="nodesContainerTab === 'peers'" class="space-y-2 max-h-48 overflow-y-auto">
<div v-show="nodesContainerTab === 'peers'" class="space-y-2 flex-1 overflow-y-auto">
<div v-if="peers.length === 0" class="p-4 text-center text-white/60 text-sm">
{{ t('web5.noPeers') }}
</div>
@@ -695,7 +695,7 @@
</div>
</div>
<button
@click="showSendMessageModal = true; sendMessageTo = p.onion"
@click="router.push('/dashboard/mesh')"
class="px-2 py-1 text-xs rounded bg-orange-500/20 text-orange-400 hover:bg-orange-500/30 transition-colors shrink-0"
>
{{ t('web5.message') }}
@@ -704,7 +704,7 @@
</div>
<!-- Messages tab -->
<div v-show="nodesContainerTab === 'messages'" class="space-y-2 max-h-64 overflow-y-auto">
<div v-show="nodesContainerTab === 'messages'" class="space-y-2 flex-1 overflow-y-auto">
<div v-if="loadingMessages" class="p-4 text-center text-white/60 text-sm">
{{ t('common.loading') }}
</div>
@@ -717,7 +717,7 @@
class="p-3 bg-white/5 rounded-lg border-l-2 border-orange-500/50"
>
<div class="flex items-center justify-between gap-2 mb-1">
<p class="text-xs font-mono text-white/60 truncate" :title="m.from_pubkey">{{ (m.from_pubkey || '').slice(0, 16) }}...</p>
<p class="text-xs font-mono text-white/60 truncate" :title="m.from_pubkey">{{ peerNameFromPubkey(m.from_pubkey) }}</p>
<span class="text-xs text-white/40 shrink-0">{{ formatMessageTime(m.timestamp) }}</span>
</div>
<p class="text-sm text-white/90 break-words">{{ m.message }}</p>
@@ -725,7 +725,7 @@
</div>
<!-- Requests tab -->
<div v-show="nodesContainerTab === 'requests'" class="space-y-2 max-h-64 overflow-y-auto">
<div v-show="nodesContainerTab === 'requests'" class="space-y-2 flex-1 overflow-y-auto">
<div v-if="loadingRequests" class="p-4 text-center text-white/60 text-sm">
{{ t('common.loading') }}
</div>
@@ -739,7 +739,7 @@
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<p class="text-xs font-mono text-white/70 truncate" :title="req.from_did">{{ req.from_did }}</p>
<p class="text-xs font-mono text-white/70 truncate" :title="req.from_did">{{ peerNameFromPubkey(req.from_did) }}</p>
<p v-if="req.message" class="text-sm text-white/80 mt-1 break-words">{{ req.message }}</p>
<p class="text-xs text-white/40 mt-1">{{ formatMessageTime(req.created_at) }}</p>
</div>
@@ -2647,6 +2647,12 @@ const peerReachableLocal = ref<Record<string, boolean>>({})
const peerReachable = computed(() => ({ ...appStore.peerHealth, ...peerReachableLocal.value }))
const connectedNodesCount = computed(() => peers.value.length)
function peerNameFromPubkey(pubkey: string): string {
const peer = peers.value.find(p => p.pubkey === pubkey || p.onion === pubkey)
if (peer?.name) return peer.name
return (pubkey || '').slice(0, 16) + '...'
}
// Hardware wallet detection
interface HwWalletDevice {
type: string