diff --git a/cmd/krply-server/main.go b/cmd/krply-server/main.go index ec52042..2e5994a 100644 --- a/cmd/krply-server/main.go +++ b/cmd/krply-server/main.go @@ -42,6 +42,17 @@ func run() error { return nil } + listenAddr := *listen + listenFlagSet := false + flag.Visit(func(f *flag.Flag) { + if f.Name == "listen" { + listenFlagSet = true + } + }) + if !listenFlagSet && os.Getenv("PORT") != "" { + listenAddr = ":" + os.Getenv("PORT") + } + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil))) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) @@ -64,11 +75,11 @@ func run() error { return fmt.Errorf("build api server: %w", err) } - httpSrv := &http.Server{Addr: *listen, Handler: srv.Handler()} + httpSrv := &http.Server{Addr: listenAddr, Handler: srv.Handler()} errCh := make(chan error, 1) go func() { - slog.Info("krply-server listening", "addr", *listen, "store", *storePath, "version", version) + slog.Info("krply-server listening", "addr", listenAddr, "store", *storePath, "version", version) errCh <- httpSrv.ListenAndServe() }() diff --git a/internal/api/server.go b/internal/api/server.go index 86dbd34..7b65a18 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -42,6 +42,8 @@ type Server struct { planMu sync.RWMutex plans map[string]*replay.Plan planTimes map[string]time.Time + + corsOrigins []string } // NewServer wires a Server onto the given store, materializer, planner, and @@ -65,18 +67,54 @@ func NewServer(store storage.Store, mat *materialize.Materializer, planner *repl if fi, err := os.Stat("web/dist"); err == nil && fi.IsDir() { static = os.DirFS("web/dist") } + origins := strings.Split(os.Getenv("KRPLY_CORS_ORIGINS"), ",") + if len(origins) == 1 && origins[0] == "" { + origins = []string{"*"} + } return &Server{ - store: store, - mat: mat, - planner: planner, - metrics: m, - version: version, - static: static, - plans: map[string]*replay.Plan{}, - planTimes: map[string]time.Time{}, + store: store, + mat: mat, + planner: planner, + metrics: m, + version: version, + static: static, + plans: map[string]*replay.Plan{}, + planTimes: map[string]time.Time{}, + corsOrigins: origins, }, nil } +// cors wraps the mux with CORS headers so the static web UI can query the API +// from a different origin. Origins are allowed when they match KRPLY_CORS_ORIGINS +// (comma-separated, "*" for any). Preflight OPTIONS requests are short-circuited. +func (s *Server) cors(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if origin != "" { + if s.allowsOrigin(origin) { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Accept") + w.Header().Set("Vary", "Origin") + } + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + } + next.ServeHTTP(w, r) + }) +} + +func (s *Server) allowsOrigin(origin string) bool { + for _, o := range s.corsOrigins { + if o == "*" || o == origin { + return true + } + } + return false +} + // Handler returns the HTTP handler with all routes registered. func (s *Server) Handler() http.Handler { mux := http.NewServeMux() @@ -95,7 +133,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /v1/replay-runs", s.handleReplayRun) mux.HandleFunc("GET /metrics", s.handleMetrics) mux.HandleFunc("/", s.handleStatic) - return mux + return s.cors(mux) } // planByID returns the registered plan and whether it exists. diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 0a27f4c..99284b2 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -181,6 +181,38 @@ func TestHealth(t *testing.T) { } } +func TestCORS(t *testing.T) { + ts := newTestServer(t, storage.NewInMemory()) + + req, err := http.NewRequest(http.MethodGet, ts.URL+"/v1/health", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Origin", "https://krply.lverma.com") + res, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("get health: %v", err) + } + defer res.Body.Close() + if got := res.Header.Get("Access-Control-Allow-Origin"); got != "https://krply.lverma.com" { + t.Errorf("Access-Control-Allow-Origin = %q, want origin echoed", got) + } + + pre, err := http.NewRequest(http.MethodOptions, ts.URL+"/v1/health", nil) + if err != nil { + t.Fatalf("new preflight request: %v", err) + } + pre.Header.Set("Origin", "https://krply.lverma.com") + pres, err := http.DefaultClient.Do(pre) + if err != nil { + t.Fatalf("preflight: %v", err) + } + defer pres.Body.Close() + if pres.StatusCode != http.StatusNoContent { + t.Errorf("preflight status = %d, want 204", pres.StatusCode) + } +} + func TestClusters(t *testing.T) { ts := newTestServer(t, seedStore(t)) var clusters []queryv1.Cluster