Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions public/js/routeDomain.js
Original file line number Diff line number Diff line change
@@ -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 };
});
250 changes: 230 additions & 20 deletions public/js/routes.js

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions src/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,14 @@
"routes.edit_title": "Route bearbeiten",
"routes.domain": "Domain",
"routes.domain_placeholder": "service.example.com",
"routes.prefix": "Subdomain-Präfix",
"routes.prefix_hint": "leer = direkt auf der Domain",
"routes.other_domain": "Andere/interne Domain (Freitext)",
"routes.no_verified_domains_hint": "Keine verifizierten Domains — Einstellungen → Allgemein → Domains",
"routes.unverified_base_option": "(unverifiziert · Bestand)",
"routes.unverified_base_prefix_warning": "Präfix-Änderung verlangt eine verifizierte Basis — Domain zuerst verifizieren",
"routes.unverified_base_badge": "Domain unverifiziert",
"routes.unverified_base_tooltip": "Die Basis dieser Domain ist nicht verifiziert — unter Einstellungen → Allgemein → Domains verifizieren",
"routes.description": "Beschreibung",
"routes.description_placeholder": "Optionale Beschreibung",
"routes.target_peer": "Ziel-Peer",
Expand Down Expand Up @@ -757,6 +765,8 @@
"error.routes.relocate_item_invalid": "Ungültige Routen-Auswahl.",
"error.routes.relocate_lan_host_invalid": "Ungültige LAN-Ziel-Adresse.",
"error.routes.relocate_lan_port_invalid": "Ungültiger LAN-Ziel-Port.",
"error.routes.public_domain_use_verified": "Öffentliche Domains bitte aus der verifizierten Liste wählen — unter Einstellungen → Allgemein → Domains verifizieren",
"error.routes.domain_collision": "Dieser Host kollidiert mit der GateControl- oder Portal-Adresse",

"error.settings.profile_get": "Profil konnte nicht geladen werden",
"error.settings.profile_update": "Profil konnte nicht aktualisiert werden",
Expand Down
10 changes: 10 additions & 0 deletions src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,14 @@
"routes.edit_title": "Edit Route",
"routes.domain": "Domain",
"routes.domain_placeholder": "service.example.com",
"routes.prefix": "Subdomain prefix",
"routes.prefix_hint": "empty = directly on the domain",
"routes.other_domain": "Other / internal domain (free text)",
"routes.no_verified_domains_hint": "No verified domains — go to Settings → General → Domains",
"routes.unverified_base_option": "(unverified · legacy)",
"routes.unverified_base_prefix_warning": "Changing the prefix requires a verified base — verify the domain first",
"routes.unverified_base_badge": "Domain unverified",
"routes.unverified_base_tooltip": "This domain's base is not verified — verify it under Settings → General → Domains",
"routes.description": "Description",
"routes.description_placeholder": "Optional description",
"routes.target_peer": "Target Peer",
Expand Down Expand Up @@ -757,6 +765,8 @@
"error.routes.relocate_item_invalid": "Invalid route selection.",
"error.routes.relocate_lan_host_invalid": "Invalid LAN target address.",
"error.routes.relocate_lan_port_invalid": "Invalid LAN target port.",
"error.routes.public_domain_use_verified": "Public domains must be picked from the verified list — verify it under Settings → General → Domains",
"error.routes.domain_collision": "This host collides with the GateControl or portal address",

