diff --git a/CHANGELOG.md b/CHANGELOG.md index ba4df3d0..90c43576 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,12 +26,17 @@ ### Security - **Das Admin-UI war standardmäßig unverschlüsselt aus dem Internet erreichbar.** Die ausgelieferte Compose-Datei nutzt `network_mode: host`; zusammen mit dem bisherigen Default `GC_HOST=0.0.0.0` lauschte der Node-Prozess damit auf allen Interfaces des Hosts — an Caddy, TLS und HSTS vorbei. Der Default bindet jetzt `127.0.0.1`, in `config/default.js` **und** in beiden `.env.example`. Caddy erreicht die App unverändert über `127.0.0.1:3000`. **Achtung beim Update:** Wer den Port aus einem Bridge-Netzwerk veröffentlicht statt Host-Networking zu nutzen, muss `GC_HOST=0.0.0.0` künftig ausdrücklich setzen. Bestehende Installationen übernehmen die Änderung erst nach Anpassung der `.env` und einem Neustart des Containers. +- **API-Token konnten sitzungs-exklusive Einstellungen über eine Pfadvariante schreiben.** Die Sperrliste in `/api/v1/settings` prüfte den Pfad exakt, Express routet aber ohne Beachtung von Groß-/Kleinschreibung und abschließendem Schrägstrich — `PUT /api/v1/settings/security/` oder `.../ROUTE-BLOCK-DEFAULT` liefen am Guard vorbei (und dabei auch an der CSRF-Prüfung, die für Token-Anfragen entfällt). Der Pfad wird jetzt vor dem Abgleich normalisiert; das wirkt für alle gesperrten Routen gleichzeitig. + +### Added +- Die Kontaktadresse für Let's Encrypt lässt sich in Einstellungen → Allgemein setzen und wirkt sofort, ohne Container-Neustart. Bisher war sie nur über `GC_CADDY_EMAIL` in der `.env` konfigurierbar. Ist das Feld leer, gilt weiterhin der Wert aus der `.env`. Die Einstellung ist bewusst nur mit einer Sitzung schreibbar, nicht per API-Token — sie bestimmt das Konto für alle öffentlichen Zertifikate. ### Fixed - Einstellungen → Allgemein: Das Standard-Design ließ sich in den Themes Classic und Pro nicht auf Aurora stellen — der Aurora-Knopf fehlte dort, obwohl der Server den Wert längst akzeptiert. Beide Templates bieten jetzt alle drei Designs an, ein Paritätstest hält die Auswahl über alle Themes und beide Seiten (Profil + Einstellungen) synchron. - Caddy versuchte dauerhaft, für den internen Ownership-Marker `gc-owner.invalid` ein öffentliches Zertifikat zu beziehen, was den Log mit fehlschlagenden ACME-Versuchen füllte. Die Marker-Route wird erst nach dem Aufbau der TLS-Automation angehängt und erreichte deren TLD-Klassifizierung nie; sie ist jetzt ausdrücklich vom automatischen HTTPS ausgenommen. - Einstellungen → Portal: Beim Setzen einer Portal-Basisdomain wurde nur die Basisdomain auf ihre DNS-Verifizierung geprüft, nie der daraus zusammengesetzte Host `.`. Fehlte dessen A/AAAA-Eintrag, landete er trotzdem in der TLS-Automation und Caddy versuchte 30 Tage lang (`max_duration`) erfolglos ein Zertifikat beim produktiven Let's-Encrypt-CA zu beziehen — das Kontingent von 5 fehlgeschlagenen Prüfungen pro Stunde und Hostname war sofort erschöpft, ohne jede Rückmeldung im UI. Der zusammengesetzte Host wird jetzt vor dem Speichern selbst aufgelöst und mit der Server-IP verglichen; passt er nicht, lehnt die API mit einem eigenen Fehlertext ab. Ein nicht erreichbarer Resolver blockiert nicht. - Der Host des Verwaltungs-UI (`GC_BASE_URL`) wurde der TLS-Automation nie mitgegeben, weil seine Route erst danach angelegt wird. Ohne passende Route-Domain fiel er auf Caddys Standard-Automation zurück — ein ACME-Konto ganz ohne Kontaktadresse, obwohl `GC_CADDY_EMAIL` gesetzt war, sodass für dieses Zertifikat nie Ablaufbenachrichtigungen verschickt wurden. Er wird jetzt wie der Portal-Host ausdrücklich übergeben. +- Die Autosave-Bestätigung in den Einstellungen zeigte auch in der deutschen Oberfläche „Saved" — der Übersetzungsschlüssel fehlte in der Client-Whitelist aller drei Designs. --- diff --git a/public/js/settings.js b/public/js/settings.js index f58e2182..f3e98806 100644 --- a/public/js/settings.js +++ b/public/js/settings.js @@ -1428,6 +1428,34 @@ }); })(); +// ─── ACME contact email ─────────────────────────────── +(function () { + var el = document.getElementById('acme-email'); + if (!el) return; + var t = (window.GC && window.GC.t) || {}; + SettingsAutosave.bind({ + cluster: 'acme-email', + fields: [el], + statusEl: document.getElementById('acme-email-status'), + valuesById: function () { return { 'acme-email': el.value }; }, + save: function () { + return api.put('/api/v1/settings/acme-email', { email: el.value }).then(function (data) { + // Der Server speichert auch dann, wenn der Caddy-Push scheitert. Die + // Warnung MUSS als ok:false zurückgegeben werden — gäbe man data + // unverändert zurück, überschriebe settingsAutosave.js:90 mit flash() + // jeden selbst gesetzten Text durch "Gespeichert". Als ok:false greift + // showError(), und der Snapshot bleibt alt → das nächste Verlassen des + // Feldes wiederholt den Push. + // ponytail: Retry ohne Extra-Code; der Wert liegt serverseitig bereits. + if (data && data.warning) { + return { ok: false, error: t[data.warning] || undefined }; + } + return data; + }); + }, + }); +})(); + (function () { var sliderEl = document.getElementById('gw-down-threshold'); var sliderOut = document.getElementById('gw-down-threshold-value'); diff --git a/src/i18n/de.json b/src/i18n/de.json index 4efd7018..483aeb9e 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -775,6 +775,8 @@ "error.settings.app_get": "App-Einstellungen konnten nicht geladen werden", "error.settings.logs_clear": "Logs konnten nicht gelöscht werden", "error.settings.user_not_found": "Benutzer nicht gefunden", + "error.settings.acme_email_invalid": "Ungültige E-Mail-Adresse", + "error.settings.acme_email_save": "Kontaktadresse konnte nicht gespeichert werden", "error.backup.create": "Backup konnte nicht erstellt werden", "error.backup.preview": "Backup-Vorschau fehlgeschlagen", "error.backup.restore": "Backup konnte nicht wiederhergestellt werden", @@ -1982,6 +1984,10 @@ "portal.pihole.owner.devices_in_snapshot_hint": "Nur Geräte in Pi-holes Top-10; weitere folgen in einem späteren Update.", "portal.pihole.household.active_clients": "{n} aktive Clients", "settings.autosave.saved": "Gespeichert", + "settings.acme_email": "ACME-Kontaktadresse", + "settings.acme_email_hint": "Für Ablaufwarnungen von Let's Encrypt. Leer lassen, um den Wert aus der .env zu verwenden.", + "settings.acme_email.inherited": "Aus der .env übernommen", + "settings.acme_email.push_failed": "Gespeichert — wirkt erst mit dem nächsten erfolgreichen Schreiben der Caddy-Konfiguration", "settings.autosave.error": "Speichern fehlgeschlagen", "settings.autosave.pending": "Wird gespeichert, sobald alle Pflichtfelder ausgefüllt sind", "settings.autosave.confirm_self": "Diese Änderung kann deine aktuelle Sitzung betreffen. Übernehmen?", diff --git a/src/i18n/en.json b/src/i18n/en.json index a3b1b748..fc594a50 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -795,6 +795,8 @@ "error.settings.app_get": "Failed to get app settings", "error.settings.logs_clear": "Failed to clear logs", "error.settings.user_not_found": "User not found", + "error.settings.acme_email_invalid": "Invalid email address", + "error.settings.acme_email_save": "Could not save the contact address", "error.backup.create": "Failed to create backup", "error.backup.preview": "Failed to preview backup", @@ -2038,6 +2040,10 @@ "portal.pihole.owner.devices_in_snapshot_hint": "Only devices in Pi-hole's top 10; more in a later update.", "portal.pihole.household.active_clients": "{n} active clients", "settings.autosave.saved": "Saved", + "settings.acme_email": "ACME contact address", + "settings.acme_email_hint": "Used for expiry warnings from Let's Encrypt. Leave empty to use the value from .env.", + "settings.acme_email.inherited": "Inherited from .env", + "settings.acme_email.push_failed": "Saved — takes effect with the next successful write of the Caddy configuration", "settings.autosave.error": "Save failed", "settings.autosave.pending": "Will save once all required fields are filled", "settings.autosave.confirm_self": "This change can affect your current session. Apply it?", diff --git a/src/routes/api/settings/appearance.js b/src/routes/api/settings/appearance.js index e1f33080..a0458a38 100644 --- a/src/routes/api/settings/appearance.js +++ b/src/routes/api/settings/appearance.js @@ -8,6 +8,8 @@ const { getDb } = require('../../../db/connection'); const settings = require('../../../services/settings'); const config = require('../../../../config/default'); const logger = require('../../../utils/logger'); +const { validateEmail } = require('../../../utils/validate'); +const activity = require('../../../services/activity'); const router = Router(); @@ -60,4 +62,60 @@ router.put('/default-theme', (req, res) => { } }); +/** + * PUT /api/settings/acme-email — Kontaktadresse für Let's Encrypt. + * + * Zugriffsschutz: requireAuth vom API-Router plus der TOKEN_FORBIDDEN-Eintrag + * in settings/index.js. Das ist KEINE Administratorprüfung — der gesamte + * Settings-Baum hat heute keine Rollenprüfung (middleware/auth.js:33). + * Bewusst Status quo; ein einzelner Rollen-Guard hier würde Schutz suggerieren, + * den die Nachbarrouten nicht haben. + */ +router.put('/acme-email', async (req, res) => { + try { + const raw = req.body ? req.body.email : undefined; + // Ein fehlendes Feld ist ein Client-Fehler, KEIN Löschbefehl. Der Rohwert + // darf deshalb nicht über String(raw || '') normalisiert werden — das ließe + // {} still eine gültige Konfiguration löschen. + if (typeof raw !== 'string') { + return res.status(400).json({ ok: false, error: req.t('error.settings.acme_email_invalid') }); + } + const trimmed = raw.trim(); + if (trimmed && validateEmail(trimmed)) { + return res.status(400).json({ ok: false, error: req.t('error.settings.acme_email_invalid') }); + } + + if (settings.get('caddy.acme_email', '') !== trimmed) { + settings.set('caddy.acme_email', trimmed); + activity.log('acme_email_updated', + trimmed ? 'ACME contact email updated' : 'ACME contact email cleared (falls back to .env)', + { source: 'admin', ipAddress: req.ip, severity: 'info' }); + } + + // Der Push läuft AUCH bei unverändertem Wert — bewusst anders als der + // changed-Guard in network.js:176. Nach einem gescheiterten Push liegt der + // Wert schon in der DB; ein Retry mit derselben Adresse muss ihn erneut + // ausliefern, sonst quittiert die Oberfläche einen Erfolg, den es nie gab. + // + // syncToCaddy signalisiert Misserfolg auf ZWEI Wegen: es wirft (Caddy nicht + // erreichbar) oder liefert false, wenn der Ownership-Guard das /load + // verweigert (caddyConfig.js:962/:969). Unter NODE_ENV=test kehrt es sofort + // zurück und liefert undefined — das zählt als Erfolg, weil es nicht false ist. + // Nebenläufigkeit ist gedeckt: syncToCaddy serialisiert global über _syncChain + // (:925-929) und baut die Config erst beim Ausführen des Kettenglieds, liest + // also immer den dann aktuellen DB-Wert. + let pushed = true; + try { + pushed = (await require('../../../services/caddyConfig').syncToCaddy()) !== false; + } catch (e) { + logger.error({ error: e.message }, 'ACME email saved but Caddy push failed'); + pushed = false; + } + res.json(pushed ? { ok: true } : { ok: true, warning: 'settings.acme_email.push_failed' }); + } catch (err) { + logger.error({ error: err.message }, 'Failed to set ACME contact email'); + res.status(500).json({ ok: false, error: req.t('error.settings.acme_email_save') }); + } +}); + module.exports = router; diff --git a/src/routes/api/settings/index.js b/src/routes/api/settings/index.js index a9040b76..e6e6b81b 100644 --- a/src/routes/api/settings/index.js +++ b/src/routes/api/settings/index.js @@ -29,10 +29,18 @@ const TOKEN_FORBIDDEN = [ /^\/profile$/, /^\/route-block-default$/, /^\/domains(\/|$)/, + // TLS-steuernd: die ACME-Kontaktadresse entscheidet, unter welchem + // Let's-Encrypt-Konto ALLE öffentlichen Zertifikate ausgestellt werden, + // und jedes Schreiben löst ein Caddy /load aus. Session-only, wie /dns. + /^\/acme-email$/, ]; router.use((req, res, next) => { if (!req.tokenAuth) return next(); - if (TOKEN_FORBIDDEN.some(rx => rx.test(req.path))) { + // Express läuft mit strict:false/caseSensitive:false — `/security/` und + // `/SECURITY` erreichen dieselbe Route. Vor dem Abgleich normalisieren, + // sonst umgeht jede Schreibweise die Liste. + const p = req.path.toLowerCase().replace(/\/+$/, '') || '/'; + if (TOKEN_FORBIDDEN.some(rx => rx.test(p))) { return res.status(403).json({ ok: false, error: 'Forbidden for token auth' }); } next(); diff --git a/src/routes/index.js b/src/routes/index.js index 40394f75..3c0203fa 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -242,7 +242,16 @@ pages.forEach(({ path, template, titleKey }) => { extraLocals.settings = { gateway_down_threshold_s: require('../services/settings').get('gateway_down_threshold_s'), }; - } catch { extraLocals.settings = {}; } + extraLocals.settingsAcmeEmail = String(require('../services/settings').get('caddy.acme_email', '') || '').trim(); + } catch { extraLocals.settings = {}; extraLocals.settingsAcmeEmail = ''; } + // Ob eine Adresse aus der .env geerbt wird — als BOOLEAN, nicht als Wert. + // config.caddy.email steht heute in KEINER API-Antwort (GET /settings/app + // liefert settings.getAll() plus einen festen config-Ausschnitt ohne caddy, + // appearance.js:21-29), und /settings ist nur durch requireAuth geschützt. + // Den Klartext auszuliefern wäre also eine neue Preisgabe an jede Session + // inklusive role='user'; der Hinweis "Aus der .env übernommen" trägt + // dieselbe Information ohne den Wert. + extraLocals.acmeEmailInherited = Boolean(String((config.caddy || {}).email || '').trim()); } // Dashboard-only: gateways that need re-pairing after master-key rotation diff --git a/src/services/caddyConfig.js b/src/services/caddyConfig.js index b00ffd02..c6959ae5 100644 --- a/src/services/caddyConfig.js +++ b/src/services/caddyConfig.js @@ -188,6 +188,19 @@ function buildExternalBlockHandler(route) { return [{ handler: 'static_response', status_code: 404 }]; } +// Die Einstellung schlägt die Umgebungsvariable; ist sie leer, gilt weiter +// GC_CADDY_EMAIL. Gleiche Bauform wie effectivePortalHost() (portalConfig.js:33). +// Der Auflöser lebt hier und nicht in caddyTlsAutomation.js — jenes Modul ist +// bewusst importfrei und seine Tests kommen ohne Datenbank aus. +function effectiveAcmeEmail() { + // settings ist in dieser Datei NICHT auf Modulebene importiert — lokal + // requiren, genau wie in buildExternalBlockHandler (:167). + const settings = require('./settings'); + const fromDb = String(settings.get('caddy.acme_email', '') || '').trim(); + if (fromDb) return fromDb; + return String((config.caddy && config.caddy.email) || '').trim(); +} + // ─── Build Caddy JSON config from all enabled routes ──── /** * Build Caddy configuration JSON. Overloaded: @@ -686,7 +699,7 @@ function buildCaddyConfig(injectedRoutes, options = {}) { try { gcHost = new URL(config.app.baseUrl || '').hostname.toLowerCase(); } catch { /* unset/invalid baseUrl */ } const forceInternal = portal.public ? [] : [homeHost]; const tlsDomains = [...new Set([...Object.keys(caddyRoutes), homeHost, gcHost].filter(Boolean))]; - const tlsConfig = buildTlsAutomation(tlsDomains, config.caddy, forceInternal); + const tlsConfig = buildTlsAutomation(tlsDomains, { ...config.caddy, email: effectiveAcmeEmail() }, forceInternal); if (tlsConfig) caddyConfig.apps.tls = tlsConfig; // GateControl management UI route @@ -1037,6 +1050,7 @@ async function _syncToCaddyInner() { module.exports = { caddyApi, buildCaddyConfig, + effectiveAcmeEmail, syncToCaddy, getAclPeers, setAclPeers, diff --git a/src/services/skoda/skodaAccounts.js b/src/services/skoda/skodaAccounts.js index 20d9457a..46a1747d 100644 --- a/src/services/skoda/skodaAccounts.js +++ b/src/services/skoda/skodaAccounts.js @@ -2,21 +2,13 @@ const { getDb } = require('../../db/connection'); const { encrypt, decrypt } = require('../../utils/crypto'); +const { validateEmail } = require('../../utils/validate'); function err(message, code) { const e = new Error(message); e.code = code; return e; } -function isValidEmail(email) { - // Linear index checks instead of a regex: /.+@.+/ on user input is a - // polynomial-ReDoS risk (CodeQL js/polynomial-redos). indexOf is O(n) and - // the length cap is the RFC 5321 maximum. - if (email.length > 254) return false; - const at = email.indexOf('@'); - return at > 0 && at < email.length - 1; -} - function createAccount({ email, password }) { const trimmed = typeof email === 'string' ? email.trim() : ''; - if (!trimmed || !isValidEmail(trimmed)) throw err('valid email required', 'SKODA_VALIDATION'); + if (validateEmail(trimmed)) throw err('valid email required', 'SKODA_VALIDATION'); if (!password || typeof password !== 'string') throw err('password required', 'SKODA_VALIDATION'); const db = getDb(); try { diff --git a/src/utils/validate.js b/src/utils/validate.js index 03878ba3..b3f66030 100644 --- a/src/utils/validate.js +++ b/src/utils/validate.js @@ -208,6 +208,42 @@ function validatePasswordComplexity(password) { return errors.length > 0 ? errors : null; } +function validateEmail(email) { + if (typeof email !== 'string') return 'Email is required'; + const trimmed = email.trim(); + if (!trimmed) return 'Email is required'; + // Lineare Prüfungen statt Regex über die Nutzereingabe: /.+@.+/ ist ein + // polynomial-ReDoS-Risiko (CodeQL js/polynomial-redos) — genau dieser Befund + // trat in skodaAccounts.createAccount auf. Streng, weil der Wert in eine + // ACME-Kontoregistrierung wandert: was Let's Encrypt ablehnt, blockiert die + // Ausstellung für ALLE öffentlichen Domains. + if (trimmed.length > 254) return 'Email too long (max 254 chars)'; + for (let i = 0; i < trimmed.length; i++) { + const c = trimmed.charCodeAt(i); + // nur druckbares ASCII → keine Steuerzeichen, kein inneres Whitespace, + // keine Unicode-Tricks (RTL-Override, Homoglyphen) + if (c < 0x21 || c > 0x7e) return 'Invalid email format'; + } + const at = trimmed.indexOf('@'); + if (at <= 0 || at !== trimmed.lastIndexOf('@') || at === trimmed.length - 1) return 'Invalid email format'; + if (at > 64) return 'Invalid email format'; // RFC 5321 local part + const local = trimmed.slice(0, at); + if (local.startsWith('.') || local.endsWith('.') || local.includes('..')) return 'Invalid email format'; + const domain = trimmed.slice(at + 1); + if (domain.length > 253) return 'Invalid email format'; + if (!domain.includes('.') || domain.includes('..')) return 'Invalid email format'; + if (domain.startsWith('.') || domain.startsWith('-') || domain.endsWith('.') || domain.endsWith('-')) { + return 'Invalid email format'; + } + for (let i = 0; i < domain.length; i++) { + const c = domain.charCodeAt(i); + const ldh = (c >= 0x30 && c <= 0x39) || (c >= 0x41 && c <= 0x5a) + || (c >= 0x61 && c <= 0x7a) || c === 0x2d || c === 0x2e; + if (!ldh) return 'Invalid email format'; + } + return null; +} + module.exports = { validatePeerName, validateDomain, @@ -228,4 +264,5 @@ module.exports = { parsePortRange, isPrivateIpv4, isLoopbackHost, + validateEmail, }; diff --git a/templates/aurora/layout.njk b/templates/aurora/layout.njk index 6ea8ac15..d77b4c7e 100644 --- a/templates/aurora/layout.njk +++ b/templates/aurora/layout.njk @@ -617,7 +617,9 @@ 'skoda.details.score_monthly': {{ t('skoda.details.score_monthly') | dump | safe }}, 'skoda.details.score_as_of': {{ t('skoda.details.score_as_of') | dump | safe }}, 'skoda.details.load_error': {{ t('skoda.details.load_error') | dump | safe }}, - 'skoda.details.rate_limited': {{ t('skoda.details.rate_limited') | dump | safe }} + 'skoda.details.rate_limited': {{ t('skoda.details.rate_limited') | dump | safe }}, + 'settings.acme_email.push_failed': {{ t('settings.acme_email.push_failed') | dump | safe }}, + 'settings.autosave.saved': {{ t('settings.autosave.saved') | dump | safe }} } }; diff --git a/templates/aurora/pages/settings.njk b/templates/aurora/pages/settings.njk index 7cf294b3..a83e9e0c 100644 --- a/templates/aurora/pages/settings.njk +++ b/templates/aurora/pages/settings.njk @@ -75,6 +75,18 @@ +
+
+ + {{ t('settings.acme_email') }} +
+
+ +
{{ t('settings.acme_email_hint') }}
+
+
+
+
diff --git a/templates/default/layout.njk b/templates/default/layout.njk index daf3ed7b..d38335d7 100644 --- a/templates/default/layout.njk +++ b/templates/default/layout.njk @@ -610,7 +610,9 @@ 'skoda.details.score_monthly': {{ t('skoda.details.score_monthly') | dump | safe }}, 'skoda.details.score_as_of': {{ t('skoda.details.score_as_of') | dump | safe }}, 'skoda.details.load_error': {{ t('skoda.details.load_error') | dump | safe }}, - 'skoda.details.rate_limited': {{ t('skoda.details.rate_limited') | dump | safe }} + 'skoda.details.rate_limited': {{ t('skoda.details.rate_limited') | dump | safe }}, + 'settings.acme_email.push_failed': {{ t('settings.acme_email.push_failed') | dump | safe }}, + 'settings.autosave.saved': {{ t('settings.autosave.saved') | dump | safe }} } }; diff --git a/templates/default/pages/settings.njk b/templates/default/pages/settings.njk index 34514f86..444c01ed 100644 --- a/templates/default/pages/settings.njk +++ b/templates/default/pages/settings.njk @@ -285,6 +285,18 @@
+
+
+ + {{ t('settings.acme_email') }} +
+
+ +
{{ t('settings.acme_email_hint') }}
+
+
+
+ {# ─── Peer-Gruppen + Tags: nebeneinander in einem Sub-Grid ─── #}
diff --git a/templates/pro/layout.njk b/templates/pro/layout.njk index d6cd6096..08328b1a 100644 --- a/templates/pro/layout.njk +++ b/templates/pro/layout.njk @@ -612,7 +612,9 @@ 'skoda.details.score_monthly': {{ t('skoda.details.score_monthly') | dump | safe }}, 'skoda.details.score_as_of': {{ t('skoda.details.score_as_of') | dump | safe }}, 'skoda.details.load_error': {{ t('skoda.details.load_error') | dump | safe }}, - 'skoda.details.rate_limited': {{ t('skoda.details.rate_limited') | dump | safe }} + 'skoda.details.rate_limited': {{ t('skoda.details.rate_limited') | dump | safe }}, + 'settings.acme_email.push_failed': {{ t('settings.acme_email.push_failed') | dump | safe }}, + 'settings.autosave.saved': {{ t('settings.autosave.saved') | dump | safe }} } }; diff --git a/templates/pro/pages/settings.njk b/templates/pro/pages/settings.njk index bdb61723..5b5b225f 100644 --- a/templates/pro/pages/settings.njk +++ b/templates/pro/pages/settings.njk @@ -164,6 +164,18 @@
+
+
+ + {{ t('settings.acme_email') }} +
+
+ +
{{ t('settings.acme_email_hint') }}
+
+
+
+ {# ─── Peer-Gruppen + Tags: nebeneinander in einem Sub-Grid ─── #}
diff --git a/tests/acme_email_resolver.test.js b/tests/acme_email_resolver.test.js new file mode 100644 index 00000000..285090cc --- /dev/null +++ b/tests/acme_email_resolver.test.js @@ -0,0 +1,81 @@ +'use strict'; +const { test, before, after, beforeEach } = require('node:test'); +const assert = require('node:assert/strict'); +const nodeCrypto = require('node:crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || nodeCrypto.randomBytes(32).toString('hex'); +const { setup, teardown } = require('./helpers/setup'); +let caddyConfig, settings, config; + +before(async () => { + await setup(); + caddyConfig = require('../src/services/caddyConfig'); + settings = require('../src/services/settings'); + config = require('../config/default'); +}); +after(async () => { await teardown(); }); +beforeEach(() => { + settings.set('caddy.acme_email', ''); + settings.set('portal.base_domain', ''); + config.caddy.email = ''; +}); + +test('the stored setting wins over the environment', () => { + config.caddy.email = 'env@example.com'; + settings.set('caddy.acme_email', 'db@example.com'); + assert.equal(caddyConfig.effectiveAcmeEmail(), 'db@example.com'); +}); + +test('an empty setting falls back to the environment', () => { + config.caddy.email = 'env@example.com'; + assert.equal(caddyConfig.effectiveAcmeEmail(), 'env@example.com'); +}); + +test('both empty yields an empty string', () => { + assert.equal(caddyConfig.effectiveAcmeEmail(), ''); +}); + +test('the stored value is trimmed', () => { + settings.set('caddy.acme_email', ' db@example.com '); + assert.equal(caddyConfig.effectiveAcmeEmail(), 'db@example.com'); +}); + +test('a never-written key behaves exactly like an empty one', () => { + const { getDb } = require('../src/db/connection'); + getDb().prepare('DELETE FROM settings WHERE key = ?').run('caddy.acme_email'); + config.caddy.email = 'env@example.com'; + assert.equal(caddyConfig.effectiveAcmeEmail(), 'env@example.com'); +}); + +function makePortalPublic() { + const { getDb } = require('../src/db/connection'); + getDb().prepare("INSERT OR IGNORE INTO domains (domain, status) VALUES ('example.com','verified')").run(); + settings.set('portal.base_domain', 'example.com'); + settings.set('portal.prefix', 'home'); +} +function acmeEmailsIn(cfg) { + return ((((cfg.apps || {}).tls || {}).automation || {}).policies || []) + .flatMap((p) => (p.issuers || []).map((i) => i.email)).filter(Boolean); +} + +test('the resolved address reaches the built caddy config', async () => { + makePortalPublic(); + settings.set('caddy.acme_email', 'db@example.com'); + const cfg = await caddyConfig.buildCaddyConfig(); + assert.ok(acmeEmailsIn(cfg).includes('db@example.com'), JSON.stringify((cfg.apps || {}).tls)); +}); + +test('an installation that never wrote the key still gets the .env address', async () => { + // Erfolgskriterium 7: Bestandsinstallationen behalten ihre apps.tls-Policies. + const { getDb } = require('../src/db/connection'); + getDb().prepare('DELETE FROM settings WHERE key = ?').run('caddy.acme_email'); + makePortalPublic(); + config.caddy.email = 'env@example.com'; + const cfg = await caddyConfig.buildCaddyConfig(); + assert.ok(acmeEmailsIn(cfg).includes('env@example.com'), 'Bestandsinstallation verlor ihre .env-Adresse'); +}); + +test('without any address there is no tls block at all (unchanged behaviour)', async () => { + // beforeEach hat portal.base_domain geleert -> homeHost ist wieder intern. + const cfg = await caddyConfig.buildCaddyConfig(); + assert.ok(!cfg.apps || !cfg.apps.tls, 'apps.tls darf ohne Adresse gar nicht existieren'); +}); diff --git a/tests/acme_email_route.test.js b/tests/acme_email_route.test.js new file mode 100644 index 00000000..df6398a5 --- /dev/null +++ b/tests/acme_email_route.test.js @@ -0,0 +1,119 @@ +'use strict'; +const { test, before, after, beforeEach } = require('node:test'); +const assert = require('node:assert/strict'); +const supertest = require('supertest'); +const nodeCrypto = require('node:crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || nodeCrypto.randomBytes(32).toString('hex'); +const { setup, teardown } = require('./helpers/setup'); +let ctx, settings, caddyConfig, origSync; + +before(async () => { + ctx = await setup(); + settings = require('../src/services/settings'); + caddyConfig = require('../src/services/caddyConfig'); + origSync = caddyConfig.syncToCaddy; +}); +after(async () => { + if (caddyConfig && origSync) caddyConfig.syncToCaddy = origSync; + await teardown(); +}); +beforeEach(() => { settings.set('caddy.acme_email', ''); caddyConfig.syncToCaddy = origSync; }); + +function put(body) { + return ctx.agent.put('/api/v1/settings/acme-email').set('x-csrf-token', ctx.csrfToken).send(body); +} + +test('a valid address is stored and pushed', async () => { + let calls = 0; + caddyConfig.syncToCaddy = async () => { calls += 1; }; + const res = await put({ email: ' me@example.com ' }); + assert.equal(res.status, 200); + assert.equal(res.body.warning, undefined); + assert.equal(settings.get('caddy.acme_email'), 'me@example.com'); // getrimmt gespeichert + assert.equal(calls, 1); +}); + +test('an empty string clears the setting and falls back to the environment', async () => { + caddyConfig.syncToCaddy = async () => {}; + for (const body of [{ email: '' }, { email: ' ' }]) { + settings.set('caddy.acme_email', 'old@example.com'); + const res = await put(body); + assert.equal(res.status, 200); + assert.equal(settings.get('caddy.acme_email'), ''); + } +}); + +test('a missing or non-string field is a client error, never a delete command', async () => { + caddyConfig.syncToCaddy = async () => { throw new Error('darf nicht aufgerufen werden'); }; + for (const body of [{}, { email: null }, { email: 42 }, { email: { a: 1 } }]) { + settings.set('caddy.acme_email', 'keep@example.com'); + const res = await put(body); + assert.equal(res.status, 400, `Body ${JSON.stringify(body)} ergab ${res.status}`); + assert.equal(settings.get('caddy.acme_email'), 'keep@example.com', 'Wert wurde angetastet'); + } +}); + +test('an invalid address is rejected without pushing', async () => { + let calls = 0; + caddyConfig.syncToCaddy = async () => { calls += 1; }; + settings.set('caddy.acme_email', 'keep@example.com'); + const res = await put({ email: 'no-at-sign' }); + assert.equal(res.status, 400); + assert.ok(res.body.error, 'Fehlertext fehlt'); // übersetzt, Wortlaut nicht festnageln + assert.equal(settings.get('caddy.acme_email'), 'keep@example.com'); + assert.equal(calls, 0); +}); + +test('a throwing push keeps the value and warns', async () => { + caddyConfig.syncToCaddy = async () => { throw new Error('caddy down'); }; + const res = await put({ email: 'me@example.com' }); + assert.equal(res.status, 200); + assert.equal(res.body.warning, 'settings.acme_email.push_failed'); + assert.equal(settings.get('caddy.acme_email'), 'me@example.com'); +}); + +test('a push refused by the ownership guard (false) also warns', async () => { + // syncToCaddy wirft in diesem Fall NICHT, es liefert false + // (caddyConfig.js:962 read-error, :969 foreign). + caddyConfig.syncToCaddy = async () => false; + const res = await put({ email: 'me@example.com' }); + assert.equal(res.status, 200); + assert.equal(res.body.warning, 'settings.acme_email.push_failed'); + assert.equal(settings.get('caddy.acme_email'), 'me@example.com'); +}); + +test('the push runs even when the value is unchanged (retry after a failed push)', async () => { + let calls = 0; + caddyConfig.syncToCaddy = async () => { calls += 1; }; + settings.set('caddy.acme_email', 'me@example.com'); + await put({ email: 'me@example.com' }); + assert.equal(calls, 1, 'ein Retry mit demselben Wert muss erneut pushen'); +}); + +test('an API token cannot write the ACME contact address', async () => { + const tokens = require('../src/services/tokens'); + const { rawToken } = tokens.create({ name: 'acme-tok', scopes: ['settings'] }, '127.0.0.1'); + settings.set('caddy.acme_email', 'keep@example.com'); + // Frische Anfrage OHNE Session-Cookie — requireAuth prüft die Session zuerst. + const res = await supertest(ctx.app) + .put('/api/v1/settings/acme-email') + .set('X-Api-Token', rawToken) + .send({ email: 'attacker@example.com' }); + assert.equal(res.status, 403); + assert.equal(settings.get('caddy.acme_email'), 'keep@example.com'); +}); + +test('a token cannot slip past the guard with a trailing slash or different casing', async () => { + // Express: strict:false/caseSensitive:false — diese Pfade treffen dieselbe Route. + const tokens = require('../src/services/tokens'); + const { rawToken } = tokens.create({ name: 'acme-tok-variants', scopes: ['settings'] }, '127.0.0.1'); + for (const path of ['/api/v1/settings/acme-email/', '/api/v1/settings/ACME-EMAIL']) { + settings.set('caddy.acme_email', 'keep@example.com'); + const res = await supertest(ctx.app) + .put(path) + .set('X-Api-Token', rawToken) + .send({ email: 'attacker@example.com' }); + assert.equal(res.status, 403, `${path} ergab ${res.status}`); + assert.equal(settings.get('caddy.acme_email'), 'keep@example.com', `${path} hat geschrieben`); + } +}); diff --git a/tests/acme_email_ui_parity.test.js b/tests/acme_email_ui_parity.test.js new file mode 100644 index 00000000..654d3250 --- /dev/null +++ b/tests/acme_email_ui_parity.test.js @@ -0,0 +1,61 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const de = require('../src/i18n/de.json'); +const en = require('../src/i18n/en.json'); + +const THEMES = ['aurora', 'default', 'pro']; +const ALL_KEYS = [ + 'settings.acme_email', + 'settings.acme_email_hint', + 'settings.acme_email.inherited', + 'settings.acme_email.push_failed', + 'error.settings.acme_email_invalid', + 'error.settings.acme_email_save', +]; +// Nur diese werden vom Client-JS gelesen und brauchen deshalb die GC.t-Brücke. +// settings.autosave.saved existiert seit je in beiden Sprachdateien, fehlt aber in +// ALLEN drei Whitelists — settingsAutosave.js:13 fällt deshalb auf das +// hartkodierte englische 'Saved' zurück, auch in der deutschen Oberfläche. +const BRIDGED = ['settings.acme_email.push_failed', 'settings.autosave.saved']; + +test('all new keys exist in de and en', () => { + for (const k of ALL_KEYS) { + assert.ok(de[k] && de[k].trim(), `de ${k}`); + assert.ok(en[k] && en[k].trim(), `en ${k}`); + } +}); + +test('client-read keys are bridged into GC.t in all three layouts', () => { + for (const theme of THEMES) { + const layout = fs.readFileSync(path.join(__dirname, '..', 'templates', theme, 'layout.njk'), 'utf8'); + // Mit Doppelpunkt prüfen (Projektkonvention, tests/i18n_update_keys.test.js:16): + // ohne ihn erfüllt schon ein beliebiges t('key')-Vorkommen die Assertion. + for (const k of BRIDGED) assert.ok(layout.includes(`'${k}':`), `${theme} ${k}`); + } +}); + +// PR #229: der Aurora-Knopf fehlte in zwei von drei Settings-Seiten — dieselbe +// Dreifach-Pflege, deshalb hier für alle drei Themes geprüft. +test('all three settings pages carry the field, its status line and the prefill', () => { + for (const theme of THEMES) { + const njk = fs.readFileSync(path.join(__dirname, '..', 'templates', theme, 'pages', 'settings.njk'), 'utf8'); + assert.match(njk, /id="acme-email"/, `${theme}/settings.njk fehlt id="acme-email"`); + assert.match(njk, /id="acme-email-status"/, `${theme}/settings.njk fehlt id="acme-email-status"`); + assert.match(njk, /value="\{\{ settingsAcmeEmail/, `${theme}: ohne Prefill ist "ändern" unmöglich`); + assert.match(njk, /acmeEmailInherited/, `${theme}: kein Hinweis auf den geerbten .env-Wert`); + } +}); + +test('the save handler routes the push warning through ok:false', () => { + // settingsAutosave.js:90 ruft bei ok:true unbedingt flash() und überschreibt + // jeden selbst gesetzten Statustext mit "Gespeichert". Die Warnung muss + // deshalb als ok:false zurückkommen, sonst ist sie unsichtbar. + const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'settings.js'), 'utf8'); + const i = js.indexOf("/api/v1/settings/acme-email"); + assert.ok(i > 0, 'Bindung an die acme-email-Route fehlt'); + assert.match(js.slice(i, i + 800), /data\.warning[\s\S]{0,300}ok:\s*false/, + 'Warnung muss als ok:false zurückgegeben werden'); +}); diff --git a/tests/validate_email.test.js b/tests/validate_email.test.js new file mode 100644 index 00000000..090daae3 --- /dev/null +++ b/tests/validate_email.test.js @@ -0,0 +1,47 @@ +'use strict'; +const { test, before, after, beforeEach } = require('node:test'); +const assert = require('node:assert/strict'); +const nodeCrypto = require('node:crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || nodeCrypto.randomBytes(32).toString('hex'); +const { validateEmail } = require('../src/utils/validate'); + +test('validateEmail accepts normal addresses', () => { + assert.equal(validateEmail('me@example.com'), null); + assert.equal(validateEmail(' me@example.com '), null); // wird getrimmt geprüft + assert.equal(validateEmail('first.last+tag@sub.example.co.uk'), null); +}); + +test('validateEmail rejects anything that would break an ACME registration', () => { + for (const bad of [ + '', ' ', null, undefined, 42, {}, + 'no-at-sign', '@leading.example', 'trailing@', + 'a@b', // Kontaktdomain ohne Punkt + 'me@ex\0ample.com', 'a@b\nc.de', 'a b@example.com', 'me@exa\vmple.com', + 'me@examp‮le.com', 'me@exämple.com', // RTL-Override / Nicht-ASCII + 'a@@example.com', + 'me@.example.com', 'me@example.com.', 'me@exa..mple.com', 'me@-example.com', + '.me@example.com', 'me.@example.com', 'me..you@example.com', // lokaler Teil: führender/abschließender Punkt, doppelter Punkt + 'a'.repeat(65) + '@example.com', // Local Part > 64 (RFC 5321) + 'a'.repeat(250) + '@example.com', // Gesamtlänge > 254 + ]) { + assert.equal(typeof validateEmail(bad), 'string', `akzeptierte fälschlich: ${JSON.stringify(bad)}`); + } +}); + +const { setup, teardown } = require('./helpers/setup'); +let accounts; +before(async () => { await setup(); accounts = require('../src/services/skoda/skodaAccounts'); }); +after(async () => { await teardown(); }); +beforeEach(() => { for (const a of accounts.listAccounts()) accounts.removeAccount(a.id); }); + +test('createAccount still accepts a valid address (catches a reversed polarity)', () => { + const acc = accounts.createAccount({ email: 'me@example.com', password: 'pw' }); + assert.ok(acc && acc.id); + assert.ok(accounts.listAccounts().some((a) => a.email === 'me@example.com')); +}); + +test('createAccount still rejects invalid addresses', () => { + for (const bad of ['', 'no-at-sign', 'a'.repeat(9000)]) { + assert.throws(() => accounts.createAccount({ email: bad, password: 'pw' }), (e) => e.code === 'SKODA_VALIDATION'); + } +});