feat: enhance grpc, dns monitoring

This commit is contained in:
phatlet
2026-06-29 14:51:34 +07:00
parent ef46836c5b
commit 29fcd22e20
10 changed files with 324 additions and 40 deletions
+8
View File
@@ -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,
+130 -11
View File
@@ -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<typeof dns2.Packet> {
@@ -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<DNSResponse> {
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<DnsQueryOptions, "tlsPort" | "tlsServername" | "allowSelfSignedCert" | "timeoutMs"> = {},
): Promise<DNSResponse> {
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<string[]> {
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<Record<string, DNSRecordResult[]>> {
const results: Record<string, DNSRecordResult[]> = {};
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;
}
}
}
}
+15 -6
View File
@@ -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 {
+15 -3
View File
@@ -61,7 +61,7 @@ class GrpcCall {
}
async execute(): Promise<MonitoringResult> {
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) => {
+5
View File
@@ -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;
}
@@ -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
@@ -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)
@@ -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() !== "");
@@ -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 @@
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-2">
<Label for="dns-transport">Transport</Label>
<Select.Root
type="single"
value={data.transport}
onValueChange={(v) => {
if (v) data.transport = v;
}}
>
<Select.Trigger id="dns-transport" class="w-full">
{data.transport === "TLS" ? "DNS-over-TLS" : "UDP"}
</Select.Trigger>
<Select.Content>
<Select.Item value="UDP">UDP - Standard DNS (port 53)</Select.Item>
<Select.Item value="TLS">TLS - DNS-over-TLS (port 853)</Select.Item>
</Select.Content>
</Select.Root>
</div>
<div class="flex flex-col gap-2">
<Label for="dns-host">Host <span class="text-destructive">*</span></Label>
<Input id="dns-host" bind:value={data.host} placeholder="example.com" />
</div>
<div class="flex flex-col gap-2">
<Label for="dns-nameserver">Name Server (optional)</Label>
<Input id="dns-nameserver" bind:value={data.nameServer} placeholder="Leave empty for authoritative lookup" />
<p class="text-muted-foreground text-xs">Leave blank to use authoritative DNS nameservers automatically.</p>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-2">
<Label for="dns-nameserver">
Name Server
{#if usesTls}
<span class="text-destructive">*</span>
{:else}
(optional)
{/if}
</Label>
<Input
id="dns-nameserver"
bind:value={data.nameServer}
placeholder={usesTls ? "1.1.1.1 or dns.example.com" : "Leave empty for authoritative lookup"}
/>
{#if usesTls}
<p class="text-muted-foreground text-xs">
DoT resolver address. For IP resolvers, set TLS Server Name when required (e.g. 8.8.8.8 → dns.google).
</p>
{:else}
<p class="text-muted-foreground text-xs">Leave blank to use authoritative DNS nameservers automatically.</p>
{/if}
</div>
{#if usesTls}
<div class="flex flex-col gap-2">
<Label for="dns-tls-port">TLS Port</Label>
<Input id="dns-tls-port" type="number" min="1" max="65535" bind:value={data.tlsPort} placeholder="853" />
</div>
{/if}
</div>
{#if usesTls}
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-2">
<Label for="dns-tls-servername">TLS Server Name (optional)</Label>
<Input id="dns-tls-servername" bind:value={data.tlsServername} placeholder="dns.google" />
<p class="text-muted-foreground text-xs">SNI hostname for TLS. Required for many public resolvers when using an IP address.</p>
</div>
<div class="flex items-center gap-3 pt-6">
<Switch id="dns-self-signed" bind:checked={data.allowSelfSignedCert} />
<Label for="dns-self-signed">Allow self-signed TLS certificates</Label>
</div>
</div>
{/if}
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-2">
<Label for="dns-record">Lookup Record</Label>
@@ -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;
</script>
<div class="space-y-4">
@@ -43,4 +44,11 @@
<Switch id="grpc-tls" bind:checked={data.tls} />
<Label for="grpc-tls">Use TLS</Label>
</div>
{#if data.tls}
<div class="flex items-center space-x-2">
<Switch id="grpc-insecure" bind:checked={data.insecure} />
<Label for="grpc-insecure">Allow Insecure TLS (skip certificate verification)</Label>
</div>
{/if}
</div>