node:http server that serves the console build and a /api/* the console accepts: its own login and session (clusterInitialized, the servers as nodes, hub flag, Dashboard and Logs only), node=<id> forwarded with that server's token, no node or cluster merged (stats, top lists, query logs, apps), every write refused with 403, a short response cache, and an unreachable server reported as hubWarnings. The dev mock takes --port and --scale so two of them differ. Vitest skips .claude/ worktrees. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
309 lines
32 KiB
JavaScript
309 lines
32 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>` and `--scale <x>`: two instances with different numbers 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)
|
|
const now = Date.now()
|
|
const iso = (ms) => new Date(ms).toISOString()
|
|
const ago = (min) => iso(now - min * 60_000)
|
|
const pick = (list, i) => list[i % list.length]
|
|
|
|
// ── Session ────────────────────────────────────────────────────────────────
|
|
const SECTIONS = ['Dashboard', 'Zones', 'Cache', 'Allowed', 'Blocked', 'Apps', 'DnsClient', 'Settings', 'DhcpServer', 'Administration', 'Logs']
|
|
const permissions = Object.fromEntries(SECTIONS.map((k) => [k, { canView: true, canModify: true, canDelete: true }]))
|
|
const session = {
|
|
status: 'ok', token: 'mock-token', username: 'admin', displayName: 'Administrator', type: 'Local', totpEnabled: false,
|
|
info: {
|
|
version: '15.5', uptimestamp: ago(60 * 24 * 9), dnsServerDomain: 'dns.example.net', permissions,
|
|
clusterInitialized: false, defaultRecordTtl: 3600, defaultNsRecordTtl: 3600,
|
|
},
|
|
}
|
|
|
|
// ── Dashboard ──────────────────────────────────────────────────────────────
|
|
const RANGES = {
|
|
LastHour: { n: 60, step: 60_000, scale: 1 / 60 },
|
|
LastDay: { n: 24, step: 3_600_000, scale: 1 },
|
|
LastWeek: { n: 7 * 24, step: 3_600_000, scale: 1 },
|
|
LastMonth: { n: 30, step: 86_400_000, scale: 24 },
|
|
LastYear: { n: 12, step: 30 * 86_400_000, scale: 24 * 30 },
|
|
}
|
|
const SERIES = [
|
|
['Total', 52000], ['No Error', 50100], ['Server Failure', 40], ['NX Domain', 1600], ['Refused', 180],
|
|
['Authoritative', 4000], ['Recursive', 17000], ['Cached', 25200], ['Blocked', 7800], ['Dropped', 5], ['Clients', 128],
|
|
]
|
|
function dashboard(type) {
|
|
const r = RANGES[type] ?? RANGES.LastDay
|
|
const instants = Array.from({ length: r.n }, (_, i) => iso(now - (r.n - 1 - i) * r.step))
|
|
const wave = (base, i) => Math.max(0, Math.round(SCALE * base * r.scale * (1 + 0.35 * Math.sin(i / 3.2) + 0.15 * Math.cos(i / 1.7))))
|
|
const totals = Object.fromEntries(SERIES.map(([name, base]) => [name, instants.reduce((a, _, i) => a + wave(base, i), 0)]))
|
|
const stats = {
|
|
totalQueries: totals.Total, totalNoError: totals['No Error'], totalServerFailure: totals['Server Failure'], totalNxDomain: totals['NX Domain'],
|
|
totalRefused: totals.Refused, totalAuthoritative: totals.Authoritative, totalRecursive: totals.Recursive, totalCached: totals.Cached,
|
|
totalBlocked: totals.Blocked, totalDropped: totals.Dropped, totalClients: Math.round(128 * SCALE), zones: zones.length, cachedEntries: 48210,
|
|
allowedZones: allowed.size, blockedZones: blocked.size, allowListZones: 1, blockListZones: 1482903,
|
|
}
|
|
return {
|
|
stats,
|
|
mainChartData: { labelFormat: 'HH:mm', labels: instants, datasets: SERIES.map(([label, base]) => ({ label, data: instants.map((_, i) => wave(base, i)) })) },
|
|
queryResponseChartData: { labels: ['Authoritative', 'Recursive', 'Cached', 'Blocked', 'Dropped'], datasets: [{ label: 'Total', data: [totals.Authoritative, totals.Recursive, totals.Cached, totals.Blocked, totals.Dropped], backgroundColor: [] }] },
|
|
queryTypeChartData: { labels: ['A', 'AAAA', 'HTTPS', 'PTR', 'TXT', 'MX'], datasets: [{ label: 'Total', data: [48, 27, 12, 6, 4, 3].map((p) => Math.round((totals.Total * p) / 100)), backgroundColor: [] }] },
|
|
protocolTypeChartData: { labels: ['UDP', 'TCP', 'TLS', 'HTTPS'], datasets: [{ label: 'Total', data: [82, 9, 6, 3].map((p) => Math.round((totals.Total * p) / 100)), backgroundColor: [] }] },
|
|
topClients: top('clients'), topDomains: top('domains'), topBlockedDomains: top('blocked'),
|
|
}
|
|
}
|
|
const CLIENTS = [['192.168.10.14', 'laptop-anna.lan'], ['192.168.10.31', 'tv-livingroom.lan'], ['10.8.0.2', 'vpn-gateway'], ['192.168.10.77', 'phone-ivan.lan'], ['192.168.10.52', 'printer-office.lan'], ['192.168.10.9', 'nas.lan'], ['10.8.0.7', 'vpn-phone'], ['192.168.20.4', 'guest-tablet'], ['192.168.10.66', 'desktop-pavel.lan'], ['fd00::1a2b', 'router.lan']]
|
|
const DOMAINS = ['connectivitycheck.gstatic.com', 'time.cloudflare.com', 'api.github.com', 'updates.example.net', 'www.example.net', 'mail.example.net', 'cdn.jsdelivr.net', 'fonts.googleapis.com', 'api.telegram.org', 'ocsp.digicert.com']
|
|
const BLOCKED = ['ads.tracker-example.com', 'telemetry.vendor-example.io', 'pixel.metrics-example.net', 'cdn.adserver-example.org', 'beacon.analytics-example.com', 'stats.adnetwork-example.net', 'track.shop-example.org', 'ads2.tracker-example.com', 'collect.data-example.io', 'sdk.ads-example.com']
|
|
function top(kind) {
|
|
if (kind === 'clients') return CLIENTS.map(([name, domain], i) => ({ name, domain: i === 3 ? DOMAINS[i] : DOMAINS[(i * 3) % 10], hits: Math.round((SCALE * 84210) / (1 + i * 0.55)), ...(i === 7 ? { rateLimited: true } : {}) }))
|
|
const list = kind === 'domains' ? DOMAINS : BLOCKED
|
|
return list.map((name, i) => ({ name, hits: Math.round((SCALE * (kind === 'domains' ? 22480 : 9804)) / (1 + i * 0.5)) }))
|
|
}
|
|
|
|
// ── Zones ──────────────────────────────────────────────────────────────────
|
|
let zones = [
|
|
{ name: 'example.net', type: 'Primary', dnssecStatus: 'SignedWithNSEC3', hasDnssecPrivateKeys: true, soaSerial: 2026102601, disabled: false, lastModified: ago(60 * 5) },
|
|
{ name: 'lan', type: 'Primary', dnssecStatus: 'Unsigned', soaSerial: 41, disabled: false, lastModified: ago(60 * 24 * 3) },
|
|
{ name: '10.in-addr.arpa', type: 'Primary', dnssecStatus: 'Unsigned', soaSerial: 7, disabled: false, lastModified: ago(60 * 24 * 12) },
|
|
{ name: 'partner.example.org', type: 'Secondary', dnssecStatus: 'Unsigned', soaSerial: 5, disabled: false, syncFailed: true, expiry: iso(now + 6 * 86_400_000), lastModified: ago(60 * 7) },
|
|
{ name: 'corp.example.com', type: 'Forwarder', dnssecStatus: 'Unsigned', disabled: true, lastModified: ago(60 * 24 * 30) },
|
|
{ name: 'stub.example.org', type: 'Stub', dnssecStatus: 'Unsigned', soaSerial: 2, disabled: false, expiry: iso(now + 20 * 86_400_000), lastModified: ago(60 * 24 * 2) },
|
|
{ name: 'catalog.example.net', type: 'Catalog', dnssecStatus: 'Unsigned', soaSerial: 3, disabled: false, lastModified: ago(60 * 24 * 40) },
|
|
{ name: 'home.example.net', type: 'Primary', dnssecStatus: 'Unsigned', soaSerial: 18, disabled: false, catalog: 'catalog.example.net', lastModified: ago(60 * 24 * 6) },
|
|
{ name: 'dev.example.net', type: 'Primary', dnssecStatus: 'SignedWithNSEC', hasDnssecPrivateKeys: false, soaSerial: 9, disabled: false, lastModified: ago(60 * 24 * 11) },
|
|
{ name: 'old.example.com', type: 'Secondary', dnssecStatus: 'Unsigned', soaSerial: 1, disabled: false, isExpired: true, expiry: iso(now - 86_400_000), lastModified: ago(60 * 24 * 60) },
|
|
{ name: 'vpn.example.net', type: 'Primary', dnssecStatus: 'Unsigned', soaSerial: 12, disabled: false, notifyFailed: true, notifyFailedFor: ['ns2.example.net'], lastModified: ago(60 * 24 * 9) },
|
|
{ name: 'staging.example.net', type: 'Primary', dnssecStatus: 'Unsigned', soaSerial: 4, disabled: true, lastModified: ago(60 * 24 * 20) },
|
|
].map((z) => ({ notifyFailed: false, notifyFailedFor: [], ...z }))
|
|
|
|
function recordsFor(zone) {
|
|
const z = zone.name
|
|
const rec = (name, type, ttl, rData) => ({ name, type, ttl, ttlString: `${ttl} (${ttl >= 3600 ? ttl / 3600 + ' hours' : ttl / 60 + ' minutes'})`, disabled: false, rData, dnssecStatus: 'Disabled', lastUsedOn: ago(40), lastModified: ago(60 * 24 * 4), expiryTtl: 0, expiryTtlString: '' })
|
|
const list = [
|
|
rec(z, 'SOA', 900, { primaryNameServer: `ns1.${z}`, responsiblePerson: `hostmaster.${z}`, serial: zone.soaSerial ?? 1, refresh: 900, retry: 300, expire: 604800, minimum: 900 }),
|
|
rec(z, 'NS', 3600, { nameServer: `ns1.${z}` }),
|
|
rec(z, 'NS', 3600, { nameServer: `ns2.${z}` }),
|
|
rec(z, 'A', 300, { ipAddress: '203.0.113.10' }),
|
|
rec(z, 'AAAA', 300, { ipAddress: '2001:db8::10' }),
|
|
rec(`www.${z}`, 'CNAME', 300, { cname: `${z}.` }),
|
|
rec(`mail.${z}`, 'A', 300, { ipAddress: '203.0.113.25' }),
|
|
rec(z, 'MX', 3600, { preference: 10, exchange: `mail.${z}` }),
|
|
rec(z, 'TXT', 3600, { text: 'v=spf1 mx -all', splitText: false }),
|
|
rec(`_dmarc.${z}`, 'TXT', 3600, { text: 'v=DMARC1; p=quarantine', splitText: false }),
|
|
]
|
|
list[8].comments = 'SPF'
|
|
return list
|
|
}
|
|
|
|
// ── Cache and rules ────────────────────────────────────────────────────────
|
|
const cacheTree = {
|
|
'': ['com', 'net', 'org', 'io', 'arpa'],
|
|
com: ['google', 'github', 'cloudflare', 'example'], net: ['example', 'cloudfront'], org: ['example', 'wikipedia'], io: ['vendor-example'],
|
|
}
|
|
const cacheRecords = (domain) =>
|
|
domain === ''
|
|
? []
|
|
: [
|
|
{ name: domain, type: 'A', ttl: 3412, ttlString: '3412 (56 minutes)', rData: { ipAddress: '93.184.216.34' }, dnssecStatus: 'Disabled', lastUsedOn: ago(5) },
|
|
{ name: domain, type: 'AAAA', ttl: 3412, ttlString: '3412 (56 minutes)', rData: { ipAddress: '2606:2800:220:1:248:1893:25c8:1946' }, dnssecStatus: 'Disabled', lastUsedOn: ago(5) },
|
|
]
|
|
const allowed = new Set(['updates.example.net', 'api.github.com', 'time.cloudflare.com'])
|
|
const blocked = new Set(['ads.tracker-example.com', 'telemetry.vendor-example.io', 'pixel.metrics-example.net', 'cdn.adserver-example.org', 'beacon.analytics-example.com'])
|
|
const listNode = (set, domain) => {
|
|
const all = [...set].sort()
|
|
return { domain, zones: domain === '' ? all : [], records: domain !== '' && set.has(domain) ? [{ name: domain, type: 'SOA', ttl: 60, rData: {}, dnssecStatus: 'Disabled' }] : [] }
|
|
}
|
|
|
|
// ── Settings ───────────────────────────────────────────────────────────────
|
|
const settings = {
|
|
version: '15.5', uptimestamp: ago(60 * 24 * 9), clusterInitialized: false,
|
|
dnsServerDomain: 'dns.example.net', dnsServerLocalEndPoints: ['0.0.0.0:53', '[::]:53'], dnsServerIPv4SourceAddresses: null, dnsServerIPv6SourceAddresses: null,
|
|
defaultRecordTtl: 3600, defaultNsRecordTtl: 3600, defaultSoaRecordTtl: 900, defaultResponsiblePerson: null, useSoaSerialDateScheme: true,
|
|
minSoaRefresh: 300, minSoaRetry: 300, zoneTransferAllowedNetworks: ['10.0.0.0/8', '192.168.0.0/16'], notifyAllowedNetworks: [],
|
|
dnsServerEnableCheckForUpdate: true, dnsAppsEnableAutomaticUpdate: true,
|
|
ipv6Mode: 'Enabled', preferIPv6: false, enableUdpSocketPool: true, socketPoolExcludedPorts: [], udpPayloadSize: 1232, dnssecValidation: true,
|
|
eDnsClientSubnet: false, eDnsClientSubnetIPv4PrefixLength: 24, eDnsClientSubnetIPv6PrefixLength: 56, eDnsClientSubnetIpv4Override: null, eDnsClientSubnetIpv6Override: null,
|
|
qpmPrefixLimitsIPv4: [{ prefix: 32, udpLimit: 600, tcpLimit: 600 }, { prefix: 24, udpLimit: 6000, tcpLimit: 6000 }], qpmPrefixLimitsIPv6: [{ prefix: 128, udpLimit: 600, tcpLimit: 600 }],
|
|
qpmLimitSampleMinutes: 5, qpmLimitUdpTruncationPercentage: 50, qpmLimitBypassList: [],
|
|
clientTimeout: 4000, tcpSendTimeout: 10000, tcpReceiveTimeout: 10000, quicIdleTimeout: 60000, quicMaxInboundStreams: 100, listenBacklog: 100,
|
|
udpSendBufferSizeKB: 0, udpReceiveBufferSizeKB: 0, maxConcurrentResolutionsPerCore: 100,
|
|
webServiceLocalAddresses: ['0.0.0.0', '[::]'], webServiceHttpPort: 5380, webServiceEnableHttpUnixSocket: false, webServiceHttpUnixSocket: null,
|
|
webServiceEnableTlsUnixSocket: false, webServiceTlsUnixSocket: null, webServiceEnableTls: false, webServiceEnableHttp3: false, webServiceHttpToTlsRedirect: false,
|
|
webServiceUseSelfSignedTlsCertificate: false, webServiceTlsPort: 53443, webServiceReverseProxyAddresses: [], webServiceRealIpHeader: 'X-Real-IP',
|
|
webServiceCspFrameAncestorsHeader: "'self'", webServiceTlsCertificatePath: null, webServiceTlsCertificatePassword: null,
|
|
enableEDnsClientSubnetSourceAddress: false, enableDnsOverUdpProxy: false, enableDnsOverTcpProxy: false, enableDnsOverHttp: false, enableDnsOverHttpUnixSocket: false,
|
|
enableDnsOverHttpsUnixSocket: false, enableDnsOverTls: true, enableDnsOverHttps: true, enableDnsOverHttp3: false, enableDnsOverQuic: false, enableDnsOverHttpHelpRedirect: true,
|
|
dnsOverUdpProxyPort: 538, dnsOverTcpProxyPort: 538, dnsOverHttpPort: 80, dnsOverHttpUnixSocket: null, dnsOverHttpsUnixSocket: null, dnsOverTlsPort: 853, dnsOverHttpsPort: 443,
|
|
dnsOverQuicPort: 853, dnsReverseProxyNetworkACL: [], dnsOverHttpRealIpHeader: 'X-Real-IP', dnsTlsCertificatePath: '/etc/dns/cert.pfx', dnsTlsCertificatePassword: '************',
|
|
tsigKeys: [{ keyName: 'transfer.example.net', sharedSecret: 'c2VjcmV0LXRyYW5zZmVy', algorithmName: 'hmac-sha256' }],
|
|
recursion: 'AllowOnlyForPrivateNetworks', recursionNetworkACL: ['!192.168.10.0/24', '192.168.0.0/16'], randomizeName: true, qnameMinimization: true,
|
|
locallyServedDnsZones: true, resolverRetries: 2, resolverTimeout: 1500, resolverConcurrency: 2, resolverMaxStackCount: 16,
|
|
saveCache: false, serveStale: true, serveStaleTtl: 259200, serveStaleAnswerTtl: 30, serveStaleResetTtl: 30, serveStaleMaxWaitTime: 1800,
|
|
cacheMaximumEntries: 10000, cacheMinimumRecordTtl: 10, cacheMaximumRecordTtl: 86400, cacheNegativeRecordTtl: 300, cacheFailureRecordTtl: 60,
|
|
enableCachePrefetch: true, cachePrefetchEligibility: 2, cachePrefetchTrigger: 9,
|
|
enableBlocking: true, allowTxtBlockingReport: true, temporaryDisableBlockingTill: null, blockingBypassList: [], blockingType: 'NxDomain', blockingAnswerTtl: 30,
|
|
customBlockingAddresses: [], blockListUrls: ['https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts', '# Privacy', 'https://big.oisd.nl/domainswild', 'https://adguardteam.github.io/AdGuardSDNSFilter/Filters/filter.txt'],
|
|
blockListUpdateIntervalHours: 24, blockListNextUpdatedOn: iso(now + 3 * 3_600_000),
|
|
proxy: null, forwarders: ['1.1.1.1', '9.9.9.9'], forwarderProtocol: 'Udp', concurrentForwarding: true, forwarderRetries: 3, forwarderTimeout: 2000, forwarderConcurrency: 2,
|
|
loggingType: 'File, Console', ignoreResolverLogs: false, logQueries: false, useLocalTime: false, logFolder: 'logs', maxLogFileDays: 365, enableInMemoryStats: false, maxStatFileDays: 365,
|
|
}
|
|
|
|
// ── DHCP ───────────────────────────────────────────────────────────────────
|
|
let scopes = [
|
|
{ name: 'Main', enabled: true, startingAddress: '192.168.10.10', endingAddress: '192.168.10.200', subnetMask: '255.255.255.0', networkAddress: '192.168.10.0', broadcastAddress: '192.168.10.255', interfaceAddress: '192.168.10.2' },
|
|
{ name: 'Guests', enabled: false, startingAddress: '172.16.5.10', endingAddress: '172.16.5.100', subnetMask: '255.255.255.0', networkAddress: '172.16.5.0', broadcastAddress: '172.16.5.255', interfaceAddress: '172.16.5.2' },
|
|
]
|
|
const leases = [
|
|
['Main', 'Dynamic', 'a4:83:e7:10:2c:91', '192.168.10.14', 'laptop-anna.lan', 60 * 5], ['Main', 'Reserved', 'f0:2f:74:55:0a:3d', '192.168.10.31', 'tv-livingroom.lan', 0],
|
|
['Main', 'Dynamic', '3c:22:fb:9e:41:07', '192.168.10.77', 'phone-ivan.lan', 8], ['Main', 'Dynamic', 'b8:27:eb:6d:19:c2', '192.168.10.52', 'printer-office.lan', 60 * 20],
|
|
['Main', 'Dynamic', '00:1a:79:2b:63:4f', '192.168.10.66', 'desktop-pavel.lan', 60 * 11], ['Guests', 'Dynamic', 'd8:3a:dd:21:6c:04', '172.16.5.20', null, 100],
|
|
].map(([scope, type, mac, address, host, left]) => ({ scope, type, hardwareAddress: mac, clientIdentifier: '01-' + mac.replace(/:/g, '-'), address, hostName: host, leaseObtained: ago(60 * 3), leaseExpires: type === 'Reserved' ? ago(-60 * 24 * 365) : ago(-left) }))
|
|
const scopeDetail = (name) => ({
|
|
name, startingAddress: '192.168.10.10', endingAddress: '192.168.10.200', subnetMask: '255.255.255.0', leaseTimeDays: 1, leaseTimeHours: 0, leaseTimeMinutes: 0, offerDelayTime: 0,
|
|
pingCheckEnabled: false, pingCheckTimeout: 1000, pingCheckRetries: 2, domainName: 'lan', domainSearchList: [], dnsUpdates: true, dnsOverwriteForDynamicLease: false, dnsTtl: 900,
|
|
routerAddress: '192.168.10.1', useThisDnsServer: true, dnsServers: [], exclusions: [{ startingAddress: '192.168.10.100', endingAddress: '192.168.10.110' }],
|
|
reservedLeases: [{ hostName: 'tv-livingroom.lan', hardwareAddress: 'f0:2f:74:55:0a:3d', address: '192.168.10.31', comments: 'Living room' }],
|
|
allowOnlyReservedLeases: false, blockLocallyAdministeredMacAddresses: false, ignoreClientIdentifierOption: false,
|
|
})
|
|
|
|
// ── Apps ───────────────────────────────────────────────────────────────────
|
|
const app = (isQueryLogs) => ({ classPath: 'Example.App', description: '', isAppRecordRequestHandler: false, recordDataTemplate: null, isRequestController: false, isAuthoritativeRequestHandler: false, isRequestBlockingHandler: !isQueryLogs, isQueryLogger: isQueryLogs, isQueryLogs, isPostProcessor: false })
|
|
let apps = [
|
|
{ name: 'Query Logs (Sqlite)', description: 'Logs DNS queries into a SQLite database', version: '7.0', dnsApps: [{ ...app(true), classPath: 'QueryLogsSqlite.App' }] },
|
|
{ name: 'Advanced Blocking', description: 'Group based blocking with regex', version: '10.1', updateAvailable: true, updateVersion: '10.2', updateUrl: 'https://download.technitium.com/dns/apps/AdvancedBlockingApp-v10.2.zip', dnsApps: [{ ...app(false), classPath: 'AdvancedBlocking.App' }] },
|
|
{ name: 'Split Horizon', description: 'Different answers for different networks', version: '9.0', dnsApps: [{ ...app(false), classPath: 'SplitHorizon.SimpleAddress' }, { ...app(false), classPath: 'SplitHorizon.SimpleCNAME' }] },
|
|
]
|
|
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/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) }) }
|
|
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, scale ${SCALE})`))
|