Feat #7: Domain-Check für DNS, Webserver und Zertifikat #15
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user