Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,22 @@ matching a now-known channel and decrypts them. Watch the startup log for

## Configuration

### Admin authentication

The `/api/v1/admin` subtree requires `Authorization: Bearer <key>`. Set the
operator key with `BEACON_API_KEY` or `auth.api_key` in YAML. A set environment
variable overrides YAML; an explicitly empty value disables admin access.
With no key, admin requests return JSON 503 while public reads and WebSockets
continue normally. With a key, missing, incorrect or duplicate Authorization
headers return JSON 401 with `WWW-Authenticate: Bearer`.

Admin operations are not implemented yet: a valid key currently reaches a 404.
Global CORS preflights remain public. Use a long, randomly generated key, keep
it out of source control and logs, and send it only in the Authorization header,
never the URL or request body. Require HTTPS at the reverse proxy and restrict
direct access to Beacon's HTTP listener to that proxy or a private connection.
Changing the key requires a restart. No API key is issued automatically.

### Environment variables (`.env`)

| Variable | Default | Description |
Expand Down
2 changes: 1 addition & 1 deletion cmd/beacon/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ func main() {
go scheduler.Start(ctx)

// ── HTTP server ──────────────────────────────────────────────────────────
r := router.New(h, reader, []*ingest.Worker{broker1, broker2}, resolved.MaxConnsPerIP, cfg.CORS, cfg.Server)
r := router.New(h, reader, []*ingest.Worker{broker1, broker2}, resolved.MaxConnsPerIP, cfg.CORS, cfg.Server, cfg.Auth)

srv := &http.Server{
Addr: addr,
Expand Down
5 changes: 5 additions & 0 deletions config.yaml.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# Beacon configuration file
# Copy to config.yaml and adjust as needed.

# Public reads stay available. No key means admin routes return 503.
# Prefer BEACON_API_KEY in the service environment; never commit a real key.
auth:
api_key: ""

server:
# Only these direct proxy peers may set the client IP through X-Real-IP.
# The proxy must overwrite that header, not pass through client input.
Expand Down
3 changes: 3 additions & 0 deletions env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
LISTEN_ADDR=:8080

# Optional admin bearer key. If set, overrides auth.api_key (even when empty).
# BEACON_API_KEY=

POSTGRES_DSN=postgres://beacon:beacon@localhost:5432/beacon?sslmode=disable

# Redis (optional — leave REDIS_ADDR unset to disable caching)
Expand Down
39 changes: 29 additions & 10 deletions internal/api/middleware/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,36 @@

package middleware

import "net/http"
import (
"crypto/subtle"
"io"
"net/http"
"strings"
)

// NoopAuth is a placeholder for the authentication middleware that will be
// wired onto the private route group when auth is implemented (see Future
// Features → Admin authentication in the design doc).
//
// Replace this with a real JWT/session validation middleware before shipping
// any write endpoints or admin functionality.
func NoopAuth(next http.Handler) http.Handler {
// BearerAuth protects admin routes with one operator key. An empty key disables
// access; public routes must be mounted outside this middleware.
func BearerAuth(apiKey string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TODO: validate bearer token, set user in context, return 401 on failure.
next.ServeHTTP(w, r)
w.Header().Set("Cache-Control", "no-store")
status := http.StatusServiceUnavailable
body := `{"error":{"code":"service_unavailable","message":"admin authentication is not configured"}}`
if apiKey != "" {
if values := r.Header.Values("Authorization"); len(values) == 1 {
scheme, token, found := strings.Cut(values[0], " ")
token = strings.TrimLeft(token, " ") // Bearer permits one or more spaces.
if found && strings.EqualFold(scheme, "Bearer") && token != "" && !strings.ContainsAny(token, " \t\r\n,") &&
subtle.ConstantTimeCompare([]byte(token), []byte(apiKey)) == 1 {
next.ServeHTTP(w, r)
return
}
}
status = http.StatusUnauthorized
body = `{"error":{"code":"unauthorized","message":"valid bearer token required"}}`
w.Header().Set("WWW-Authenticate", "Bearer")
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = io.WriteString(w, body+"\n")
})
}
75 changes: 75 additions & 0 deletions internal/api/middleware/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

package middleware

import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)

func TestBearerAuth(t *testing.T) {
const key = "synthetic-test-key"
for _, tc := range []struct {
name, key string
headers []string
status int
}{
{"disabled", "", nil, 503}, {"disabled with token", "", []string{"Bearer " + key}, 503},
{"missing", key, nil, 401}, {"empty", key, []string{""}, 401},
{"wrong scheme", key, []string{"Basic " + key}, 401}, {"wrong token", key, []string{"Bearer wrong"}, 401},
{"empty token", key, []string{"Bearer "}, 401}, {"no separator", key, []string{"Bearer" + key}, 401},
{"tab separator", key, []string{"Bearer\t" + key}, 401}, {"extra token", key, []string{"Bearer " + key + " extra"}, 401},
{"line break", key, []string{"Bearer " + key + "\r\n"}, 401},
{"duplicate", key, []string{"Bearer " + key, "Bearer " + key}, 401},
{"combined", key, []string{"Bearer " + key + ", Bearer " + key}, 401},
{"case sensitive token", key, []string{"Bearer SYNTHETIC-TEST-KEY"}, 401},
{"valid", key, []string{"Bearer " + key}, 200},
{"scheme case", key, []string{"bEaReR " + key}, 200},
{"multiple spaces", key, []string{"Bearer " + key}, 200},
} {
t.Run(tc.name, func(t *testing.T) {
calls := 0
handler := BearerAuth(tc.key, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls++
w.WriteHeader(200)
}))
// Query and form tokens alone must never grant access.
req := httptest.NewRequest("POST", "/admin?access_token="+key, strings.NewReader("access_token="+key))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, value := range tc.headers {
req.Header.Add("Authorization", value)
}
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != tc.status || (calls == 1) != (tc.status == 200) {
t.Fatalf("status=%d downstream calls=%d", w.Code, calls)
}
if w.Header().Get("Cache-Control") != "no-store" || strings.Contains(w.Body.String(), key) {
t.Fatal("cache policy missing or credential reflected")
}
if tc.status == 200 {
return
}
var body struct {
Error struct{ Code, Message string }
}
if w.Header().Get("Content-Type") != "application/json" || json.Unmarshal(w.Body.Bytes(), &body) != nil || body.Error.Message == "" {
t.Fatal("invalid JSON error contract")
}
wantCode := "service_unavailable"
if tc.status == 401 {
wantCode = "unauthorized"
if w.Header().Get("WWW-Authenticate") != "Bearer" {
t.Fatal("missing bearer challenge")
}
}
if body.Error.Code != wantCode {
t.Fatalf("error code=%s", body.Error.Code)
}
})
}
}
56 changes: 56 additions & 0 deletions internal/api/router/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

package router

import (
"net/http"
"net/http/httptest"
"testing"

"github.com/MeshCore-Beacon/beacon-server/internal/config"
)

func TestAdminAuthBoundary(t *testing.T) {
for _, key := range []string{"", "synthetic-test-key"} {
handler := New(nil, nil, nil, 5, config.CORSConfig{}, config.ServerConfig{}, config.AuthConfig{APIKey: key})
for _, path := range []string{"/api/v1/admin", "/api/v1/admin/", "/api/v1/admin/config", "/api/v1/admin//config"} {
for _, method := range []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"} {
for _, token := range []string{"", "wrong", "synthetic-test-key"} {
req := httptest.NewRequest(method, path, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
want := http.StatusServiceUnavailable
if key != "" {
want = 401
if token == key {
want = 404
}
}
if w.Code != want {
t.Fatalf("%s %s status=%d want=%d", method, path, w.Code, want)
}
}
}
}
for path, want := range map[string]int{"/api/v1/brokers": 200, "/api/v1/administrator": 404, "/swagger": 301} {
w := httptest.NewRecorder()
handler.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
if w.Code != want {
t.Fatalf("public %s status=%d want=%d", path, w.Code, want)
}
}
req := httptest.NewRequest("OPTIONS", "/api/v1/admin/config", nil)
req.Header.Set("Origin", "https://example.test")
req.Header.Set("Access-Control-Request-Method", "GET")
req.Header.Set("Access-Control-Request-Headers", "Authorization")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != 200 || w.Header().Get("Access-Control-Allow-Origin") == "" {
t.Fatal("CORS preflight blocked")
}
}
}
20 changes: 7 additions & 13 deletions internal/api/router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@

// Package router wires all HTTP routes onto the Chi router and injects
// dependencies (hub, reader, ingest workers) into the handler closures.
// All routes are mounted under /api/v1 with public and private groups
// stubbed for future auth middleware.
// REST routes are mounted under /api/v1; its admin subtree requires a bearer key.
package router

import (
Expand Down Expand Up @@ -39,9 +38,8 @@ import (
// /regions → regions subrouter
// /stats → stats subrouter
//
// 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, corsCfg config.CORSConfig, serverCfg config.ServerConfig) http.Handler {
// Admin handlers are added separately; the reserved subtree is protected now.
func New(h *hub.Hub, reader api.Reader, workers []*ingest.Worker, maxConnsPerIP int, corsCfg config.CORSConfig, serverCfg config.ServerConfig, authCfg config.AuthConfig) http.Handler {
r := chi.NewRouter()

// ── CORS ─────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -91,7 +89,7 @@ func New(h *hub.Hub, reader api.Reader, workers []*ingest.Worker, maxConnsPerIP

// ── Public REST API (v1) ─────────────────────────────────────────────────
r.Route("/api/v1", func(r chi.Router) {
// Public group — no authentication required (all of v1 is public).
// Public group — no authentication required.
r.Group(func(r chi.Router) {
r.Mount("/packets", handlers.PacketsRouter(reader))
r.Mount("/nodes", handlers.NodesRouter(reader))
Expand All @@ -107,13 +105,9 @@ func New(h *hub.Hub, reader api.Reader, workers []*ingest.Worker, maxConnsPerIP
r.Mount("/traces", handlers.TracesRouter(reader))
})

// Private group — auth middleware applied.
// Stubbed for the admin endpoints described in Future Features.
// Swap mw.NoopAuth for a real JWT/session middleware when ready.
r.Group(func(r chi.Router) {
r.Use(mw.NoopAuth)
// r.Mount("/admin", handlers.AdminRouter())
})
// Protect the entire subtree, including its root and unknown paths.
// Replace the empty router with the admin handlers when they are added.
r.Mount("/admin", mw.BearerAuth(authCfg.APIKey, chi.NewRouter()))
})

return r
Expand Down
2 changes: 1 addition & 1 deletion internal/api/router/router_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func TestWebSocketLimitUsesTrustedClientIP(t *testing.T) {

func checkWebSocketLimit(t *testing.T, cfg config.ServerConfig, secondIP string, wantStatus int) {
t.Helper()
server := httptest.NewServer(New(hub.New(), nil, nil, 1, config.CORSConfig{}, cfg))
server := httptest.NewServer(New(hub.New(), nil, nil, 1, config.CORSConfig{}, cfg, config.AuthConfig{}))
defer server.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
Expand Down
54 changes: 54 additions & 0 deletions internal/config/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

package config

import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)

func TestLoadAuth(t *testing.T) {
for _, tc := range []struct {
name, env, want string
file, set bool
}{
{"no config", "", "", false, false},
{"YAML", "", "yaml-test-key", true, false},
{"environment overrides", "env-test-key", "env-test-key", true, true},
{"empty environment disables", "", "", true, true},
{"environment without file", "env-test-key", "env-test-key", false, true},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("BEACON_API_KEY", tc.env)
if !tc.set {
if err := os.Unsetenv("BEACON_API_KEY"); err != nil {
t.Fatal(err)
}
}
path := filepath.Join(t.TempDir(), "config.yaml")
if tc.file {
if err := os.WriteFile(path, []byte("auth:\n api_key: yaml-test-key\n"), 0600); err != nil {
t.Fatal(err)
}
}
cfg, err := Load(path)
if err != nil {
t.Fatal(err)
}
if cfg.Auth.APIKey != tc.want {
t.Fatal("wrong auth configuration precedence")
}
encoded, err := json.Marshal(cfg)
if err != nil || strings.Contains(string(encoded), "test-key") {
t.Fatal("key exposed in JSON")
}
if strings.Contains(Resolve(cfg).String(), "test-key") {
t.Fatal("key exposed in startup summary")
}
})
}
}
11 changes: 11 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (

// Config is the top-level structure of the Beacon config file.
type Config struct {
Auth AuthConfig `yaml:"auth"`
Server ServerConfig `yaml:"server"`
IATAs map[string]IATAConfig `yaml:"iatas"`
Regions []RegionConfig `yaml:"regions"`
Expand All @@ -35,6 +36,12 @@ type Config struct {
Observers ObserversConfig `yaml:"observers"`
}

// AuthConfig holds the operator key for the protected admin subtree.
// The key is excluded from JSON; it must not be exposed by configuration APIs.
type AuthConfig struct {
APIKey string `yaml:"api_key" json:"-"`
}

// ServerConfig controls which direct peers may supply the client address.
type ServerConfig struct {
// TrustedProxies accepts IPv4/IPv6 CIDRs; an empty list trusts no proxy.
Expand Down Expand Up @@ -333,13 +340,17 @@ func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
cfg.Auth.APIKey = os.Getenv("BEACON_API_KEY")
return cfg, nil
}
return nil, err
}
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, err
}
if value, set := os.LookupEnv("BEACON_API_KEY"); set {
cfg.Auth.APIKey = value
}
for i, prefix := range cfg.Server.TrustedProxies {
if !prefix.IsValid() {
return nil, fmt.Errorf("server.trusted_proxies[%d] must be a valid CIDR", i)
Expand Down
Loading