Alte Schannel-Stacks brechen an einem TLS-1.3-ClientHello kommentarlos ab: sie nehmen die TCP-Verbindung an, lesen den ClientHello und setzen zurueck, ohne ein Zertifikat zu schicken. Herunterhandeln hilft dann nicht — TLS 1.3 darf gar nicht erst angeboten werden. max_tls_version (Default 1.3) deckelt die Hoechstversion; die Validierung lehnt eine Hoechstversion unterhalb der Mindestversion ab. check erkennt zusaetzlich zurueckgesetzte Verbindungen und nennt die beiden plausiblen Ursachen: fehlendes LDAPS-Zertifikat auf dem Server oder TLS-1.3-Inkompatibilitaet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
331 lines
11 KiB
Go
331 lines
11 KiB
Go
// Package check prüft beim Start und über das check-Kommando die gesamte
|
|
// Wirkungskette: Konfiguration, Dateirechte, Firewall, Verzeichnis, Uhrzeit.
|
|
package check
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.ravensburg.dev/cabele/opnsense-portal/internal/auth"
|
|
"git.ravensburg.dev/cabele/opnsense-portal/internal/config"
|
|
"git.ravensburg.dev/cabele/opnsense-portal/internal/opnsense"
|
|
)
|
|
|
|
// maxClockSkew ist die tolerierte Abweichung zur Firewall-Uhrzeit.
|
|
// Eine falsche Systemzeit würde abgelaufene Zertifikate falsch bewerten.
|
|
const maxClockSkew = 2 * time.Minute
|
|
|
|
// Result ist das Ergebnis eines Prüfpunkts.
|
|
type Result struct {
|
|
Name string
|
|
OK bool
|
|
Detail string
|
|
}
|
|
|
|
// Report bündelt alle Prüfergebnisse.
|
|
type Report struct {
|
|
Results []Result
|
|
}
|
|
|
|
// OK meldet, ob alle Prüfungen bestanden wurden.
|
|
func (r *Report) OK() bool {
|
|
for _, res := range r.Results {
|
|
if !res.OK {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// WriteTo gibt die Checkliste aus.
|
|
func (r *Report) WriteTo(w io.Writer) (int64, error) {
|
|
var total int64
|
|
for _, res := range r.Results {
|
|
mark := "✓"
|
|
if !res.OK {
|
|
mark = "✗"
|
|
}
|
|
line := fmt.Sprintf("%s %s", mark, res.Name)
|
|
if res.Detail != "" {
|
|
line += "\n " + strings.ReplaceAll(res.Detail, "\n", "\n ")
|
|
}
|
|
n, err := io.WriteString(w, line+"\n")
|
|
total += int64(n)
|
|
if err != nil {
|
|
return total, err
|
|
}
|
|
}
|
|
return total, nil
|
|
}
|
|
|
|
// Pinger ist der benötigte Ausschnitt des Firewall-Clients.
|
|
type Pinger interface {
|
|
Ping(ctx context.Context) (time.Time, error)
|
|
}
|
|
|
|
// DirectoryChecker ist der benötigte Ausschnitt des AD-Authenticators.
|
|
type DirectoryChecker interface {
|
|
ResolveGroupDN(ctx context.Context) (string, error)
|
|
Lookup(ctx context.Context, username string) (*auth.LookupResult, error)
|
|
}
|
|
|
|
// Checker führt die Prüfungen aus.
|
|
type Checker struct {
|
|
Cfg *config.Config
|
|
ConfigPath string
|
|
FW Pinger
|
|
Dir DirectoryChecker
|
|
Now func() time.Time
|
|
}
|
|
|
|
// Run führt alle Prüfungen aus. testAuthUser leer = keine Testanmeldung.
|
|
func (c *Checker) Run(ctx context.Context, testAuthUser string) *Report {
|
|
now := c.Now
|
|
if now == nil {
|
|
now = time.Now
|
|
}
|
|
rep := &Report{}
|
|
add := func(name string, err error, detail string) {
|
|
if err != nil {
|
|
rep.Results = append(rep.Results, Result{Name: name, OK: false, Detail: err.Error()})
|
|
return
|
|
}
|
|
rep.Results = append(rep.Results, Result{Name: name, OK: true, Detail: detail})
|
|
}
|
|
|
|
// 1. Konfiguration
|
|
add("Konfiguration", c.Cfg.Validate(), "gelesen und plausibel")
|
|
|
|
// 2. Dateirechte
|
|
permsDetail := "Konfiguration, Secrets und Log-Verzeichnis sind ausreichend geschützt"
|
|
if note := logDirNote(c.Cfg.Logging.AuditLog); note != "" {
|
|
permsDetail += "\n" + note
|
|
}
|
|
add("Dateirechte", c.checkPermissions(), permsDetail)
|
|
|
|
// 3. OPNsense
|
|
serverTime, fwErr := c.checkFirewall(ctx)
|
|
add("OPNsense-Verbindung", fwErr, "Export-API erreichbar, Key und Berechtigung in Ordnung")
|
|
|
|
// 4. Verzeichnisdienst
|
|
groupDN, dirErr := c.checkDirectory(ctx)
|
|
add("Verzeichnisdienst", dirErr,
|
|
"Anmeldung des Dienstkontos erfolgreich, Gruppe aufgelöst: "+groupDN)
|
|
|
|
// 5. Systemzeit (nur sinnvoll, wenn die Firewall geantwortet hat)
|
|
add("Systemzeit", checkClock(now(), serverTime, fwErr),
|
|
"Abweichung zur Firewall unterhalb der Schwelle")
|
|
|
|
// 6. Optionale Testanmeldung
|
|
if testAuthUser != "" {
|
|
detail, err := c.checkTestAuth(ctx, testAuthUser)
|
|
add("Testanmeldung "+testAuthUser, err, detail)
|
|
}
|
|
return rep
|
|
}
|
|
|
|
// checkPermissions prüft Config, Secret-Dateien, TLS-Key und Log-Verzeichnis.
|
|
func (c *Checker) checkPermissions() error {
|
|
var errs []error
|
|
if c.ConfigPath != "" {
|
|
if err := config.CheckFileMode(c.ConfigPath, 0o640); err != nil {
|
|
errs = append(errs, err)
|
|
}
|
|
}
|
|
for _, p := range []string{
|
|
c.Cfg.OPNsense.APIKeyFile, c.Cfg.OPNsense.APISecretFile, c.Cfg.AD.BindPasswordFile,
|
|
} {
|
|
if p == "" {
|
|
continue
|
|
}
|
|
if err := config.CheckFileMode(p, 0o640); err != nil {
|
|
errs = append(errs, err)
|
|
}
|
|
}
|
|
// Der private TLS-Schlüssel darf niemandem außer dem Eigentümer offenstehen.
|
|
if c.Cfg.Portal.TLSKey != "" {
|
|
if err := config.CheckFileMode(c.Cfg.Portal.TLSKey, 0o600); err != nil {
|
|
errs = append(errs, err)
|
|
}
|
|
}
|
|
if c.Cfg.Logging.AuditLog != "" {
|
|
if err := checkLogDir(filepath.Dir(c.Cfg.Logging.AuditLog)); err != nil {
|
|
errs = append(errs, err)
|
|
}
|
|
}
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
// checkLogDir prüft das Verzeichnis des Audit-Logs.
|
|
//
|
|
// Der Audit-Logger legt sein Verzeichnis beim Start selbst an. Der Check darf
|
|
// deshalb nicht strenger sein als die Laufzeit: fehlt nur das letzte
|
|
// Pfadelement, ist das in Ordnung. Fehlt auch das übergeordnete Verzeichnis,
|
|
// steckt fast immer ein Tippfehler im Pfad — das bleibt ein Fehler.
|
|
func checkLogDir(dir string) error {
|
|
info, err := os.Stat(dir)
|
|
if err == nil {
|
|
if !info.IsDir() {
|
|
return fmt.Errorf("Log-Pfad %s ist kein Verzeichnis", dir)
|
|
}
|
|
return config.CheckDirMode(dir, 0o750)
|
|
}
|
|
if !os.IsNotExist(err) {
|
|
return fmt.Errorf("Log-Verzeichnis %s nicht lesbar: %w", dir, err)
|
|
}
|
|
|
|
parent := filepath.Dir(dir)
|
|
parentInfo, perr := os.Stat(parent)
|
|
if perr != nil || !parentInfo.IsDir() {
|
|
return fmt.Errorf("Log-Verzeichnis %s existiert nicht und kann nicht angelegt werden, "+
|
|
"weil auch %s fehlt — bitte logging.audit_log prüfen", dir, parent)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// logDirNote beschreibt den Zustand des Log-Verzeichnisses für die Ausgabe.
|
|
func logDirNote(auditLog string) string {
|
|
if auditLog == "" {
|
|
return ""
|
|
}
|
|
dir := filepath.Dir(auditLog)
|
|
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
|
return fmt.Sprintf("Log-Verzeichnis %s wird beim Start angelegt", dir)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// checkFirewall ruft den Export-Endpunkt auf und übersetzt die Fehler in
|
|
// handlungsleitende Meldungen.
|
|
func (c *Checker) checkFirewall(ctx context.Context) (time.Time, error) {
|
|
if c.FW == nil {
|
|
return time.Time{}, errors.New("kein Firewall-Client konfiguriert")
|
|
}
|
|
t, err := c.FW.Ping(ctx)
|
|
switch {
|
|
case err == nil:
|
|
return t, nil
|
|
case errors.Is(err, opnsense.ErrUnauthorized):
|
|
return time.Time{}, fmt.Errorf(
|
|
"die Zugangsdaten wurden abgelehnt — opnsense.api_key und api_secret prüfen (%v)", err)
|
|
case errors.Is(err, opnsense.ErrForbidden):
|
|
return time.Time{}, fmt.Errorf(
|
|
"der API-Benutzer hat keine Berechtigung — auf der OPNsense das Privileg "+
|
|
"\"VPN: OpenVPN Client Export\" zuweisen (%v)", err)
|
|
case errors.Is(err, opnsense.ErrUnreachable):
|
|
return time.Time{}, fmt.Errorf(
|
|
"die Firewall ist nicht erreichbar — URL, Netzwerkweg und CA prüfen (%v)", err)
|
|
default:
|
|
return time.Time{}, err
|
|
}
|
|
}
|
|
|
|
// directoryHint übersetzt typische TLS- und Zertifikatsfehler in einen
|
|
// handlungsleitenden Zusatz. Der technische Originalfehler bleibt erhalten;
|
|
// nur ein Hinweis kommt dazu. Passt nichts, bleibt der Zusatz leer — ein
|
|
// reiner Netzwerkfehler soll keinen falschen Verdacht wecken.
|
|
func directoryHint(err error) string {
|
|
msg := strings.ToLower(err.Error())
|
|
contains := func(needles ...string) bool {
|
|
for _, n := range needles {
|
|
if strings.Contains(msg, n) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
switch {
|
|
case contains("insecure algorithm sha1", "sha1-rsa"):
|
|
return "Das Zertifikat des Domain Controllers ist mit SHA-1 signiert. " +
|
|
"Go akzeptiert das seit Version 1.24 nicht mehr und bietet auch keinen " +
|
|
"Schalter dafür — das Zertifikat muss mit SHA-256 neu ausgestellt werden."
|
|
case contains("certificate signed by unknown authority", "unknown authority", "unable to verify"):
|
|
return "Die ausstellende CA ist auf diesem Host nicht bekannt. Die interne " +
|
|
"Stammzertifizierungsstelle als PEM ablegen und in ad.ca_file eintragen " +
|
|
"(die Datei muss die gesamte Kette bis zur Wurzel enthalten). " +
|
|
"Ohne ad.ca_file wird nur der Trust-Store des Betriebssystems benutzt."
|
|
case contains("certificate is valid for", "certificate is not valid for any names", "x509: certificate relies on legacy"):
|
|
return "Das Zertifikat passt nicht zum angefragten Namen. In ad.servers muss " +
|
|
"genau der Name stehen, der im Zertifikat als Subject Alternative Name " +
|
|
"eingetragen ist — IP-Adressen sind deshalb nicht zulässig."
|
|
case contains("certificate has expired", "not yet valid"):
|
|
return "Das Zertifikat des Domain Controllers ist abgelaufen oder noch nicht " +
|
|
"gültig. Zertifikat erneuern und die Systemzeit beider Seiten prüfen."
|
|
case contains("protocol version not supported", "unsupported protocol version"):
|
|
return "Der Domain Controller spricht kein TLS 1.2. Für Altsysteme wie " +
|
|
"Windows Server 2012 R2 lässt sich ad.min_tls_version auf \"1.0\" setzen; " +
|
|
"besser ist, TLS 1.2 auf dem Domain Controller zu aktivieren."
|
|
case contains("connection reset by peer", "eof", "broken pipe"):
|
|
return "Der Domain Controller hat die Verbindung zurückgesetzt, ohne ein " +
|
|
"Zertifikat zu schicken. Zwei häufige Ursachen: (1) Auf dem Server ist " +
|
|
"gar kein LDAPS-Zertifikat installiert — Windows nimmt Port 636 dann " +
|
|
"zwar an, bricht aber ab. (2) Ein alter Schannel-Stack kommt mit dem " +
|
|
"TLS-1.3-ClientHello nicht zurecht; dann ad.max_tls_version auf \"1.2\" " +
|
|
"setzen. Prüfen mit: openssl s_client -connect <dc>:636 -tls1_2"
|
|
case contains("no cipher suite supported by both", "handshake failure"):
|
|
return "Client und Domain Controller haben keine gemeinsame Cipher-Suite. " +
|
|
"Zuerst ad.min_tls_version prüfen. Braucht der Domain Controller zwingend " +
|
|
"RSA-Schlüsselaustausch, hilft nur GODEBUG=tlsrsakex=1 in der systemd-Unit."
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// checkDirectory bindet den Service-Account und löst die Gruppe auf.
|
|
func (c *Checker) checkDirectory(ctx context.Context) (string, error) {
|
|
if c.Dir == nil {
|
|
return "", errors.New("kein Verzeichnis-Client konfiguriert")
|
|
}
|
|
dn, err := c.Dir.ResolveGroupDN(ctx)
|
|
if err != nil {
|
|
if hint := directoryHint(err); hint != "" {
|
|
return "", fmt.Errorf("Verzeichnisdienst nicht nutzbar: %w\n\n%s", err, hint)
|
|
}
|
|
return "", fmt.Errorf("Verzeichnisdienst nicht nutzbar: %w", err)
|
|
}
|
|
if dn == "" {
|
|
return "", fmt.Errorf("Gruppe %q konnte nicht aufgelöst werden", c.Cfg.AD.VPNGroup)
|
|
}
|
|
return dn, nil
|
|
}
|
|
|
|
// checkClock vergleicht lokale Zeit und Firewall-Zeit.
|
|
func checkClock(local, remote time.Time, fwErr error) error {
|
|
if fwErr != nil {
|
|
return errors.New("nicht prüfbar, weil die Firewall nicht geantwortet hat")
|
|
}
|
|
if remote.IsZero() {
|
|
return nil // Die Firewall hat keinen auswertbaren Date-Header geliefert.
|
|
}
|
|
skew := local.Sub(remote)
|
|
if skew < 0 {
|
|
skew = -skew
|
|
}
|
|
if skew > maxClockSkew {
|
|
return fmt.Errorf(
|
|
"die Systemzeit weicht um %s von der Firewall ab — bitte NTP einrichten "+
|
|
"(abgelaufene Zertifikate würden sonst falsch bewertet)", skew.Round(time.Second))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// checkTestAuth spielt Suche und Gruppenprüfung ohne Passwort durch.
|
|
func (c *Checker) checkTestAuth(ctx context.Context, username string) (string, error) {
|
|
res, err := c.Dir.Lookup(ctx, username)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !res.InVPNGroup {
|
|
return "", fmt.Errorf("DN: %s\nsAMAccountName: %s\nMitglied in %s: NEIN — "+
|
|
"dieser Benutzer würde abgewiesen", res.DN, res.SAMAccountName, c.Cfg.AD.VPNGroup)
|
|
}
|
|
return fmt.Sprintf("DN: %s\nsAMAccountName: %s\nMitglied in %s: ja",
|
|
res.DN, res.SAMAccountName, c.Cfg.AD.VPNGroup), nil
|
|
}
|