Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
238 lines
6.7 KiB
Go
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
|
|
}
|
|
}
|