Add a mock API with made-up data to look at the console

dev/mock-api.mjs answers the endpoints the screens call, with surrogate data, and keeps
what is changed in memory (zones, scopes, users, groups, rules, sessions, the cache).
`npm run mock` starts it on 127.0.0.1:5380, where the dev server's proxy already points.
.claude/launch.json starts the mock and the console together in the preview pane.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-27 01:36:25 +07:00
1 parent f24f4972ba
commit 44b6dc246c
3 files changed
+318

No files matched your search

+300
View File
@@ -0,0 +1,300 @@
#!/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
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'
const PORT = Number(process.env.PORT ?? 5380)
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(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: 128, 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(84210 / (1 + i * 0.55)), ...(i === 7 ? { rateLimited: true } : {}) }))
const list = kind === 'domains' ? DOMAINS : BLOCKED
return list.map((name, i) => ({ name, hits: Math.round((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' }] },
]
const store = [
['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'],
].map(([name, description, version, size]) => ({ name, description, version, size, url: `https://download.technitium.com/dns/apps/${name.replace(/ /g, '')}App-v${version}.zip`, 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 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/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) }) }
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': case 'settings/forceUpdateBlockLists': case 'settings/temporaryDisableBlocking': return ok(path === 'settings/set' ? settings : { temporaryDisableBlockingTill: iso(now + 10 * 60_000) })
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: store })
case 'apps/config/get': return ok({ config: '{\n "enabled": true\n}' })
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)
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)`))