Lagt till diagnostic och mer inställningar

This commit is contained in:
2026-06-02 13:25:40 +02:00
parent 229a3c7383
commit 8491fe1386
17 changed files with 498 additions and 17 deletions
+26
View File
@@ -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
}
]
+3 -1
View File
@@ -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}`,
+4 -2
View File
@@ -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';
+12 -2
View File
@@ -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();
});
+6 -2
View File
@@ -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;
+2 -1
View File
@@ -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',
+92
View File
@@ -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 };
+36
View File
@@ -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;
+43 -7
View File
@@ -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}`));
+8
View File
@@ -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`,
+4
View File
@@ -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 });
+10
View File
@@ -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);