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
|
|
@ -1,17 +1,80 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.ravensburg.dev/cabele/opnsense-portal/internal/auth"
|
||||||
|
"git.ravensburg.dev/cabele/opnsense-portal/internal/check"
|
||||||
|
"git.ravensburg.dev/cabele/opnsense-portal/internal/config"
|
||||||
|
"git.ravensburg.dev/cabele/opnsense-portal/internal/opnsense"
|
||||||
)
|
)
|
||||||
|
|
||||||
func runCheck(args []string, stdout, stderr io.Writer) int {
|
func runCheck(args []string, stdout, stderr io.Writer) int {
|
||||||
fs, path := configFlags("check", stderr)
|
fs, path := configFlags("check", stderr)
|
||||||
testAuth := fs.String("test-auth", "", "Suche und Gruppenprüfung für einen Benutzer durchspielen")
|
testAuth := fs.String("test-auth", "",
|
||||||
|
"Suche und Gruppenprüfung für einen konkreten Benutzer durchspielen (ohne Passwort)")
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
_ = testAuth
|
|
||||||
fmt.Fprintf(stderr, "Fehler: config %q kann noch nicht geladen werden (nicht implementiert)\n", *path)
|
cfg, err := config.Load(*path, nil)
|
||||||
return 1
|
if err != nil {
|
||||||
|
// Ohne lesbare Konfiguration lässt sich nichts weiter prüfen.
|
||||||
|
fmt.Fprintf(stdout, "✗ Konfiguration\n %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fw, ad, err := buildBackends(cfg)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stdout, "✗ Initialisierung\n %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
c := &check.Checker{Cfg: cfg, ConfigPath: *path, FW: fw, Dir: ad}
|
||||||
|
report := c.Run(ctx, *testAuth)
|
||||||
|
report.WriteTo(stdout)
|
||||||
|
|
||||||
|
if !report.OK() {
|
||||||
|
fmt.Fprintln(stdout, "\nEs sind Fehler aufgetreten. Das Portal würde nicht korrekt arbeiten.")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fmt.Fprintln(stdout, "\nAlle Prüfungen bestanden.")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildBackends erzeugt Firewall- und Verzeichnis-Client aus der Konfiguration.
|
||||||
|
// Wird von serve und check gemeinsam genutzt.
|
||||||
|
func buildBackends(cfg *config.Config) (*opnsense.Client, *auth.AD, error) {
|
||||||
|
fw, err := opnsense.New(opnsense.Options{
|
||||||
|
BaseURL: cfg.OPNsense.URL,
|
||||||
|
APIKey: cfg.OPNsense.APIKey,
|
||||||
|
APISecret: cfg.OPNsense.APISecret,
|
||||||
|
CAFile: cfg.OPNsense.CAFile,
|
||||||
|
InsecureSkipVerify: cfg.OPNsense.InsecureSkipVerify,
|
||||||
|
Timeout: 20 * time.Second,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
ad, err := auth.NewAD(auth.Options{
|
||||||
|
Servers: cfg.AD.Servers,
|
||||||
|
Port: cfg.AD.Port,
|
||||||
|
TLSMode: cfg.AD.TLSMode,
|
||||||
|
BaseDN: cfg.EffectiveBaseDN(),
|
||||||
|
BindUser: cfg.AD.BindUser,
|
||||||
|
BindPassword: cfg.AD.BindPassword,
|
||||||
|
VPNGroup: cfg.AD.VPNGroup,
|
||||||
|
CAFile: cfg.AD.CAFile,
|
||||||
|
Timeout: time.Duration(cfg.AD.Timeout),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return fw, ad, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
62
cmd/vpnportal/health.go
Normal file
62
cmd/vpnportal/health.go
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.ravensburg.dev/cabele/opnsense-portal/internal/check"
|
||||||
|
"git.ravensburg.dev/cabele/opnsense-portal/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// healthCacheTTL verhindert, dass häufige Monitoring-Abfragen Firewall und
|
||||||
|
// Verzeichnis belasten.
|
||||||
|
const healthCacheTTL = 30 * time.Second
|
||||||
|
|
||||||
|
// directoryPinger ist der für /healthz benötigte Ausschnitt des AD-Clients.
|
||||||
|
type directoryPinger interface {
|
||||||
|
ResolveGroupDN(ctx context.Context) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// healthChecker beantwortet /healthz mit kurz gecachten Ergebnissen.
|
||||||
|
type healthChecker struct {
|
||||||
|
fw check.Pinger
|
||||||
|
dir directoryPinger
|
||||||
|
clock func() time.Time
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
cached web.HealthReport
|
||||||
|
cachedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHealthChecker(fw check.Pinger, dir directoryPinger) *healthChecker {
|
||||||
|
return &healthChecker{fw: fw, dir: dir, clock: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *healthChecker) Check(ctx context.Context) web.HealthReport {
|
||||||
|
now := h.clock()
|
||||||
|
h.mu.Lock()
|
||||||
|
if !h.cachedAt.IsZero() && now.Sub(h.cachedAt) < healthCacheTTL {
|
||||||
|
rep := h.cached
|
||||||
|
h.mu.Unlock()
|
||||||
|
return rep
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var rep web.HealthReport
|
||||||
|
_, fwErr := h.fw.Ping(ctx)
|
||||||
|
rep.OPNsense = fwErr == nil
|
||||||
|
_, dirErr := h.dir.ResolveGroupDN(ctx)
|
||||||
|
rep.Directory = dirErr == nil
|
||||||
|
rep.OK = rep.OPNsense && rep.Directory
|
||||||
|
|
||||||
|
h.mu.Lock()
|
||||||
|
h.cached, h.cachedAt = rep, now
|
||||||
|
h.mu.Unlock()
|
||||||
|
return rep
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ web.HealthChecker = (*healthChecker)(nil)
|
||||||
|
|
@ -1,13 +1,34 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.ravensburg.dev/cabele/opnsense-portal/internal/audit"
|
||||||
|
"git.ravensburg.dev/cabele/opnsense-portal/internal/certmatch"
|
||||||
|
"git.ravensburg.dev/cabele/opnsense-portal/internal/check"
|
||||||
|
"git.ravensburg.dev/cabele/opnsense-portal/internal/config"
|
||||||
|
"git.ravensburg.dev/cabele/opnsense-portal/internal/portal"
|
||||||
|
"git.ravensburg.dev/cabele/opnsense-portal/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultConfigPath = "/etc/vpnportal/config.yaml"
|
const defaultConfigPath = "/etc/vpnportal/config.yaml"
|
||||||
|
|
||||||
|
// providerCacheTTL ist die einzige erlaubte Cache-Ausnahme: die Liste der
|
||||||
|
// VPN-Instanzen. Zertifikate werden nie gecacht.
|
||||||
|
const providerCacheTTL = 5 * time.Minute
|
||||||
|
|
||||||
func configFlags(name string, stderr io.Writer) (*flag.FlagSet, *string) {
|
func configFlags(name string, stderr io.Writer) (*flag.FlagSet, *string) {
|
||||||
fs := flag.NewFlagSet(name, flag.ContinueOnError)
|
fs := flag.NewFlagSet(name, flag.ContinueOnError)
|
||||||
fs.SetOutput(stderr)
|
fs.SetOutput(stderr)
|
||||||
|
|
@ -15,11 +36,203 @@ func configFlags(name string, stderr io.Writer) (*flag.FlagSet, *string) {
|
||||||
return fs, path
|
return fs, path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// configChecksum liefert eine gekürzte Prüfsumme der Konfigurationsdatei
|
||||||
|
// für das startup-Ereignis — sie erlaubt, Konfigurationsstände zu
|
||||||
|
// unterscheiden, ohne Inhalte zu protokollieren.
|
||||||
|
func configChecksum(path string) (string, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
return hex.EncodeToString(sum[:])[:12], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTLSConfig lädt Zertifikat und Schlüssel des Portals.
|
||||||
|
func newTLSConfig(certFile, keyFile string) (*tls.Config, error) {
|
||||||
|
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("TLS-Zertifikat/-Schlüssel konnten nicht geladen werden: %w", err)
|
||||||
|
}
|
||||||
|
return &tls.Config{
|
||||||
|
Certificates: []tls.Certificate{cert},
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func runServe(args []string, stdout, stderr io.Writer) int {
|
func runServe(args []string, stdout, stderr io.Writer) int {
|
||||||
fs, path := configFlags("serve", stderr)
|
fs, path := configFlags("serve", stderr)
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
fmt.Fprintf(stderr, "Fehler: config %q kann noch nicht geladen werden (nicht implementiert)\n", *path)
|
|
||||||
return 1
|
cfg, err := config.Load(*path, nil)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "Fehler: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if cfg.OPNsense.InsecureSkipVerify {
|
||||||
|
// Bewusst bei jedem Start, nicht nur einmalig.
|
||||||
|
fmt.Fprintln(stderr,
|
||||||
|
"WARNUNG: opnsense.insecure_skip_verify ist aktiv — "+
|
||||||
|
"die TLS-Verbindung zur Firewall wird NICHT geprüft. Nur für Tests verwenden.")
|
||||||
|
}
|
||||||
|
|
||||||
|
fw, ad, err := buildBackends(cfg)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "Fehler: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Startprüfung: das Portal startet nicht mit kaputter Umgebung.
|
||||||
|
startCtx, startCancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
report := (&check.Checker{Cfg: cfg, ConfigPath: *path, FW: fw, Dir: ad}).Run(startCtx, "")
|
||||||
|
startCancel()
|
||||||
|
report.WriteTo(stdout)
|
||||||
|
if !report.OK() {
|
||||||
|
fmt.Fprintln(stderr, "\nStart abgebrochen: die Startprüfung ist fehlgeschlagen.")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
auditLog, err := audit.New(cfg.Logging.AuditLog, audit.Options{
|
||||||
|
MaxSizeMB: cfg.Logging.MaxSizeMB,
|
||||||
|
MaxBackups: cfg.Logging.MaxBackups,
|
||||||
|
Compress: cfg.Logging.Compress,
|
||||||
|
Stdout: stdout,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "Fehler: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
defer auditLog.Close()
|
||||||
|
|
||||||
|
// Der Failover-Hook kann erst jetzt gesetzt werden — vorher gab es noch
|
||||||
|
// kein Audit-Log, in das er hätte schreiben können.
|
||||||
|
ad.SetFailoverHook(func(server string, err error) {
|
||||||
|
auditLog.Log(audit.Event{Event: audit.EventLDAPFailover, Server: server})
|
||||||
|
})
|
||||||
|
|
||||||
|
matcher, err := certmatch.NewMatcher(cfg.Matching.CNPattern, cfg.Matching.CNRegex)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "Fehler: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
certs := portal.NewSource(fw, matcher, providerCacheTTL, time.Now)
|
||||||
|
|
||||||
|
renderer, err := web.NewRenderer(web.Brand{
|
||||||
|
Title: cfg.Portal.Title,
|
||||||
|
SupportContact: cfg.Portal.SupportContact,
|
||||||
|
Version: version,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "Fehler: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions := web.NewSessionStore(time.Duration(cfg.Portal.SessionTTL), time.Now)
|
||||||
|
limiter := web.NewLimiter(time.Now)
|
||||||
|
|
||||||
|
srv, err := web.NewServer(web.Deps{
|
||||||
|
Auth: ad,
|
||||||
|
Certs: certs,
|
||||||
|
Audit: auditLog,
|
||||||
|
Sessions: sessions,
|
||||||
|
Limiter: limiter,
|
||||||
|
Renderer: renderer,
|
||||||
|
Health: newHealthChecker(fw, ad),
|
||||||
|
Clock: time.Now,
|
||||||
|
LogoPath: cfg.Portal.LogoFile,
|
||||||
|
SupportContact: cfg.Portal.SupportContact,
|
||||||
|
SessionTTL: time.Duration(cfg.Portal.SessionTTL),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "Fehler: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
tlsCfg, err := newTLSConfig(cfg.Portal.TLSCert, cfg.Portal.TLSKey)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "Fehler: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
sum, _ := configChecksum(*path)
|
||||||
|
auditLog.Log(audit.Event{
|
||||||
|
Event: audit.EventStartup, Version: versionString(), ConfigSum: sum,
|
||||||
|
Detail: "listen=" + cfg.Portal.Listen,
|
||||||
|
})
|
||||||
|
|
||||||
|
if msg := checkForUpdate(context.Background(), cfg.Portal.UpdateCheck, releaseURL, version, nil); msg != "" {
|
||||||
|
fmt.Fprintln(stdout, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpSrv := &http.Server{
|
||||||
|
Addr: cfg.Portal.Listen,
|
||||||
|
Handler: srv.Handler(),
|
||||||
|
TLSConfig: tlsCfg,
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
ReadTimeout: 30 * time.Second,
|
||||||
|
// WriteTimeout bleibt großzügig, weil Downloads gestreamt werden.
|
||||||
|
WriteTimeout: 5 * time.Minute,
|
||||||
|
IdleTimeout: 60 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hintergrundaufräumen für Sessions und Rate-Limiter.
|
||||||
|
stopGC := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
sessions.GC()
|
||||||
|
limiter.GC()
|
||||||
|
case <-stopGC:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
defer close(stopGC)
|
||||||
|
|
||||||
|
// SIGHUP öffnet das Audit-Log neu (logrotate-Umgebungen).
|
||||||
|
hup := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(hup, syscall.SIGHUP)
|
||||||
|
defer signal.Stop(hup)
|
||||||
|
go func() {
|
||||||
|
for range hup {
|
||||||
|
if err := auditLog.Reopen(); err != nil {
|
||||||
|
fmt.Fprintf(stderr, "Audit-Log konnte nicht neu geöffnet werden: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Geordnetes Herunterfahren.
|
||||||
|
shutdown := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(shutdown, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer signal.Stop(shutdown)
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
fmt.Fprintf(stdout, "%s lauscht auf https://%s\n", versionString(), cfg.Portal.Listen)
|
||||||
|
errCh <- httpSrv.ListenAndServeTLS("", "")
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-errCh:
|
||||||
|
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
fmt.Fprintf(stderr, "Fehler: Server beendet: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
case sig := <-shutdown:
|
||||||
|
fmt.Fprintf(stdout, "Signal %s empfangen, fahre herunter…\n", sig)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := httpSrv.Shutdown(ctx); err != nil {
|
||||||
|
fmt.Fprintf(stderr, "Herunterfahren nicht sauber beendet: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
82
cmd/vpnportal/serve_test.go
Normal file
82
cmd/vpnportal/serve_test.go
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestServeRejectsMissingConfig(t *testing.T) {
|
||||||
|
var out, errOut bytes.Buffer
|
||||||
|
code := runServe([]string{"--config", "/gibt/es/nicht.yaml"}, &out, &errOut)
|
||||||
|
if code == 0 {
|
||||||
|
t.Fatal("fehlende Config muss zum Abbruch führen")
|
||||||
|
}
|
||||||
|
if !strings.Contains(errOut.String(), "nicht.yaml") {
|
||||||
|
t.Errorf("Fehlermeldung muss den Pfad nennen: %q", errOut.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeRejectsLooseConfigPermissions(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := filepath.Join(dir, "config.yaml")
|
||||||
|
os.WriteFile(p, []byte("portal:\n listen: \"127.0.0.1:0\"\n"), 0o644)
|
||||||
|
os.Chmod(p, 0o644)
|
||||||
|
|
||||||
|
var out, errOut bytes.Buffer
|
||||||
|
if code := runServe([]string{"--config", p}, &out, &errOut); code == 0 {
|
||||||
|
t.Fatal("zu offene Dateirechte müssen den Start verhindern")
|
||||||
|
}
|
||||||
|
if !strings.Contains(errOut.String(), "chmod") {
|
||||||
|
t.Errorf("Fehlermeldung sollte den Reparaturbefehl nennen: %q", errOut.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckRejectsUnknownConfigField(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := filepath.Join(dir, "config.yaml")
|
||||||
|
os.WriteFile(p, []byte("portal:\n tls_cert_typo: \"x\"\n"), 0o600)
|
||||||
|
os.Chmod(p, 0o600)
|
||||||
|
|
||||||
|
var out, errOut bytes.Buffer
|
||||||
|
code := runCheck([]string{"--config", p}, &out, &errOut)
|
||||||
|
if code == 0 {
|
||||||
|
t.Fatal("Tippfehler in der Config muss zum Abbruch führen")
|
||||||
|
}
|
||||||
|
combined := out.String() + errOut.String()
|
||||||
|
if !strings.Contains(combined, "tls_cert_typo") {
|
||||||
|
t.Errorf("das unbekannte Feld muss genannt werden: %q", combined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigChecksumIsStable(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := filepath.Join(dir, "config.yaml")
|
||||||
|
os.WriteFile(p, []byte("inhalt"), 0o600)
|
||||||
|
os.Chmod(p, 0o600)
|
||||||
|
|
||||||
|
a, err := configChecksum(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, _ := configChecksum(p)
|
||||||
|
if a != b || a == "" {
|
||||||
|
t.Fatalf("Prüfsumme instabil: %q vs %q", a, b)
|
||||||
|
}
|
||||||
|
if len(a) != 12 {
|
||||||
|
t.Errorf("Prüfsumme sollte gekürzt sein (12 Zeichen), ist %d", len(a))
|
||||||
|
}
|
||||||
|
|
||||||
|
os.WriteFile(p, []byte("anderer inhalt"), 0o600)
|
||||||
|
if c, _ := configChecksum(p); c == a {
|
||||||
|
t.Error("geänderte Datei muss eine andere Prüfsumme ergeben")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewTLSConfigRejectsMissingFiles(t *testing.T) {
|
||||||
|
if _, err := newTLSConfig("/nix.crt", "/nix.key"); err == nil {
|
||||||
|
t.Fatal("fehlende Zertifikatsdateien müssen abgelehnt werden")
|
||||||
|
}
|
||||||
|
}
|
||||||
94
cmd/vpnportal/uidemo_test.go
Normal file
94
cmd/vpnportal/uidemo_test.go
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
//go:build uidemo
|
||||||
|
|
||||||
|
// Dieser Test startet das Portal mit Attrappen für AD und Firewall auf
|
||||||
|
// http://127.0.0.1:8080, damit die Oberfläche ohne echte Backends begutachtet
|
||||||
|
// werden kann. Aufruf:
|
||||||
|
//
|
||||||
|
// go test -tags uidemo ./cmd/vpnportal/ -run TestUIDemo -v -timeout 0
|
||||||
|
//
|
||||||
|
// Anmeldung mit beliebigem Benutzernamen und Passwort.
|
||||||
|
// Browser erlauben Secure-Cookies auf 127.0.0.1, deshalb funktioniert der
|
||||||
|
// gesamte Ablauf auch ohne TLS.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.ravensburg.dev/cabele/opnsense-portal/internal/audit"
|
||||||
|
"git.ravensburg.dev/cabele/opnsense-portal/internal/auth"
|
||||||
|
"git.ravensburg.dev/cabele/opnsense-portal/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
type demoAuth struct{}
|
||||||
|
|
||||||
|
func (demoAuth) Authenticate(ctx context.Context, u, p string) (*auth.Identity, error) {
|
||||||
|
return &auth.Identity{Username: "mmueller", Groups: []string{"VPN-Users"}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type demoCerts struct{}
|
||||||
|
|
||||||
|
func (demoCerts) EntriesFor(ctx context.Context, user string) ([]web.CertEntry, error) {
|
||||||
|
return []web.CertEntry{
|
||||||
|
{Token: "1:a1", InstanceName: "VPN Homeoffice", CommonName: "mmueller",
|
||||||
|
ValidTo: time.Now().AddDate(1, 2, 0)},
|
||||||
|
{Token: "2:b1", InstanceName: "VPN Außendienst", CommonName: "mmueller",
|
||||||
|
ValidTo: time.Now().AddDate(0, 0, 12)},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (demoCerts) Export(ctx context.Context, user, vpnID, refID, format string) (*web.ExportStream, error) {
|
||||||
|
return &web.ExportStream{
|
||||||
|
Filename: "vpn-demo-mmueller.ovpn",
|
||||||
|
ContentType: "application/x-openvpn-profile",
|
||||||
|
Body: io.NopCloser(strings.NewReader("client\nremote fw01.testfirma.local 1194\n")),
|
||||||
|
InstanceName: "VPN Homeoffice",
|
||||||
|
CommonName: "mmueller",
|
||||||
|
ValidTo: time.Now().AddDate(1, 0, 0),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (demoCerts) Pattern(user string) string { return user }
|
||||||
|
|
||||||
|
// leereCerts zeigt den Zustand "kein Zertifikat hinterlegt".
|
||||||
|
type leereCerts struct{ demoCerts }
|
||||||
|
|
||||||
|
func (leereCerts) EntriesFor(ctx context.Context, user string) ([]web.CertEntry, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type demoAudit struct{ t *testing.T }
|
||||||
|
|
||||||
|
func (d demoAudit) Log(e audit.Event) { d.t.Logf("audit: %+v", e) }
|
||||||
|
|
||||||
|
func TestUIDemo(t *testing.T) {
|
||||||
|
rn, err := web.NewRenderer(web.Brand{Title: "VPN-Portal Testfirma", Version: "uidemo"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Mit VPNPORTAL_UIDEMO_EMPTY=1 den Zustand "kein Zertifikat" begutachten.
|
||||||
|
var certs web.CertSource = demoCerts{}
|
||||||
|
if os.Getenv("VPNPORTAL_UIDEMO_EMPTY") != "" {
|
||||||
|
certs = leereCerts{}
|
||||||
|
}
|
||||||
|
srv, err := web.NewServer(web.Deps{
|
||||||
|
Auth: demoAuth{}, Certs: certs, Audit: demoAudit{t},
|
||||||
|
Sessions: web.NewSessionStore(30*time.Minute, nil),
|
||||||
|
Limiter: web.NewLimiter(nil), Renderer: rn,
|
||||||
|
SupportContact: "it-helpdesk@testfirma.de",
|
||||||
|
SessionTTL: 30 * time.Minute,
|
||||||
|
MinLoginDuration: time.Millisecond,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Log("Oberfläche unter http://127.0.0.1:8080 — mit Strg-C beenden")
|
||||||
|
if err := http.ListenAndServe("127.0.0.1:8080", srv.Handler()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
61
cmd/vpnportal/updatecheck.go
Normal file
61
cmd/vpnportal/updatecheck.go
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// releaseURL ist der Endpunkt des Versionschecks. Er wird nur abgefragt,
|
||||||
|
// wenn portal.update_check ausdrücklich eingeschaltet ist (Default: aus).
|
||||||
|
const releaseURL = "https://git.ravensburg.dev/api/v1/repos/cabele/opnsense-portal/releases/latest"
|
||||||
|
|
||||||
|
// checkForUpdate fragt read-only die neueste Version ab und liefert einen
|
||||||
|
// Hinweistext, falls sich die veröffentlichte Version unterscheidet.
|
||||||
|
// Jeder Fehler führt zu einer leeren Rückgabe — der Check darf den Start
|
||||||
|
// unter keinen Umständen stören.
|
||||||
|
func checkForUpdate(ctx context.Context, enabled bool, url, current string, client *http.Client) string {
|
||||||
|
if !enabled {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 5 * time.Second}
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("User-Agent", "vpnportal/"+current)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var release struct {
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
}
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if err != nil || json.Unmarshal(raw, &release) != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// Reiner Gleichheitsvergleich, keine Semver-Ordnung: das Portal meldet nur,
|
||||||
|
// dass sich die veröffentlichte Version unterscheidet, und urteilt nicht
|
||||||
|
// selbst über Versionsordnungen.
|
||||||
|
latest := strings.TrimPrefix(strings.TrimSpace(release.TagName), "v")
|
||||||
|
if latest == "" || latest == strings.TrimPrefix(strings.TrimSpace(current), "v") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Hinweis: Version %s ist verfügbar (installiert: %s).", latest, current)
|
||||||
|
}
|
||||||
78
cmd/vpnportal/updatecheck_test.go
Normal file
78
cmd/vpnportal/updatecheck_test.go
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUpdateCheckDisabledMakesNoRequest(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
called = true
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
if msg := checkForUpdate(context.Background(), false, srv.URL, "1.0.0", srv.Client()); msg != "" {
|
||||||
|
t.Errorf("bei update_check=false darf nichts gemeldet werden, got %q", msg)
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Fatal("bei update_check=false darf kein Netzwerkzugriff stattfinden")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateCheckReportsNewerVersion(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
io.WriteString(w, `{"tag_name":"v1.4.0"}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
msg := checkForUpdate(context.Background(), true, srv.URL, "1.2.0", srv.Client())
|
||||||
|
if msg == "" {
|
||||||
|
t.Fatal("abweichende Version muss gemeldet werden")
|
||||||
|
}
|
||||||
|
if !strings.Contains(msg, "1.4.0") {
|
||||||
|
t.Errorf("Meldung nennt die neue Version nicht: %q", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateCheckSilentWhenCurrent(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
io.WriteString(w, `{"tag_name":"v1.2.0"}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
// Auch mit "v"-Präfix auf der einen und ohne auf der anderen Seite.
|
||||||
|
if msg := checkForUpdate(context.Background(), true, srv.URL, "1.2.0", srv.Client()); msg != "" {
|
||||||
|
t.Errorf("bei aktueller Version darf nichts gemeldet werden, got %q", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateCheckSurvivesUnreachableEndpoint(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||||
|
url := srv.URL
|
||||||
|
client := srv.Client()
|
||||||
|
srv.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
// Ein nicht erreichbarer Endpunkt darf den Start niemals stören.
|
||||||
|
if msg := checkForUpdate(ctx, true, url, "1.2.0", client); msg != "" {
|
||||||
|
t.Errorf("bei Fehler darf nichts gemeldet werden, got %q", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateCheckIgnoresGarbage(t *testing.T) {
|
||||||
|
for _, body := range []string{`nicht json`, `{}`, `{"tag_name":""}`, `{"tag_name":" "}`} {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
io.WriteString(w, body)
|
||||||
|
}))
|
||||||
|
if msg := checkForUpdate(context.Background(), true, srv.URL, "1.2.0", srv.Client()); msg != "" {
|
||||||
|
t.Errorf("Antwort %q darf nichts melden, got %q", body, msg)
|
||||||
|
}
|
||||||
|
srv.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
260
internal/check/check_test.go
Normal file
260
internal/check/check_test.go
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
package check
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakePinger struct {
|
||||||
|
at time.Time
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakePinger) Ping(ctx context.Context) (time.Time, error) { return f.at, f.err }
|
||||||
|
|
||||||
|
type fakeDir struct {
|
||||||
|
dn string
|
||||||
|
dnErr error
|
||||||
|
lookup *auth.LookupResult
|
||||||
|
lookErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeDir) ResolveGroupDN(ctx context.Context) (string, error) { return f.dn, f.dnErr }
|
||||||
|
func (f *fakeDir) Lookup(ctx context.Context, u string) (*auth.LookupResult, error) {
|
||||||
|
return f.lookup, f.lookErr
|
||||||
|
}
|
||||||
|
|
||||||
|
var checkNow = time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
func newChecker(t *testing.T, fw Pinger, dir DirectoryChecker) *Checker {
|
||||||
|
t.Helper()
|
||||||
|
tmp := t.TempDir()
|
||||||
|
cfgPath := filepath.Join(tmp, "config.yaml")
|
||||||
|
if err := os.WriteFile(cfgPath, []byte("x"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
os.Chmod(cfgPath, 0o600)
|
||||||
|
|
||||||
|
logDir := filepath.Join(tmp, "log")
|
||||||
|
os.MkdirAll(logDir, 0o750)
|
||||||
|
os.Chmod(logDir, 0o750)
|
||||||
|
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.Portal.TLSCert = filepath.Join(tmp, "portal.crt")
|
||||||
|
cfg.Portal.TLSKey = filepath.Join(tmp, "portal.key")
|
||||||
|
os.WriteFile(cfg.Portal.TLSCert, []byte("cert"), 0o644)
|
||||||
|
os.WriteFile(cfg.Portal.TLSKey, []byte("key"), 0o600)
|
||||||
|
os.Chmod(cfg.Portal.TLSKey, 0o600)
|
||||||
|
cfg.OPNsense.URL = "https://fw01.firma.local"
|
||||||
|
cfg.OPNsense.APIKey, cfg.OPNsense.APISecret = "k", "s"
|
||||||
|
cfg.AD.Domain = "firma.local"
|
||||||
|
cfg.AD.Servers = []string{"dc01.firma.local"}
|
||||||
|
cfg.AD.BindUser, cfg.AD.BindPassword = "svc@firma.local", "pw"
|
||||||
|
cfg.AD.VPNGroup = "VPN-Users"
|
||||||
|
cfg.Logging.AuditLog = filepath.Join(logDir, "audit.log")
|
||||||
|
|
||||||
|
return &Checker{Cfg: cfg, ConfigPath: cfgPath, FW: fw, Dir: dir,
|
||||||
|
Now: func() time.Time { return checkNow }}
|
||||||
|
}
|
||||||
|
|
||||||
|
func report(t *testing.T, rep *Report) string {
|
||||||
|
t.Helper()
|
||||||
|
var b strings.Builder
|
||||||
|
if _, err := rep.WriteTo(&b); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllChecksPass(t *testing.T) {
|
||||||
|
c := newChecker(t, &fakePinger{at: checkNow},
|
||||||
|
&fakeDir{dn: "CN=VPN-Users,DC=firma,DC=local"})
|
||||||
|
rep := c.Run(context.Background(), "")
|
||||||
|
|
||||||
|
if !rep.OK() {
|
||||||
|
t.Fatalf("alle Prüfungen sollten bestehen:\n%s", report(t, rep))
|
||||||
|
}
|
||||||
|
out := report(t, rep)
|
||||||
|
for _, want := range []string{"Konfiguration", "Dateirechte", "OPNsense", "Verzeichnisdienst", "Systemzeit"} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("Prüfpunkt %q fehlt:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFailedAPISecretIsNamed(t *testing.T) {
|
||||||
|
c := newChecker(t, &fakePinger{err: opnsense.ErrUnauthorized},
|
||||||
|
&fakeDir{dn: "CN=VPN-Users,DC=firma,DC=local"})
|
||||||
|
rep := c.Run(context.Background(), "")
|
||||||
|
|
||||||
|
if rep.OK() {
|
||||||
|
t.Fatal("falsches Secret muss auffallen")
|
||||||
|
}
|
||||||
|
out := report(t, rep)
|
||||||
|
if !strings.Contains(out, "✗") {
|
||||||
|
t.Errorf("Ausgabe muss ein ✗ enthalten:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.ToLower(out), "zugangsdaten") {
|
||||||
|
t.Errorf("Ursache muss benannt werden:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMissingAPIPermissionIsDistinguished(t *testing.T) {
|
||||||
|
c := newChecker(t, &fakePinger{err: opnsense.ErrForbidden}, &fakeDir{dn: "CN=x"})
|
||||||
|
rep := c.Run(context.Background(), "")
|
||||||
|
out := report(t, rep)
|
||||||
|
if !strings.Contains(out, "Client Export") {
|
||||||
|
t.Errorf("fehlende Berechtigung muss den nötigen Privilegnamen nennen:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnreachableDCIsNamed(t *testing.T) {
|
||||||
|
c := newChecker(t, &fakePinger{at: checkNow},
|
||||||
|
&fakeDir{dnErr: errors.New("dc01.firma.local: connection refused")})
|
||||||
|
rep := c.Run(context.Background(), "")
|
||||||
|
if rep.OK() {
|
||||||
|
t.Fatal("nicht erreichbarer DC muss auffallen")
|
||||||
|
}
|
||||||
|
if out := report(t, rep); !strings.Contains(out, "dc01.firma.local") {
|
||||||
|
t.Errorf("betroffener DC muss genannt werden:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLooseConfigPermissionsAreDetected(t *testing.T) {
|
||||||
|
c := newChecker(t, &fakePinger{at: checkNow}, &fakeDir{dn: "CN=x"})
|
||||||
|
os.Chmod(c.ConfigPath, 0o644)
|
||||||
|
rep := c.Run(context.Background(), "")
|
||||||
|
if rep.OK() {
|
||||||
|
t.Fatal("0644-Config muss auffallen")
|
||||||
|
}
|
||||||
|
if out := report(t, rep); !strings.Contains(out, "chmod") {
|
||||||
|
t.Errorf("Reparaturbefehl muss genannt werden:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLooseTLSKeyIsDetected(t *testing.T) {
|
||||||
|
c := newChecker(t, &fakePinger{at: checkNow}, &fakeDir{dn: "CN=x"})
|
||||||
|
os.Chmod(c.Cfg.Portal.TLSKey, 0o644)
|
||||||
|
rep := c.Run(context.Background(), "")
|
||||||
|
if rep.OK() {
|
||||||
|
t.Fatal("world-readable TLS-Key muss auffallen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMissingLogDirectoryIsDetected(t *testing.T) {
|
||||||
|
c := newChecker(t, &fakePinger{at: checkNow}, &fakeDir{dn: "CN=x"})
|
||||||
|
c.Cfg.Logging.AuditLog = "/gibt/es/nicht/audit.log"
|
||||||
|
rep := c.Run(context.Background(), "")
|
||||||
|
if rep.OK() {
|
||||||
|
t.Fatal("fehlendes Log-Verzeichnis muss auffallen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClockSkewWarns(t *testing.T) {
|
||||||
|
c := newChecker(t, &fakePinger{at: checkNow.Add(5 * time.Minute)}, &fakeDir{dn: "CN=x"})
|
||||||
|
rep := c.Run(context.Background(), "")
|
||||||
|
out := report(t, rep)
|
||||||
|
if !strings.Contains(out, "NTP") {
|
||||||
|
t.Errorf("Zeitabweichung muss auf NTP hinweisen:\n%s", out)
|
||||||
|
}
|
||||||
|
if rep.OK() {
|
||||||
|
t.Error("Zeitabweichung über der Schwelle muss als Fehler zählen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSmallClockSkewIsFine(t *testing.T) {
|
||||||
|
c := newChecker(t, &fakePinger{at: checkNow.Add(30 * time.Second)},
|
||||||
|
&fakeDir{dn: "CN=VPN-Users,DC=firma,DC=local"})
|
||||||
|
if rep := c.Run(context.Background(), ""); !rep.OK() {
|
||||||
|
t.Fatalf("30 Sekunden Abweichung sind unkritisch:\n%s", report(t, rep))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClockCheckSkippedWhenFirewallDown(t *testing.T) {
|
||||||
|
// Ohne Antwort der Firewall lässt sich die Uhr nicht vergleichen; das darf
|
||||||
|
// nicht als eigenständiger Zeitfehler dastehen.
|
||||||
|
c := newChecker(t, &fakePinger{err: opnsense.ErrUnreachable}, &fakeDir{dn: "CN=x"})
|
||||||
|
rep := c.Run(context.Background(), "")
|
||||||
|
out := report(t, rep)
|
||||||
|
if !strings.Contains(out, "nicht prüfbar") {
|
||||||
|
t.Errorf("Zeitprüfung muss als nicht prüfbar ausgewiesen werden:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTestAuthShowsLookupResult(t *testing.T) {
|
||||||
|
dir := &fakeDir{
|
||||||
|
dn: "CN=VPN-Users,DC=firma,DC=local",
|
||||||
|
lookup: &auth.LookupResult{
|
||||||
|
DN: "CN=Max Mueller,OU=Users,DC=firma,DC=local", SAMAccountName: "mmueller", InVPNGroup: true},
|
||||||
|
}
|
||||||
|
c := newChecker(t, &fakePinger{at: checkNow}, dir)
|
||||||
|
rep := c.Run(context.Background(), "mmueller")
|
||||||
|
|
||||||
|
out := report(t, rep)
|
||||||
|
for _, want := range []string{"CN=Max Mueller", "mmueller", "VPN-Users"} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("Ausgabe von --test-auth enthält %q nicht:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTestAuthReportsMissingGroupMembership(t *testing.T) {
|
||||||
|
dir := &fakeDir{
|
||||||
|
dn: "CN=VPN-Users,DC=firma,DC=local",
|
||||||
|
lookup: &auth.LookupResult{DN: "CN=J Doe", SAMAccountName: "jdoe", InVPNGroup: false},
|
||||||
|
}
|
||||||
|
c := newChecker(t, &fakePinger{at: checkNow}, dir)
|
||||||
|
rep := c.Run(context.Background(), "jdoe")
|
||||||
|
if rep.OK() {
|
||||||
|
t.Fatal("fehlende Gruppenmitgliedschaft muss als Fehler gelten")
|
||||||
|
}
|
||||||
|
if out := report(t, rep); !strings.Contains(out, "NEIN") {
|
||||||
|
t.Errorf("das Ergebnis muss deutlich benannt werden:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTestAuthSkippedWhenEmpty(t *testing.T) {
|
||||||
|
c := newChecker(t, &fakePinger{at: checkNow}, &fakeDir{dn: "CN=x"})
|
||||||
|
rep := c.Run(context.Background(), "")
|
||||||
|
if strings.Contains(report(t, rep), "Testanmeldung") {
|
||||||
|
t.Error("ohne --test-auth darf kein Testanmelde-Punkt erscheinen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReportOutputFormat(t *testing.T) {
|
||||||
|
rep := &Report{Results: []Result{
|
||||||
|
{Name: "Konfiguration", OK: true},
|
||||||
|
{Name: "Dateirechte", OK: false, Detail: "config.yaml ist 0644"},
|
||||||
|
}}
|
||||||
|
out := report(t, rep)
|
||||||
|
if !strings.Contains(out, "✓ Konfiguration") {
|
||||||
|
t.Errorf("Erfolgszeile falsch:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "✗ Dateirechte") || !strings.Contains(out, "config.yaml ist 0644") {
|
||||||
|
t.Errorf("Fehlerzeile falsch:\n%s", out)
|
||||||
|
}
|
||||||
|
if rep.OK() {
|
||||||
|
t.Error("ein ✗ muss OK() falsch machen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidConfigIsReported(t *testing.T) {
|
||||||
|
c := newChecker(t, &fakePinger{at: checkNow}, &fakeDir{dn: "CN=x"})
|
||||||
|
c.Cfg.AD.Servers = []string{"10.1.1.10"} // IP statt Hostname
|
||||||
|
rep := c.Run(context.Background(), "")
|
||||||
|
if rep.OK() {
|
||||||
|
t.Fatal("ungültige Konfiguration muss auffallen")
|
||||||
|
}
|
||||||
|
if out := report(t, rep); !strings.Contains(out, "10.1.1.10") {
|
||||||
|
t.Errorf("der beanstandete Wert muss genannt werden:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue