diff --git a/cmd/beacon/main.go b/cmd/beacon/main.go index 6cd51b1..b3eada1 100644 --- a/cmd/beacon/main.go +++ b/cmd/beacon/main.go @@ -267,7 +267,7 @@ func main() { }() // ── HTTP server ────────────────────────────────────────────────────────── - r := router.New(h, reader, []*ingest.Worker{broker1, broker2}, maxConnsPerIP) + r := router.New(h, reader, []*ingest.Worker{broker1, broker2}, maxConnsPerIP, cfg.CORS) srv := &http.Server{ Addr: addr, diff --git a/config.yaml.example b/config.yaml.example index 45618e0..657a8d9 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -75,6 +75,18 @@ packets: websocket: max_connections_per_ip: 5 # default: 5 +# CORS configuration. +# Controls which origins, methods and headers are allowed for cross-origin requests. +# Defaults to allowing all origins with GET/HEAD/OPTIONS if omitted — appropriate +# for a public read-only API. Restrict allowed_origins if you expose write endpoints. +#cors: +# allowed_origins: +# - "https://app.example.com" +# allowed_methods: [GET, HEAD, OPTIONS, POST] +# allowed_headers: [Accept, Authorization, Content-Type] +# allow_credentials: false +# max_age: 300 + # Redis caching layer. # If REDIS_ADDR is not set, caching is disabled and all reads go directly to PostgreSQL. # TTLs are specified as duration strings e.g. "30m", "1h", "2h". diff --git a/go.mod b/go.mod index 565d234..a84bebc 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( filippo.io/edwards25519 v1.2.0 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/go-chi/cors v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.19.5 // indirect github.com/go-openapi/jsonreference v0.20.0 // indirect github.com/go-openapi/spec v0.20.6 // indirect diff --git a/go.sum b/go.sum index c6f9972..a4ec469 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2I github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= +github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= diff --git a/internal/api/router/router.go b/internal/api/router/router.go index f1ba68e..ce4ac66 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -12,10 +12,12 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" "github.com/MeshCore-Beacon/beacon-server/internal/api" "github.com/MeshCore-Beacon/beacon-server/internal/api/handlers" mw "github.com/MeshCore-Beacon/beacon-server/internal/api/middleware" + "github.com/MeshCore-Beacon/beacon-server/internal/config" "github.com/MeshCore-Beacon/beacon-server/internal/hub" "github.com/MeshCore-Beacon/beacon-server/internal/ingest" "github.com/MeshCore-Beacon/beacon-server/internal/ws" @@ -39,9 +41,34 @@ import ( // // The private group is stubbed and ready for the auth middleware drop-in // described in Future Features → Admin authentication. -func New(h *hub.Hub, reader api.Reader, workers []*ingest.Worker, maxConnsPerIP int) http.Handler { +func New(h *hub.Hub, reader api.Reader, workers []*ingest.Worker, maxConnsPerIP int, corsCfg config.CORSConfig) http.Handler { r := chi.NewRouter() + // ── CORS ───────────────────────────────────────────────────────────────── + allowedOrigins := corsCfg.AllowedOrigins + if len(allowedOrigins) == 0 { + allowedOrigins = []string{"*"} + } + allowedMethods := corsCfg.AllowedMethods + if len(allowedMethods) == 0 { + allowedMethods = []string{"GET", "HEAD", "OPTIONS"} + } + allowedHeaders := corsCfg.AllowedHeaders + if len(allowedHeaders) == 0 { + allowedHeaders = []string{"Accept", "Authorization", "Content-Type"} + } + maxAge := corsCfg.MaxAge + if maxAge == 0 { + maxAge = 300 + } + r.Use(cors.Handler(cors.Options{ + AllowedOrigins: allowedOrigins, + AllowedMethods: allowedMethods, + AllowedHeaders: allowedHeaders, + AllowCredentials: corsCfg.AllowCredentials, + MaxAge: maxAge, + })) + // ── Global middleware ──────────────────────────────────────────────────── r.Use(middleware.RequestID) r.Use(middleware.RealIP) diff --git a/internal/config/config.go b/internal/config/config.go index 0326fd8..52b4104 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -23,6 +23,33 @@ type Config struct { Ingest IngestFilterConfig `yaml:"ingest"` Scopes []ScopeConfig `yaml:"scopes"` Cache CacheConfig `yaml:"cache"` + CORS CORSConfig `yaml:"cors"` +} + +// CORSConfig controls Cross-Origin Resource Sharing behaviour. +// If omitted, Beacon defaults to allowing all origins, which is appropriate +// for a public read-only API. Operators exposing write endpoints should +// restrict AllowedOrigins to known frontends. +type CORSConfig struct { + // AllowedOrigins is the list of origins permitted to make cross-origin + // requests. Use ["*"] to allow all origins (default if omitted). + AllowedOrigins []string `yaml:"allowed_origins"` + + // AllowedMethods is the list of HTTP methods allowed in CORS requests. + // Defaults to [GET, HEAD, OPTIONS] if omitted. + AllowedMethods []string `yaml:"allowed_methods"` + + // AllowedHeaders is the list of request headers allowed in CORS requests. + // Defaults to [Accept, Authorization, Content-Type] if omitted. + AllowedHeaders []string `yaml:"allowed_headers"` + + // AllowCredentials indicates whether the request can include user + // credentials (cookies, HTTP authentication). Defaults to false. + AllowCredentials bool `yaml:"allow_credentials"` + + // MaxAge is the number of seconds the browser may cache a preflight + // response. Defaults to 300 if omitted. + MaxAge int `yaml:"max_age"` } // CacheConfig controls Redis caching behaviour.