Files
technitium-console/dev/mock-api.mjs
T
DenozordecandClaude Opus 5.5 392b644faa Hub stage 7: compare and align zones, settings and blocking
Zones gets "All servers" in hub mode: one table of every zone on every
server with its type and serial, marking zones that are on one server
only or whose serials or types differ. A zone can be copied to another
server (export, create, import; Overwrite must be ticked for a zone
that is already there) or made a Secondary there (the source's zone
transfer and notify lists gain the target, then the target zone is
created).

Settings and Blocking can be compared across servers against a baseline
kept in the hub. Host fields stay local, secrets are compared inside
the hub only and never shown or logged, and an apply sends only the
changed shared fields after saving each server's settings to history.
Blocked and Allowed lists are compared as sets; extras are removed only
when asked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 01:20:05 +07:00

452 lines
42 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 }))
if (VARIANT === 'b') {
// The second server: a zone the first lacks, a serial that moved on, and a Secondary of the first server's Primary.
zones = zones.filter((z) => z.name !== 'dev.example.net' && z.name !== 'vpn.example.net')
zones = zones.map((z) => (z.name === 'lan' ? { ...z, soaSerial: 44 } : z.name === 'home.example.net' ? { ...z, type: 'Secondary', soaSerial: 16, catalog: null } : z))
zones.push({ name: 'ndns-only.example.net', type: 'Primary', dnssecStatus: 'Unsigned', soaSerial: 3, disabled: false, lastModified: ago(60 * 3), notifyFailed: false, notifyFailedFor: [] })
}
// Zone files (zones/export, zones/import) and options (zones/options/get, set) are kept per zone.
const zoneFiles = {}
const zoneOptions = {}
const zoneFile = (name) => zoneFiles[name] ?? `$ORIGIN ${name}.\n@ IN SOA ns1.${name}. hostmaster.${name}. ${(zones.find((z) => z.name === name) ?? {}).soaSerial ?? 1} 900 300 604800 900\n@ IN NS ns1.${name}.\nwww IN A 203.0.113.${VARIANT === 'b' ? 20 : 10}\n`
const optionsOf = (name) => ({ zoneTransfer: 'Deny', zoneTransferNetworkACL: [], zoneTransferTsigKeyNames: [], notify: 'ZoneNameServers', notifyNameServers: [], ...(zoneOptions[name] ?? {}) })
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'])
if (VARIANT === 'b') {
blocked.delete('beacon.analytics-example.com'); blocked.add('only-ndns.tracker-example.com')
allowed.add('extra-allowed.example.net')
}
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,
}
if (VARIANT === 'b') {
// The second server: other shared values (these show up in the compare), and its own identity and log folder (local fields).
Object.assign(settings, {
dnsServerDomain: 'ndns.example.net', dnsServerLocalEndPoints: ['10.0.0.9:53'], logFolder: 'logs-ndns', webServiceHttpPort: 5381,
defaultRecordTtl: 7200, forwarders: ['8.8.8.8'], blockingType: 'AnyAddress', serveStale: false,
tsigKeys: [{ keyName: 'transfer.example.net', sharedSecret: 'bmRucy1vdGhlci1zZWNyZXQ=', algorithmName: 'hmac-sha256' }],
})
}
// ── 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, q = new URLSearchParams()) => ({
'allowed/export': [...allowed].join('\n') + '\n',
'blocked/export': [...blocked].join('\n') + '\n',
'zones/export': zones.some((z) => z.name === q.get('zone')) ? zoneFile(q.get('zone')) : undefined,
'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': {
if (zones.some((z) => z.name === domain)) return { status: 'error', errorMessage: `Zone already exists: ${domain}` }
const type = q.get('type') ?? 'Primary'
zones = [{ name: domain, type, dnssecStatus: type === 'Primary' ? 'Unsigned' : undefined, soaSerial: type === 'Forwarder' ? undefined : 1, disabled: false, lastModified: iso(Date.now()), notifyFailed: false, notifyFailedFor: [] }, ...zones]
if (type === 'Secondary') zoneOptions[domain] = { primaryNameServerAddresses: (q.get('primaryNameServerAddresses') ?? '').split(','), primaryZoneTransferProtocol: q.get('zoneTransferProtocol') ?? 'Tcp', primaryZoneTransferTsigKeyName: q.get('tsigKeyName') ?? '' }
return ok({ domain })
}
case 'zones/import': {
const z = zones.find((x) => x.name === domain)
if (!z) return { status: 'error', errorMessage: `No such zone was found: ${domain}` }
zoneFiles[domain] = q.get('__text') ?? zoneFile(domain)
const serial = /SOA\s+\S+\s+\S+\s+(\d+)/.exec(zoneFiles[domain])
if (serial) zones = zones.map((x) => (x.name === domain ? { ...x, soaSerial: Number(serial[1]), lastModified: iso(Date.now()) } : x))
return ok()
}
case 'zones/options/get': { const z = zones.find((x) => x.name === domain); return ok({ name: domain, type: z?.type ?? 'Primary', dnssecStatus: z?.dnssecStatus ?? 'Unsigned', disabled: false, catalog: z?.catalog ?? null, queryAccess: 'Allow', queryAccessNetworkACL: [], update: 'Deny', updateNetworkACL: [], updateSecurityPolicies: [], availableCatalogZoneNames: [], availableTsigKeyNames: ['transfer.example.net'], ...optionsOf(domain) }) }
case 'zones/options/set': {
const next = { ...(zoneOptions[domain] ?? {}) }
for (const [k, v] of q) {
if (['zone', 'node', 'token'].includes(k)) continue
next[k] = /ACL$|Names$|Servers$|Addresses$/.test(k) ? (v === 'false' || v === '' ? [] : v.split(',')) : v
}
zoneOptions[domain] = next
return ok()
}
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
if (k === 'tsigKeys') {
// Rows of keyName|sharedSecret|algorithmName joined with a pipe, or "false" for none.
const cells = v === 'false' ? [] : v.split('|')
settings.tsigKeys = Array.from({ length: Math.floor(cells.length / 3) }, (_, i) => ({ keyName: cells[i * 3], sharedSecret: cells[i * 3 + 1], algorithmName: cells[i * 3 + 2] }))
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: settings.tsigKeys.map((k) => k.keyName) })
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)
if ((req.headers['content-type'] ?? '').includes('text/plain') && body !== '') q.set('__text', body)
const file = fileFor(path, q)
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})`))