feat(web): Router, Login/Logout, Übersicht, CSRF-geschützter Download, Anleitungen und /healthz

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
This commit is contained in:
Carsten Abele 2026-08-14 09:25:52 +02:00
parent bb2bc223b9
commit eb89d78ce2
8 changed files with 1618 additions and 0 deletions

234
internal/web/server.go Normal file
View file

@ -0,0 +1,234 @@
package web
import (
"context"
"errors"
"io"
"io/fs"
"net/http"
"path"
"strings"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/audit"
"git.ravensburg.dev/cabele/opnsense-portal/internal/auth"
)
// AuditSink ist die Log-Senke; als Interface, damit Tests mitschreiben können.
type AuditSink interface {
Log(audit.Event)
}
// CertEntry ist ein für den Benutzer freigegebenes Zertifikat, aufbereitet
// für die Oberfläche.
type CertEntry struct {
Token string
InstanceName string
CommonName string
ValidTo time.Time
}
// ExportStream ist eine zum Download bereite Konfiguration.
// Body muss vom Aufrufer geschlossen werden.
type ExportStream struct {
Filename string
ContentType string
Body io.ReadCloser
InstanceName string
CommonName string
ValidTo time.Time
}
// ErrCertNotOwned signalisiert, dass das angeforderte Zertifikat nicht zum
// angemeldeten Benutzer gehört.
var ErrCertNotOwned = errors.New("Zertifikat gehört nicht zum angemeldeten Benutzer")
// ErrBackendUnavailable signalisiert eine nicht erreichbare Firewall.
var ErrBackendUnavailable = errors.New("Firewall nicht erreichbar")
// CertSource liefert die Zertifikate eines Benutzers und exportiert sie.
type CertSource interface {
EntriesFor(ctx context.Context, username string) ([]CertEntry, error)
// Export prüft die Zuordnung serverseitig erneut und liefert sonst
// ErrCertNotOwned. Die Auswahl in der Oberfläche ist keine Autorisierung.
Export(ctx context.Context, username, vpnID, refID, format string) (*ExportStream, error)
}
// HealthChecker liefert den Zustand der Abhängigkeiten für /healthz.
type HealthChecker interface {
Check(ctx context.Context) HealthReport
}
// HealthReport ist die Antwort von /healthz — bewusst ohne sensible Details.
type HealthReport struct {
OK bool `json:"ok"`
OPNsense bool `json:"opnsense"`
Directory bool `json:"directory"`
Sessions int `json:"sessions"`
}
// Deps sind die Abhängigkeiten des Servers.
type Deps struct {
Auth auth.Authenticator
Certs CertSource
Audit AuditSink
Sessions *SessionStore
Limiter *Limiter
Renderer *Renderer
Health HealthChecker
Clock func() time.Time
LogoPath string
SupportContact string
SessionTTL time.Duration
// MinLoginDuration gleicht die Antwortzeit der Anmeldung an.
// 0 = Vorgabewert; Tests setzen sie klein.
MinLoginDuration time.Duration
}
// Server bündelt Routing und Handler.
type Server struct {
d Deps
}
// defaultMinLoginDuration verhindert, dass die Laufzeit verrät, ob ein Konto
// existiert.
const defaultMinLoginDuration = 400 * time.Millisecond
// NewServer prüft die Abhängigkeiten.
func NewServer(d Deps) (*Server, error) {
switch {
case d.Auth == nil:
return nil, errors.New("web: Authenticator fehlt")
case d.Certs == nil:
return nil, errors.New("web: CertSource fehlt")
case d.Audit == nil:
return nil, errors.New("web: Audit-Senke fehlt")
case d.Sessions == nil:
return nil, errors.New("web: SessionStore fehlt")
case d.Limiter == nil:
return nil, errors.New("web: Limiter fehlt")
case d.Renderer == nil:
return nil, errors.New("web: Renderer fehlt")
}
if d.Clock == nil {
d.Clock = time.Now
}
if d.SessionTTL <= 0 {
d.SessionTTL = 10 * time.Minute
}
if d.MinLoginDuration <= 0 {
d.MinLoginDuration = defaultMinLoginDuration
}
return &Server{d: d}, nil
}
// Handler baut den Router samt Middleware.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /login", s.handleLoginForm)
mux.HandleFunc("POST /login", s.handleLoginSubmit)
mux.HandleFunc("POST /logout", s.requireSession(s.handleLogout))
mux.HandleFunc("GET /{$}", s.requireSession(s.handleOverview))
mux.HandleFunc("POST /download", s.requireSession(s.handleDownload))
mux.HandleFunc("GET /anleitungen", s.requireSession(s.handleGuides))
mux.HandleFunc("GET /healthz", s.handleHealth)
mux.HandleFunc("GET /assets/{file}", s.handleAsset)
mux.HandleFunc("/", s.handleNotFound)
return SecurityHeaders(mux)
}
// log schreibt ein Audit-Ereignis.
func (s *Server) log(e audit.Event) { s.d.Audit.Log(e) }
// renderPage füllt die Standardfelder und rendert.
func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, status int,
page string, sess *Session, data PageData) {
NoStore(w)
data.Brand = s.d.Renderer.Brand()
data.Brand.HasLogo = s.d.LogoPath != ""
if sess != nil {
data.CSRF = sess.CSRF
data.User = sess.Identity.Username
}
s.d.Renderer.Render(w, r, status, page, data)
}
// renderError zeigt die Fehlerseite.
func (s *Server) renderError(w http.ResponseWriter, r *http.Request, status int,
sess *Session, titleKey, messageKey string) {
s.renderPage(w, r, status, "error", sess, PageData{
Data: errorPageData{
Title: T(titleKey),
Message: T(messageKey),
ShowOverviewLink: sess != nil,
},
})
}
// requireSession schützt eine Route. Ohne gültige Session wird auf /login
// umgeleitet; ein abgelaufenes Cookie erzeugt zusätzlich ein Audit-Ereignis.
func (s *Server) requireSession(next func(http.ResponseWriter, *http.Request, *Session)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
NoStore(w)
cookie, err := r.Cookie(SessionCookieName)
if err != nil || cookie.Value == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
sess, ok := s.d.Sessions.Get(cookie.Value)
if !ok {
s.clearCookie(w)
s.log(audit.Event{
Event: audit.EventSessionExpired,
SrcIP: ClientIP(r),
Session: audit.ShortSession(cookie.Value),
})
http.Redirect(w, r, "/login?expired=1", http.StatusSeeOther)
return
}
next(w, r, sess)
}
}
// handleAsset liefert die eingebetteten Assets und das optionale Logo.
func (s *Server) handleAsset(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("file")
// Verteidigung in der Tiefe: embed.FS erlaubt ohnehin kein Traversal,
// aber ein Pfadtrenner im Namen hat hier nichts zu suchen.
if name == "" || name != path.Base(name) || strings.HasPrefix(name, ".") {
http.NotFound(w, r)
return
}
if name == "logo" {
if s.d.LogoPath == "" {
http.NotFound(w, r)
return
}
// Assets dürfen zwischengespeichert werden; sie enthalten keine
// personenbezogenen Daten.
w.Header().Set("Cache-Control", "public, max-age=3600")
http.ServeFile(w, r, s.d.LogoPath)
return
}
data, err := fs.ReadFile(AssetsFS, "assets/"+name)
if err != nil {
http.NotFound(w, r)
return
}
switch path.Ext(name) {
case ".css":
w.Header().Set("Content-Type", "text/css; charset=utf-8")
case ".js":
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
}
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Write(data)
}
// handleNotFound zeigt eine schlichte 404-Seite.
func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) {
s.renderError(w, r, http.StatusNotFound, nil, "error_not_found", "error_not_found_body")
}