From 2dc04a8771cda28609877b197ef3df30f08817e7 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:36:39 +0200 Subject: [PATCH 01/10] feat(routes): assembleRouteDomain helper (prefix+base, apex) --- public/js/routeDomain.js | 19 +++++++++++++++++++ tests/route_domain_assemble.test.js | 22 ++++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 public/js/routeDomain.js create mode 100644 tests/route_domain_assemble.test.js 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/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); +}); From f61cb3088de266f1b7da09d4f9684ef3e4b52163 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:43:34 +0200 Subject: [PATCH 02/10] =?UTF-8?q?feat(routes):=20domain=20policy=20?= =?UTF-8?q?=E2=80=94=20verified=20public=20base,=20carve-out,=20collision?= =?UTF-8?q?=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/routeDomainPolicy.js | 43 ++++++++++++++++++++++ tests/route_domain_policy.test.js | 59 +++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 src/services/routeDomainPolicy.js create mode 100644 tests/route_domain_policy.test.js 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/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'); +}); From 425efb72449e1da8c2ca76550c5a853304041b2a Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:58:40 +0200 Subject: [PATCH 03/10] feat(routes): enforce verified public base on create/update (carve-out + collision) --- src/i18n/de.json | 2 ++ src/i18n/en.json | 2 ++ src/routes/api/routes.js | 13 +++++++++ src/services/routes.js | 12 ++++++++ tests/api_routes_registry.test.js | 47 +++++++++++++++++++++++++++++++ 5 files changed, 76 insertions(+) create mode 100644 tests/api_routes_registry.test.js diff --git a/src/i18n/de.json b/src/i18n/de.json index 5826ee25..7daf57df 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -757,6 +757,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..cc249420 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -757,6 +757,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..5b1e9fd8 100644 --- a/src/routes/api/routes.js +++ b/src/routes/api/routes.js @@ -12,6 +12,7 @@ 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 multer = require('multer'); const path = require('node:path'); const fs = require('node:fs'); @@ -380,6 +381,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 +546,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/routes.js b/src/services/routes.js index ca4001cf..da12bd4f 100644 --- a/src/services/routes.js +++ b/src/services/routes.js @@ -151,6 +151,12 @@ async function create(data, opts = {}) { if (domainErr) throw new Error(domainErr); } + if (routeType === 'http' || data.domain) { + const { checkDomainPolicy } = require('./routeDomainPolicy'); + const pol = checkDomainPolicy(data.domain, { routeType }); + if (pol.error) throw Object.assign(new Error('Domain policy violation: ' + pol.error), { code: pol.error }); + } + const portErr = validatePort(data.target_port); if (portErr) throw new Error(portErr); @@ -421,6 +427,12 @@ async function update(id, data) { }); } + if (data.domain !== undefined) { + const { checkDomainPolicy } = require('./routeDomainPolicy'); + const pol = checkDomainPolicy(data.domain, { currentDomain: route.domain, routeType }); + if (pol.error) throw Object.assign(new Error('Domain policy violation: ' + pol.error), { code: pol.error }); + } + validateIfProvided(data, 'target_port', validatePort); validateIfProvided(data, 'target_lan_host', validateLanHost); validateIfProvided(data, 'description', validateDescription); diff --git a/tests/api_routes_registry.test.js b/tests/api_routes_registry.test.js new file mode 100644 index 00000000..a3c444e1 --- /dev/null +++ b/tests/api_routes_registry.test.js @@ -0,0 +1,47 @@ +'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); +}); From b0ae26391bc6a06de4c55941dd86b6158532c3a1 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:04:07 +0200 Subject: [PATCH 04/10] feat(routes): list API flags public routes with unverified base (server-side) --- src/routes/api/routes.js | 18 +++++++++++++++++- tests/api_routes_registry.test.js | 16 ++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/routes/api/routes.js b/src/routes/api/routes.js index 5b1e9fd8..1a1dca3d 100644 --- a/src/routes/api/routes.js +++ b/src/routes/api/routes.js @@ -13,6 +13,9 @@ 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'); @@ -240,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') }); diff --git a/tests/api_routes_registry.test.js b/tests/api_routes_registry.test.js index a3c444e1..535de11d 100644 --- a/tests/api_routes_registry.test.js +++ b/tests/api_routes_registry.test.js @@ -45,3 +45,19 @@ test('update changing domain to unverified public base → 400', async () => { 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); +}); From 8f8695020b16451da1132550177f323a83178ae8 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:20:11 +0200 Subject: [PATCH 05/10] feat(routes): create wizard prefix+verified-base dropdown + free-text carve-out --- public/js/routes.js | 96 ++++++++++++++++++++++++++---- src/i18n/de.json | 5 ++ src/i18n/en.json | 5 ++ templates/aurora/pages/routes.njk | 6 +- templates/default/pages/routes.njk | 6 +- templates/pro/pages/routes.njk | 6 +- tests/routes_registry_ui.test.js | 28 +++++++++ 7 files changed, 138 insertions(+), 14 deletions(-) create mode 100644 tests/routes_registry_ui.test.js diff --git a/public/js/routes.js b/public/js/routes.js index 60b581d5..98a85ded 100644 --- a/public/js/routes.js +++ b/public/js/routes.js @@ -953,7 +953,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 +1199,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 +1306,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 +1359,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 +1443,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-preview'); + 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 +1506,29 @@ }); } + // 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); + })(); + // Refresh visible steps if route-type changes mid-wizard if (routeTypeInput) { const routeTypeGroup = document.getElementById('route-type-group'); @@ -2552,7 +2626,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') @@ -3364,7 +3438,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 +3472,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() { @@ -4829,7 +4903,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 7daf57df..fbe39ae5 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -208,6 +208,11 @@ "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.base_domain": "Basis-Domain", + "routes.other_domain": "Andere/interne Domain (Freitext)", + "routes.no_verified_domains_hint": "Keine verifizierten Domains — Einstellungen → Allgemein → Domains", "routes.description": "Beschreibung", "routes.description_placeholder": "Optionale Beschreibung", "routes.target_peer": "Ziel-Peer", diff --git a/src/i18n/en.json b/src/i18n/en.json index cc249420..5ee1e292 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -208,6 +208,11 @@ "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.base_domain": "Base domain", + "routes.other_domain": "Other / internal domain (free text)", + "routes.no_verified_domains_hint": "No verified domains — go to Settings → General → Domains", "routes.description": "Description", "routes.description_placeholder": "Optional description", "routes.target_peer": "Target Peer", diff --git a/templates/aurora/pages/routes.njk b/templates/aurora/pages/routes.njk index 8ba7691f..6c81dbed 100644 --- a/templates/aurora/pages/routes.njk +++ b/templates/aurora/pages/routes.njk @@ -270,10 +270,13 @@
- + + +
@@ -911,5 +914,6 @@ {% block scripts %} + {% endblock %} diff --git a/templates/default/pages/routes.njk b/templates/default/pages/routes.njk index 78967f10..201ea743 100644 --- a/templates/default/pages/routes.njk +++ b/templates/default/pages/routes.njk @@ -308,10 +308,13 @@
- + + +
@@ -994,5 +997,6 @@ + {% endblock %} diff --git a/templates/pro/pages/routes.njk b/templates/pro/pages/routes.njk index 89275760..82d04d07 100644 --- a/templates/pro/pages/routes.njk +++ b/templates/pro/pages/routes.njk @@ -308,10 +308,13 @@
- + + +
@@ -998,5 +1001,6 @@ + {% endblock %} diff --git a/tests/routes_registry_ui.test.js b/tests/routes_registry_ui.test.js new file mode 100644 index 00000000..d7605391 --- /dev/null +++ b/tests/routes_registry_ui.test.js @@ -0,0 +1,28 @@ +'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/); + assert.doesNotMatch(res.text, /routes\.(prefix|base_domain|other_domain)\b/); +}); + +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}`)); + } +}); From 979907847eae1c9de7aab58afd5602dbebeebc12 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:25:15 +0200 Subject: [PATCH 06/10] fix(routes): show no-verified-domains hint in ctx-hint not preview --- public/js/routes.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/js/routes.js b/public/js/routes.js index 98a85ded..bfef2aa4 100644 --- a/public/js/routes.js +++ b/public/js/routes.js @@ -1467,7 +1467,7 @@ if (verified.length > 0) { sel.value = verified[0].domain; } else { - const noHint = document.getElementById('create-route-domain-preview'); + 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) {} From 148a8eda213d3609374ba2ad2ec50de94af7aec7 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:40:25 +0200 Subject: [PATCH 07/10] fix(routes): expose create-wizard i18n keys + render prefix hint + clear stale freetext on L4-none --- public/js/routes.js | 2 ++ src/i18n/de.json | 1 - src/i18n/en.json | 1 - templates/aurora/layout.njk | 2 ++ templates/aurora/pages/routes.njk | 2 ++ templates/default/layout.njk | 2 ++ templates/default/pages/routes.njk | 2 ++ templates/pro/layout.njk | 2 ++ templates/pro/pages/routes.njk | 2 ++ tests/routes_registry_ui.test.js | 6 +++++- 10 files changed, 19 insertions(+), 3 deletions(-) diff --git a/public/js/routes.js b/public/js/routes.js index bfef2aa4..2a6e6d55 100644 --- a/public/js/routes.js +++ b/public/js/routes.js @@ -2599,6 +2599,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 = ''; diff --git a/src/i18n/de.json b/src/i18n/de.json index fbe39ae5..baa36b3c 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -210,7 +210,6 @@ "routes.domain_placeholder": "service.example.com", "routes.prefix": "Subdomain-Präfix", "routes.prefix_hint": "leer = direkt auf der Domain", - "routes.base_domain": "Basis-Domain", "routes.other_domain": "Andere/interne Domain (Freitext)", "routes.no_verified_domains_hint": "Keine verifizierten Domains — Einstellungen → Allgemein → Domains", "routes.description": "Beschreibung", diff --git a/src/i18n/en.json b/src/i18n/en.json index 5ee1e292..ac827a21 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -210,7 +210,6 @@ "routes.domain_placeholder": "service.example.com", "routes.prefix": "Subdomain prefix", "routes.prefix_hint": "empty = directly on the domain", - "routes.base_domain": "Base domain", "routes.other_domain": "Other / internal domain (free text)", "routes.no_verified_domains_hint": "No verified domains — go to Settings → General → Domains", "routes.description": "Description", diff --git a/templates/aurora/layout.njk b/templates/aurora/layout.njk index 6d05e047..2acba69a 100644 --- a/templates/aurora/layout.njk +++ b/templates/aurora/layout.njk @@ -178,6 +178,8 @@ '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 }}, '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 6c81dbed..61fe7055 100644 --- a/templates/aurora/pages/routes.njk +++ b/templates/aurora/pages/routes.njk @@ -270,8 +270,10 @@
+ {# required is inert: wizard submits via JS .value reads, not native requestSubmit #} + {{ t('routes.prefix_hint') }}
+ {# required is inert: wizard submits via JS .value reads, not native requestSubmit #} + {{ t('routes.prefix_hint') }}
+ {# required is inert: wizard submits via JS .value reads, not native requestSubmit #} + {{ t('routes.prefix_hint') }} { From 7c56fa5adfcecf5c67bf24786ee25ebc9ddb9651 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:53:45 +0200 Subject: [PATCH 08/10] feat(routes): edit modal path detection (public dropdown / internal free-text) Replace single edit-route-domain input with dropdown+prefix (public routes) or freetext fallback (internal/stale). Preselects verified base or injects unverified-legacy option with warning. Remaps all 6 edit-side JS call sites, migrates data-dns to edit-route-domain-freetext, adds i18n+GC.t for unverified_base_option and unverified_base_prefix_warning. --- public/js/routes.js | 146 +++++++++++++++++- src/i18n/de.json | 2 + src/i18n/en.json | 2 + templates/aurora/layout.njk | 2 + .../aurora/partials/modals/route-edit.njk | 8 +- templates/default/layout.njk | 2 + .../default/partials/modals/route-edit.njk | 8 +- templates/pro/layout.njk | 2 + templates/pro/partials/modals/route-edit.njk | 8 +- tests/routes_registry_ui.test.js | 8 + 10 files changed, 177 insertions(+), 11 deletions(-) diff --git a/public/js/routes.js b/public/js/routes.js index 2a6e6d55..f26bace5 100644 --- a/public/js/routes.js +++ b/public/js/routes.js @@ -1529,6 +1529,41 @@ 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'); @@ -1771,10 +1806,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 || ''; @@ -2293,7 +2399,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'); @@ -2301,7 +2413,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'); @@ -2449,7 +2570,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 { @@ -2656,11 +2781,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'); } @@ -3484,7 +3614,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() { diff --git a/src/i18n/de.json b/src/i18n/de.json index baa36b3c..06bba945 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -212,6 +212,8 @@ "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.description": "Beschreibung", "routes.description_placeholder": "Optionale Beschreibung", "routes.target_peer": "Ziel-Peer", diff --git a/src/i18n/en.json b/src/i18n/en.json index ac827a21..e782bc0b 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -212,6 +212,8 @@ "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.description": "Description", "routes.description_placeholder": "Optional description", "routes.target_peer": "Target Peer", diff --git a/templates/aurora/layout.njk b/templates/aurora/layout.njk index 2acba69a..d3cc0abc 100644 --- a/templates/aurora/layout.njk +++ b/templates/aurora/layout.njk @@ -180,6 +180,8 @@ '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 }}, '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/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 42c97053..171f3936 100644 --- a/templates/default/layout.njk +++ b/templates/default/layout.njk @@ -173,6 +173,8 @@ '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 }}, '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/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 4dba2dce..961450ef 100644 --- a/templates/pro/layout.njk +++ b/templates/pro/layout.njk @@ -175,6 +175,8 @@ '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 }}, '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/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/routes_registry_ui.test.js b/tests/routes_registry_ui.test.js index ef9accec..b107ed90 100644 --- a/tests/routes_registry_ui.test.js +++ b/tests/routes_registry_ui.test.js @@ -30,3 +30,11 @@ test('all three themes carry the create-route registry ids', () => { .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}`)); + } +}); From dd1512f178170704898193359b49a7bae2452400 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:05:16 +0200 Subject: [PATCH 09/10] feat(routes): unverified-base nudge badge in routes list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add discreet amber badge to routes where baseUnverified is true — reads GC.t['routes.unverified_base_badge'] (with tooltip pointing to Settings → General → Domains). Badge participates in existing extraTags +N collapse logic. Both i18n keys added to en/de and all three layout GC.t allow-lists. --- public/js/routes.js | 6 +++++- src/i18n/de.json | 2 ++ src/i18n/en.json | 2 ++ templates/aurora/layout.njk | 2 ++ templates/default/layout.njk | 2 ++ templates/pro/layout.njk | 2 ++ tests/routes_registry_ui.test.js | 6 ++++++ 7 files changed, 21 insertions(+), 1 deletion(-) diff --git a/public/js/routes.js b/public/js/routes.js index f26bace5..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(''); diff --git a/src/i18n/de.json b/src/i18n/de.json index 06bba945..1f611fcc 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -214,6 +214,8 @@ "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", diff --git a/src/i18n/en.json b/src/i18n/en.json index e782bc0b..a20873f3 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -214,6 +214,8 @@ "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", diff --git a/templates/aurora/layout.njk b/templates/aurora/layout.njk index d3cc0abc..26615cb3 100644 --- a/templates/aurora/layout.njk +++ b/templates/aurora/layout.njk @@ -182,6 +182,8 @@ '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/layout.njk b/templates/default/layout.njk index 171f3936..a07e83a1 100644 --- a/templates/default/layout.njk +++ b/templates/default/layout.njk @@ -175,6 +175,8 @@ '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/layout.njk b/templates/pro/layout.njk index 961450ef..05fd7f83 100644 --- a/templates/pro/layout.njk +++ b/templates/pro/layout.njk @@ -177,6 +177,8 @@ '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/tests/routes_registry_ui.test.js b/tests/routes_registry_ui.test.js index b107ed90..cf9600f2 100644 --- a/tests/routes_registry_ui.test.js +++ b/tests/routes_registry_ui.test.js @@ -38,3 +38,9 @@ test('edit modal carries registry ids in all three themes', () => { .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/); +}); From f172aa469c145c987c39e3ac43bba69794c6b376 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:33:54 +0200 Subject: [PATCH 10/10] fix(routes): scope domain enforcement to API layer; seed verified base in affected suites --- src/services/routes.js | 12 ------------ tests/api.test.js | 2 ++ tests/api_route_external.test.js | 2 ++ tests/api_route_external_block.test.js | 3 ++- 4 files changed, 6 insertions(+), 13 deletions(-) diff --git a/src/services/routes.js b/src/services/routes.js index da12bd4f..ca4001cf 100644 --- a/src/services/routes.js +++ b/src/services/routes.js @@ -151,12 +151,6 @@ async function create(data, opts = {}) { if (domainErr) throw new Error(domainErr); } - if (routeType === 'http' || data.domain) { - const { checkDomainPolicy } = require('./routeDomainPolicy'); - const pol = checkDomainPolicy(data.domain, { routeType }); - if (pol.error) throw Object.assign(new Error('Domain policy violation: ' + pol.error), { code: pol.error }); - } - const portErr = validatePort(data.target_port); if (portErr) throw new Error(portErr); @@ -427,12 +421,6 @@ async function update(id, data) { }); } - if (data.domain !== undefined) { - const { checkDomainPolicy } = require('./routeDomainPolicy'); - const pol = checkDomainPolicy(data.domain, { currentDomain: route.domain, routeType }); - if (pol.error) throw Object.assign(new Error('Domain policy violation: ' + pol.error), { code: pol.error }); - } - validateIfProvided(data, 'target_port', validatePort); validateIfProvided(data, 'target_lan_host', validateLanHost); validateIfProvided(data, 'description', validateDescription); 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 };