feat(ui): SSH-over-mesh card, store-listing filter, icon treatment
Demo images / Build & push demo images (push) Failing after 41s
Demo images / Build & push demo images (push) Failing after 41s
Settings gains the SSH-over-mesh card (danger-zone confirmation for the any-peer scope, sshd preflights, fipssh copy hint). The signed-catalog merge filters components via the shared serviceNames module; Discover grids get the standard icon container; install no longer yanks the user to My Apps; v1.8.8 release notes.
This commit is contained in:
@@ -1,5 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## v1.8.8-alpha (2026-09-01)
|
||||
|
||||
- **SSH over the mesh is now a first-class setting.** Settings gains an "SSH over mesh" card: off by default, and when you allow it the node's mesh firewall opens port 22 — either to every mesh peer (behind an explicit "I understand" confirmation, because that's a real exposure) or only to the mesh addresses you list. The rule is owned by the node (the `90-ssh.nft` drop-in), so it survives upgrades and daemon reinstalls, and the card tells you up front whether sshd is running, whether it listens on IPv6 (the mesh is IPv6-only — this is what a broken attempt looks like before it happens), and whether password login is on (keys-only is the recommended pairing). From Termux on your phone, `fipssh <user>@<node-npub>` connects once the toggle is on — the npub is the durable address, and the command is shown with a copy button on the card.
|
||||
|
||||
- **The App Store now lists apps — not parts of apps.** The signed catalog carries every manifest because the node's update layer needs their pins, and the store briefly listed them all: Mempool API, LND UI, Bitcoin UI, the Pine voice engines, the IndeeHub and Immich backends, the mesh router and friends. Components are hidden from the store listing (they still appear where they belong — the Services tab of My Apps, once installed), and four entries that never earned a tile are gone outright: MorphOS server (old), the Web5 DID wallet, Lightning Stack (an untracked upstream bundle — LND covers the need), and CryptPad (never tested).
|
||||
|
||||
- **App icons now persist everywhere, in the proper container style.** Two fixes: installed apps render the icon from their own manifest — Cuprate no longer falls back to the generic A-mark on its Services tile — and the store grids (the Discover page) apply the same icon container treatment (backdrop, border, shadow) as My Apps, the detail pages, and Home. Manifest-declared UI apps also classify correctly again: Alby Hub installs into My Apps with a working tile, not into Services, because a probe miss no longer buries an app the manifest itself says has a frontend.
|
||||
|
||||
- **Installing from the store keeps you on the store page.** The install progress lives on the tile itself and the app appears in My Apps when it lands — no more being yanked to My Apps mid-browse.
|
||||
|
||||
## v1.8.7-alpha (2026-08-31)
|
||||
|
||||
- **What's New really does stop at v1.8.0 now.** The first correction removed old generated release blocks but missed six much older hand-written v1.2 sections at the bottom of the modal. Those sections are gone, and the release check now recognizes and rejects that legacy format too, so the history floor cannot falsely pass again.
|
||||
|
||||
@@ -515,8 +515,10 @@ async function installApp(app: MarketplaceApp) {
|
||||
}
|
||||
|
||||
queueInstall(app)
|
||||
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps")
|
||||
router.push('/dashboard/apps').catch(() => {})
|
||||
// Stay on the store page: the tile itself shows install progress via the
|
||||
// global watcher, and a forced jump to My Apps yanked the user out of the
|
||||
// page they were deliberately browsing.
|
||||
toast.info("Installing " + (app.title ?? app.id) + " — it will appear in My Apps")
|
||||
|
||||
try {
|
||||
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
|
||||
@@ -540,8 +542,8 @@ async function installCommunityApp(app: MarketplaceApp) {
|
||||
}
|
||||
|
||||
queueInstall(app)
|
||||
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps")
|
||||
router.push('/dashboard/apps').catch(() => {})
|
||||
// Stay on the store page (see installApp).
|
||||
toast.info("Installing " + (app.title ?? app.id) + " — it will appear in My Apps")
|
||||
|
||||
try {
|
||||
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: app.version }
|
||||
|
||||
@@ -6,33 +6,17 @@ import { PackageState, type PackageDataEntry } from '@/types/api'
|
||||
import { matchPageScheme, resolveAppUrl } from '../appSession/appSessionConfig'
|
||||
import { portIsGateFronted } from '../discover/curatedApps'
|
||||
import { isAutoTabApp } from '@/utils/autoTabApps'
|
||||
import {
|
||||
SERVICE_NAMES as SHARED_SERVICE_NAMES,
|
||||
isServiceContainer as sharedIsServiceContainer,
|
||||
} from './serviceNames'
|
||||
|
||||
export type AppsTab = 'apps' | 'websites' | 'services'
|
||||
|
||||
// Service container name patterns (backend/infra, not user-facing)
|
||||
export const SERVICE_NAMES = new Set([
|
||||
'dwn', 'archy-mempool-db', 'archy-btcpay-db', 'archy-nbxplorer', 'archy-tor',
|
||||
// Headless backends with no user-facing UI: the Fedimint ecash client daemon,
|
||||
// the Nostr relay, and the Meshtastic LoRa daemon (its chat UI lives in the
|
||||
// built-in Mesh tab) belong in Services, not My Apps.
|
||||
'fedimint-clientd', 'nostr-rs-relay', 'meshtastic',
|
||||
'immich_postgres', 'immich_redis',
|
||||
// immich is now a manifest-driven stack (app_id-named, hyphen). The server is
|
||||
// the launcher app; postgres/redis are backends → Services.
|
||||
'immich-postgres', 'immich-redis',
|
||||
'mysql-mempool', 'mempool-api', 'archy-mempool-web',
|
||||
'archy-bitcoin-ui', 'archy-lnd-ui', 'archy-electrs-ui',
|
||||
'bitcoin-ui', 'lnd-ui', 'electrs-ui',
|
||||
'indeedhub-postgres', 'indeedhub-redis', 'indeedhub-minio',
|
||||
'indeedhub-api', 'indeedhub-ffmpeg',
|
||||
'indeedhub-relay', 'indeedhub-build_api_1', 'indeedhub-build_ffmpeg-worker_1',
|
||||
'indeedhub-build_postgres_1', 'indeedhub-build_redis_1', 'indeedhub-build_minio_1',
|
||||
'indeedhub-build_minio-init_1', 'indeedhub-build_relay_1',
|
||||
// Pine voice-assistant stack: the two Wyoming engines are backends (STT/TTS)
|
||||
// reached by Home Assistant over host.containers.internal — the user-facing
|
||||
// card is "pine" (the setup/status launcher), so the engines go to Services.
|
||||
'pine-whisper', 'pine-piper', 'pine-openwakeword',
|
||||
])
|
||||
// Re-exported for every existing caller — the canon moved to serviceNames.ts
|
||||
// so the App Store's catalog merge can share it without a circular import.
|
||||
export const SERVICE_NAMES = SHARED_SERVICE_NAMES
|
||||
export const isServiceContainer = sharedIsServiceContainer
|
||||
|
||||
const INTERNAL_TOOLING_NAMES = new Set([
|
||||
'buildx_buildkit_default',
|
||||
@@ -43,16 +27,7 @@ export function isInternalToolingPackage(id: string, pkg?: PackageDataEntry): bo
|
||||
return INTERNAL_TOOLING_NAMES.has(id) || INTERNAL_TOOLING_NAMES.has(manifestId) || id.startsWith('buildx_buildkit') || manifestId.startsWith('buildx_buildkit')
|
||||
}
|
||||
|
||||
export function isServiceContainer(id: string): boolean {
|
||||
if (SERVICE_NAMES.has(id)) return true
|
||||
if (id.startsWith('indeedhub-build_')) return true
|
||||
if (id.startsWith('archy-')) return true
|
||||
// Backend naming patterns that never carry a user-facing UI: databases and
|
||||
// caches. Safe to classify by suffix (a database is never a launcher).
|
||||
if (/-(db|postgres|postgresql|redis|valkey|mariadb|mysql|cache)$/.test(id)) return true
|
||||
if (id.endsWith('_db')) return true
|
||||
return false
|
||||
}
|
||||
// isServiceContainer now re-exports the shared canon (serviceNames.ts).
|
||||
|
||||
export function isServicePackage(id: string, pkg?: PackageDataEntry): boolean {
|
||||
if (isServiceContainer(id)) return true
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Canonical internal/service app-id classification — the shared source for
|
||||
* every surface that must decide "is this a user-facing app or a component
|
||||
* of one": the My Apps/Services tabs (appsConfig) and the App Store listing
|
||||
* (the signed-catalog merge in discover/curatedApps must not publish
|
||||
* components as standalone store apps).
|
||||
*
|
||||
* Keep this module dependency-free: it is imported from both the apps view
|
||||
* and the catalog module (which must not import view code that reads the
|
||||
* store — circulars).
|
||||
*/
|
||||
|
||||
// Service container ids (backend/infra, not user-facing). Mirrors the
|
||||
// SERVICE_NAMES set that used to live in appsConfig.ts verbatim.
|
||||
export const SERVICE_NAMES = new Set([
|
||||
'dwn', 'archy-mempool-db', 'archy-btcpay-db', 'archy-nbxplorer', 'archy-tor',
|
||||
// Headless backends with no user-facing UI: the Fedimint ecash client daemon,
|
||||
// the Nostr relay, and the Meshtastic LoRa daemon (its chat UI lives in the
|
||||
// built-in Mesh tab) belong in Services, not My Apps.
|
||||
'fedimint-clientd', 'nostr-rs-relay', 'meshtastic',
|
||||
'immich_postgres', 'immich_redis',
|
||||
// immich is now a manifest-driven stack (app_id-named, hyphen). The server is
|
||||
// the launcher app; postgres/redis are backends → Services.
|
||||
'immich-postgres', 'immich-redis',
|
||||
'mysql-mempool', 'mempool-api', 'archy-mempool-web',
|
||||
'archy-bitcoin-ui', 'archy-lnd-ui', 'archy-electrs-ui',
|
||||
'bitcoin-ui', 'lnd-ui', 'electrs-ui',
|
||||
'indeedhub-postgres', 'indeedhub-redis', 'indeedhub-minio',
|
||||
'indeedhub-api', 'indeedhub-ffmpeg',
|
||||
'indeedhub-relay', 'indeedhub-build_api_1', 'indeedhub-build_ffmpeg-worker_1',
|
||||
'indeedhub-build_postgres_1', 'indeedhub-build_redis_1', 'indeedhub-build_minio_1',
|
||||
'indeedhub-build_minio-init_1', 'indeedhub-build_relay_1',
|
||||
// Pine voice-assistant stack: the two Wyoming engines are backends (STT/TTS)
|
||||
// reached by Home Assistant over host.containers.internal — the user-facing
|
||||
// card is "pine" (the setup/status launcher), so the engines go to Services.
|
||||
'pine-whisper', 'pine-piper', 'pine-openwakeword',
|
||||
])
|
||||
|
||||
// Node-bundled internals that are real manifests (so they surface in the
|
||||
// signed catalog's manifest list) but are not store apps: the assistant and
|
||||
// the bundled relay/UIs ship with the node itself, the DWN is internal
|
||||
// plumbing, and the mesh router + CLN are components of other surfaces.
|
||||
export const NODE_INTERNAL_IDS = new Set([
|
||||
'aiui', 'fips-ui', 'strfry', 'web5-dwn', 'router', 'core-lightning',
|
||||
])
|
||||
|
||||
/** Is this id a component/backend rather than a standalone user app? */
|
||||
export function isServiceContainer(id: string): boolean {
|
||||
if (SERVICE_NAMES.has(id)) return true
|
||||
if (id.startsWith('indeedhub-build_')) return true
|
||||
if (id.startsWith('archy-')) return true
|
||||
// Backend naming patterns that never carry a user-facing UI: databases and
|
||||
// caches. Safe to classify by suffix (a database is never a launcher).
|
||||
if (/-(db|postgres|postgresql|redis|valkey|mariadb|mysql|cache)$/.test(id)) return true
|
||||
if (id.endsWith('_db')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/** Should this app id appear in the App Store listing at all? */
|
||||
export function isStoreListedApp(id: string): boolean {
|
||||
return !isServiceContainer(id) && !NODE_INTERNAL_IDS.has(id)
|
||||
}
|
||||
@@ -40,7 +40,7 @@
|
||||
v-if="app.icon"
|
||||
:src="app.icon"
|
||||
:alt="app.title"
|
||||
class="w-14 h-14 rounded-lg object-cover"
|
||||
class="w-14 h-14 rounded-lg archy-app-icon"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div v-else class="w-14 h-14 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
v-if="app.icon"
|
||||
:src="app.icon"
|
||||
:alt="app.title"
|
||||
class="w-20 h-20 rounded-xl object-cover flex-shrink-0"
|
||||
class="w-20 h-20 rounded-xl archy-app-icon flex-shrink-0"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { MarketplaceApp } from './types'
|
||||
import { isStoreListedApp } from '../apps/serviceNames'
|
||||
|
||||
const R = 'source.archipelago-foundation.org/lfg2025'
|
||||
|
||||
@@ -48,10 +49,15 @@ export interface SignedAppEntry {
|
||||
}
|
||||
|
||||
/** Convert the signed catalog's keyed entries into store-listing apps.
|
||||
* Pure — unit-tested against the live catalog's shape (Cuprate). */
|
||||
* Pure — unit-tested against the live catalog's shape (Cuprate).
|
||||
* Components and node internals never enter the listing: the signed catalog
|
||||
* carries every manifest (including backends like mempool-api and the
|
||||
* bundled UIs) because the node's update layer needs their pins, but the
|
||||
* App Store publishes apps, not parts of apps. */
|
||||
export function signedCatalogToApps(catalog: SignedAppCatalog): MarketplaceApp[] {
|
||||
const out: MarketplaceApp[] = []
|
||||
for (const [id, entry] of Object.entries(catalog.apps || {})) {
|
||||
if (!isStoreListedApp(id)) continue
|
||||
const app = entry.manifest?.app
|
||||
out.push({
|
||||
id,
|
||||
@@ -200,7 +206,6 @@ export function getCuratedAppList(): MarketplaceApp[] {
|
||||
{ id: 'grafana', title: 'Grafana', version: '10.2.0', description: 'Analytics and monitoring platform. Dashboards for your node metrics and system health.', icon: '/assets/img/app-icons/grafana.png', author: 'Grafana Labs', dockerImage: `${R}/grafana:10.2.0`, repoUrl: 'https://github.com/grafana/grafana' },
|
||||
{ id: 'searxng', title: 'SearXNG', version: '2024.1.0', description: 'Privacy-respecting metasearch engine. Search the internet without being tracked or profiled.', icon: '/assets/img/app-icons/searxng.png', author: 'SearXNG', dockerImage: `${R}/searxng:latest`, repoUrl: 'https://github.com/searxng/searxng' },
|
||||
{ id: 'ollama', title: 'Ollama', version: '0.5.4', description: 'Run AI models locally. Llama, Mistral, and more — on your hardware, completely private.', icon: '/assets/img/app-icons/ollama.png', author: 'Ollama', dockerImage: `${R}/ollama:latest`, repoUrl: 'https://github.com/ollama/ollama' },
|
||||
{ id: 'cryptpad', title: 'CryptPad', version: '2024.12.0', description: 'End-to-end encrypted documents, spreadsheets, and presentations. Zero-knowledge collaboration.', icon: '/assets/icon/favico-black-v2.svg', author: 'XWiki SAS', dockerImage: `${R}/cryptpad:2024.12.0`, repoUrl: 'https://github.com/cryptpad/cryptpad' },
|
||||
{ id: 'nextcloud', title: 'Nextcloud', version: '29', description: 'Your own private cloud. File sync, calendars, contacts — all on your hardware.', icon: '/assets/img/app-icons/nextcloud.webp', author: 'Nextcloud', dockerImage: `${R}/nextcloud:29`, repoUrl: 'https://github.com/nextcloud/server' },
|
||||
{ id: 'vaultwarden', title: 'Vaultwarden', version: '1.30.0', description: 'Self-hosted password vault. Bitwarden-compatible with zero-knowledge encryption.', icon: '/assets/img/app-icons/vaultwarden.webp', author: 'Vaultwarden', dockerImage: `${R}/vaultwarden:1.30.0-alpine`, repoUrl: 'https://github.com/dani-garcia/vaultwarden' },
|
||||
{ id: 'jellyfin', title: 'Jellyfin', version: '10.8.13', description: 'Free media server. Stream your movies, music, and photos to any device.', icon: '/assets/img/app-icons/jellyfin.webp', author: 'Jellyfin', dockerImage: `${R}/jellyfin:10.8.13`, repoUrl: 'https://github.com/jellyfin/jellyfin' },
|
||||
|
||||
@@ -247,17 +247,6 @@ export function getCuratedAppList(): MarketplaceApp[] {
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/ollama/ollama'
|
||||
},
|
||||
{
|
||||
id: 'cryptpad',
|
||||
title: 'CryptPad',
|
||||
version: '2024.12.0',
|
||||
description: 'End-to-end encrypted documents, spreadsheets, and presentations. Zero-knowledge collaboration.',
|
||||
icon: '/assets/icon/favico-black-v2.svg',
|
||||
author: 'XWiki SAS',
|
||||
dockerImage: `${REGISTRY}/cryptpad:2024.12.0`,
|
||||
manifestUrl: undefined,
|
||||
repoUrl: 'https://github.com/cryptpad/cryptpad'
|
||||
},
|
||||
{
|
||||
id: 'nextcloud',
|
||||
title: 'Nextcloud',
|
||||
|
||||
@@ -362,6 +362,19 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.8.8-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.8-alpha</span>
|
||||
<span class="text-xs text-white/40">September 1, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p><strong>SSH over the mesh is now a first-class setting.</strong> Settings gains an "SSH over mesh" card: off by default, and when you allow it the node's mesh firewall opens port 22 — either to every mesh peer (behind an explicit "I understand" confirmation, because that's a real exposure) or only to the mesh addresses you list. The rule is owned by the node (the 90-ssh.nft drop-in), so it survives upgrades and daemon reinstalls, and the card tells you up front whether sshd is running, whether it listens on IPv6 (the mesh is IPv6-only — this is what a broken attempt looks like before it happens), and whether password login is on (keys-only is the recommended pairing). From Termux on your phone, fipssh <user>@<node-npub> connects once the toggle is on — the npub is the durable address, and the command is shown with a copy button on the card.</p>
|
||||
<p><strong>The App Store now lists apps — not parts of apps.</strong> The signed catalog carries every manifest because the node's update layer needs their pins, and the store briefly listed them all: Mempool API, LND UI, Bitcoin UI, the Pine voice engines, the IndeeHub and Immich backends, the mesh router and friends. Components are hidden from the store listing (they still appear where they belong — the Services tab of My Apps, once installed), and four entries that never earned a tile are gone outright: MorphOS server (old), the Web5 DID wallet, Lightning Stack (an untracked upstream bundle — LND covers the need), and CryptPad (never tested).</p>
|
||||
<p><strong>App icons now persist everywhere, in the proper container style.</strong> Two fixes: installed apps render the icon from their own manifest — Cuprate no longer falls back to the generic A-mark on its Services tile — and the store grids (the Discover page) apply the same icon container treatment (backdrop, border, shadow) as My Apps, the detail pages, and Home. Manifest-declared UI apps also classify correctly again: Alby Hub installs into My Apps with a working tile, not into Services, because a probe miss no longer buries an app the manifest itself says has a frontend.</p>
|
||||
<p><strong>Installing from the store keeps you on the store page.</strong> The install progress lives on the tile itself and the app appears in My Apps when it lands — no more being yanked to My Apps mid-browse.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.8.7-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
interface SshOverMeshStatus {
|
||||
enabled: boolean
|
||||
sources: string[]
|
||||
scope: 'any' | 'list'
|
||||
preflights?: {
|
||||
sshd_active: boolean
|
||||
sshd_ipv6_listen: boolean
|
||||
password_auth: boolean | null
|
||||
}
|
||||
applied?: boolean
|
||||
reloaded?: boolean
|
||||
}
|
||||
|
||||
const toast = useToast()
|
||||
const status = ref<SshOverMeshStatus | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const saving = ref(false)
|
||||
const confirmAnyPeer = ref(false)
|
||||
const pendingEnableAny = ref(false)
|
||||
const sourcesText = ref('')
|
||||
const fipsNpub = ref<string | null>(null)
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
status.value = (await rpcClient.call({ method: 'fips.ssh-over-mesh.get' })) as SshOverMeshStatus
|
||||
sourcesText.value = (status.value.sources || []).join('\n')
|
||||
if (!fipsNpub.value) {
|
||||
try {
|
||||
const s = (await rpcClient.call<{ npub?: string }>({ method: 'fips.status', dedup: true, maxRetries: 1 }))
|
||||
fipsNpub.value = s.npub ?? null
|
||||
} catch { /* npub hint is optional */ }
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to load SSH-over-mesh state'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** The unrestricted scope opens port 22 to every mesh peer — demand an
|
||||
* explicit confirmation before applying it, like the other danger zones. */
|
||||
async function toggle(enabled: boolean) {
|
||||
const sources = enabled && status.value?.scope === 'list'
|
||||
? sourcesText.value.split(/[\n,]+/).map(s => s.trim()).filter(Boolean)
|
||||
: (status.value?.sources ?? [])
|
||||
if (enabled && sources.length === 0) {
|
||||
pendingEnableAny.value = true
|
||||
confirmAnyPeer.value = true
|
||||
return
|
||||
}
|
||||
await apply(enabled, sources)
|
||||
}
|
||||
|
||||
async function apply(enabled: boolean, sources: string[]) {
|
||||
saving.value = true
|
||||
try {
|
||||
status.value = (await rpcClient.call({
|
||||
method: 'fips.ssh-over-mesh.set',
|
||||
params: { enabled, sources },
|
||||
timeout: 30_000,
|
||||
})) as SshOverMeshStatus
|
||||
sourcesText.value = (status.value.sources || []).join('\n')
|
||||
toast.success(enabled ? 'SSH over mesh allowed' : 'SSH over mesh blocked')
|
||||
if (enabled && status.value.reloaded === false) {
|
||||
toast.error('Rule saved, but the firewall reload failed — reconnect or check logs')
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Failed to apply')
|
||||
} finally {
|
||||
saving.value = false
|
||||
pendingEnableAny.value = false
|
||||
confirmAnyPeer.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function copyFipssh() {
|
||||
if (!fipsNpub.value) return
|
||||
const cmd = `fipssh <user>@${fipsNpub.value}`
|
||||
navigator.clipboard?.writeText(cmd).then(
|
||||
() => toast.success('Copied: ' + cmd),
|
||||
() => toast.error('Copy failed'),
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(refresh)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="glass-card px-6 py-6 mb-6">
|
||||
<div class="flex items-start justify-between gap-4 mb-2">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white/96">SSH over mesh</h2>
|
||||
<p class="text-sm text-white/60 mt-1">
|
||||
Let the phone's FIPS mesh reach this node's SSH (port 22). Off by default — the mesh
|
||||
firewall refuses SSH until you allow it here.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="path-action-button path-action-button--continue px-4 py-2 text-sm shrink-0"
|
||||
:disabled="saving || loading"
|
||||
:class="{ '!bg-orange-500/30 !border-orange-400/50': status?.enabled }"
|
||||
@click="toggle(!status?.enabled)"
|
||||
>
|
||||
{{ status?.enabled ? 'Allowed — block again' : 'Allow SSH over mesh' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-sm text-white/50 py-3">Loading…</div>
|
||||
<div v-else-if="error" class="text-sm text-red-300 py-3">{{ error }}
|
||||
<button class="ml-2 underline" @click="refresh">Retry</button>
|
||||
</div>
|
||||
<template v-else-if="status">
|
||||
<!-- Preflights: explain the toggle rather than gating it -->
|
||||
<div class="mt-3 space-y-1.5 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span :class="status.preflights?.sshd_active ? 'text-green-400' : 'text-orange-300'">●</span>
|
||||
<span class="text-white/80">sshd {{ status.preflights?.sshd_active ? 'is running' : 'is NOT running' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span :class="status.preflights?.sshd_ipv6_listen ? 'text-green-400' : 'text-orange-300'">●</span>
|
||||
<span class="text-white/80">
|
||||
{{ status.preflights?.sshd_ipv6_listen ? 'listens on IPv6 (mesh-reachable)' : 'does not listen on IPv6 — the mesh cannot reach it' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span :class="status.preflights?.password_auth === false ? 'text-green-400' : 'text-orange-300'">●</span>
|
||||
<span class="text-white/80">
|
||||
{{ status.preflights?.password_auth == null
|
||||
? 'PasswordAuthentication unknown'
|
||||
: status.preflights?.password_auth
|
||||
? 'password login allowed — keys-only is the safer pairing for the firewall rule'
|
||||
: 'keys-only login (recommended)' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Source restriction -->
|
||||
<div class="mt-4">
|
||||
<p class="text-xs text-white/50 mb-1">Restrict to specific mesh addresses (one per line), or leave empty to allow any mesh peer.</p>
|
||||
<textarea
|
||||
v-model="sourcesText"
|
||||
rows="2"
|
||||
class="w-full bg-white/10 border border-white/20 rounded-lg p-2 text-sm text-white/90 font-mono placeholder-white/30"
|
||||
placeholder="fd79:… phone mesh address"
|
||||
:disabled="!status.enabled || saving"
|
||||
@change="apply(true, sourcesText.split(/[\n,]+/).map(s => s.trim()).filter(Boolean))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Phone-side hint: the npub is the durable address -->
|
||||
<div v-if="status.enabled && fipsNpub" class="mt-3 flex items-center gap-2 text-sm">
|
||||
<span class="text-white/60">From Termux:</span>
|
||||
<code class="text-orange-200 truncate max-w-[55%]">fipssh <user>@{{ fipsNpub.slice(0, 12) }}…</code>
|
||||
<button class="glass-button px-2 py-1 text-xs" @click="copyFipssh">Copy</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Danger-zone confirmation for the unrestricted scope -->
|
||||
<Teleport to="body">
|
||||
<div v-if="confirmAnyPeer" class="fixed inset-0 z-[3000] flex items-center justify-center p-4" @click.self="confirmAnyPeer = false">
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
||||
<div class="glass-card p-6 max-w-md w-full relative z-10">
|
||||
<h3 class="text-lg font-semibold text-white mb-3">Allow SSH from ANY mesh peer?</h3>
|
||||
<p class="text-sm text-white/70 mb-4">
|
||||
Anyone who can route to this node over the FIPS mesh will reach port 22. Restricting
|
||||
to your phone's mesh address above is the safer pairing. Only continue if you
|
||||
understand port 22 will face every mesh peer.
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<button class="flex-1 glass-button px-4 py-2 text-sm" @click="confirmAnyPeer = false">Restrict instead</button>
|
||||
<button
|
||||
class="flex-1 px-4 py-2 rounded-lg text-sm border border-orange-400/50 bg-orange-500/20 text-orange-200 hover:bg-orange-500/30"
|
||||
:disabled="saving || pendingEnableAny === false"
|
||||
@click="apply(true, [])"
|
||||
>I understand — allow any</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
@@ -6,6 +6,7 @@ import AIDataAccessSection from '@/views/settings/AIDataAccessSection.vue'
|
||||
import RoutstrBudgetSection from '@/views/settings/RoutstrBudgetSection.vue'
|
||||
import WebhookSection from '@/views/settings/WebhookSection.vue'
|
||||
import TelemetrySection from '@/views/settings/TelemetrySection.vue'
|
||||
import SshOverMeshCard from '@/views/settings/SshOverMeshCard.vue'
|
||||
import NodeCertificateSection from '@/views/settings/NodeCertificateSection.vue'
|
||||
import LightningCredentialsSection from '@/views/settings/LightningCredentialsSection.vue'
|
||||
import BackupSection from '@/views/settings/BackupSection.vue'
|
||||
@@ -20,6 +21,7 @@ import SystemDangerZone from '@/views/settings/SystemDangerZone.vue'
|
||||
<AIDataAccessSection />
|
||||
<WebhookSection />
|
||||
<TelemetrySection />
|
||||
<SshOverMeshCard />
|
||||
<NodeCertificateSection />
|
||||
<LightningCredentialsSection />
|
||||
<BackupSection />
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
const getSpy = vi.fn()
|
||||
const setSpy = vi.fn()
|
||||
const toast = { success: vi.fn(), error: vi.fn(), info: vi.fn() }
|
||||
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: (req: { method: string; params?: unknown }) => {
|
||||
if (req.method === 'fips.ssh-over-mesh.get') return getSpy(req)
|
||||
if (req.method === 'fips.ssh-over-mesh.set') return setSpy(req)
|
||||
if (req.method === 'fips.status') return Promise.resolve({ npub: 'npub1test' })
|
||||
return Promise.resolve({})
|
||||
},
|
||||
},
|
||||
}))
|
||||
vi.mock('@/composables/useToast', () => ({ useToast: () => toast }))
|
||||
|
||||
import SshOverMeshCard from '../SshOverMeshCard.vue'
|
||||
|
||||
function statusBody(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
enabled: false,
|
||||
sources: [],
|
||||
scope: 'any',
|
||||
preflights: { sshd_active: true, sshd_ipv6_listen: true, password_auth: false },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('SshOverMeshCard', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
getSpy.mockResolvedValue(statusBody())
|
||||
setSpy.mockResolvedValue(statusBody({ enabled: true }))
|
||||
})
|
||||
|
||||
it('renders preflights from the RPC state', async () => {
|
||||
getSpy.mockResolvedValue(statusBody())
|
||||
const wrapper = mount(SshOverMeshCard)
|
||||
await vi.dynamicImportSettled()
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
expect(getSpy).toHaveBeenCalledWith(expect.objectContaining({ method: 'fips.ssh-over-mesh.get' }))
|
||||
expect(wrapper.text()).toContain('sshd is running')
|
||||
expect(wrapper.text()).toContain('keys-only login')
|
||||
})
|
||||
|
||||
it('flags the missing IPv6 listener honestly', async () => {
|
||||
getSpy.mockResolvedValue(statusBody({
|
||||
preflights: { sshd_active: true, sshd_ipv6_listen: false, password_auth: true },
|
||||
}))
|
||||
const wrapper = mount(SshOverMeshCard)
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
expect(wrapper.text()).toContain('does not listen on IPv6')
|
||||
expect(wrapper.text()).toContain('keys-only is the safer pairing')
|
||||
})
|
||||
|
||||
it('demands the danger-zone confirmation before an unrestricted enable', async () => {
|
||||
const wrapper = mount(SshOverMeshCard)
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
await wrapper.find('button.path-action-button').trigger('click')
|
||||
// The confirmation modal Teleports to <body>; nothing applied yet.
|
||||
expect(document.body.textContent).toContain('Allow SSH from ANY mesh peer')
|
||||
expect(setSpy).not.toHaveBeenCalled()
|
||||
// Confirming applies the unrestricted rule.
|
||||
const confirm = [...document.body.querySelectorAll('button')]
|
||||
.find(b => (b.textContent ?? '').includes('I understand'))
|
||||
expect(confirm).toBeDefined()
|
||||
confirm!.dispatchEvent(new Event('click'))
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
expect(setSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
params: { enabled: true, sources: [] },
|
||||
}))
|
||||
})
|
||||
|
||||
it('applies a restricted enable without the any-peer confirmation', async () => {
|
||||
// An existing restricted rule: turning it back on re-applies the list.
|
||||
getSpy.mockResolvedValue(statusBody({ enabled: false, sources: ['fd00::1'], scope: 'list' }))
|
||||
setSpy.mockResolvedValue(statusBody({ enabled: true, sources: ['fd00::1'], scope: 'list' }))
|
||||
const wrapper = mount(SshOverMeshCard)
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
await wrapper.find('button.path-action-button').trigger('click')
|
||||
expect(setSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
params: { enabled: true, sources: ['fd00::1'] },
|
||||
}))
|
||||
expect(wrapper.text()).not.toContain('Allow SSH from ANY mesh peer')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user