"error.settings.profile_get": "Failed to get profile",
"error.settings.profile_update": "Failed to update profile",
Expand Down
31 changes: 30 additions & 1 deletion src/routes/api/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ const { uploadLimiter } = require('../../middleware/rateLimit');
const config = require('../../../config/default');
const { requireLimit, requireFeatureField, requireFeature } = require('../../middleware/license');
const { getDb } = require('../../db/connection');
const { checkDomainPolicy } = require('../../services/routeDomainPolicy');
const { isPublicDomain } = require('../../services/caddyTlsAutomation');
const { baseDomain } = require('../../services/domainSeed');
const domainsService = require('../../services/domains');
const multer = require('multer');
const path = require('node:path');
const fs = require('node:fs');
Expand Down Expand Up @@ -239,7 +243,20 @@ router.get('/', async (req, res) => {
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
const { type } = req.query;
const list = routes.getAll({ limit, offset, type: type || null }).map(stripRoute);
res.json({ ok: true, routes: list, limit, offset });
// Guard the registry read: a better-sqlite3 throw must NOT turn the routes view
// into a 500. Fallback to an empty set → badges suppressed, routes stay functional.
let verifiedSet;
try { verifiedSet = new Set(domainsService.baseDomains()); }
catch (err) { logger.warn({ err: err.message }, 'routes list: baseDomains() failed; suppressing nudge'); verifiedSet = new Set(); }
const withFlags = list.map(r => {
const isPub = !!(r.domain && isPublicDomain(r.domain));
return {
...r,
domainIsPublic: isPub, // drives edit-modal path detection (Task 6)
baseUnverified: !!(isPub && !verifiedSet.has(baseDomain(r.domain))),
};
});
res.json({ ok: true, routes: withFlags, limit, offset });
} catch (err) {
logger.error({ error: err.message }, 'Failed to list routes');
res.status(500).json({ ok: false, error: req.t('error.routes.list') });
Expand Down Expand Up @@ -380,6 +397,10 @@ router.post('/',
const domErr = validateDomain(domain);
if (domErr) fields.domain = req.t('error.routes.domain_invalid') || domErr;
}
if ((rt === 'http' || domain) && !fields.domain) {
const pol = checkDomainPolicy(domain, { routeType: rt });
if (pol.error) fields.domain = req.t('error.routes.' + pol.error);
}
const portErr = validatePort(target_port);
if (portErr) fields.target_port = req.t('error.routes.port_invalid') || portErr;
if (description) {
Expand Down Expand Up @@ -541,6 +562,14 @@ router.put('/:id',
const domErr = validateDomain(domain);
if (domErr) fields.domain = req.t('error.routes.domain_invalid') || domErr;
}
if (domain !== undefined && !fields.domain) {
const cur = getDb().prepare('SELECT domain, route_type FROM routes WHERE id = ?').get(Number(req.params.id));
const pol = checkDomainPolicy(domain, {
currentDomain: cur ? cur.domain : null,
routeType: req.body.route_type || (cur && cur.route_type) || 'http',
});
if (pol.error) fields.domain = req.t('error.routes.' + pol.error);
}
if (target_port !== undefined) {
const portErr = validatePort(target_port);
if (portErr) fields.target_port = req.t('error.routes.port_invalid') || portErr;
Expand Down
43 changes: 43 additions & 0 deletions src/services/routeDomainPolicy.js
Original file line number Diff line number Diff line change
@@ -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 };
6 changes: 6 additions & 0 deletions templates/aurora/layout.njk
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,12 @@
'routes.target_port_required': {{ t('routes.target_port_required') | dump | safe }},
'routes.type': {{ t('routes.type') | dump | safe }},
'routes.target_peer': {{ t('routes.target_peer') | dump | safe }},
'routes.other_domain': {{ t('routes.other_domain') | dump | safe }},
'routes.no_verified_domains_hint': {{ t('routes.no_verified_domains_hint') | dump | safe }},
'routes.unverified_base_option': {{ t('routes.unverified_base_option') | dump | safe }},
'routes.unverified_base_prefix_warning': {{ t('routes.unverified_base_prefix_warning') | dump | safe }},
'routes.unverified_base_badge': {{ t('routes.unverified_base_badge') | dump | safe }},
'routes.unverified_base_tooltip': {{ t('routes.unverified_base_tooltip') | dump | safe }},
'gateways.online': {{ t('gateways.online') | dump | safe }},
'gateways.offline': {{ t('gateways.offline') | dump | safe }},
'gateways.degraded': {{ t('gateways.degraded') | dump | safe }},
Expand Down
8 changes: 7 additions & 1 deletion templates/aurora/pages/routes.njk
Original file line number Diff line number Diff line change
Expand Up @@ -270,10 +270,15 @@
<div class="wiz-row-2">
<div id="create-route-domain-wrap">
<label id="create-route-domain-label" style="font-size:11px;font-family:var(--font-mono);color:var(--text-2);text-transform:uppercase;letter-spacing:0.08em;display:block;margin-bottom:6px;font-weight:600"><span class="gc-label-text">{{ t('routes.domain') }}</span>{{ ui.tip('routes.tip_domain') }}</label>
<input type="text" id="create-route-domain" name="domain" placeholder="{{ t('routes.domain_placeholder') }}" style="width:100%;padding:8px 12px" required maxlength="253"
{# required is inert: wizard submits via JS .value reads, not native requestSubmit #}
<select id="create-route-base-domain" style="width:100%;padding:8px 12px;margin-bottom:6px"></select>
<input type="text" id="create-route-prefix" placeholder="{{ t('routes.prefix') }}" style="width:100%;padding:8px 12px;margin-bottom:4px" maxlength="63">
<small class="form-hint">{{ t('routes.prefix_hint') }}</small>
<input type="text" id="create-route-domain-freetext" placeholder="{{ t('routes.domain_placeholder') }}" style="width:100%;padding:8px 12px;display:none" maxlength="253"
data-dns-checking="{{ t('routes.dns_checking') }}"
data-dns-ok="{{ t('routes.dns_ok') }}"
data-dns-warning="{{ t('routes.dns_warning') }}">
<small id="create-route-domain-preview" class="form-hint" style="display:none"></small>
<small id="create-route-domain-ctx-hint" class="form-hint" style="display:none"></small>
<small id="create-route-dns-hint" class="form-hint" style="display:none"></small>
</div>
Expand Down Expand Up @@ -911,5 +916,6 @@
{% block scripts %}
<script src="/js/vendor/qrcode.min.js?v={{ appVersion }}"></script>
<script src="/js/routes-view.js?v={{ appVersion }}"></script>
<script src="/js/routeDomain.js?v={{ appVersion }}"></script>
<script src="/js/routes.js?v={{ appVersion }}"></script>
{% endblock %}
8 changes: 7 additions & 1 deletion templates/aurora/partials/modals/route-edit.njk
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,16 @@
<div class="form-row">
<div class="form-group" id="edit-route-domain-wrap">
<label class="form-label" id="edit-route-domain-label"><span class="gc-label-text">{{ t('routes.domain') }}</span>{{ ui.tip('routes.tip_domain') }}</label>
<input type="text" id="edit-route-domain" placeholder="{{ t('routes.domain_placeholder') }}" maxlength="253"
{# required is inert on edit: submit reads values via JS #}
<select id="edit-route-base-domain" style="width:100%;padding:8px 12px;margin-bottom:6px"></select>
<input type="text" id="edit-route-prefix" placeholder="{{ t('routes.prefix') }}" style="width:100%;padding:8px 12px;margin-bottom:4px" maxlength="63">
<small class="form-hint">{{ t('routes.prefix_hint') }}</small>
<small id="edit-route-unverified-warning" class="form-hint" style="display:none;color:var(--amber)"></small>
<input type="text" id="edit-route-domain-freetext" placeholder="{{ t('routes.domain_placeholder') }}" maxlength="253" style="display:none"
data-dns-checking="{{ t('routes.dns_checking') }}"
data-dns-ok="{{ t('routes.dns_ok') }}"
data-dns-warning="{{ t('routes.dns_warning') }}">
<small id="edit-route-domain-preview" class="form-hint" style="display:none"></small>
<small id="edit-route-domain-ctx-hint" class="form-hint" style="display:none"></small>
<small id="edit-route-dns-hint" class="form-hint" style="display:none"></small>
</div>
Expand Down
6 changes: 6 additions & 0 deletions templates/default/layout.njk
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,12 @@
'routes.target_port_required': {{ t('routes.target_port_required') | dump | safe }},
'routes.type': {{ t('routes.type') | dump | safe }},
'routes.target_peer': {{ t('routes.target_peer') | dump | safe }},
'routes.other_domain': {{ t('routes.other_domain') | dump | safe }},
'routes.no_verified_domains_hint': {{ t('routes.no_verified_domains_hint') | dump | safe }},
'routes.unverified_base_option': {{ t('routes.unverified_base_option') | dump | safe }},
'routes.unverified_base_prefix_warning': {{ t('routes.unverified_base_prefix_warning') | dump | safe }},
'routes.unverified_base_badge': {{ t('routes.unverified_base_badge') | dump | safe }},
'routes.unverified_base_tooltip': {{ t('routes.unverified_base_tooltip') | dump | safe }},
'gateways.online': {{ t('gateways.online') | dump | safe }},
'gateways.offline': {{ t('gateways.offline') | dump | safe }},
'gateways.degraded': {{ t('gateways.degraded') | dump | safe }},
Expand Down
8 changes: 7 additions & 1 deletion templates/default/pages/routes.njk
Original file line number Diff line number Diff line change
Expand Up @@ -308,10 +308,15 @@
<div class="wiz-row-2">
<div id="create-route-domain-wrap">
<label id="create-route-domain-label" style="font-size:11px;font-family:var(--font-mono);color:var(--text-2);text-transform:uppercase;letter-spacing:0.08em;display:block;margin-bottom:6px;font-weight:600"><span class="gc-label-text">{{ t('routes.domain') }}</span>{{ ui.tip('routes.tip_domain') }}</label>
<input type="text" id="create-route-domain" name="domain" placeholder="{{ t('routes.domain_placeholder') }}" style="width:100%;padding:8px 12px" required maxlength="253"
{# required is inert: wizard submits via JS .value reads, not native requestSubmit #}
<select id="create-route-base-domain" style="width:100%;padding:8px 12px;margin-bottom:6px"></select>
<input type="text" id="create-route-prefix" placeholder="{{ t('routes.prefix') }}" style="width:100%;padding:8px 12px;margin-bottom:4px" maxlength="63">
<small class="form-hint">{{ t('routes.prefix_hint') }}</small>
<input type="text" id="create-route-domain-freetext" placeholder="{{ t('routes.domain_placeholder') }}" style="width:100%;padding:8px 12px;display:none" maxlength="253"
data-dns-checking="{{ t('routes.dns_checking') }}"
data-dns-ok="{{ t('routes.dns_ok') }}"
data-dns-warning="{{ t('routes.dns_warning') }}">
<small id="create-route-domain-preview" class="form-hint" style="display:none"></small>
<small id="create-route-domain-ctx-hint" class="form-hint" style="display:none"></small>
<small id="create-route-dns-hint" class="form-hint" style="display:none"></small>
</div>
Expand Down Expand Up @@ -994,5 +999,6 @@
<script src="/js/vendor/qrcode.min.js?v={{ appVersion }}"></script>
<script src="/js/routes-view.js?v={{ appVersion }}"></script>
<script src="/js/printerPresetForm.js?v={{ appVersion }}"></script>
<script src="/js/routeDomain.js?v={{ appVersion }}"></script>
<script src="/js/routes.js?v={{ appVersion }}"></script>
{% endblock %}
8 changes: 7 additions & 1 deletion templates/default/partials/modals/route-edit.njk
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,16 @@
<div class="form-row">
<div class="form-group" id="edit-route-domain-wrap">
<label class="form-label" id="edit-route-domain-label"><span class="gc-label-text">{{ t('routes.domain') }}</span>{{ ui.tip('routes.tip_domain') }}</label>
<input type="text" id="edit-route-domain" placeholder="{{ t('routes.domain_placeholder') }}" maxlength="253"
{# required is inert on edit: submit reads values via JS #}
<select id="edit-route-base-domain" style="width:100%;padding:8px 12px;margin-bottom:6px"></select>
<input type="text" id="edit-route-prefix" placeholder="{{ t('routes.prefix') }}" style="width:100%;padding:8px 12px;margin-bottom:4px" maxlength="63">
<small class="form-hint">{{ t('routes.prefix_hint') }}</small>
<small id="edit-route-unverified-warning" class="form-hint" style="display:none;color:var(--amber)"></small>
<input type="text" id="edit-route-domain-freetext" placeholder="{{ t('routes.domain_placeholder') }}" maxlength="253" style="display:none"
data-dns-checking="{{ t('routes.dns_checking') }}"
data-dns-ok="{{ t('routes.dns_ok') }}"
data-dns-warning="{{ t('routes.dns_warning') }}">
<small id="edit-route-domain-preview" class="form-hint" style="display:none"></small>
<small id="edit-route-domain-ctx-hint" class="form-hint" style="display:none"></small>
<small id="edit-route-dns-hint" class="form-hint" style="display:none"></small>
</div>
Expand Down
6 changes: 6 additions & 0 deletions templates/pro/layout.njk
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,12 @@
'routes.target_port_required': {{ t('routes.target_port_required') | dump | safe }},
'routes.type': {{ t('routes.type') | dump | safe }},
'routes.target_peer': {{ t('routes.target_peer') | dump | safe }},
'routes.other_domain': {{ t('routes.other_domain') | dump | safe }},
'routes.no_verified_domains_hint': {{ t('routes.no_verified_domains_hint') | dump | safe }},
'routes.unverified_base_option': {{ t('routes.unverified_base_option') | dump | safe }},
'routes.unverified_base_prefix_warning': {{ t('routes.unverified_base_prefix_warning') | dump | safe }},
'routes.unverified_base_badge': {{ t('routes.unverified_base_badge') | dump | safe }},
'routes.unverified_base_tooltip': {{ t('routes.unverified_base_tooltip') | dump | safe }},
'gateways.online': {{ t('gateways.online') | dump | safe }},
'gateways.offline': {{ t('gateways.offline') | dump | safe }},
'gateways.degraded': {{ t('gateways.degraded') | dump | safe }},
Expand Down
Loading
Loading