Files
wehub-resource-sync f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

1155 lines
32 KiB
Go

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 <base href> 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 <base href> 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 <base href>.
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
// <base href> 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 <base href> AFTER rewriting paths so it doesn't
// get double-prefixed by the replacement above.
baseTag := fmt.Sprintf(
`<base href="%s/">`, bp,
)
html = strings.Replace(
html, "<head>", "<head>\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 "+
"<origin> 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 <origin> 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., <form> 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)
})
}