package server import ( "context" "fmt" "io" "io/fs" "log" "net" "net/http" httppprof "net/http/pprof" "net/url" "sort" "strconv" "strings" gosync "sync" "time" "github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2/adapters/humago" "go.kenn.io/agentsview/internal/config" "go.kenn.io/agentsview/internal/db" "go.kenn.io/agentsview/internal/insight" "go.kenn.io/agentsview/internal/postgres" "go.kenn.io/agentsview/internal/remotesync" "go.kenn.io/agentsview/internal/service" "go.kenn.io/agentsview/internal/sync" "go.kenn.io/agentsview/internal/web" "go.kenn.io/kit/daemon" ) // VersionInfo holds build-time version metadata. type VersionInfo struct { Version string `json:"version"` Commit string `json:"commit"` BuildDate string `json:"build_date"` ReadOnly bool `json:"read_only,omitempty"` InsightGenerationAvailable bool `json:"insight_generation_available,omitempty"` APIVersion int `json:"api_version"` DataVersion int `json:"data_version"` } const daemonService = "agentsview" const ( defaultInsightLogDrainTimeout = 2 * time.Second defaultInsightLogStopWaitTimeout = 500 * time.Millisecond ) // Server is the HTTP server that serves the SPA and REST API. type Server struct { mu gosync.RWMutex cfg config.Config db db.Store engine *sync.Engine onDemandEngine *sync.Engine sessions service.SessionService broadcaster *Broadcaster mux *http.ServeMux api huma.API httpSrv *http.Server version VersionInfo dataDir string httpRemoteCleanupRegistry *remotesync.CleanupRegistry // baseCtx, when set, is used as the base context for all // incoming requests. Cancelling it causes SSE handlers to // exit promptly, which unblocks graceful shutdown. baseCtx context.Context generateStreamFunc insight.GenerateStreamFunc spaFS fs.FS spaHandler http.Handler insightLogDrainTimeout time.Duration insightLogStopWaitTimeout time.Duration // handlerDelay is injected before each timeout-wrapped // handler, used only by tests to guarantee handlers // exceed a short timeout. Zero in production. handlerDelay time.Duration // updateCheckFn is the function called to check for // updates. Defaults to update.CheckForUpdate; tests // can override it via WithUpdateChecker. updateCheckFn UpdateCheckFunc // basePath is a URL prefix for reverse-proxy deployments // (e.g. "/agentsview"). When set, all routes are served // under this prefix and a tag is injected // into the SPA's index.html. basePath string idle *IdleTracker // pprofEnabled registers net/http/pprof handlers under // /debug/pprof/ so a running daemon can be profiled. Off by // default; enabled by the hidden serve --pprof flag. pprofEnabled bool // embeddingsManager, when set, backs the /api/v1/embeddings/... // build lifecycle routes. Nil (the default) leaves those routes // unregistered, e.g. when semantic search is not configured. embeddingsManager EmbeddingsManager // embeddingsUnavailableReason, when non-empty, replaces the generic // "embeddings manager not available" 501 message on the embeddings // routes with a cause-specific one (e.g. vector serving disabled at // startup because vectors.write.lock was held). embeddingsUnavailableReason string // vectorPushSource, when set, supplies the local vectors.db active // generation to the daemon's pg push handler. Nil leaves the vector // push phase skipped, e.g. when [vector] is disabled. vectorPushSource postgres.VectorPushSource } // New creates a new Server. func New( cfg config.Config, database db.Store, engine *sync.Engine, opts ...Option, ) *Server { dist, err := web.Assets() if err != nil { log.Fatalf("embedded frontend not found: %v", err) } // Pick the backend that matches the concrete store. A local *db.DB uses // the direct backend even when the sync engine is nil; direct Sync already // returns db.ErrReadOnly without an engine, while read APIs such as stats // still need the local handle. Non-SQLite stores use the generic read-only // backend. var sessions service.SessionService if local, ok := database.(*db.DB); ok { sessions = service.NewDirectBackend(local, engine) } else { sessions = service.NewReadOnlyBackend(database) } s := &Server{ cfg: cfg, db: database, engine: engine, sessions: sessions, mux: http.NewServeMux(), httpRemoteCleanupRegistry: new(remotesync.CleanupRegistry), insightLogDrainTimeout: defaultInsightLogDrainTimeout, insightLogStopWaitTimeout: defaultInsightLogStopWaitTimeout, generateStreamFunc: func( ctx context.Context, agent, prompt string, onLog insight.LogFunc, ) (insight.Result, error) { return insight.GenerateStreamWithOptions( ctx, agent, prompt, onLog, insight.GenerateOptions{ Agents: insightAgentConfig(cfg.Agent), }, ) }, spaFS: dist, spaHandler: http.FileServerFS(dist), } for _, opt := range opts { opt(s) } if s.version.APIVersion == 0 { s.version.APIVersion = 2 } if s.version.DataVersion == 0 { s.version.DataVersion = db.CurrentDataVersion() } s.routes() return s } // Option configures a Server. type Option func(*Server) func insightAgentConfig( cfg map[string]config.AgentConfig, ) map[string]insight.AgentConfig { if len(cfg) == 0 { return nil } agents := make(map[string]insight.AgentConfig, len(cfg)) for name, agentCfg := range cfg { agents[name] = insight.AgentConfig{ Binary: agentCfg.Binary, Sandbox: agentCfg.Sandbox, AllowUnsafe: agentCfg.AllowUnsafe, } } return agents } // WithVersion sets the build-time version metadata. Zero-valued fields are // filled with defaults in New after all options have been applied. func WithVersion(v VersionInfo) Option { return func(s *Server) { s.version = v } } // WithDataDir sets the data directory used for update caching. func WithDataDir(dir string) Option { return func(s *Server) { s.dataDir = dir } } // WithBaseContext sets the base context for all incoming HTTP // requests. When this context is cancelled, request contexts // are also cancelled, causing long-lived handlers (SSE) to // exit and unblocking graceful shutdown. func WithBaseContext(ctx context.Context) Option { return func(s *Server) { s.baseCtx = ctx } } // WithHTTPRemoteCleanupRegistry shares cleanup ownership with other HTTP sync // entry points in the same process, such as scheduled daemon syncs. func WithHTTPRemoteCleanupRegistry(registry *remotesync.CleanupRegistry) Option { return func(s *Server) { if registry != nil { s.httpRemoteCleanupRegistry = registry } } } // WithBroadcaster wires an event broadcaster into the server so the // /api/v1/events handler has something to subscribe to. Required for // live-refresh SSE; absent in PG serve mode where the engine is nil. func WithBroadcaster(b *Broadcaster) Option { return func(s *Server) { s.broadcaster = b } } // WithUpdateChecker overrides the update check function, // allowing tests to substitute a deterministic stub. func WithUpdateChecker(f UpdateCheckFunc) Option { return func(s *Server) { s.updateCheckFn = f } } // WithBasePath sets a URL prefix for reverse-proxy deployments. // The path must start with "/" and not end with "/" (e.g. // "/agentsview"). When set, the server strips this prefix from // incoming requests and injects a tag into the SPA. func WithBasePath(path string) Option { return func(s *Server) { s.basePath = strings.TrimRight(path, "/") } } // WithGenerateFunc overrides the insight generation function, // allowing tests to substitute a stub. Nil is ignored. func WithGenerateFunc(f insight.GenerateFunc) Option { return func(s *Server) { if f != nil { s.generateStreamFunc = func( ctx context.Context, agent, prompt string, _ insight.LogFunc, ) (insight.Result, error) { return f(ctx, agent, prompt) } } } } // WithGenerateStreamFunc overrides the streaming insight // generation function used by the SSE handler. Nil is ignored. func WithGenerateStreamFunc(f insight.GenerateStreamFunc) Option { return func(s *Server) { if f != nil { s.generateStreamFunc = f } } } // WithInsightLogDrainTimeouts overrides SSE insight log stream drain timeouts. // Zero or negative values keep the production defaults. func WithInsightLogDrainTimeouts(drain, stopWait time.Duration) Option { return func(s *Server) { if drain > 0 { s.insightLogDrainTimeout = drain } if stopWait > 0 { s.insightLogStopWaitTimeout = stopWait } } } func WithIdleTracker(t *IdleTracker) Option { return func(s *Server) { s.idle = t } } // WithPprof enables the net/http/pprof handlers under // /debug/pprof/ for live profiling of a running daemon. func WithPprof(enabled bool) Option { return func(s *Server) { s.pprofEnabled = enabled } } func (s *Server) humaConfig() huma.Config { version := s.version.Version if version == "" { version = "dev" } cfg := huma.DefaultConfig("AgentsView API", version) cfg.Info.Description = "HTTP API for browsing, searching, syncing, and managing local agent sessions." cfg.OpenAPIPath = "/api/openapi" cfg.DocsPath = "" cfg.SchemasPath = "" cfg.CreateHooks = nil cfg.Components.Schemas = huma.NewMapRegistry( "#/components/schemas/", agentsViewSchemaNamer, ) if s.basePath != "" { cfg.Servers = []*huma.Server{{ URL: s.basePath, Description: "Configured reverse-proxy base path", }} } return cfg } func (s *Server) routes() { configureHumaErrors() s.api = humago.New(s.mux, s.humaConfig()) s.registerTypedAPIRoutes() if s.pprofEnabled { s.mux.HandleFunc("/debug/pprof/", httppprof.Index) s.mux.HandleFunc("/debug/pprof/cmdline", httppprof.Cmdline) s.mux.HandleFunc("/debug/pprof/profile", httppprof.Profile) s.mux.HandleFunc("/debug/pprof/symbol", httppprof.Symbol) s.mux.HandleFunc("/debug/pprof/trace", httppprof.Trace) } s.mux.Handle("GET /api/v1/recall/entries", s.withTimeout(s.handleListRecallEntries)) s.mux.Handle("GET /api/v1/recall/entries/{id}", s.withTimeout(s.handleGetRecallEntry)) s.mux.Handle("POST /api/v1/recall/query", s.withTimeout(s.handleQueryRecallEntries)) s.mux.Handle("POST /api/v1/recall/import", s.withTimeout(s.handleImportRecallEntries)) s.registerEvalIngestRoutes() // SPA fallback: serve embedded frontend // Do not use timeout handler for static assets to avoid buffering. s.mux.Handle("/", http.HandlerFunc(s.handleSPA)) } func (s *Server) handleSPA(w http.ResponseWriter, r *http.Request) { // Try to serve the exact file path := strings.TrimPrefix(r.URL.Path, "/") if path == "" { path = "index.html" } f, err := s.spaFS.Open(path) if err == nil { f.Close() if path == "index.html" { w.Header().Set("Cache-Control", "no-cache") } if strings.HasPrefix(path, "assets/") { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") } // For index.html with a base path, inject . if s.basePath != "" && path == "index.html" { s.serveIndexWithBase(w, r) return } s.spaHandler.ServeHTTP(w, r) return } // Fingerprinted frontend assets are files, not client-side routes. // Returning index.html here disguises stale asset URLs as successful // JavaScript or CSS responses after an upgrade. if strings.HasPrefix(path, "assets/") { http.NotFound(w, r) return } // SPA fallback: serve index.html for all routes w.Header().Set("Cache-Control", "no-cache") if s.basePath != "" { s.serveIndexWithBase(w, r) return } r.URL.Path = "/" s.spaHandler.ServeHTTP(w, r) } // serveIndexWithBase reads the embedded index.html, injects a // tag, and rewrites root-relative asset paths so // everything resolves correctly behind a reverse proxy subpath. func (s *Server) serveIndexWithBase( w http.ResponseWriter, _ *http.Request, ) { f, err := s.spaFS.Open("index.html") if err != nil { http.Error(w, "index.html not found", http.StatusInternalServerError) return } defer f.Close() data, err := io.ReadAll(f) if err != nil { http.Error(w, "reading index.html", http.StatusInternalServerError) return } html := string(data) // Rewrite root-relative asset paths (href="/...", src="/...") // to include the base path prefix so the browser fetches // assets through the reverse proxy. bp := s.basePath html = strings.ReplaceAll(html, `href="/`, `href="`+bp+`/`) html = strings.ReplaceAll(html, `src="/`, `src="`+bp+`/`) // Inject AFTER rewriting paths so it doesn't // get double-prefixed by the replacement above. baseTag := fmt.Sprintf( ``, bp, ) html = strings.Replace( html, "", "\n "+baseTag, 1, ) w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write([]byte(html)) } // SetPort updates the listen port (for testing). func (s *Server) SetPort(port int) { s.mu.Lock() defer s.mu.Unlock() s.cfg.Port = port } // SetGithubToken updates the GitHub token for testing. func (s *Server) SetGithubToken(token string) { s.mu.Lock() defer s.mu.Unlock() s.cfg.GithubToken = token } // githubToken returns the configured GitHub token or a fallback token // from the process environment / GitHub CLI (thread-safe). func (s *Server) githubToken(ctx context.Context) string { s.mu.RLock() defer s.mu.RUnlock() return resolveGitHubToken(ctx, s.cfg.GithubToken) } // Handler returns the http.Handler with middleware applied. func (s *Server) Handler() http.Handler { allowedOrigins := buildAllowedOrigins( s.cfg.Host, s.cfg.Port, s.cfg.PublicOrigins, ) allowedHosts := buildAllowedHosts( s.cfg.Host, s.cfg.Port, s.cfg.PublicURL, s.cfg.PublicOrigins, ) bindAll := isBindAll(s.cfg.Host) bindAllIPs := map[string]bool(nil) if bindAll { bindAllIPs = localInterfaceIPs() } h := cspMiddleware( s.cfg.Host, s.cfg.Port, s.basePath, s.cfg.PublicURL, s.cfg.PublicOrigins, s.authMiddleware( hostCheckMiddleware( allowedHosts, bindAll, s.cfg.Port, bindAllIPs, s.protectedPath, corsMiddleware( allowedOrigins, bindAll, s.cfg.Port, bindAllIPs, gzipMiddleware(logMiddleware(s.mux)), ), ), ), ) if s.basePath != "" { inner := h prefix := s.basePath h = http.HandlerFunc(func( w http.ResponseWriter, r *http.Request, ) { p := r.URL.Path // Redirect /basepath to /basepath/ for the SPA. if p == prefix { http.Redirect(w, r, prefix+"/", http.StatusMovedPermanently) return } // Only match full path-segment prefixes to // prevent /basepathFOO from being handled. if !strings.HasPrefix(p, prefix+"/") { http.NotFound(w, r) return } http.StripPrefix(prefix, inner). ServeHTTP(w, r) }) } h = s.idle.Wrap(h) return h } // cspMiddleware sets a Content-Security-Policy header on non-API // responses. The policy pins the exact host:port origin so that // even if Tauri's compile-time CSP uses a wildcard port, the // intersection narrows to the actual runtime port. func cspMiddleware( host string, port int, basePath string, publicURL string, publicOrigins []string, next http.Handler, ) http.Handler { policy := buildCSPPolicy(host, port, basePath, publicURL, publicOrigins) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.HasPrefix(r.URL.Path, "/api/") { w.Header().Set("Content-Security-Policy", policy) w.Header().Set("X-Frame-Options", "DENY") } next.ServeHTTP(w, r) }) } // buildCSPPolicy constructs the Content-Security-Policy string. // // The server's own origin (host:port) is pinned in the resource // directives (default/script/img/style/font) because WebKitGTK in a // Tauri webview may not resolve 'self' to the Go server origin after // navigating from tauri://localhost. // // connect-src is intentionally widened to any http/https/ws/wss // origin. The "Connect to Remote Server" feature (see // frontend/src/lib/api/client.ts) lets the user point the SPA at an // arbitrary remote agentsview API origin stored client-side, which // this server cannot know when the policy is built. This mirrors the // backend, where authenticated remote requests already bypass the // host-check and CORS restrictions (see isRemoteAuth in auth.go and // corsMiddleware). Security tradeoff: a broad connect-src means that // if an XSS ever executed in the app, exfiltration would be easier; // the other directives stay pinned so script execution remains gated // to 'self'. func buildCSPPolicy( host string, port int, basePath string, publicURL string, publicOrigins []string, ) string { // serverOrigins are the pinned origins used in the resource // directives so resources load correctly regardless of how the // webview resolves 'self'. Public origins are included for // reverse-proxy deployments; concrete local origins are preserved // for desktop webviews that need the backend socket pinned. serverOrigins := cspPinnedOrigins(host, port, publicURL, publicOrigins) resourceSrc := "'self' " + strings.Join(serverOrigins, " ") baseURI := "'none'" if basePath != "" { baseURI = "'self'" } return fmt.Sprintf( "default-src %[1]s; "+ "script-src %[1]s; "+ "connect-src 'self' http: https: ws: wss:; "+ "img-src %[1]s data:; "+ "style-src %[1]s 'unsafe-inline' https://fonts.googleapis.com; "+ "font-src %[1]s data: https://fonts.gstatic.com; "+ "object-src 'none'; "+ "base-uri %[2]s; "+ "frame-ancestors 'none'", resourceSrc, baseURI, ) } func cspPinnedOrigins( host string, port int, publicURL string, publicOrigins []string, ) []string { origins := make([]string, 0, 1+len(publicOrigins)+1) seen := make(map[string]bool) add := func(origin string) { if origin == "" || seen[origin] { return } seen[origin] = true origins = append(origins, origin) } for _, raw := range append([]string{publicURL}, publicOrigins...) { add(normalizedOrigin(raw)) } if !isUnspecifiedHost(host) { add("http://" + net.JoinHostPort(host, strconv.Itoa(port))) } if len(origins) == 0 { add("http://" + net.JoinHostPort(host, strconv.Itoa(port))) } return origins } func isUnspecifiedHost(host string) bool { ip := net.ParseIP(host) return ip != nil && ip.IsUnspecified() } func normalizedOrigin(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { return "" } u, err := url.Parse(raw) if err != nil || u.Scheme == "" || u.Host == "" { return "" } if u.Scheme != "http" && u.Scheme != "https" { return "" } return u.Scheme + "://" + u.Host } // buildAllowedHosts returns the set of Host header values that // are legitimate for this server. This defends against DNS // rebinding attacks where an attacker's domain resolves to // 127.0.0.1 — the browser sends the attacker's domain as the // Host header, which we reject. func buildAllowedHosts( host string, port int, publicURL string, publicOrigins []string, ) map[string]bool { hosts := make(map[string]bool) add := func(h string) { hosts[net.JoinHostPort(h, strconv.Itoa(port))] = true // Browsers may omit port 80 from the Host header. // IPv6 literals need brackets (e.g., [::1]). if port == 80 { if strings.Contains(h, ":") { hosts["["+h+"]"] = true } else { hosts[h] = true } } } add(host) switch host { case "127.0.0.1": add("localhost") case "localhost": add("127.0.0.1") case "0.0.0.0", "::": add("127.0.0.1") add("localhost") add("::1") case "::1": add("127.0.0.1") add("localhost") } if publicURL != "" { addHostHeadersFromOrigin(hosts, publicURL) } for _, origin := range publicOrigins { addHostHeadersFromOrigin(hosts, origin) } return hosts } // hostCheckMiddleware validates the Host header against expected // values to prevent DNS rebinding attacks. Only applied to paths the // protected predicate matches (API routes, and pprof when enabled) — // the SPA fallback is left accessible for flexibility. func hostCheckMiddleware( allowedHosts map[string]bool, bindAll bool, port int, allowedIPs map[string]bool, protected func(path string) bool, next http.Handler, ) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if protected(r.URL.Path) { // Authenticated remote requests bypass host checks. if isRemoteAuth(r) { next.ServeHTTP(w, r) return } hostAllowed := allowedHosts[r.Host] // In bind-all mode, also allow local-interface IP-literal // hosts on the configured port so LAN clients can reach the // API while still rejecting rebinding via attacker-controlled // domains. if !hostAllowed && bindAll { hostAllowed = isAllowedBindAllHost(r.Host, port, allowedIPs) } if !hostAllowed { allowed := sortedHosts(allowedHosts) log.Printf( "host check rejected %s %s: Host %q not in allowed "+ "set %v; if reaching agentsview through a forwarded "+ "port or remote host, restart with --public-url "+ " matching your browser URL", r.Method, r.URL.Path, r.Host, allowed, ) http.Error( w, hostRejectionMessage(r.Host, allowed), http.StatusForbidden, ) return } } next.ServeHTTP(w, r) }) } // sortedHosts returns the allowed Host header values as a sorted // slice for deterministic log and error output. func sortedHosts(hosts map[string]bool) []string { out := make([]string, 0, len(hosts)) for h := range hosts { out = append(out, h) } sort.Strings(out) return out } // hostRejectionMessage builds a self-explaining 403 body for a // rejected Host header. It names the offending Host, lists the // allowed values, and points at --public-url so users behind SSH // port-forwarding, reverse proxies, or remote dev environments // (exe.dev, Codespaces, Coder, WSL2) can diagnose without devtools. func hostRejectionMessage(host string, allowed []string) string { return fmt.Sprintf( "Forbidden: request Host %q is not in the allowed set %v. "+ "If you are reaching agentsview through SSH port-forwarding, "+ "a reverse proxy, or a remote dev environment, restart the "+ "server with --public-url matching the URL in your "+ "browser (for example --public-url http://%s).", host, allowed, host, ) } // httpOrigin formats an HTTP origin string. It uses // net.JoinHostPort to handle IPv6 bracket formatting correctly // (e.g., [::1]:8080). Browsers omit the port from the Origin // header for default ports (80 for HTTP), so for port 80 both // forms are returned. func httpOrigin(host string, port int) []string { hp := net.JoinHostPort(host, strconv.Itoa(port)) origin := "http://" + hp if port == 80 { // net.JoinHostPort brackets IPv6, so use it for the // portless form too: JoinHostPort("::1","") is not // valid, so bracket manually when needed. bare := host if strings.Contains(host, ":") { bare = "[" + host + "]" } return []string{origin, "http://" + bare} } return []string{origin} } // buildAllowedOrigins returns the set of origins that should be // permitted by CORS. For loopback addresses, both "127.0.0.1" // and "localhost" are allowed because browsers treat them as // distinct origins. func buildAllowedOrigins(host string, port int, publicOrigins []string) map[string]bool { origins := make(map[string]bool) add := func(h string) { for _, o := range httpOrigin(h, port) { origins[o] = true } } add(host) // When binding to a loopback address, also allow the other // loopback variants because browsers treat them as distinct // origins. When binding to 0.0.0.0 or :: (all interfaces), // allow all loopback origins since that's how browsers will // access a bind-all server. switch host { case "127.0.0.1": add("localhost") case "localhost": add("127.0.0.1") case "0.0.0.0", "::": add("127.0.0.1") add("localhost") add("::1") case "::1": add("127.0.0.1") add("localhost") } for _, origin := range publicOrigins { origins[origin] = true } return origins } func addHostHeadersFromOrigin(hosts map[string]bool, origin string) { u, err := url.Parse(origin) if err != nil || u == nil || u.Host == "" { return } hosts[u.Host] = true if u.Port() != "" { return } defaultPort := "80" if u.Scheme == "https" { defaultPort = "443" } hosts[net.JoinHostPort(u.Hostname(), defaultPort)] = true } // isBindAll returns true when the server is listening on all // interfaces (0.0.0.0 or ::), meaning LAN clients may connect // via the machine's real IP. func isBindAll(host string) bool { return host == "0.0.0.0" || host == "::" } // isAllowedBindAllHost returns true for Host header values that are // local-interface IP literals on the server's configured port. func isAllowedBindAllHost( hostHeader string, port int, allowedIPs map[string]bool, ) bool { host, ok := parseHostHeader(hostHeader, port) if !ok { return false } ip := net.ParseIP(host) if ip == nil { return false } return allowedIPs[ip.String()] } // parseHostHeader validates and normalizes an HTTP Host header for // the configured server port, returning the host portion. func parseHostHeader(hostHeader string, port int) (string, bool) { if hostHeader == "" { return "", false } host, gotPort, err := net.SplitHostPort(hostHeader) if err == nil { return host, gotPort == strconv.Itoa(port) } // Browsers may omit :80 from Host for default HTTP port. if port != 80 { return "", false } host = hostHeader // Strip IPv6 brackets for ParseIP. if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { host = host[1 : len(host)-1] } return host, true } // localInterfaceIPs returns canonical IP strings assigned to local // network interfaces (including loopback). func localInterfaceIPs() map[string]bool { ips := map[string]bool{ "127.0.0.1": true, "::1": true, } ifaces, err := net.Interfaces() if err != nil { return ips } for _, iface := range ifaces { if iface.Flags&net.FlagUp == 0 { continue } addrs, err := iface.Addrs() if err != nil { continue } for _, addr := range addrs { var ip net.IP switch v := addr.(type) { case *net.IPNet: ip = v.IP case *net.IPAddr: ip = v.IP default: continue } if ip == nil { continue } ips[ip.String()] = true } } return ips } // ListenAndServe starts the HTTP server. func (s *Server) ListenAndServe() error { addr := fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port) listenCtx := context.Background() if s.baseCtx != nil { listenCtx = s.baseCtx } ln, err := daemon.Listen( listenCtx, daemon.Endpoint{ Network: daemon.NetworkTCP, Address: addr, }, daemon.WithRuntimeStore(daemon.RuntimeStore{Dir: s.dataDir}), ) if err != nil { return err } return s.Serve(ln) } // Serve starts the HTTP server on an existing listener. func (s *Server) Serve(ln net.Listener) error { addr := ln.Addr().String() srv := &http.Server{ Addr: addr, Handler: s.Handler(), ReadTimeout: 10 * time.Second, IdleTimeout: 120 * time.Second, } if s.baseCtx != nil { ctx := s.baseCtx srv.BaseContext = func(_ net.Listener) context.Context { return ctx } } s.mu.Lock() s.httpSrv = srv s.mu.Unlock() log.Printf("Starting server at http://%s", addr) return srv.Serve(ln) } // Shutdown gracefully shuts down the HTTP server, then closes the // server-owned on-demand sync engine (if one was lazily created) so // its pending debounced signal recomputes flush while the DB is // still open. Injected engines are closed by their owner. func (s *Server) Shutdown(ctx context.Context) error { s.mu.RLock() srv := s.httpSrv s.mu.RUnlock() var err error if srv != nil { err = srv.Shutdown(ctx) } s.mu.Lock() engine := s.onDemandEngine s.onDemandEngine = nil s.mu.Unlock() if engine != nil { engine.Close() } return err } // FindAvailablePort finds an available port starting from the // given port, binding to the specified host. func FindAvailablePort(host string, start int) int { if start == 0 { addr := net.JoinHostPort(host, "0") ln, err := net.Listen("tcp", addr) if err == nil { defer ln.Close() if tcpAddr, ok := ln.Addr().(*net.TCPAddr); ok { return tcpAddr.Port } } return start } for port := start; port < start+100; port++ { addr := net.JoinHostPort(host, strconv.Itoa(port)) ln, err := net.Listen("tcp", addr) if err == nil { ln.Close() return port } } return start } // isMutating returns true for HTTP methods that change state. func isMutating(method string) bool { return method == http.MethodPost || method == http.MethodPut || method == http.MethodPatch || method == http.MethodDelete } func corsMiddleware( allowedOrigins map[string]bool, bindAll bool, port int, allowedIPs map[string]bool, next http.Handler, ) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/api/") { origin := r.Header.Get("Origin") // Authenticated remote requests: allow any origin. if isRemoteAuth(r) { if origin != "" { w.Header().Set( "Access-Control-Allow-Origin", origin, ) } ensureVaryHeader(w.Header(), "Origin") w.Header().Set( "Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS", ) w.Header().Set( "Access-Control-Allow-Headers", "Content-Type, Authorization, "+service.SemanticSearchIntentHeader, ) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } next.ServeHTTP(w, r) return } // For reads (GET/HEAD), allow empty Origin (same-origin // requests often omit it). For mutating methods and // preflights, require Origin to be present and allowed. originAllowed := allowedOrigins[origin] // In bind-all mode, allow local-interface IP-literal // origins on the configured port so LAN UI access works // without opening wildcard cross-origin access. if !originAllowed && bindAll { originAllowed = isAllowedBindAllOrigin(origin, port, allowedIPs) } safeForReads := origin == "" || originAllowed if originAllowed { w.Header().Set( "Access-Control-Allow-Origin", origin, ) } // Always set Vary so caches don't serve a // response without CORS headers to a // legitimate origin. ensureVaryHeader(w.Header(), "Origin") w.Header().Set( "Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS", ) w.Header().Set( "Access-Control-Allow-Headers", "Content-Type, Authorization, "+service.SemanticSearchIntentHeader, ) if r.Method == http.MethodOptions { if !safeForReads { http.Error( w, "Forbidden", http.StatusForbidden, ) return } w.WriteHeader(http.StatusNoContent) return } // Block state-changing requests unless Origin // is present and recognized. This prevents // CSRF via simple requests (e.g.,
POST) // and DNS rebinding where Origin is absent. if !originAllowed && isMutating(r.Method) { http.Error( w, "Forbidden", http.StatusForbidden, ) return } } next.ServeHTTP(w, r) }) } // isAllowedBindAllOrigin returns true when Origin is an http:// // local-interface IP-literal origin using the configured server port. func isAllowedBindAllOrigin(origin string, port int, allowedIPs map[string]bool) bool { u, err := url.Parse(origin) if err != nil || u == nil { return false } if u.Scheme != "http" || u.Host == "" { return false } if u.Path != "" || u.RawQuery != "" || u.Fragment != "" { return false } ip := net.ParseIP(u.Hostname()) if ip == nil { return false } gotPort := u.Port() portOK := false if port == 80 { portOK = gotPort == "" || gotPort == "80" } else { portOK = gotPort == strconv.Itoa(port) } if !portOK { return false } return allowedIPs[ip.String()] } // ensureVaryHeader appends token to Vary if not already present, // preserving any existing Vary values. func ensureVaryHeader(h http.Header, token string) { if token == "" { return } seen := make(map[string]bool) values := make([]string, 0, 4) for _, vary := range h.Values("Vary") { for part := range strings.SplitSeq(vary, ",") { p := strings.TrimSpace(part) if p == "" { continue } key := strings.ToLower(p) if seen[key] { continue } seen[key] = true values = append(values, p) } } tokenKey := strings.ToLower(token) if !seen[tokenKey] { values = append(values, token) } if len(values) == 0 { return } h.Set("Vary", strings.Join(values, ", ")) } func logMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/api/") { log.Printf("%s %s", r.Method, r.URL.Path) } next.ServeHTTP(w, r) }) }