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
183 changes: 183 additions & 0 deletions public/js/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
})();
15 changes: 15 additions & 0 deletions src/db/migrationList.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
20 changes: 19 additions & 1 deletion src/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
20 changes: 19 additions & 1 deletion src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
89 changes: 89 additions & 0 deletions src/routes/api/settings/domains.js
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 2 additions & 0 deletions src/routes/api/settings/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const TOKEN_FORBIDDEN = [
/^\/password$/,
/^\/profile$/,
/^\/route-block-default$/,
/^\/domains(\/|$)/,
];
router.use((req, res, next) => {
if (!req.tokenAuth) return next();
Expand All @@ -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;
14 changes: 14 additions & 0 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading