Files
dashcaddy/status/js/core/credentials.js
Sami f2f33b4b40 Make DNS servers fully dynamic from config.json
DNS server IDs (dns1, dns2, dns3) were hardcoded throughout the frontend
and backend. Now config.json's dnsServers object is the single source of
truth — adding or removing a DNS server in config automatically updates
the dashboard cards, credential modal, health checks, and probes.

- credentials.js: rebuild modal sections dynamically from SITE.dnsServers
- globals.js: add getPrimaryDnsId() helper for primary DNS lookups
- service-create.js, service-infrastructure.js: use dynamic DNS ID
- startup-validator.js: dynamic topCardServices from config
- middleware.js: add license endpoints to public routes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 16:55:07 -07:00

399 lines
15 KiB
JavaScript

// ========== CREDENTIAL MANAGEMENT ==========
(function () {
// Inject skeleton modal — credential sections are built dynamically when opened
injectModal('token-management-modal', `
<div id="token-management-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 500px; max-width: 600px;">
<h3>\u{1F511} DNS Credentials</h3>
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 16px;">
Enter Technitium DNS login credentials. Read-only accounts are used for logs; admin accounts for restarts, records, and updates.
</p>
<div id="dns-cred-sections"></div>
<div class="weather-modal-buttons modal-footer-bar">
<button id="token-clear-all" style="margin-right: auto; background: color-mix(in srgb, var(--bad-fg) 15%, transparent); border-color: var(--bad-fg); color: var(--bad-fg);">Clear All</button>
<button id="token-cancel">Cancel</button>
<button id="token-save" class="btn-accent">Save</button>
</div>
</div>
</div>
`);
// ── Helpers ──
function getDnsIds() {
return Object.keys(SITE.dnsServers || {});
}
function getDnsDisplayName(dnsId) {
const server = (SITE.dnsServers || {})[dnsId];
return server?.name || dnsId.toUpperCase();
}
/** Build per-server credential form sections from SITE.dnsServers */
function buildCredentialSections() {
const container = document.getElementById('dns-cred-sections');
if (!container) return;
container.innerHTML = '';
const dnsIds = getDnsIds();
if (dnsIds.length === 0) {
container.innerHTML = '<p style="color: var(--muted); text-align: center; padding: 20px;">No DNS servers configured.</p>';
return;
}
for (const id of dnsIds) {
container.insertAdjacentHTML('beforeend', `
<div class="token-section">
<h4 class="token-section-title">${getDnsDisplayName(id)}</h4>
<div class="token-grid">
<div class="token-field">
<label for="${id}-readonly-username">\u{1F4D6} Read-Only (Logs):</label>
<input type="text" id="${id}-readonly-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
<div class="token-input-row">
<input type="password" id="${id}-readonly-token" placeholder="Password" autocomplete="off" />
<button type="button" class="token-toggle" data-target="${id}-readonly-token">\u{1F441}</button>
</div>
</div>
<div class="token-field">
<label for="${id}-admin-username">\u{1F527} Admin:</label>
<input type="text" id="${id}-admin-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
<div class="token-input-row">
<input type="password" id="${id}-admin-token" placeholder="Password" autocomplete="off" />
<button type="button" class="token-toggle" data-target="${id}-admin-token">\u{1F441}</button>
</div>
</div>
</div>
<div class="token-status" id="${id}-token-status"></div>
</div>
`);
}
}
// ── Credential encryption ──
function getEncryptionKey() {
let key = safeSessionGet('dashcaddy-encryption-key');
if (key) return key;
const oldKey = safeGet('dashcaddy-encryption-key');
if (oldKey) {
safeSessionSet('dashcaddy-encryption-key', oldKey);
safeRemove('dashcaddy-encryption-key');
return oldKey;
}
const array = new Uint8Array(32);
crypto.getRandomValues(array);
key = Array.from(array, b => b.toString(16).padStart(2, '0')).join('');
safeSessionSet('dashcaddy-encryption-key', key);
return key;
}
const ENCRYPTION_KEY = getEncryptionKey();
function credentialEncrypt(text, key) {
if (!text) return '';
const iv = crypto.getRandomValues(new Uint8Array(8));
const ivHex = Array.from(iv, b => b.toString(16).padStart(2, '0')).join('');
const keyBytes = new TextEncoder().encode(key + ivHex);
let result = '';
for (let i = 0; i < text.length; i++) {
const charCode = text.charCodeAt(i)
^ keyBytes[i % keyBytes.length]
^ iv[i % iv.length]
^ ((i * 31 + 17) & 0xFF);
result += String.fromCharCode(charCode);
}
return ivHex + ':' + btoa(result);
}
function credentialDecrypt(encryptedText, key) {
if (!encryptedText) return '';
try {
const colonIdx = encryptedText.indexOf(':');
if (colonIdx === 16) {
const ivHex = encryptedText.substring(0, 16);
const iv = new Uint8Array(ivHex.match(/.{2}/g).map(h => parseInt(h, 16)));
const decoded = atob(encryptedText.substring(17));
const keyBytes = new TextEncoder().encode(key + ivHex);
let result = '';
for (let i = 0; i < decoded.length; i++) {
const charCode = decoded.charCodeAt(i)
^ keyBytes[i % keyBytes.length]
^ iv[i % iv.length]
^ ((i * 31 + 17) & 0xFF);
result += String.fromCharCode(charCode);
}
return result;
}
const decoded = atob(encryptedText);
let result = '';
for (let i = 0; i < decoded.length; i++) {
const charCode = decoded.charCodeAt(i) ^ key.charCodeAt(i % key.length);
result += String.fromCharCode(charCode);
}
return result;
} catch (e) {
return '';
}
}
// ── Credential storage ──
function getCredential(dnsId, tokenType, credType) {
const encrypted = safeGet(`${dnsId}-${tokenType}-${credType}-enc`);
return credentialDecrypt(encrypted, ENCRYPTION_KEY);
}
function setCredential(dnsId, tokenType, credType, value) {
const key = `${dnsId}-${tokenType}-${credType}-enc`;
if (value) {
safeSet(key, credentialEncrypt(value, ENCRYPTION_KEY));
} else {
safeRemove(key);
}
}
function getToken(dnsId, tokenType) {
return getCredential(dnsId, tokenType, 'token');
}
function getUsername(dnsId, tokenType) {
return getCredential(dnsId, tokenType, 'username');
}
function setToken(dnsId, tokenType, token) {
setCredential(dnsId, tokenType, 'token', token);
}
function setUsername(dnsId, tokenType, username) {
setCredential(dnsId, tokenType, 'username', username);
}
function getAllCredentials() {
const result = {};
for (const dnsId of getDnsIds()) {
result[dnsId] = {
readonly: { username: getUsername(dnsId, 'readonly'), token: getToken(dnsId, 'readonly') },
admin: { username: getUsername(dnsId, 'admin'), token: getToken(dnsId, 'admin') }
};
}
return result;
}
function clearAllCredentials() {
getDnsIds().forEach(dnsId => {
['readonly', 'admin'].forEach(tokenType => {
['token', 'username'].forEach(credType => {
safeRemove(`${dnsId}-${tokenType}-${credType}-enc`);
});
});
safeRemove(`${dnsId}-token-enc`);
safeRemove(`${dnsId}-username-enc`);
});
}
function getStoredCredentials(dnsId) {
const readonlyToken = getToken(dnsId, 'readonly');
const readonlyUsername = getUsername(dnsId, 'readonly');
const adminToken = getToken(dnsId, 'admin');
const adminUsername = getUsername(dnsId, 'admin');
const oldToken = credentialDecrypt(safeGet(`${dnsId}-token-enc`), ENCRYPTION_KEY);
const oldUsername = credentialDecrypt(safeGet(`${dnsId}-username-enc`), ENCRYPTION_KEY);
return {
username: adminUsername || readonlyUsername || oldUsername,
token: adminToken || readonlyToken || oldToken,
readonlyToken: readonlyToken || oldToken,
readonlyUsername: readonlyUsername || oldUsername,
adminToken: adminToken || oldToken,
adminUsername: adminUsername || oldUsername
};
}
// ── Modal handlers ──
// Open modal — build sections dynamically, populate from stored creds
document.getElementById('manage-tokens')?.addEventListener('click', () => {
buildCredentialSections();
const modal = document.getElementById('token-management-modal');
const creds = getAllCredentials();
getDnsIds().forEach(dnsId => {
const c = creds[dnsId];
document.getElementById(`${dnsId}-readonly-username`).value = c.readonly.username;
document.getElementById(`${dnsId}-readonly-token`).value = c.readonly.token;
document.getElementById(`${dnsId}-admin-username`).value = c.admin.username;
document.getElementById(`${dnsId}-admin-token`).value = c.admin.token;
document.getElementById(`${dnsId}-token-status`).textContent = '';
});
modal.classList.add('show');
});
// Toggle password visibility + backdrop close (event delegation for dynamic elements)
document.getElementById('token-management-modal')?.addEventListener('click', (e) => {
const btn = e.target.closest('.token-toggle');
if (btn) {
const targetId = btn.dataset.target;
const input = document.getElementById(targetId);
if (input.type === 'password') {
input.type = 'text';
btn.textContent = '\u{1F648}';
} else {
input.type = 'password';
btn.textContent = '\u{1F441}';
}
return;
}
if (e.target.id === 'token-management-modal') {
e.target.classList.remove('show');
}
});
// Save credentials
document.getElementById('token-save')?.addEventListener('click', async () => {
const dnsIds = getDnsIds();
// Save all to localStorage
dnsIds.forEach(dnsId => {
setUsername(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-username`).value.trim());
setToken(dnsId, 'readonly', document.getElementById(`${dnsId}-readonly-token`).value.trim());
setUsername(dnsId, 'admin', document.getElementById(`${dnsId}-admin-username`).value.trim());
setToken(dnsId, 'admin', document.getElementById(`${dnsId}-admin-token`).value.trim());
});
// Build per-server credentials payload for backend sync
const servers = {};
let hasAnyCreds = false;
dnsIds.forEach(dnsId => {
const entry = {};
const roUser = document.getElementById(`${dnsId}-readonly-username`).value.trim();
const roPass = document.getElementById(`${dnsId}-readonly-token`).value.trim();
const adminUser = document.getElementById(`${dnsId}-admin-username`).value.trim();
const adminPass = document.getElementById(`${dnsId}-admin-token`).value.trim();
if (roUser && roPass) {
entry.readonly = { username: roUser, password: roPass };
hasAnyCreds = true;
}
if (adminUser && adminPass) {
entry.admin = { username: adminUser, password: adminPass };
hasAnyCreds = true;
}
if (Object.keys(entry).length > 0) {
servers[dnsId] = entry;
}
});
if (hasAnyCreds) {
dnsIds.forEach(dnsId => {
if (servers[dnsId]) {
document.getElementById(`${dnsId}-token-status`).textContent = 'Verifying...';
document.getElementById(`${dnsId}-token-status`).className = 'token-status';
}
});
try {
const res = await secureFetch('/api/v1/dns/credentials', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ servers })
});
const data = await res.json();
if (data.results) {
dnsIds.forEach(dnsId => {
const statusEl = document.getElementById(`${dnsId}-token-status`);
if (!servers[dnsId]) { statusEl.textContent = ''; return; }
const result = data.results[dnsId];
if (result?.success) {
statusEl.textContent = '\u2713 Verified & saved';
statusEl.className = 'token-status success';
} else if (result?.partial) {
statusEl.textContent = '\u2713 ' + result.partial;
statusEl.className = 'token-status success';
} else {
statusEl.textContent = '\u2717 ' + (result?.error || 'Login failed');
statusEl.className = 'token-status error';
}
});
} else if (data.success) {
dnsIds.forEach(dnsId => {
if (servers[dnsId]) {
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Saved';
document.getElementById(`${dnsId}-token-status`).className = 'token-status success';
}
});
} else {
dnsIds.forEach(dnsId => {
if (servers[dnsId]) {
document.getElementById(`${dnsId}-token-status`).textContent = '\u2717 ' + (data.error || 'Failed');
document.getElementById(`${dnsId}-token-status`).className = 'token-status error';
}
});
}
} catch (e) {
console.error('Failed to sync DNS credentials to backend:', e);
dnsIds.forEach(dnsId => {
if (servers[dnsId]) {
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Saved locally (sync failed)';
document.getElementById(`${dnsId}-token-status`).className = 'token-status';
}
});
}
} else {
dnsIds.forEach(dnsId => {
document.getElementById(`${dnsId}-token-status`).textContent = '';
});
}
// Auto-close after delay if all succeeded
setTimeout(() => {
const allGood = dnsIds.every(dnsId => {
const status = document.getElementById(`${dnsId}-token-status`)?.textContent;
return !status || status.includes('\u2713');
});
if (allGood) closeModal('token-management-modal');
}, 1500);
});
document.getElementById('token-cancel')?.addEventListener('click', () => {
closeModal('token-management-modal');
});
document.getElementById('token-clear-all')?.addEventListener('click', async () => {
if (confirm('Clear all stored DNS credentials? This cannot be undone.')) {
clearAllCredentials();
getDnsIds().forEach(dnsId => {
document.getElementById(`${dnsId}-readonly-username`).value = '';
document.getElementById(`${dnsId}-readonly-token`).value = '';
document.getElementById(`${dnsId}-admin-username`).value = '';
document.getElementById(`${dnsId}-admin-token`).value = '';
document.getElementById(`${dnsId}-token-status`).textContent = '\u2713 Cleared';
document.getElementById(`${dnsId}-token-status`).className = 'token-status success';
});
try {
await secureFetch('/api/v1/dns/credentials', { method: 'DELETE' });
} catch (_) {}
}
});
// ── Window exports ──
window.getToken = getToken;
window.getUsername = getUsername;
window.setToken = setToken;
window.setUsername = setUsername;
window.getAllCredentials = getAllCredentials;
window.getCredential = getCredential;
window.setCredential = setCredential;
window.getEncryptionKey = getEncryptionKey;
window.getDnsIds = getDnsIds;
window.getDnsDisplayName = getDnsDisplayName;
})();