package server import ( "context" "io/fs" "log/slog" "net" "net/http" "strings" "time" "github.com/jcmturner/gokrb5/v8/keytab" "github.com/uopi/uopi/internal/access" "github.com/uopi/uopi/internal/api" "github.com/uopi/uopi/internal/audit" "github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/confmgr" "github.com/uopi/uopi/internal/controllogic" "github.com/uopi/uopi/internal/datasource/synthetic" "github.com/uopi/uopi/internal/metrics" "github.com/uopi/uopi/internal/panelacl" "github.com/uopi/uopi/internal/storage" ) const apiPrefix = "/api/v1" type Server struct { httpServer *http.Server tlsCert string tlsKey string tlsRedirect string // plain-HTTP addr that 301-redirects to HTTPS; empty = off log *slog.Logger } // New creates the HTTP server, registers all routes, and returns a ready-to-start Server. // synth may be nil if the synthetic data source is not enabled. // // basicAuthFn, when non-nil, enables built-in HTTP Basic authentication validated // by that function (typically PAM): REST requests are challenged (401 Basic) and // WebSocket upgrades validate proactively-resent credentials best-effort. It is // mutually exclusive with Kerberos (krbKeytab). When tlsCert and tlsKey are both // set the server serves HTTPS via ListenAndServeTLS. func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfgStore *confmgr.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, dialogs *DialogHub, debug *DebugHub, rec audit.Recorder, channelFinderURL, archiverURL, trustedUserHeader string, krbKeytab *keytab.Keytab, krbSPN string, basicAuthFn func(user, pass string) error, basicAuthRealm, tlsCert, tlsKey, tlsRedirect string, uiDefaultZoom float64, log *slog.Logger) *Server { if rec == nil { rec = audit.Nop() } mux := http.NewServeMux() // When built-in authentication (Kerberos or Basic) is enabled but no external // proxy header is configured, use an internal header to carry the validated // username downstream. userHeader := trustedUserHeader if (krbKeytab != nil || basicAuthFn != nil) && userHeader == "" { userHeader = internalUserHeader } // Health check mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) }) // WebSocket endpoint var wsHandlerH http.Handler = &wsHandler{broker: brk, log: log, userHeader: userHeader, policy: policy, audit: rec, dialogs: dialogs, debug: debug} // Prometheus-format metrics mux.HandleFunc("/metrics", metrics.Handler(brk.ActiveSubscriptions)) // REST API — registered on a dedicated mux so it can be wrapped with the // access-control middleware (identity resolution + global level enforcement). apiMux := http.NewServeMux() api.New(brk, synth, store, cfgStore, policy, acl, ctrlLogic, ctrlEngine, rec, channelFinderURL, archiverURL, uiDefaultZoom, log).Register(apiMux, apiPrefix) var apiHandler http.Handler = accessMiddleware(policy, userHeader, apiMux) // Native SPNEGO/Kerberos authentication (optional). REST/page requests are // challenged (401 Negotiate); WebSocket upgrades validate proactively-sent // credentials best-effort. Both stash the validated username in userHeader. var frontendHandler http.Handler = http.FileServerFS(webFS) if krbKeytab != nil { apiHandler = kerberosAuth(krbKeytab, krbSPN, userHeader, true, log, apiHandler) wsHandlerH = kerberosAuth(krbKeytab, krbSPN, userHeader, false, log, wsHandlerH) } else if basicAuthFn != nil { // Built-in HTTP Basic authentication (validated by basicAuthFn, e.g. PAM). // A shared positive-result cache spares a validation round-trip on every // browser request. REST is challenged; WebSocket upgrades are best-effort. cache := newCredCache(5 * time.Minute) apiHandler = basicAuth(basicAuthFn, userHeader, basicAuthRealm, true, cache, log, apiHandler) wsHandlerH = basicAuth(basicAuthFn, userHeader, basicAuthRealm, false, cache, log, wsHandlerH) // Challenge the top-level page load too. Browsers only show the native // Basic login dialog in response to a 401 on a navigation, not on the // SPA's background fetch('/api/v1/me'); without this the user is never // prompted and silently resolves to default_user. Once credentials are // entered the browser caches them for the origin and resends them on // every asset, API call and the WebSocket upgrade. frontendHandler = basicAuth(basicAuthFn, userHeader, basicAuthRealm, true, cache, log, frontendHandler) } mux.Handle("/ws", wsHandlerH) mux.Handle(apiPrefix+"/", apiHandler) // Embedded frontend — must be last (catch-all) mux.Handle("/", frontendHandler) return &Server{ httpServer: &http.Server{ Addr: addr, Handler: mux, ReadTimeout: 10 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 120 * time.Second, }, tlsCert: tlsCert, tlsKey: tlsKey, tlsRedirect: tlsRedirect, log: log, } } // accessMiddleware resolves the end-user identity from the trusted proxy header // (falling back to the configured default_user), stores it on the request // context, and enforces the user's global access level on every API request: // - LevelNone → 403 for all requests. // - LevelRead → 403 for any mutating request (non GET/HEAD). // - LevelWrite → no restriction. // // The /me endpoint is always reachable so the frontend can discover the // caller's identity and level even when otherwise restricted. func accessMiddleware(policy *access.Policy, userHeader string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var raw string if userHeader != "" { raw = r.Header.Get(userHeader) } user := policy.ResolveUser(raw) r = r.WithContext(access.WithUser(r.Context(), user)) // Always allow identity discovery. if strings.HasSuffix(r.URL.Path, apiPrefix+"/me") { next.ServeHTTP(w, r) return } switch policy.Level(user) { case access.LevelNone: http.Error(w, "access denied", http.StatusForbidden) return case access.LevelRead: if r.Method != http.MethodGet && r.Method != http.MethodHead { http.Error(w, "read-only access", http.StatusForbidden) return } } next.ServeHTTP(w, r) }) } // Start listens and serves until ctx is cancelled. func (s *Server) Start(ctx context.Context) error { tls := s.tlsCert != "" && s.tlsKey != "" s.log.Info("listening", "addr", s.httpServer.Addr, "tls", tls) errCh := make(chan error, 1) go func() { var err error if tls { err = s.httpServer.ListenAndServeTLS(s.tlsCert, s.tlsKey) } else { err = s.httpServer.ListenAndServe() } if err != nil && err != http.ErrServerClosed { errCh <- err } }() // Optional plain-HTTP redirector: upgrade http:// visitors to the HTTPS // service instead of letting them hit the TLS port with a cleartext request. var redirectSrv *http.Server if tls && s.tlsRedirect != "" { redirectSrv = &http.Server{ Addr: s.tlsRedirect, Handler: httpsRedirectHandler(s.httpServer.Addr), ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, } s.log.Info("http→https redirect listening", "addr", s.tlsRedirect) go func() { if err := redirectSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { errCh <- err } }() } select { case err := <-errCh: return err case <-ctx.Done(): shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() s.log.Info("shutting down") if redirectSrv != nil { _ = redirectSrv.Shutdown(shutCtx) } return s.httpServer.Shutdown(shutCtx) } } // httpsRedirectHandler 301-redirects any plain-HTTP request to the HTTPS service. // It preserves the requested hostname and path, swapping in the TLS listener's // port (omitted when 443). func httpsRedirectHandler(tlsAddr string) http.Handler { _, tlsPort, _ := net.SplitHostPort(tlsAddr) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { host := r.Host if h, _, err := net.SplitHostPort(host); err == nil { host = h } target := "https://" + host if tlsPort != "" && tlsPort != "443" { target += ":" + tlsPort } target += r.URL.RequestURI() http.Redirect(w, r, target, http.StatusMovedPermanently) }) }