initial commit

This commit is contained in:
2026-06-02 01:00:27 +02:00
commit d2a8072a47
64 changed files with 26467 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
const express = require('express');
const router = express.Router();
const users = require('../users');
const audit = require('../audit');
const { signToken, requireAuth } = require('../auth');
// POST /api/auth/login
router.post('/login', (req, res) => {
const { username, password } = req.body;
if (!username || !password) return res.status(400).json({ error: 'Username and password are required' });
const user = users.findByUsername(username);
if (!user || !users.verifyPassword(user, password)) {
return res.status(401).json({ error: 'Invalid username or password' });
}
const token = signToken(user);
res.json({ token, user: { id: user.id, username: user.username } });
});
// GET /api/auth/me — validate current token and return user info
router.get('/me', requireAuth, (req, res) => {
const user = users.findById(req.user.id);
if (!user) return res.status(401).json({ error: 'User not found' });
res.json({ id: user.id, username: user.username });
});
// POST /api/auth/change-password — change own password
router.post('/change-password', requireAuth, (req, res) => {
const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword) return res.status(400).json({ error: 'Both current and new password are required' });
if (newPassword.length < 6) return res.status(400).json({ error: 'New password must be at least 6 characters' });
const user = users.findById(req.user.id);
if (!users.verifyPassword(user, currentPassword)) {
return res.status(401).json({ error: 'Current password is incorrect' });
}
users.updatePassword(req.user.id, newPassword);
audit.logUser(req.user, 'update', { id: req.user.id, username: req.user.username });
res.json({ success: true });
});
module.exports = router;
+147
View File
@@ -0,0 +1,147 @@
const express = require('express');
const router = express.Router();
const { requireAuth } = require('../auth');
router.use(requireAuth);
async function checkCloudflare() {
const start = Date.now();
const res = await fetch('https://api.cloudflare.com/client/v4/zones?per_page=1', {
headers: { Authorization: `Bearer ${process.env.CLOUDFLARE_API_TOKEN}` },
});
const data = await res.json();
if (!data.success) throw new Error(data.errors?.[0]?.message ?? 'Auth failed');
return Date.now() - start;
}
async function checkLoopia() {
const start = Date.now();
const body = `<?xml version="1.0" encoding="UTF-8"?><methodCall><methodName>getDomains</methodName><params><param><value><string>${process.env.LOOPIA_USER}</string></value></param><param><value><string>${process.env.LOOPIA_PASSWORD}</string></value></param></params></methodCall>`;
const res = await fetch('https://api.loopia.se/RPCSERV', {
method: 'POST',
headers: { 'Content-Type': 'text/xml; charset=utf-8' },
body,
});
const text = await res.text();
if (text.includes('AUTH_ERROR')) throw new Error('Authentication failed');
if (text.includes('faultCode')) throw new Error('API returned a fault');
return Date.now() - start;
}
async function checkPihole() {
const start = Date.now();
const base = process.env.PIHOLE_URL.replace(/\/$/, '');
const res = await fetch(`${base}/api/auth`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: process.env.PIHOLE_PASSWORD }),
});
const data = await res.json();
if (!data.session?.valid) throw new Error('Authentication failed');
// Log out to clean up the session
try {
await fetch(`${base}/api/auth`, {
method: 'DELETE',
headers: { 'X-FTL-SID': data.session.sid },
});
} catch { /* ignore logout errors */ }
return Date.now() - start;
}
async function checkAzure() {
const start = Date.now();
const { AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_SUBSCRIPTION_ID } = process.env;
const tokenRes = await fetch(
`https://login.microsoftonline.com/${AZURE_TENANT_ID}/oauth2/v2.0/token`,
{
method: 'POST',
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: AZURE_CLIENT_ID,
client_secret: AZURE_CLIENT_SECRET,
scope: 'https://management.azure.com/.default',
}),
}
);
const tokenData = await tokenRes.json();
if (tokenData.error) throw new Error(tokenData.error_description ?? tokenData.error);
// Quick check: list DNS zones
const zonesRes = await fetch(
`https://management.azure.com/subscriptions/${AZURE_SUBSCRIPTION_ID}/providers/Microsoft.Network/dnsZones?api-version=2018-05-01`,
{ headers: { Authorization: `Bearer ${tokenData.access_token}` } }
);
const zonesData = await zonesRes.json();
if (zonesData.error) throw new Error(zonesData.error.message ?? 'API error');
return Date.now() - start;
}
async function checkCpanel() {
const https = require('https');
const start = Date.now();
const insecure = process.env.CPANEL_INSECURE === 'true';
await new Promise((resolve, reject) => {
const url = new URL(`${process.env.CPANEL_URL.replace(/\/$/, '')}/execute/DNS/list_zones`);
const lib = url.protocol === 'https:' ? https : require('http');
const req = lib.request(
{
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname,
method: 'GET',
headers: { Authorization: `cpanel ${process.env.CPANEL_USERNAME}:${process.env.CPANEL_API_TOKEN}`, Accept: 'application/json' },
rejectUnauthorized: !insecure,
},
res => {
let body = '';
res.on('data', c => { body += c; });
res.on('end', () => {
try {
const data = JSON.parse(body);
// status 0 with AUTH_ERROR or similar means bad credentials
if (data.status === 0) reject(new Error(data.errors?.join(', ') ?? 'API error'));
else resolve();
} catch {
if (body.trimStart().startsWith('<')) reject(new Error('Received HTML — check credentials or CPANEL_INSECURE setting'));
else reject(new Error('Invalid response'));
}
});
}
);
req.on('error', reject);
req.end();
});
return Date.now() - start;
}
const CHECKS = {
cloudflare: { name: 'Cloudflare', fn: checkCloudflare,
configured: () => !!process.env.CLOUDFLARE_API_TOKEN },
loopia: { name: 'Loopia', fn: checkLoopia,
configured: () => !!(process.env.LOOPIA_USER && process.env.LOOPIA_PASSWORD) },
pihole: { name: 'Pi-hole', fn: checkPihole,
configured: () => !!(process.env.PIHOLE_URL && process.env.PIHOLE_PASSWORD) },
azure: { name: 'Azure DNS', fn: checkAzure,
configured: () => !!(process.env.AZURE_TENANT_ID && process.env.AZURE_CLIENT_ID && process.env.AZURE_CLIENT_SECRET && process.env.AZURE_SUBSCRIPTION_ID) },
cpanel: { name: 'cPanel', fn: checkCpanel,
configured: () => !!(process.env.CPANEL_URL && process.env.CPANEL_USERNAME && process.env.CPANEL_API_TOKEN) },
};
// GET /api/health/providers
router.get('/providers', async (req, res) => {
const results = await Promise.all(
Object.entries(CHECKS).map(async ([id, { name, fn, configured }]) => {
if (!configured()) return { id, name, status: 'unconfigured', latency: null, error: null };
try {
const latency = await fn();
return { id, name, status: 'ok', latency, error: null };
} catch (err) {
return { id, name, status: 'error', latency: null, error: err.message };
}
})
);
res.json(results);
});
module.exports = router;
+45
View File
@@ -0,0 +1,45 @@
const express = require('express');
const router = express.Router();
const ipam = require('../ipam');
const audit = require('../audit');
const { requireAuth } = require('../auth');
router.use(requireAuth);
router.get('/', (req, res) => {
res.json(ipam.getAll());
});
router.post('/', (req, res) => {
try {
const entry = ipam.create(req.body);
audit.logIpam(req.user, 'add', entry);
res.status(201).json(entry);
} catch (err) {
res.status(400).json({ error: err.message });
}
});
router.put('/:id', (req, res) => {
try {
const prev = ipam.getById(req.params.id);
const entry = ipam.update(req.params.id, req.body);
audit.logIpam(req.user, 'update', entry, prev);
res.json(entry);
} catch (err) {
res.status(404).json({ error: err.message });
}
});
router.delete('/:id', (req, res) => {
try {
const entry = ipam.getById(req.params.id);
ipam.remove(req.params.id);
audit.logIpam(req.user, 'delete', entry);
res.json({ success: true });
} catch (err) {
res.status(404).json({ error: err.message });
}
});
module.exports = router;
+98
View File
@@ -0,0 +1,98 @@
const express = require('express');
const router = express.Router();
const cloudflare = require('../adapters/cloudflare');
const loopia = require('../adapters/loopia');
const pihole = require('../adapters/pihole');
const azure = require('../adapters/azure');
const cpanel = require('../adapters/cpanel');
const db = require('../db');
const notify = require('../notify');
const audit = require('../audit');
const adapters = { cloudflare, loopia, pihole, azure, cpanel };
// GET /api/records/:provider/:zone
// Returns cached records from the local DB. If the zone has never been
// synced, returns an empty array with a `synced_at: null` hint.
router.get('/:provider/:zone', (req, res) => {
const { provider, zone } = req.params;
const records = db.getRecords(provider, zone);
const synced_at = db.getSyncedAt(provider, zone);
res.json({ records, synced_at });
});
// POST /api/sync/:provider/:zone
// Fetches fresh records from the provider API, stores them in the DB,
// and returns the updated record list.
router.post('/sync/:provider/:zone', async (req, res) => {
const { provider, zone } = req.params;
const adapter = adapters[provider];
if (!adapter) return res.status(400).json({ error: 'Unknown provider' });
try {
const records = await adapter.listRecords(zone);
db.replaceZoneRecords(provider, zone, records, req.body?.zoneName);
const synced_at = db.getSyncedAt(provider, zone);
res.json({ records: db.getRecords(provider, zone), synced_at });
} catch (err) {
console.error(`[sync] ${provider}/${zone} error:`, err);
res.status(500).json({ error: err.message });
}
});
// POST /api/records/:provider/:zone
router.post('/:provider/:zone', async (req, res) => {
const { provider, zone } = req.params;
const adapter = adapters[provider];
if (!adapter) return res.status(400).json({ error: 'Unknown provider' });
try {
const result = await adapter.addRecord(zone, req.body);
db.upsertRecord(provider, zone, result);
notify.recordAdded(provider, zone, result);
audit.log(req.user, 'add', provider, db.getZoneName(provider, zone), result);
res.status(201).json(result);
} catch (err) {
console.error(`[records] ${provider} addRecord error:`, err);
res.status(500).json({ error: err.message });
}
});
// PUT /api/records/:provider/:zone/:recordId
router.put('/:provider/:zone/:recordId', async (req, res) => {
const { provider, zone, recordId } = req.params;
const adapter = adapters[provider];
if (!adapter) return res.status(400).json({ error: 'Unknown provider' });
try {
const oldRecord = db.getRecords(provider, zone).find(r => r.id === recordId);
const result = await adapter.updateRecord(zone, recordId, req.body);
// For providers that delete+re-add (Loopia, Pi-hole), remove the old DB
// entry by old ID and insert the new one returned by the adapter.
db.deleteRecord(provider, zone, recordId);
db.upsertRecord(provider, zone, { ...req.body, id: result.id ?? recordId });
notify.recordUpdated(provider, zone, oldRecord ?? req.body, req.body);
audit.log(req.user, 'update', provider, db.getZoneName(provider, zone), req.body, oldRecord);
res.json(result);
} catch (err) {
console.error(`[records] ${provider} updateRecord error:`, err);
res.status(500).json({ error: err.message });
}
});
// DELETE /api/records/:provider/:zone/:recordId
router.delete('/:provider/:zone/:recordId', async (req, res) => {
const { provider, zone, recordId } = req.params;
const adapter = adapters[provider];
if (!adapter) return res.status(400).json({ error: 'Unknown provider' });
try {
const oldRecord = db.getRecords(provider, zone).find(r => r.id === recordId);
await adapter.deleteRecord(zone, recordId);
db.deleteRecord(provider, zone, recordId);
if (oldRecord) notify.recordDeleted(provider, zone, oldRecord);
audit.log(req.user, 'delete', provider, db.getZoneName(provider, zone), oldRecord ?? { id: recordId });
res.json({ success: true });
} catch (err) {
console.error(`[records] ${provider} deleteRecord error:`, err);
res.status(500).json({ error: err.message });
}
});
module.exports = router;
+52
View File
@@ -0,0 +1,52 @@
const express = require('express');
const router = express.Router();
const secrets = require('../secrets');
const audit = require('../audit');
const { requireAuth } = require('../auth');
router.use(requireAuth);
// GET /api/secrets — all secrets with live status
router.get('/', (req, res) => {
res.json(secrets.getStatus());
});
// POST /api/secrets
router.post('/', (req, res) => {
const { name, type, expires_at } = req.body;
if (!name) return res.status(400).json({ error: 'Name is required' });
if (!expires_at) return res.status(400).json({ error: 'Expiry date is required' });
try {
const secret = secrets.create(req.body);
audit.logSecret(req.user, 'add', secret);
res.status(201).json({ ...secret, daysLeft: null, status: 'ok' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// PUT /api/secrets/:id
router.put('/:id', (req, res) => {
try {
const prev = secrets.getById(req.params.id);
const secret = secrets.update(req.params.id, req.body);
audit.logSecret(req.user, 'update', secret, prev);
res.json(secret);
} catch (err) {
res.status(404).json({ error: err.message });
}
});
// DELETE /api/secrets/:id
router.delete('/:id', (req, res) => {
try {
const secret = secrets.getById(req.params.id);
secrets.remove(req.params.id);
audit.logSecret(req.user, 'delete', secret);
res.json({ success: true });
} catch (err) {
res.status(404).json({ error: err.message });
}
});
module.exports = router;
+67
View File
@@ -0,0 +1,67 @@
const express = require('express');
const router = express.Router();
const settings = require('../settings');
const { notify } = require('../notify');
// GET /api/settings
router.get('/', (req, res) => {
res.json(settings.get());
});
// PUT /api/settings
router.put('/', (req, res) => {
try {
const updated = settings.update(req.body);
res.json(updated);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/settings/test-notification
router.post('/test-notification', async (req, res) => {
// Use the payload from the request body so the user can test
// before saving (the frontend sends the current form values)
const { url, token, priority } = req.body;
if (!url || !token) {
return res.status(400).json({ error: 'URL and token are required' });
}
const base = url.replace(/\/$/, '');
try {
const response = await fetch(`${base}/message?token=${encodeURIComponent(token)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: '🦥 Sloth Manager — Test',
message: 'Gotify notifications are working correctly.',
priority: priority ?? 5,
}),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return res.status(502).json({ error: `Gotify returned ${response.status}: ${text}` });
}
res.json({ success: true });
} catch (err) {
res.status(502).json({ error: `Could not reach Gotify: ${err.message}` });
}
});
// POST /api/settings/clear-cache
router.post('/clear-cache', (req, res) => {
try {
const fs = require('fs');
const path = require('path');
const cachePath = process.env.DB_PATH || path.join(__dirname, '../../dns-cache.json');
fs.writeFileSync(cachePath, '{}', 'utf8');
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;
+58
View File
@@ -0,0 +1,58 @@
const express = require('express');
const router = express.Router();
const users = require('../users');
const audit = require('../audit');
const { requireAuth } = require('../auth');
// All user management routes require authentication
router.use(requireAuth);
// GET /api/users
router.get('/', (req, res) => {
res.json(users.getAll());
});
// POST /api/users
router.post('/', (req, res) => {
const { username, password } = req.body;
if (!username || !password) return res.status(400).json({ error: 'Username and password are required' });
if (password.length < 6) return res.status(400).json({ error: 'Password must be at least 6 characters' });
try {
const user = users.create(username, password);
audit.logUser(req.user, 'add', user);
res.status(201).json(user);
} catch (err) {
res.status(400).json({ error: err.message });
}
});
// DELETE /api/users/:id
router.delete('/:id', (req, res) => {
if (req.params.id === req.user.id) {
return res.status(400).json({ error: 'You cannot delete your own account' });
}
try {
const target = users.findById(req.params.id);
users.remove(req.params.id);
audit.logUser(req.user, 'delete', target);
res.json({ success: true });
} catch (err) {
res.status(400).json({ error: err.message });
}
});
// PUT /api/users/:id/password — admin reset of another user's password
router.put('/:id/password', (req, res) => {
const { newPassword } = req.body;
if (!newPassword || newPassword.length < 6) return res.status(400).json({ error: 'Password must be at least 6 characters' });
try {
const target = users.findById(req.params.id);
users.updatePassword(req.params.id, newPassword);
audit.logUser(req.user, 'update', target);
res.json({ success: true });
} catch (err) {
res.status(400).json({ error: err.message });
}
});
module.exports = router;
+24
View File
@@ -0,0 +1,24 @@
const express = require('express');
const router = express.Router();
const cloudflare = require('../adapters/cloudflare');
const loopia = require('../adapters/loopia');
const pihole = require('../adapters/pihole');
const azure = require('../adapters/azure');
const cpanel = require('../adapters/cpanel');
const adapters = { cloudflare, loopia, pihole, azure, cpanel };
// GET /api/zones/:provider
router.get('/:provider', async (req, res) => {
const adapter = adapters[req.params.provider];
if (!adapter) return res.status(400).json({ error: 'Unknown provider' });
try {
const zones = await adapter.listZones();
res.json(zones);
} catch (err) {
console.error(`[zones] ${req.params.provider} listZones error:`, err);
res.status(500).json({ error: err.message });
}
});
module.exports = router;