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
2 changes: 2 additions & 0 deletions RELEASE_NOTES_2026.06.2.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

This release strengthens internal verification routines on the Arc Enterprise activation path and tightens authorization on database-management API endpoints. The changes are internal to Arc; existing license keys, activation flows, tokens, and `arc.toml` configurations continue to work unchanged.

**`GET /api/v1/logs` now requires an admin token.** The application-logs endpoint was registered among the public base routes (health, readiness, metrics), which placed it ahead of the global authentication middleware in the router and left it reachable without a token when authentication was enabled. It now requires an admin token, matching every other management endpoint. The exposure was limited to operational log metadata — component names, source-file locations, log messages, and request outcomes; Arc does not write query text, database or measurement names, or credentials to this buffer, and the first-run admin-token banner is printed to stderr rather than through the logged buffer, so none of those were exposed. The JSON metrics endpoints under `/api/v1/metrics` remain reachable without a token by design — the same as the Prometheus `/metrics` endpoint, they expose only aggregate operational counters and runtime statistics — and are now explicitly marked public so their status is a deliberate, audited decision rather than a side effect of route ordering. Deployments running without authentication are unchanged. Thanks to **@sondt99** for reporting this.

**Clustering now requires a shared secret and fails closed if one isn't set.** Arc Enterprise clustering authenticates inter-node messages (join, heartbeat, leave, replication) with an HMAC derived from `cluster.shared_secret`. A node configured with `cluster.enabled = true` but no shared secret now refuses to start, with a clear message to set `ARC_CLUSTER_SHARED_SECRET` — previously such a node would start and skip inter-node authentication, which is unsafe on any network where the coordinator port is reachable by untrusted hosts. This makes the secret mandatory for clustering, the same way it was already mandatory for peer replication. As additional hardening, cluster heartbeat messages are now HMAC-authenticated alongside the join and leave messages that already were. The shared secret has been recommended in the clustering documentation since the feature shipped, so correctly-configured clusters continue to run unchanged; only a cluster intentionally run without a secret needs to set one before upgrading. Clustering is an Enterprise feature and is off by default, so single-node and OSS deployments are unaffected.

**Rolling-upgrade note:** because upgraded nodes now require an HMAC on heartbeats, a node running this version will treat heartbeats from a not-yet-upgraded node (same shared secret, older build that doesn't sign heartbeats) as unauthenticated and mark it unhealthy until it is upgraded. This does not affect Raft membership or cause data loss — it only suppresses read routing to the lagging node — and it clears automatically once the rollout completes. Upgrade all cluster nodes in one window and expect transient "node unhealthy" log lines during the roll.
Expand Down
24 changes: 24 additions & 0 deletions cmd/arc/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1625,8 +1625,26 @@ func main() {
// here: pprof is no longer mounted on the public Fiber app
// (internal/api/server.go). The opt-in localhost pprof listener
// runs on a separate port; see startDebugPprofIfEnabled.
//
// The JSON metrics endpoints (/api/v1/metrics and its sub-paths) are
// deliberately public, equivalent to the existing Prometheus /metrics
// surface: aggregate operational counters plus Go runtime/GC stats and
// host-arch fingerprinting (go_version/os/arch). They expose no queries,
// database/measurement names, file paths, or credentials, and monitoring
// dashboards poll them without a token. Whitelist them explicitly so
// their public status is an auditable decision, not an accident of
// registration order (they were previously reachable only because
// RegisterRoutes runs before this middleware; see GHSA-m3qr-fvp4-78xj).
// /api/v1/logs, by contrast, is NOT public — it is registered with admin
// auth below.
middlewareConfig.PublicPrefixes = append(middlewareConfig.PublicPrefixes, "/api/v1/metrics")
server.GetApp().Use(auth.NewMiddleware(middlewareConfig))

// Register the admin-authenticated logs route AFTER the auth
// middleware so it sits later in the Fiber stack and is actually
// gated (GHSA-m3qr-fvp4-78xj).
server.RegisterLogsRoute(authManager)

// Initialize RBAC Manager (Enterprise feature)
rbacManager = auth.NewRBACManager(&auth.RBACManagerConfig{
DB: authManager.GetDB(),
Expand Down Expand Up @@ -1797,6 +1815,12 @@ func main() {
}
}
}
} else {
// Authentication disabled: register the logs route unguarded so the
// endpoint still works for no-auth deployments. (When auth is enabled
// it is registered with admin auth inside the block above, after the
// middleware — GHSA-m3qr-fvp4-78xj.)
server.RegisterLogsRoute(nil)
}

// Initialize Audit Logging (Enterprise feature - requires valid license)
Expand Down
112 changes: 112 additions & 0 deletions internal/api/logs_auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package api

import (
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"

"github.com/basekick-labs/arc/internal/auth"
"github.com/gofiber/fiber/v2"
"github.com/rs/zerolog"
)

// setupLogsAuthServer builds a Server with a real auth manager, mounts the
// global auth middleware exactly as cmd/arc/main.go does, then registers the
// logs route via RegisterLogsRoute AFTER the middleware — mirroring production
// ordering. Returns the fiber app and auth manager.
func setupLogsAuthServer(t *testing.T) (*fiber.App, *auth.AuthManager, func()) {
t.Helper()
tmpDir, err := os.MkdirTemp("", "logs-auth-test-*")
if err != nil {
t.Fatalf("mkdtemp: %v", err)
}
logger := zerolog.New(os.Stderr).Level(zerolog.Disabled)
am, err := auth.NewAuthManager(filepath.Join(tmpDir, "auth.db"), 1*time.Second, 100, logger)
if err != nil {
os.RemoveAll(tmpDir)
t.Fatalf("NewAuthManager: %v", err)
}

srv := &Server{app: fiber.New(), logger: logger}
// Mirror main.go: global auth middleware first, then the admin-gated
// logs route registered after it. /api/v1/metrics is whitelisted as a
// public prefix (parity with the Prometheus /metrics endpoint).
mwCfg := auth.DefaultMiddlewareConfig()
mwCfg.AuthManager = am
mwCfg.PublicPrefixes = append(mwCfg.PublicPrefixes, "/api/v1/metrics")
srv.app.Use(auth.NewMiddleware(mwCfg))
srv.RegisterLogsRoute(am)
// A representative public route + a public metrics route to assert they
// remain reachable. (/health is in DefaultMiddlewareConfig.PublicRoutes.)
srv.app.Get("/health", func(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) })
srv.app.Get("/api/v1/metrics", func(c *fiber.Ctx) error { return c.JSON(fiber.Map{"ok": true}) })

cleanup := func() { am.Close(); os.RemoveAll(tmpDir) }
return srv.app, am, cleanup
}

