security+feat: v1.3.0 — pentest remediation, container reliability, UI overhaul

Security (33 pentest findings addressed):
- CRITICAL: backend binds 127.0.0.1, path traversal in tor.rs/dwn fixed
- HIGH: federation requires signatures, XSS login redirect, RBAC viewer restricted
- HIGH: tar slip prevention, S3 SSRF validation, backup ID validation
- MEDIUM: remember-me random secret, TOTP session rotation, password re-auth
- LOW: CSP unsafe-inline removed, CORS dev-only, onion/webhook validation

Container reliability:
- Memory limits on all 37 containers (OOM prevention)
- Exited vs stopped state distinction with health-aware status badges
- Crash recovery coordination (no more restart cascade)
- User-stopped tracking survives reboots
- Tiered boot recovery (databases → core → services → apps)

UI:
- Wallet TransactionsModal, health-aware app status badges
- Restart button on containers, exited/crashed red state
- Mesh view overhaul, glass button updates, BaseModal/ToggleSwitch
- Apps sticky header removed, dev faucet, mutable mock wallet

Infrastructure:
- LND REST port 8080 exposed over Tor (LND Connect fix)
- Nginx cookie_session fix, deploy script Tor config updated
- Dev environment: podman auto-start, boot mode simulation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-19 12:44:31 +00:00
co-authored by Claude Opus 4.6
parent d1b48388fb
commit 1a74a930f7
77 changed files with 2485 additions and 966 deletions
+78 -33
View File
@@ -40,7 +40,7 @@
</div>
<!-- Loading Skeleton -->
<div v-if="!store.isConnected && sortedPackageEntries.length === 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 pb-6">
<div v-if="!store.isConnected && sortedPackageEntries.length === 0 && !connectionError" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 pb-6">
<div v-for="i in 3" :key="i" class="glass-card p-6 animate-pulse">
<div class="flex items-start gap-4">
<div class="w-16 h-16 rounded-lg bg-white/10"></div>
@@ -56,6 +56,19 @@
</div>
</div>
<!-- Connection Error -->
<div v-else-if="connectionError && sortedPackageEntries.length === 0" class="text-center py-12 pb-6">
<div class="glass-card p-8 max-w-md mx-auto">
<div class="alert-error mb-4">{{ connectionError }}</div>
<button
@click="connectionError = ''; store.connectWebSocket()"
class="glass-button px-6 py-3 rounded-lg font-medium"
>
Retry Connection
</button>
</div>
</div>
<!-- Empty State -->
<div v-else-if="sortedPackageEntries.length === 0 && !searchQuery" class="text-center py-16 pb-6">
<div class="glass-card p-12 max-w-md mx-auto">
@@ -136,10 +149,10 @@
<div class="flex items-center gap-2">
<span
class="inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium"
:class="getStatusClass(pkg.state)"
:class="getStatusClass(pkg.state, pkg.health)"
>
<svg
v-if="pkg.state === 'starting' || pkg.state === 'installing' || pkg.state === 'stopping' || pkg.state === 'restarting'"
v-if="pkg.state === 'starting' || pkg.state === 'installing' || pkg.state === 'stopping' || pkg.state === 'restarting' || (pkg.state === 'running' && pkg.health === 'starting')"
class="animate-spin h-3 w-3"
xmlns="http://www.w3.org/2000/svg"
fill="none"
@@ -148,7 +161,8 @@
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ pkg.state }}
<span v-if="pkg.state === 'running' && pkg.health === 'unhealthy'" class="w-1.5 h-1.5 rounded-full bg-orange-400 animate-pulse"></span>
{{ getStatusLabel(pkg.state, pkg.health) }}
</span>
<span class="text-xs text-white/50">
v{{ pkg.manifest.version }}
@@ -171,14 +185,14 @@
<button
v-if="!isWebOnlyApp(id as string) && !loadingActions[id as string] && (pkg.state === 'stopped' || pkg.state === 'exited')"
@click.stop="startApp(id as string)"
class="flex-1 px-4 py-2 bg-green-500/20 border border-green-500/40 rounded-lg text-green-200 text-sm font-medium hover:bg-green-500/30 transition-colors flex items-center justify-center gap-2"
class="flex-1 px-4 py-2 glass-button glass-button-success rounded-lg text-sm font-medium flex items-center justify-center gap-2"
>
<span>{{ t('common.start') }}</span>
<span>{{ pkg.state === 'exited' ? 'Restart' : t('common.start') }}</span>
</button>
<button
v-if="!isWebOnlyApp(id as string) && loadingActions[id as string] && (pkg.state === 'stopped' || pkg.state === 'exited' || pkg.state === 'starting')"
disabled
class="flex-1 px-4 py-2 bg-green-500/20 border border-green-500/40 rounded-lg text-green-200 text-sm font-medium opacity-50 cursor-not-allowed flex items-center justify-center gap-2"
class="flex-1 px-4 py-2 glass-button glass-button-success rounded-lg text-sm font-medium opacity-50 cursor-not-allowed flex items-center justify-center gap-2"
>
<svg class="animate-spin h-4 w-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
@@ -193,6 +207,16 @@
>
<span>{{ t('common.stop') }}</span>
</button>
<button
v-if="!isWebOnlyApp(id as string) && !loadingActions[id as string] && (pkg.state === 'running' || pkg.state === 'starting')"
@click.stop="restartApp(id as string)"
class="px-2.5 py-2 glass-button glass-button-sm rounded-lg flex items-center justify-center"
:title="t('common.restart')"
>
<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>
</button>
<button
v-if="!isWebOnlyApp(id as string) && loadingActions[id as string] && (pkg.state === 'running' || pkg.state === 'starting' || pkg.state === 'stopping')"
disabled
@@ -249,7 +273,7 @@
<button
@click="confirmUninstall"
:disabled="uninstalling"
class="px-4 py-2 bg-red-600/80 hover:bg-red-600 rounded-lg text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
class="px-4 py-2 glass-button glass-button-danger rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
<svg
v-if="uninstalling"
@@ -274,7 +298,7 @@
<!-- Action error toast -->
<Transition name="fade">
<div v-if="actionError" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4" role="alert" aria-live="assertive">
<div class="bg-red-500/20 border border-red-500/40 backdrop-blur-sm rounded-lg px-4 py-3 text-red-200 text-sm flex items-center justify-between gap-3">
<div class="alert-error backdrop-blur-sm rounded-lg px-4 py-3 text-sm flex items-center justify-between gap-3">
<span>{{ actionError }}</span>
<button @click="actionError = ''" :aria-label="t('apps.dismissError')" class="text-red-300 hover:text-white shrink-0">&times;</button>
</div>
@@ -284,7 +308,7 @@
</template>
<script setup lang="ts">
import { computed, ref, onBeforeUnmount } from 'vue'
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import { useRouter, useRoute, RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '../stores/app'
@@ -377,6 +401,20 @@ const categoriesWithApps = computed(() => {
})
// Connection error state — show after timeout if backend never connects
const connectionError = ref('')
let connectionTimer: ReturnType<typeof setTimeout> | undefined
onMounted(() => {
if (!store.isConnected) {
connectionTimer = setTimeout(() => {
if (!store.isConnected && sortedPackageEntries.value.length === 0) {
connectionError.value = 'Unable to connect to server. Check that the backend is running.'
}
}, 15000)
}
})
// Track loading states for each app action
const loadingActions = ref<Record<string, boolean>>({})
@@ -523,12 +561,16 @@ function launchApp(id: string) {
useAppLauncherStore().openSession(id)
}
function getStatusClass(state: PackageState): string {
function getStatusClass(state: PackageState, health?: string | null): string {
if (state === PackageState.Running && health === 'starting') return 'bg-yellow-500/20 text-yellow-200'
if (state === PackageState.Running && health === 'unhealthy') return 'bg-orange-500/20 text-orange-200'
switch (state) {
case PackageState.Running:
return 'bg-green-500/20 text-green-200'
case PackageState.Stopped:
return 'bg-gray-500/20 text-gray-200'
case PackageState.Exited:
return 'bg-red-500/20 text-red-200'
case PackageState.Starting:
case PackageState.Stopping:
case PackageState.Restarting:
@@ -540,6 +582,14 @@ function getStatusClass(state: PackageState): string {
}
}
function getStatusLabel(state: PackageState, health?: string | null): string {
if (state === PackageState.Running && health === 'starting') return 'starting up'
if (state === PackageState.Running && health === 'unhealthy') return 'unhealthy'
if (state === PackageState.Running && health === 'healthy') return 'healthy'
if (state === PackageState.Exited) return 'crashed'
return state
}
function goToApp(id: string) {
router.push(`/dashboard/apps/${id}`).catch(() => {})
}
@@ -578,9 +628,26 @@ async function stopApp(id: string) {
}
}
async function restartApp(id: string) {
loadingActions.value[id] = true
try {
await store.restartPackage(id)
if (actionTimers.has(id)) clearTimeout(actionTimers.get(id)!)
actionTimers.set(id, setTimeout(() => {
loadingActions.value[id] = false
actionTimers.delete(id)
}, 8000))
} catch (err) {
if (import.meta.env.DEV) console.error('Failed to restart app:', err)
showActionError(`Failed to restart app: ${err instanceof Error ? err.message : 'Unknown error'}`)
loadingActions.value[id] = false
}
}
onBeforeUnmount(() => {
for (const t of actionTimers.values()) clearTimeout(t)
actionTimers.clear()
if (connectionTimer) clearTimeout(connectionTimer)
})
@@ -638,25 +705,3 @@ function handleImageError(e: Event) {
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.3s ease;
}
.modal-enter-active .glass-card,
.modal-leave-active .glass-card {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-from .glass-card,
.modal-leave-to .glass-card {
transform: scale(0.95);
opacity: 0;
}
</style>