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.") } if cfg.AD.MinTLSVersionIsWeak() { fmt.Fprintf(stderr, "WARNUNG: ad.min_tls_version ist auf %s gesetzt — die Verbindung zum "+ "Verzeichnisdienst erlaubt veraltetes TLS. Die Zertifikatsprüfung bleibt "+ "aktiv, aber bitte die Domain Controller auf TLS 1.2 heben.\n", cfg.AD.MinTLSVersion) } 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 } }