// Package httpsrv exposes the svc-proxy operator UI + JSON API. Designed to // be reverse-proxied behind server-manager (no auth/TLS at this layer; the // listener should bind to the container network only). package httpsrv import ( "context" "embed" "encoding/json" "errors" "io/fs" "log/slog" "net/http" "sort" "time" "git.timemachine.center/timemachine/svc-proxy/internal/bridge" ) //go:embed static/* var staticFS embed.FS type Server struct { addr string mgr *bridge.Manager bus *LogBus srv *http.Server } func New(addr string, mgr *bridge.Manager, bus *LogBus) *Server { mux := http.NewServeMux() s := &Server{ addr: addr, mgr: mgr, bus: bus, } sub, err := fs.Sub(staticFS, "static") if err != nil { panic(err) // embed.FS misconfigured at build time } mux.Handle("GET /", http.FileServer(http.FS(sub))) mux.HandleFunc("GET /api/tunnels", s.handleTunnels) mux.HandleFunc("GET /api/logs", sseLogs(bus)) s.srv = &http.Server{ Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second, } return s } // Run blocks until ctx is cancelled or the server errors. func (s *Server) Run(ctx context.Context) error { go func() { <-ctx.Done() shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = s.srv.Shutdown(shutCtx) }() slog.Info("http server listening", "addr", s.addr) if err := s.srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { return err } return nil } func (s *Server) handleTunnels(w http.ResponseWriter, _ *http.Request) { snap := s.mgr.Snapshot() // Sort by most-recently-active first so the UI can render top-down. sort.Slice(snap, func(i, j int) bool { return snap[i].LastSeen.After(snap[j].LastSeen) }) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "tunnels": snap, "at": time.Now(), }) }