225 lines
8.2 KiB
Go
225 lines
8.2 KiB
Go
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)
|
|
})
|
|
}
|