// TestLogsEndpoint_RequiresAdmin is the regression test for
// GHSA-m3qr-fvp4-78xj: GET /api/v1/logs was reachable unauthenticated because
// it was registered among the public base routes before the global auth
// middleware. It must now require an admin token.
func TestLogsEndpoint_RequiresAdmin(t *testing.T) {
app, am, cleanup := setupLogsAuthServer(t)
defer cleanup()

adminToken := mustCreateToken(t, am, "admin", "read,write,delete,admin")
readToken := mustCreateToken(t, am, "read", "read")

get := func(path, token string) int {
req := httptest.NewRequest("GET", path, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test(%s): %v", path, err)
}
defer resp.Body.Close()
return resp.StatusCode
}

tests := []struct {
name string
path string
token string
want int
}{
{"logs no token rejected", "/api/v1/logs?limit=5", "", fiber.StatusUnauthorized},
{"logs read token forbidden", "/api/v1/logs?limit=5", readToken, fiber.StatusForbidden},
{"logs admin token ok", "/api/v1/logs?limit=5", adminToken, fiber.StatusOK},
{"health stays public", "/health", "", fiber.StatusOK},
{"metrics stays public", "/api/v1/metrics", "", fiber.StatusOK},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := get(tt.path, tt.token); got != tt.want {
t.Errorf("GET %s (hasToken=%t) = %d, want %d", tt.path, tt.token != "", got, tt.want)
}
})
}
}

// TestLogsEndpoint_AuthDisabledStaysOpen pins that with no auth manager
// (authentication disabled), the logs route is still registered and reachable
// — no-auth deployments must not lose the endpoint.
func TestLogsEndpoint_AuthDisabledStaysOpen(t *testing.T) {
logger := zerolog.New(os.Stderr).Level(zerolog.Disabled)
srv := &Server{app: fiber.New(), logger: logger}
srv.RegisterLogsRoute(nil) // auth disabled

req := httptest.NewRequest("GET", "/api/v1/logs?limit=3", nil)
resp, err := srv.app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != fiber.StatusOK {
t.Errorf("auth-disabled logs = %d, want 200", resp.StatusCode)
}
}
19 changes: 17 additions & 2 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"syscall"
"time"

"github.com/basekick-labs/arc/internal/auth"
"github.com/basekick-labs/arc/internal/logger"
"github.com/basekick-labs/arc/internal/metrics"
"github.com/gofiber/fiber/v2"
Expand Down Expand Up @@ -179,13 +180,27 @@ func (s *Server) RegisterRoutes() {
s.app.Get("/api/v1/metrics/endpoints", s.endpointMetricsHandler)
s.app.Get("/api/v1/metrics/timeseries/:type", s.timeseriesMetricsHandler)

// Application logs endpoint
s.app.Get("/api/v1/logs", s.logsHandler)
// NOTE: /api/v1/logs is intentionally NOT registered here. It exposes
// buffered application logs and must be admin-authenticated. Registering
// it among these public base routes (before the global auth middleware is
// installed) left it reachable unauthenticated, because Fiber matches
// routes in registration order and returns before reaching a later
// app.Use middleware (GHSA-m3qr-fvp4-78xj). It is now registered via
// RegisterLogsRoute() AFTER the auth middleware, from cmd/arc/main.go.

// API v1 routes will be added here
// MessagePack endpoint will be registered separately
}

// RegisterLogsRoute registers the admin-authenticated application logs
// endpoint. It must be called AFTER the global auth middleware is installed so
// the route sits later in the Fiber stack than the middleware. withAdminAuth
// returns the admin guard when authManager is non-nil, or a passthrough when it
// is nil (authentication disabled), preserving no-auth deployment behaviour.
func (s *Server) RegisterLogsRoute(authManager *auth.AuthManager) {
s.app.Get("/api/v1/logs", withAdminAuth(authManager), s.logsHandler)
}
Comment thread
xe-nvdk marked this conversation as resolved.

// healthHandler returns server health status
func (s *Server) healthHandler(c *fiber.Ctx) error {
uptime := time.Since(startTime)
Expand Down