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:
parent
bb2bc223b9
commit
eb89d78ce2
8 changed files with 1618 additions and 0 deletions
234
internal/web/server.go
Normal file
234
internal/web/server.go
Normal 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")
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue