Files
uopi/internal/server/server.go
T
2026-06-22 17:49:14 +02:00

130 lines
4.1 KiB
Go

package server
import (
"context"
"io/fs"
"log/slog"
"net/http"
"strings"
"time"
"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
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.
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, log *slog.Logger) *Server {
if rec == nil {
rec = audit.Nop()
}
mux := http.NewServeMux()
// Health check
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
// WebSocket endpoint
mux.Handle("/ws", &wsHandler{broker: brk, log: log, userHeader: trustedUserHeader, 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, log).Register(apiMux, apiPrefix)
mux.Handle(apiPrefix+"/", accessMiddleware(policy, trustedUserHeader, apiMux))
// Embedded frontend — must be last (catch-all)
mux.Handle("/", http.FileServerFS(webFS))
return &Server{
httpServer: &http.Server{
Addr: addr,
Handler: mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
},
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 {
s.log.Info("listening", "addr", s.httpServer.Addr)
errCh := make(chan error, 1)
go func() {
if err := s.httpServer.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")
return s.httpServer.Shutdown(shutCtx)
}
}