#!/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 `, `--scale ` 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})`))