opnsense-portal/cmd/vpnportal/serve.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

238 lines
6.7 KiB
Go

package main
import (
"context"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"errors"
"flag"
"fmt"
"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"
// 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) {
fs := flag.NewFlagSet(name, flag.ContinueOnError)
fs.SetOutput(stderr)
path := fs.String("config", defaultConfigPath, "Pfad zur Konfigurationsdatei")
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 {
fs, path := configFlags("serve", stderr)
if err := fs.Parse(args); err != nil {
return 2
}
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
}
}