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 @@