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