Skip to content
Draft
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
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,40 @@ 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`.

`GET /api/v1/admin/config` returns selected startup settings: CORS options with
Beacon defaults applied, `auth.configured`, and `ingest.broker_count` (configured
broker workers, not connection status or a tunable processing-worker pool).
The CORS lists are the options supplied to the middleware; its normal matching
normalization still applies. The response is a startup snapshot and excludes
credential fields, broker addresses, channel material, database settings and
other configuration. Changes require a restart. Configuration writes are not
implemented; unknown admin paths return 404 and unsupported
methods on the config endpoint return 405 after authentication.
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.

Operator accounts are available at `GET/POST /api/v1/admin/accounts` and
`GET/DELETE /api/v1/admin/accounts/{id}`. POST accepts a JSON `name` field in a
body up to 4 KiB; names are trimmed, case-sensitive and limited to 128 Unicode
characters without control characters. Active names are unique. DELETE soft
deactivates the record (204); missing IDs return 404 and an already inactive
record returns 409. A deactivated name may be reused by a new account.
Lists include active and inactive records, newest first, without pagination.
These are operator-defined records; no login, session or API token is created.
Cross-origin clients must have their methods allowed in the existing CORS config.

### Environment variables (`.env`)

| Variable | Default | Description |
Expand Down
7 changes: 6 additions & 1 deletion cmd/beacon/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ var version = "dev"

// @schemes http https

// @securityDefinitions.apikey AdminKey
// @in header
// @name Authorization
// @description Enter Bearer followed by the configured operator key. Use HTTPS.

// @tag.name IATAs
// @tag.description Airport/location codes that group observers and packets
// @tag.name Regions
Expand Down Expand Up @@ -275,7 +280,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, store)

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
78 changes: 78 additions & 0 deletions db/accounts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

package db

import (
"context"
"errors"

sqlc "github.com/MeshCore-Beacon/beacon-server/db/sqlc"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)

var _ api.AccountStore = (*Store)(nil)

func (s *Store) CreateAccount(ctx context.Context, name string) (api.Account, error) {
name, err := api.NormalizeAccountName(name)
if err != nil {
return api.Account{}, err
}
row, err := s.q.CreateAccount(ctx, name)
if errors.Is(err, pgx.ErrNoRows) {
return api.Account{}, api.ErrAccountNameConflict
}
if err != nil {
return api.Account{}, err
}
return accountFromRow(row), nil
}

func (s *Store) ListAccounts(ctx context.Context) ([]api.Account, error) {
// ponytail: unpaginated operator list; add cursors if account counts grow large.
rows, err := s.q.ListAccounts(ctx)
if err != nil {
return nil, err
}
items := make([]api.Account, 0, len(rows))
for _, row := range rows {
items = append(items, accountFromRow(row))
}
return items, nil
}

func (s *Store) GetAccount(ctx context.Context, id uuid.UUID) (api.Account, error) {
row, err := s.q.GetAccount(ctx, id)
if errors.Is(err, pgx.ErrNoRows) {
return api.Account{}, api.ErrAccountNotFound
}
if err != nil {
return api.Account{}, err
}
return accountFromRow(row), nil
}

func (s *Store) DeactivateAccount(ctx context.Context, id uuid.UUID) error {
result, err := s.q.DeactivateAccount(ctx, id)
if err != nil {
return err
}
if !result.Found {
return api.ErrAccountNotFound
}
if !result.Deactivated {
return api.ErrAccountInactive
}
return nil
}

func accountFromRow(row sqlc.Account) api.Account {
account := api.Account{ID: row.ID, Name: row.Name, CreatedAt: row.CreatedAt.Time.UTC(), Active: !row.DeactivatedAt.Valid}
if row.DeactivatedAt.Valid {
when := row.DeactivatedAt.Time.UTC()
account.DeactivatedAt = &when
}
return account
}
155 changes: 155 additions & 0 deletions db/accounts_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

package db

import (
"context"
"errors"
"os"
"strings"
"testing"
"time"

"github.com/MeshCore-Beacon/beacon-server/internal/api"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)

func TestAccountsPostgres(t *testing.T) {
dsn := os.Getenv("BEACON_TEST_POSTGRES_DSN")
if dsn == "" {
t.Skip("set BEACON_TEST_POSTGRES_DSN for account regression")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
setup, err := pgx.Connect(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer setup.Close(context.Background())
schema := "account_test_" + strings.ReplaceAll(uuid.NewString(), "-", "")
quoted := pgx.Identifier{schema}.Sanitize()
if _, err := setup.Exec(ctx, "CREATE SCHEMA "+quoted); err != nil {
t.Fatal(err)
}
defer func() {
if _, err := setup.Exec(context.Background(), "DROP SCHEMA "+quoted+" CASCADE"); err != nil {
t.Error(err)
}
}()
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
t.Fatal(err)
}
cfg.ConnConfig.RuntimeParams["search_path"] = schema
cfg.MaxConns = 4
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
sql, err := migrationFiles.ReadFile("migrations/034_accounts.sql")
if err != nil {
t.Fatal(err)
}
for i := 0; i < 2; i++ {
if err := applyMigration(ctx, pool, string(sql)); err != nil {
t.Fatal(err)
}
}
store := New(pool, 0, 0)
items, err := store.ListAccounts(ctx)
if err != nil || items == nil || len(items) != 0 {
t.Fatal("empty list", err)
}
if _, err := store.CreateAccount(ctx, "\t"); !errors.Is(err, api.ErrAccountNameInvalid) {
t.Fatal("invalid name accepted", err)
}
first, err := store.CreateAccount(ctx, " Montréal 🦀 ")
if err != nil || first.Name != "Montréal 🦀" || !first.Active || first.ID == uuid.Nil || first.CreatedAt.IsZero() {
t.Fatal("create", err)
}
if _, err := store.CreateAccount(ctx, first.Name); !errors.Is(err, api.ErrAccountNameConflict) {
t.Fatal("duplicate", err)
}
if err := store.DeactivateAccount(ctx, first.ID); err != nil {
t.Fatal(err)
}
inactive, err := store.GetAccount(ctx, first.ID)
if err != nil || inactive.Active || inactive.DeactivatedAt == nil {
t.Fatal("deactivate", err)
}
if err := store.DeactivateAccount(ctx, first.ID); !errors.Is(err, api.ErrAccountInactive) {
t.Fatal("repeated deactivate", err)
}
again, _ := store.GetAccount(ctx, first.ID)
if !again.DeactivatedAt.Equal(*inactive.DeactivatedAt) {
t.Fatal("deactivation timestamp changed")
}
reused, err := store.CreateAccount(ctx, first.Name)
if err != nil || reused.ID == first.ID {
t.Fatal("name reuse", err)
}
if _, err := store.CreateAccount(ctx, "montréal 🦀"); err != nil {
t.Fatal("names must be case-sensitive", err)
}
missing := uuid.New()
if _, err := store.GetAccount(ctx, missing); !errors.Is(err, api.ErrAccountNotFound) {
t.Fatal("missing get", err)
}
if err := store.DeactivateAccount(ctx, missing); !errors.Is(err, api.ErrAccountNotFound) {
t.Fatal("missing deactivate", err)
}
items, err = store.ListAccounts(ctx)
if err != nil || len(items) != 3 {
t.Fatal("list lifecycle", err)
}
for i := 1; i < len(items); i++ {
if items[i-1].CreatedAt.Before(items[i].CreatedAt) || (items[i-1].CreatedAt.Equal(items[i].CreatedAt) && items[i-1].ID.String() < items[i].ID.String()) {
t.Fatal("unstable list order")
}
}
start := make(chan struct{})
results := make(chan error, 8)
for i := 0; i < 8; i++ {
go func() { <-start; _, err := store.CreateAccount(ctx, "concurrent"); results <- err }()
}
close(start)
created := 0
for i := 0; i < 8; i++ {
err := <-results
if err == nil {
created++
} else if !errors.Is(err, api.ErrAccountNameConflict) {
t.Fatal("concurrent create", err)
}
}
if created != 1 {
t.Fatalf("created %d accounts with the same name", created)
}
for round := 0; round < 8; round++ {
account, err := store.CreateAccount(ctx, "race-deactivate")
if err != nil {
t.Fatal(err)
}
gate := make(chan struct{})
for i := 0; i < 2; i++ {
go func() { <-gate; results <- store.DeactivateAccount(ctx, account.ID) }()
}
close(gate)
changed := 0
for i := 0; i < 2; i++ {
err := <-results
if err == nil {
changed++
} else if !errors.Is(err, api.ErrAccountInactive) {
t.Fatal("concurrent deactivate", err)
}
}
if changed != 1 {
t.Fatalf("deactivated %d times", changed)
}
}
}
27 changes: 27 additions & 0 deletions db/accounts_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

package db

import (
"testing"
"time"

sqlc "github.com/MeshCore-Beacon/beacon-server/db/sqlc"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)

func TestAccountFromRow(t *testing.T) {
when := time.Date(2026, 1, 1, 12, 0, 0, 0, time.FixedZone("test", 3600))
row := sqlc.Account{ID: uuid.New(), Name: "test", CreatedAt: pgtype.Timestamptz{Time: when, Valid: true}}
active := accountFromRow(row)
if active.ID != row.ID || active.Name != row.Name || !active.Active || active.DeactivatedAt != nil || !active.CreatedAt.Equal(when) || active.CreatedAt.Location() != time.UTC {
t.Fatal("active account mapping")
}
row.DeactivatedAt = pgtype.Timestamptz{Time: when.Add(time.Hour), Valid: true}
inactive := accountFromRow(row)
if inactive.Active || inactive.DeactivatedAt == nil || !inactive.DeactivatedAt.Equal(row.DeactivatedAt.Time) {
t.Fatal("inactive account mapping")
}
}
12 changes: 12 additions & 0 deletions db/migrations/034_accounts.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
-- Copyright 2026 Beacon Contributors
-- SPDX-License-Identifier: AGPL-3.0-or-later

CREATE TABLE IF NOT EXISTS accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL CHECK (char_length(name) BETWEEN 1 AND 128 AND name = btrim(name)),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deactivated_at TIMESTAMPTZ
);

CREATE UNIQUE INDEX IF NOT EXISTS accounts_name_uidx
ON accounts (name) WHERE deactivated_at IS NULL;
25 changes: 25 additions & 0 deletions db/queries/queries.sql
Original file line number Diff line number Diff line change
Expand Up @@ -1453,3 +1453,28 @@ OR (
AND iata = nn.iata
)
) > 1;

-- name: CreateAccount :one
INSERT INTO accounts (name) VALUES (sqlc.arg(name))
ON CONFLICT (name) WHERE deactivated_at IS NULL DO NOTHING
RETURNING id, name, created_at, deactivated_at;

-- name: ListAccounts :many
SELECT id, name, created_at, deactivated_at FROM accounts
ORDER BY created_at DESC, id DESC;

-- name: GetAccount :one
SELECT id, name, created_at, deactivated_at FROM accounts WHERE id = $1;

-- name: DeactivateAccount :one
-- Lock the current row before deciding the outcome, including when another
-- deactivation commits while this statement is waiting for its row lock.
WITH target AS MATERIALIZED (
SELECT a.id, a.deactivated_at FROM accounts a WHERE a.id = $1 FOR UPDATE
), changed AS (
UPDATE accounts a SET deactivated_at = NOW()
FROM target t WHERE a.id = t.id AND t.deactivated_at IS NULL
RETURNING a.id
)
SELECT EXISTS(SELECT 1 FROM target) AS found,
EXISTS(SELECT 1 FROM changed) AS deactivated;
Loading
Loading