From 29fcd22e20c0b739a519fcf9ebef3d1b019a6f3c Mon Sep 17 00:00:00 2001 From: phatlet Date: Mon, 29 Jun 2026 14:51:34 +0700 Subject: [PATCH] feat: enhance grpc, dns monitoring --- src/lib/clientTools.ts | 8 + src/lib/server/dns.ts | 141 ++++++++++++++++-- src/lib/server/services/dnsCall.ts | 21 ++- src/lib/server/services/grpcCall.ts | 18 ++- src/lib/server/types/monitor.ts | 5 + .../(docs)/docs/content/v4/monitors/dns.md | 67 +++++++-- .../(docs)/docs/content/v4/monitors/grpc.md | 9 +- .../[tag]/components/MonitorTypeCard.svelte | 13 +- .../monitors/[tag]/types/monitor-dns.svelte | 74 ++++++++- .../monitors/[tag]/types/monitor-grpc.svelte | 8 + 10 files changed, 324 insertions(+), 40 deletions(-) diff --git a/src/lib/clientTools.ts b/src/lib/clientTools.ts index 7b095742..c21587ec 100644 --- a/src/lib/clientTools.ts +++ b/src/lib/clientTools.ts @@ -152,6 +152,13 @@ function IsValidNameServer(nameServer: string): boolean { const regex = /^([0-9]{1,3}\.){3}[0-9]{1,3}$/; return regex.test(nameServer); } +function IsValidDnsResolver(resolver: string): boolean { + const ipType = ValidateIpAddress(resolver); + if (ipType === "IP4" || ipType === "IP6") { + return true; + } + return IsValidHost(resolver); +} const IsValidURL = function (url: string): boolean { return /^(http|https):\/\/[^ "]+$/.test(url); }; @@ -383,6 +390,7 @@ export { ValidateIpAddress, IsValidHost, IsValidNameServer, + IsValidDnsResolver, IsValidURL, IsValidPort, CollapseStatusCounts, diff --git a/src/lib/server/dns.ts b/src/lib/server/dns.ts index 452e9f54..18d0e992 100644 --- a/src/lib/server/dns.ts +++ b/src/lib/server/dns.ts @@ -1,7 +1,8 @@ import dns2 from "dns2"; import dgram, { type Socket } from "dgram"; +import tls from "tls"; import { Resolver as NodeResolver } from "node:dns/promises"; -import { AllRecordTypes } from "../clientTools"; +import { AllRecordTypes, IsValidHost, ValidateIpAddress } from "../clientTools"; interface DNSAnswer { name: string; @@ -25,13 +26,32 @@ interface DNSRecordResult { data: unknown; } +export interface DnsQueryOptions { + transport?: "UDP" | "TLS"; + nameserverOverride?: string; + tlsPort?: number; + tlsServername?: string; + allowSelfSignedCert?: boolean; + timeoutMs?: number; +} + +const DEFAULT_DOT_PORT = 853; +const DEFAULT_QUERY_TIMEOUT_MS = 3000; + class DNSResolver { nameserver: string; - socket: Socket; + socket: Socket | null; constructor() { this.nameserver = "8.8.8.8"; - this.socket = dgram.createSocket("udp4"); + this.socket = null; + } + + private getUdpSocket(): Socket { + if (!this.socket) { + this.socket = dgram.createSocket("udp4"); + } + return this.socket; } createQuery(domain: string, type: string): InstanceType { @@ -49,11 +69,24 @@ class DNSResolver { return packet; } + private resolveTlsServername(nameserver: string, tlsServername?: string): string | undefined { + const configuredServername = tlsServername?.trim(); + if (configuredServername) { + return configuredServername; + } + if (IsValidHost(nameserver)) { + return nameserver; + } + return undefined; + } + async query(domain: string, recordType: string, nameserverOverride?: string): Promise { + const socket = this.getUdpSocket(); return new Promise((resolve, reject) => { const query = this.createQuery(domain, recordType); const buffer = query.toBuffer(); const targetNameserver = nameserverOverride || this.nameserver; + const timeoutMs = DEFAULT_QUERY_TIMEOUT_MS; const onMessage = (message: Buffer) => { clearTimeout(timeoutId); @@ -63,22 +96,87 @@ class DNSResolver { }; const timeoutId = setTimeout(() => { - this.socket.removeListener("message", onMessage); + socket.removeListener("message", onMessage); reject(new Error(`DNS query timed out for ${domain} (${recordType}) via ${targetNameserver}`)); - }, 3000); + }, timeoutMs); - this.socket.once("message", onMessage); + socket.once("message", onMessage); - this.socket.send(buffer, 0, buffer.length, 53, targetNameserver, (err: Error | null) => { + socket.send(buffer, 0, buffer.length, 53, targetNameserver, (err: Error | null) => { if (err) { clearTimeout(timeoutId); - this.socket.removeListener("message", onMessage); + socket.removeListener("message", onMessage); reject(err); } }); }); } + async queryOverTls( + domain: string, + recordType: string, + nameserver: string, + options: Pick = {}, + ): Promise { + const query = this.createQuery(domain, recordType); + const buffer = query.toBuffer(); + const lengthPrefix = Buffer.alloc(2); + lengthPrefix.writeUInt16BE(buffer.length, 0); + const message = Buffer.concat([lengthPrefix, buffer]); + const port = options.tlsPort ?? DEFAULT_DOT_PORT; + const timeoutMs = options.timeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS; + const servername = this.resolveTlsServername(nameserver, options.tlsServername); + const ipType = ValidateIpAddress(nameserver); + + return new Promise((resolve, reject) => { + let responseBuffer = Buffer.alloc(0); + let expectedLength: number | null = null; + + const socket = tls.connect({ + host: nameserver, + port, + servername, + rejectUnauthorized: !options.allowSelfSignedCert, + family: ipType === "IP6" ? 6 : ipType === "IP4" ? 4 : undefined, + }); + + const timeoutId = setTimeout(() => { + socket.destroy(); + reject(new Error(`DNS-over-TLS query timed out for ${domain} (${recordType}) via ${nameserver}:${port}`)); + }, timeoutMs); + + const cleanup = () => { + clearTimeout(timeoutId); + }; + + socket.on("error", (err) => { + cleanup(); + reject(err); + }); + + socket.on("data", (chunk: Buffer) => { + responseBuffer = Buffer.concat([responseBuffer, chunk]); + + if (expectedLength === null && responseBuffer.length >= 2) { + expectedLength = responseBuffer.readUInt16BE(0); + } + + if (expectedLength !== null && responseBuffer.length >= expectedLength + 2) { + cleanup(); + const responseData = responseBuffer.subarray(2, 2 + expectedLength); + // @ts-expect-error dns2 types are incomplete + const response = dns2.Packet.parse(responseData) as DNSResponse; + socket.end(); + resolve(response); + } + }); + + socket.on("secureConnect", () => { + socket.write(message); + }); + }); + } + async getAuthoritativeNameServers(domain: string, resolverNameserver?: string): Promise { const resolver = new NodeResolver(); resolver.setServers([resolverNameserver || this.nameserver]); @@ -140,12 +238,30 @@ class DNSResolver { async getRecord( domain: string, recordType: string, - nameserverOverride?: string, + options: DnsQueryOptions = {}, ): Promise> { const results: Record = {}; + const transport = options.transport ?? "UDP"; + const nameserverOverride = options.nameserverOverride?.trim() || undefined; try { - const response = await this.queryAuthoritativeRecord(domain, recordType, nameserverOverride); + let response: DNSResponse; + + if (transport === "TLS") { + if (!nameserverOverride) { + throw new Error("Name server is required for DNS-over-TLS queries"); + } + + response = await this.queryOverTls(domain, recordType, nameserverOverride, { + tlsPort: options.tlsPort, + tlsServername: options.tlsServername, + allowSelfSignedCert: options.allowSelfSignedCert, + timeoutMs: options.timeoutMs, + }); + } else { + response = await this.queryAuthoritativeRecord(domain, recordType, nameserverOverride); + } + results[recordType] = response.answers.map((answer: DNSAnswer) => ({ name: answer.name, type: recordType, @@ -157,7 +273,10 @@ class DNSResolver { console.error("Error querying DNS records:", error); throw error; } finally { - this.socket.close(); + if (this.socket) { + this.socket.close(); + this.socket = null; + } } } } diff --git a/src/lib/server/services/dnsCall.ts b/src/lib/server/services/dnsCall.ts index 459dee31..6103652d 100644 --- a/src/lib/server/services/dnsCall.ts +++ b/src/lib/server/services/dnsCall.ts @@ -32,18 +32,26 @@ class DnsCall { let matchType = this.monitor.type_data.matchType; let values = this.monitor.type_data.values; const configuredNameServer = this.monitor.type_data.nameServer?.trim() || undefined; + const transport = this.monitor.type_data.transport ?? "UDP"; const queryStartTime = performance.now(); try { - let dnsRes = await dnsResolver.getRecord(host, recordType, configuredNameServer); + let dnsRes = await dnsResolver.getRecord(host, recordType, { + transport, + nameserverOverride: configuredNameServer, + tlsPort: this.monitor.type_data.tlsPort, + tlsServername: this.monitor.type_data.tlsServername, + allowSelfSignedCert: this.monitor.type_data.allowSelfSignedCert, + }); let latency = Math.round(performance.now() - queryStartTime); + const transportLabel = transport === "TLS" ? "DNS-over-TLS" : "DNS"; if (dnsRes[recordType] === undefined) { return { status: GC.DOWN, latency: latency, type: GC.REALTIME, - error_message: `No DNS ${recordType} response found for ${host}`, + error_message: `No ${transportLabel} ${recordType} response found for ${host}`, }; } let data = dnsRes[recordType]; @@ -55,7 +63,7 @@ class DnsCall { status: GC.DOWN, latency: latency, type: GC.REALTIME, - error_message: `No DNS ${recordType} records returned for ${host}`, + error_message: `No ${transportLabel} ${recordType} records returned for ${host}`, }; } @@ -66,7 +74,7 @@ class DnsCall { status: GC.DOWN, latency: latency, type: GC.REALTIME, - error_message: `DNS ${recordType} mismatch for ${host}. Missing: ${missingValues.join(", ")}`, + error_message: `${transportLabel} ${recordType} mismatch for ${host}. Missing: ${missingValues.join(", ")}`, }; } return { @@ -88,17 +96,18 @@ class DnsCall { status: GC.DOWN, latency: latency, type: GC.REALTIME, - error_message: `DNS ${recordType} mismatch for ${host}. Got: ${dnsData.join(", ")}`, + error_message: `${transportLabel} ${recordType} mismatch for ${host}. Got: ${dnsData.join(", ")}`, }; } } catch (error) { const message = error instanceof Error ? error.message : String(error); const latency = Math.round(performance.now() - queryStartTime); + const transportLabel = transport === "TLS" ? "DNS-over-TLS" : "DNS"; return { status: GC.DOWN, latency, type: GC.REALTIME, - error_message: `DNS query failed for ${host} (${recordType}): ${message}`, + error_message: `${transportLabel} query failed for ${host} (${recordType}): ${message}`, }; } return { diff --git a/src/lib/server/services/grpcCall.ts b/src/lib/server/services/grpcCall.ts index 3bc733f5..fc2b294e 100644 --- a/src/lib/server/services/grpcCall.ts +++ b/src/lib/server/services/grpcCall.ts @@ -61,7 +61,7 @@ class GrpcCall { } async execute(): Promise { - const { host, port, service, tls, timeout } = this.monitor.type_data; + const { host, port, service, tls, insecure, timeout } = this.monitor.type_data; const timeoutMs = timeout || 10000; const target = `${host}:${port}`; @@ -72,8 +72,20 @@ class GrpcCall { // eslint-disable-next-line @typescript-eslint/no-explicit-any const healthService = (proto.grpc as any).health.v1.Health; - const credentials = tls ? grpc.credentials.createSsl() : grpc.credentials.createInsecure(); - const client = new healthService(target, credentials); + const credentials = + tls && insecure + ? grpc.credentials.createSsl(null, null, null, { rejectUnauthorized: false }) + : tls + ? grpc.credentials.createSsl() + : grpc.credentials.createInsecure(); + const clientOptions: grpc.ChannelOptions = {}; + + // Keep channel-level insecure knob for grpc-js compatibility. + if (tls && insecure) { + clientOptions["grpc-node.tls_reject_unauthorized"] = 0; + } + + const client = new healthService(target, credentials, clientOptions); const deadline = new Date(Date.now() + timeoutMs); const result = await new Promise<{ status: string }>((resolve, reject) => { diff --git a/src/lib/server/types/monitor.ts b/src/lib/server/types/monitor.ts index 78eb38e6..d038292c 100644 --- a/src/lib/server/types/monitor.ts +++ b/src/lib/server/types/monitor.ts @@ -34,6 +34,10 @@ export interface DnsMonitorTypeData { lookupRecord: string; matchType: "ALL" | "ANY"; values: string[]; + transport?: "UDP" | "TLS"; + tlsPort?: number; + tlsServername?: string; + allowSelfSignedCert?: boolean; } export type { PingHost, PingMonitorTypeData }; @@ -86,6 +90,7 @@ export interface GrpcMonitorTypeData { port: number; service?: string; tls?: boolean; + insecure?: boolean; timeout?: number; } diff --git a/src/routes/(docs)/docs/content/v4/monitors/dns.md b/src/routes/(docs)/docs/content/v4/monitors/dns.md index b32f9658..16660c29 100644 --- a/src/routes/(docs)/docs/content/v4/monitors/dns.md +++ b/src/routes/(docs)/docs/content/v4/monitors/dns.md @@ -1,6 +1,6 @@ --- title: DNS Monitor -description: Validate DNS records against expected values +description: Validate DNS records against expected values over UDP or DNS-over-TLS --- DNS monitors query records for a host and compare returned values to your expected values. @@ -14,17 +14,43 @@ Configure: - `matchType` (`ANY` or `ALL`, default `ANY`) - at least one expected value in `values` -`nameServer` is optional (leave blank for resolver defaults). +For UDP transport, `nameServer` is optional (leave blank for resolver defaults). + +For DNS-over-TLS (`transport: "TLS"`), `nameServer` is required. ## Configuration fields {#configuration-fields} -| Field | Type | Default | Notes | -| :------------- | :--------- | :------ | :-------------------------- | -| `host` | `string` | — | Required | -| `nameServer` | `string` | `""` | Optional override | -| `lookupRecord` | `string` | `A` | Required | -| `matchType` | `ANY\|ALL` | `ANY` | Required | -| `values` | `string[]` | `[]` | Required (non-empty values) | +| Field | Type | Default | Notes | +| :-------------------- | :------------ | :------ | :------------------------------------------------- | +| `host` | `string` | — | Required | +| `transport` | `UDP\|TLS` | `UDP` | Query transport | +| `nameServer` | `string` | `""` | Optional for UDP; required for TLS | +| `tlsPort` | `number` | `853` | DoT port when `transport` is `TLS` | +| `tlsServername` | `string` | `""` | TLS SNI hostname for IP-based DoT resolvers | +| `allowSelfSignedCert` | `boolean` | `false` | Disable TLS verification for private DoT resolvers | +| `lookupRecord` | `string` | `A` | Required | +| `matchType` | `ANY\|ALL` | `ANY` | Required | +| `values` | `string[]` | `[]` | Required (non-empty values) | + +## Transport modes {#transport-modes} + +### UDP (default) + +Standard DNS over UDP port 53. When `nameServer` is blank, Kener walks authoritative nameservers for the zone before falling back to `8.8.8.8`. + +### DNS-over-TLS (DoT) + +Encrypted DNS over TCP port 853 (RFC 7858). Queries go directly to the configured resolver — authoritative lookup is not used. + +Common public resolvers: + +| Resolver | Address | TLS Server Name | +| :------- | :-------- | :------------------ | +| Google | `8.8.8.8` | `dns.google` | +| Cloudflare | `1.1.1.1` | `cloudflare-dns.com` | +| Quad9 | `9.9.9.9` | `dns.quad9.net` | + +When connecting to an IP address, set `tlsServername` to the provider's hostname if the resolver requires SNI. ## Match behavior {#match-behavior} @@ -39,7 +65,9 @@ Before comparison, values are normalized by runtime logic: - trailing `.` removed - trimmed whitespace -## Example {#example} +## Examples {#examples} + +### UDP ```json { @@ -53,8 +81,27 @@ Before comparison, values are normalized by runtime logic: } ``` +### DNS-over-TLS + +```json +{ + "type": "DNS", + "type_data": { + "host": "example.com", + "transport": "TLS", + "nameServer": "1.1.1.1", + "tlsPort": 853, + "tlsServername": "cloudflare-dns.com", + "lookupRecord": "A", + "matchType": "ANY", + "values": ["93.184.216.34"] + } +} +``` + ## Troubleshooting {#troubleshooting} - **Unexpected DOWN**: copy exact record output (after normalization rules) - **No response**: check `lookupRecord` type and resolver reachability - **Partial mismatches**: use `ANY` for multi-value dynamic DNS setups +- **DoT TLS errors**: set `tlsServername` when using an IP resolver; enable `allowSelfSignedCert` only for trusted private resolvers diff --git a/src/routes/(docs)/docs/content/v4/monitors/grpc.md b/src/routes/(docs)/docs/content/v4/monitors/grpc.md index 157efd1e..fcca60d6 100644 --- a/src/routes/(docs)/docs/content/v4/monitors/grpc.md +++ b/src/routes/(docs)/docs/content/v4/monitors/grpc.md @@ -31,8 +31,9 @@ Connection errors and timeouts return **DOWN**. | `host` | `string` | — | Required | | `port` | `number` | `50051` | Required | | `service` | `string` | `""` | Fully qualified service name; empty = overall | -| `tls` | `boolean` | `false` | Use TLS credentials | -| `timeout` | `number` | `10000` | Request deadline in ms | +| `tls` | `boolean` | `false` | Use TLS credentials | +| `insecure` | `boolean` | `false` | Skip TLS certificate verification (TLS only) | +| `timeout` | `number` | `10000` | Request deadline in ms | ## Example {#example} @@ -44,6 +45,7 @@ Connection errors and timeouts return **DOWN**. "port": 50051, "service": "my.package.MyService", "tls": true, + "insecure": true, "timeout": 5000 } } @@ -54,4 +56,5 @@ Connection errors and timeouts return **DOWN**. - **Immediate DOWN**: wrong host/port, service not running, or firewall blocking the connection - **DEGRADED**: server returned `UNKNOWN` or `SERVICE_UNKNOWN` — verify the service name is registered - **Timeout**: increase `timeout` or check network latency to the gRPC server -- **TLS errors**: ensure the server has a valid certificate, or check if TLS should be disabled +- **TLS errors**: ensure the server has a valid certificate, disable `insecure`, or check if TLS should be disabled +- **Self-signed certificate** (messages mentioning `--use-system-ca`): set `tls: true` and `insecure: true` on the monitor (same as `grpcurl -insecure`). Alternatively, trust the issuer: run Node with `NODE_OPTIONS='--use-system-ca'` so the OS CA store is used (Node 22+ defaults to bundled CA roots only) diff --git a/src/routes/(manage)/manage/app/monitors/[tag]/components/MonitorTypeCard.svelte b/src/routes/(manage)/manage/app/monitors/[tag]/components/MonitorTypeCard.svelte index 3d797ddf..7b7b039c 100644 --- a/src/routes/(manage)/manage/app/monitors/[tag]/components/MonitorTypeCard.svelte +++ b/src/routes/(manage)/manage/app/monitors/[tag]/components/MonitorTypeCard.svelte @@ -11,7 +11,7 @@ import type { GroupMonitorTypeData, MonitoringResult } from "$lib/server/types/monitor.js"; import { MONITOR_TYPES, type MonitorType } from "$lib/types/monitor.js"; import { toast } from "svelte-sonner"; - import { ValidateIpAddress, IsValidHost, IsValidNameServer, IsValidURL, IsValidPort } from "$lib/clientTools"; + import { ValidateIpAddress, IsValidHost, IsValidNameServer, IsValidDnsResolver, IsValidURL, IsValidPort } from "$lib/clientTools"; import { GAMEDIG_SOCKET_TIMEOUT } from "$lib/anywhere"; import { resolve } from "$app/paths"; import clientResolver from "$lib/client/resolver.js"; @@ -153,7 +153,16 @@ const data = typeData as any; if (!data.host || !IsValidHost(data.host)) return false; const nameServer = (data.nameServer || "").trim(); - if (nameServer && !IsValidNameServer(nameServer)) return false; + const transport = data.transport || "UDP"; + if (transport === "TLS") { + if (!nameServer || !IsValidDnsResolver(nameServer)) return false; + const tlsPort = Number(data.tlsPort ?? 853); + if (!IsValidPort(String(tlsPort))) return false; + const tlsServername = (data.tlsServername || "").trim(); + if (tlsServername && !IsValidHost(tlsServername)) return false; + } else if (nameServer && !IsValidNameServer(nameServer)) { + return false; + } if (!data.lookupRecord) return false; if (!data.values || !Array.isArray(data.values) || data.values.length === 0) return false; const hasNonEmptyValue = data.values.some((val: string) => val && val.trim() !== ""); diff --git a/src/routes/(manage)/manage/app/monitors/[tag]/types/monitor-dns.svelte b/src/routes/(manage)/manage/app/monitors/[tag]/types/monitor-dns.svelte index 96a2ff97..48419ac3 100644 --- a/src/routes/(manage)/manage/app/monitors/[tag]/types/monitor-dns.svelte +++ b/src/routes/(manage)/manage/app/monitors/[tag]/types/monitor-dns.svelte @@ -2,6 +2,7 @@ import { Input } from "$lib/components/ui/input/index.js"; import { Label } from "$lib/components/ui/label/index.js"; import { Button } from "$lib/components/ui/button/index.js"; + import { Switch } from "$lib/components/ui/switch/index.js"; import * as Select from "$lib/components/ui/select/index.js"; import * as InputGroup from "$lib/components/ui/input-group/index.js"; import Plus from "@lucide/svelte/icons/plus"; @@ -16,9 +17,14 @@ if (!data.nameServer) data.nameServer = ""; if (!data.lookupRecord) data.lookupRecord = "A"; if (!data.matchType) data.matchType = "ANY"; + if (!data.transport) data.transport = "UDP"; + if (!data.tlsPort) data.tlsPort = 853; + if (!data.tlsServername) data.tlsServername = ""; + if (data.allowSelfSignedCert === undefined) data.allowSelfSignedCert = false; if (!data.values) data.values = [""]; const recordTypes = Object.keys(AllRecordTypes); + const usesTls = $derived(data.transport === "TLS"); function addValue() { data.values = [...data.values, ""]; @@ -31,17 +37,75 @@
+
+ + { + if (v) data.transport = v; + }} + > + + {data.transport === "TLS" ? "DNS-over-TLS" : "UDP"} + + + UDP - Standard DNS (port 53) + TLS - DNS-over-TLS (port 853) + + +
-
- - -

Leave blank to use authoritative DNS nameservers automatically.

-
+
+
+ + + {#if usesTls} +

+ DoT resolver address. For IP resolvers, set TLS Server Name when required (e.g. 8.8.8.8 → dns.google). +

+ {:else} +

Leave blank to use authoritative DNS nameservers automatically.

+ {/if} +
+ {#if usesTls} +
+ + +
+ {/if} +
+ + {#if usesTls} +
+
+ + +

SNI hostname for TLS. Required for many public resolvers when using an IP address.

+
+
+ + +
+
+ {/if} +
diff --git a/src/routes/(manage)/manage/app/monitors/[tag]/types/monitor-grpc.svelte b/src/routes/(manage)/manage/app/monitors/[tag]/types/monitor-grpc.svelte index 2c70b610..cac88f03 100644 --- a/src/routes/(manage)/manage/app/monitors/[tag]/types/monitor-grpc.svelte +++ b/src/routes/(manage)/manage/app/monitors/[tag]/types/monitor-grpc.svelte @@ -12,6 +12,7 @@ if (!data.service) data.service = ""; if (!data.timeout) data.timeout = 10000; if (data.tls === undefined) data.tls = false; + if (data.insecure === undefined) data.insecure = false;
@@ -43,4 +44,11 @@
+ + {#if data.tls} +
+ + +
+ {/if}