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
This commit is contained in:
parent
7e23df9fec
commit
2d223c5823
9 changed files with 1158 additions and 6 deletions
239
internal/check/check.go
Normal file
239
internal/check/check.go
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
// 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue