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") }