From 6f3daf5eea1e85d0ed08e0199f744ea248fb405d Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:17:49 +0200 Subject: [PATCH 01/10] feat(domains): base-domain extraction + server-IP-flag guard helpers --- src/services/domainSeed.js | 32 ++++++++++++++++++++++++++++++++ tests/domain_seed.test.js | 21 +++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 src/services/domainSeed.js create mode 100644 tests/domain_seed.test.js diff --git a/src/services/domainSeed.js b/src/services/domainSeed.js new file mode 100644 index 00000000..3298e171 --- /dev/null +++ b/src/services/domainSeed.js @@ -0,0 +1,32 @@ +// src/services/domainSeed.js +'use strict'; + +function normalizeHost(h) { + return String(h || '').trim().toLowerCase().replace(/\.$/, ''); +} + +/** Last-two-labels registrable host. 'a.b.example.com' -> 'example.com', apex -> itself. */ +function baseDomain(host) { + const h = normalizeHost(host).replace(/^\*\./, ''); + if (!h || h.startsWith('*')) return ''; + const parts = h.split('.').filter(Boolean); + if (parts.length < 2) return ''; + return parts.slice(-2).join('.'); +} + +function extractBaseDomains(routeDomains) { + const set = new Set(); + for (const d of routeDomains || []) { + const b = baseDomain(d); + if (b) set.add(b); + } + return [...set]; +} + +function shouldFlagServerIp(results) { + const r = results || []; + if (r.length < 2) return false; + return r.every(x => !x.matched); +} + +module.exports = { normalizeHost, baseDomain, extractBaseDomains, shouldFlagServerIp }; diff --git a/tests/domain_seed.test.js b/tests/domain_seed.test.js new file mode 100644 index 00000000..8b04e816 --- /dev/null +++ b/tests/domain_seed.test.js @@ -0,0 +1,21 @@ +'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 { extractBaseDomains, shouldFlagServerIp } = require('../src/services/domainSeed'); + +test('extractBaseDomains reduces to last-two-labels, distinct, lowercased', () => { + const out = extractBaseDomains([ + 'nas.domaincaster.com', 'foo.bar.domaincaster.com', 'domaincaster.com', + 'a.marcbackes.net', 'MARCBACKES.NET', '', '*.wild.com', null, + ]); + assert.deepEqual(out.sort(), ['domaincaster.com', 'marcbackes.net', 'wild.com'].sort()); +}); + +test('shouldFlagServerIp: true only when >=2 and all mismatch', () => { + assert.equal(shouldFlagServerIp([{ domain: 'a', matched: false }]), false); // n=1 + assert.equal(shouldFlagServerIp([{ domain: 'a', matched: false }, { domain: 'b', matched: false }]), true); + assert.equal(shouldFlagServerIp([{ domain: 'a', matched: false }, { domain: 'b', matched: true }]), false); + assert.equal(shouldFlagServerIp([]), false); +}); From d761317b719b9b1239ab2f5229f8e5a9936cb4c9 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:20:40 +0200 Subject: [PATCH 02/10] =?UTF-8?q?feat(domains):=20migration=2058=20?= =?UTF-8?q?=E2=80=94=20domains=20registry=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/db/migrationList.js | 15 +++++++++++++++ tests/domain_service.test.js | 17 +++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 tests/domain_service.test.js diff --git a/src/db/migrationList.js b/src/db/migrationList.js index ff50ee8d..fbb1e156 100644 --- a/src/db/migrationList.js +++ b/src/db/migrationList.js @@ -1031,6 +1031,21 @@ const migrations = [ ON egress_routes(near_peer_id, lan_listen_port) WHERE enabled = 1 AND near_peer_id IS NOT NULL;`, detect: (db) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='index' AND name='idx_egress_near_port_unique'").get(), }, + { + version: 58, + name: 'create_domains_registry', + sql: `CREATE TABLE IF NOT EXISTS domains ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + domain TEXT NOT NULL UNIQUE, + status TEXT NOT NULL DEFAULT 'pending', + resolved_ip TEXT, + last_error TEXT, + verified_at TEXT, + last_checked_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + );`, + detect: (db) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='domains'").get(), + }, ]; module.exports = { migrations }; diff --git a/tests/domain_service.test.js b/tests/domain_service.test.js new file mode 100644 index 00000000..b7e271e7 --- /dev/null +++ b/tests/domain_service.test.js @@ -0,0 +1,17 @@ +'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 getDb; +beforeEach(async () => { await setup(); getDb = require('../src/db/connection').getDb; }); +afterEach(teardown); + +test('domains table exists with expected columns', () => { + const cols = getDb().prepare("PRAGMA table_info('domains')").all().map(c => c.name); + for (const c of ['id','domain','status','resolved_ip','last_error','verified_at','last_checked_at','created_at']) { + assert.ok(cols.includes(c), `missing column ${c}`); + } +}); From 2abdc2a71b46c46beb153bffd80935f3b2c63133 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:25:26 +0200 Subject: [PATCH 03/10] =?UTF-8?q?feat(domains):=20registry=20service=20?= =?UTF-8?q?=E2=80=94=20server-IP=20derivation,=20DNS-match=20verify,=20CRU?= =?UTF-8?q?D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/domains.js | 120 +++++++++++++++++++++++++++++++++ tests/domain_server_ip.test.js | 47 +++++++++++++ tests/domain_service.test.js | 10 +++ tests/domain_verify.test.js | 47 +++++++++++++ 4 files changed, 224 insertions(+) create mode 100644 src/services/domains.js create mode 100644 tests/domain_server_ip.test.js create mode 100644 tests/domain_verify.test.js diff --git a/src/services/domains.js b/src/services/domains.js new file mode 100644 index 00000000..77710663 --- /dev/null +++ b/src/services/domains.js @@ -0,0 +1,120 @@ +// src/services/domains.js +'use strict'; +const dns = require('node:dns').promises; +const net = require('node:net'); +const { getDb } = require('../db/connection'); +const settings = require('./settings'); +const config = require('../../config/default'); +const { normalizeHost } = require('./domainSeed'); +const { isLoopbackHost } = require('../utils/validate'); + +const DEFAULT_RESOLVERS = ['1.1.1.1', '9.9.9.9', '8.8.8.8']; +const TIMEOUT_MS = 5000; + +function withTimeout(promise, ms = TIMEOUT_MS) { + return Promise.race([ + promise, + new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), ms).unref()), + ]); +} + +// Injectable seam: resolve(host, family) -> Promise. Default uses an +// EXPLICIT public resolver (never the ambient/dnsmasq resolver). +let _resolve = async (host, family) => { + const servers = (settings.get('server.verify_resolver', '') || DEFAULT_RESOLVERS.join(',')) + .split(',').map(s => s.trim()).filter(Boolean); + const r = new dns.Resolver(); + r.setServers(servers); + return withTimeout(family === 6 ? r.resolve6(host) : r.resolve4(host)); +}; +function _setResolverForTest(fn) { if (process.env.NODE_ENV === 'test') _resolve = fn; } + +async function resolveHost(host) { + // returns { v4, v6 }. A FAMILY THROW = transient (timeout/SERVFAIL); an empty + // array = NXDOMAIN (legit "no records"). If any family threw AND we got NO + // records at all, surface transient (caller → pending), NOT "no records". + const out = { v4: [], v6: [] }; + let threw = 0; + try { out.v4 = await _resolve(host, 4) || []; } catch { threw++; } + try { out.v6 = await _resolve(host, 6) || []; } catch { threw++; } + if (threw > 0 && out.v4.length === 0 && out.v6.length === 0) { + throw new Error('resolver_unreachable'); + } + return out; +} + +async function getServerPublicIp() { + const override = (settings.get('server.public_ip', '') || '').trim(); + if (override) return { ip: override, family: net.isIPv6(override) ? 6 : 4, source: 'override' }; + const candidates = [config.wireguard.host, new URL(config.app.baseUrl).hostname] + .map(h => normalizeHost(h)).filter(Boolean); + for (const host of candidates) { + if (isLoopbackHost(host) || host === '0.0.0.0') continue; // never 127.x/::1/localhost + if (net.isIP(host)) return { ip: host, family: net.isIPv6(host) ? 6 : 4, source: 'literal' }; + try { + const { v4, v6 } = await resolveHost(host); + if (v4[0]) return { ip: v4[0], family: 4, source: 'wg_host' }; + if (v6[0]) return { ip: v6[0], family: 6, source: 'wg_host' }; + } catch { /* try next candidate */ } + } + return { ip: null, family: null, source: 'unknown' }; +} + +async function verify(domain) { + const host = normalizeHost(domain); + const server = await getServerPublicIp(); + if (!server.ip) { + return { status: 'pending', resolvedIp: null, expectedIp: null, error: 'server_ip_unknown' }; + } + let resolved; + try { resolved = await resolveHost(host); } + catch { return { status: 'pending', resolvedIp: null, expectedIp: server.ip, error: 'resolver_unreachable' }; } + const all = [...resolved.v4, ...resolved.v6]; + if (all.length === 0) { + return { status: 'failed', resolvedIp: null, expectedIp: server.ip, error: 'no A/AAAA records' }; + } + if (all.includes(server.ip)) { + return { status: 'verified', resolvedIp: server.ip, expectedIp: server.ip, error: null }; + } + return { status: 'failed', resolvedIp: all[0], expectedIp: server.ip, + error: `DNS verweist auf ${all[0]}, erwartet ${server.ip}` }; +} + +// ── CRUD ── +function row(domain) { return getDb().prepare('SELECT * FROM domains WHERE domain = ?').get(normalizeHost(domain)); } +function list() { return getDb().prepare('SELECT * FROM domains ORDER BY domain').all(); } +function listVerified() { return getDb().prepare("SELECT * FROM domains WHERE status='verified' ORDER BY domain").all(); } +function baseDomains() { return listVerified().map(r => r.domain); } +function isVerified(domain) { const r = row(domain); return !!r && r.status === 'verified'; } + +function upsert(domain, v) { + const db = getDb(); + db.prepare(`INSERT INTO domains (domain, status, resolved_ip, last_error, verified_at, last_checked_at) + VALUES (@domain, @status, @resolved_ip, @last_error, @verified_at, datetime('now')) + ON CONFLICT(domain) DO UPDATE SET status=excluded.status, resolved_ip=excluded.resolved_ip, + last_error=excluded.last_error, verified_at=excluded.verified_at, last_checked_at=excluded.last_checked_at`) + .run({ + domain: normalizeHost(domain), status: v.status, resolved_ip: v.resolvedIp || null, + last_error: v.error || null, verified_at: v.status === 'verified' ? new Date().toISOString() : null, + }); + return row(domain); +} + +async function add(domain) { + const v = await verify(domain); + return upsert(domain, v); +} +function seedPending(domain) { + const db = getDb(); + db.prepare("INSERT OR IGNORE INTO domains (domain, status) VALUES (?, 'pending')").run(normalizeHost(domain)); +} +function remove(idOrDomain) { + const db = getDb(); + if (typeof idOrDomain === 'number') return db.prepare('DELETE FROM domains WHERE id = ?').run(idOrDomain); + return db.prepare('DELETE FROM domains WHERE domain = ?').run(normalizeHost(idOrDomain)); +} + +module.exports = { + getServerPublicIp, verify, add, seedPending, list, listVerified, baseDomains, isVerified, remove, row, + _setResolverForTest, +}; diff --git a/tests/domain_server_ip.test.js b/tests/domain_server_ip.test.js new file mode 100644 index 00000000..868b229e --- /dev/null +++ b/tests/domain_server_ip.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'); +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const { setup, teardown } = require('./helpers/setup'); + +let domains, settings; +beforeEach(async () => { + await setup(); + domains = require('../src/services/domains'); + settings = require('../src/services/settings'); +}); +afterEach(teardown); + +test('manual override wins', async () => { + settings.set('server.public_ip', '203.0.113.9'); + const r = await domains.getServerPublicIp(); + assert.equal(r.ip, '203.0.113.9'); + assert.equal(r.source, 'override'); +}); + +test('derives from GC_WG_HOST via the injected resolver (no third-party call)', async () => { + // GC_WG_HOST defaults to test.example.com in the harness; stub the resolver. + domains._setResolverForTest(async (host, family) => (family === 4 ? ['198.51.100.7'] : [])); + const r = await domains.getServerPublicIp(); + assert.equal(r.ip, '198.51.100.7'); + assert.equal(r.family, 4); +}); + +test('unresolvable/localhost host -> unknown, never 127.0.0.1', async () => { + domains._setResolverForTest(async () => []); // nothing resolves + const r = await domains.getServerPublicIp(); + assert.equal(r.ip, null); +}); + +test('loopback IP-literal in GC_WG_HOST -> unknown, never returns 127.0.0.1', async () => { + // Force GC_WG_HOST to a loopback literal; isLoopbackHost must skip it (not return it). + process.env.GC_WG_HOST = '127.0.0.1'; + delete require.cache[require.resolve('../config/default')]; + delete require.cache[require.resolve('../src/services/domains')]; + const d2 = require('../src/services/domains'); + d2._setResolverForTest(async () => { throw new Error('resolver must not be called'); }); + // GC_BASE_URL host is localhost in the harness → also skipped → unknown. + const r = await d2.getServerPublicIp(); + assert.equal(r.ip, null); +}); diff --git a/tests/domain_service.test.js b/tests/domain_service.test.js index b7e271e7..22c957ee 100644 --- a/tests/domain_service.test.js +++ b/tests/domain_service.test.js @@ -15,3 +15,13 @@ test('domains table exists with expected columns', () => { assert.ok(cols.includes(c), `missing column ${c}`); } }); + +test('add upserts a verified row; listVerified/baseDomains/isVerified reflect it', async () => { + const domains = require('../src/services/domains'); + const settings = require('../src/services/settings'); + settings.set('server.public_ip', '198.51.100.7'); + domains._setResolverForTest(async (h, f) => (f === 4 ? ['198.51.100.7'] : [])); + await domains.add('home.example.com'); + assert.equal(domains.isVerified('home.example.com'), true); + assert.deepEqual(domains.baseDomains(), ['home.example.com']); +}); diff --git a/tests/domain_verify.test.js b/tests/domain_verify.test.js new file mode 100644 index 00000000..165c7eeb --- /dev/null +++ b/tests/domain_verify.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'); +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const { setup, teardown } = require('./helpers/setup'); + +let domains, settings; +beforeEach(async () => { + await setup(); + domains = require('../src/services/domains'); + settings = require('../src/services/settings'); + settings.set('server.public_ip', '198.51.100.7'); // known-good server IP +}); +afterEach(teardown); + +test('verified when a resolved A matches the server IP', async () => { + domains._setResolverForTest(async (h, f) => (f === 4 ? ['198.51.100.7'] : [])); + const r = await domains.verify('home.example.com'); + assert.equal(r.status, 'verified'); + assert.equal(r.resolvedIp, '198.51.100.7'); +}); + +test('failed when resolves elsewhere (server IP known-good)', async () => { + domains._setResolverForTest(async (h, f) => (f === 4 ? ['203.0.113.1'] : [])); + const r = await domains.verify('elsewhere.example.com'); + assert.equal(r.status, 'failed'); + assert.match(r.error, /198\.51\.100\.7/); // expected IP in the message +}); + +test('pending (not failed) when server IP unknown', async () => { + settings.set('server.public_ip', ''); // clear the override → must derive + // CRITICAL: the stub answers ALL hosts incl. GC_WG_HOST (test.example.com). To make + // the server IP truly unknown, GC_WG_HOST must resolve to [] (else getServerPublicIp + // would derive an IP and the test would not exercise the unknown path). + domains._setResolverForTest(async (h, f) => + (h === 'test.example.com' ? [] : (f === 4 ? ['203.0.113.1'] : []))); + const r = await domains.verify('x.example.com'); + assert.equal(r.status, 'pending'); +}); + +test('pending (not failed) when all public resolvers unreachable', async () => { + domains._setResolverForTest(async () => { const e = new Error('ESERVFAIL'); throw e; }); + const r = await domains.verify('x.example.com'); + assert.equal(r.status, 'pending'); + assert.match(r.error, /resolver|erreichbar/i); +}); From d62d82d76b77d5f248274869cdeff9e261921b18 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:32:51 +0200 Subject: [PATCH 04/10] feat(domains): boot seed + verify pass (non-alarming, all-mismatch guard) --- src/server.js | 14 ++++++++++++ src/services/domainBoot.js | 31 +++++++++++++++++++++++++++ tests/domain_boot_seed.test.js | 39 ++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 src/services/domainBoot.js create mode 100644 tests/domain_boot_seed.test.js diff --git a/src/server.js b/src/server.js index 0ae29c84..772e4ce5 100644 --- a/src/server.js +++ b/src/server.js @@ -107,6 +107,20 @@ async function start() { }, config.intervals.caddySyncDelay); } + // Domain registry — seed base domains from routes and run a best-effort + // verify pass on boot. Non-blocking: errors are swallowed. Never marks + // rows 'failed'; only sets the server-IP warning setting if all domains + // mismatched (≥2). Re-runs only pending rows, so it is idempotent. + setTimeout(async () => { + try { + const { runDomainSeedAndVerify } = require('./services/domainBoot'); + const r = await runDomainSeedAndVerify(); + logger.info({ seeded: r.seeded, verified: r.verified, serverIpWarning: r.flagged }, 'Domain registry seed+verify done'); + } catch (err) { + logger.warn({ error: err.message }, 'Domain registry boot pass skipped'); + } + }, 8000); // after network/route sync settles; backoff not required (re-runs verify pending on next boot) + // Start background tasks startCollector(config.intervals.trafficCollector); startPoller(config.intervals.peerPoller); diff --git a/src/services/domainBoot.js b/src/services/domainBoot.js new file mode 100644 index 00000000..184d5147 --- /dev/null +++ b/src/services/domainBoot.js @@ -0,0 +1,31 @@ +// src/services/domainBoot.js +'use strict'; +const { getDb } = require('../db/connection'); +const domains = require('./domains'); +const settings = require('./settings'); +const { extractBaseDomains, shouldFlagServerIp } = require('./domainSeed'); + +async function runDomainSeedAndVerify({ verifyEach = domains.verify } = {}) { + const routeDomains = getDb().prepare('SELECT DISTINCT domain FROM routes WHERE domain IS NOT NULL').all().map(r => r.domain); + const bases = extractBaseDomains(routeDomains); + for (const d of bases) domains.seedPending(d); + + // verify only rows still pending (idempotent across boots) + const pending = getDb().prepare("SELECT domain FROM domains WHERE status='pending'").all().map(r => r.domain); + const results = []; + for (const d of pending) { + const v = await verifyEach(d); + results.push({ domain: d, matched: v.status === 'verified' }); + if (v.status === 'verified') { + getDb().prepare("UPDATE domains SET status='verified', resolved_ip=?, last_error=NULL, verified_at=datetime('now'), last_checked_at=datetime('now') WHERE domain=?").run(v.resolvedIp || null, d); + } else { + // keep pending; do NOT redden on boot (per spec) + getDb().prepare("UPDATE domains SET last_checked_at=datetime('now') WHERE domain=?").run(d); + } + } + const flagged = shouldFlagServerIp(results); + settings.set('domains.server_ip_warning', flagged ? '1' : '0'); + return { seeded: bases.length, verified: results.filter(r => r.matched).length, flagged }; +} + +module.exports = { runDomainSeedAndVerify }; diff --git a/tests/domain_boot_seed.test.js b/tests/domain_boot_seed.test.js new file mode 100644 index 00000000..2f291fdb --- /dev/null +++ b/tests/domain_boot_seed.test.js @@ -0,0 +1,39 @@ +'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 getDb, domainBoot, settings; +beforeEach(async () => { + await setup(); + getDb = require('../src/db/connection').getDb; + domainBoot = require('../src/services/domainBoot'); + settings = require('../src/services/settings'); + getDb().prepare(`INSERT INTO routes (description, domain, target_ip, target_port, enabled, route_type) + VALUES ('r1','nas.domaincaster.com','10.0.0.2','80',1,'http')`).run(); + getDb().prepare(`INSERT INTO routes (description, domain, target_ip, target_port, enabled, route_type) + VALUES ('r2','foo.marcbackes.net','10.0.0.3','80',1,'http')`).run(); +}); +afterEach(teardown); + +test('seeds distinct base domains and verifies them', async () => { + const res = await domainBoot.runDomainSeedAndVerify({ + verifyEach: async (d) => ({ status: 'verified', resolvedIp: '1.2.3.4', expectedIp: '1.2.3.4', error: null }), + }); + assert.equal(res.seeded, 2); + const rows = getDb().prepare('SELECT domain,status FROM domains ORDER BY domain').all(); + assert.deepEqual(rows.map(r => r.domain), ['domaincaster.com', 'marcbackes.net']); + assert.ok(rows.every(r => r.status === 'verified')); +}); + +test('>=2 all-mismatch sets the server-IP warning and keeps rows pending', async () => { + const res = await domainBoot.runDomainSeedAndVerify({ + verifyEach: async (d) => ({ status: 'failed', resolvedIp: '9.9.9.9', expectedIp: '1.2.3.4', error: 'x' }), + }); + assert.equal(res.flagged, true); + assert.equal(settings.get('domains.server_ip_warning', '0'), '1'); + const rows = getDb().prepare('SELECT status FROM domains').all(); + assert.ok(rows.every(r => r.status === 'pending')); // not reddened +}); From f26e0efc8a8f60caa9055b3e75fde475cc2044d0 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:41:07 +0200 Subject: [PATCH 05/10] feat(domains): admin settings API (/api/v1/settings/domains) --- src/routes/api/settings/domains.js | 72 ++++++++++++++++++++++++++++++ src/routes/api/settings/index.js | 2 + tests/domains_api.test.js | 68 ++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+) create mode 100644 src/routes/api/settings/domains.js create mode 100644 tests/domains_api.test.js diff --git a/src/routes/api/settings/domains.js b/src/routes/api/settings/domains.js new file mode 100644 index 00000000..50c1d114 --- /dev/null +++ b/src/routes/api/settings/domains.js @@ -0,0 +1,72 @@ +'use strict'; +const { Router } = require('express'); +const net = require('node:net'); +const domains = require('../../../services/domains'); +const settings = require('../../../services/settings'); +const activity = require('../../../services/activity'); +const { getDb } = require('../../../db/connection'); +const logger = require('../../../utils/logger'); +const router = Router(); + +router.get('/domains', async (req, res) => { + try { + const server = await domains.getServerPublicIp(); + res.json({ ok: true, data: { + domains: domains.list(), + serverIp: server.ip, + serverIpWarning: settings.get('domains.server_ip_warning', '0') === '1', + } }); + } catch (err) { + logger.warn({ err: err.message }, 'GET /domains failed'); + res.status(500).json({ ok: false, error: req.t('common.error') }); + } +}); + +router.post('/domains', async (req, res) => { + const domain = String(req.body?.domain || '').trim().toLowerCase(); + if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) { + return res.status(400).json({ ok: false, error: req.t('settings.domains.invalid') }); + } + try { + const row = await domains.add(domain); + activity.log('domain_added', `Domain ${domain} (${row.status})`, { + source: 'admin', + ipAddress: req.ip, + severity: 'info', + }); + res.json({ ok: true, data: row }); + } catch (err) { + logger.warn({ err: err.message, domain }, 'POST /domains failed'); + res.status(500).json({ ok: false, error: req.t('common.error') }); + } +}); + +router.post('/domains/:id/verify', async (req, res) => { + try { + const rowById = getDb() + .prepare('SELECT domain FROM domains WHERE id = ?') + .get(Number(req.params.id)); + if (!rowById) return res.status(404).json({ ok: false, error: req.t('error.not_found') }); + const updated = await domains.add(rowById.domain); + res.json({ ok: true, data: updated }); + } catch (err) { + logger.warn({ err: err.message, id: req.params.id }, 'POST /domains/:id/verify failed'); + res.status(500).json({ ok: false, error: req.t('common.error') }); + } +}); + +router.delete('/domains/:id', (req, res) => { + domains.remove(Number(req.params.id)); + res.json({ ok: true }); +}); + +router.put('/domains/server-ip', (req, res) => { + const ip = String(req.body?.ip ?? '').trim(); + if (ip !== '' && !net.isIP(ip)) { + return res.status(400).json({ ok: false, error: req.t('settings.domains.invalid_ip') }); + } + settings.set('server.public_ip', ip); + res.json({ ok: true }); +}); + +module.exports = router; diff --git a/src/routes/api/settings/index.js b/src/routes/api/settings/index.js index 63e9fc7f..a9040b76 100644 --- a/src/routes/api/settings/index.js +++ b/src/routes/api/settings/index.js @@ -28,6 +28,7 @@ const TOKEN_FORBIDDEN = [ /^\/password$/, /^\/profile$/, /^\/route-block-default$/, + /^\/domains(\/|$)/, ]; router.use((req, res, next) => { if (!req.tokenAuth) return next(); @@ -46,5 +47,6 @@ router.use('/', require('./observability')); router.use('/', require('./gateway')); router.use('/', require('./pihole')); router.use('/', require('./portal')); +router.use('/', require('./domains')); module.exports = router; diff --git a/tests/domains_api.test.js b/tests/domains_api.test.js new file mode 100644 index 00000000..7af3ddb2 --- /dev/null +++ b/tests/domains_api.test.js @@ -0,0 +1,68 @@ +'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, getAgent, getCsrf } = require('./helpers/setup'); + +let domains; +beforeEach(async () => { + await setup(); + domains = require('../src/services/domains'); + domains._setResolverForTest(async (h, f) => (f === 4 ? ['198.51.100.7'] : [])); + require('../src/services/settings').set('server.public_ip', '198.51.100.7'); +}); +afterEach(teardown); + +test('POST adds + verifies a domain; GET lists it (admin session)', async () => { + const agent = getAgent(); const csrf = getCsrf(); + const post = await agent.post('/api/v1/settings/domains').set('X-CSRF-Token', csrf) + .send({ domain: 'home.example.com' }).expect(200); + assert.equal(post.body.data.status, 'verified'); + const get = await agent.get('/api/v1/settings/domains').expect(200); + assert.ok(get.body.data.domains.some(d => d.domain === 'home.example.com')); +}); + +test('unauthenticated requests are rejected (GET + POST)', async () => { + const supertest = require('supertest'); + const app = require('../src/app').createApp(); + const check = res => assert.ok([401, 403].includes(res.status), `expected 401/403, got ${res.status}`); + await supertest(app).get('/api/v1/settings/domains').expect(check); + await supertest(app).post('/api/v1/settings/domains').send({ domain: 'x.example.com' }).expect(check); +}); + +test('POST with malformed domain returns 400', async () => { + const agent = getAgent(); const csrf = getCsrf(); + const res = await agent.post('/api/v1/settings/domains').set('X-CSRF-Token', csrf) + .send({ domain: 'not a domain!' }).expect(400); + assert.equal(res.body.ok, false); +}); + +test('PUT /domains/server-ip with invalid IP returns 400', async () => { + const agent = getAgent(); const csrf = getCsrf(); + const res = await agent.put('/api/v1/settings/domains/server-ip').set('X-CSRF-Token', csrf) + .send({ ip: 'not-an-ip' }).expect(400); + assert.equal(res.body.ok, false); +}); + +test('PUT /domains/server-ip with empty string clears the override (200)', async () => { + const agent = getAgent(); const csrf = getCsrf(); + const res = await agent.put('/api/v1/settings/domains/server-ip').set('X-CSRF-Token', csrf) + .send({ ip: '' }).expect(200); + assert.equal(res.body.ok, true); +}); + +test('DELETE /domains/:id removes the domain', async () => { + const agent = getAgent(); const csrf = getCsrf(); + // First add a domain + const post = await agent.post('/api/v1/settings/domains').set('X-CSRF-Token', csrf) + .send({ domain: 'delete-me.example.com' }).expect(200); + const id = post.body.data.id; + assert.ok(id); + // Now delete it + const del = await agent.delete(`/api/v1/settings/domains/${id}`).set('X-CSRF-Token', csrf).expect(200); + assert.equal(del.body.ok, true); + // Confirm it's gone + const get = await agent.get('/api/v1/settings/domains').expect(200); + assert.ok(!get.body.data.domains.some(d => d.domain === 'delete-me.example.com')); +}); From 6b08ed77a30f17a947f3482ddb5e64d5ae461d87 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:48:49 +0200 Subject: [PATCH 06/10] fix(domains): wrap DELETE/server-ip handlers in try/catch; assert server-ip clear --- src/routes/api/settings/domains.js | 18 ++++++++++++++---- tests/domains_api.test.js | 2 ++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/routes/api/settings/domains.js b/src/routes/api/settings/domains.js index 50c1d114..7f3bcc03 100644 --- a/src/routes/api/settings/domains.js +++ b/src/routes/api/settings/domains.js @@ -56,8 +56,13 @@ router.post('/domains/:id/verify', async (req, res) => { }); router.delete('/domains/:id', (req, res) => { - domains.remove(Number(req.params.id)); - res.json({ ok: true }); + try { + domains.remove(Number(req.params.id)); + res.json({ ok: true }); + } catch (err) { + logger.warn({ err: err.message, id: req.params.id }, 'DELETE /domains/:id failed'); + res.status(500).json({ ok: false, error: req.t('common.error') }); + } }); router.put('/domains/server-ip', (req, res) => { @@ -65,8 +70,13 @@ router.put('/domains/server-ip', (req, res) => { if (ip !== '' && !net.isIP(ip)) { return res.status(400).json({ ok: false, error: req.t('settings.domains.invalid_ip') }); } - settings.set('server.public_ip', ip); - res.json({ ok: true }); + try { + settings.set('server.public_ip', ip); + res.json({ ok: true }); + } catch (err) { + logger.warn({ err: err.message }, 'PUT /domains/server-ip failed'); + res.status(500).json({ ok: false, error: req.t('common.error') }); + } }); module.exports = router; diff --git a/tests/domains_api.test.js b/tests/domains_api.test.js index 7af3ddb2..a16432b7 100644 --- a/tests/domains_api.test.js +++ b/tests/domains_api.test.js @@ -50,6 +50,8 @@ test('PUT /domains/server-ip with empty string clears the override (200)', async const res = await agent.put('/api/v1/settings/domains/server-ip').set('X-CSRF-Token', csrf) .send({ ip: '' }).expect(200); assert.equal(res.body.ok, true); + const storedIp = require('../src/services/settings').get('server.public_ip', ''); + assert.equal(storedIp, ''); }); test('DELETE /domains/:id removes the domain', async () => { From bca6b198514f1c0209d42d305c73900dd1964756 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:51:47 +0200 Subject: [PATCH 07/10] feat(domains): i18n strings (en + de) --- src/i18n/de.json | 20 +++++++++++++++++++- src/i18n/en.json | 20 +++++++++++++++++++- tests/domains_i18n.test.js | 23 +++++++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 tests/domains_i18n.test.js diff --git a/src/i18n/de.json b/src/i18n/de.json index 4e768a81..acdc83a4 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -1963,5 +1963,23 @@ "settings.portal.widget_device": "Gerätestatus", "settings.portal.widget_traffic": "Traffic-Diagramm", "settings.portal.widget_services": "Dienste", - "settings.portal.saved": "Portal-Einstellungen gespeichert" + "settings.portal.saved": "Portal-Einstellungen gespeichert", + + "settings.domains.title": "Domains", + "settings.domains.intro": "Hinterlege die Hauptdomains dieses Servers. Eine Domain muss auf diesen Server zeigen (DNS A/AAAA → Server-IP), bevor sie genutzt werden kann.", + "settings.domains.add": "Domain hinzufügen", + "settings.domains.domain": "Domain", + "settings.domains.status": "Status", + "settings.domains.verified_at": "Geprüft", + "settings.domains.verify": "Erneut prüfen", + "settings.domains.remove": "Entfernen", + "settings.domains.invalid": "Ungültiger Domainname", + "settings.domains.invalid_ip": "Ungültige IP-Adresse", + "settings.domains.status_verified": "Verifiziert", + "settings.domains.status_failed": "Zeigt nicht hierher", + "settings.domains.status_pending": "Ausstehend", + "settings.domains.server_ip": "Erkannte Server-IP", + "settings.domains.server_ip_override": "Server-IP überschreiben", + "settings.domains.server_ip_warning": "Keine Domain konnte gegen die Server-IP bestätigt werden — bitte Server-IP prüfen/überschreiben. Bestehende Routen laufen weiter.", + "settings.domains.points_note": "Verifiziert heißt: öffentliches DNS zeigt auf die Server-IP. Bestehende Routen/Portal laufen unabhängig weiter." } diff --git a/src/i18n/en.json b/src/i18n/en.json index 1bee9070..510b514b 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -1963,5 +1963,23 @@ "settings.portal.widget_device": "Device status", "settings.portal.widget_traffic": "Traffic chart", "settings.portal.widget_services": "Services", - "settings.portal.saved": "Portal settings saved" + "settings.portal.saved": "Portal settings saved", + + "settings.domains.title": "Domains", + "settings.domains.intro": "Register the main domains this server owns. A domain must point to this server (DNS A/AAAA → server IP) before it can be used.", + "settings.domains.add": "Add domain", + "settings.domains.domain": "Domain", + "settings.domains.status": "Status", + "settings.domains.verified_at": "Verified", + "settings.domains.verify": "Re-check", + "settings.domains.remove": "Remove", + "settings.domains.invalid": "Invalid domain name", + "settings.domains.invalid_ip": "Invalid IP address", + "settings.domains.status_verified": "Verified", + "settings.domains.status_failed": "Not pointing here", + "settings.domains.status_pending": "Pending", + "settings.domains.server_ip": "Detected server IP", + "settings.domains.server_ip_override": "Override server IP", + "settings.domains.server_ip_warning": "Could not confirm any domain against the server IP — please check/override the server IP. Existing routes keep working.", + "settings.domains.points_note": "Verified means: public DNS points to this server's IP. Existing routes/portal keep working regardless." } diff --git a/tests/domains_i18n.test.js b/tests/domains_i18n.test.js new file mode 100644 index 00000000..6798d50d --- /dev/null +++ b/tests/domains_i18n.test.js @@ -0,0 +1,23 @@ +'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 en = require('../src/i18n/en.json'); +const de = require('../src/i18n/de.json'); + +const KEYS = [ + 'settings.domains.title', 'settings.domains.intro', 'settings.domains.add', + 'settings.domains.domain', 'settings.domains.status', 'settings.domains.verified_at', + 'settings.domains.verify', 'settings.domains.remove', 'settings.domains.invalid', + 'settings.domains.status_verified', 'settings.domains.status_failed', 'settings.domains.status_pending', + 'settings.domains.server_ip', 'settings.domains.server_ip_override', 'settings.domains.server_ip_warning', + 'settings.domains.points_note', 'settings.domains.invalid_ip', +]; + +test('all settings.domains.* keys present in both locales', () => { + for (const k of KEYS) { + assert.ok(en[k], `en missing ${k}`); + assert.ok(de[k], `de missing ${k}`); + } +}); From 10ce94699d79a829c7b54eb15810ce0b859233d5 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:01:16 +0200 Subject: [PATCH 08/10] feat(domains): General-tab Domains section + settings.js wiring --- public/js/settings.js | 183 +++++++++++++++++++++++++++ templates/aurora/pages/settings.njk | 47 +++++++ templates/default/pages/settings.njk | 47 +++++++ templates/pro/pages/settings.njk | 47 +++++++ tests/domains_ui_smoke.test.js | 15 +++ 5 files changed, 339 insertions(+) create mode 100644 tests/domains_ui_smoke.test.js diff --git a/public/js/settings.js b/public/js/settings.js index 8751b140..f2e39a29 100644 --- a/public/js/settings.js +++ b/public/js/settings.js @@ -1769,3 +1769,186 @@ }); } })(); + +// ─── Domains Registry ───────────────────────────── +(function () { + var tbl = document.getElementById('domains-table'); + if (!tbl) return; + + var tbody = document.getElementById('domains-tbody'); + var serverIpEl = document.getElementById('domains-server-ip'); + var warningEl = document.getElementById('domains-server-ip-warning'); + var ipInput = document.getElementById('domains-server-ip-input'); + var ipSaveBtn = document.getElementById('domains-server-ip-save'); + var addInput = document.getElementById('domains-add-input'); + var addBtn = document.getElementById('domains-add-btn'); + var addError = document.getElementById('domains-add-error'); + + var labelVerified = tbl.dataset.labelVerified || 'Verified'; + var labelFailed = tbl.dataset.labelFailed || 'Not pointing here'; + var labelPending = tbl.dataset.labelPending || 'Pending'; + var labelVerify = tbl.dataset.labelVerify || 'Re-check'; + var labelRemove = tbl.dataset.labelRemove || 'Remove'; + + function statusBadge(status) { + var span = document.createElement('span'); + span.className = 'tag ' + (status === 'verified' ? 'tag-green' : status === 'failed' ? 'tag-amber' : 'tag-grey'); + span.style.fontSize = '11px'; + var dot = document.createElement('span'); + dot.className = 'tag-dot'; + span.appendChild(dot); + span.appendChild(document.createTextNode( + status === 'verified' ? labelVerified : status === 'failed' ? labelFailed : labelPending + )); + return span; + } + + function formatDate(iso) { + if (!iso) return '—'; + try { return new Date(iso).toLocaleDateString(); } catch (e) { return iso; } + } + + function renderRows(domains) { + tbody.textContent = ''; + if (!domains || domains.length === 0) return; + domains.forEach(function (d) { + var tr = document.createElement('tr'); + tr.dataset.domainId = d.id; + tr.style.borderBottom = '1px solid var(--border)'; + + var tdDomain = document.createElement('td'); + tdDomain.style.cssText = 'padding:8px;font-family:var(--font-mono);font-size:12px'; + tdDomain.textContent = d.domain; + if (d.last_error && d.status === 'failed') { + var errEl = document.createElement('div'); + errEl.style.cssText = 'font-size:11px;color:var(--red);margin-top:2px;font-family:inherit'; + errEl.textContent = d.last_error; + tdDomain.appendChild(errEl); + } + tr.appendChild(tdDomain); + + var tdStatus = document.createElement('td'); + tdStatus.style.padding = '8px'; + tdStatus.appendChild(statusBadge(d.status)); + tr.appendChild(tdStatus); + + var tdDate = document.createElement('td'); + tdDate.style.cssText = 'padding:8px;font-size:11px;color:var(--text-3)'; + tdDate.textContent = formatDate(d.verified_at); + tr.appendChild(tdDate); + + var tdAct = document.createElement('td'); + tdAct.style.cssText = 'padding:8px;white-space:nowrap'; + + var verifyBtn = document.createElement('button'); + verifyBtn.className = 'btn btn-ghost'; + verifyBtn.style.cssText = 'font-size:11px;padding:3px 8px;margin-right:4px'; + verifyBtn.textContent = labelVerify; + verifyBtn.addEventListener('click', (function (domainId, row) { + return function () { recheckDomain(domainId, row); }; + })(d.id, tr)); + tdAct.appendChild(verifyBtn); + + var removeBtn = document.createElement('button'); + removeBtn.className = 'btn btn-ghost'; + removeBtn.style.cssText = 'font-size:11px;padding:3px 8px;color:var(--red)'; + removeBtn.textContent = labelRemove; + removeBtn.addEventListener('click', (function (domainId, row) { + return function () { removeDomain(domainId, row); }; + })(d.id, tr)); + tdAct.appendChild(removeBtn); + + tr.appendChild(tdAct); + tbody.appendChild(tr); + }); + } + + async function loadDomains() { + try { + var r = await api.get('/api/v1/settings/domains'); + if (!r.ok) return; + renderRows(r.data.domains); + if (serverIpEl) serverIpEl.textContent = r.data.serverIp || '—'; + if (warningEl) warningEl.style.display = r.data.serverIpWarning ? '' : 'none'; + } catch (err) { + console.error('Failed to load domains:', err); + } + } + + async function recheckDomain(id, tr) { + try { + var r = await api.post('/api/v1/settings/domains/' + id + '/verify', {}); + if (r.ok && r.data) { + var d = r.data; + var domainCell = tr.cells[0]; + domainCell.textContent = d.domain; + if (d.last_error && d.status === 'failed') { + var errEl = document.createElement('div'); + errEl.style.cssText = 'font-size:11px;color:var(--red);margin-top:2px'; + errEl.textContent = d.last_error; + domainCell.appendChild(errEl); + } + var statusCell = tr.cells[1]; + statusCell.textContent = ''; + statusCell.appendChild(statusBadge(d.status)); + tr.cells[2].textContent = formatDate(d.verified_at); + } + } catch (err) { + console.error('Recheck failed:', err); + } + } + + async function removeDomain(id, tr) { + try { + var r = await api.del('/api/v1/settings/domains/' + id); + if (r.ok) tr.remove(); + } catch (err) { + console.error('Remove domain failed:', err); + } + } + + if (addBtn) { + addBtn.addEventListener('click', async function () { + var domain = addInput ? addInput.value.trim() : ''; + if (!domain) return; + if (addError) { addError.style.display = 'none'; addError.textContent = ''; } + btnLoading(addBtn); + try { + var r = await api.post('/api/v1/settings/domains', { domain: domain }); + if (!r.ok) { + if (addError) { addError.textContent = r.error || domain + ' is invalid'; addError.style.display = ''; } + } else { + if (addInput) addInput.value = ''; + await loadDomains(); + } + } catch (err) { + if (addError) { addError.textContent = err.message; addError.style.display = ''; } + } finally { + btnReset(addBtn); + } + }); + } + + if (ipSaveBtn) { + ipSaveBtn.addEventListener('click', async function () { + var ip = ipInput ? ipInput.value.trim() : ''; + if (addError) { addError.style.display = 'none'; addError.textContent = ''; } + btnLoading(ipSaveBtn); + try { + var r = await api.put('/api/v1/settings/domains/server-ip', { ip: ip }); + if (r.ok) { + if (ipInput) ipInput.value = ''; + await loadDomains(); + } else { + if (addError) { addError.textContent = r.error || 'Invalid IP'; addError.style.display = ''; } + } + } catch (err) { + if (addError) { addError.textContent = err.message; addError.style.display = ''; } + } finally { + btnReset(ipSaveBtn); + } + }); + } + + loadDomains(); +})(); diff --git a/templates/aurora/pages/settings.njk b/templates/aurora/pages/settings.njk index 91ac77ff..a67aa077 100644 --- a/templates/aurora/pages/settings.njk +++ b/templates/aurora/pages/settings.njk @@ -248,6 +248,53 @@ + {# ─── Domains ─────────────────────────────────────── #} +
+
+ + {{ t('settings.domains.title') }} +
+
+
{{ t('settings.domains.intro') }}
+
+
+ {{ t('settings.domains.server_ip') }} + +
+ +
+ + +
+
+ + + + + + + + + + +
{{ t('settings.domains.domain') }}{{ t('settings.domains.status') }}{{ t('settings.domains.verified_at') }}
+
+
+ + +
+ +
+
{{ t('settings.domains.points_note') }}
+
+
+ {# ─── WG Stop Password Modal ────────────────────── #} + {# ─── Domains ─────────────────────────────────────── #} +
+
+ + {{ t('settings.domains.title') }} +
+
+
{{ t('settings.domains.intro') }}
+
+
+ {{ t('settings.domains.server_ip') }} + +
+ +
+ + +
+
+ + + + + + + + + + +
{{ t('settings.domains.domain') }}{{ t('settings.domains.status') }}{{ t('settings.domains.verified_at') }}
+
+
+ + +
+ +
+
{{ t('settings.domains.points_note') }}
+
+
+ {# ─── WG Stop Password Modal ────────────────────── #} + {# ─── Domains ─────────────────────────────────────── #} +
+
+ + {{ t('settings.domains.title') }} +
+
+
{{ t('settings.domains.intro') }}
+
+
+ {{ t('settings.domains.server_ip') }} + +
+ +
+ + +
+
+ + + + + + + + + + +
{{ t('settings.domains.domain') }}{{ t('settings.domains.status') }}{{ t('settings.domains.verified_at') }}
+
+
+ + +
+ +
+
{{ t('settings.domains.points_note') }}
+
+
+ {# ─── WG Stop Password Modal ────────────────────── #}