since3-portal/modules/shared/lib/reachability.ts
2026-09-29 15:20:50 +02:00

124 lines
4.9 KiB
TypeScript

import "server-only"
import dns from "node:dns"
import http from "node:http"
import https from "node:https"
import net from "node:net"
export type CheckResult = {
ok: boolean
statusCode: number | null
responseMs: number | null
error: string | null
}
const TIMEOUT_MS = 8000
// Nur öffentliche Ziele: schützt davor, über den Check interne Dienste (Metadata-API, localhost, LAN) anzusprechen.
const blocked = new net.BlockList()
for (const [address, prefix] of [
["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8], ["169.254.0.0", 16],
["172.16.0.0", 12], ["192.0.0.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15], ["224.0.0.0", 4], ["240.0.0.0", 4],
] as const) {
blocked.addSubnet(address, prefix, "ipv4")
}
for (const [address, prefix] of [["::", 128], ["::1", 128], ["fc00::", 7], ["fe80::", 10], ["ff00::", 8]] as const) {
blocked.addSubnet(address, prefix, "ipv6")
}
function isBlockedAddress(address: string, family: number) {
const mappedV4 = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address)?.[1]
if (mappedV4) return blocked.check(mappedV4, "ipv4")
return blocked.check(address, family === 6 ? "ipv6" : "ipv4")
}
// Wird von http(s).request für jede Verbindung genutzt, also auch gegen DNS-Rebinding zwischen Prüfung und Connect.
const safeLookup: net.LookupFunction = (hostname, options, callback) => {
dns.lookup(hostname, { ...options, all: true }, (error, addresses) => {
if (error) return callback(error, "", 4)
const list = addresses as dns.LookupAddress[]
const forbidden = list.find((entry) => isBlockedAddress(entry.address, entry.family))
if (list.length === 0 || forbidden) {
return callback(Object.assign(new Error("Ziel nicht erlaubt"), { code: "EBLOCKED" }), "", 4)
}
if (options.all) return (callback as unknown as (err: null, addresses: dns.LookupAddress[]) => void)(null, list)
callback(null, list[0].address, list[0].family)
})
}
const DOMAIN_PATTERN = /^(https?:\/\/)?[a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,}(:\d{1,5})?(\/\S*)?$/i
// "www.firma.de" oder "https://firma.de/shop" → prüfbar; "Server", "info@firma.de" → nicht.
export function toCheckableUrl(value: string | null): URL | null {
if (!value) return null
const trimmed = value.trim()
if (trimmed.includes("@") || !DOMAIN_PATTERN.test(trimmed)) return null
try {
const url = new URL(/^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`)
return url.protocol === "http:" || url.protocol === "https:" ? url : null
} catch {
return null
}
}
function describeError(error: NodeJS.ErrnoException) {
switch (error.code) {
case "EBLOCKED": return "Ziel nicht erlaubt"
case "ENOTFOUND": case "EAI_AGAIN": return "Domain nicht gefunden"
case "ECONNREFUSED": return "Verbindung abgelehnt"
case "ECONNRESET": return "Verbindung abgebrochen"
case "ETIMEDOUT": return "Zeitüberschreitung"
default:
return error.code?.startsWith("ERR_TLS") || error.code?.includes("CERT")
? "SSL-Zertifikat ungültig"
: "Nicht erreichbar"
}
}
export function checkUrl(url: URL): Promise<CheckResult> {
// Bei IP-Literalen ruft Node den lookup-Hook nicht auf, daher hier separat prüfen.
const host = url.hostname.replace(/^\[|\]$/g, "")
const ipFamily = net.isIP(host)
if (ipFamily !== 0 && isBlockedAddress(host, ipFamily)) {
return Promise.resolve({ ok: false, statusCode: null, responseMs: null, error: "Ziel nicht erlaubt" })
}
const client = url.protocol === "http:" ? http : https
const started = performance.now()
return new Promise((resolve) => {
let settled = false
const finish = (result: CheckResult) => {
if (settled) return
settled = true
resolve(result)
}
const request = client.request(
url,
{
method: "GET",
lookup: safeLookup,
timeout: TIMEOUT_MS,
headers: { "User-Agent": "Since3-Statuscheck/1.0", Accept: "text/html,*/*" },
},
(response) => {
const statusCode = response.statusCode ?? 0
response.destroy()
finish({
ok: statusCode > 0 && statusCode < 400,
statusCode,
responseMs: Math.round(performance.now() - started),
error: statusCode >= 400 ? `HTTP ${statusCode}` : null,
})
}
)
request.on("timeout", () => {
request.destroy(Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }))
})
request.on("error", (error: NodeJS.ErrnoException) => {
finish({ ok: false, statusCode: null, responseMs: null, error: describeError(error) })
})
request.end()
})
}