diff --git a/public/js/routeDomain.js b/public/js/routeDomain.js new file mode 100644 index 00000000..cea88e08 --- /dev/null +++ b/public/js/routeDomain.js @@ -0,0 +1,19 @@ +(function (root, factory) { + const api = factory(); + if (typeof module !== 'undefined' && module.exports) module.exports = api; + else root.RouteDomain = api; +})(typeof self !== 'undefined' ? self : this, function () { + 'use strict'; + function isValidPrefix(prefix) { + const p = String(prefix == null ? '' : prefix).trim().toLowerCase(); + if (p === '') return true; + return p.split('.').every(l => /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(l)); + } + function assembleRouteDomain(prefix, base) { + const b = String(base || '').trim().toLowerCase(); + if (!b) return ''; + const p = String(prefix || '').trim().toLowerCase(); + return p ? `${p}.${b}` : b; + } + return { assembleRouteDomain, isValidPrefix }; +}); diff --git a/public/js/routes.js b/public/js/routes.js index 60b581d5..b7e057d7 100644 --- a/public/js/routes.js +++ b/public/js/routes.js @@ -325,13 +325,17 @@ showDescLine = false; } + const unverifiedBaseTag = r.baseUnverified + ? '' + escapeHtml(GC.t['routes.unverified_base_badge'] || 'Domain unverified') + '' + : ''; + // Badge budget: status/monitoring/circuit-breaker/L4 type are always // visible; the first two feature badges follow, the rest collapse // behind a "+N" toggle (expandedBadges survives SSE re-renders). const primaryTags = statusTag + monitorTag + cbTag + l4Tags; const extraTags = [internalTag, blockActionTag, debugTag, botTag, aclTag, ipFilterTag, rateLimitTag, retryTag, backendsTag, stickyTag, httpsTag, backendHttpsTag, compressTag, authTag, - routeAuthTags, headersTag, mirrorTag].filter(function (tag) { return !!tag; }); + routeAuthTags, headersTag, mirrorTag, unverifiedBaseTag].filter(function (tag) { return !!tag; }); let visibleExtras, moreBtn = ''; if (expandedBadges.has(String(r.id)) || extraTags.length <= 3) { visibleExtras = extraTags.join(''); @@ -953,7 +957,15 @@ e.preventDefault(); const fd = new FormData(routeForm); - const domain = fd.get('domain').trim(); + const _cBaseSel = document.getElementById('create-route-base-domain'); + const _cPfxEl = document.getElementById('create-route-prefix'); + const _cFtEl = document.getElementById('create-route-domain-freetext'); + const _cFtMode = _cBaseSel && _cBaseSel.value === ''; + const domain = _cFtMode + ? ((_cFtEl && _cFtEl.value) || '').trim() + : (window.RouteDomain && _cBaseSel + ? window.RouteDomain.assembleRouteDomain((_cPfxEl && _cPfxEl.value) || '', _cBaseSel.value || '') + : ''); const description = fd.get('description') ? fd.get('description').trim() : ''; const targetKind = (document.getElementById('create-route-target-kind')?.value) || 'peer'; const isGateway = targetKind === 'gateway'; @@ -1191,7 +1203,7 @@ // In gateway mode #route-port is inside a display:none div, so the error // would be attached to an invisible element. showFieldErrors(data.fields, { - domain: 'create-route-domain', + domain: (document.getElementById('create-route-base-domain')?.value === '' ? 'create-route-domain-freetext' : 'create-route-base-domain'), target_port: isGateway ? 'create-route-lan-port' : 'route-port', description: 'route-description', target_ip: 'route-ip', }); @@ -1298,11 +1310,19 @@ function validateWizardStep(n) { if (n !== 1) return true; - const domainEl = document.getElementById('create-route-domain'); - const domain = (domainEl && domainEl.value || '').trim(); + const _vBaseSel = document.getElementById('create-route-base-domain'); + const _vPfxEl = document.getElementById('create-route-prefix'); + const _vFtEl = document.getElementById('create-route-domain-freetext'); + const _vFtMode = _vBaseSel && _vBaseSel.value === ''; + const domain = _vFtMode + ? ((_vFtEl && _vFtEl.value) || '').trim() + : (window.RouteDomain && _vBaseSel + ? window.RouteDomain.assembleRouteDomain((_vPfxEl && _vPfxEl.value) || '', _vBaseSel.value || '') + : (_vBaseSel && _vBaseSel.value || '')); + const domainErrEl = _vFtMode ? _vFtEl : _vBaseSel; const tlsModeC = (document.getElementById('l4-tls-mode') || {}).value || 'none'; const isL4None = isL4Route() && tlsModeC === 'none'; - if (!domain && !isL4None) return wizardError('routes.domain_required', 'Domain is required', domainEl); + if (!domain && !isL4None) return wizardError('routes.domain_required', 'Domain is required', domainErrEl); if (isL4Route()) { const lpEl = document.getElementById('l4-listen-port'); if (!lpEl || !lpEl.value.trim()) return wizardError('routes.l4_listen_port_required', 'Listen-Port erforderlich', lpEl); @@ -1343,7 +1363,15 @@ if (!wizardReviewEl) return; while (wizardReviewEl.firstChild) wizardReviewEl.removeChild(wizardReviewEl.firstChild); - const domain = ((document.getElementById('create-route-domain') || {}).value || '').trim() || '—'; + const _rBaseSel = document.getElementById('create-route-base-domain'); + const _rPfxEl = document.getElementById('create-route-prefix'); + const _rFtEl = document.getElementById('create-route-domain-freetext'); + const _rFtMode = _rBaseSel && _rBaseSel.value === ''; + const domain = _rFtMode + ? ((_rFtEl && _rFtEl.value) || '').trim() || '—' + : (window.RouteDomain && _rBaseSel + ? (window.RouteDomain.assembleRouteDomain((_rPfxEl && _rPfxEl.value) || '', _rBaseSel.value || '') || '—') + : ((_rBaseSel && _rBaseSel.value) || '—')); const type = (routeTypeInput && routeTypeInput.value) || 'http'; let target = '—'; if (isL4Route()) { @@ -1419,9 +1447,36 @@ showWizardStep(1); syncBlockVisibility('create'); setTimeout(() => { - const f = document.getElementById('create-route-domain'); + const f = document.getElementById('create-route-base-domain'); if (f) f.focus(); }, 50); + (async function _loadDomains() { + const sel = document.getElementById('create-route-base-domain'); + if (!sel) return; + while (sel.firstChild) sel.removeChild(sel.firstChild); + const ftOpt = document.createElement('option'); + ftOpt.value = ''; + ftOpt.textContent = GC.t['routes.other_domain'] || 'Other / internal domain (free text)'; + sel.appendChild(ftOpt); + try { + const resp = await api.get('/api/v1/settings/domains'); + const domainsList = (resp.data && resp.data.domains) || []; + const verified = domainsList.filter(function(d) { return d.status === 'verified'; }); + for (var _i = 0; _i < verified.length; _i++) { + const opt = document.createElement('option'); + opt.value = verified[_i].domain; + opt.textContent = verified[_i].domain; + sel.insertBefore(opt, ftOpt); + } + if (verified.length > 0) { + sel.value = verified[0].domain; + } else { + const noHint = document.getElementById('create-route-domain-ctx-hint'); + if (noHint) { noHint.textContent = GC.t['routes.no_verified_domains_hint'] || 'No verified domains'; noHint.style.display = ''; } + } + } catch (_e) {} + sel.dispatchEvent(new Event('change')); + })(); } function closeRouteWizard() { @@ -1455,6 +1510,64 @@ }); } + // Domain-registry fields: freetext toggle + preview + (function setupCreateDomainRegistry() { + const sel = document.getElementById('create-route-base-domain'); + const pfx = document.getElementById('create-route-prefix'); + const ft = document.getElementById('create-route-domain-freetext'); + const prev = document.getElementById('create-route-domain-preview'); + function updatePreview() { + if (!sel) return; + const isFt = sel.value === ''; + if (ft) ft.style.display = isFt ? '' : 'none'; + if (prev) { + if (isFt) { prev.style.display = 'none'; return; } + const assembled = window.RouteDomain + ? window.RouteDomain.assembleRouteDomain((pfx && pfx.value) || '', sel.value) + : sel.value; + if (assembled) { prev.textContent = assembled; prev.style.display = ''; } + else prev.style.display = 'none'; + } + } + if (sel) sel.addEventListener('change', updatePreview); + if (pfx) pfx.addEventListener('input', updatePreview); + })(); + + // Edit-modal domain-registry fields: freetext toggle + preview + unverified warning + (function setupEditDomainRegistry() { + const sel = document.getElementById('edit-route-base-domain'); + const pfx = document.getElementById('edit-route-prefix'); + const ft = document.getElementById('edit-route-domain-freetext'); + const prev = document.getElementById('edit-route-domain-preview'); + const unvWarn = document.getElementById('edit-route-unverified-warning'); + function updateEditPreview() { + if (!sel) return; + const isFt = sel.value === ''; + if (ft) ft.style.display = isFt ? '' : 'none'; + if (prev) { + if (isFt) { prev.style.display = 'none'; return; } + const assembled = window.RouteDomain + ? window.RouteDomain.assembleRouteDomain((pfx && pfx.value) || '', sel.value) + : sel.value; + if (assembled) { prev.textContent = assembled; prev.style.display = ''; } + else prev.style.display = 'none'; + } + } + function updateEditUnverifiedWarning() { + if (!sel || !unvWarn) return; + const selectedOpt = sel.options[sel.selectedIndex]; + const isUnverified = selectedOpt && selectedOpt.dataset && selectedOpt.dataset.unverified === '1'; + if (isUnverified) { + unvWarn.textContent = GC.t['routes.unverified_base_prefix_warning'] || 'Changing the prefix requires a verified base — verify the domain first'; + unvWarn.style.display = ''; + } else { + unvWarn.style.display = 'none'; + } + } + if (sel) sel.addEventListener('change', function() { updateEditPreview(); updateEditUnverifiedWarning(); }); + if (pfx) pfx.addEventListener('input', updateEditPreview); + })(); + // Refresh visible steps if route-type changes mid-wizard if (routeTypeInput) { const routeTypeGroup = document.getElementById('route-type-group'); @@ -1697,10 +1810,81 @@ if (!route) return; document.getElementById('edit-route-id').value = id; - document.getElementById('edit-route-domain').value = route.domain || ''; // Reset DNS hint on modal open - const editDnsHintEl = document.getElementById('edit-route-dns-hint'); - if (editDnsHintEl) editDnsHintEl.style.display = 'none'; + const _eDnsHintEl = document.getElementById('edit-route-dns-hint'); + if (_eDnsHintEl) _eDnsHintEl.style.display = 'none'; + // ─── Edit domain: path detection ───────────────────────────────────── + { + const _eBaseSel = document.getElementById('edit-route-base-domain'); + const _ePfxEl = document.getElementById('edit-route-prefix'); + const _eFtEl = document.getElementById('edit-route-domain-freetext'); + const _ePrevEl = document.getElementById('edit-route-domain-preview'); + const _eUnvWarn = document.getElementById('edit-route-unverified-warning'); + // Reset state + if (_ePrevEl) _ePrevEl.style.display = 'none'; + if (_eUnvWarn) _eUnvWarn.style.display = 'none'; + if (route.domainIsPublic === true) { + // Dropdown + prefix path + if (_eFtEl) _eFtEl.style.display = 'none'; + // Compute last-two-labels base and prefix + const _d = route.domain || ''; + const _dParts = _d ? _d.split('.') : []; + const _base = _dParts.length >= 2 ? _dParts.slice(-2).join('.') : _d; + const _pfx = (_d && _base && _d.length > _base.length + 1) + ? _d.slice(0, _d.length - _base.length - 1) : ''; + if (_ePfxEl) _ePfxEl.value = _pfx; + // Load verified domains async, populate dropdown, preselect base + (async function _loadEditDomains() { + if (!_eBaseSel) return; + while (_eBaseSel.firstChild) _eBaseSel.removeChild(_eBaseSel.firstChild); + const _ftOpt = document.createElement('option'); + _ftOpt.value = ''; + _ftOpt.textContent = GC.t['routes.other_domain'] || 'Other / internal domain (free text)'; + _eBaseSel.appendChild(_ftOpt); + const _verifiedSet = []; + try { + const _resp = await api.get('/api/v1/settings/domains'); + const _domList = (_resp.data && _resp.data.domains) || []; + const _verList = _domList.filter(function(d) { return d.status === 'verified'; }); + for (var _vi = 0; _vi < _verList.length; _vi++) { + const _opt = document.createElement('option'); + _opt.value = _verList[_vi].domain; + _opt.textContent = _verList[_vi].domain; + _eBaseSel.insertBefore(_opt, _ftOpt); + _verifiedSet.push(_verList[_vi].domain); + } + } catch (_e) { /* network error: proceed to inject unverified as fallback */ } + // Preselect base: inject as unverified legacy if not in verified list + if (_base) { + if (_verifiedSet.includes(_base)) { + _eBaseSel.value = _base; + } else { + const _unvOpt = document.createElement('option'); + _unvOpt.value = _base; + _unvOpt.textContent = _base + ' ' + (GC.t['routes.unverified_base_option'] || '(unverified · legacy)'); + _unvOpt.dataset.unverified = '1'; + _eBaseSel.insertBefore(_unvOpt, _ftOpt); + _eBaseSel.value = _base; + if (_eUnvWarn) { + _eUnvWarn.textContent = GC.t['routes.unverified_base_prefix_warning'] || 'Changing the prefix requires a verified base — verify the domain first'; + _eUnvWarn.style.display = ''; + } + } + } + // Update preview + const _assembled = (_eBaseSel.value && window.RouteDomain) + ? window.RouteDomain.assembleRouteDomain((_ePfxEl && _ePfxEl.value) || '', _eBaseSel.value) : ''; + if (_ePrevEl) { + if (_assembled) { _ePrevEl.textContent = _assembled; _ePrevEl.style.display = ''; } + else _ePrevEl.style.display = 'none'; + } + })(); + } else { + // Freetext path: internal domain / no domain / stale cache — safe default + if (_eFtEl) { _eFtEl.value = route.domain || ''; _eFtEl.style.display = ''; } + if (_eBaseSel) _eBaseSel.value = ''; // ensure "other/internal" option is selected + } + } document.getElementById('edit-route-desc').value = route.description || ''; document.getElementById('edit-route-port').value = route.target_port || ''; @@ -2219,7 +2403,13 @@ currentEditRouteId = id; stopTracePolling(); openModal('modal-edit-route'); - document.getElementById('edit-route-domain').focus(); + // Focus the active domain element: freetext (if visible) or base-domain select + (function() { + const _ft = document.getElementById('edit-route-domain-freetext'); + if (_ft && _ft.style.display !== 'none') { _ft.focus(); return; } + const _base = document.getElementById('edit-route-base-domain'); + if (_base) _base.focus(); + })(); } const btnEditSubmit = document.getElementById('btn-edit-route-submit'); @@ -2227,7 +2417,16 @@ btnEditSubmit.addEventListener('click', async function() { const btn = this; const id = document.getElementById('edit-route-id').value; - const domain = document.getElementById('edit-route-domain').value.trim(); + // Read domain from active path: freetext if visible, else assemble from base+prefix dropdown + const _eDomFt = document.getElementById('edit-route-domain-freetext'); + const _eDomBase = document.getElementById('edit-route-base-domain'); + const _eDomPfx = document.getElementById('edit-route-prefix'); + const _isFtPath = _eDomFt && _eDomFt.style.display !== 'none'; + const domain = _isFtPath + ? (_eDomFt ? _eDomFt.value.trim() : '') + : (window.RouteDomain && _eDomBase && _eDomBase.value + ? window.RouteDomain.assembleRouteDomain((_eDomPfx && _eDomPfx.value) || '', _eDomBase.value) + : (_eDomBase ? _eDomBase.value.trim() : '')); const description = document.getElementById('edit-route-desc').value.trim(); const target_port = document.getElementById('edit-route-port').value.trim(); const editPeerSelect = document.getElementById('edit-route-peer'); @@ -2375,7 +2574,11 @@ if (!data.ok) { if (data.fields) { showFieldErrors(data.fields, { - domain: 'edit-route-domain', target_port: 'edit-route-port', + domain: (function() { + // Point to active domain element: freetext if visible, else base select + const _sft = document.getElementById('edit-route-domain-freetext'); + return (_sft && _sft.style.display !== 'none') ? 'edit-route-domain-freetext' : 'edit-route-base-domain'; + })(), target_port: 'edit-route-port', description: 'edit-route-desc', target_ip: 'edit-route-ip', }); } else { @@ -2525,6 +2728,8 @@ if (row) row.classList.add('gc-row-collapsed'); input.required = false; input.value = ''; + const _ftClear = document.getElementById('create-route-domain-freetext'); + if (_ftClear) _ftClear.value = ''; if (ctxHint) ctxHint.style.display = 'none'; } else { if (wrap) wrap.style.display = ''; @@ -2552,7 +2757,7 @@ const tlsMode = document.getElementById('l4-tls-mode')?.value || 'none'; applyDomainContext( routeType, tlsMode, - document.getElementById('create-route-domain'), + document.getElementById('create-route-base-domain'), document.getElementById('create-route-domain-wrap'), document.getElementById('create-route-domain-label'), document.getElementById('create-route-domain-ctx-hint') @@ -2580,11 +2785,16 @@ const editTlsMode = document.getElementById('edit-l4-tls-mode')?.value || 'none'; applyDomainContext( routeType, editTlsMode, - document.getElementById('edit-route-domain'), + document.getElementById('edit-route-base-domain'), document.getElementById('edit-route-domain-wrap'), document.getElementById('edit-route-domain-label'), document.getElementById('edit-route-domain-ctx-hint') ); + // Also clear edit-side freetext on L4-none (applyDomainContext only clears create-side freetext) + if (routeType === 'l4' && editTlsMode === 'none') { + const _eftClear = document.getElementById('edit-route-domain-freetext'); + if (_eftClear) _eftClear.value = ''; + } updateTlsHint('edit-l4-tls-mode', 'edit-l4-tls-hint'); } @@ -3364,7 +3574,7 @@ // ─── DNS check ────────────────────────────────────────── async function checkDns(domain, hintEl, inputEl) { if (!domain || !hintEl || !inputEl) return; - const routeTypeId = inputEl.id === 'create-route-domain' ? 'route-type' : 'edit-route-type'; + const routeTypeId = inputEl.id === 'create-route-domain-freetext' ? 'route-type' : 'edit-route-type'; const routeType = document.getElementById(routeTypeId)?.value || 'http'; if (routeType === 'l4') { hintEl.style.display = 'none'; @@ -3398,7 +3608,7 @@ // Attach DNS check blur handlers (function setupDnsCheck() { - const createDomainInput = document.getElementById('create-route-domain'); + const createDomainInput = document.getElementById('create-route-domain-freetext'); const createDnsHint = document.getElementById('create-route-dns-hint'); if (createDomainInput && createDnsHint) { createDomainInput.addEventListener('blur', function() { @@ -3408,7 +3618,7 @@ }); } - const editDomainInput = document.getElementById('edit-route-domain'); + const editDomainInput = document.getElementById('edit-route-domain-freetext'); const editDnsHint = document.getElementById('edit-route-dns-hint'); if (editDomainInput && editDnsHint) { editDomainInput.addEventListener('blur', function() { @@ -4829,7 +5039,7 @@ // own field-visibility refresh (a 'change' event on the hidden input is a no-op). var rt = document.getElementById('route-type'); if (rt) { rt.value = cls.routeType; if (typeof updateFieldVisibility === 'function') updateFieldVisibility(); } - var dom = document.getElementById('create-route-domain'); if (dom && !dom.value) dom.value = suggestDomainFrom(dev.hostname); + var _dfBaseSel = document.getElementById('create-route-base-domain'); var _dfFt = document.getElementById('create-route-domain-freetext'); if (_dfFt && _dfBaseSel && !_dfFt.value) { _dfBaseSel.value = ''; _dfFt.value = suggestDomainFrom(dev.hostname); _dfBaseSel.dispatchEvent(new Event('change')); } if (dev.mac) { var wolCb = document.getElementById('create-route-wol-enabled'); var macI = document.getElementById('create-route-wol-mac'); if (wolCb) { wolCb.checked = true; wolCb.dispatchEvent(new Event('change')); } if (macI) macI.value = dev.mac; } } diff --git a/src/i18n/de.json b/src/i18n/de.json index 5826ee25..1f611fcc 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -208,6 +208,14 @@ "routes.edit_title": "Route bearbeiten", "routes.domain": "Domain", "routes.domain_placeholder": "service.example.com", + "routes.prefix": "Subdomain-Präfix", + "routes.prefix_hint": "leer = direkt auf der Domain", + "routes.other_domain": "Andere/interne Domain (Freitext)", + "routes.no_verified_domains_hint": "Keine verifizierten Domains — Einstellungen → Allgemein → Domains", + "routes.unverified_base_option": "(unverifiziert · Bestand)", + "routes.unverified_base_prefix_warning": "Präfix-Änderung verlangt eine verifizierte Basis — Domain zuerst verifizieren", + "routes.unverified_base_badge": "Domain unverifiziert", + "routes.unverified_base_tooltip": "Die Basis dieser Domain ist nicht verifiziert — unter Einstellungen → Allgemein → Domains verifizieren", "routes.description": "Beschreibung", "routes.description_placeholder": "Optionale Beschreibung", "routes.target_peer": "Ziel-Peer", @@ -757,6 +765,8 @@ "error.routes.relocate_item_invalid": "Ungültige Routen-Auswahl.", "error.routes.relocate_lan_host_invalid": "Ungültige LAN-Ziel-Adresse.", "error.routes.relocate_lan_port_invalid": "Ungültiger LAN-Ziel-Port.", + "error.routes.public_domain_use_verified": "Öffentliche Domains bitte aus der verifizierten Liste wählen — unter Einstellungen → Allgemein → Domains verifizieren", + "error.routes.domain_collision": "Dieser Host kollidiert mit der GateControl- oder Portal-Adresse", "error.settings.profile_get": "Profil konnte nicht geladen werden", "error.settings.profile_update": "Profil konnte nicht aktualisiert werden", diff --git a/src/i18n/en.json b/src/i18n/en.json index b7b078a9..a20873f3 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -208,6 +208,14 @@ "routes.edit_title": "Edit Route", "routes.domain": "Domain", "routes.domain_placeholder": "service.example.com", + "routes.prefix": "Subdomain prefix", + "routes.prefix_hint": "empty = directly on the domain", + "routes.other_domain": "Other / internal domain (free text)", + "routes.no_verified_domains_hint": "No verified domains — go to Settings → General → Domains", + "routes.unverified_base_option": "(unverified · legacy)", + "routes.unverified_base_prefix_warning": "Changing the prefix requires a verified base — verify the domain first", + "routes.unverified_base_badge": "Domain unverified", + "routes.unverified_base_tooltip": "This domain's base is not verified — verify it under Settings → General → Domains", "routes.description": "Description", "routes.description_placeholder": "Optional description", "routes.target_peer": "Target Peer", @@ -757,6 +765,8 @@ "error.routes.relocate_item_invalid": "Invalid route selection.", "error.routes.relocate_lan_host_invalid": "Invalid LAN target address.", "error.routes.relocate_lan_port_invalid": "Invalid LAN target port.", + "error.routes.public_domain_use_verified": "Public domains must be picked from the verified list — verify it under Settings → General → Domains", + "error.routes.domain_collision": "This host collides with the GateControl or portal address", "error.settings.profile_get": "Failed to get profile", "error.settings.profile_update": "Failed to update profile", diff --git a/src/routes/api/routes.js b/src/routes/api/routes.js index 42cf9c0e..1a1dca3d 100644 --- a/src/routes/api/routes.js +++ b/src/routes/api/routes.js @@ -12,6 +12,10 @@ const { uploadLimiter } = require('../../middleware/rateLimit'); const config = require('../../../config/default'); const { requireLimit, requireFeatureField, requireFeature } = require('../../middleware/license'); const { getDb } = require('../../db/connection'); +const { checkDomainPolicy } = require('../../services/routeDomainPolicy'); +const { isPublicDomain } = require('../../services/caddyTlsAutomation'); +const { baseDomain } = require('../../services/domainSeed'); +const domainsService = require('../../services/domains'); const multer = require('multer'); const path = require('node:path'); const fs = require('node:fs'); @@ -239,7 +243,20 @@ router.get('/', async (req, res) => { const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0); const { type } = req.query; const list = routes.getAll({ limit, offset, type: type || null }).map(stripRoute); - res.json({ ok: true, routes: list, limit, offset }); + // Guard the registry read: a better-sqlite3 throw must NOT turn the routes view + // into a 500. Fallback to an empty set → badges suppressed, routes stay functional. + let verifiedSet; + try { verifiedSet = new Set(domainsService.baseDomains()); } + catch (err) { logger.warn({ err: err.message }, 'routes list: baseDomains() failed; suppressing nudge'); verifiedSet = new Set(); } + const withFlags = list.map(r => { + const isPub = !!(r.domain && isPublicDomain(r.domain)); + return { + ...r, + domainIsPublic: isPub, // drives edit-modal path detection (Task 6) + baseUnverified: !!(isPub && !verifiedSet.has(baseDomain(r.domain))), + }; + }); + res.json({ ok: true, routes: withFlags, limit, offset }); } catch (err) { logger.error({ error: err.message }, 'Failed to list routes'); res.status(500).json({ ok: false, error: req.t('error.routes.list') }); @@ -380,6 +397,10 @@ router.post('/', const domErr = validateDomain(domain); if (domErr) fields.domain = req.t('error.routes.domain_invalid') || domErr; } + if ((rt === 'http' || domain) && !fields.domain) { + const pol = checkDomainPolicy(domain, { routeType: rt }); + if (pol.error) fields.domain = req.t('error.routes.' + pol.error); + } const portErr = validatePort(target_port); if (portErr) fields.target_port = req.t('error.routes.port_invalid') || portErr; if (description) { @@ -541,6 +562,14 @@ router.put('/:id', const domErr = validateDomain(domain); if (domErr) fields.domain = req.t('error.routes.domain_invalid') || domErr; } + if (domain !== undefined && !fields.domain) { + const cur = getDb().prepare('SELECT domain, route_type FROM routes WHERE id = ?').get(Number(req.params.id)); + const pol = checkDomainPolicy(domain, { + currentDomain: cur ? cur.domain : null, + routeType: req.body.route_type || (cur && cur.route_type) || 'http', + }); + if (pol.error) fields.domain = req.t('error.routes.' + pol.error); + } if (target_port !== undefined) { const portErr = validatePort(target_port); if (portErr) fields.target_port = req.t('error.routes.port_invalid') || portErr; diff --git a/src/services/routeDomainPolicy.js b/src/services/routeDomainPolicy.js new file mode 100644 index 00000000..ed56d33f --- /dev/null +++ b/src/services/routeDomainPolicy.js @@ -0,0 +1,43 @@ +'use strict'; +const { isPublicDomain } = require('./caddyTlsAutomation'); +const { baseDomain } = require('./domainSeed'); +const domains = require('./domains'); +const config = require('../../config/default'); + +function norm(h) { return String(h || '').trim().toLowerCase().replace(/\.$/, ''); } +function managementHost() { + // Management host = the host GateControl is reached on. Derived from config.app.baseUrl. + // NOT caddyAdminClient._managementHost() — that needs a live Caddy config object + // unavailable at policy time (zero-arg → null → guard silently disabled). + try { return norm(new URL(config.app.baseUrl).hostname); } catch { return null; } +} +function portalHost() { + // effectivePortalHost() returns { host } as a bare hostname (no port) — use it directly. + try { return norm(require('./portalConfig').effectivePortalHost().host); } catch { return null; } +} + +/** + * Domain policy for route create/update. Only checks when `domain` is set and + * actually changed. Public TLDs require a verified registry base; non-public + * TLDs are carved out (free-text, internal CA). Collision guard applies to all. + */ +// `routeType` is reserved for future L4-vs-HTTP policy differentiation; currently unused by design. +function checkDomainPolicy(domain, { currentDomain = null, routeType = 'http' } = {}) { + const host = norm(domain); + if (!host) return { error: null }; // L4-none etc. + if (currentDomain && host === norm(currentDomain)) return { error: null }; // grandfathering + + // Collision guard (all domains), normalized both sides. + const mh = managementHost(); + const ph = portalHost(); + if ((mh && host === mh) || (ph && host === ph)) return { error: 'domain_collision' }; + + // Verified-only for public TLDs; carve-out for non-public. + if (isPublicDomain(host)) { + const base = baseDomain(host); + if (!base || !domains.isVerified(base)) return { error: 'public_domain_use_verified' }; + } + return { error: null }; +} + +module.exports = { checkDomainPolicy }; diff --git a/templates/aurora/layout.njk b/templates/aurora/layout.njk index 6d05e047..26615cb3 100644 --- a/templates/aurora/layout.njk +++ b/templates/aurora/layout.njk @@ -178,6 +178,12 @@ 'routes.target_port_required': {{ t('routes.target_port_required') | dump | safe }}, 'routes.type': {{ t('routes.type') | dump | safe }}, 'routes.target_peer': {{ t('routes.target_peer') | dump | safe }}, + 'routes.other_domain': {{ t('routes.other_domain') | dump | safe }}, + 'routes.no_verified_domains_hint': {{ t('routes.no_verified_domains_hint') | dump | safe }}, + 'routes.unverified_base_option': {{ t('routes.unverified_base_option') | dump | safe }}, + 'routes.unverified_base_prefix_warning': {{ t('routes.unverified_base_prefix_warning') | dump | safe }}, + 'routes.unverified_base_badge': {{ t('routes.unverified_base_badge') | dump | safe }}, + 'routes.unverified_base_tooltip': {{ t('routes.unverified_base_tooltip') | dump | safe }}, 'gateways.online': {{ t('gateways.online') | dump | safe }}, 'gateways.offline': {{ t('gateways.offline') | dump | safe }}, 'gateways.degraded': {{ t('gateways.degraded') | dump | safe }}, diff --git a/templates/aurora/pages/routes.njk b/templates/aurora/pages/routes.njk index 8ba7691f..61fe7055 100644 --- a/templates/aurora/pages/routes.njk +++ b/templates/aurora/pages/routes.njk @@ -270,10 +270,15 @@