diff --git a/public/js/settings.js b/public/js/settings.js index 8751b140..bea343cb 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 || ''; + var labelFailed = tbl.dataset.labelFailed || ''; + var labelPending = tbl.dataset.labelPending || ''; + var labelVerify = tbl.dataset.labelVerify || ''; + var labelRemove = tbl.dataset.labelRemove || ''; + + 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 || ''; 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 || ''; addError.style.display = ''; } + } + } catch (err) { + if (addError) { addError.textContent = err.message; addError.style.display = ''; } + } finally { + btnReset(ipSaveBtn); + } + }); + } + + loadDomains(); +})(); 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/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/src/routes/api/settings/domains.js b/src/routes/api/settings/domains.js new file mode 100644 index 00000000..910f3a89 --- /dev/null +++ b/src/routes/api/settings/domains.js @@ -0,0 +1,89 @@ +'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) => { + 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', async (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') }); + } + try { + settings.set('server.public_ip', ip); + // Best-effort: re-verify all domains against the new IP so the + // server-IP warning is refreshed immediately (without restart). + try { + await require('../../../services/domainBoot').reverifyAllAndReflag(); + } catch (verifyErr) { + logger.warn({ err: verifyErr.message }, 'reverifyAllAndReflag failed after server-ip save'); + } + 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/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/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..5036b248 --- /dev/null +++ b/src/services/domainBoot.js @@ -0,0 +1,59 @@ +// src/services/domainBoot.js +'use strict'; +const { getDb } = require('../db/connection'); +const domains = require('./domains'); +const settings = require('./settings'); +const { extractBaseDomains, shouldFlagServerIp } = require('./domainSeed'); + +/** + * Verify a set of domain names against the server IP, persist results, and + * recompute the instance-level server-IP warning flag. + * + * Non-alarming path: rows that do NOT resolve correctly stay/return to + * status='pending' (never written as 'failed' here — per-domain 'failed' is + * produced only by the explicit per-row Re-check which calls domains.add()). + * + * Returns { verified, flagged }. + */ +async function verifyAndReflag(domainNames, { verifyEach = domains.verify } = {}) { + const results = []; + for (const d of domainNames) { + 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/reset to pending; do NOT write 'failed' on this path + getDb().prepare("UPDATE domains SET status='pending', last_checked_at=datetime('now') WHERE domain=?").run(d); + } + } + const flagged = shouldFlagServerIp(results); + settings.set('domains.server_ip_warning', flagged ? '1' : '0'); + return { verified: results.filter(r => r.matched).length, flagged }; +} + +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 { verified, flagged } = await verifyAndReflag(pending, { verifyEach }); + return { seeded: bases.length, verified, flagged }; +} + +/** + * Re-verify ALL domain rows (regardless of current status) against the + * current server IP and recompute the server-IP warning flag. + * Used after the admin saves a corrected server-IP override so the warning + * is refreshed immediately without a process restart. + * Non-alarming: non-verified rows return to status='pending', never 'failed'. + * Returns { verified, flagged }. + */ +async function reverifyAllAndReflag({ verifyEach = domains.verify } = {}) { + const all = getDb().prepare('SELECT domain FROM domains').all().map(r => r.domain); + return verifyAndReflag(all, { verifyEach }); +} + +module.exports = { runDomainSeedAndVerify, reverifyAllAndReflag, verifyAndReflag }; 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/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/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 ────────────────────── #}