feat(api): add cors middleware with configurable origins

- wrap all routes with CORS, honoring KRPLY_CORS_ORIGINS (default *)
- short-circuit preflight OPTIONS and echo allowed origins
- honor the PORT env var for platform deployments (render etc)
This commit is contained in:
lakshit verma 2026-08-06 05:53:41 +05:30
parent 6576672b1d
commit 9155ea248c
No known key found for this signature in database
GPG key ID: EB498AFC60A7A01A
3 changed files with 92 additions and 11 deletions

View file

@ -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()
}()

View file

@ -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,6 +67,10 @@ 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,
@ -74,9 +80,41 @@ func NewServer(store storage.Store, mat *materialize.Materializer, planner *repl
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.

View file

@ -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