feat: add isolated static website setup with FIPS and Tor publishing

This commit is contained in:
archipelago
2026-10-08 06:25:16 -04:00
parent c57119e9a7
commit 05e999b117
32 changed files with 2266 additions and 31 deletions
+3 -1
View File
@@ -6,7 +6,7 @@
<RouterLink
v-for="(goal, idx) in goals"
:key="goal.id"
:to="`/dashboard/goals/${goal.id}`"
:to="goal.route || `/dashboard/goals/${goal.id}`"
class="goal-card glass-card p-6 block"
:class="{ 'home-card-animate': animate }"
:style="{ '--card-stagger': idx }"
@@ -84,6 +84,8 @@ function goalAppIcons(goal: GoalDefinition): { appId: string; url: string }[] {
function goalIcon(icon: string): string {
const icons: Record<string, string> = {
globe: '🌐',
website: '📝',
shop: '🏪',
payments: '⚡',
photos: '📸',
+14 -28
View File
@@ -22,6 +22,20 @@ const FUND_WALLET_STEP: GoalStep = {
}
export const GOALS: GoalDefinition[] = [
{
id: 'external-access', title: 'Allow external connections',
subtitle: 'Choose FIPS, public web and Tor access for your services',
icon: 'globe', category: 'network', requiredApps: [],
route: '/dashboard/setup/external-access', estimatedTime: 'Guided setup', difficulty: 'beginner',
steps: [{ id: 'external-access', title: 'Configure access', description: 'Choose and verify each connection.', action: 'configure', isAutomatic: false }],
},
{
id: 'publish-website', title: 'Publish a website',
subtitle: 'Create a website on your node and choose where to publish it',
icon: 'website', category: 'community', requiredApps: [],
route: '/dashboard/setup/website', estimatedTime: 'Guided setup', difficulty: 'beginner',
steps: [{ id: 'publish-website', title: 'Create and publish', description: 'Preview your website and reuse existing connections.', action: 'configure', isAutomatic: false }],
},
{
id: 'open-a-shop',
title: 'Open a Shop',
@@ -116,34 +130,6 @@ export const GOALS: GoalDefinition[] = [
estimatedTime: '~30 min + sync time',
difficulty: 'beginner',
},
{
id: 'file-browser',
title: 'File Browser',
subtitle: 'Browse, upload, and manage files on your server',
icon: 'files',
category: 'storage',
requiredApps: ['filebrowser'],
steps: [
{
id: 'install-filebrowser',
title: 'Install FileBrowser',
description: 'FileBrowser is a lightweight web file manager. Upload, download, and organize files on your server from any browser.',
appId: 'filebrowser',
action: 'install',
isAutomatic: true,
},
{
id: 'configure-filebrowser',
title: 'Log In',
description: 'Open FileBrowser and log in. Change your password on first login, then start managing your files.',
appId: 'filebrowser',
action: 'configure',
isAutomatic: false,
},
],
estimatedTime: '~5 min',
difficulty: 'beginner',
},
{
id: 'store-files',
title: 'Store My Files',
+10
View File
@@ -245,6 +245,16 @@ const router = createRouter({
name: 'app-registries',
component: () => import('../views/AppRegistries.vue'),
},
{
path: 'setup/external-access',
name: 'external-access',
component: () => import('@/views/publishing/PublishingSetup.vue'),
},
{
path: 'setup/website',
name: 'publish-website',
component: () => import('@/views/publishing/PublishingSetup.vue'),
},
{
path: 'goals/:goalId',
name: 'goal-detail',
@@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref, type Ref } from 'vue'
import { setActivePinia, createPinia } from 'pinia'
vi.mock('@/router', () => ({ default: { push: vi.fn() } }))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
@@ -22,6 +24,8 @@ import { ContextBroker } from '../contextBroker'
import { useAIPermissionsStore } from '@/stores/aiPermissions'
import { rpcClient } from '@/api/rpc-client'
import { fileBrowserClient } from '@/api/filebrowser-client'
import router from '@/router'
import { pendingWebsiteHtml } from '../websiteImport'
describe('ContextBroker', () => {
let broker: ContextBroker
@@ -31,6 +35,7 @@ describe('ContextBroker', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
pendingWebsiteHtml.value = null
mockPostMessage = vi.fn()
iframeRef = ref<HTMLIFrameElement | null>({
@@ -46,6 +51,23 @@ describe('ContextBroker', () => {
expect(broker).toBeDefined()
})
it('only accepts website drafts from the registered AIUI frame and origin', async () => {
const receive = (origin: string, source: MessageEventSource | null) => {
;(broker as unknown as { handleMessage(event: MessageEvent): void }).handleMessage(new MessageEvent('message', {
origin, source, data: { type: 'action:request', id: 'website-draft', action: 'prepare-website', params: { html: '<h1>Draft</h1>' } },
}))
}
receive('https://untrusted.example', iframeRef.value!.contentWindow)
receive('http://localhost:8100', window)
expect(pendingWebsiteHtml.value).toBeNull()
expect(router.push).not.toHaveBeenCalled()
receive('http://localhost:8100', iframeRef.value!.contentWindow)
await vi.waitFor(() => expect(router.push).toHaveBeenCalledWith('/dashboard/setup/website'))
expect(pendingWebsiteHtml.value).toBe('<h1>Draft</h1>')
expect(rpcClient.call).not.toHaveBeenCalled()
expect(mockPostMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'action:response', id: 'website-draft', success: true }), expect.any(String))
})
it('start registers message listener', () => {
const addSpy = vi.spyOn(window, 'addEventListener')
broker.start()
@@ -0,0 +1,22 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
import { PUBLISH_ROUTES, publishing, websitePreview } from '../publishing'
import { rpcClient } from '@/api/rpc-client'
describe('publishing trust boundaries', () => {
it('places restrictive CSP before untrusted website content', () => {
const hostile = '<script>fetch("/rpc")</script><meta http-equiv="Content-Security-Policy" content="default-src *">'
const preview = websitePreview(hostile)
expect(preview.indexOf("default-src 'none'")).toBeLessThan(preview.indexOf(hostile))
expect(preview).toContain("form-action 'none'")
expect(preview).not.toContain("script-src 'unsafe-inline'")
})
it('supports all four routes without Tailscale', () => {
expect(PUBLISH_ROUTES.map(r => r.id)).toEqual(['fips', 'public-web', 'tor', 'nostr'])
})
it('does not retry ambiguous writes and carries the node version', async () => {
vi.mocked(rpcClient.call).mockResolvedValue({ state: { version: 8 }, project_id: null })
await publishing.update(7, { action: 'connections', routes: ['fips', 'tor'] })
expect(rpcClient.call).toHaveBeenCalledWith({ method: 'publishing.update', params: { version: 7, change: { action: 'connections', routes: ['fips', 'tor'] } }, maxRetries: 0 })
})
})
@@ -0,0 +1,14 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { pendingWebsiteHtml, prepareWebsiteImport } from '../websiteImport'
beforeEach(() => { pendingWebsiteHtml.value = null })
describe('AIUI website handoff', () => {
it('holds HTML only in memory for explicit import', () => {
expect(prepareWebsiteImport('<h1>My page</h1>')).toBe(true)
expect(pendingWebsiteHtml.value).toBe('<h1>My page</h1>')
})
it('rejects invalid or oversized content without destroying a pending draft', () => {
prepareWebsiteImport('existing')
for (const bad of [null, {}, '', '\0', 'a'.repeat(512 * 1024 + 1)]) expect(prepareWebsiteImport(bad)).toBe(false)
expect(pendingWebsiteHtml.value).toBe('existing')
})
})
+10
View File
@@ -1,4 +1,5 @@
import type { Ref } from 'vue'
import { prepareWebsiteImport } from '@/services/websiteImport'
import type {
AIUIRequest,
ArchyResponse,
@@ -237,6 +238,7 @@ export class ContextBroker {
this.handleContextRequest(msg.id, msg.category, msg.query)
break
case 'action:request':
if (msg.action === 'prepare-website' && event.source !== this.iframe.value?.contentWindow) return
this.handleActionRequest(msg.id, msg.action, msg.params)
break
case 'theme:request':
@@ -640,6 +642,14 @@ export class ContextBroker {
try {
switch (action) {
case 'prepare-website':
if (prepareWebsiteImport(params?.html)) {
void import('@/router').then(({ default: router }) => router.push('/dashboard/setup/website'))
success = true
} else {
error = 'Provide a nonempty HTML draft up to 512 KiB'
}
break
case 'navigate':
if (params.path) {
window.dispatchEvent(new CustomEvent('aiui:navigate', { detail: params.path }))
+47
View File
@@ -0,0 +1,47 @@
import { rpcClient } from '@/api/rpc-client'
export type PublishRoute = 'fips' | 'public-web' | 'tor' | 'nostr'
export interface PublishDomain { hostname: string; destination: string | null }
export interface WebsiteRevision { id: string; created_at: string; html: string }
export interface WebsiteProject {
id: string; name: string; routes: PublishRoute[]; domain: PublishDomain | null
draft: string; revisions: WebsiteRevision[]
fips_publication?: { port: number; html: string; created_at: string } | null
tor_publication?: { port: number; html: string; created_at: string } | null
}
export interface PublishingState {
schema: number; version: number; connections: PublishRoute[]; projects: Record<string, WebsiteProject>
}
export interface PublishingStatus {
state: PublishingState; fips_address: string | null; publication_enabled: boolean; notice: string
listeners?: { project_id: string; address: string | null; listening: boolean; externally_verified: boolean; error: string | null }[]
onions?: { project_id: string; onion_address: string | null; listening: boolean; externally_verified: boolean; error: string | null }[]
apps: { id: string; name: string; port: number; authentication: string; listener_claimed: boolean }[]
}
export interface DnsPlan {
records: { record_type: string; name: string; value: string; ttl: number }[]
verified: boolean; notes: string[]; instructions_url: string
}
export const PUBLISH_ROUTES: { id: PublishRoute; title: string; description: string }[] = [
{ id: 'fips', title: 'FIPS network', description: 'Reach your node through FIPS. Visitors need a FIPS connection or a configured LAN gateway.' },
{ id: 'public-web', title: 'Public web', description: 'An HTTPS address for ordinary browsers, using your selected gateway or a direct public connection.' },
{ id: 'tor', title: 'Tor', description: 'An onion address controlled by your node. Visitors use Tor Browser.' },
{ id: 'nostr', title: 'Nostr / nsites', description: 'Publish a static website through Nostr and Blossom. Public copies may remain after you unpublish.' },
]
export const publishing = {
status: () => rpcClient.call<PublishingStatus>({ method: 'publishing.status', maxRetries: 1 }),
update: (version: number, change: Record<string, unknown>) => rpcClient.call<{state: PublishingState; project_id: string | null}>({
method: 'publishing.update', params: { version, change }, maxRetries: 0,
}),
dns: (domain: PublishDomain) => rpcClient.call<DnsPlan>({ method: 'publishing.dns', params: { ...domain }, maxRetries: 0 }),
generate: (prompt: string, model: string) => rpcClient.call<{html: string; provider: string}>({
method: 'publishing.generate', params: { prompt, model }, timeout: 150000, maxRetries: 0,
}),
}
// This document is also sandboxed with no allow-* tokens by its iframe. The CSP
// precedes model content and cannot be relaxed by a second meta tag. No fetches,
// scripts, forms, navigation of the parent, cookies or management origin access.
export function websitePreview(html: string): string {
return '<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="default-src \'none\'; style-src \'unsafe-inline\'; img-src data:; font-src \'none\'; base-uri \'none\'; form-action \'none\'"><meta name="referrer" content="no-referrer"></head><body>' + html + '</body></html>'
}
+10
View File
@@ -0,0 +1,10 @@
import { shallowRef } from 'vue'
// In-memory handoff only. Receiving model content never writes files, opens a
// route or publishes. The trusted setup screen requires an explicit import.
export const pendingWebsiteHtml = shallowRef<string | null>(null)
export function prepareWebsiteImport(html: unknown): boolean {
if (typeof html !== 'string' || !html.trim() || new TextEncoder().encode(html).length > 512 * 1024 || html.includes('\0')) return false
pendingWebsiteHtml.value = html
return true
}
+1 -1
View File
@@ -21,7 +21,7 @@ export type AIContextCategory =
| 'bitcoin'
/** Actions AIUI can request Archy to perform */
export type AIActionType = 'install-app' | 'open-app' | 'navigate' | 'launch-app' | 'search-web' | 'read-file' | 'tail-logs'
export type AIActionType = 'prepare-website' | 'install-app' | 'open-app' | 'navigate' | 'launch-app' | 'search-web' | 'read-file' | 'tail-logs'
// ─── AIUI → Archy (Requests) ───────────────────────────────────────────────
+1
View File
@@ -6,6 +6,7 @@ export interface GoalDefinition {
subtitle: string
icon: string
category: 'commerce' | 'payments' | 'storage' | 'identity' | 'network' | 'backup' | 'community'
route?: string
requiredApps: string[]
steps: GoalStep[]
estimatedTime: string
@@ -0,0 +1,263 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { pendingWebsiteHtml } from '@/services/websiteImport'
import { publishing, PUBLISH_ROUTES, websitePreview } from '@/services/publishing'
import type { DnsPlan, PublishRoute, PublishingStatus, WebsiteProject } from '@/services/publishing'
const route = useRoute()
const websiteMode = computed(() => route.name === 'publish-website')
const status = ref<PublishingStatus | null>(null)
const error = ref('')
const message = ref('')
const busy = ref(false)
const projectId = ref('')
const name = ref('My website')
const selected = ref<PublishRoute[]>([])
const html = ref('')
const hostname = ref('')
const destination = ref('')
const prompt = ref('')
const model = ref('')
const dns = ref<DnsPlan | null>(null)
const acknowledgeFips = ref(false)
const acknowledgeTor = ref(false)
const onion = computed(() => status.value?.onions?.find(l => l.project_id === projectId.value))
const listener = computed(() => status.value?.listeners?.find(l => l.project_id === projectId.value))
const current = computed(() => status.value?.state.projects[projectId.value])
const projects = computed(() => Object.values(status.value?.state.projects ?? {}))
const publicName = computed(() => selected.value.includes('public-web') || selected.value.includes('nostr'))
const preview = computed(() => websitePreview(html.value))
const routes = computed(() => PUBLISH_ROUTES.filter(r => websiteMode.value || r.id !== 'nostr'))
async function perform(work: () => Promise<void>) {
if (busy.value) return
busy.value = true; error.value = ''; message.value = ''
try { await work() } catch (e) { error.value = e instanceof Error ? e.message : 'The operation failed. Your saved project has been retained.' }
finally { busy.value = false }
}
function selectProject(p: WebsiteProject) {
projectId.value = p.id; name.value = p.name; selected.value = [...p.routes]
html.value = p.draft; hostname.value = p.domain?.hostname ?? ''; destination.value = p.domain?.destination ?? ''; dns.value = null; acknowledgeFips.value = false; acknowledgeTor.value = false
}
async function refresh() {
await perform(async () => {
status.value = await publishing.status()
if (!websiteMode.value) selected.value = [...status.value.state.connections]
else if (current.value) selectProject(current.value)
else if (projects.value[0]) selectProject(projects.value[0])
})
}
async function create() {
await perform(async () => {
if (!status.value) return
const result = await publishing.update(status.value.state.version, { action: 'create', name: name.value })
status.value.state = result.state
if (result.project_id) selectProject(result.state.projects[result.project_id]!)
message.value = 'Website project created on your node.'
})
}
async function importFromAiui() {
await perform(async () => {
if (!status.value || pendingWebsiteHtml.value === null) return
const incoming = pendingWebsiteHtml.value
const result = await publishing.update(status.value.state.version, { action: 'create', name: 'Website from AIUI' })
status.value.state = result.state
if (result.project_id) {
selectProject(result.state.projects[result.project_id]!)
html.value = incoming
pendingWebsiteHtml.value = null
message.value = 'AIUI draft imported into a new project. Preview it, then save before publishing.'
}
})
}
async function save() {
await perform(async () => {
if (!status.value) return
const change = websiteMode.value ? {
action: 'save', id: projectId.value, name: name.value, routes: selected.value,
domain: publicName.value && hostname.value.trim() ? { hostname: hostname.value, destination: destination.value.trim() || null } : null,
html: html.value,
} : { action: 'connections', routes: selected.value }
const result = await publishing.update(status.value.state.version, change)
status.value.state = result.state
message.value = websiteMode.value ? 'Draft and route choices saved on your node. Publish when you are ready to share this version.' : 'Connection preferences saved on your node. Existing app access has not changed.'
})
}
async function restore(revision: string) {
await perform(async () => {
if (!status.value) return
const result = await publishing.update(status.value.state.version, { action: 'restore', id: projectId.value, revision })
status.value.state = result.state
selectProject(result.state.projects[projectId.value]!)
message.value = 'Previous draft restored. Published content has not changed.'
})
}
async function setFipsPublication(enable: boolean) {
await perform(async () => {
if (!status.value || !current.value) return
const result = await publishing.update(status.value.state.version, enable
? { action: 'publish-fips', id: projectId.value, acknowledge_public: acknowledgeFips.value }
: { action: 'unpublish-fips', id: projectId.value })
status.value.state = result.state
status.value = await publishing.status()
acknowledgeFips.value = false
message.value = enable ? 'Saved version selected for FIPS publication. Check listener status, firewall and access from another FIPS device.' : 'FIPS website unpublished. Your draft and revisions are retained.'
})
}
async function generate() {
await perform(async () => {
const result = await publishing.generate(prompt.value, model.value.trim())
html.value = result.html
message.value = 'Local model draft ready to preview. Save it to keep this revision.'
})
}
async function setTorPublication(enable: boolean) {
await perform(async () => {
if (!status.value || !current.value) return
const result = await publishing.update(status.value.state.version, enable
? { action: 'publish-tor', id: projectId.value, acknowledge_public: acknowledgeTor.value }
: { action: 'unpublish-tor', id: projectId.value })
status.value.state = result.state
status.value = await publishing.status()
acknowledgeTor.value = false
message.value = enable ? 'Onion publication requested. Tor may take a few minutes to connect; reload to check its address.' : 'Onion website unpublished. Its address keys are retained so you can publish again at the same address.'
})
}
async function prepareDns() {
await perform(async () => { dns.value = await publishing.dns({ hostname: hostname.value, destination: destination.value || null }) })
}
function download() {
const url = URL.createObjectURL(new Blob([html.value], { type: 'text/html;charset=utf-8' }))
const a = document.createElement('a'); a.href = url; a.download = 'index.html'; a.click()
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
onMounted(refresh)
</script>
<template>
<main class="max-w-5xl mx-auto px-4 sm:px-6 py-6 space-y-6">
<RouterLink to="/dashboard?tab=setup" class="text-sm text-white/60 hover:text-white">← Setup</RouterLink>
<div class="flex items-start justify-between gap-4">
<div><h1 class="text-2xl font-semibold">{{ websiteMode ? 'Publish a website' : 'Allow external connections' }}</h1>
<p class="text-white/60 mt-2">{{ websiteMode ? 'Create a website on your node and choose where people can find it.' : 'Choose how people will connect to selected services on your node.' }}</p></div>
<button class="glass-button px-4 py-2" :disabled="busy" @click="refresh">Reload</button>
</div>
<p v-if="error" role="alert" class="rounded-xl p-4 bg-red-500/10 text-red-200">{{ error }}</p>
<p v-if="message" role="status" class="rounded-xl p-4 bg-green-500/10 text-green-200">{{ message }}</p>
<p v-if="busy" role="status" class="text-white/60">Working…</p>
<template v-if="status">
<div class="rounded-xl p-4 border border-amber-300/20 bg-amber-400/10 text-amber-100 text-sm">{{ status.notice }}</div>
<fieldset :disabled="busy" class="space-y-6">
<section v-if="websiteMode && pendingWebsiteHtml !== null" class="glass-card p-5 space-y-3">
<h2 class="text-lg font-semibold">Continue from AIUI</h2>
<p class="text-sm text-white/60">Create a new project from the HTML you selected in AIUI. Existing projects remain unchanged.</p>
<button class="glass-button px-4 py-2" @click="importFromAiui">Import into a new website</button>
<button class="text-sm underline ml-3" @click="pendingWebsiteHtml = null">Discard import</button>
</section>
<section v-if="websiteMode" class="glass-card p-5 space-y-4">
<h2 class="text-lg font-semibold">Your websites</h2>
<div v-if="projects.length" class="flex flex-wrap gap-2">
<button v-for="p in projects" :key="p.id" class="glass-button px-3 py-2" :aria-pressed="p.id === projectId" @click="selectProject(p)">{{ p.name }}</button>
</div>
<label class="block">Website name<input v-model="name" maxlength="100" class="field mt-2" /></label>
<button class="glass-button px-4 py-2" @click="create">Create another website</button>
</section>
<section v-if="websiteMode && current" class="glass-card p-5 space-y-4">
<h2 class="text-lg font-semibold">Describe and preview</h2>
<p class="text-sm text-white/60">Generate a simple page with a model installed on this node, or paste HTML from AIUI. Generation stays on your node.</p>
<label class="block">What would you like to make?<textarea v-model="prompt" maxlength="16000" rows="3" class="field mt-2" placeholder="A simple website for my community garden…" /></label>
<label class="block">Installed Ollama model<input v-model="model" class="field mt-2" placeholder="Enter the exact installed model name" /></label>
<button class="glass-button px-4 py-2" :disabled="!prompt.trim() || !model.trim()" @click="generate">Generate locally</button>
<label class="block">Website HTML<textarea v-model="html" rows="8" class="field mt-2 font-mono text-xs" spellcheck="false" /></label>
<iframe :srcdoc="preview" sandbox="" referrerpolicy="no-referrer" title="Isolated website preview" class="w-full h-96 rounded-xl bg-white" />
<p class="text-xs text-white/50">Preview blocks scripts, forms and external requests. It cannot access your dashboard.</p>
<button class="glass-button px-4 py-2" :disabled="!html" @click="download">Download HTML</button>
</section>
<section class="glass-card p-5 space-y-4">
<h2 class="text-lg font-semibold">Where should it be available?</h2>
<p class="text-sm text-white/60">Choose any combination. Each connection will be checked separately.</p>
<label v-for="option in routes" :key="option.id" class="flex items-start gap-3 rounded-xl border border-white/10 p-4 cursor-pointer">
<input v-model="selected" type="checkbox" :value="option.id" class="mt-1" />
<span><span class="font-medium">{{ option.title }}</span><span class="block text-sm text-white/60 mt-1">{{ option.description }}</span>
<span v-if="websiteMode && status.state.connections.includes(option.id)" class="block text-xs text-amber-200 mt-2">Already selected in connection setup; reachability still needs verification.</span>
</span>
</label>
<p v-if="selected.includes('fips')" class="text-sm text-white/60">{{ status.fips_address ? 'A local FIPS address exists. This does not yet verify a website or app route.' : 'No local FIPS address detected. FIPS must be connected before its routes can be verified.' }}</p>
<RouterLink v-if="websiteMode" to="/dashboard/setup/external-access" class="inline-block text-sm underline">Manage shared connections</RouterLink>
</section>
<section v-if="websiteMode && publicName" class="glass-card p-5 space-y-4">
<h2 class="text-lg font-semibold">Your domain</h2>
<p class="text-sm text-white/60">Use a domain you own, or buy one with Bitcoin or Lightning. FIPS and Tor addresses do not need a domain purchase.</p>
<a href="https://mynymbox.io/domainregistration" target="_blank" rel="noopener noreferrer" class="glass-button inline-block px-4 py-2">Buy a domain through Mynymbox ↗</a>
<p class="text-xs text-white/50">Mynymbox is the registrant of record; you retain contractual control and transfer rights. Complete checkout yourself, then return here. No hosting purchase is needed.</p>
<label class="block">Website hostname<input v-model="hostname" class="field mt-2" placeholder="www.yourdomain.com" /></label>
<label class="block">Gateway hostname or public IP<input v-model="destination" class="field mt-2" placeholder="Use the destination supplied by your gateway" /></label>
<p class="text-sm text-white/60">For a tunnel, point DNS at the public gateway. For a direct connection, use the node’s public IP. Do not use a home-network, FIPS or onion address for public web DNS.</p>
<div v-if="selected.includes('public-web') && current?.fips_publication && status.fips_address" class="space-y-2 rounded-lg border border-white/10 p-4">
<h3 class="font-medium">Use an existing reverse proxy</h3>
<p class="text-sm text-white/60">If your proxy can reach this node over FIPS, you can reuse that connection. In Nginx Proxy Manager, add a separate Proxy Host with these settings:</p>
<dl class="text-sm grid grid-cols-[auto_1fr] gap-x-4 gap-y-2">
<dt>Domain</dt><dd class="font-mono break-all">{{ hostname || 'Your website hostname' }}</dd>
<dt>Scheme</dt><dd>http</dd>
<dt>Forward host</dt><dd class="font-mono break-all">[{{ status.fips_address }}]</dd>
<dt>Forward port</dt><dd>{{ current.fips_publication.port }}</dd>
</dl>
<p class="text-sm text-white/60">Point the domain’s DNS at your proxy’s public address. Request a certificate in the proxy’s SSL tab and enable Force SSL. Then open the HTTPS address from a device outside your home network.</p>
<p class="text-sm text-amber-200">The proxy terminates HTTPS and can read the public page. This setup is manual; the dashboard has not verified it. Removing this FIPS publication also disconnects this proxy route.</p>
</div>
<button class="glass-button px-4 py-2" :disabled="!hostname || !destination" @click="prepareDns">Show DNS instructions</button>
<div v-if="dns" class="space-y-3">
<p>In Mynymbox, open Domains → DNS Management → your domain → Manage records → Add Record.</p>
<div class="overflow-x-auto"><table class="w-full text-sm text-left"><thead><tr><th>Type</th><th>Name</th><th>Value</th><th>TTL</th></tr></thead><tbody><tr v-for="record in dns.records" :key="record.name"><td>{{ record.record_type }}</td><td class="select-all">{{ record.name }}</td><td class="select-all">{{ record.value }}</td><td>{{ record.ttl }}</td></tr></tbody></table></div>
<p v-for="note in dns.notes" :key="note" class="text-sm text-white/60">{{ note }}</p>
<p class="text-amber-200 text-sm">Instructions prepared — DNS and HTTPS have not been verified.</p>
<a href="https://mynymbox.io/docs?doc=domains/dns-records" target="_blank" rel="noopener noreferrer" class="underline text-sm">Mynymbox’s DNS guide ↗</a>
</div>
</section>
<section v-if="!websiteMode" class="glass-card p-5 space-y-3">
<h2 class="text-lg font-semibold">Existing app access</h2>
<p class="text-sm text-white/60">This inventory shows existing access policies. Local-only APIs are excluded. A local listener does not prove external reachability.</p>
<ul class="space-y-2"><li v-for="app in status.apps" :key="app.id + app.port" class="flex flex-wrap justify-between gap-2 text-sm"><span>{{ app.name }} · {{ app.port }}</span><span class="text-white/60">{{ app.listener_claimed ? 'Local proxy listening' : 'Listener not confirmed' }} · {{ app.authentication === 'node-session' ? 'Node login required' : 'App access policy' }}</span></li></ul>
</section>
<button class="glass-button px-5 py-3" :disabled="websiteMode && !current" @click="save">{{ websiteMode ? 'Save website draft and choices' : 'Save connection choices' }}</button>
<section v-if="websiteMode && current && status.publication_enabled" class="glass-card p-5 space-y-3">
<h2 class="text-lg font-semibold">Publish the saved version on FIPS</h2>
<p class="text-sm text-white/60">Anyone who can reach this node through FIPS can view this website. Save your draft first. Scripts and external resources remain blocked in this first static-site version.</p>
<label class="flex items-start gap-3"><input v-model="acknowledgeFips" type="checkbox" class="mt-1" /><span>I want the saved website to be visible to visitors on FIPS.</span></label>
<button class="glass-button px-4 py-2" :disabled="!acknowledgeFips || !current.routes.includes('fips') || !current.draft" @click="setFipsPublication(true)">{{ current.fips_publication ? 'Publish saved update on FIPS' : 'Publish saved website on FIPS' }}</button>
<button v-if="current.fips_publication" class="glass-button px-4 py-2 ml-2" @click="setFipsPublication(false)">Unpublish from FIPS</button>
<div v-if="current.fips_publication" class="text-sm space-y-2">
<p>{{ listener?.listening ? 'Local FIPS listener is ready.' : 'FIPS listener is not confirmed yet. Reload to check.' }}</p>
<p v-if="listener?.error" role="alert">{{ listener.error }}</p>
<p v-if="listener?.address" class="font-mono select-all break-all">{{ listener.address }}</p>
<p class="text-amber-200">External access is not verified. The FIPS firewall must allow this website’s port, {{ current.fips_publication.port }}.</p>
</div>
</section>
<section v-if="websiteMode && current && status.publication_enabled && selected.includes('tor')" class="glass-card p-5 space-y-3">
<h2 class="text-lg font-semibold">Publish the saved version on Tor</h2>
<p class="text-sm text-white/60">Share an onion address without buying a domain. Anyone who knows the address can read the page in Tor Browser. Your node keeps the address keys when you unpublish.</p>
<label class="flex items-start gap-3"><input v-model="acknowledgeTor" type="checkbox" class="mt-1" /><span>I want the saved website to be visible to visitors using Tor.</span></label>
<button class="glass-button px-4 py-2" :disabled="!acknowledgeTor || !current.routes.includes('tor') || !current.draft" @click="setTorPublication(true)">{{ current.tor_publication ? 'Publish saved update on Tor' : 'Publish saved website on Tor' }}</button>
<button v-if="current.tor_publication" class="glass-button px-4 py-2 ml-2" @click="setTorPublication(false)">Unpublish from Tor</button>
<div v-if="current.tor_publication" class="text-sm space-y-2">
<p v-if="onion?.error" role="alert">{{ onion.error }}</p>
<p v-if="onion?.onion_address" class="font-mono select-all break-all">http://{{ onion.onion_address }}/</p>
<p>{{ onion?.listening ? 'Local website listener is ready. Open the address in Tor Browser to check external access.' : 'Waiting for the website listener. Reload to check.' }}</p>
<p class="text-amber-200">An address alone does not confirm that Tor has connected or that visitors can reach the page.</p>
</div>
</section>
<section v-if="websiteMode && current?.revisions.length" class="glass-card p-5 space-y-3">
<h2 class="text-lg font-semibold">Saved revisions</h2>
<div v-for="revision in [...current.revisions].reverse()" :key="revision.id" class="flex justify-between gap-3"><span class="text-sm text-white/60">{{ new Date(revision.created_at).toLocaleString() }}</span><button class="text-sm underline" @click="restore(revision.id)">Restore draft</button></div>
</section>
</fieldset>
</template>
</main>
</template>
<style scoped>
.field { display: block; width: 100%; border: 1px solid rgb(255 255 255 / .15); border-radius: .5rem; padding: .75rem; background: rgb(0 0 0 / .2); color: white; }
button:disabled { opacity: .5; cursor: not-allowed; }
th, td { padding: .5rem; }
</style>
@@ -0,0 +1,43 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const api = vi.hoisted(() => ({ status: vi.fn(), update: vi.fn(), dns: vi.fn(), generate: vi.fn() }))
const page = vi.hoisted(() => ({ name: 'external-access' }))
vi.mock('vue-router', () => ({ useRoute: () => page, RouterLink: { props: ['to'], template: '<a :href="to"><slot /></a>' } }))
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
vi.mock('@/services/publishing', async (original) => ({ ...await original<typeof import('@/services/publishing')>(), publishing: api }))
import PublishingSetup from '../PublishingSetup.vue'
const state = () => ({ schema: 1, version: 2, connections: ['fips'], projects: {} })
beforeEach(() => {
vi.clearAllMocks(); page.name = 'external-access'
api.status.mockResolvedValue({ state: state(), fips_address: null, apps: [], publication_enabled: false, notice: 'Saving does not publish.' })
api.update.mockResolvedValue({ state: { ...state(), version: 3 }, project_id: null })
})
describe('publishing setup', () => {
it('loads choices from the node and saves multiple routes without activating them', async () => {
const wrapper = mount(PublishingSetup); await flushPromises()
const inputs = wrapper.findAll('input[type="checkbox"]')
expect((inputs[0]!.element as HTMLInputElement).checked).toBe(true)
await inputs[2]!.setValue(true)
await wrapper.findAll('button').find(b => b.text() === 'Save connection choices')!.trigger('click')
await flushPromises()
expect(api.update).toHaveBeenCalledWith(2, { action: 'connections', routes: ['fips', 'tor'] })
expect(wrapper.text()).toContain('Existing app access has not changed')
})
it('keeps a failed save visible and does not pretend it succeeded', async () => {
api.update.mockRejectedValue(new Error('Reload before saving'))
const wrapper = mount(PublishingSetup); await flushPromises()
await wrapper.findAll('button').find(b => b.text() === 'Save connection choices')!.trigger('click'); await flushPromises()
expect(wrapper.get('[role="alert"]').text()).toContain('Reload before saving')
expect(wrapper.text()).not.toContain('Connection preferences saved')
})
it('isolates saved HTML and presents Nostr as an independent choice', async () => {
page.name = 'publish-website'
api.status.mockResolvedValue({ state: { ...state(), projects: { site: { id: 'site', name: 'Site', draft: '<script>parent.fetch("/rpc")</script>', routes: ['fips', 'nostr'], domain: null, revisions: [] } } }, apps: [], fips_address: 'fd00::1', publication_enabled: false, notice: 'Saving does not publish.' })
const wrapper = mount(PublishingSetup); await flushPromises()
expect(wrapper.get('iframe').attributes('sandbox')).toBe('')
expect(wrapper.get('iframe').attributes('srcdoc')).toContain("default-src 'none'")
expect(wrapper.findAll('input[type="checkbox"]')).toHaveLength(4)
expect(wrapper.text()).toContain('reachability still needs verification')
})
})
@@ -7,7 +7,7 @@ interface TunnelStatus {
connected?: boolean
provider?: string
ip_address?: string
wg_ip?: string
wg_ip?: string | null
}
interface FipsStatus {