Preview-Router: öffentliche Seiten überstehen Server-Neustart

Nach einem Neustart startet der Kundenbereich oft vor Appwrite. Bisher
lieferte getPublicSubdomains dann ein leeres Set, syncPreviewTls setzte
alle Preview-Hosts hinter die Login-Weiche (preview-chain) und wiederholte
das nie. Öffentliche Seiten wie anelia-wolf und emmas-schatzkiste waren
bis zum nächsten Deploy nur noch mit Login erreichbar bzw. 403.

- Antwortet Appwrite nicht, übernimmt syncPreviewTls die öffentlichen
  Seiten aus der zuletzt geschriebenen preview-hosts.yml. Neue Subdomains
  bleiben login-geschützt, ohne alte Datei ist alles geschützt.
- Beim Start wird der Abgleich alle 15 s wiederholt (max. 10 min), bis er
  mit Appwrite-Daten geklappt hat.
- Tests: Datei lesen, Abgleich mit/ohne Appwrite, Startup-Wiederholung.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-26 22:19:33 +00:00
1 parent ba9916cfe6
commit d5eb908cb6
3 files changed
+218 -10

No files matched your search

+2 -4
View File
@@ -3,7 +3,7 @@ import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { config, assertServerConfig, WOMS_DATABASE_ID } from './config.js'
import { verifyDatabaseAccess } from './services/appwriteAdmin.js'
import { syncPreviewTls } from './services/previewDeploy.js'
import { syncPreviewTlsOnStartup } from './services/previewDeploy.js'
import { requireSession, sessionMiddleware } from './middleware/session.js'
import authRoutes from './routes/auth.js'
import projectsRoutes from './routes/projects.js'
@@ -101,9 +101,7 @@ app.use((err, _req, res, _next) => {
const server = app.listen(config.port, '0.0.0.0', () => {
console.log(`Webklar Kundenbereich läuft auf Port ${config.port}`)
syncPreviewTls()
.then((r) => console.log('[startup] Preview-TLS-Sync:', JSON.stringify(r)))
.catch((err) => console.error('[startup] Preview-TLS-Sync fehlgeschlagen:', err.message))
syncPreviewTlsOnStartup()
verifyDatabaseAccess().then((result) => {
if (result.ok) return
console.error(
+69 -6
View File
@@ -10,8 +10,8 @@ const execFileAsync = promisify(execFile)
/**
* Subdomains, die als "oeffentliche Website" (ohne Login) laufen sollen.
* Diese bekommen in syncPreviewTls einen Router OHNE preview-auth-ForwardAuth.
* Faellt bei Appwrite-Fehlern bewusst auf ein leeres Set zurueck -> alle Seiten
* bleiben dann login-geschuetzt (sicherer Default).
* Liefert null, wenn Appwrite nicht antwortet (z.B. direkt nach einem
* Server-Neustart, solange Appwrite noch hochfaehrt).
*/
async function getPublicSubdomains() {
try {
@@ -22,10 +22,38 @@ async function getPublicSubdomains() {
return new Set(docs.map((d) => d.subdomain).filter(Boolean))
} catch (err) {
console.error('[previewDeploy] getPublicSubdomains fehlgeschlagen:', err.message)
return new Set()
return null
}
}
/**
* Oeffentliche Subdomains aus einer von syncPreviewTls geschriebenen
* preview-hosts.yml: alle HTTPS-Router ohne preview-chain.
*/
export function publicSubdomainsFromHostsFile(content) {
const result = new Set()
let current = null
let isProtected = false
const finish = () => {
if (current && !isProtected) result.add(current)
current = null
}
for (const line of String(content || '').split('\n')) {
if (/^ {4}\S/.test(line)) {
finish()
const match = line.match(/^ {4}preview-([a-z0-9-]+)-https:\s*$/)
if (match) {
current = match[1]
isProtected = false
}
} else if (current && line.includes('preview-chain@docker')) {
isProtected = true
}
}
finish()
return result
}
export async function fetchPreviewConfig(repoFullName, branch) {
const [owner, repo] = repoFullName.split('/')
if (!owner || !repo || !config.gitea.apiToken) {
@@ -305,7 +333,16 @@ export async function syncPreviewTls() {
.map((entry) => entry.name)
.sort()
const publicSet = await getPublicSubdomains()
const hostsFile = path.join(outDir, 'preview-hosts.yml')
let publicSet = await getPublicSubdomains()
let publicSource = 'appwrite'
if (!publicSet) {
// Appwrite antwortet nicht: oeffentliche Seiten aus der letzten Datei
// uebernehmen, statt sie alle hinter den Login zu setzen. Neue
// Subdomains bleiben login-geschuetzt (sicherer Default).
publicSet = await fs.readFile(hostsFile, 'utf8').then(publicSubdomainsFromHostsFile, () => new Set())
publicSource = 'previous-file'
}
const blocks = slugs.map((name) => {
const host = `${name}.${baseHost}`
@@ -347,8 +384,34 @@ export async function syncPreviewTls() {
].join('\n')
await fs.mkdir(outDir, { recursive: true })
await fs.writeFile(path.join(outDir, 'preview-hosts.yml'), content, 'utf8')
return { synced: true, hosts: slugs.length }
await fs.writeFile(hostsFile, content, 'utf8')
return { synced: true, hosts: slugs.length, publicSource }
}
/**
* Nach einem Server-Neustart startet der Kundenbereich oft vor Appwrite. Dann
* kennt syncPreviewTls die oeffentlichen Seiten nur aus der letzten Datei; der
* Abgleich wird wiederholt, bis er mit Appwrite-Daten geklappt hat.
*/
export async function syncPreviewTlsOnStartup({
sync = syncPreviewTls,
retryMs = 15_000,
maxAttempts = 40,
} = {}) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const result = await sync()
console.log('[startup] Preview-TLS-Sync:', JSON.stringify(result))
if (!result.synced || result.publicSource === 'appwrite') return result
} catch (err) {
console.error('[startup] Preview-TLS-Sync fehlgeschlagen:', err.message)
}
if (attempt < maxAttempts) await new Promise((resolve) => setTimeout(resolve, retryMs))
}
console.error(
`[startup] Preview-TLS-Sync: Appwrite nach ${maxAttempts} Versuchen nicht erreichbar, oeffentliche Seiten bleiben wie in der letzten Datei`
)
return null
}
export async function runDeploy(repoFullName, branch, previewConfig) {
+147
View File
@@ -0,0 +1,147 @@
import { test, before, after } from 'node:test'
import assert from 'node:assert/strict'
import http from 'node:http'
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
// Nachgestellter Server-Neustart: Appwrite antwortet erst, wenn appwriteUp true ist.
let appwriteUp = true
const appwrite = http.createServer((req, res) => {
if (!appwriteUp) return req.socket.destroy() // wie beim Boot: "fetch failed"
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ total: 1, documents: [{ subdomain: 'oeffentlich' }] }))
})
let tmp
let hostsFile
let previewDeploy
before(async () => {
await new Promise((resolve) => appwrite.listen(0, '127.0.0.1', resolve))
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'preview-sync-'))
for (const name of ['oeffentlich', 'privat']) {
await fs.mkdir(path.join(tmp, 'previews', name), { recursive: true })
}
hostsFile = path.join(tmp, 'dynamic', 'preview-hosts.yml')
Object.assign(process.env, {
APPWRITE_ENDPOINT: `http://127.0.0.1:${appwrite.address().port}/v1`,
APPWRITE_API_KEY: 'test-key',
PREVIEW_DEPLOY_ROOT: path.join(tmp, 'previews'),
TRAEFIK_DYNAMIC_DIR: path.join(tmp, 'dynamic'),
})
// config.js liest die Umgebung beim Import
previewDeploy = await import('../server/services/previewDeploy.js')
})
after(async () => {
appwrite.close()
await fs.rm(tmp, { recursive: true, force: true })
})
const publicInFile = async () =>
[...previewDeploy.publicSubdomainsFromHostsFile(await fs.readFile(hostsFile, 'utf8'))].sort()
/* ------------------------------------------------------------- Datei --- */
test('Datei lesen: nur HTTPS-Router ohne preview-chain gelten als öffentlich', () => {
const content = [
'# Auto-generated by previewDeploy.syncPreviewTls - do not edit manually',
'http:',
' routers:',
' preview-a-b-https:',
' rule: "Host(`a-b.project.webklar.com`)"',
' service: preview-nginx@docker',
' priority: 100',
'',
' preview-a-b-http:',
' middlewares:',
' - preview-sites-redirect@docker',
' preview-geheim-https:',
' service: preview-nginx@docker',
' middlewares:',
' - preview-chain@docker',
' preview-geheim-http:',
' priority: 100',
].join('\n')
assert.deepEqual([...previewDeploy.publicSubdomainsFromHostsFile(content)], ['a-b'])
assert.equal(previewDeploy.publicSubdomainsFromHostsFile('').size, 0)
assert.equal(previewDeploy.publicSubdomainsFromHostsFile(undefined).size, 0)
})
/* --------------------------------------------------------- Abgleich --- */
test('Abgleich mit Appwrite: öffentliche Seite ohne Login, Rest geschützt', async () => {
appwriteUp = true
const result = await previewDeploy.syncPreviewTls()
assert.deepEqual(result, { synced: true, hosts: 2, publicSource: 'appwrite' })
assert.deepEqual(await publicInFile(), ['oeffentlich'])
})
test('Neustart, Appwrite noch nicht da: öffentliche Seiten bleiben öffentlich, neue bleiben geschützt', async () => {
appwriteUp = true
await previewDeploy.syncPreviewTls()
await fs.mkdir(path.join(tmp, 'previews', 'neu'))
appwriteUp = false
try {
const result = await previewDeploy.syncPreviewTls()
assert.deepEqual(result, { synced: true, hosts: 3, publicSource: 'previous-file' })
assert.deepEqual(await publicInFile(), ['oeffentlich'])
assert.match(await fs.readFile(hostsFile, 'utf8'), /preview-neu-https:/)
} finally {
appwriteUp = true
await fs.rm(path.join(tmp, 'previews', 'neu'), { recursive: true })
}
})
test('Ohne Appwrite und ohne alte Datei: alles login-geschützt (sicherer Default)', async () => {
await fs.rm(hostsFile, { force: true })
appwriteUp = false
try {
const result = await previewDeploy.syncPreviewTls()
assert.equal(result.publicSource, 'previous-file')
assert.deepEqual(await publicInFile(), [])
} finally {
appwriteUp = true
}
})
/* ---------------------------------------------------------- Startup --- */
test('Startup: wiederholt, bis der Abgleich mit Appwrite-Daten geklappt hat', async () => {
const answers = [
{ synced: true, hosts: 2, publicSource: 'previous-file' },
new Error('Schreiben fehlgeschlagen'),
{ synced: true, hosts: 2, publicSource: 'appwrite' },
]
let calls = 0
const sync = async () => {
const answer = answers[calls++]
if (answer instanceof Error) throw answer
return answer
}
const result = await previewDeploy.syncPreviewTlsOnStartup({ sync, retryMs: 0 })
assert.equal(calls, 3)
assert.equal(result.publicSource, 'appwrite')
})
test('Startup: ohne Preview-Konfiguration kein Wiederholen', async () => {
let calls = 0
const sync = async () => {
calls++
return { synced: false, reason: 'PREVIEW_DEPLOY_ROOT oder TRAEFIK_DYNAMIC_DIR nicht gesetzt' }
}
await previewDeploy.syncPreviewTlsOnStartup({ sync, retryMs: 0 })
assert.equal(calls, 1)
})
test('Startup: gibt nach maxAttempts auf', async () => {
let calls = 0
const sync = async () => {
calls++
return { synced: true, hosts: 2, publicSource: 'previous-file' }
}
const result = await previewDeploy.syncPreviewTlsOnStartup({ sync, retryMs: 0, maxAttempts: 4 })
assert.equal(calls, 4)
assert.equal(result, null)
})