opnsense-portal/internal/check/check.go
Carsten Abele 11a3ec2e5f feat(auth): ad.max_tls_version zum Deckeln der angebotenen TLS-Version
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
2026-08-14 13:36:03 +02:00

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
}