initial commit
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
*.json
|
||||
!package.json
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
@@ -0,0 +1,32 @@
|
||||
# Cloudflare
|
||||
CLOUDFLARE_API_TOKEN=6wDdCPIi63p9Mbd1bDsaoITtkkd9MKSCcRhnRzDZ
|
||||
|
||||
# Loopia
|
||||
LOOPIA_USER=your_loopia_api_user@loopiaapi
|
||||
LOOPIA_PASSWORD=your_loopia_api_password
|
||||
|
||||
# Pi-hole (v6)
|
||||
PIHOLE_URL=http://192.168.1.x
|
||||
PIHOLE_PASSWORD=your_pihole_web_password
|
||||
|
||||
# Azure DNS
|
||||
AZURE_TENANT_ID=your_tenant_id
|
||||
AZURE_CLIENT_ID=your_client_id
|
||||
AZURE_CLIENT_SECRET=your_client_secret
|
||||
AZURE_SUBSCRIPTION_ID=your_subscription_id
|
||||
|
||||
# cPanel
|
||||
CPANEL_URL=https://hostname:2083
|
||||
CPANEL_USERNAME=your_cpanel_username
|
||||
CPANEL_API_TOKEN=your_api_token
|
||||
CPANEL_INSECURE=false # set to true if cPanel uses a self-signed certificate
|
||||
|
||||
# Comma-separated list of providers to disable (credentials are kept but provider is hidden)
|
||||
# Example: DISABLED_PROVIDERS=loopia,cpanel
|
||||
DISABLED_PROVIDERS=
|
||||
|
||||
# Auth — generate a strong random secret, e.g: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
JWT_SECRET=change-this-to-a-long-random-string
|
||||
JWT_EXPIRES_IN=24h
|
||||
|
||||
PORT=3001
|
||||
@@ -0,0 +1,26 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies first (layer cache)
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# Copy source
|
||||
COPY src/ ./src/
|
||||
|
||||
# Data directory for persistent files (mounted as a volume)
|
||||
RUN mkdir -p /data
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3001 \
|
||||
DB_PATH=/data/dns-cache.json \
|
||||
SETTINGS_PATH=/data/settings.json \
|
||||
USERS_PATH=/data/users.json \
|
||||
AUDIT_PATH=/data/audit-log.json \
|
||||
SECRETS_PATH=/data/secrets.json \
|
||||
IPAM_PATH=/data/ipam.json
|
||||
|
||||
CMD ["node", "src/index.js"]
|
||||
Generated
+1542
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "dns-manager-backend",
|
||||
"version": "1.0.0",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"node-schedule": "^2.1.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"xml2js": "^0.6.2",
|
||||
"xmlbuilder2": "^3.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Azure DNS adapter — uses the Azure Resource Manager REST API.
|
||||
*
|
||||
* Requires env:
|
||||
* AZURE_TENANT_ID Azure AD tenant ID
|
||||
* AZURE_CLIENT_ID Service principal application (client) ID
|
||||
* AZURE_CLIENT_SECRET Service principal client secret
|
||||
* AZURE_SUBSCRIPTION_ID Azure subscription ID
|
||||
*
|
||||
* The service principal needs the "DNS Zone Contributor" role (or higher)
|
||||
* on the subscription or resource group containing your DNS zones.
|
||||
*
|
||||
* Zone IDs are encoded as "resourceGroup::zoneName" so we know which
|
||||
* resource group to target for record operations.
|
||||
*/
|
||||
|
||||
const ARM_BASE = 'https://management.azure.com';
|
||||
const API_VERSION = '2018-05-01';
|
||||
|
||||
// ─── Auth ────────────────────────────────────────────────────────────────────
|
||||
|
||||
let tokenCache = null;
|
||||
|
||||
async function getToken() {
|
||||
if (tokenCache && tokenCache.expiresAt > Date.now() + 60_000) return tokenCache.token;
|
||||
|
||||
const { AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET } = process.env;
|
||||
const url = `https://login.microsoftonline.com/${AZURE_TENANT_ID}/oauth2/v2.0/token`;
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: AZURE_CLIENT_ID,
|
||||
client_secret: AZURE_CLIENT_SECRET,
|
||||
scope: 'https://management.azure.com/.default',
|
||||
});
|
||||
|
||||
const res = await fetch(url, { method: 'POST', body });
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok || data.error) {
|
||||
throw new Error(`Azure auth failed: ${data.error_description ?? data.error ?? res.statusText}`);
|
||||
}
|
||||
|
||||
tokenCache = {
|
||||
token: data.access_token,
|
||||
expiresAt: Date.now() + data.expires_in * 1000,
|
||||
};
|
||||
return tokenCache.token;
|
||||
}
|
||||
|
||||
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, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status === 204) return null;
|
||||
|
||||
const text = await res.text();
|
||||
if (!text || !text.trim()) return null;
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(`Azure returned non-JSON response (HTTP ${res.status}): ${text.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const msg = data?.error?.message ?? `Azure API error ${res.status}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// ─── Zone helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function encodeZoneId(resourceGroup, zoneName) {
|
||||
return `${resourceGroup}::${zoneName}`;
|
||||
}
|
||||
|
||||
function decodeZoneId(zoneId) {
|
||||
const idx = zoneId.indexOf('::');
|
||||
return { resourceGroup: zoneId.slice(0, idx), zoneName: zoneId.slice(idx + 2) };
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function listZones() {
|
||||
const sub = process.env.AZURE_SUBSCRIPTION_ID;
|
||||
const data = await armFetch(`/subscriptions/${sub}/providers/Microsoft.Network/dnsZones`);
|
||||
|
||||
return (data.value ?? []).map(z => {
|
||||
// Extract resource group from the zone's resource ID
|
||||
// e.g. /subscriptions/.../resourceGroups/myRG/providers/Microsoft.Network/dnsZones/example.com
|
||||
const rgMatch = z.id.match(/resourceGroups\/([^/]+)\//i);
|
||||
const rg = rgMatch ? rgMatch[1] : 'unknown';
|
||||
return {
|
||||
id: encodeZoneId(rg, z.name),
|
||||
name: z.name,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function listRecords(zoneId) {
|
||||
const { resourceGroup, zoneName } = decodeZoneId(zoneId);
|
||||
const sub = process.env.AZURE_SUBSCRIPTION_ID;
|
||||
const path = `/subscriptions/${sub}/resourceGroups/${resourceGroup}/providers/Microsoft.Network/dnsZones/${zoneName}/recordSets`;
|
||||
const data = await armFetch(path);
|
||||
|
||||
const records = [];
|
||||
for (const rs of data.value ?? []) {
|
||||
const type = rs.type.split('/').pop(); // e.g. "Microsoft.Network/dnsZones/A" → "A"
|
||||
const name = rs.name === '@' ? zoneName : `${rs.name}.${zoneName}`;
|
||||
const ttl = rs.properties.TTL;
|
||||
|
||||
// Each record set can contain multiple records — flatten them
|
||||
const entries = extractRecords(type, rs.properties);
|
||||
for (const content of entries) {
|
||||
records.push({
|
||||
id: `${rs.name}::${type}::${content}`,
|
||||
type,
|
||||
name,
|
||||
content,
|
||||
ttl,
|
||||
priority: type === 'MX' ? extractMxPriority(rs.properties) : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
function extractRecords(type, props) {
|
||||
switch (type) {
|
||||
case 'A': return (props.ARecords ?? []).map(r => r.ipv4Address);
|
||||
case 'AAAA': return (props.AAAARecords ?? []).map(r => r.ipv6Address);
|
||||
case 'CNAME': return props.CNAMERecord ? [props.CNAMERecord.cname] : [];
|
||||
case 'MX': return (props.MXRecords ?? []).map(r => r.exchange);
|
||||
case 'NS': return (props.NSRecords ?? []).map(r => r.nsdname);
|
||||
case 'TXT': return (props.TXTRecords ?? []).map(r => r.value.join(''));
|
||||
case 'SRV': return (props.SRVRecords ?? []).map(r => `${r.priority} ${r.weight} ${r.port} ${r.target}`);
|
||||
case 'CAA': return (props.caaRecords ?? []).map(r => `${r.flags} ${r.tag} ${r.value}`);
|
||||
case 'PTR': return (props.PTRRecords ?? []).map(r => r.ptrdname);
|
||||
case 'SOA': return []; // skip SOA
|
||||
default: return [];
|
||||
}
|
||||
}
|
||||
|
||||
function extractMxPriority(props) {
|
||||
return props.MXRecords?.[0]?.preference ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a DNS record. Azure groups records into record sets by name+type,
|
||||
* so we GET the existing set (if any), append the new value, then PUT.
|
||||
*/
|
||||
async function addRecord(zoneId, record) {
|
||||
const { resourceGroup, zoneName } = decodeZoneId(zoneId);
|
||||
const sub = process.env.AZURE_SUBSCRIPTION_ID;
|
||||
|
||||
// Derive relative record name
|
||||
let relName = record.name;
|
||||
if (relName.endsWith(`.${zoneName}`)) relName = relName.slice(0, -(zoneName.length + 1));
|
||||
if (relName === zoneName) relName = '@';
|
||||
|
||||
const path = `/subscriptions/${sub}/resourceGroups/${resourceGroup}/providers/Microsoft.Network/dnsZones/${zoneName}/${record.type}/${relName}`;
|
||||
|
||||
// Fetch existing record set so we can merge
|
||||
let existing = null;
|
||||
try {
|
||||
existing = await armFetch(path);
|
||||
} catch {
|
||||
// 404 means it doesn't exist yet — that's fine
|
||||
}
|
||||
|
||||
const props = existing?.properties ?? { TTL: Number(record.ttl) || 3600 };
|
||||
props.TTL = Number(record.ttl) || props.TTL || 3600;
|
||||
|
||||
appendToRecordSet(props, record.type, record.content, record.priority);
|
||||
|
||||
await armFetch(path, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ properties: props }),
|
||||
});
|
||||
|
||||
return {
|
||||
id: `${relName}::${record.type}::${record.content}`,
|
||||
type: record.type,
|
||||
name: relName === '@' ? zoneName : `${relName}.${zoneName}`,
|
||||
content: record.content,
|
||||
ttl: props.TTL,
|
||||
priority: record.priority ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function appendToRecordSet(props, type, content, priority) {
|
||||
switch (type) {
|
||||
case 'A':
|
||||
props.ARecords = [...(props.ARecords ?? []), { ipv4Address: content }];
|
||||
break;
|
||||
case 'AAAA':
|
||||
props.AAAARecords = [...(props.AAAARecords ?? []), { ipv6Address: content }];
|
||||
break;
|
||||
case 'CNAME':
|
||||
props.CNAMERecord = { cname: content };
|
||||
break;
|
||||
case 'MX':
|
||||
props.MXRecords = [...(props.MXRecords ?? []), { preference: Number(priority) || 10, exchange: content }];
|
||||
break;
|
||||
case 'NS':
|
||||
props.NSRecords = [...(props.NSRecords ?? []), { nsdname: content }];
|
||||
break;
|
||||
case 'TXT':
|
||||
props.TXTRecords = [...(props.TXTRecords ?? []), { value: [content] }];
|
||||
break;
|
||||
case 'PTR':
|
||||
props.PTRRecords = [...(props.PTRRecords ?? []), { ptrdname: content }];
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Record type ${type} is not supported for add via this adapter`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a record in-place with a single GET → modify → PUT.
|
||||
* Falls back to a plain add if the record set no longer exists in Azure.
|
||||
* recordId format: "relName::type::oldContent"
|
||||
*/
|
||||
async function updateRecord(zoneId, recordId, record) {
|
||||
const { resourceGroup, zoneName } = decodeZoneId(zoneId);
|
||||
const sub = process.env.AZURE_SUBSCRIPTION_ID;
|
||||
|
||||
const parts = recordId.split('::');
|
||||
const oldRelName = parts[0];
|
||||
const type = parts[1];
|
||||
const oldContent = parts.slice(2).join('::');
|
||||
|
||||
// Derive the new relative name from the updated record
|
||||
let newRelName = record.name;
|
||||
if (newRelName.endsWith(`.${zoneName}`)) newRelName = newRelName.slice(0, -(zoneName.length + 1));
|
||||
if (newRelName === zoneName) newRelName = '@';
|
||||
|
||||
const oldPath = `/subscriptions/${sub}/resourceGroups/${resourceGroup}/providers/Microsoft.Network/dnsZones/${zoneName}/${type}/${oldRelName}`;
|
||||
const newPath = `/subscriptions/${sub}/resourceGroups/${resourceGroup}/providers/Microsoft.Network/dnsZones/${zoneName}/${type}/${newRelName}`;
|
||||
|
||||
// Try to fetch the existing record set
|
||||
let existing = null;
|
||||
try { existing = await armFetch(oldPath); } catch { /* doesn't exist in Azure */ }
|
||||
|
||||
if (existing) {
|
||||
const props = existing.properties;
|
||||
// Remove old value and add new one
|
||||
removeFromRecordSet(props, type, oldContent);
|
||||
props.TTL = Number(record.ttl) || props.TTL || 3600;
|
||||
appendToRecordSet(props, type, record.content, record.priority);
|
||||
|
||||
if (oldRelName === newRelName) {
|
||||
// Same name — just update in place
|
||||
await armFetch(oldPath, { method: 'PUT', body: JSON.stringify({ properties: props }) });
|
||||
} else {
|
||||
// Name changed — delete old set, create new one
|
||||
await armFetch(oldPath, { method: 'DELETE' });
|
||||
const newProps = { TTL: props.TTL };
|
||||
appendToRecordSet(newProps, type, record.content, record.priority);
|
||||
await armFetch(newPath, { method: 'PUT', body: JSON.stringify({ properties: newProps }) });
|
||||
}
|
||||
} else {
|
||||
// Record set gone from Azure — just add it fresh
|
||||
await addRecord(zoneId, record);
|
||||
}
|
||||
|
||||
return {
|
||||
id: `${newRelName}::${type}::${record.content}`,
|
||||
type,
|
||||
name: newRelName === '@' ? zoneName : `${newRelName}.${zoneName}`,
|
||||
content: record.content,
|
||||
ttl: Number(record.ttl) || 3600,
|
||||
priority: record.priority ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a single value from an Azure record set.
|
||||
* If the set becomes empty, the entire record set is deleted.
|
||||
* recordId format: "relName::type::content"
|
||||
*/
|
||||
async function deleteRecord(zoneId, recordId) {
|
||||
const { resourceGroup, zoneName } = decodeZoneId(zoneId);
|
||||
const sub = process.env.AZURE_SUBSCRIPTION_ID;
|
||||
|
||||
const parts = recordId.split('::');
|
||||
const relName = parts[0];
|
||||
const type = parts[1];
|
||||
const content = parts.slice(2).join('::'); // content may contain "::"
|
||||
|
||||
const path = `/subscriptions/${sub}/resourceGroups/${resourceGroup}/providers/Microsoft.Network/dnsZones/${zoneName}/${type}/${relName}`;
|
||||
// Fetch the record set and remove just this value
|
||||
const existing = await armFetch(path);
|
||||
const props = existing.properties;
|
||||
|
||||
removeFromRecordSet(props, type, content);
|
||||
|
||||
const remaining = countRecords(props, type);
|
||||
if (remaining === 0) {
|
||||
// Delete the entire record set
|
||||
await armFetch(path, { method: 'DELETE' });
|
||||
} else {
|
||||
await armFetch(path, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ properties: props }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function removeFromRecordSet(props, type, content) {
|
||||
switch (type) {
|
||||
case 'A': props.ARecords = (props.ARecords ?? []).filter(r => r.ipv4Address !== content); break;
|
||||
case 'AAAA': props.AAAARecords = (props.AAAARecords ?? []).filter(r => r.ipv6Address !== content); break;
|
||||
case 'CNAME': props.CNAMERecord = null; break;
|
||||
case 'MX': props.MXRecords = (props.MXRecords ?? []).filter(r => r.exchange !== content); break;
|
||||
case 'NS': props.NSRecords = (props.NSRecords ?? []).filter(r => r.nsdname !== content); break;
|
||||
case 'TXT': props.TXTRecords = (props.TXTRecords ?? []).filter(r => r.value.join('') !== content); break;
|
||||
case 'PTR': props.PTRRecords = (props.PTRRecords ?? []).filter(r => r.ptrdname !== content); break;
|
||||
}
|
||||
}
|
||||
|
||||
function countRecords(props, type) {
|
||||
switch (type) {
|
||||
case 'A': return (props.ARecords ?? []).length;
|
||||
case 'AAAA': return (props.AAAARecords ?? []).length;
|
||||
case 'CNAME': return props.CNAMERecord ? 1 : 0;
|
||||
case 'MX': return (props.MXRecords ?? []).length;
|
||||
case 'NS': return (props.NSRecords ?? []).length;
|
||||
case 'TXT': return (props.TXTRecords ?? []).length;
|
||||
case 'PTR': return (props.PTRRecords ?? []).length;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listZones, listRecords, addRecord, updateRecord, deleteRecord };
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Cloudflare adapter — uses the Cloudflare v4 REST API.
|
||||
* Requires env: CLOUDFLARE_API_TOKEN
|
||||
*/
|
||||
|
||||
const BASE = 'https://api.cloudflare.com/client/v4';
|
||||
|
||||
function headers() {
|
||||
return {
|
||||
Authorization: `Bearer ${process.env.CLOUDFLARE_API_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
async function cfFetch(path, options = {}) {
|
||||
const res = await fetch(`${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';
|
||||
throw new Error(msg);
|
||||
}
|
||||
return data.result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns array of { id, name }
|
||||
*/
|
||||
async function listZones() {
|
||||
let page = 1;
|
||||
let allZones = [];
|
||||
while (true) {
|
||||
const result = await cfFetch(`/zones?per_page=50&page=${page}`);
|
||||
allZones = allZones.concat(result);
|
||||
if (result.length < 50) break;
|
||||
page++;
|
||||
}
|
||||
return allZones.map(z => ({ id: z.id, name: z.name }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns array of DNS records for a zone.
|
||||
* zoneId: the Cloudflare zone ID string
|
||||
*/
|
||||
async function listRecords(zoneId) {
|
||||
let page = 1;
|
||||
let all = [];
|
||||
while (true) {
|
||||
const result = await cfFetch(`/zones/${zoneId}/dns_records?per_page=100&page=${page}`);
|
||||
all = all.concat(result);
|
||||
if (result.length < 100) break;
|
||||
page++;
|
||||
}
|
||||
return all.map(r => ({
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
name: r.name,
|
||||
content: r.content,
|
||||
ttl: r.ttl,
|
||||
priority: r.priority ?? null,
|
||||
proxied: r.proxied ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a DNS record. record: { type, name, content, ttl, priority?, proxied? }
|
||||
*/
|
||||
async function addRecord(zoneId, record) {
|
||||
const body = {
|
||||
type: record.type,
|
||||
name: record.name,
|
||||
content: record.content,
|
||||
ttl: record.ttl || 1,
|
||||
};
|
||||
if (record.priority !== undefined && record.priority !== '') body.priority = Number(record.priority);
|
||||
if (record.proxied !== undefined) body.proxied = record.proxied;
|
||||
|
||||
const result = await cfFetch(`/zones/${zoneId}/dns_records`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return { id: result.id, ...body };
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing DNS record.
|
||||
*/
|
||||
async function updateRecord(zoneId, recordId, record) {
|
||||
const body = {
|
||||
type: record.type,
|
||||
name: record.name,
|
||||
content: record.content,
|
||||
ttl: record.ttl || 1,
|
||||
};
|
||||
if (record.priority !== undefined && record.priority !== '') body.priority = Number(record.priority);
|
||||
if (record.proxied !== undefined) body.proxied = record.proxied;
|
||||
|
||||
const result = await cfFetch(`/zones/${zoneId}/dns_records/${recordId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return { id: result.id, ...body };
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a DNS record by ID.
|
||||
*/
|
||||
async function deleteRecord(zoneId, recordId) {
|
||||
await cfFetch(`/zones/${zoneId}/dns_records/${recordId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
module.exports = { listZones, listRecords, addRecord, updateRecord, deleteRecord };
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* cPanel adapter — uses the cPanel UAPI (user-level).
|
||||
*
|
||||
* Requires env:
|
||||
* CPANEL_URL Base URL of the cPanel instance, e.g. https://hostname:2083
|
||||
* CPANEL_USERNAME cPanel account username
|
||||
* CPANEL_API_TOKEN API token created inside cPanel (Manage API Tokens)
|
||||
*
|
||||
* The cPanel account must own the domains you want to manage.
|
||||
* API tokens are created under cPanel → Security → Manage API Tokens.
|
||||
*/
|
||||
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
|
||||
function base() {
|
||||
return process.env.CPANEL_URL.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function authHeader() {
|
||||
return `cpanel ${process.env.CPANEL_USERNAME}:${process.env.CPANEL_API_TOKEN}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal HTTP/HTTPS request helper using Node built-ins.
|
||||
* Avoids undici/fetch version conflicts and supports self-signed certs.
|
||||
*/
|
||||
function request(url, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const insecure = process.env.CPANEL_INSECURE === 'true';
|
||||
const lib = parsed.protocol === 'https:' ? https : http;
|
||||
|
||||
const reqOptions = {
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: options.method || 'GET',
|
||||
headers: options.headers || {},
|
||||
rejectUnauthorized: !insecure,
|
||||
};
|
||||
|
||||
const req = lib.request(reqOptions, res => {
|
||||
let body = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', chunk => { body += chunk; });
|
||||
res.on('end', () => resolve({ status: res.statusCode, text: () => body }));
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
if (options.body) req.write(options.body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function parseResponse(res, debug = false) {
|
||||
const text = res.text();
|
||||
if (text.trimStart().startsWith('<')) {
|
||||
throw new Error(
|
||||
`cPanel returned HTTP ${res.status} with an HTML page — ` +
|
||||
`check CPANEL_URL, username, and API token. ` +
|
||||
`If cPanel uses a self-signed certificate, set CPANEL_INSECURE=true in .env`
|
||||
);
|
||||
}
|
||||
const envelope = JSON.parse(text);
|
||||
if (debug) console.log('[cpanel] full envelope:', JSON.stringify(envelope, null, 2));
|
||||
if (res.status >= 400) throw new Error(`cPanel HTTP ${res.status}`);
|
||||
|
||||
// UAPI can nest the real payload under result{} or return it flat
|
||||
const result = envelope.result ?? envelope;
|
||||
if (result.status === 0) throw new Error(`cPanel UAPI error: ${result.errors?.join(', ') ?? 'Unknown error'}`);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async function uapiGet(module, func, params = {}, debug = false) {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
const url = `${base()}/execute/${module}/${func}${qs ? '?' + qs : ''}`;
|
||||
const res = await request(url, {
|
||||
headers: { Authorization: authHeader(), Accept: 'application/json' },
|
||||
});
|
||||
return parseResponse(res, debug);
|
||||
}
|
||||
|
||||
async function uapiPost(module, func, params = {}) {
|
||||
const body = Object.entries(params)
|
||||
.map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`)
|
||||
.join('&');
|
||||
const url = `${base()}/execute/${module}/${func}`;
|
||||
const res = await request(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: authHeader(),
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
},
|
||||
body,
|
||||
});
|
||||
return parseResponse(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* cPanel API 2 call — used for write operations (add/remove zone records).
|
||||
* API 2 uses simpler flat parameters and is more widely supported.
|
||||
*/
|
||||
async function api2(func, params = {}, debug = false) {
|
||||
const qs = new URLSearchParams({
|
||||
cpanel_jsonapi_module: 'ZoneEdit',
|
||||
cpanel_jsonapi_func: func,
|
||||
cpanel_jsonapi_apiversion: '2',
|
||||
...params,
|
||||
}).toString();
|
||||
const url = `${base()}/json-api/cpanel?${qs}`;
|
||||
const res = await request(url, {
|
||||
headers: { Authorization: authHeader(), Accept: 'application/json' },
|
||||
});
|
||||
const text = res.text();
|
||||
if (text.trimStart().startsWith('<')) throw new Error('cPanel returned HTML — check credentials');
|
||||
const data = JSON.parse(text);
|
||||
const result = data?.cpanelresult?.data?.[0];
|
||||
if (result?.reason && result.reason !== 'OK' && result?.status !== 1) {
|
||||
throw new Error(`cPanel API 2 error: ${result.reason}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function listZones() {
|
||||
// Try DNS::list_zones first (cPanel v82+), fall back to DomainInfo::list_domains
|
||||
let domains = [];
|
||||
|
||||
try {
|
||||
const data = await uapiGet('DNS', 'list_zones');
|
||||
if (data && data.length > 0) {
|
||||
return data.map(z => {
|
||||
const name = typeof z === 'string' ? z : (z.domain ?? z.zone ?? z.name);
|
||||
return { id: name, name };
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (!err.message.includes('could not find the function')) throw err;
|
||||
// Function doesn't exist on this cPanel version — use DomainInfo fallback
|
||||
}
|
||||
|
||||
const info = await uapiGet('DomainInfo', 'list_domains');
|
||||
// Returns { main_domain, addon_domains, parked_domains, sub_domains }
|
||||
if (info) {
|
||||
const main = info.main_domain ? [info.main_domain] : [];
|
||||
const addon = Array.isArray(info.addon_domains) ? info.addon_domains : [];
|
||||
const parked = Array.isArray(info.parked_domains) ? info.parked_domains : [];
|
||||
domains = [...new Set([...main, ...addon, ...parked])];
|
||||
}
|
||||
|
||||
return domains.map(d => ({ id: d, name: d }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists DNS records for a zone by parsing it with UAPI.
|
||||
*/
|
||||
async function listRecords(domain) {
|
||||
// Use API 2 fetchzone_records — returns pre-decoded values and the correct
|
||||
// Line numbers that edit_zone_record / remove_zone_record expect.
|
||||
const qs = new URLSearchParams({
|
||||
cpanel_jsonapi_module: 'ZoneEdit',
|
||||
cpanel_jsonapi_func: 'fetchzone_records',
|
||||
cpanel_jsonapi_apiversion: '2',
|
||||
domain,
|
||||
}).toString();
|
||||
const url = `${base()}/json-api/cpanel?${qs}`;
|
||||
const res = await request(url, {
|
||||
headers: { Authorization: authHeader(), Accept: 'application/json' },
|
||||
});
|
||||
const text = res.text();
|
||||
const envelope = JSON.parse(text);
|
||||
const records = envelope?.cpanelresult?.data ?? [];
|
||||
|
||||
const SKIP_TYPES = new Set(['SOA', 'NS', '$TTL', ':RAW']);
|
||||
|
||||
return records
|
||||
.filter(r => r.type && !SKIP_TYPES.has(r.type) && !r.type.startsWith('$') && !r.type.startsWith(':'))
|
||||
.map(r => {
|
||||
const rawName = (r.name ?? '').replace(/\.$/, '');
|
||||
const name = rawName || domain;
|
||||
const content = extractContent(r);
|
||||
return {
|
||||
id: `${r.Line}::${r.type}::${name}`,
|
||||
type: r.type,
|
||||
name,
|
||||
content,
|
||||
ttl: r.ttl ?? null,
|
||||
priority: r.type === 'MX' ? (Number(r.preference) || null) : null,
|
||||
_line: r.Line,
|
||||
};
|
||||
})
|
||||
.filter(r => r.content !== null);
|
||||
}
|
||||
|
||||
function extractContent(r) {
|
||||
switch (r.type) {
|
||||
case 'A':
|
||||
case 'AAAA': return r.address ?? null;
|
||||
case 'CNAME': return (r.cname ?? '').replace(/\.$/, '') || null;
|
||||
case 'MX': return (r.exchange ?? '').replace(/\.$/, '') || null;
|
||||
case 'TXT': return Array.isArray(r.txtdata) ? r.txtdata.join('') : (r.txtdata ?? null);
|
||||
case 'NS': return (r.nsdname ?? '').replace(/\.$/, '') || null;
|
||||
case 'PTR': return (r.ptrdname ?? '').replace(/\.$/, '') || null;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a DNS record using mass_edit_zone.
|
||||
* Fetches the current serial first (required by the API).
|
||||
*/
|
||||
async function addRecord(domain, record) {
|
||||
let name = record.name;
|
||||
if (name.endsWith(`.${domain}`)) name = name.slice(0, -(domain.length + 1));
|
||||
if (name === domain) name = domain + '.'; // apex needs FQDN with trailing dot for API 2
|
||||
|
||||
const params = { domain, name, type: record.type, ttl: record.ttl || 3600 };
|
||||
|
||||
switch (record.type) {
|
||||
case 'A':
|
||||
case 'AAAA': params.address = record.content; break;
|
||||
case 'CNAME': params.cname = record.content; break;
|
||||
case 'MX': params.exchange = record.content; params.priority = record.priority ?? 10; break;
|
||||
case 'TXT': params.txtdata = record.content; break;
|
||||
case 'NS': params.nsdname = record.content; break;
|
||||
case 'PTR': params.ptrdname = record.content; break;
|
||||
default: throw new Error(`Unsupported record type for cPanel add: ${record.type}`);
|
||||
}
|
||||
|
||||
await api2('add_zone_record', params);
|
||||
|
||||
// Re-parse to find the newly created line index
|
||||
const all = await listRecords(domain);
|
||||
const expectedName = (name === domain + '.') ? domain : `${name}.${domain}`;
|
||||
const created = all.find(r =>
|
||||
r.type === record.type &&
|
||||
r.content === record.content &&
|
||||
r.name === expectedName
|
||||
);
|
||||
|
||||
return created ?? {
|
||||
id: `new::${record.type}::${record.content}`,
|
||||
type: record.type,
|
||||
name: expectedName,
|
||||
content: record.content,
|
||||
ttl: record.ttl ?? 3600,
|
||||
priority: record.priority ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a record — delete old line, add new record.
|
||||
*/
|
||||
async function updateRecord(domain, recordId, record) {
|
||||
// cPanel's edit_zone_record adds instead of replacing on some versions.
|
||||
// Delete the old record by line number, then add the new one.
|
||||
await deleteRecord(domain, recordId);
|
||||
return addRecord(domain, record);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a record by its line number.
|
||||
* recordId format: "lineNumber::type::name"
|
||||
*/
|
||||
async function deleteRecord(domain, recordId) {
|
||||
const lineIndex = parseInt(recordId.split('::')[0]);
|
||||
await api2('remove_zone_record', { domain, line: lineIndex });
|
||||
}
|
||||
|
||||
|
||||
module.exports = { listZones, listRecords, addRecord, updateRecord, deleteRecord };
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Loopia adapter — uses the Loopia XML-RPC API.
|
||||
* Requires env: LOOPIA_USER, LOOPIA_PASSWORD
|
||||
*
|
||||
* API docs: https://www.loopia.se/api/
|
||||
* Endpoint: https://api.loopia.se/RPCSERV
|
||||
*/
|
||||
|
||||
const { parseStringPromise } = require('xml2js');
|
||||
|
||||
const ENDPOINT = 'https://api.loopia.se/RPCSERV';
|
||||
|
||||
// ─── XML-RPC builder ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Serialise a JS value to an XML-RPC <value> string.
|
||||
*/
|
||||
function valueXml(v) {
|
||||
if (v === null || v === undefined) return '<value><string></string></value>';
|
||||
if (typeof v === 'boolean') return `<value><boolean>${v ? 1 : 0}</boolean></value>`;
|
||||
if (typeof v === 'number') {
|
||||
return Number.isInteger(v)
|
||||
? `<value><int>${v}</int></value>`
|
||||
: `<value><double>${v}</double></value>`;
|
||||
}
|
||||
if (typeof v === 'string') return `<value><string>${xmlEscape(v)}</string></value>`;
|
||||
if (Array.isArray(v)) {
|
||||
return `<value><array><data>${v.map(valueXml).join('')}</data></array></value>`;
|
||||
}
|
||||
if (typeof v === 'object') {
|
||||
const members = Object.entries(v)
|
||||
.map(([k, val]) => `<member><name>${xmlEscape(k)}</name>${valueXml(val)}</member>`)
|
||||
.join('');
|
||||
return `<value><struct>${members}</struct></value>`;
|
||||
}
|
||||
return `<value><string>${xmlEscape(String(v))}</string></value>`;
|
||||
}
|
||||
|
||||
function xmlEscape(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function buildRequest(method, args) {
|
||||
const params = args
|
||||
.map(a => `<param>${valueXml(a)}</param>`)
|
||||
.join('');
|
||||
return `<?xml version="1.0" encoding="UTF-8"?><methodCall><methodName>${method}</methodName><params>${params}</params></methodCall>`;
|
||||
}
|
||||
|
||||
// ─── XML-RPC response parser ─────────────────────────────────────────────────
|
||||
|
||||
function parseValue(node) {
|
||||
if (node.string !== undefined) return Array.isArray(node.string) ? node.string[0] : node.string;
|
||||
if (node.int !== undefined) return parseInt(Array.isArray(node.int) ? node.int[0] : node.int);
|
||||
if (node.i4 !== undefined) return parseInt(Array.isArray(node.i4) ? node.i4[0] : node.i4);
|
||||
if (node.boolean !== undefined) {
|
||||
const b = Array.isArray(node.boolean) ? node.boolean[0] : node.boolean;
|
||||
return b === '1' || b === 1 || b === true;
|
||||
}
|
||||
if (node.double !== undefined) return parseFloat(Array.isArray(node.double) ? node.double[0] : node.double);
|
||||
if (node.array) {
|
||||
const data = node.array[0]?.data?.[0]?.value ?? [];
|
||||
return data.map(parseValue);
|
||||
}
|
||||
if (node.struct) {
|
||||
const obj = {};
|
||||
for (const member of node.struct[0].member ?? []) {
|
||||
obj[member.name[0]] = parseValue(member.value[0]);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
// bare string (no type tag)
|
||||
const keys = Object.keys(node).filter(k => k !== '_');
|
||||
if (keys.length > 0) {
|
||||
const v = node[keys[0]];
|
||||
return Array.isArray(v) ? v[0] : v;
|
||||
}
|
||||
return node._ ?? null;
|
||||
}
|
||||
|
||||
// ─── Core RPC call ───────────────────────────────────────────────────────────
|
||||
|
||||
async function xmlRpc(method, args) {
|
||||
const body = buildRequest(method, args);
|
||||
console.log(`[loopia] → ${method}`);
|
||||
|
||||
const res = await fetch(ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'text/xml; charset=utf-8' },
|
||||
body,
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = await parseStringPromise(text, { explicitArray: true });
|
||||
} catch (e) {
|
||||
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]);
|
||||
throw new Error(`Loopia fault ${fv.faultCode}: ${fv.faultString}`);
|
||||
}
|
||||
|
||||
const param = parsed?.methodResponse?.params?.[0]?.param?.[0]?.value?.[0];
|
||||
const result = param ? parseValue(param) : null;
|
||||
|
||||
// Loopia returns status strings like "OK", "AUTH_ERROR", etc. for write calls
|
||||
if (typeof result === 'string' && result !== 'OK' && !/^\d+$/.test(result)) {
|
||||
throw new Error(`Loopia returned status: ${result}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Credential helper ───────────────────────────────────────────────────────
|
||||
|
||||
function creds() {
|
||||
return [process.env.LOOPIA_USER, process.env.LOOPIA_PASSWORD];
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function listZones() {
|
||||
const domains = await xmlRpc('getDomains', creds());
|
||||
if (!Array.isArray(domains)) throw new Error(`getDomains returned unexpected value: ${JSON.stringify(domains)}`);
|
||||
return domains.map(d => ({ id: d.domain, name: d.domain }));
|
||||
}
|
||||
|
||||
async function listRecords(domain) {
|
||||
const subdomains = await xmlRpc('getSubdomains', [...creds(), domain]);
|
||||
if (!Array.isArray(subdomains)) throw new Error(`getSubdomains returned unexpected value: ${JSON.stringify(subdomains)}`);
|
||||
|
||||
const allRecords = [];
|
||||
for (const sub of subdomains) {
|
||||
const records = await xmlRpc('getZoneRecords', [...creds(), domain, sub]);
|
||||
if (!Array.isArray(records)) continue;
|
||||
for (const r of records) {
|
||||
allRecords.push({
|
||||
id: `${sub}::${r.record_id}`,
|
||||
type: r.type,
|
||||
name: sub === '@' ? domain : `${sub}.${domain}`,
|
||||
content: r.rdata,
|
||||
ttl: r.ttl,
|
||||
priority: r.priority ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return allRecords;
|
||||
}
|
||||
|
||||
async function addRecord(domain, record) {
|
||||
let subdomain = record.name;
|
||||
if (subdomain.endsWith(`.${domain}`)) subdomain = subdomain.slice(0, -(domain.length + 1));
|
||||
if (subdomain === domain || subdomain === '') subdomain = '@';
|
||||
|
||||
const entry = {
|
||||
type: record.type,
|
||||
ttl: Number(record.ttl) || 3600,
|
||||
priority: Number(record.priority) || 0,
|
||||
rdata: record.content,
|
||||
};
|
||||
|
||||
await xmlRpc('addZoneRecord', [...creds(), domain, subdomain, entry]);
|
||||
|
||||
const displayName = subdomain === '@' ? domain : `${subdomain}.${domain}`;
|
||||
return {
|
||||
id: `${subdomain}::new`,
|
||||
type: record.type,
|
||||
name: displayName,
|
||||
content: record.content,
|
||||
ttl: entry.ttl,
|
||||
priority: entry.priority ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function updateRecord(domain, recordId, record) {
|
||||
await deleteRecord(domain, recordId);
|
||||
return addRecord(domain, record);
|
||||
}
|
||||
|
||||
async function deleteRecord(domain, recordId) {
|
||||
const [subdomain, loopiaId] = recordId.split('::');
|
||||
await xmlRpc('removeZoneRecord', [...creds(), domain, subdomain, parseInt(loopiaId)]);
|
||||
|
||||
// If no records remain on this subdomain, remove the subdomain itself.
|
||||
// Skip '@' — that's the root zone and should never be removed.
|
||||
if (subdomain !== '@') {
|
||||
const remaining = await xmlRpc('getZoneRecords', [...creds(), domain, subdomain]);
|
||||
if (!Array.isArray(remaining) || remaining.length === 0) {
|
||||
await xmlRpc('removeSubdomain', [...creds(), domain, subdomain]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listZones, listRecords, addRecord, updateRecord, deleteRecord };
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Pi-hole v6 adapter — uses the Pi-hole v6 REST API.
|
||||
* Supports custom A/AAAA records and CNAME records (Pi-hole local DNS).
|
||||
*
|
||||
* Requires env:
|
||||
* PIHOLE_URL e.g. http://192.168.1.2 (no trailing slash)
|
||||
* PIHOLE_PASSWORD your Pi-hole web password
|
||||
*
|
||||
* Pi-hole v6 docs: https://ftl.pi-hole.net/
|
||||
*
|
||||
* Note: Pi-hole local DNS has no zones or TTL — the whole instance is
|
||||
* treated as a single zone. Record types are limited to A, AAAA, CNAME.
|
||||
*/
|
||||
|
||||
let cachedSid = null;
|
||||
let sidExpiry = 0;
|
||||
|
||||
function base() {
|
||||
return process.env.PIHOLE_URL.replace(/\/$/, '') + '/api';
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate and return a session ID, reusing a cached one if still valid.
|
||||
*/
|
||||
async function getSession() {
|
||||
if (cachedSid && Date.now() < sidExpiry) return cachedSid;
|
||||
|
||||
const res = await fetch(`${base()}/auth`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: process.env.PIHOLE_PASSWORD }),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Pi-hole auth failed: ${res.status} ${res.statusText}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.session?.valid) {
|
||||
throw new Error('Pi-hole authentication failed — check PIHOLE_PASSWORD');
|
||||
}
|
||||
|
||||
cachedSid = data.session.sid;
|
||||
// Sessions last 5 minutes by default; refresh 30s early
|
||||
sidExpiry = Date.now() + (data.session.validity ?? 300) * 1000 - 30_000;
|
||||
return cachedSid;
|
||||
}
|
||||
|
||||
async function phFetch(path, options = {}) {
|
||||
const sid = await getSession();
|
||||
const res = await fetch(`${base()}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-FTL-SID': sid,
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
|
||||
// Invalidate cache on auth errors so next call re-authenticates
|
||||
if (res.status === 401) {
|
||||
cachedSid = null;
|
||||
throw new Error('Pi-hole session expired — will re-authenticate on next request');
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`Pi-hole API error ${res.status}: ${text}`);
|
||||
}
|
||||
|
||||
// 204 No Content
|
||||
if (res.status === 204) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a single "zone" representing the Pi-hole instance.
|
||||
*/
|
||||
async function listZones() {
|
||||
return [{ id: 'local', name: process.env.PIHOLE_URL || 'Pi-hole' }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all custom DNS (A/AAAA) and CNAME records.
|
||||
*
|
||||
* Pi-hole v6 stores these under /api/config/dns/hosts and
|
||||
* /api/config/dns/cnameRecords.
|
||||
*
|
||||
* hosts entries are plain strings: "1.2.3.4 hostname"
|
||||
* cnameRecords entries are plain strings: "alias,target"
|
||||
*/
|
||||
async function listRecords(_zoneId) {
|
||||
const [hostsData, cnameData] = await Promise.all([
|
||||
phFetch('/config/dns/hosts'),
|
||||
phFetch('/config/dns/cnameRecords'),
|
||||
]);
|
||||
|
||||
const records = [];
|
||||
|
||||
for (const entry of hostsData?.config?.dns?.hosts ?? []) {
|
||||
// entry: "1.2.3.4 hostname" or "::1 hostname"
|
||||
const spaceIdx = entry.indexOf(' ');
|
||||
if (spaceIdx === -1) continue;
|
||||
const ip = entry.slice(0, spaceIdx).trim();
|
||||
const domain = entry.slice(spaceIdx + 1).trim();
|
||||
const type = ip.includes(':') ? 'AAAA' : 'A';
|
||||
records.push({
|
||||
id: `dns::${domain}::${ip}`,
|
||||
type,
|
||||
name: domain,
|
||||
content: ip,
|
||||
ttl: null,
|
||||
priority: null,
|
||||
});
|
||||
}
|
||||
|
||||
for (const entry of cnameData?.config?.dns?.cnameRecords ?? []) {
|
||||
// entry: "alias,target"
|
||||
const [alias, target] = entry.split(',');
|
||||
if (!alias || !target) continue;
|
||||
records.push({
|
||||
id: `cname::${alias.trim()}::${target.trim()}`,
|
||||
type: 'CNAME',
|
||||
name: alias.trim(),
|
||||
content: target.trim(),
|
||||
ttl: null,
|
||||
priority: null,
|
||||
});
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a custom DNS or CNAME record.
|
||||
* record: { type, name, content }
|
||||
*/
|
||||
async function addRecord(_zoneId, record) {
|
||||
const type = record.type.toUpperCase();
|
||||
|
||||
if (type === 'CNAME') {
|
||||
const entry = encodeURIComponent(`${record.name},${record.content}`);
|
||||
await phFetch(`/config/dns/cnameRecords/${entry}`, { method: 'PUT' });
|
||||
return { id: `cname::${record.name}::${record.content}`, type: 'CNAME', name: record.name, content: record.content };
|
||||
}
|
||||
|
||||
if (type === 'A' || type === 'AAAA') {
|
||||
const entry = encodeURIComponent(`${record.content} ${record.name}`);
|
||||
await phFetch(`/config/dns/hosts/${entry}`, { method: 'PUT' });
|
||||
return { id: `dns::${record.name}::${record.content}`, type, name: record.name, content: record.content };
|
||||
}
|
||||
|
||||
throw new Error(`Pi-hole only supports A, AAAA, and CNAME records (got ${type})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a record — Pi-hole has no native update, so delete + re-add.
|
||||
*/
|
||||
async function updateRecord(zoneId, recordId, record) {
|
||||
await deleteRecord(zoneId, recordId);
|
||||
return addRecord(zoneId, record);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a record by composite ID.
|
||||
* recordId format: "dns::domain::ip" or "cname::alias::target"
|
||||
*/
|
||||
async function deleteRecord(_zoneId, recordId) {
|
||||
const [kind, part1, part2] = recordId.split('::');
|
||||
|
||||
if (kind === 'cname') {
|
||||
const entry = encodeURIComponent(`${part1},${part2}`);
|
||||
await phFetch(`/config/dns/cnameRecords/${entry}`, { method: 'DELETE' });
|
||||
} else {
|
||||
const entry = encodeURIComponent(`${part2} ${part1}`);
|
||||
await phFetch(`/config/dns/hosts/${entry}`, { method: 'DELETE' });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listZones, listRecords, addRecord, updateRecord, deleteRecord };
|
||||
@@ -0,0 +1,118 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const AUDIT_PATH = process.env.AUDIT_PATH || path.join(__dirname, '..', 'audit-log.json');
|
||||
const MAX_ENTRIES = 500;
|
||||
|
||||
function load() {
|
||||
try { return JSON.parse(fs.readFileSync(AUDIT_PATH, 'utf8')); }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
function save(entries) {
|
||||
fs.writeFileSync(AUDIT_PATH, JSON.stringify(entries, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function append(entry) {
|
||||
const entries = load();
|
||||
entries.unshift(entry);
|
||||
if (entries.length > MAX_ENTRIES) entries.splice(MAX_ENTRIES);
|
||||
save(entries);
|
||||
}
|
||||
|
||||
// ─── DNS record changes ───────────────────────────────────────────────────────
|
||||
|
||||
function log(user, action, provider, zone, record, prev = null) {
|
||||
append({
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
category: 'dns',
|
||||
user: { id: user.id, username: user.username },
|
||||
action,
|
||||
provider,
|
||||
zone,
|
||||
record: {
|
||||
type: record?.type ?? null,
|
||||
name: record?.name ?? null,
|
||||
content: record?.content ?? null,
|
||||
},
|
||||
prev: prev ? {
|
||||
type: prev?.type ?? null,
|
||||
name: prev?.name ?? null,
|
||||
content: prev?.content ?? null,
|
||||
} : null,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Secrets changes ──────────────────────────────────────────────────────────
|
||||
|
||||
function logSecret(user, action, secret, prev = null) {
|
||||
append({
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
category: 'secret',
|
||||
user: { id: user.id, username: user.username },
|
||||
action,
|
||||
target: {
|
||||
name: secret?.name ?? null,
|
||||
type: secret?.type ?? null,
|
||||
expires: secret?.expires_at ?? null,
|
||||
},
|
||||
prev: prev ? {
|
||||
name: prev?.name ?? null,
|
||||
type: prev?.type ?? null,
|
||||
expires: prev?.expires_at ?? null,
|
||||
} : null,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── IPAM changes ─────────────────────────────────────────────────────────────
|
||||
|
||||
function logIpam(user, action, entry, prev = null) {
|
||||
append({
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
category: 'ipam',
|
||||
user: { id: user.id, username: user.username },
|
||||
action,
|
||||
target: {
|
||||
address: entry?.address ?? null,
|
||||
label: entry?.label ?? null,
|
||||
vendor: entry?.vendor ?? null,
|
||||
},
|
||||
prev: prev ? {
|
||||
address: prev?.address ?? null,
|
||||
label: prev?.label ?? null,
|
||||
vendor: prev?.vendor ?? null,
|
||||
} : null,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── User management changes ──────────────────────────────────────────────────
|
||||
|
||||
function logUser(actor, action, targetUser) {
|
||||
append({
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
category: 'user',
|
||||
user: { id: actor.id, username: actor.username },
|
||||
action,
|
||||
target: { id: targetUser?.id ?? null, username: targetUser?.username ?? null },
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Query ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function getEntries({ limit = 100, offset = 0, user, action, provider, category } = {}) {
|
||||
let entries = load();
|
||||
|
||||
if (user) entries = entries.filter(e => e.user.username.toLowerCase().includes(user.toLowerCase()));
|
||||
if (action) entries = entries.filter(e => e.action === action);
|
||||
if (category) entries = entries.filter(e => e.category === category);
|
||||
if (provider) entries = entries.filter(e => e.provider === provider);
|
||||
|
||||
const total = entries.length;
|
||||
return { total, entries: entries.slice(offset, offset + limit) };
|
||||
}
|
||||
|
||||
module.exports = { log, logSecret, logIpam, logUser, getEntries };
|
||||
@@ -0,0 +1,40 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
function getSecret() {
|
||||
if (!process.env.JWT_SECRET) {
|
||||
console.warn('⚠️ JWT_SECRET not set in .env — using an insecure default. Set a strong random secret.');
|
||||
return 'dns-manager-insecure-default-secret';
|
||||
}
|
||||
return process.env.JWT_SECRET;
|
||||
}
|
||||
|
||||
function signToken(user) {
|
||||
return jwt.sign(
|
||||
{ id: user.id, username: user.username },
|
||||
getSecret(),
|
||||
{ expiresIn: process.env.JWT_EXPIRES_IN || '24h' }
|
||||
);
|
||||
}
|
||||
|
||||
function verifyToken(token) {
|
||||
return jwt.verify(token, getSecret());
|
||||
}
|
||||
|
||||
/**
|
||||
* Express middleware — rejects requests without a valid JWT.
|
||||
*/
|
||||
function requireAuth(req, res, next) {
|
||||
const header = req.headers.authorization;
|
||||
if (!header || !header.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
const token = header.slice(7);
|
||||
try {
|
||||
req.user = verifyToken(token);
|
||||
next();
|
||||
} catch {
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { signToken, verifyToken, requireAuth };
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Simple JSON file-based cache for DNS records.
|
||||
* No native dependencies — works on any Node version.
|
||||
*
|
||||
* Structure of dns-cache.json:
|
||||
* {
|
||||
* "cloudflare": {
|
||||
* "zone123": {
|
||||
* "synced_at": "2024-01-01T00:00:00.000Z",
|
||||
* "records": [ { id, type, name, content, ttl, priority }, ... ]
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const CACHE_PATH = process.env.DB_PATH || path.join(__dirname, '..', 'dns-cache.json');
|
||||
|
||||
// ─── Load / save ─────────────────────────────────────────────────────────────
|
||||
|
||||
function load() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(CACHE_PATH, 'utf8'));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function save(data) {
|
||||
fs.writeFileSync(CACHE_PATH, JSON.stringify(data, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
function getRecords(provider, zoneId) {
|
||||
const data = load();
|
||||
return data[provider]?.[zoneId]?.records ?? [];
|
||||
}
|
||||
|
||||
function getSyncedAt(provider, zoneId) {
|
||||
const data = load();
|
||||
return data[provider]?.[zoneId]?.synced_at ?? null;
|
||||
}
|
||||
|
||||
// Returns { [zoneId]: { synced_at, record_count } } for all cached zones of a provider
|
||||
function getProviderSyncStatus(provider) {
|
||||
const data = load();
|
||||
const zones = data[provider] ?? {};
|
||||
const result = {};
|
||||
for (const [zoneId, zone] of Object.entries(zones)) {
|
||||
result[zoneId] = {
|
||||
synced_at: zone.synced_at ?? null,
|
||||
record_count: (zone.records ?? []).length,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getZoneName(provider, zoneId) {
|
||||
const data = load();
|
||||
return data[provider]?.[zoneId]?.zone_name ?? zoneId;
|
||||
}
|
||||
|
||||
function replaceZoneRecords(provider, zoneId, records, zoneName) {
|
||||
const data = load();
|
||||
if (!data[provider]) data[provider] = {};
|
||||
data[provider][zoneId] = {
|
||||
zone_name: zoneName ?? data[provider]?.[zoneId]?.zone_name ?? zoneId,
|
||||
synced_at: new Date().toISOString(),
|
||||
records: records.map(r => ({
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
name: r.name,
|
||||
content: r.content,
|
||||
ttl: r.ttl ?? null,
|
||||
priority: r.priority ?? null,
|
||||
})),
|
||||
};
|
||||
save(data);
|
||||
}
|
||||
|
||||
function upsertRecord(provider, zoneId, record) {
|
||||
const data = load();
|
||||
if (!data[provider]) data[provider] = {};
|
||||
if (!data[provider][zoneId]) data[provider][zoneId] = { synced_at: null, records: [] };
|
||||
|
||||
const records = data[provider][zoneId].records;
|
||||
const idx = records.findIndex(r => r.id === record.id);
|
||||
const entry = {
|
||||
id: record.id,
|
||||
type: record.type,
|
||||
name: record.name,
|
||||
content: record.content,
|
||||
ttl: record.ttl ?? null,
|
||||
priority: record.priority ?? null,
|
||||
};
|
||||
|
||||
if (idx >= 0) records[idx] = entry;
|
||||
else records.push(entry);
|
||||
|
||||
save(data);
|
||||
}
|
||||
|
||||
function deleteRecord(provider, zoneId, recordId) {
|
||||
const data = load();
|
||||
const zone = data[provider]?.[zoneId];
|
||||
if (!zone) return;
|
||||
zone.records = zone.records.filter(r => r.id !== recordId);
|
||||
save(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all cached zones across all providers as a flat list.
|
||||
* Groups duplicate zone names so you can spot the same domain on multiple providers.
|
||||
*/
|
||||
function getAllZones() {
|
||||
const data = load();
|
||||
const byName = {};
|
||||
|
||||
for (const [provider, zones] of Object.entries(data)) {
|
||||
for (const [zoneId, zone] of Object.entries(zones)) {
|
||||
const name = zone.zone_name ?? zoneId;
|
||||
if (!byName[name]) byName[name] = [];
|
||||
byName[name].push({
|
||||
provider,
|
||||
zone_id: zoneId,
|
||||
record_count: (zone.records ?? []).length,
|
||||
synced_at: zone.synced_at ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Object.entries(byName)
|
||||
.map(([name, entries]) => ({ name, entries, duplicate: entries.length > 1 }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function getStats() {
|
||||
const data = load();
|
||||
const typeCounts = {};
|
||||
let totalZones = 0;
|
||||
let zonesWithMx = 0;
|
||||
const perProvider = {};
|
||||
|
||||
for (const [provider, zones] of Object.entries(data)) {
|
||||
const zoneCount = Object.keys(zones).length;
|
||||
totalZones += zoneCount;
|
||||
perProvider[provider] = { zones: zoneCount };
|
||||
|
||||
for (const zone of Object.values(zones)) {
|
||||
const records = zone.records ?? [];
|
||||
const hasMx = records.some(r => r.type === 'MX');
|
||||
if (hasMx) zonesWithMx++;
|
||||
|
||||
for (const r of records) {
|
||||
typeCounts[r.type] = (typeCounts[r.type] || 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const recordTypes = Object.entries(typeCounts)
|
||||
.map(([type, count]) => ({ type, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
return { totalZones, zonesWithMx, recordTypes, perProvider };
|
||||
}
|
||||
|
||||
module.exports = { getRecords, getSyncedAt, getZoneName, getProviderSyncStatus, getAllZones, replaceZoneRecords, upsertRecord, deleteRecord, getStats };
|
||||
@@ -0,0 +1,117 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
|
||||
const { requireAuth } = require('./auth');
|
||||
const { ensureDefaultAdmin } = require('./users');
|
||||
const audit = require('./audit');
|
||||
const secrets = require('./secrets');
|
||||
const schedule = require('node-schedule');
|
||||
|
||||
const zonesRouter = require('./routes/zones');
|
||||
const secretsRouter = require('./routes/secrets');
|
||||
const ipamRouter = require('./routes/ipam');
|
||||
const healthRouter = require('./routes/health');
|
||||
const recordsRouter = require('./routes/records');
|
||||
const settingsRouter = require('./routes/settings');
|
||||
const authRouter = require('./routes/auth');
|
||||
const usersRouter = require('./routes/users');
|
||||
const db = require('./db');
|
||||
|
||||
// Create default admin user if none exist
|
||||
ensureDefaultAdmin();
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// ─── Public routes (no auth required) ────────────────────────────────────────
|
||||
app.use('/api/auth', authRouter);
|
||||
|
||||
// ─── Protected routes (JWT required) ─────────────────────────────────────────
|
||||
app.use('/api/zones', requireAuth, zonesRouter);
|
||||
app.use('/api/records', requireAuth, recordsRouter);
|
||||
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.get('/api/audit', requireAuth, (req, res) => {
|
||||
const { limit, offset, user, action, provider, category } = req.query;
|
||||
res.json(audit.getEntries({
|
||||
limit: Math.min(parseInt(limit) || 100, 500),
|
||||
offset: parseInt(offset) || 0,
|
||||
user, action, provider, category,
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/api/sync-status/:provider', requireAuth, (req, res) => {
|
||||
res.json(db.getProviderSyncStatus(req.params.provider));
|
||||
});
|
||||
|
||||
app.get('/api/domains', requireAuth, (req, res) => {
|
||||
res.json(db.getAllZones());
|
||||
});
|
||||
|
||||
app.get('/api/stats', requireAuth, (req, res) => {
|
||||
res.json(db.getStats());
|
||||
});
|
||||
|
||||
app.get('/api/providers', requireAuth, (req, res) => {
|
||||
const providers = [];
|
||||
const disabled = new Set((process.env.DISABLED_PROVIDERS ?? '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean));
|
||||
|
||||
if (!disabled.has('cloudflare') && process.env.CLOUDFLARE_API_TOKEN) providers.push({ id: 'cloudflare', name: 'Cloudflare' });
|
||||
if (!disabled.has('loopia') && process.env.LOOPIA_USER && process.env.LOOPIA_PASSWORD) providers.push({ id: 'loopia', name: 'Loopia' });
|
||||
if (!disabled.has('pihole') && process.env.PIHOLE_URL && process.env.PIHOLE_PASSWORD) providers.push({ id: 'pihole', name: 'Pi-hole', url: process.env.PIHOLE_URL });
|
||||
if (!disabled.has('azure') && process.env.AZURE_TENANT_ID && process.env.AZURE_CLIENT_ID && process.env.AZURE_CLIENT_SECRET && process.env.AZURE_SUBSCRIPTION_ID) providers.push({ id: 'azure', name: 'Azure DNS' });
|
||||
if (!disabled.has('cpanel') && process.env.CPANEL_URL && process.env.CPANEL_USERNAME && process.env.CPANEL_API_TOKEN) providers.push({ id: 'cpanel', name: 'cPanel', url: process.env.CPANEL_URL });
|
||||
res.json(providers);
|
||||
});
|
||||
|
||||
// ─── Daily secrets expiry check (runs at 08:00 every day) ────────────────────
|
||||
const { notify } = require('./notify');
|
||||
|
||||
const LAST_CHECK_PATH = require('path').join(__dirname, '..', '.last-secret-check');
|
||||
|
||||
function getLastCheckDate() {
|
||||
try { return require('fs').readFileSync(LAST_CHECK_PATH, 'utf8').trim(); } catch { return null; }
|
||||
}
|
||||
|
||||
function saveLastCheckDate(date) {
|
||||
require('fs').writeFileSync(LAST_CHECK_PATH, date, 'utf8');
|
||||
}
|
||||
|
||||
async function checkSecretExpiry() {
|
||||
const expiring = secrets.getExpiring();
|
||||
if (expiring.length === 0) return;
|
||||
|
||||
const lines = expiring.map(s =>
|
||||
s.status === 'expired'
|
||||
? `✕ EXPIRED — ${s.name}`
|
||||
: `⚠ ${s.daysLeft}d left — ${s.name}`
|
||||
);
|
||||
|
||||
await notify(
|
||||
`🦥 Sloth Manager — Secrets Alert`,
|
||||
`${expiring.length} secret${expiring.length !== 1 ? 's' : ''} need attention:\n\n${lines.join('\n')}`
|
||||
);
|
||||
}
|
||||
|
||||
async function checkSecretExpiryOnce() {
|
||||
const today = new Date().toDateString();
|
||||
if (getLastCheckDate() === today) return; // already ran today, persisted to disk
|
||||
saveLastCheckDate(today);
|
||||
await checkSecretExpiry();
|
||||
}
|
||||
|
||||
// Run at startup only if not already checked today (persisted to disk), then every day at 08:00
|
||||
checkSecretExpiryOnce();
|
||||
schedule.scheduleJob('0 8 * * *', () => {
|
||||
saveLastCheckDate(''); // clear so the scheduled job always fires
|
||||
checkSecretExpiry();
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 3001;
|
||||
app.listen(PORT, () => console.log(`Sloth Manager backend running on port ${PORT}`));
|
||||
@@ -0,0 +1,95 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const IPAM_PATH = process.env.IPAM_PATH || path.join(__dirname, '..', 'ipam.json');
|
||||
const CACHE_PATH = process.env.DB_PATH || path.join(__dirname, '..', 'dns-cache.json');
|
||||
|
||||
function load() {
|
||||
try { return JSON.parse(fs.readFileSync(IPAM_PATH, 'utf8')); }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
function save(entries) {
|
||||
fs.writeFileSync(IPAM_PATH, JSON.stringify(entries, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the DNS cache for A/AAAA records whose content matches a given IP.
|
||||
*/
|
||||
function findDnsMatches(address) {
|
||||
const matches = [];
|
||||
let cache = {};
|
||||
try { cache = JSON.parse(fs.readFileSync(CACHE_PATH, 'utf8')); } catch { }
|
||||
|
||||
for (const [provider, zones] of Object.entries(cache)) {
|
||||
for (const [zoneId, zone] of Object.entries(zones)) {
|
||||
const zoneName = zone.zone_name ?? zoneId;
|
||||
for (const record of zone.records ?? []) {
|
||||
if (['A', 'AAAA'].includes(record.type) && record.content === address) {
|
||||
// Ensure we always show the full FQDN, not just a relative label
|
||||
const fullName = record.name && record.name.includes('.')
|
||||
? record.name
|
||||
: record.name
|
||||
? `${record.name}.${zoneName}`
|
||||
: zoneName;
|
||||
matches.push({ provider, zone: zoneName, type: record.type, name: fullName });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
function getAll() {
|
||||
return load().map(e => ({ ...e, dnsMatches: findDnsMatches(e.address) }));
|
||||
}
|
||||
|
||||
function getById(id) {
|
||||
const entry = load().find(e => e.id === id);
|
||||
if (!entry) return null;
|
||||
return { ...entry, dnsMatches: findDnsMatches(entry.address) };
|
||||
}
|
||||
|
||||
function create(data) {
|
||||
const entries = load();
|
||||
if (!data.address) throw new Error('IP address is required');
|
||||
const entry = {
|
||||
id: Date.now().toString(),
|
||||
address: data.address.trim(),
|
||||
label: data.label || '',
|
||||
vendor: data.vendor || '',
|
||||
location: data.location || '',
|
||||
notes: data.notes || '',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
entries.push(entry);
|
||||
save(entries);
|
||||
return { ...entry, dnsMatches: findDnsMatches(entry.address) };
|
||||
}
|
||||
|
||||
function update(id, data) {
|
||||
const entries = load();
|
||||
const idx = entries.findIndex(e => e.id === id);
|
||||
if (idx === -1) throw new Error('Entry not found');
|
||||
entries[idx] = {
|
||||
...entries[idx],
|
||||
address: data.address !== undefined ? data.address.trim() : entries[idx].address,
|
||||
label: data.label !== undefined ? data.label : entries[idx].label,
|
||||
vendor: data.vendor !== undefined ? data.vendor : entries[idx].vendor,
|
||||
location: data.location !== undefined ? data.location : entries[idx].location,
|
||||
notes: data.notes !== undefined ? data.notes : entries[idx].notes,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
save(entries);
|
||||
return { ...entries[idx], dnsMatches: findDnsMatches(entries[idx].address) };
|
||||
}
|
||||
|
||||
function remove(id) {
|
||||
const entries = load();
|
||||
const filtered = entries.filter(e => e.id !== id);
|
||||
if (filtered.length === entries.length) throw new Error('Entry not found');
|
||||
save(filtered);
|
||||
}
|
||||
|
||||
module.exports = { getAll, getById, create, update, remove };
|
||||
@@ -0,0 +1,61 @@
|
||||
const settings = require('./settings');
|
||||
const db = require('./db');
|
||||
|
||||
/**
|
||||
* Send a Gotify notification if enabled and configured.
|
||||
* Errors are logged but never thrown — notifications are best-effort.
|
||||
*/
|
||||
async function notify(title, message) {
|
||||
const { gotify } = settings.get();
|
||||
|
||||
if (!gotify.enabled || !gotify.url || !gotify.token) return;
|
||||
|
||||
const base = gotify.url.replace(/\/$/, '');
|
||||
|
||||
try {
|
||||
const res = await fetch(`${base}/message?token=${encodeURIComponent(gotify.token)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
message,
|
||||
priority: gotify.priority ?? 5,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
console.error(`[notify] Gotify error ${res.status}: ${text}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[notify] Failed to send Gotify notification:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Convenience helpers ─────────────────────────────────────────────────────
|
||||
|
||||
function recordAdded(provider, zoneId, record) {
|
||||
const zoneName = db.getZoneName(provider, zoneId);
|
||||
return notify(
|
||||
`DNS Record Added`,
|
||||
`[${provider}] ${zoneName}\n+ ${record.type} ${record.name} → ${record.content}`,
|
||||
);
|
||||
}
|
||||
|
||||
function recordUpdated(provider, zoneId, oldRecord, newRecord) {
|
||||
const zoneName = db.getZoneName(provider, zoneId);
|
||||
return notify(
|
||||
`DNS Record Updated`,
|
||||
`[${provider}] ${zoneName}\n✎ ${oldRecord.type} ${oldRecord.name}\n ${oldRecord.content} → ${newRecord.content}`,
|
||||
);
|
||||
}
|
||||
|
||||
function recordDeleted(provider, zoneId, record) {
|
||||
const zoneName = db.getZoneName(provider, zoneId);
|
||||
return notify(
|
||||
`DNS Record Deleted`,
|
||||
`[${provider}] ${zoneName}\n− ${record.type} ${record.name} ${record.content}`,
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = { notify, recordAdded, recordUpdated, recordDeleted };
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,97 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SECRETS_PATH = process.env.SECRETS_PATH || path.join(__dirname, '..', 'secrets.json');
|
||||
|
||||
const SECRET_TYPES = ['api_token', 'ssl_certificate', 'password', 'generic'];
|
||||
|
||||
function load() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(SECRETS_PATH, 'utf8'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function save(secrets) {
|
||||
fs.writeFileSync(SECRETS_PATH, JSON.stringify(secrets, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function getAll() {
|
||||
return load();
|
||||
}
|
||||
|
||||
function getById(id) {
|
||||
return load().find(s => s.id === id) ?? null;
|
||||
}
|
||||
|
||||
function create(data) {
|
||||
const secrets = load();
|
||||
const secret = {
|
||||
id: Date.now().toString(),
|
||||
name: data.name,
|
||||
type: data.type || 'generic',
|
||||
description: data.description || '',
|
||||
expires_at: data.expires_at,
|
||||
warning_days: Number(data.warning_days) || 30,
|
||||
notes: data.notes || '',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
secrets.push(secret);
|
||||
save(secrets);
|
||||
return secret;
|
||||
}
|
||||
|
||||
function update(id, data) {
|
||||
const secrets = load();
|
||||
const idx = secrets.findIndex(s => s.id === id);
|
||||
if (idx === -1) throw new Error('Secret not found');
|
||||
secrets[idx] = {
|
||||
...secrets[idx],
|
||||
name: data.name ?? secrets[idx].name,
|
||||
type: data.type ?? secrets[idx].type,
|
||||
description: data.description ?? secrets[idx].description,
|
||||
expires_at: data.expires_at ?? secrets[idx].expires_at,
|
||||
warning_days: data.warning_days !== undefined ? Number(data.warning_days) : secrets[idx].warning_days,
|
||||
notes: data.notes ?? secrets[idx].notes,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
save(secrets);
|
||||
return secrets[idx];
|
||||
}
|
||||
|
||||
function remove(id) {
|
||||
const secrets = load();
|
||||
const filtered = secrets.filter(s => s.id !== id);
|
||||
if (filtered.length === secrets.length) throw new Error('Secret not found');
|
||||
save(filtered);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns secrets grouped by expiry status.
|
||||
* Status: 'expired' | 'warning' | 'ok'
|
||||
*/
|
||||
function getStatus() {
|
||||
const secrets = load();
|
||||
const now = new Date();
|
||||
|
||||
return secrets.map(s => {
|
||||
const expiry = new Date(s.expires_at);
|
||||
const daysLeft = Math.ceil((expiry - now) / (1000 * 60 * 60 * 24));
|
||||
let status;
|
||||
if (daysLeft < 0) status = 'expired';
|
||||
else if (daysLeft <= s.warning_days) status = 'warning';
|
||||
else status = 'ok';
|
||||
return { ...s, daysLeft, status };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns secrets that are expired or within their warning window.
|
||||
*/
|
||||
function getExpiring() {
|
||||
return getStatus().filter(s => s.status !== 'ok');
|
||||
}
|
||||
|
||||
module.exports = { SECRET_TYPES, getAll, getById, create, update, remove, getStatus, getExpiring };
|
||||
@@ -0,0 +1,57 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SETTINGS_PATH = process.env.SETTINGS_PATH || path.join(__dirname, '..', 'settings.json');
|
||||
|
||||
const DEFAULTS = {
|
||||
gotify: {
|
||||
enabled: false,
|
||||
url: '',
|
||||
token: '',
|
||||
priority: 5,
|
||||
},
|
||||
providerColors: {
|
||||
cloudflare: '#f6821f',
|
||||
loopia: '#2ecc71',
|
||||
pihole: '#96060c',
|
||||
azure: '#0078d4',
|
||||
cpanel: '#ff6c2c',
|
||||
},
|
||||
};
|
||||
|
||||
function load() {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf8'));
|
||||
// Deep merge with defaults so new keys are always present
|
||||
return {
|
||||
...DEFAULTS,
|
||||
...raw,
|
||||
gotify: { ...DEFAULTS.gotify, ...(raw.gotify ?? {}) },
|
||||
providerColors: { ...DEFAULTS.providerColors, ...(raw.providerColors ?? {}) },
|
||||
};
|
||||
} catch {
|
||||
return structuredClone(DEFAULTS);
|
||||
}
|
||||
}
|
||||
|
||||
function save(settings) {
|
||||
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function get() {
|
||||
return load();
|
||||
}
|
||||
|
||||
function update(partial) {
|
||||
const current = load();
|
||||
const merged = {
|
||||
...current,
|
||||
...partial,
|
||||
gotify: { ...current.gotify, ...(partial.gotify ?? {}) },
|
||||
providerColors: { ...current.providerColors, ...(partial.providerColors ?? {}) },
|
||||
};
|
||||
save(merged);
|
||||
return merged;
|
||||
}
|
||||
|
||||
module.exports = { get, update };
|
||||
@@ -0,0 +1,84 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const USERS_PATH = process.env.USERS_PATH || path.join(__dirname, '..', 'users.json');
|
||||
|
||||
function load() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(USERS_PATH, 'utf8'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function save(users) {
|
||||
fs.writeFileSync(USERS_PATH, JSON.stringify(users, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* On first run, create a default admin user if no users exist.
|
||||
* Prints a warning to remind the user to change the password.
|
||||
*/
|
||||
function ensureDefaultAdmin() {
|
||||
const users = load();
|
||||
if (users.length === 0) {
|
||||
const hash = bcrypt.hashSync('admin', 10);
|
||||
save([{ id: '1', username: 'admin', passwordHash: hash, createdAt: new Date().toISOString() }]);
|
||||
console.warn('⚠️ No users found — created default admin account.');
|
||||
console.warn('⚠️ Username: admin Password: admin');
|
||||
console.warn('⚠️ Change this password immediately in Settings → Users.');
|
||||
}
|
||||
}
|
||||
|
||||
function getAll() {
|
||||
return load().map(({ passwordHash, ...u }) => u); // strip hash from output
|
||||
}
|
||||
|
||||
function findByUsername(username) {
|
||||
return load().find(u => u.username.toLowerCase() === username.toLowerCase()) ?? null;
|
||||
}
|
||||
|
||||
function findById(id) {
|
||||
return load().find(u => u.id === id) ?? null;
|
||||
}
|
||||
|
||||
function create(username, password) {
|
||||
const users = load();
|
||||
if (users.find(u => u.username.toLowerCase() === username.toLowerCase())) {
|
||||
throw new Error('Username already exists');
|
||||
}
|
||||
const hash = bcrypt.hashSync(password, 10);
|
||||
const user = {
|
||||
id: Date.now().toString(),
|
||||
username,
|
||||
passwordHash: hash,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
users.push(user);
|
||||
save(users);
|
||||
const { passwordHash, ...safe } = user;
|
||||
return safe;
|
||||
}
|
||||
|
||||
function updatePassword(id, newPassword) {
|
||||
const users = load();
|
||||
const idx = users.findIndex(u => u.id === id);
|
||||
if (idx === -1) throw new Error('User not found');
|
||||
users[idx].passwordHash = bcrypt.hashSync(newPassword, 10);
|
||||
save(users);
|
||||
}
|
||||
|
||||
function remove(id) {
|
||||
const users = load();
|
||||
if (users.length === 1) throw new Error('Cannot delete the last user');
|
||||
const filtered = users.filter(u => u.id !== id);
|
||||
if (filtered.length === users.length) throw new Error('User not found');
|
||||
save(filtered);
|
||||
}
|
||||
|
||||
function verifyPassword(user, password) {
|
||||
return bcrypt.compareSync(password, user.passwordHash);
|
||||
}
|
||||
|
||||
module.exports = { ensureDefaultAdmin, getAll, findByUsername, findById, create, updatePassword, remove, verifyPassword };
|
||||
Reference in New Issue
Block a user