diff --git a/backend/diag-log.json b/backend/diag-log.json new file mode 100644 index 0000000..3ecee59 --- /dev/null +++ b/backend/diag-log.json @@ -0,0 +1,26 @@ +[ + { + "id": "1780397326074-s7tw", + "timestamp": "2026-06-02T10:48:46.074Z", + "provider": "cpanel", + "operation": "GET /execute/DomainInfo/list_domains", + "method": "GET", + "url": "https://cpsrv32.misshosting.com/execute/DomainInfo/list_domains", + "status": 200, + "latency": 17, + "ok": true, + "error": null + }, + { + "id": "1780397326055-i077", + "timestamp": "2026-06-02T10:48:46.055Z", + "provider": "cpanel", + "operation": "GET /execute/DNS/list_zones", + "method": "GET", + "url": "https://cpsrv32.misshosting.com/execute/DNS/list_zones", + "status": 200, + "latency": 124, + "ok": true, + "error": null + } +] \ No newline at end of file diff --git a/backend/src/adapters/azure.js b/backend/src/adapters/azure.js index cd365e5..0c5f734 100644 --- a/backend/src/adapters/azure.js +++ b/backend/src/adapters/azure.js @@ -16,6 +16,7 @@ const ARM_BASE = 'https://management.azure.com'; const API_VERSION = '2018-05-01'; +const diagFetch = require('../diagFetch'); // ─── Auth ──────────────────────────────────────────────────────────────────── @@ -52,7 +53,8 @@ async function armFetch(path, options = {}) { const token = await getToken(); const url = `${ARM_BASE}${path}${path.includes('?') ? '&' : '?'}api-version=${API_VERSION}`; - const res = await fetch(url, { + const operation = `${options.method ?? 'GET'} ${path.split('?')[0].replace(/\/subscriptions\/[^/]+/, '/subscriptions/***')}`; + const res = await diagFetch('azure', operation, url, { ...options, headers: { Authorization: `Bearer ${token}`, diff --git a/backend/src/adapters/cloudflare.js b/backend/src/adapters/cloudflare.js index 090e166..529d176 100644 --- a/backend/src/adapters/cloudflare.js +++ b/backend/src/adapters/cloudflare.js @@ -3,7 +3,8 @@ * Requires env: CLOUDFLARE_API_TOKEN */ -const BASE = 'https://api.cloudflare.com/client/v4'; +const BASE = 'https://api.cloudflare.com/client/v4'; +const diagFetch = require('../diagFetch'); function headers() { return { @@ -13,7 +14,8 @@ function headers() { } async function cfFetch(path, options = {}) { - const res = await fetch(`${BASE}${path}`, { ...options, headers: headers() }); + const operation = `${options.method ?? 'GET'} ${path.split('?')[0]}`; + const res = await diagFetch('cloudflare', operation, `${BASE}${path}`, { ...options, headers: headers() }); const data = await res.json(); if (!data.success) { const msg = data.errors?.map(e => e.message).join(', ') || 'Cloudflare API error'; diff --git a/backend/src/adapters/cpanel.js b/backend/src/adapters/cpanel.js index e8c2158..7a27743 100644 --- a/backend/src/adapters/cpanel.js +++ b/backend/src/adapters/cpanel.js @@ -12,6 +12,7 @@ const https = require('https'); const http = require('http'); +const diag = require('../diag'); function base() { return process.env.CPANEL_URL.replace(/\/$/, ''); @@ -26,6 +27,8 @@ function authHeader() { * Avoids undici/fetch version conflicts and supports self-signed certs. */ function request(url, options = {}) { + const start = Date.now(); + const method = options.method || 'GET'; return new Promise((resolve, reject) => { const parsed = new URL(url); const insecure = process.env.CPANEL_INSECURE === 'true'; @@ -44,10 +47,17 @@ function request(url, options = {}) { let body = ''; res.setEncoding('utf8'); res.on('data', chunk => { body += chunk; }); - res.on('end', () => resolve({ status: res.statusCode, text: () => body })); + res.on('end', () => { + const latency = Date.now() - start; + diag.logRequest('cpanel', `${method} ${parsed.pathname}`, method, url, res.statusCode, latency); + resolve({ status: res.statusCode, text: () => body }); + }); }); - req.on('error', reject); + req.on('error', err => { + diag.logRequest('cpanel', `${method} ${parsed.pathname}`, method, url, null, Date.now() - start, err.message); + reject(err); + }); if (options.body) req.write(options.body); req.end(); }); diff --git a/backend/src/adapters/loopia.js b/backend/src/adapters/loopia.js index 4bc9718..187dd22 100644 --- a/backend/src/adapters/loopia.js +++ b/backend/src/adapters/loopia.js @@ -9,6 +9,7 @@ const { parseStringPromise } = require('xml2js'); const ENDPOINT = 'https://api.loopia.se/RPCSERV'; +const diag = require('../diag'); // ─── XML-RPC builder ──────────────────────────────────────────────────────── @@ -86,8 +87,7 @@ function parseValue(node) { async function xmlRpc(method, args) { const body = buildRequest(method, args); - console.log(`[loopia] → ${method}`); - + const start = Date.now(); const res = await fetch(ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'text/xml; charset=utf-8' }, @@ -95,20 +95,24 @@ async function xmlRpc(method, args) { }); const text = await res.text(); + const latency = Date.now() - start; let parsed; try { parsed = await parseStringPromise(text, { explicitArray: true }); } catch (e) { + diag.logRequest('loopia', method, 'POST', ENDPOINT, res.status, latency, 'Invalid XML response'); throw new Error(`Loopia returned invalid XML: ${text.slice(0, 300)}`); } const fault = parsed?.methodResponse?.fault; if (fault) { const fv = parseValue(fault[0].value[0]); + diag.logRequest('loopia', method, 'POST', ENDPOINT, res.status, latency, `Fault ${fv.faultCode}: ${fv.faultString}`); throw new Error(`Loopia fault ${fv.faultCode}: ${fv.faultString}`); } + diag.logRequest('loopia', method, 'POST', ENDPOINT, res.status, latency); const param = parsed?.methodResponse?.params?.[0]?.param?.[0]?.value?.[0]; const result = param ? parseValue(param) : null; diff --git a/backend/src/adapters/pihole.js b/backend/src/adapters/pihole.js index 310aa29..084b39f 100644 --- a/backend/src/adapters/pihole.js +++ b/backend/src/adapters/pihole.js @@ -12,6 +12,7 @@ * treated as a single zone. Record types are limited to A, AAAA, CNAME. */ +const diagFetch = require('../diagFetch'); let cachedSid = null; let sidExpiry = 0; @@ -46,7 +47,7 @@ async function getSession() { async function phFetch(path, options = {}) { const sid = await getSession(); - const res = await fetch(`${base()}${path}`, { + const res = await diagFetch('pihole', `${options.method ?? 'GET'} ${path}`, `${base()}${path}`, { ...options, headers: { 'Content-Type': 'application/json', diff --git a/backend/src/diag.js b/backend/src/diag.js new file mode 100644 index 0000000..c384e6e --- /dev/null +++ b/backend/src/diag.js @@ -0,0 +1,92 @@ +/** + * Provider diagnostic log — records every API call made to DNS providers. + * Keeps the last 200 entries in memory and on disk. + */ + +const fs = require('fs'); +const path = require('path'); + +const DIAG_PATH = process.env.DIAG_PATH || path.join(__dirname, '..', 'diag-log.json'); +const MAX_ENTRIES = 200; + +// In-memory buffer so high-frequency reads don't hammer the disk +let buffer = null; + +function load() { + if (buffer) return buffer; + try { buffer = JSON.parse(fs.readFileSync(DIAG_PATH, 'utf8')); } + catch { buffer = []; } + return buffer; +} + +function save() { + try { fs.writeFileSync(DIAG_PATH, JSON.stringify(buffer, null, 2), 'utf8'); } + catch { /* non-fatal */ } +} + +/** + * Sanitise a URL — strip tokens, passwords, and API keys from query strings + * and path segments so credentials are never stored in the log. + */ +function sanitiseUrl(url) { + if (!url) return '—'; + try { + const u = new URL(url); + // Remove common credential query params + ['token', 'api_token', 'key', 'password', 'secret', 'auth'].forEach(p => { + if (u.searchParams.has(p)) u.searchParams.set(p, '***'); + }); + // Mask Azure subscription IDs and resource group names in paths + let p = u.pathname.replace( + /\/subscriptions\/[a-f0-9-]{36}/gi, + '/subscriptions/***' + ); + return `${u.protocol}//${u.hostname}${p}${u.search}`; + } catch { + return url.replace(/token=[^&]+/gi, 'token=***'); + } +} + +/** + * Record an API call. + * + * @param {string} provider - 'cloudflare' | 'loopia' | 'pihole' | 'azure' | 'cpanel' + * @param {string} operation - human label, e.g. 'listZones', 'addRecord' + * @param {string} method - HTTP method + * @param {string} url - full URL (will be sanitised before storage) + * @param {number|null} status - HTTP status code, or null on network error + * @param {number} latency - milliseconds + * @param {string|null} error - error message if the call failed + */ +function logRequest(provider, operation, method, url, status, latency, error = null) { + load(); + buffer.unshift({ + id: `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + timestamp: new Date().toISOString(), + provider, + operation, + method: method.toUpperCase(), + url: sanitiseUrl(url), + status, + latency, + ok: !error && status >= 200 && status < 300, + error, + }); + if (buffer.length > MAX_ENTRIES) buffer.splice(MAX_ENTRIES); + save(); +} + +function getEntries({ provider, ok, limit = 100, offset = 0 } = {}) { + const entries = load(); + let filtered = entries; + if (provider) filtered = filtered.filter(e => e.provider === provider); + if (ok !== undefined) filtered = filtered.filter(e => e.ok === ok); + return { total: filtered.length, entries: filtered.slice(offset, offset + limit) }; +} + +function clearLog() { + buffer = []; + save(); +} + +module.exports = { logRequest, getEntries, clearLog }; diff --git a/backend/src/diagFetch.js b/backend/src/diagFetch.js new file mode 100644 index 0000000..3b2b9fc --- /dev/null +++ b/backend/src/diagFetch.js @@ -0,0 +1,36 @@ +/** + * Thin fetch wrapper that logs every request to the diagnostic log. + * Use this instead of plain fetch() inside adapters. + */ +const diag = require('./diag'); + +async function diagFetch(provider, operation, url, options = {}) { + const method = options.method ?? 'GET'; + const start = Date.now(); + let status = null; + let error = null; + + try { + const res = await fetch(url, options); + status = res.status; + const latency = Date.now() - start; + + if (!res.ok) { + const body = await res.text().catch(() => ''); + error = `HTTP ${res.status}: ${body.slice(0, 200)}`; + diag.logRequest(provider, operation, method, url, status, latency, error); + // Re-create a Response-like object since we consumed the body + return new Response(body, { status: res.status, headers: res.headers }); + } + + diag.logRequest(provider, operation, method, url, status, latency); + return res; + } catch (err) { + const latency = Date.now() - start; + error = err.message; + diag.logRequest(provider, operation, method, url, null, latency, error); + throw err; + } +} + +module.exports = diagFetch; diff --git a/backend/src/index.js b/backend/src/index.js index 3760be4..c632092 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -4,7 +4,8 @@ const cors = require('cors'); const { requireAuth } = require('./auth'); const { ensureDefaultAdmin } = require('./users'); -const audit = require('./audit'); +const audit = require('./audit'); +const settings = require('./settings'); const secrets = require('./secrets'); const schedule = require('node-schedule'); @@ -12,6 +13,7 @@ const zonesRouter = require('./routes/zones'); const secretsRouter = require('./routes/secrets'); const ipamRouter = require('./routes/ipam'); const healthRouter = require('./routes/health'); +const diag = require('./diag'); const recordsRouter = require('./routes/records'); const settingsRouter = require('./routes/settings'); const authRouter = require('./routes/auth'); @@ -36,7 +38,22 @@ app.use('/api/settings', requireAuth, settingsRouter); app.use('/api/users', usersRouter); app.use('/api/secrets', secretsRouter); app.use('/api/ipam', ipamRouter); -app.use('/api/health', healthRouter); // requireAuth applied inside router +app.use('/api/health', healthRouter); + +app.get('/api/diag', requireAuth, (req, res) => { + const { provider, ok, limit, offset } = req.query; + res.json(diag.getEntries({ + provider, + ok: ok !== undefined ? ok === 'true' : undefined, + limit: Math.min(parseInt(limit) || 100, 200), + offset: parseInt(offset) || 0, + })); +}); + +app.delete('/api/diag', requireAuth, (req, res) => { + diag.clearLog(); + res.json({ success: true }); +}); // requireAuth applied inside router app.get('/api/audit', requireAuth, (req, res) => { const { limit, offset, user, action, provider, category } = req.query; @@ -107,12 +124,31 @@ async function checkSecretExpiryOnce() { await checkSecretExpiry(); } -// Run at startup only if not already checked today (persisted to disk), then every day at 08:00 +// Build cron string from HH:MM setting +function getSecretCron() { + const time = settings.get().notifications?.secret_check_time ?? '08:00'; + const [h, m] = time.split(':').map(Number); + return `${m ?? 0} ${h ?? 8} * * *`; +} + +let secretJob = null; + +function scheduleSecretCheck() { + if (secretJob) secretJob.cancel(); + const notif = settings.get().notifications ?? {}; + const tz = notif.timezone || 'UTC'; + secretJob = schedule.scheduleJob({ rule: getSecretCron(), tz }, () => { + saveLastCheckDate(''); + const { notifications } = settings.get(); + if (notifications?.secret_check !== false) checkSecretExpiry(); + }); + console.log(`Secret check scheduled at ${notif.secret_check_time ?? '08:00'} (${tz})`); +} + +// Run at startup only if not already checked today, then on schedule checkSecretExpiryOnce(); -schedule.scheduleJob('0 8 * * *', () => { - saveLastCheckDate(''); // clear so the scheduled job always fires - checkSecretExpiry(); -}); +scheduleSecretCheck(); +module.exports = { scheduleSecretCheck }; const PORT = process.env.PORT || 3001; app.listen(PORT, () => console.log(`Sloth Manager backend running on port ${PORT}`)); diff --git a/backend/src/notify.js b/backend/src/notify.js index 3213907..d57b1ec 100644 --- a/backend/src/notify.js +++ b/backend/src/notify.js @@ -34,7 +34,13 @@ async function notify(title, message) { // ─── Convenience helpers ───────────────────────────────────────────────────── +function eventEnabled(key) { + const { notifications } = settings.get(); + return notifications?.[key] !== false; +} + function recordAdded(provider, zoneId, record) { + if (!eventEnabled('dns_add')) return; const zoneName = db.getZoneName(provider, zoneId); return notify( `DNS Record Added`, @@ -43,6 +49,7 @@ function recordAdded(provider, zoneId, record) { } function recordUpdated(provider, zoneId, oldRecord, newRecord) { + if (!eventEnabled('dns_update')) return; const zoneName = db.getZoneName(provider, zoneId); return notify( `DNS Record Updated`, @@ -51,6 +58,7 @@ function recordUpdated(provider, zoneId, oldRecord, newRecord) { } function recordDeleted(provider, zoneId, record) { + if (!eventEnabled('dns_delete')) return; const zoneName = db.getZoneName(provider, zoneId); return notify( `DNS Record Deleted`, diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js index a8cb22e..8980d55 100644 --- a/backend/src/routes/settings.js +++ b/backend/src/routes/settings.js @@ -12,6 +12,10 @@ router.get('/', (req, res) => { router.put('/', (req, res) => { try { const updated = settings.update(req.body); + // Reschedule secret check if the notification time changed + if (req.body.notifications?.secret_check_time) { + try { require('../index').scheduleSecretCheck(); } catch { /* safe to ignore */ } + } res.json(updated); } catch (err) { res.status(500).json({ error: err.message }); diff --git a/backend/src/settings.js b/backend/src/settings.js index 302d3fc..b6c45f9 100644 --- a/backend/src/settings.js +++ b/backend/src/settings.js @@ -10,6 +10,14 @@ const DEFAULTS = { token: '', priority: 5, }, + notifications: { + dns_add: true, + dns_update: true, + dns_delete: true, + secret_check: true, + secret_check_time: '08:00', + timezone: 'UTC', + }, providerColors: { cloudflare: '#f6821f', loopia: '#2ecc71', @@ -27,6 +35,7 @@ function load() { ...DEFAULTS, ...raw, gotify: { ...DEFAULTS.gotify, ...(raw.gotify ?? {}) }, + notifications: { ...DEFAULTS.notifications, ...(raw.notifications ?? {}) }, providerColors: { ...DEFAULTS.providerColors, ...(raw.providerColors ?? {}) }, }; } catch { @@ -48,6 +57,7 @@ function update(partial) { ...current, ...partial, gotify: { ...current.gotify, ...(partial.gotify ?? {}) }, + notifications: { ...current.notifications, ...(partial.notifications ?? {}) }, providerColors: { ...current.providerColors, ...(partial.providerColors ?? {}) }, }; save(merged); diff --git a/frontend/src/App.css b/frontend/src/App.css index e4684f6..28a4699 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -260,6 +260,10 @@ body { color: #fb923c; } +/* ===== Diagnostics ===== */ +.diag-row-error td { background: rgba(239,68,68,0.04); } +.diag-row-error:hover td { background: rgba(239,68,68,0.08) !important; } + /* ===== Provider health status ===== */ .health-badge { display: inline-block; diff --git a/frontend/src/App.js b/frontend/src/App.js index d3a37a9..cbf1144 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -10,6 +10,7 @@ import ProfilePage from './components/ProfilePage'; import AuditPage from './components/AuditPage'; import SecretsPage from './components/SecretsPage'; import IpamPage from './components/IpamPage'; +import DiagnosticsPage from './components/DiagnosticsPage'; import DomainsPage from './components/DomainsPage'; import { useProviderColors, providerBadgeStyle } from './context/ProviderColors'; import { useTheme } from './context/Theme'; @@ -223,6 +224,12 @@ function AppShell({ currentUser, onLogout }) { > 📋 Audit Log + + + + {error &&
Error: {error}
} + +
+ + + + + {total} entries{errorCount > 0 && · {errorCount} errors on this page} +
+ + {loading ?

Loading…

: entries.length === 0 ? ( +
+

No diagnostic entries yet — make a DNS operation (sync, add, delete) to populate the log.

+
+ ) : ( + <> +
+ + + + + + + + + + + + + + {entries.map(e => ( + + + + + + + + + + ))} + +
TimeProviderOperationStatusLatencyURLError
{formatDate(e.timestamp)}{e.provider}{e.operation} + + {e.status ?? 'ERR'} + + 2000 ? '#f59e0b' : 'var(--text-muted)' }}> + {e.latency}ms + + {e.url} + + {e.error ?? ''} +
+
+ + {totalPages > 1 && ( +
+ + Page {page + 1} of {totalPages} + +
+ )} + + )} + + {confirmClear && ( + setConfirmClear(false)} + /> + )} + + ); +} diff --git a/frontend/src/components/SettingsPage.js b/frontend/src/components/SettingsPage.js index abf2085..e01013d 100644 --- a/frontend/src/components/SettingsPage.js +++ b/frontend/src/components/SettingsPage.js @@ -16,6 +16,7 @@ const TABS = [ function NotificationsTab() { const [form, setForm] = useState({ enabled: false, url: '', token: '', priority: 5 }); + const [events, setEvents] = useState({ dns_add: true, dns_update: true, dns_delete: true, secret_check: true, secret_check_time: '08:00', timezone: 'UTC' }); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(false); @@ -25,7 +26,10 @@ function NotificationsTab() { useEffect(() => { getSettings() - .then(s => setForm({ ...s.gotify })) + .then(s => { + setForm({ ...s.gotify }); + setEvents({ ...{ dns_add: true, dns_update: true, dns_delete: true, secret_check: true, secret_check_time: '08:00', timezone: 'UTC' }, ...s.notifications }); + }) .catch(e => setError(e.message)) .finally(() => setLoading(false)); }, []); @@ -37,11 +41,17 @@ function NotificationsTab() { setTestResult(null); } + function handleEventChange(e) { + const { name, value, type, checked } = e.target; + setEvents(ev => ({ ...ev, [name]: type === 'checkbox' ? checked : value })); + setSaved(false); + } + async function handleSave(e) { e.preventDefault(); setSaving(true); setError(''); setSaved(false); try { - await saveSettings({ gotify: { ...form, priority: Number(form.priority) } }); + await saveSettings({ gotify: { ...form, priority: Number(form.priority) }, notifications: events }); setSaved(true); setTimeout(() => setSaved(false), 3000); } catch (err) { setError(err.message); } @@ -82,6 +92,64 @@ function NotificationsTab() { +
+

Notify on

+ {[ + { key: 'dns_add', label: 'DNS record added' }, + { key: 'dns_update', label: 'DNS record updated' }, + { key: 'dns_delete', label: 'DNS record deleted' }, + { key: 'secret_check', label: 'Secret expiry reminder' }, + ].map(({ key, label }) => ( + + ))} +
+ + +
+
+ {testResult && (

{testResult.ok ? '✓' : '✕'} {testResult.message}