// 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("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 }