opnsense-portal/internal/check/check.go
Carsten Abele 2d223c5823 feat(check,serve): Startvalidierung, HTTPS-Server, Signal-Handling und Versionscheck
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
2026-08-14 09:34:22 +02:00

239 lines
6.9 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
add("Dateirechte", c.checkPermissions(),
"Konfiguration, Secrets und Log-Verzeichnis sind ausreichend geschützt")
// 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 != "" {
dir := filepath.Dir(c.Cfg.Logging.AuditLog)
if _, err := os.Stat(dir); err == nil {
if err := config.CheckDirMode(dir, 0o750); err != nil {
errs = append(errs, err)
}
} else {
errs = append(errs, fmt.Errorf("Log-Verzeichnis %s existiert nicht: %w", dir, err))
}
}
return errors.Join(errs...)
}
// 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
}
}
// 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 {
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
}