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 @@
- + + {{ t('routes.prefix_hint') }} + +
@@ -911,5 +916,6 @@ {% block scripts %} + {% endblock %} diff --git a/templates/aurora/partials/modals/route-edit.njk b/templates/aurora/partials/modals/route-edit.njk index b8191f63..978c41dd 100644 --- a/templates/aurora/partials/modals/route-edit.njk +++ b/templates/aurora/partials/modals/route-edit.njk @@ -23,10 +23,16 @@
- + + {{ t('routes.prefix_hint') }} + + +
diff --git a/templates/default/layout.njk b/templates/default/layout.njk index 39dc01df..a07e83a1 100644 --- a/templates/default/layout.njk +++ b/templates/default/layout.njk @@ -171,6 +171,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/default/pages/routes.njk b/templates/default/pages/routes.njk index 78967f10..696320dc 100644 --- a/templates/default/pages/routes.njk +++ b/templates/default/pages/routes.njk @@ -308,10 +308,15 @@
- + + {{ t('routes.prefix_hint') }} + +
@@ -994,5 +999,6 @@ + {% endblock %} diff --git a/templates/default/partials/modals/route-edit.njk b/templates/default/partials/modals/route-edit.njk index b8191f63..978c41dd 100644 --- a/templates/default/partials/modals/route-edit.njk +++ b/templates/default/partials/modals/route-edit.njk @@ -23,10 +23,16 @@
- + + {{ t('routes.prefix_hint') }} + + +
diff --git a/templates/pro/layout.njk b/templates/pro/layout.njk index d2b81d71..05fd7f83 100644 --- a/templates/pro/layout.njk +++ b/templates/pro/layout.njk @@ -173,6 +173,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/pro/pages/routes.njk b/templates/pro/pages/routes.njk index 89275760..1d90b893 100644 --- a/templates/pro/pages/routes.njk +++ b/templates/pro/pages/routes.njk @@ -308,10 +308,15 @@
- + + {{ t('routes.prefix_hint') }} + +
@@ -998,5 +1003,6 @@ + {% endblock %} diff --git a/templates/pro/partials/modals/route-edit.njk b/templates/pro/partials/modals/route-edit.njk index 5773aaab..027d1fc8 100644 --- a/templates/pro/partials/modals/route-edit.njk +++ b/templates/pro/partials/modals/route-edit.njk @@ -23,10 +23,16 @@
- + + {{ t('routes.prefix_hint') }} + + +
diff --git a/tests/api.test.js b/tests/api.test.js index 50d860a1..beb8156c 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -4,6 +4,7 @@ const { describe, it, before, after } = require('node:test'); const assert = require('node:assert/strict'); const { execFileSync } = require('node:child_process'); const { setup, teardown, getAgent, getCsrf } = require('./helpers/setup'); +const { getDb } = require('../src/db/connection'); let agent, csrf; let hasWg = false; @@ -13,6 +14,7 @@ before(async () => { const ctx = await setup(); agent = ctx.agent; csrf = ctx.csrfToken; + getDb().prepare("INSERT INTO domains (domain, status) VALUES ('example.com','verified')").run(); }); after(() => teardown()); diff --git a/tests/api_route_external.test.js b/tests/api_route_external.test.js index ea82ec0e..cb64b037 100644 --- a/tests/api_route_external.test.js +++ b/tests/api_route_external.test.js @@ -12,6 +12,7 @@ const { test, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const { setup, teardown, getAgent, getCsrf } = require('./helpers/setup'); +const { getDb } = require('../src/db/connection'); let agent, csrf; @@ -19,6 +20,7 @@ beforeEach(async () => { await setup(); agent = getAgent(); csrf = getCsrf(); + getDb().prepare("INSERT INTO domains (domain, status) VALUES ('example.com','verified')").run(); }); afterEach(teardown); diff --git a/tests/api_route_external_block.test.js b/tests/api_route_external_block.test.js index f757514e..e4857bbb 100644 --- a/tests/api_route_external_block.test.js +++ b/tests/api_route_external_block.test.js @@ -2,9 +2,10 @@ const { test, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const { setup, teardown, getAgent, getCsrf } = require('./helpers/setup'); +const { getDb } = require('../src/db/connection'); let agent, csrf; -beforeEach(async () => { await setup(); agent = getAgent(); csrf = getCsrf(); }); +beforeEach(async () => { await setup(); agent = getAgent(); csrf = getCsrf(); getDb().prepare("INSERT INTO domains (domain, status) VALUES ('example.com','verified')").run(); }); afterEach(teardown); const MINIMAL = { domain: 'block-api.example.com', target_ip: '93.184.216.34', target_port: 80 }; diff --git a/tests/api_routes_registry.test.js b/tests/api_routes_registry.test.js new file mode 100644 index 00000000..535de11d --- /dev/null +++ b/tests/api_routes_registry.test.js @@ -0,0 +1,63 @@ +'use strict'; +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +process.env.GC_SECRET = process.env.GC_SECRET || crypto.randomBytes(32).toString('hex'); +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const { setup, teardown, getAgent, getCsrf } = require('./helpers/setup'); + +let agent, csrf, getDb; +beforeEach(async () => { await setup(); agent = getAgent(); csrf = getCsrf(); getDb = require('../src/db/connection').getDb; }); +afterEach(teardown); + +test('create with unverified public base → 400 field error', async () => { + const res = await agent.post('/api/v1/routes').set('X-CSRF-Token', csrf) + .send({ domain: 'nas.domaincaster.com', target_ip: '1.1.1.1', target_port: 80, route_type: 'http' }); + assert.equal(res.status, 400); + assert.ok(res.body.fields && res.body.fields.domain); +}); + +test('create with verified public base → 201', async () => { + getDb().prepare("INSERT INTO domains (domain, status) VALUES ('domaincaster.com','verified')").run(); + const res = await agent.post('/api/v1/routes').set('X-CSRF-Token', csrf) + .send({ domain: 'nas.domaincaster.com', target_ip: '1.1.1.1', target_port: 80, route_type: 'http' }); + assert.equal(res.status, 201); +}); + +test('create with non-public TLD (carve-out) → 201 without verify', async () => { + const res = await agent.post('/api/v1/routes').set('X-CSRF-Token', csrf) + .send({ domain: 'nas.gc.internal', target_ip: '1.1.1.1', target_port: 80, route_type: 'http' }); + assert.equal(res.status, 201); +}); + +test('update non-domain field on a legacy unverified-base route → ok (grandfathering)', async () => { + // seed a legacy route directly (bypasses policy), unverified public base + getDb().prepare("INSERT INTO routes (domain, target_ip, target_port, route_type, enabled) VALUES ('legacy.example.com','10.0.0.3',80,'http',1)").run(); + const id = getDb().prepare("SELECT id FROM routes WHERE domain='legacy.example.com'").get().id; + const res = await agent.put('/api/v1/routes/' + id).set('X-CSRF-Token', csrf).send({ target_port: 81 }); + assert.equal(res.status, 200); +}); + +test('update changing domain to unverified public base → 400', async () => { + getDb().prepare("INSERT INTO routes (domain, target_ip, target_port, route_type, enabled) VALUES ('a.gc.internal','10.0.0.3',80,'http',1)").run(); + const id = getDb().prepare("SELECT id FROM routes WHERE domain='a.gc.internal'").get().id; + const res = await agent.put('/api/v1/routes/' + id).set('X-CSRF-Token', csrf).send({ domain: 'b.unverified.com' }); + assert.equal(res.status, 400); + assert.ok(res.body.fields.domain); +}); + +test('routes list flags public routes with unverified base', async () => { + getDb().prepare("INSERT INTO routes (domain, target_ip, target_port, route_type, enabled) VALUES ('x.unverified.com','10.0.0.4',80,'http',1)").run(); + getDb().prepare("INSERT INTO domains (domain, status) VALUES ('verified.com','verified')").run(); + getDb().prepare("INSERT INTO routes (domain, target_ip, target_port, route_type, enabled) VALUES ('y.verified.com','10.0.0.5',80,'http',1)").run(); + getDb().prepare("INSERT INTO routes (domain, target_ip, target_port, route_type, enabled) VALUES ('z.gc.internal','10.0.0.6',80,'http',1)").run(); + const res = await agent.get('/api/v1/routes').expect(200); + const by = Object.fromEntries(res.body.routes.map(r => [r.domain, r])); + assert.equal(by['x.unverified.com'].baseUnverified, true); + assert.equal(by['y.verified.com'].baseUnverified, false); + assert.equal(by['z.gc.internal'].baseUnverified, false); // non-public → never flagged + // domainIsPublic is a first-class flag (drives Task 6 edit-modal path detection): + assert.equal(by['x.unverified.com'].domainIsPublic, true); + assert.equal(by['y.verified.com'].domainIsPublic, true); + assert.equal(by['z.gc.internal'].domainIsPublic, false); +}); diff --git a/tests/route_domain_assemble.test.js b/tests/route_domain_assemble.test.js new file mode 100644 index 00000000..d2ee173d --- /dev/null +++ b/tests/route_domain_assemble.test.js @@ -0,0 +1,22 @@ +'use strict'; +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { assembleRouteDomain, isValidPrefix } = require('../public/js/routeDomain'); + +test('assembleRouteDomain joins prefix + base; empty prefix = apex', () => { + assert.equal(assembleRouteDomain('nas', 'domaincaster.com'), 'nas.domaincaster.com'); + assert.equal(assembleRouteDomain('', 'domaincaster.com'), 'domaincaster.com'); + assert.equal(assembleRouteDomain(' NAS ', 'Domaincaster.com'), 'nas.domaincaster.com'); + assert.equal(assembleRouteDomain('a.b', 'example.com'), 'a.b.example.com'); + assert.equal(assembleRouteDomain('nas', ''), ''); +}); + +test('isValidPrefix: empty ok; labels validated', () => { + assert.equal(isValidPrefix(''), true); + assert.equal(isValidPrefix('nas'), true); + assert.equal(isValidPrefix('a.b'), true); + assert.equal(isValidPrefix('bad_label'), false); + assert.equal(isValidPrefix('-bad'), false); +}); diff --git a/tests/route_domain_policy.test.js b/tests/route_domain_policy.test.js new file mode 100644 index 00000000..788c8096 --- /dev/null +++ b/tests/route_domain_policy.test.js @@ -0,0 +1,59 @@ +'use strict'; +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const { setup, teardown } = require('./helpers/setup'); + +let policy, getDb; +beforeEach(async () => { + await setup(); + // setup.js (module-level) resets GC_BASE_URL to 'http://localhost:3000'. + // Override AFTER setup() so managementHost() resolves to admin.example.com. + // Purge both config and the policy module from require.cache so the + // policy captures the fresh baseUrl when it first requires config. + process.env.GC_BASE_URL = 'https://admin.example.com'; + delete require.cache[require.resolve('../config/default')]; + delete require.cache[require.resolve('../src/services/routeDomainPolicy')]; + policy = require('../src/services/routeDomainPolicy'); + getDb = require('../src/db/connection').getDb; +}); +afterEach(teardown); + +test('public TLD requires a verified base', () => { + assert.equal(policy.checkDomainPolicy('nas.domaincaster.com', {}).error, 'public_domain_use_verified'); + getDb().prepare("INSERT INTO domains (domain, status) VALUES ('domaincaster.com','verified')").run(); + assert.equal(policy.checkDomainPolicy('nas.domaincaster.com', {}).error, null); +}); + +test('non-public TLD is carved out (no verify needed)', () => { + assert.equal(policy.checkDomainPolicy('nas.gc.internal', {}).error, null); + assert.equal(policy.checkDomainPolicy('printer.lan', {}).error, null); +}); + +test('unchanged domain is skipped (grandfathering)', () => { + // public, unverified, but unchanged → no error + assert.equal(policy.checkDomainPolicy('old.example.com', { currentDomain: 'old.example.com' }).error, null); +}); + +test('collision with management host is rejected (strict, public base seeded)', () => { + getDb().prepare("INSERT INTO domains (domain, status) VALUES ('example.com','verified')").run(); + // base example.com is verified → only the collision path can fail. Management host = admin.example.com. + assert.equal(policy.checkDomainPolicy('admin.example.com', { routeType: 'http' }).error, 'domain_collision'); + // a different verified-base host does NOT collide: + assert.equal(policy.checkDomainPolicy('nas.example.com', { routeType: 'http' }).error, null); + // trailing dot / casing still collides (normalized): + assert.equal(policy.checkDomainPolicy('ADMIN.example.com.', { routeType: 'http' }).error, 'domain_collision'); +}); + +test('collision with portal host is rejected when C is present', () => { + getDb().prepare("INSERT INTO domains (domain, status) VALUES ('example.com','verified')").run(); + const settings = require('../src/services/settings'); + // C: portal.base_domain + prefix → effectivePortalHost() = home.example.com. + // C/portalConfig IS present on this branch, so this must be deterministic — + // a failure to set the keys should surface, not be swallowed. + settings.set('portal.base_domain', 'example.com'); + settings.set('portal.prefix', 'home'); + const r = policy.checkDomainPolicy('home.example.com', { routeType: 'http' }); + assert.equal(r.error, 'domain_collision'); +}); diff --git a/tests/routes_registry_ui.test.js b/tests/routes_registry_ui.test.js new file mode 100644 index 00000000..cf9600f2 --- /dev/null +++ b/tests/routes_registry_ui.test.js @@ -0,0 +1,46 @@ +'use strict'; +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); const path = require('node:path'); +const supertest = require('supertest'); +const { setup, teardown, getAgent } = require('./helpers/setup'); + +let app; +beforeEach(async () => { await setup(); app = require('../src/app').createApp(); }); +afterEach(teardown); + +test('create wizard has prefix + base-domain dropdown + free-text carve-out (served, no raw keys)', async () => { + const res = await getAgent().get('/routes').expect(200); + assert.match(res.text, /create-route-prefix/); + assert.match(res.text, /create-route-base-domain/); + assert.match(res.text, /create-route-domain-freetext/); + // routes.other_domain and routes.no_verified_domains_hint are intentionally in GC.t (for JS); + // the following keys must NOT appear as unrendered template artefacts: + assert.doesNotMatch(res.text, /routes\.(prefix|prefix_hint|base_domain)\b/); + // prefix_hint must be server-rendered (Nunjucks), not leaked as a raw key + assert.match(res.text, /empty = directly on the domain|leer = direkt auf der Domain/); +}); + +test('all three themes carry the create-route registry ids', () => { + for (const theme of ['aurora', 'default', 'pro']) { + const html = fs.readFileSync(path.join(__dirname, '..', 'templates', theme, 'pages', 'routes.njk'), 'utf8'); + ['create-route-prefix', 'create-route-base-domain', 'create-route-domain-freetext'] + .forEach(id => assert.ok(html.includes(id), `${theme}: ${id}`)); + } +}); + +test('edit modal carries registry ids in all three themes', () => { + for (const theme of ['aurora', 'default', 'pro']) { + const html = fs.readFileSync(path.join(__dirname, '..', 'templates', theme, 'partials', 'modals', 'route-edit.njk'), 'utf8'); + ['edit-route-prefix', 'edit-route-base-domain', 'edit-route-domain-freetext'] + .forEach(id => assert.ok(html.includes(id), `${theme}: ${id}`)); + } +}); + +test('routes.js renders an unverified-base badge from baseUnverified', async () => { + const js = await supertest(app).get('/js/routes.js').expect(200); + assert.match(js.text, /baseUnverified/); + assert.match(js.text, /routes\.unverified_base_badge|unverified-base/); +});