From cd9aebe4b16266ad9d5fac6bfa45d411903d1a33 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 09:24:57 +0000 Subject: [PATCH] Analytics: Admin-Routen nur auf internem Listener (R5), Transaktion, Duplikat-Check Co-Authored-By: Claude Sonnet 5.5 --- analytics/src/config.js | 1 + analytics/src/index.js | 19 ++++++++++++++++-- analytics/src/server.js | 25 ++++++++++++++++++------ analytics/test/sites.test.js | 38 ++++++++++++++++++++++++++++++++---- 4 files changed, 71 insertions(+), 12 deletions(-) diff --git a/analytics/src/config.js b/analytics/src/config.js index c53532f..1c6148f 100644 --- a/analytics/src/config.js +++ b/analytics/src/config.js @@ -2,6 +2,7 @@ export function loadConfig(env = process.env) { return { port: Number(env.ANALYTICS_PORT) || 8090, + adminPort: Number(env.ANALYTICS_ADMIN_PORT) || 8091, dataDir: env.ANALYTICS_DATA_DIR || '/srv/webklar-analytics', adminToken: env.ANALYTICS_ADMIN_TOKEN || '', heatSecret: env.ANALYTICS_HEAT_SECRET || '', diff --git a/analytics/src/index.js b/analytics/src/index.js index 1249bd7..5c760d6 100644 --- a/analytics/src/index.js +++ b/analytics/src/index.js @@ -2,9 +2,24 @@ import { mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { loadConfig } from './config.js'; import { openDb } from './db.js'; -import { createServer } from './server.js'; +import { createPublicServer, createAdminServer } from './server.js'; const config = loadConfig(process.env); mkdirSync(config.dataDir, { recursive: true }); const db = openDb(join(config.dataDir, 'analytics.db')); -createServer({ config, db }).listen(config.port, () => console.log(`Analytics-Dienst auf Port ${config.port}`)); + +const servers = [ + ['öffentlich', createPublicServer({ config, db }), config.port], + ['intern (Admin)', createAdminServer({ config, db }), config.adminPort], +]; +for (const [name, server, port] of servers) { + server.on('error', (e) => { console.error(`Analytics-Listener ${name} (Port ${port}):`, e.message); process.exit(1); }); + server.listen(port, () => console.log(`Analytics-Dienst ${name} auf Port ${port}`)); +} + +process.on('SIGTERM', () => { + let open = servers.length; + const done = () => { if (--open === 0) { db.close(); process.exit(0); } }; + for (const [, server] of servers) { server.close(done); server.closeIdleConnections?.(); } + setTimeout(() => process.exit(0), 5000).unref(); +}); diff --git a/analytics/src/server.js b/analytics/src/server.js index 7324fd1..245812a 100644 --- a/analytics/src/server.js +++ b/analytics/src/server.js @@ -48,11 +48,12 @@ function validateSite(b) { || !b.domains.every((d) => typeof d === 'string' && d.length <= 253 && HOSTNAME.test(d))) { return { error: 'Ungültige Domains (1 bis 5 Hostnamen, klein geschrieben, ohne Port und Pfad)' }; } + if (new Set(b.domains).size !== b.domains.length) return { error: 'Doppelte Domains' }; if (typeof b.active !== 'boolean') return { error: 'Ungültiger Status' }; return { site: { projectId: b.projectId, domains: b.domains, active: b.active } }; } -export function createServer({ config, db }) { +function createHandlerServer({ config, db, role }) { return http.createServer(async (req, res) => { try { const url = new URL(req.url, 'http://x'); @@ -61,17 +62,24 @@ export function createServer({ config, db }) { if (method === 'GET' && url.pathname === '/a/health') return send(res, 200, { ok: true }); - if (parts[0] === 'a' && parts[1] === 'admin' && parts[2] === 'sites' && parts.length === 4 + if (role === 'admin' && parts[0] === 'a' && parts[1] === 'admin' && parts[2] === 'sites' && parts.length === 4 && (method === 'PUT' || method === 'DELETE')) { if (!tokenOk(req.headers.authorization, config.adminToken)) return send(res, 401, { error: 'Nicht autorisiert' }); const key = parts[3]; if (!SITE_KEY.test(key)) return send(res, 400, { error: 'Ungültiger Website-Schlüssel' }); if (method === 'DELETE') { - db.prepare('DELETE FROM sites WHERE siteKey = ?').run(key); - db.prepare('DELETE FROM events_raw WHERE site = ?').run(key); - db.prepare('DELETE FROM clicks WHERE site = ?').run(key); - db.prepare('DELETE FROM daily WHERE site = ?').run(key); + db.exec('BEGIN'); + try { + db.prepare('DELETE FROM sites WHERE siteKey = ?').run(key); + db.prepare('DELETE FROM events_raw WHERE site = ?').run(key); + db.prepare('DELETE FROM clicks WHERE site = ?').run(key); + db.prepare('DELETE FROM daily WHERE site = ?').run(key); + db.exec('COMMIT'); + } catch (e) { + db.exec('ROLLBACK'); + throw e; + } return send(res, 200, { ok: true }); } const v = validateSite(await readJson(req, MAX_ADMIN_BODY)); @@ -93,3 +101,8 @@ export function createServer({ config, db }) { } }); } + +// Öffentlicher Listener: nur /a/health (Erfassung folgt); Admin-Routen gibt es hier nicht. +export const createPublicServer = ({ config, db }) => createHandlerServer({ config, db, role: 'public' }); +// Interner Listener für die Website-Verwaltung. +export const createAdminServer = ({ config, db }) => createHandlerServer({ config, db, role: 'admin' }); diff --git a/analytics/test/sites.test.js b/analytics/test/sites.test.js index d57a884..c600457 100644 --- a/analytics/test/sites.test.js +++ b/analytics/test/sites.test.js @@ -1,14 +1,17 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { loadConfig } from '../src/config.js'; import { openDb } from '../src/db.js'; -import { createServer } from '../src/server.js'; +import { createPublicServer, createAdminServer } from '../src/server.js'; const KEY = 'abcdef0123456789abcd'; -async function setup(adminToken = 'geheim') { +async function setup(adminToken = 'geheim', make = createAdminServer) { const db = openDb(':memory:'); - const server = createServer({ config: { adminToken }, db }); + const server = make({ config: { adminToken }, db }); await new Promise((r) => server.listen(0, '127.0.0.1', r)); const base = `http://127.0.0.1:${server.address().port}`; const call = async (method, path, body, token = 'geheim', raw) => { @@ -25,7 +28,7 @@ const valid = { projectId: 'p1', domains: ['abc.project.webklar.com'], active: t test('loadConfig: Vorgaben und Umgebung', () => { const c = loadConfig({}); - assert.deepEqual(c, { port: 8090, dataDir: '/srv/webklar-analytics', adminToken: '', heatSecret: '', geoFile: '', baseHost: 'project.webklar.com' }); + assert.deepEqual(c, { port: 8090, adminPort: 8091, dataDir: '/srv/webklar-analytics', adminToken: '', heatSecret: '', geoFile: '', baseHost: 'project.webklar.com' }); const d = loadConfig({ ANALYTICS_PORT: '9000', ANALYTICS_ADMIN_TOKEN: 't', ANALYTICS_HEAT_SECRET: 'h' }); assert.equal(d.port, 9000); assert.equal(d.adminToken, 't'); @@ -136,3 +139,30 @@ test('DELETE entfernt Website, unbekannte Route 404', async () => { assert.equal((await t.call('GET', '/a/nichts', undefined, null)).status, 404); t.close(); }); + +test('Admin-Route auf dem öffentlichen Listener: 404 auch mit richtigem Token', async () => { + const t = await setup('geheim', createPublicServer); + assert.equal((await t.call('PUT', `/a/admin/sites/${KEY}`, valid)).status, 404); + assert.equal((await t.call('DELETE', `/a/admin/sites/${KEY}`)).status, 404); + assert.equal(row(t.db), undefined); + assert.equal((await t.call('GET', '/a/health', undefined, null)).status, 200); + t.close(); +}); + +test('Doppelte Domains: 400', async () => { + const t = await setup(); + assert.equal((await t.call('PUT', `/a/admin/sites/${KEY}`, { ...valid, domains: ['a.de', 'a.de'] })).status, 400); + t.close(); +}); + +test('Datei-DB: zweimal öffnen (idempotent) und WAL', () => { + const f = join(mkdtempSync(join(tmpdir(), 'ana-')), 'a.db'); + const d1 = openDb(f); + d1.prepare("INSERT INTO sites VALUES ('k','p','[]',1,NULL)").run(); + assert.equal(d1.prepare('PRAGMA journal_mode').get().journal_mode, 'wal'); + d1.close(); + const d2 = openDb(f); + assert.equal(d2.prepare('SELECT COUNT(*) c FROM sites').get().c, 1); + assert.equal(d2.prepare('PRAGMA journal_mode').get().journal_mode, 'wal'); + d2.close(); +});