One endpoint, hub/servers/run (operate), runs a fixed set of actions on the chosen servers in parallel with each server's admin token: add or remove a domain in Blocked or Allowed, disable blocking for a time or enable it, update block lists, replace the block list URLs, flush the cache or delete a domain from it. Each server gets its own result and the console offers Retry failed. In "All servers", Blocking, Cache and Query Logs rows open this flow instead of being disabled. The hub validates domains, minutes and the server's list grammar (allow lists with !, switched-off lines with #) and logs no URL list. Operators can now use the blocking switch. Co-Authored-By: Claude Opus 5.5 <[email protected]>
397 lines
38 KiB
JavaScript
397 lines
38 KiB
JavaScript
#!/usr/bin/env node
|
|
/*
|
|
A stand-in for the Technitium API, with made-up data, to look at and click through the
|
|
whole console without a server and without Docker.
|
|
|
|
node dev/mock-api.mjs # listens on 127.0.0.1:5380
|
|
node dev/mock-api.mjs --port 5381 --scale 2 # another port; counters and hits times 2
|
|
npm run dev # the console, proxying /api to it
|
|
|
|
Any user and password log in. It answers the endpoints the screens call, keeps what you
|
|
change in memory (enable, disable and delete a zone, a scope, a user, a rule; flush the
|
|
cache; sign a session out) and forgets it all when it stops. Nothing here is the server's
|
|
behaviour: it exists so a design can be seen with data in it. The real harness is
|
|
`dev/compose.yaml`.
|
|
*/
|
|
import http from 'node:http'
|
|
|
|
// `--port <n>`, `--scale <x>` and `--variant a|b`: two instances with different numbers (and apps) stand in for two servers.
|
|
const arg = (name) => { const i = process.argv.indexOf(`--${name}`); return i >= 0 ? process.argv[i + 1] : undefined }
|
|
const PORT = Number(arg('port') ?? process.env.PORT ?? 5380)
|
|
const SCALE = Number(arg('scale') ?? 1)
|
|
// `--variant b`: the second server of the Apps matrix (a missing app, another version, other config fields).
|
|
const VARIANT = arg('variant') === 'b' ? 'b' : 'a'
|
|
const now = Date.now()
|
|
const iso = (ms) => new Date(ms).toISOString()
|
|
const ago = (min) => iso(now - min * 60_000)
|
|
const pick = (list, i) => list[i % list.length]
|
|
|
|
// ── Session ────────────────────────────────────────────────────────────────
|
|
const SECTIONS = ['Dashboard', 'Zones', 'Cache', 'Allowed', 'Blocked', 'Apps', 'DnsClient', 'Settings', 'DhcpServer', 'Administration', 'Logs']
|
|
const permissions = Object.fromEntries(SECTIONS.map((k) => [k, { canView: true, canModify: true, canDelete: true }]))
|
|
const session = {
|
|
status: 'ok', token: 'mock-token', username: 'admin', displayName: 'Administrator', type: 'Local', totpEnabled: false,
|
|
info: {
|
|
version: '15.5', uptimestamp: ago(60 * 24 * 9), dnsServerDomain: 'dns.example.net', permissions,
|
|
clusterInitialized: false, defaultRecordTtl: 3600, defaultNsRecordTtl: 3600,
|
|
},
|
|
}
|
|
|
|
// ── Dashboard ──────────────────────────────────────────────────────────────
|
|
const RANGES = {
|
|
LastHour: { n: 60, step: 60_000, scale: 1 / 60 },
|
|
LastDay: { n: 24, step: 3_600_000, scale: 1 },
|
|
LastWeek: { n: 7 * 24, step: 3_600_000, scale: 1 },
|
|
LastMonth: { n: 30, step: 86_400_000, scale: 24 },
|
|
LastYear: { n: 12, step: 30 * 86_400_000, scale: 24 * 30 },
|
|
}
|
|
const SERIES = [
|
|
['Total', 52000], ['No Error', 50100], ['Server Failure', 40], ['NX Domain', 1600], ['Refused', 180],
|
|
['Authoritative', 4000], ['Recursive', 17000], ['Cached', 25200], ['Blocked', 7800], ['Dropped', 5], ['Clients', 128],
|
|
]
|
|
function dashboard(type) {
|
|
const r = RANGES[type] ?? RANGES.LastDay
|
|
const instants = Array.from({ length: r.n }, (_, i) => iso(now - (r.n - 1 - i) * r.step))
|
|
const wave = (base, i) => Math.max(0, Math.round(SCALE * base * r.scale * (1 + 0.35 * Math.sin(i / 3.2) + 0.15 * Math.cos(i / 1.7))))
|
|
const totals = Object.fromEntries(SERIES.map(([name, base]) => [name, instants.reduce((a, _, i) => a + wave(base, i), 0)]))
|
|
const stats = {
|
|
totalQueries: totals.Total, totalNoError: totals['No Error'], totalServerFailure: totals['Server Failure'], totalNxDomain: totals['NX Domain'],
|
|
totalRefused: totals.Refused, totalAuthoritative: totals.Authoritative, totalRecursive: totals.Recursive, totalCached: totals.Cached,
|
|
totalBlocked: totals.Blocked, totalDropped: totals.Dropped, totalClients: Math.round(128 * SCALE), zones: zones.length, cachedEntries: 48210,
|
|
allowedZones: allowed.size, blockedZones: blocked.size, allowListZones: 1, blockListZones: 1482903,
|
|
}
|
|
return {
|
|
stats,
|
|
mainChartData: { labelFormat: 'HH:mm', labels: instants, datasets: SERIES.map(([label, base]) => ({ label, data: instants.map((_, i) => wave(base, i)) })) },
|
|
queryResponseChartData: { labels: ['Authoritative', 'Recursive', 'Cached', 'Blocked', 'Dropped'], datasets: [{ label: 'Total', data: [totals.Authoritative, totals.Recursive, totals.Cached, totals.Blocked, totals.Dropped], backgroundColor: [] }] },
|
|
queryTypeChartData: { labels: ['A', 'AAAA', 'HTTPS', 'PTR', 'TXT', 'MX'], datasets: [{ label: 'Total', data: [48, 27, 12, 6, 4, 3].map((p) => Math.round((totals.Total * p) / 100)), backgroundColor: [] }] },
|
|
protocolTypeChartData: { labels: ['UDP', 'TCP', 'TLS', 'HTTPS'], datasets: [{ label: 'Total', data: [82, 9, 6, 3].map((p) => Math.round((totals.Total * p) / 100)), backgroundColor: [] }] },
|
|
topClients: top('clients'), topDomains: top('domains'), topBlockedDomains: top('blocked'),
|
|
}
|
|
}
|
|
const CLIENTS = [['192.168.10.14', 'laptop-anna.lan'], ['192.168.10.31', 'tv-livingroom.lan'], ['10.8.0.2', 'vpn-gateway'], ['192.168.10.77', 'phone-ivan.lan'], ['192.168.10.52', 'printer-office.lan'], ['192.168.10.9', 'nas.lan'], ['10.8.0.7', 'vpn-phone'], ['192.168.20.4', 'guest-tablet'], ['192.168.10.66', 'desktop-pavel.lan'], ['fd00::1a2b', 'router.lan']]
|
|
const DOMAINS = ['connectivitycheck.gstatic.com', 'time.cloudflare.com', 'api.github.com', 'updates.example.net', 'www.example.net', 'mail.example.net', 'cdn.jsdelivr.net', 'fonts.googleapis.com', 'api.telegram.org', 'ocsp.digicert.com']
|
|
const BLOCKED = ['ads.tracker-example.com', 'telemetry.vendor-example.io', 'pixel.metrics-example.net', 'cdn.adserver-example.org', 'beacon.analytics-example.com', 'stats.adnetwork-example.net', 'track.shop-example.org', 'ads2.tracker-example.com', 'collect.data-example.io', 'sdk.ads-example.com']
|
|
function top(kind) {
|
|
if (kind === 'clients') return CLIENTS.map(([name, domain], i) => ({ name, domain: i === 3 ? DOMAINS[i] : DOMAINS[(i * 3) % 10], hits: Math.round((SCALE * 84210) / (1 + i * 0.55)), ...(i === 7 ? { rateLimited: true } : {}) }))
|
|
const list = kind === 'domains' ? DOMAINS : BLOCKED
|
|
return list.map((name, i) => ({ name, hits: Math.round((SCALE * (kind === 'domains' ? 22480 : 9804)) / (1 + i * 0.5)) }))
|
|
}
|
|
|
|
// ── Zones ──────────────────────────────────────────────────────────────────
|
|
let zones = [
|
|
{ name: 'example.net', type: 'Primary', dnssecStatus: 'SignedWithNSEC3', hasDnssecPrivateKeys: true, soaSerial: 2026102601, disabled: false, lastModified: ago(60 * 5) },
|
|
{ name: 'lan', type: 'Primary', dnssecStatus: 'Unsigned', soaSerial: 41, disabled: false, lastModified: ago(60 * 24 * 3) },
|
|
{ name: '10.in-addr.arpa', type: 'Primary', dnssecStatus: 'Unsigned', soaSerial: 7, disabled: false, lastModified: ago(60 * 24 * 12) },
|
|
{ name: 'partner.example.org', type: 'Secondary', dnssecStatus: 'Unsigned', soaSerial: 5, disabled: false, syncFailed: true, expiry: iso(now + 6 * 86_400_000), lastModified: ago(60 * 7) },
|
|
{ name: 'corp.example.com', type: 'Forwarder', dnssecStatus: 'Unsigned', disabled: true, lastModified: ago(60 * 24 * 30) },
|
|
{ name: 'stub.example.org', type: 'Stub', dnssecStatus: 'Unsigned', soaSerial: 2, disabled: false, expiry: iso(now + 20 * 86_400_000), lastModified: ago(60 * 24 * 2) },
|
|
{ name: 'catalog.example.net', type: 'Catalog', dnssecStatus: 'Unsigned', soaSerial: 3, disabled: false, lastModified: ago(60 * 24 * 40) },
|
|
{ name: 'home.example.net', type: 'Primary', dnssecStatus: 'Unsigned', soaSerial: 18, disabled: false, catalog: 'catalog.example.net', lastModified: ago(60 * 24 * 6) },
|
|
{ name: 'dev.example.net', type: 'Primary', dnssecStatus: 'SignedWithNSEC', hasDnssecPrivateKeys: false, soaSerial: 9, disabled: false, lastModified: ago(60 * 24 * 11) },
|
|
{ name: 'old.example.com', type: 'Secondary', dnssecStatus: 'Unsigned', soaSerial: 1, disabled: false, isExpired: true, expiry: iso(now - 86_400_000), lastModified: ago(60 * 24 * 60) },
|
|
{ name: 'vpn.example.net', type: 'Primary', dnssecStatus: 'Unsigned', soaSerial: 12, disabled: false, notifyFailed: true, notifyFailedFor: ['ns2.example.net'], lastModified: ago(60 * 24 * 9) },
|
|
{ name: 'staging.example.net', type: 'Primary', dnssecStatus: 'Unsigned', soaSerial: 4, disabled: true, lastModified: ago(60 * 24 * 20) },
|
|
].map((z) => ({ notifyFailed: false, notifyFailedFor: [], ...z }))
|
|
|
|
function recordsFor(zone) {
|
|
const z = zone.name
|
|
const rec = (name, type, ttl, rData) => ({ name, type, ttl, ttlString: `${ttl} (${ttl >= 3600 ? ttl / 3600 + ' hours' : ttl / 60 + ' minutes'})`, disabled: false, rData, dnssecStatus: 'Disabled', lastUsedOn: ago(40), lastModified: ago(60 * 24 * 4), expiryTtl: 0, expiryTtlString: '' })
|
|
const list = [
|
|
rec(z, 'SOA', 900, { primaryNameServer: `ns1.${z}`, responsiblePerson: `hostmaster.${z}`, serial: zone.soaSerial ?? 1, refresh: 900, retry: 300, expire: 604800, minimum: 900 }),
|
|
rec(z, 'NS', 3600, { nameServer: `ns1.${z}` }),
|
|
rec(z, 'NS', 3600, { nameServer: `ns2.${z}` }),
|
|
rec(z, 'A', 300, { ipAddress: '203.0.113.10' }),
|
|
rec(z, 'AAAA', 300, { ipAddress: '2001:db8::10' }),
|
|
rec(`www.${z}`, 'CNAME', 300, { cname: `${z}.` }),
|
|
rec(`mail.${z}`, 'A', 300, { ipAddress: '203.0.113.25' }),
|
|
rec(z, 'MX', 3600, { preference: 10, exchange: `mail.${z}` }),
|
|
rec(z, 'TXT', 3600, { text: 'v=spf1 mx -all', splitText: false }),
|
|
rec(`_dmarc.${z}`, 'TXT', 3600, { text: 'v=DMARC1; p=quarantine', splitText: false }),
|
|
]
|
|
list[8].comments = 'SPF'
|
|
return list
|
|
}
|
|
|
|
// ── Cache and rules ────────────────────────────────────────────────────────
|
|
const cacheTree = {
|
|
'': ['com', 'net', 'org', 'io', 'arpa'],
|
|
com: ['google', 'github', 'cloudflare', 'example'], net: ['example', 'cloudfront'], org: ['example', 'wikipedia'], io: ['vendor-example'],
|
|
}
|
|
const cacheRecords = (domain) =>
|
|
domain === ''
|
|
? []
|
|
: [
|
|
{ name: domain, type: 'A', ttl: 3412, ttlString: '3412 (56 minutes)', rData: { ipAddress: '93.184.216.34' }, dnssecStatus: 'Disabled', lastUsedOn: ago(5) },
|
|
{ name: domain, type: 'AAAA', ttl: 3412, ttlString: '3412 (56 minutes)', rData: { ipAddress: '2606:2800:220:1:248:1893:25c8:1946' }, dnssecStatus: 'Disabled', lastUsedOn: ago(5) },
|
|
]
|
|
const allowed = new Set(['updates.example.net', 'api.github.com', 'time.cloudflare.com'])
|
|
const blocked = new Set(['ads.tracker-example.com', 'telemetry.vendor-example.io', 'pixel.metrics-example.net', 'cdn.adserver-example.org', 'beacon.analytics-example.com'])
|
|
const listNode = (set, domain) => {
|
|
const all = [...set].sort()
|
|
return { domain, zones: domain === '' ? all : [], records: domain !== '' && set.has(domain) ? [{ name: domain, type: 'SOA', ttl: 60, rData: {}, dnssecStatus: 'Disabled' }] : [] }
|
|
}
|
|
|
|
// ── Settings ───────────────────────────────────────────────────────────────
|
|
const settings = {
|
|
version: '15.5', uptimestamp: ago(60 * 24 * 9), clusterInitialized: false,
|
|
dnsServerDomain: 'dns.example.net', dnsServerLocalEndPoints: ['0.0.0.0:53', '[::]:53'], dnsServerIPv4SourceAddresses: null, dnsServerIPv6SourceAddresses: null,
|
|
defaultRecordTtl: 3600, defaultNsRecordTtl: 3600, defaultSoaRecordTtl: 900, defaultResponsiblePerson: null, useSoaSerialDateScheme: true,
|
|
minSoaRefresh: 300, minSoaRetry: 300, zoneTransferAllowedNetworks: ['10.0.0.0/8', '192.168.0.0/16'], notifyAllowedNetworks: [],
|
|
dnsServerEnableCheckForUpdate: true, dnsAppsEnableAutomaticUpdate: true,
|
|
ipv6Mode: 'Enabled', preferIPv6: false, enableUdpSocketPool: true, socketPoolExcludedPorts: [], udpPayloadSize: 1232, dnssecValidation: true,
|
|
eDnsClientSubnet: false, eDnsClientSubnetIPv4PrefixLength: 24, eDnsClientSubnetIPv6PrefixLength: 56, eDnsClientSubnetIpv4Override: null, eDnsClientSubnetIpv6Override: null,
|
|
qpmPrefixLimitsIPv4: [{ prefix: 32, udpLimit: 600, tcpLimit: 600 }, { prefix: 24, udpLimit: 6000, tcpLimit: 6000 }], qpmPrefixLimitsIPv6: [{ prefix: 128, udpLimit: 600, tcpLimit: 600 }],
|
|
qpmLimitSampleMinutes: 5, qpmLimitUdpTruncationPercentage: 50, qpmLimitBypassList: [],
|
|
clientTimeout: 4000, tcpSendTimeout: 10000, tcpReceiveTimeout: 10000, quicIdleTimeout: 60000, quicMaxInboundStreams: 100, listenBacklog: 100,
|
|
udpSendBufferSizeKB: 0, udpReceiveBufferSizeKB: 0, maxConcurrentResolutionsPerCore: 100,
|
|
webServiceLocalAddresses: ['0.0.0.0', '[::]'], webServiceHttpPort: 5380, webServiceEnableHttpUnixSocket: false, webServiceHttpUnixSocket: null,
|
|
webServiceEnableTlsUnixSocket: false, webServiceTlsUnixSocket: null, webServiceEnableTls: false, webServiceEnableHttp3: false, webServiceHttpToTlsRedirect: false,
|
|
webServiceUseSelfSignedTlsCertificate: false, webServiceTlsPort: 53443, webServiceReverseProxyAddresses: [], webServiceRealIpHeader: 'X-Real-IP',
|
|
webServiceCspFrameAncestorsHeader: "'self'", webServiceTlsCertificatePath: null, webServiceTlsCertificatePassword: null,
|
|
enableEDnsClientSubnetSourceAddress: false, enableDnsOverUdpProxy: false, enableDnsOverTcpProxy: false, enableDnsOverHttp: false, enableDnsOverHttpUnixSocket: false,
|
|
enableDnsOverHttpsUnixSocket: false, enableDnsOverTls: true, enableDnsOverHttps: true, enableDnsOverHttp3: false, enableDnsOverQuic: false, enableDnsOverHttpHelpRedirect: true,
|
|
dnsOverUdpProxyPort: 538, dnsOverTcpProxyPort: 538, dnsOverHttpPort: 80, dnsOverHttpUnixSocket: null, dnsOverHttpsUnixSocket: null, dnsOverTlsPort: 853, dnsOverHttpsPort: 443,
|
|
dnsOverQuicPort: 853, dnsReverseProxyNetworkACL: [], dnsOverHttpRealIpHeader: 'X-Real-IP', dnsTlsCertificatePath: '/etc/dns/cert.pfx', dnsTlsCertificatePassword: '************',
|
|
tsigKeys: [{ keyName: 'transfer.example.net', sharedSecret: 'c2VjcmV0LXRyYW5zZmVy', algorithmName: 'hmac-sha256' }],
|
|
recursion: 'AllowOnlyForPrivateNetworks', recursionNetworkACL: ['!192.168.10.0/24', '192.168.0.0/16'], randomizeName: true, qnameMinimization: true,
|
|
locallyServedDnsZones: true, resolverRetries: 2, resolverTimeout: 1500, resolverConcurrency: 2, resolverMaxStackCount: 16,
|
|
saveCache: false, serveStale: true, serveStaleTtl: 259200, serveStaleAnswerTtl: 30, serveStaleResetTtl: 30, serveStaleMaxWaitTime: 1800,
|
|
cacheMaximumEntries: 10000, cacheMinimumRecordTtl: 10, cacheMaximumRecordTtl: 86400, cacheNegativeRecordTtl: 300, cacheFailureRecordTtl: 60,
|
|
enableCachePrefetch: true, cachePrefetchEligibility: 2, cachePrefetchTrigger: 9,
|
|
enableBlocking: true, allowTxtBlockingReport: true, temporaryDisableBlockingTill: null, blockingBypassList: [], blockingType: 'NxDomain', blockingAnswerTtl: 30,
|
|
customBlockingAddresses: [], blockListUrls: ['https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts', '# Privacy', 'https://big.oisd.nl/domainswild', 'https://adguardteam.github.io/AdGuardSDNSFilter/Filters/filter.txt'],
|
|
blockListUpdateIntervalHours: 24, blockListNextUpdatedOn: iso(now + 3 * 3_600_000),
|
|
proxy: null, forwarders: ['1.1.1.1', '9.9.9.9'], forwarderProtocol: 'Udp', concurrentForwarding: true, forwarderRetries: 3, forwarderTimeout: 2000, forwarderConcurrency: 2,
|
|
loggingType: 'File, Console', ignoreResolverLogs: false, logQueries: false, useLocalTime: false, logFolder: 'logs', maxLogFileDays: 365, enableInMemoryStats: false, maxStatFileDays: 365,
|
|
}
|
|
|
|
// ── DHCP ───────────────────────────────────────────────────────────────────
|
|
let scopes = [
|
|
{ name: 'Main', enabled: true, startingAddress: '192.168.10.10', endingAddress: '192.168.10.200', subnetMask: '255.255.255.0', networkAddress: '192.168.10.0', broadcastAddress: '192.168.10.255', interfaceAddress: '192.168.10.2' },
|
|
{ name: 'Guests', enabled: false, startingAddress: '172.16.5.10', endingAddress: '172.16.5.100', subnetMask: '255.255.255.0', networkAddress: '172.16.5.0', broadcastAddress: '172.16.5.255', interfaceAddress: '172.16.5.2' },
|
|
]
|
|
const leases = [
|
|
['Main', 'Dynamic', 'a4:83:e7:10:2c:91', '192.168.10.14', 'laptop-anna.lan', 60 * 5], ['Main', 'Reserved', 'f0:2f:74:55:0a:3d', '192.168.10.31', 'tv-livingroom.lan', 0],
|
|
['Main', 'Dynamic', '3c:22:fb:9e:41:07', '192.168.10.77', 'phone-ivan.lan', 8], ['Main', 'Dynamic', 'b8:27:eb:6d:19:c2', '192.168.10.52', 'printer-office.lan', 60 * 20],
|
|
['Main', 'Dynamic', '00:1a:79:2b:63:4f', '192.168.10.66', 'desktop-pavel.lan', 60 * 11], ['Guests', 'Dynamic', 'd8:3a:dd:21:6c:04', '172.16.5.20', null, 100],
|
|
].map(([scope, type, mac, address, host, left]) => ({ scope, type, hardwareAddress: mac, clientIdentifier: '01-' + mac.replace(/:/g, '-'), address, hostName: host, leaseObtained: ago(60 * 3), leaseExpires: type === 'Reserved' ? ago(-60 * 24 * 365) : ago(-left) }))
|
|
const scopeDetail = (name) => ({
|
|
name, startingAddress: '192.168.10.10', endingAddress: '192.168.10.200', subnetMask: '255.255.255.0', leaseTimeDays: 1, leaseTimeHours: 0, leaseTimeMinutes: 0, offerDelayTime: 0,
|
|
pingCheckEnabled: false, pingCheckTimeout: 1000, pingCheckRetries: 2, domainName: 'lan', domainSearchList: [], dnsUpdates: true, dnsOverwriteForDynamicLease: false, dnsTtl: 900,
|
|
routerAddress: '192.168.10.1', useThisDnsServer: true, dnsServers: [], exclusions: [{ startingAddress: '192.168.10.100', endingAddress: '192.168.10.110' }],
|
|
reservedLeases: [{ hostName: 'tv-livingroom.lan', hardwareAddress: 'f0:2f:74:55:0a:3d', address: '192.168.10.31', comments: 'Living room' }],
|
|
allowOnlyReservedLeases: false, blockLocallyAdministeredMacAddresses: false, ignoreClientIdentifierOption: false,
|
|
})
|
|
|
|
// ── Apps ───────────────────────────────────────────────────────────────────
|
|
const app = (isQueryLogs) => ({ classPath: 'Example.App', description: '', isAppRecordRequestHandler: false, recordDataTemplate: null, isRequestController: false, isAuthoritativeRequestHandler: false, isRequestBlockingHandler: !isQueryLogs, isQueryLogger: isQueryLogs, isQueryLogs, isPostProcessor: false })
|
|
let apps = [
|
|
{ name: 'Query Logs (Sqlite)', description: 'Logs DNS queries into a SQLite database', version: '7.0', dnsApps: [{ ...app(true), classPath: 'QueryLogsSqlite.App' }] },
|
|
{ name: 'Advanced Blocking', description: 'Group based blocking with regex', version: '10.1', updateAvailable: true, updateVersion: '10.2', updateUrl: 'https://download.technitium.com/dns/apps/AdvancedBlockingApp-v10.2.zip', dnsApps: [{ ...app(false), classPath: 'AdvancedBlocking.App' }] },
|
|
{ name: 'Split Horizon', description: 'Different answers for different networks', version: '9.0', dnsApps: [{ ...app(false), classPath: 'SplitHorizon.SimpleAddress' }, { ...app(false), classPath: 'SplitHorizon.SimpleCNAME' }] },
|
|
]
|
|
if (VARIANT === 'b') {
|
|
apps = apps.filter((a) => a.name !== 'Split Horizon')
|
|
apps = apps.map((a) => (a.name === 'Query Logs (Sqlite)' ? { ...a, version: '7.1', updateAvailable: true, updateVersion: '7.2', updateUrl: 'https://download.technitium.com/dns/apps/QueryLogsSqliteApp-v7.2.zip' } : a))
|
|
}
|
|
const appConfigs = {
|
|
'Advanced Blocking': VARIANT === 'b'
|
|
? { enableBlocking: true, blockingAnswerTtl: 60, localGroup: 'office', networkGroupMap: { '0.0.0.0/0': 'default' }, groups: [{ name: 'default', enableBlocking: false, blockAsNxDomain: false }] }
|
|
: { enableBlocking: true, blockingAnswerTtl: 30, localGroup: 'home', networkGroupMap: { '0.0.0.0/0': 'default' }, groups: [{ name: 'default', enableBlocking: true, blockAsNxDomain: false }] },
|
|
'Query Logs (Sqlite)': { enableLogging: true, maxLogDays: 365, sqliteDbPath: VARIANT === 'b' ? '/var/lib/dns/ndns/querylogs.db' : '/var/lib/dns/mdns/querylogs.db' },
|
|
'Split Horizon': { domainGroupMap: { 'lan.example.net': 'internal' }, networkGroupMap: { '10.0.0.0/8': 'internal' } },
|
|
}
|
|
const catalogRows = [
|
|
['Failover', 'Fails over to a backup address when the primary is down', '8.0', '58 KB'], ['Geo Country', 'Answers by the country of the client', '7.2', '1.2 MB'],
|
|
['Wild IP', 'Answers with the IP address in the name', '5.1', '22 KB'], ['Auto PTR', 'Answers PTR queries from the forward zones', '6.0', '31 KB'],
|
|
['Drop Requests', 'Drops requests from the listed networks', '7.0', '19 KB'], ['NX Domain', 'Answers NXDOMAIN for the listed domains', '6.1', '17 KB'],
|
|
['Advanced Blocking', 'Group based blocking with regex', '10.2', '240 KB'], ['Query Logs (Sqlite)', 'Logs DNS queries into a SQLite database', '7.2', '310 KB'],
|
|
['Split Horizon', 'Different answers for different networks', '9.0', '120 KB'],
|
|
].map(([name, description, version, size]) => ({ name, description, version, size, url: `https://download.technitium.com/dns/apps/${name.replace(/[ ()]/g, '')}App-v${version}.zip` }))
|
|
const catalog = () => catalogRows
|
|
const storeListing = () => catalogRows.map((c) => {
|
|
const have = apps.find((a) => a.name === c.name)
|
|
return have ? { ...c, installed: true, installedVersion: have.version, updateAvailable: have.version !== c.version } : { ...c, installed: false }
|
|
})
|
|
|
|
// ── Query logs ─────────────────────────────────────────────────────────────
|
|
const RTYPES = ['Recursive', 'Cached', 'Authoritative', 'Blocked', 'Cached', 'Recursive']
|
|
const QTYPES = ['A', 'AAAA', 'HTTPS', 'A', 'PTR', 'TXT']
|
|
const logEntries = Array.from({ length: 240 }, (_, i) => {
|
|
const type = pick(RTYPES, i * 7)
|
|
const blockedRow = type === 'Blocked'
|
|
return {
|
|
rowNumber: i + 1, timestamp: iso(now - i * 47_000), clientIpAddress: pick(CLIENTS, i * 3)[0], protocol: pick(['Udp', 'Udp', 'Tcp', 'Tls', 'Https'], i),
|
|
responseType: type, responseRtt: blockedRow ? 0 : Math.round(3 + ((i * 13) % 90)), rcode: blockedRow ? 'NxDomain' : i % 41 === 0 ? 'ServerFailure' : 'NoError',
|
|
qname: blockedRow ? pick(BLOCKED, i) : pick(DOMAINS, i * 5), qtype: pick(QTYPES, i), qclass: 'IN',
|
|
answer: blockedRow || i % 41 === 0 ? null : i % 2 ? '2606:50c0:8000::154' : '104.18.12.33',
|
|
}
|
|
})
|
|
|
|
// ── Administration ─────────────────────────────────────────────────────────
|
|
let users = [
|
|
{ displayName: 'Administrator', username: 'admin', type: 'Local', isSsoUser: false, totpEnabled: true, disabled: false },
|
|
{ displayName: 'Anna K.', username: 'anna', type: 'Local', isSsoUser: false, totpEnabled: false, disabled: false },
|
|
{ displayName: 'Monitoring', username: 'readonly', type: 'Local', isSsoUser: false, totpEnabled: false, disabled: true },
|
|
].map((u, i) => ({ previousSessionLoggedOn: ago(60 * 24 * (i + 2)), previousSessionRemoteAddress: '192.168.10.14', recentSessionLoggedOn: i === 2 ? '0001-01-01T00:00:00Z' : ago(60 * (i + 1)), recentSessionRemoteAddress: i === 2 ? '' : '192.168.10.14', ...u }))
|
|
let groups = [{ name: 'Administrators', description: 'Full access' }, { name: 'DNS Administrators', description: 'Zones, cache and blocking' }, { name: 'Everyone', description: 'All users' }]
|
|
let sessions = [
|
|
{ username: 'admin', isCurrentSession: true, partialToken: 'a1b2c3d4', type: 'Standard', tokenName: null, lastSeen: ago(1), lastSeenRemoteAddress: '192.168.10.14', lastSeenUserAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Firefox/131.0' },
|
|
{ username: 'anna', isCurrentSession: false, partialToken: 'e5f6a7b8', type: 'Standard', tokenName: null, lastSeen: ago(60 * 17), lastSeenRemoteAddress: '10.8.0.2', lastSeenUserAgent: 'Mozilla/5.0 (Linux; Android 14) Chrome/130.0 Mobile' },
|
|
{ username: 'admin', isCurrentSession: false, partialToken: '9c0d1e2f', type: 'ApiToken', tokenName: 'backup-script', lastSeen: ago(60 * 24 * 6), lastSeenRemoteAddress: '10.8.0.9', lastSeenUserAgent: 'curl/8.5.0' },
|
|
]
|
|
const userDetails = (u) => ({ ...u, sessionTimeoutSeconds: 1800, remotelyManagedGroups: false, memberOfGroups: u.username === 'admin' ? ['Administrators'] : ['DNS Administrators', 'Everyone'], sessions: sessions.filter((s) => s.username === u.username) })
|
|
|
|
// ── Router ─────────────────────────────────────────────────────────────────
|
|
const addedRecords = {}
|
|
// Answers that are files, not JSON (the downloads and the Rules table's reads).
|
|
const fileFor = (path) => ({
|
|
'allowed/export': [...allowed].join('\n') + '\n',
|
|
'blocked/export': [...blocked].join('\n') + '\n',
|
|
'zones/export': '$ORIGIN example.net.\n@ IN SOA ns1 hostmaster 1 900 300 604800 900\n',
|
|
'logs/download': '[2026-10-26 10:00:00 UTC] [127.0.0.1:5380] [] DNS Server was started.\n',
|
|
})[path]
|
|
const ok = (response = {}) => ({ status: 'ok', response })
|
|
function route(path, q) {
|
|
const domain = q.get('domain') ?? q.get('zone') ?? ''
|
|
switch (path) {
|
|
case 'status': return { status: 'ok', hasDefaultCredentials: false, ssoEnabled: false }
|
|
case 'user/login': case 'user/session/get': return session
|
|
case 'user/profile/get': return ok({ displayName: 'Administrator', username: 'admin', totpEnabled: true, sessionTimeoutSeconds: 1800, memberOfGroups: ['Administrators'], sessions })
|
|
case 'user/checkForUpdate': return ok({ updateAvailable: false })
|
|
case 'dashboard/metrics/json': {
|
|
const total = Math.round(SCALE * 1_250_000)
|
|
return ok({ uptimestamp: ago(60 * 24 * 9), lifetimeCounters: { totalQueries: total, totalNoError: Math.round(total * 0.96), totalServerFailure: 40, totalNxDomain: Math.round(total * 0.03), totalRefused: 180, totalAuthoritative: Math.round(total * 0.08), totalRecursive: Math.round(total * 0.33), totalCached: Math.round(total * 0.48), totalBlocked: Math.round(total * 0.15), totalDropped: 5, totalClients: 128 } })
|
|
}
|
|
case 'dashboard/stats/get': return ok(dashboard(q.get('type') ?? 'LastDay'))
|
|
case 'dashboard/stats/getTop': return ok({ [{ TopClients: 'topClients', TopDomains: 'topDomains', TopBlockedDomains: 'topBlockedDomains' }[q.get('statsType')] ?? 'topDomains']: top({ TopClients: 'clients', TopDomains: 'domains', TopBlockedDomains: 'blocked' }[q.get('statsType')] ?? 'domains') })
|
|
case 'zones/list': {
|
|
const per = Number(q.get('zonesPerPage') ?? 10), page = Math.max(1, Number(q.get('pageNumber') ?? 1))
|
|
return ok({ zones: zones.slice((page - 1) * per, page * per), pageNumber: page, totalPages: Math.max(1, Math.ceil(zones.length / per)), totalZones: zones.length })
|
|
}
|
|
case 'zones/records/get': { const z = zones.find((x) => x.name === domain) ?? zones[0]; return ok({ zone: { name: z.name, type: z.type, disabled: z.disabled, dnssecStatus: z.dnssecStatus, soaSerial: z.soaSerial, catalog: z.catalog ?? null }, records: [...recordsFor(z), ...(addedRecords[z.name] ?? [])] }) }
|
|
case 'zones/records/add': {
|
|
// One record added for the session: it comes back in the zone's list. `type` decides what the data is called.
|
|
const type = q.get('type') ?? 'A'
|
|
const rData = type === 'TXT' ? { text: q.get('text') ?? '', splitText: false } : type === 'CNAME' ? { cname: q.get('cname') ?? '' } : { ipAddress: q.get('ipAddress') ?? '' }
|
|
const added = { name: q.get('domain') ?? domain, type, ttl: Number(q.get('ttl') ?? 3600), ttlString: `${q.get('ttl') ?? 3600}`, disabled: false, rData, dnssecStatus: 'Disabled', lastUsedOn: '0001-01-01T00:00:00Z', lastModified: iso(Date.now()), expiryTtl: 0, expiryTtlString: '0' }
|
|
const z = zones.find((x) => x.name === (q.get('zone') ?? domain)) ?? zones[0]
|
|
addedRecords[z.name] = [...(addedRecords[z.name] ?? []), added]
|
|
return ok({ addedRecord: added, zone: { name: z.name, type: z.type, disabled: z.disabled } })
|
|
}
|
|
case 'zones/enable': case 'zones/disable': zones = zones.map((z) => (q.get('zone') === z.name ? { ...z, disabled: path === 'zones/disable' } : z)); return ok()
|
|
case 'zones/delete': { const gone = (q.get('zones') ?? q.get('zone') ?? '').split(',').filter(Boolean); zones = zones.filter((z) => !gone.includes(z.name)); return ok({ deleted: gone, failed: {} }) }
|
|
case 'zones/create': zones = [{ name: domain, type: q.get('type') ?? 'Primary', dnssecStatus: 'Unsigned', soaSerial: 1, disabled: false, lastModified: iso(Date.now()), notifyFailed: false, notifyFailedFor: [] }, ...zones]; return ok({ domain })
|
|
case 'zones/options/get': return ok({ name: domain, type: 'Primary', dnssecStatus: 'Unsigned', disabled: false, catalog: null, queryAccess: 'Allow', queryAccessNetworkACL: [], zoneTransfer: 'AllowOnlyZoneNameServers', zoneTransferNetworkACL: [], zoneTransferTsigKeyNames: [], notify: 'ZoneNameServers', notifyNameServers: [], update: 'Deny', updateNetworkACL: [], updateSecurityPolicies: [], availableCatalogZoneNames: [], availableTsigKeyNames: ['transfer.example.net'] })
|
|
case 'zones/permissions/get': return ok({ zone: domain, userPermissions: [{ username: 'admin', canView: true, canModify: true, canDelete: true }], groupPermissions: [{ name: 'DNS Administrators', canView: true, canModify: true, canDelete: false }], users: users.map((u) => u.username), groups: groups.map((g) => g.name) })
|
|
case 'zones/catalogs/list': return ok({ catalogZoneNames: ['catalog.example.net'] })
|
|
case 'cache/list': return ok({ domain, zones: cacheTree[domain] ?? [], records: cacheRecords(domain) })
|
|
case 'cache/flush': case 'cache/delete': return ok()
|
|
case 'allowed/list': return ok(listNode(allowed, domain))
|
|
case 'blocked/list': return ok(listNode(blocked, domain))
|
|
case 'allowed/add': case 'blocked/add': (path.startsWith('allowed') ? allowed : blocked).add(domain); return ok()
|
|
case 'allowed/delete': case 'blocked/delete': (path.startsWith('allowed') ? allowed : blocked).delete(domain); return ok()
|
|
case 'allowed/flush': allowed.clear(); return ok()
|
|
case 'blocked/flush': blocked.clear(); return ok()
|
|
case 'settings/get': return ok(settings)
|
|
case 'settings/set': {
|
|
// Changes only what is sent and already there, by its kind (a flag, a number, a list or text); the rest is kept.
|
|
for (const [k, v] of q) {
|
|
if (!(k in settings) || k === 'node' || k === 'token') continue
|
|
const old = settings[k]
|
|
if (k === 'enableBlocking' && v === 'true') settings.temporaryDisableBlockingTill = null
|
|
settings[k] = typeof old === 'boolean' ? v === 'true' : typeof old === 'number' ? Number(v) : Array.isArray(old) ? v.split(/[,\n]/).map((x) => x.trim()).filter((x) => x && x !== 'false') : v
|
|
}
|
|
return ok(settings)
|
|
}
|
|
case 'settings/forceUpdateBlockLists': return ok()
|
|
case 'settings/temporaryDisableBlocking': {
|
|
// Blocking is off until the time given, as on a server; `settings/set enableBlocking=true` turns it back on.
|
|
const minutes = Number(q.get('minutes') ?? 10)
|
|
settings.enableBlocking = false
|
|
settings.temporaryDisableBlockingTill = iso(Date.now() + minutes * 60_000)
|
|
return ok({ temporaryDisableBlockingTill: settings.temporaryDisableBlockingTill })
|
|
}
|
|
case 'user/createSingleUseToken': return ok({ token: 'mock-once' })
|
|
case 'settings/getTsigKeyNames': return ok({ tsigKeyNames: ['transfer.example.net'] })
|
|
case 'dhcp/leases/list': return ok({ leases })
|
|
case 'dhcp/scopes/list': return ok({ scopes })
|
|
case 'dhcp/scopes/get': return ok(scopeDetail(q.get('name') ?? 'Main'))
|
|
case 'dhcp/scopes/enable': case 'dhcp/scopes/disable': scopes = scopes.map((s) => (s.name === q.get('name') ? { ...s, enabled: path === 'dhcp/scopes/enable' } : s)); return ok()
|
|
case 'dhcp/scopes/delete': scopes = scopes.filter((s) => s.name !== q.get('name')); return ok()
|
|
case 'apps/list': return ok({ apps })
|
|
case 'apps/listStoreApps': return ok({ storeApps: storeListing() })
|
|
case 'apps/downloadAndInstall': case 'apps/downloadAndUpdate': {
|
|
const entry = catalog().find((c) => c.name === q.get('name') && c.url === q.get('url'))
|
|
if (!entry) return { status: 'error', errorMessage: 'The app was not found in the store.' }
|
|
const update = path === 'apps/downloadAndUpdate'
|
|
const have = apps.find((a) => a.name === entry.name)
|
|
if (update !== Boolean(have)) return { status: 'error', errorMessage: update ? `DNS application '${entry.name}' was not found.` : `DNS application '${entry.name}' already exists.` }
|
|
const installed = { ...(have ?? { name: entry.name, description: entry.description, dnsApps: [app(false)] }), version: entry.version }
|
|
delete installed.updateAvailable; delete installed.updateVersion; delete installed.updateUrl
|
|
apps = update ? apps.map((a) => (a.name === entry.name ? installed : a)) : [...apps, installed]
|
|
return ok(update ? { updatedApp: installed } : { installedApp: installed })
|
|
}
|
|
case 'apps/install': case 'apps/update': {
|
|
const name = q.get('name') ?? ''
|
|
const have = apps.find((a) => a.name === name)
|
|
const update = path === 'apps/update'
|
|
if (update !== Boolean(have)) return { status: 'error', errorMessage: update ? `DNS application '${name}' was not found.` : `DNS application '${name}' already exists.` }
|
|
const installed = { ...(have ?? { name, description: 'Installed from a zip file', dnsApps: [app(false)] }), version: update ? `${have.version}.1` : '1.0' }
|
|
apps = update ? apps.map((a) => (a.name === name ? installed : a)) : [...apps, installed]
|
|
return ok(update ? { updatedApp: installed } : { installedApp: installed })
|
|
}
|
|
case 'apps/config/set': {
|
|
const name = q.get('name') ?? ''
|
|
if (!apps.some((a) => a.name === name)) return { status: 'error', errorMessage: `DNS application '${name}' was not found.` }
|
|
try { appConfigs[name] = JSON.parse(q.get('config') ?? '') } catch (e) { return { status: 'error', errorMessage: `The config is not valid JSON: ${e.message}` } }
|
|
return ok()
|
|
}
|
|
case 'apps/config/get': return ok({ config: appConfigs[q.get('name')] ? JSON.stringify(appConfigs[q.get('name')], null, 2) : JSON.stringify({ enabled: true }, null, 2) })
|
|
case 'apps/uninstall': apps = apps.filter((a) => a.name !== q.get('name')); return ok()
|
|
case 'logs/list': return ok({ logFiles: [{ fileName: '2026-10-26', size: '412.8 KB' }, { fileName: '2026-10-25', size: '1.2 MB' }, { fileName: '2026-10-24', size: '980.4 KB' }] })
|
|
case 'logs/query': {
|
|
const per = Number(q.get('entriesPerPage') ?? 25), page = Math.max(1, Number(q.get('pageNumber') ?? 1))
|
|
return ok({ pageNumber: page, totalPages: Math.ceil(logEntries.length / per), totalEntries: logEntries.length, entries: logEntries.slice((page - 1) * per, page * per) })
|
|
}
|
|
case 'dnsClient/resolve': return ok({ result: { Metadata: { NameServer: 'This Server (127.0.0.1)', Protocol: 'Udp', DatagramSize: '57 bytes', RoundTripTime: '12.34 ms' }, Question: [{ Name: q.get('domain') ?? 'example.net', Type: q.get('type') ?? 'A', Class: 'IN' }], Answer: [{ Name: q.get('domain') ?? 'example.net', Type: 'A', Class: 'IN', TTL: '300 (5 minutes)', RDATA: { IPAddress: '203.0.113.10' } }] } })
|
|
case 'admin/users/list': return ok({ users })
|
|
case 'admin/users/get': return ok(userDetails(users.find((u) => u.username === q.get('user')) ?? users[0]))
|
|
case 'admin/users/delete': users = users.filter((u) => u.username !== q.get('user')); return ok()
|
|
case 'admin/users/create': users = [...users, { displayName: q.get('displayName') || q.get('user'), username: q.get('user'), type: 'Local', isSsoUser: false, totpEnabled: false, disabled: false, previousSessionLoggedOn: '0001-01-01T00:00:00Z', previousSessionRemoteAddress: '', recentSessionLoggedOn: '0001-01-01T00:00:00Z', recentSessionRemoteAddress: '' }]; return ok()
|
|
case 'admin/groups/list': return ok({ groups })
|
|
case 'admin/groups/get': return ok({ ...(groups.find((g) => g.name === q.get('group')) ?? groups[0]), members: users.slice(0, 2).map((u) => u.username) })
|
|
case 'admin/groups/delete': groups = groups.filter((g) => g.name !== q.get('group')); return ok()
|
|
case 'admin/groups/create': groups = [...groups, { name: q.get('group'), description: q.get('description') ?? '' }]; return ok()
|
|
case 'admin/sessions/list': return ok({ sessions })
|
|
case 'admin/sessions/delete': case 'user/session/delete': sessions = sessions.filter((s) => s.partialToken !== q.get('partialToken')); return ok()
|
|
case 'admin/permissions/list': return ok({ permissions: SECTIONS.map((section) => ({ section, userPermissions: [{ username: 'admin', canView: true, canModify: true, canDelete: true }], groupPermissions: [{ name: 'Administrators', canView: true, canModify: true, canDelete: true }, { name: 'Everyone', canView: section === 'Dashboard', canModify: false, canDelete: false }] })) })
|
|
case 'admin/permissions/get': return ok({ section: q.get('section') ?? 'Dashboard', userPermissions: [{ username: 'admin', canView: true, canModify: true, canDelete: true }], groupPermissions: [{ name: 'Administrators', canView: true, canModify: true, canDelete: true }], users: users.map((u) => u.username), groups: groups.map((g) => g.name) })
|
|
case 'admin/sso/get': return ok({ ssoEnabled: false, ssoAuthority: null, ssoClientId: null, ssoClientSecret: null, ssoMetadataAddress: null, ssoScopes: ['openid', 'profile', 'email'], ssoAllowSignup: false, ssoAllowSignupOnlyForMappedUsers: false, ssoGroupMap: [], localGroups: groups.map((g) => g.name) })
|
|
case 'admin/ldap/get': return ok({ ldapEnabled: false, ldapServer: null, ldapPort: 389, ldapSslOption: 'None', ldapIgnoreSslErrors: false, ldapBindUsername: null, ldapBindPassword: null, ldapSearchBase: null, ldapUserSearchFilter: null, ldapGroupAttribute: null, ldapAllowSignup: false, ldapAllowSignupOnlyForMappedUsers: false, ldapGroupMap: [], localGroups: groups.map((g) => g.name) })
|
|
case 'admin/cluster/state': return ok({ clusterInitialized: false, serverIpAddresses: ['192.168.10.2'], dnsServerDomain: 'dns.example.net', version: '15.5' })
|
|
default: return ok()
|
|
}
|
|
}
|
|
|
|
http.createServer((req, res) => {
|
|
const url = new URL(req.url ?? '/', 'http://mock')
|
|
const path = url.pathname.replace(/^.*\/api\//, '').replace(/\/$/, '')
|
|
let body = ''
|
|
req.on('data', (c) => (body += c))
|
|
req.on('end', () => {
|
|
const q = new URLSearchParams(url.search)
|
|
if ((req.headers['content-type'] ?? '').includes('application/x-www-form-urlencoded')) for (const [k, v] of new URLSearchParams(body)) q.set(k, v)
|
|
const file = fileFor(path)
|
|
if (file !== undefined) {
|
|
res.setHeader('content-type', 'text/plain')
|
|
res.setHeader('content-disposition', `attachment; filename="${path.replace('/', '-')}.txt"`)
|
|
res.end(file)
|
|
return
|
|
}
|
|
res.setHeader('content-type', 'application/json')
|
|
res.end(JSON.stringify(route(path, q)))
|
|
})
|
|
}).listen(PORT, '127.0.0.1', () => console.log(`Mock Technitium API on http://127.0.0.1:${PORT} (any user and password, scale ${SCALE})`))
|