Feat #7: Domain-Check für DNS, Webserver und Zertifikat #15

Merged
florian.wilde merged 2 commits from feat/7-domain-check into main 2026-08-21 08:53:24 +00:00
9 changed files with 733 additions and 0 deletions
+24
View File
@@ -353,6 +353,30 @@ prüft ihn gegen die registrierten Domains. Ein unbekannter Hostname endet mit
404 — über einen gefälschten Header lässt sich also keine fremde Domain 404 — über einen gefälschten Header lässt sich also keine fremde Domain
ansprechen. ansprechen.
### Nachprüfen, ob eine Domain wirklich hierher führt
Auf der Domain-Seite steht bei jedem Eintrag ein Knopf **Prüfen**. Er geht den
Weg der Scanner nach und meldet vier Dinge:
| Schritt | Was geprüft wird |
| --- | --- |
| DNS | Löst der Name auf? Auf welche Adressen? |
| Zertifikat | Gültig, von wem ausgestellt, wie lange noch? |
| Erreichbar | Antwortet dort ein Webserver, und wie schnell? |
| Ziel | Steht dahinter **diese** Installation? |
Der letzte Schritt ist der eigentliche Wert. Die Anwendung ruft dazu unter der
Domain eine eigene Route auf und lässt sich einen HMAC über eine frische
Zufallszahl zurückgeben; das Geheimnis dafür steht in der Datenbank und
verlässt den Server nie. Nur so unterscheidet sich „die Domain zeigt
irgendwohin“ von „die Domain zeigt hierher“.
> **Grenze der Aussage:** Der Aufruf startet dort, wo der Container läuft.
> Wer internes DNS oder Hairpin-NAT hat, bekommt womöglich einen Fehler,
> obwohl von außen alles stimmt. Umgekehrt gilt: Meldet die Prüfung von innen
> Erfolg, heißt das noch nicht, dass die Domain auch von außen erreichbar ist.
> Dafür bleibt der Blick von einem fremden Netz aus.
Zwei Dinge, die daraus folgen: Zwei Dinge, die daraus folgen:
- Der **Slug ist je Domain eindeutig**, nicht global. Derselbe sprechende - Der **Slug ist je Domain eindeutig**, nicht global. Derselbe sprechende
+23
View File
@@ -191,6 +191,29 @@ Konto nur bei **bestätigter** E-Mail-Adresse.
--- ---
## Der Domain-Check und seine Grenzen
Auf der Domain-Seite ruft der Knopf **Prüfen** die eingetragene Domain vom
Server aus auf. Drei Dinge grenzen das ein:
- Geprüft wird ausschließlich ein Hostname **aus der Datenbank**, nie einer
aus der Anfrage. Sonst wäre die Route ein Werkzeug, mit dem sich vom Server
aus beliebige Adressen abklopfen ließen.
- Auslösen darf sie nur, wer `instance:manage` hat — dieselbe Berechtigung,
die auch Domains anlegt.
- Die Route, die den Beweis liefert (`/api/domain-check`), ist **ohne
Anmeldung** erreichbar; der Aufruf kommt ja von außen. Sie gibt nichts
preis: Die Antwort ist ein HMAC über die mitgeschickte Zufallszahl. Ohne
das Geheimnis lässt er sich nicht bilden, und aus ihm folgt nichts über das
Geheimnis. Es entsteht beim ersten Check und verlässt den Server nie.
Beim Zertifikat läuft die Verbindung bewusst mit `rejectUnauthorized: false`.
Nur so lässt sich ein schlechtes Zertifikat **beschreiben** — abgelaufen,
selbst ausgestellt, falscher Name — statt bloß daran zu scheitern.
Übertragen wird über diese Verbindung nichts.
---
## Offen ## Offen
Bewusst nicht umgesetzt, mit Begründung. Bewusst nicht umgesetzt, mit Begründung.
@@ -0,0 +1,7 @@
-- Der Domain-Check ruft die Domain von aussen auf und prueft, ob er dabei bei
-- dieser Installation landet. Beweis dafuer ist ein HMAC ueber eine
-- Zufallszahl, gebildet mit einem Geheimnis, das den Server nie verlaesst.
--
-- Nullable und ohne Vorbelegung: Der Wert entsteht beim ersten Check. Eine
-- feste Vorbelegung waere fuer alle Installationen dieselbe und damit wertlos.
ALTER TABLE "InstanceSetting" ADD COLUMN "checkSecret" TEXT;
+8
View File
@@ -262,6 +262,14 @@ model InstanceSetting {
/// entwertet im Zweifel gedruckte Codes oder besetzt Adressen, die /// entwertet im Zweifel gedruckte Codes oder besetzt Adressen, die
/// jemand anderes gebraucht hätte. Administratoren können das freigeben. /// jemand anderes gebraucht hätte. Administratoren können das freigeben.
editorsMaySetSlug Boolean @default(false) editorsMaySetSlug Boolean @default(false)
/// Geheimnis fuer den Domain-Check.
///
/// Die Pruefung ruft die Domain von aussen auf und laesst sich einen HMAC
/// ueber eine Zufallszahl zurueckgeben. Nur wer dieses Geheimnis kennt,
/// kann ihn bilden — so steht fest, dass wirklich diese Installation
/// geantwortet hat und nicht irgendein anderer Server. Es verlaesst den
/// Server nie und entsteht beim ersten Check.
checkSecret String?
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
@@ -0,0 +1,43 @@
import { getActor } from "@/lib/auth/actor";
import { requirePermission } from "@/lib/auth/policy";
import { prisma } from "@/lib/db";
import { pruefeDomain } from "@/lib/domain-check";
import { errorResponse } from "@/lib/http";
import { QrRequestError } from "@/lib/qr/type-module";
export const dynamic = "force-dynamic";
type Context = { params: Promise<{ id: string }> };
/**
* Prüft eine registrierte Domain: DNS, Zertifikat, Erreichbarkeit — und ob
* dahinter wirklich diese Installation steht.
*
* `POST`, obwohl nichts gespeichert wird: Der Aufruf stößt eine Verbindung
* nach außen an und ist nichts, was sich vorsorglich abrufen lässt.
*
* Geprüft wird ausschließlich der Hostname aus der Datenbank. Ein Hostname
* aus der Anfrage würde diese Route zum Werkzeug machen, mit dem sich vom
* Server aus beliebige Adressen abklopfen lassen.
*/
export async function POST(
_request: Request,
context: Context,
): Promise<Response> {
try {
requirePermission(await getActor(), "instance:manage");
const { id } = await context.params;
const domain = await prisma.domain.findUnique({
where: { id },
select: { hostname: true },
});
if (!domain) {
throw new QrRequestError("Diese Domain gibt es nicht.", 404);
}
return Response.json({ report: await pruefeDomain(domain.hostname) });
} catch (error) {
return errorResponse(error);
}
}
+40
View File
@@ -0,0 +1,40 @@
import type { NextRequest } from "next/server";
import { bildeBeweis, getCheckSecret } from "@/lib/domain-check";
export const dynamic = "force-dynamic";
/**
* Beweis-Route für den Domain-Check.
*
* Bewusst **ohne Anmeldung**: Der Aufruf kommt von außen über die zu prüfende
* Domain und trägt keine Sitzung mit sich. Preisgegeben wird dabei nichts —
* die Antwort ist ein HMAC über die mitgeschickte Zufallszahl. Wer das
* Geheimnis nicht kennt, kann ihn nicht bilden; wer ihn sieht, lernt daraus
* nichts über das Geheimnis.
*
* Dass es diese Anwendung gibt, verrät ohnehin schon jede Landingpage.
*/
export async function GET(request: NextRequest): Promise<Response> {
const nonce = request.nextUrl.searchParams.get("nonce") ?? "";
// Eine Grenze nach oben, damit die Route nicht als Rechenknecht für fremde
// Daten taugt, und eine nach unten, damit sie nicht auf Zuruf antwortet.
if (!/^[a-f0-9]{16,64}$/.test(nonce)) {
return Response.json(
{ error: "Es fehlt eine gültige Zufallszahl." },
{ status: 400 },
);
}
try {
const proof = bildeBeweis(await getCheckSecret(), nonce);
return Response.json(
{ app: "qrcode-generator", proof },
{ headers: { "Cache-Control": "no-store" } },
);
} catch (error) {
console.error("Domain-Check konnte keinen Beweis bilden:", error);
return Response.json({ error: "Nicht möglich." }, { status: 500 });
}
}
+40
View File
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { DomainBericht } from "@/components/domain-bericht";
import { DomainHilfe } from "@/components/domain-hilfe"; import { DomainHilfe } from "@/components/domain-hilfe";
import { HelpButton } from "@/components/help-button"; import { HelpButton } from "@/components/help-button";
import { Icon } from "@/components/icons"; import { Icon } from "@/components/icons";
@@ -16,6 +17,7 @@ import {
TextInput, TextInput,
} from "@/components/ui"; } from "@/components/ui";
import { ApiError, apiJson } from "@/lib/api-client"; import { ApiError, apiJson } from "@/lib/api-client";
import type { Pruefbericht } from "@/lib/domain-check";
type Grant = { groupId: string; groupName: string; isDefault: boolean }; type Grant = { groupId: string; groupName: string; isDefault: boolean };
@@ -46,6 +48,10 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
const [deleting, setDeleting] = useState<AdminDomain | null>(null); const [deleting, setDeleting] = useState<AdminDomain | null>(null);
/** Prüfberichte je Domain — bleiben stehen, bis erneut geprüft wird. */
const [reports, setReports] = useState<Record<string, Pruefbericht>>({});
const [checking, setChecking] = useState<string | null>(null);
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
const result = await apiJson<{ const result = await apiJson<{
@@ -124,6 +130,26 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
} }
}; };
const check = async (domain: AdminDomain) => {
setChecking(domain.id);
setError(null);
try {
const { report } = await apiJson<{ report: Pruefbericht }>(
`/api/admin/domains/${domain.id}/check`,
{ method: "POST" },
);
setReports((bisher) => ({ ...bisher, [domain.id]: report }));
} catch (caught) {
setError(
caught instanceof ApiError
? caught.message
: "Die Prüfung ließ sich nicht ausführen.",
);
} finally {
setChecking(null);
}
};
if (domains === null) { if (domains === null) {
return <p className="text-sm text-slate-500">Domains werden geladen …</p>; return <p className="text-sm text-slate-500">Domains werden geladen …</p>;
} }
@@ -198,6 +224,16 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
<Icon name="pencil" className="size-3.5" /> <Icon name="pencil" className="size-3.5" />
Gruppen zuteilen Gruppen zuteilen
</Button> </Button>
<Button
type="button"
variant="secondary"
size="sm"
disabled={checking !== null}
onClick={() => void check(domain)}
>
<Icon name="scan" className="size-3.5" />
{checking === domain.id ? "Prüft …" : "Prüfen"}
</Button>
<Button <Button
type="button" type="button"
variant="secondary" variant="secondary"
@@ -219,6 +255,10 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
Löschen Löschen
</Button> </Button>
</div> </div>
{reports[domain.id] ? (
<DomainBericht bericht={reports[domain.id]} />
) : null}
</div> </div>
</div> </div>
</Card> </Card>
+70
View File
@@ -0,0 +1,70 @@
"use client";
import { Icon } from "@/components/icons";
import { cx } from "@/components/ui";
import type { Pruefbericht, SchrittStatus } from "@/lib/domain-check";
/**
* Das Ergebnis eines Domain-Checks.
*
* Vier Zeilen, eine je Schritt, mit dem Grund im Klartext daneben. Bewusst
* ohne Ampelfarbe als einziges Merkmal: Jede Zeile trägt ein eigenes Symbol
* und einen eigenen Text, damit sie auch ohne Farbe lesbar bleibt.
*/
const SYMBOLE: Record<SchrittStatus, { icon: "check" | "warning" | "eyeOff"; farbe: string; wort: string }> = {
ok: { icon: "check", farbe: "text-emerald-600", wort: "in Ordnung" },
warnung: { icon: "warning", farbe: "text-amber-600", wort: "Achtung" },
fehler: { icon: "warning", farbe: "text-red-600", wort: "Fehler" },
uebersprungen: { icon: "eyeOff", farbe: "text-slate-400", wort: "übersprungen" },
};
const RAHMEN: Record<Pruefbericht["gesamt"], string> = {
ok: "border-emerald-300 bg-emerald-50/60",
warnung: "border-amber-300 bg-amber-50/60",
fehler: "border-red-300 bg-red-50/60",
};
const FAZIT: Record<Pruefbericht["gesamt"], string> = {
ok: "Die Domain führt hierher.",
warnung: "Es läuft, aber etwas will beachtet werden.",
fehler: "So kommt niemand hier an.",
};
export function DomainBericht({ bericht }: { bericht: Pruefbericht }) {
return (
<div className={cx("mt-4 rounded-xl border p-4", RAHMEN[bericht.gesamt])}>
<p className="text-sm font-semibold text-slate-900">
{FAZIT[bericht.gesamt]}
</p>
<ul className="mt-3 space-y-1.5">
{bericht.schritte.map((schritt) => {
const zeichen = SYMBOLE[schritt.status];
return (
<li key={schritt.name} className="flex items-start gap-2 text-sm">
<Icon
name={zeichen.icon}
className={cx("mt-0.5 size-4 shrink-0", zeichen.farbe)}
/>
<span className="sr-only">{zeichen.wort}: </span>
<span className="w-24 shrink-0 font-medium text-slate-700">
{schritt.titel}
</span>
<span className="min-w-0 flex-1 text-slate-600">
{schritt.detail}
</span>
</li>
);
})}
</ul>
<p className="mt-3 text-xs text-slate-500">
Aufgerufen wurde{" "}
<code className="font-mono">{bericht.geprueft}</code> — von dort, wo
dieser Container läuft. Bei internem DNS oder Hairpin-NAT kann die
Prüfung scheitern, obwohl von außen alles stimmt.
</p>
</div>
);
}
+478
View File
@@ -0,0 +1,478 @@
import "server-only";
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { lookup } from "node:dns/promises";
import { connect, type PeerCertificate } from "node:tls";
import { prisma } from "@/lib/db";
/**
* Domain-Check: Führt die Domain wirklich hierher?
*
* Eine Domain zu registrieren ist der kleinste Teil der Arbeit — DNS, Reverse
* Proxy und Zertifikat liegen außerhalb dieser Anwendung, und wenn dort etwas
* fehlt, merkt man es erst am gedruckten Code, der nicht aufgeht.
*
* Die Prüfung geht deshalb den Weg der Scanner nach: Namen auflösen, das
* Zertifikat ansehen, die Adresse aufrufen — und sich beweisen lassen, dass
* am anderen Ende **diese** Installation steht. Der Beweis ist ein HMAC über
* eine frische Zufallszahl; das Geheimnis dafür verlässt den Server nie.
*
* Die Grenze der Aussage steht in der Meldung selbst: Der Aufruf startet dort,
* wo dieser Container läuft. Wer internes DNS oder Hairpin-NAT hat, bekommt
* hier womöglich einen Fehler, obwohl von außen alles stimmt.
*/
export type SchrittName = "dns" | "erreichbar" | "zertifikat" | "ziel";
export type SchrittStatus = "ok" | "warnung" | "fehler" | "uebersprungen";
export type Pruefschritt = {
name: SchrittName;
titel: string;
status: SchrittStatus;
detail: string;
};
export type Pruefbericht = {
hostname: string;
/** Die Adresse, die tatsächlich aufgerufen wurde. */
geprueft: string;
gesamt: "ok" | "warnung" | "fehler";
schritte: Pruefschritt[];
};
/** Antwort der öffentlichen Beweis-Route. */
export type Domainbeweis = { app: string; proof: string };
const APP_KENNUNG = "qrcode-generator";
const ZEITGRENZE_MS = 8000;
/* ------------------------------------------------------------- Geheimnis */
/**
* Das Geheimnis für den Beweis — beim ersten Aufruf erzeugt.
*
* Bewusst in der Datenbank und nicht im Arbeitsspeicher: Die prüfende Route
* und die antwortende Route sind zwei getrennte Bündel und teilen sich keinen
* Modulzustand. Ein Wert im Speicher wäre auf beiden Seiten ein anderer.
*/
export async function getCheckSecret(): Promise<string> {
const vorhanden = await prisma.instanceSetting.findUnique({
where: { id: "instance" },
select: { checkSecret: true },
});
if (vorhanden?.checkSecret) return vorhanden.checkSecret;
const neu = randomBytes(32).toString("hex");
// `updateMany` mit der Bedingung „noch leer“ statt eines schlichten
// `update`: Zwei gleichzeitige Prüfungen sollen nicht zwei verschiedene
// Geheimnisse hinterlassen, von denen eines sofort wieder ungültig ist.
await prisma.instanceSetting.upsert({
where: { id: "instance" },
create: { id: "instance", checkSecret: neu },
update: {},
});
await prisma.instanceSetting.updateMany({
where: { id: "instance", checkSecret: null },
data: { checkSecret: neu },
});
const danach = await prisma.instanceSetting.findUnique({
where: { id: "instance" },
select: { checkSecret: true },
});
return danach?.checkSecret ?? neu;
}
export function bildeBeweis(secret: string, nonce: string): string {
return createHmac("sha256", secret).update(nonce).digest("hex");
}
function gleich(a: string, b: string): boolean {
const links = Buffer.from(a, "utf8");
const rechts = Buffer.from(b, "utf8");
if (links.length !== rechts.length) return false;
return timingSafeEqual(links, rechts);
}
/* ------------------------------------------------------------------ DNS */
function trenneHost(hostname: string): { host: string; port: number | null } {
const treffer = /^(.+):(\d{1,5})$/.exec(hostname);
if (!treffer) return { host: hostname, port: null };
return { host: treffer[1], port: Number(treffer[2]) };
}
async function pruefeDns(host: string): Promise<Pruefschritt> {
try {
const adressen = await lookup(host, { all: true });
if (adressen.length === 0) {
return {
name: "dns",
titel: "DNS",
status: "fehler",
detail: `Für ${host} ist keine Adresse hinterlegt.`,
};
}
return {
name: "dns",
titel: "DNS",
status: "ok",
detail: `löst auf ${adressen.map((a) => a.address).join(", ")}`,
};
} catch (error) {
const code = (error as { code?: string }).code;
return {
name: "dns",
titel: "DNS",
status: "fehler",
detail:
code === "ENOTFOUND"
? `Der Name ${host} ist unbekannt — es fehlt der A- oder AAAA-Eintrag.`
: `Der Name ${host} ließ sich nicht auflösen (${code ?? "unbekannter Fehler"}).`,
};
}
}
/* ----------------------------------------------------------- Zertifikat */
type Zertifikatslage =
| { erreichbar: true; schritt: Pruefschritt }
| { erreichbar: false; schritt: Pruefschritt };
const datumsformat = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" });
function beschreibeZertifikat(
zertifikat: PeerCertificate,
authorized: boolean,
fehler: string | undefined,
): Pruefschritt {
const bis = new Date(zertifikat.valid_to);
const tage = Math.floor((bis.getTime() - Date.now()) / 86_400_000);
// Die Felder eines Zertifikats dürfen mehrfach vorkommen; Node liefert sie
// dann als Liste.
const ersteZeile = (wert: string | string[] | undefined): string | undefined =>
Array.isArray(wert) ? wert[0] : wert;
const aussteller =
ersteZeile(zertifikat.issuer?.O) ??
ersteZeile(zertifikat.issuer?.CN) ??
"unbekannt";
if (!authorized) {
return {
name: "zertifikat",
titel: "Zertifikat",
status: "fehler",
detail: uebersetzeTlsFehler(fehler, aussteller, bis),
};
}
if (tage < 0) {
return {
name: "zertifikat",
titel: "Zertifikat",
status: "fehler",
detail: `abgelaufen am ${datumsformat.format(bis)}`,
};
}
return {
name: "zertifikat",
titel: "Zertifikat",
status: tage <= 14 ? "warnung" : "ok",
detail:
tage <= 14
? `läuft in ${tage} Tagen ab (${datumsformat.format(bis)}), ausgestellt von ${aussteller}`
: `gültig bis ${datumsformat.format(bis)}, ausgestellt von ${aussteller}`,
};
}
function uebersetzeTlsFehler(
fehler: string | undefined,
aussteller: string,
bis: Date,
): string {
switch (fehler) {
case "CERT_HAS_EXPIRED":
return `abgelaufen am ${datumsformat.format(bis)}`;
case "DEPTH_ZERO_SELF_SIGNED_CERT":
case "SELF_SIGNED_CERT_IN_CHAIN":
return "selbst ausgestellt — Scanner-Apps brechen darauf ohne Erklärung ab";
case "ERR_TLS_CERT_ALTNAME_INVALID":
return "gilt für einen anderen Namen — im Proxy ist die falsche Domain hinterlegt";
case "UNABLE_TO_VERIFY_LEAF_SIGNATURE":
return `Zwischenzertifikat fehlt — der Proxy liefert die Kette unvollständig aus (${aussteller})`;
default:
return `nicht gültig (${fehler ?? "unbekannter Grund"})`;
}
}
/**
* Der Grund, aus dem ein Zertifikat durchfiel.
*
* Node liefert ihn je nach Fassung als Fehlerobjekt mit `code`, als
* Fehlerobjekt ohne `code` oder schlicht als Zeichenkette.
*/
function tlsGrund(fehler: unknown): string | undefined {
if (fehler === null || fehler === undefined) return undefined;
if (typeof fehler === "string") return fehler;
const objekt = fehler as { code?: unknown; message?: unknown };
if (typeof objekt.code === "string") return objekt.code;
if (typeof objekt.message === "string") return objekt.message;
return undefined;
}
async function pruefeZertifikat(host: string): Promise<Zertifikatslage> {
return new Promise((aufloesen) => {
// `rejectUnauthorized: false`, um ein schlechtes Zertifikat **beschreiben**
// zu können statt nur an ihm zu scheitern. Übertragen wird hierüber nichts.
const socket = connect({
host,
port: 443,
servername: host,
rejectUnauthorized: false,
timeout: ZEITGRENZE_MS,
});
const fertig = (lage: Zertifikatslage) => {
socket.removeAllListeners();
socket.destroy();
aufloesen(lage);
};
socket.once("secureConnect", () => {
fertig({
erreichbar: true,
schritt: beschreibeZertifikat(
socket.getPeerCertificate(),
socket.authorized,
tlsGrund(socket.authorizationError),
),
});
});
socket.once("timeout", () => {
fertig({
erreichbar: false,
schritt: {
name: "zertifikat",
titel: "Zertifikat",
status: "fehler",
detail: "Auf Port 443 kam keine Antwort — läuft dort ein Webserver?",
},
});
});
socket.once("error", (error: NodeJS.ErrnoException) => {
fertig({
erreichbar: false,
schritt: {
name: "zertifikat",
titel: "Zertifikat",
status: "fehler",
detail:
error.code === "ECONNREFUSED"
? "Port 443 nimmt keine Verbindung an — es gibt hier kein HTTPS."
: `Die TLS-Verbindung scheiterte (${error.code ?? error.message}).`,
},
});
});
});
}
/* --------------------------------------------------------------- Aufruf */
type Abrufergebnis = {
erreichbar: Pruefschritt;
ziel: Pruefschritt;
};
/**
* Der eigentliche Grund hinter einem `fetch failed`.
*
* Node verpackt Netzwerkfehler; die Meldung an der Oberfläche lautet sonst
* schlicht „fetch failed" und hilft niemandem weiter.
*/
function ursache(error: unknown): string | undefined {
const grund = (error as { cause?: unknown }).cause;
if (grund === null || grund === undefined) return undefined;
const objekt = grund as { code?: unknown; message?: unknown };
if (typeof objekt.code === "string") return objekt.code;
if (typeof objekt.message === "string") return objekt.message;
return undefined;
}
async function rufeAuf(
basis: string,
nonce: string,
secret: string,
): Promise<Abrufergebnis> {
const start = Date.now();
try {
const antwort = await fetch(`${basis}/api/domain-check?nonce=${nonce}`, {
redirect: "follow",
cache: "no-store",
signal: AbortSignal.timeout(ZEITGRENZE_MS),
headers: { "User-Agent": `${APP_KENNUNG}-domain-check` },
});
const dauer = Date.now() - start;
if (!antwort.ok) {
return {
erreichbar: {
name: "erreichbar",
titel: "Erreichbar",
status: "fehler",
detail: `HTTP ${antwort.status} nach ${dauer} ms`,
},
ziel: {
name: "ziel",
titel: "Ziel",
status: "fehler",
detail:
antwort.status === 404
? "Es antwortet zwar etwas, aber nicht diese Anwendung — der Proxy leitet woandershin."
: "Wegen der Fehlerantwort nicht feststellbar.",
},
};
}
const erreichbar: Pruefschritt = {
name: "erreichbar",
titel: "Erreichbar",
status: dauer > 3000 ? "warnung" : "ok",
detail: `HTTP ${antwort.status} nach ${dauer} ms`,
};
const koerper = (await antwort.json().catch(() => null)) as
| Partial<Domainbeweis>
| null;
if (
koerper === null ||
koerper.app !== APP_KENNUNG ||
typeof koerper.proof !== "string"
) {
return {
erreichbar,
ziel: {
name: "ziel",
titel: "Ziel",
status: "fehler",
detail:
"Die Antwort stammt nicht von dieser Anwendung — hinter der Domain steht etwas anderes.",
},
};
}
return {
erreichbar,
ziel: gleich(koerper.proof, bildeBeweis(secret, nonce))
? {
name: "ziel",
titel: "Ziel",
status: "ok",
detail: "diese Installation",
}
: {
name: "ziel",
titel: "Ziel",
status: "fehler",
detail:
"Es antwortet eine andere Installation dieser Anwendung, nicht diese hier.",
},
};
} catch (error) {
const code = (error as { code?: string; name?: string }).code;
const name = (error as { name?: string }).name;
const grund =
name === "TimeoutError"
? `Keine Antwort binnen ${ZEITGRENZE_MS / 1000} Sekunden.`
: code === "ECONNREFUSED"
? "Die Verbindung wurde abgelehnt — dort nimmt kein Webserver an."
: `Der Aufruf scheiterte (${code ?? ursache(error) ?? (error as Error).message}).`;
return {
erreichbar: {
name: "erreichbar",
titel: "Erreichbar",
status: "fehler",
detail: grund,
},
ziel: {
name: "ziel",
titel: "Ziel",
status: "fehler",
detail: "Ohne Antwort nicht feststellbar.",
},
};
}
}
/* ---------------------------------------------------------------- Ganzes */
/**
* Prüft eine registrierte Domain von diesem Server aus.
*
* Der Hostname stammt aus der Datenbank, nicht aus der Anfrage — es lässt
* sich hierüber also keine beliebige Adresse abrufen.
*/
export async function pruefeDomain(hostname: string): Promise<Pruefbericht> {
const { host, port } = trenneHost(hostname);
const secret = await getCheckSecret();
const nonce = randomBytes(16).toString("hex");
const schritte: Pruefschritt[] = [];
const dns = await pruefeDns(host);
schritte.push(dns);
// Ohne aufgelösten Namen gibt es nichts anzurufen. Die Folgeschritte
// trotzdem zu versuchen brächte drei weitere Fehlermeldungen, die alle
// dasselbe sagen — und die eine, auf die es ankommt, ginge darin unter.
if (dns.status === "fehler") {
for (const [name, titel] of [
["zertifikat", "Zertifikat"],
["erreichbar", "Erreichbar"],
["ziel", "Ziel"],
] as [SchrittName, string][]) {
schritte.push({
name,
titel,
status: "uebersprungen",
detail: "Ohne aufgelösten Namen nicht prüfbar.",
});
}
return { hostname, geprueft: host, gesamt: "fehler", schritte };
}
// Ein Eintrag mit Port meint eine Adresse ohne Reverse Proxy — etwa die
// Standard-Domain aus BASE_URL im Entwicklungsbetrieb. Dort gibt es kein
// Zertifikat zu prüfen, und der Aufruf läuft über http.
let basis: string;
if (port !== null) {
schritte.push({
name: "zertifikat",
titel: "Zertifikat",
status: "uebersprungen",
detail: `Der Eintrag nennt Port ${port} — geprüft wird ohne HTTPS.`,
});
basis = `http://${host}:${port}`;
} else {
const lage = await pruefeZertifikat(host);
schritte.push(lage.schritt);
// Ohne HTTPS lohnt der zweite Anlauf über Port 80: So steht im Bericht,
// ob überhaupt etwas antwortet — das ist der Unterschied zwischen
// „Zertifikat fehlt" und „da ist gar nichts".
basis = lage.erreichbar ? `https://${host}` : `http://${host}`;
}
const { erreichbar, ziel } = await rufeAuf(basis, nonce, secret);
schritte.push(erreichbar, ziel);
const gesamt = schritte.some((s) => s.status === "fehler")
? "fehler"
: schritte.some((s) => s.status === "warnung")
? "warnung"
: "ok";
return { hostname, geprueft: basis, gesamt, schritte };
}