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
124 changes: 123 additions & 1 deletion dashboard/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,14 @@ type Callbacks struct {
// implicitly allowed (default-Closed semantics, same as a missing
// breakers.json).
BreakerAllow func(name string) (allowed bool, reason string)
// VerifyRequest verifies an external request-signature envelope
// (reqsig canonical form) plus its base64 signature and returns the
// JSON-serializable verification response (server.VerifyResponse).
// nil disables POST /api/v1/verify.
VerifyRequest func(canonical, sigB64 string) interface{}
// VerifyKeys returns the verdict-issuer public keys published on
// GET /api/v1/verify/keys. Each entry carries kid, algo, public_key.
VerifyKeys func() []map[string]string
// BreakerList returns the live snapshot for /api/breakers — one
// entry per known name with state, reason, counters, last-denied
// timestamp. Required for the read endpoint; nil disables it.
Expand Down Expand Up @@ -307,6 +315,12 @@ type Handler struct {
// Per-IP rate limiter for public badge endpoints.
badgeLimiter *ipRateLimiter

// Per-IP rate limiters for the public verification endpoints.
// Deliberately separate buckets from badgeLimiter (and from each
// other) so verify traffic can't starve badge fetches or vice versa.
verifyLimiter *ipRateLimiter
verifyKeysLimiter *ipRateLimiter

// Probe state — per-probe health history ring.
probeMu sync.Mutex
probeStates map[string]*ProbeState
Expand All @@ -321,7 +335,12 @@ type Handler struct {

// NewHandler creates a ready-to-use dashboard Handler backed by cb.
func NewHandler(cb Callbacks) *Handler {
return &Handler{cb: cb, badgeLimiter: newIPRateLimiter()}
return &Handler{
cb: cb,
badgeLimiter: newIPRateLimiter(),
verifyLimiter: newIPRateLimiter(),
verifyKeysLimiter: newIPRateLimiter(),
}
}

// SetWhitelistCallbacks wires the GET/PUT /api/admin/whitelist endpoints
Expand Down Expand Up @@ -781,6 +800,104 @@ func localhostOnly(next http.HandlerFunc) http.HandlerFunc {
}
}

// --------------------------------------------------------------------------
// External request verification — /api/v1/verify
// --------------------------------------------------------------------------

// maxVerifyBodyBytes caps the POST /api/v1/verify request body. A canonical
// envelope plus base64 signature fits comfortably under 1 KB; 8 KB leaves
// headroom without letting anonymous callers stream junk.
const maxVerifyBodyBytes = 8 << 10

// registerVerifyRoutes registers the public verification endpoints on mux.
// Called from Serve; split out so tests can mount the routes on a bare mux
// without spinning up the full Serve lifecycle.
func (h *Handler) registerVerifyRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/verify", h.verifyLimiter.middleware(60, time.Minute, h.handleVerify))
mux.HandleFunc("/api/v1/verify/keys", h.verifyKeysLimiter.middleware(120, time.Minute, h.handleVerifyKeys))
}

// verifyBreakerDenied writes the 503 breaker payload (same shape as
// /api/public-stats) and reports whether the request was denied. Both
// verification endpoints share the single "dashboard.verify" switch.
func (h *Handler) verifyBreakerDenied(w http.ResponseWriter) bool {
if h.cb.BreakerAllow == nil {
return false
}
ok, reason := h.cb.BreakerAllow("dashboard.verify")
if ok {
return false
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
body := map[string]string{"status": "unavailable"}
if reason != "" {
body["reason"] = reason
}
_ = json.NewEncoder(w).Encode(body)
return true
}

// handleVerify serves POST /api/v1/verify — public (no admin token),
// breaker-gated, per-IP rate-limited. Body: {"envelope":"...","signature":"..."}.
// The response is the server's VerifyResponse; failures inside verification
// still return 200 with valid:false (uniform shape, no existence oracle).
func (h *Handler) handleVerify(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if h.verifyBreakerDenied(w) {
return
}
if h.cb.VerifyRequest == nil {
http.Error(w, "verification not available", http.StatusServiceUnavailable)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxVerifyBodyBytes)
defer r.Body.Close()
var req struct {
Envelope string `json:"envelope"`
Signature string `json:"signature"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
// MaxBytesReader turns an oversized body into a read error, so
// malformed JSON and oversized bodies both land here.
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if req.Envelope == "" || req.Signature == "" {
http.Error(w, "envelope and signature are required", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
_ = json.NewEncoder(w).Encode(h.cb.VerifyRequest(req.Envelope, req.Signature))
}

// handleVerifyKeys serves GET /api/v1/verify/keys — the verdict-issuer public
// keys, so consumers can pin them and check verdicts offline.
func (h *Handler) handleVerifyKeys(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", http.MethodGet)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if h.verifyBreakerDenied(w) {
return
}
keys := []map[string]string{}
if h.cb.VerifyKeys != nil {
if k := h.cb.VerifyKeys(); k != nil {
keys = k
}
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
_ = json.NewEncoder(w).Encode(map[string]interface{}{"keys": keys})
}

// --------------------------------------------------------------------------
// Serve
// --------------------------------------------------------------------------
Expand All @@ -799,6 +916,11 @@ func (h *Handler) Serve(addr string) error {
func (h *Handler) buildMux() *http.ServeMux {
mux := http.NewServeMux()

// /api/v1/verify + /api/v1/verify/keys — public external
// request-signature verification (no admin token, breaker-gated,
// per-IP rate-limited).
h.registerVerifyRoutes(mux)

mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
Expand Down
215 changes: 215 additions & 0 deletions dashboard/zz_verify_http_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
// SPDX-License-Identifier: AGPL-3.0-or-later

package dashboard

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

// newVerifyMux mounts only the verification routes on a bare mux, mirroring
// how tests elsewhere in this package exercise single endpoints without the
// full Serve lifecycle. registerVerifyRoutes is the SAME registration Serve
// uses, so there is no shim to drift.
func newVerifyMux(cb Callbacks) (*Handler, *http.ServeMux) {
h := NewHandler(cb)
mux := http.NewServeMux()
h.registerVerifyRoutes(mux)
return h, mux
}

func verifyCallbacks(t *testing.T) Callbacks {
t.Helper()
cb := minimalCallbacks()
cb.VerifyRequest = func(canonical, sigB64 string) interface{} {
return map[string]interface{}{
"valid": true,
"envelope": canonical,
"signature": sigB64,
}
}
cb.VerifyKeys = func() []map[string]string {
return []map[string]string{{
"kid": "vfy-v1",
"algo": "ed25519",
"public_key": "AAAAC3NzaC1lZDI1NTE5AAAA",
}}
}
return cb
}

func postVerify(mux *http.ServeMux, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/api/v1/verify", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec
}

// TestVerifyHTTPHappyPath: a well-formed POST reaches the callback and the
// callback's response is returned verbatim as JSON.
func TestVerifyHTTPHappyPath(t *testing.T) {
t.Parallel()
_, mux := newVerifyMux(verifyCallbacks(t))

rec := postVerify(mux, `{"envelope":"pilot-req-v1|deadbeef","signature":"c2ln"}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Fatalf("content-type = %q", ct)
}
var payload map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if payload["valid"] != true {
t.Fatalf("valid = %v, want true", payload["valid"])
}
if payload["envelope"] != "pilot-req-v1|deadbeef" || payload["signature"] != "c2ln" {
t.Fatalf("callback did not receive envelope/signature: %v", payload)
}
}

// TestVerifyHTTPMethodNotAllowed: only POST is accepted on /api/v1/verify.
func TestVerifyHTTPMethodNotAllowed(t *testing.T) {
t.Parallel()
_, mux := newVerifyMux(verifyCallbacks(t))

for _, method := range []string{http.MethodGet, http.MethodPut, http.MethodDelete} {
req := httptest.NewRequest(method, "/api/v1/verify", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("%s: status = %d, want 405", method, rec.Code)
}
}
}

// TestVerifyHTTPMalformedBody: junk JSON and missing fields both 400.
func TestVerifyHTTPMalformedBody(t *testing.T) {
t.Parallel()
_, mux := newVerifyMux(verifyCallbacks(t))

for _, body := range []string{"{not json", "{}", `{"envelope":"only"}`, `{"signature":"only"}`} {
rec := postVerify(mux, body)
if rec.Code != http.StatusBadRequest {
t.Fatalf("body %q: status = %d, want 400", body, rec.Code)
}
}
}

// TestVerifyHTTPOversizedBody: bodies beyond the 8KB cap are rejected.
func TestVerifyHTTPOversizedBody(t *testing.T) {
t.Parallel()
_, mux := newVerifyMux(verifyCallbacks(t))

big := `{"envelope":"` + strings.Repeat("a", maxVerifyBodyBytes+1) + `","signature":"c2ln"}`
rec := postVerify(mux, big)
if rec.Code != http.StatusBadRequest && rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("oversized body: status = %d, want 400 or 413", rec.Code)
}
}

// TestVerifyHTTPBreakerOpen: an open dashboard.verify breaker turns both
// endpoints into 503s with the standard unavailable payload.
func TestVerifyHTTPBreakerOpen(t *testing.T) {
t.Parallel()
cb := verifyCallbacks(t)
cb.BreakerAllow = func(name string) (bool, string) {
if name == "dashboard.verify" {
return false, "maintenance"
}
return true, ""
}
_, mux := newVerifyMux(cb)

rec := postVerify(mux, `{"envelope":"e","signature":"s"}`)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("verify with open breaker: status = %d, want 503", rec.Code)
}
var payload map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("unmarshal 503 body: %v", err)
}
if payload["status"] != "unavailable" || payload["reason"] != "maintenance" {
t.Fatalf("503 payload = %v", payload)
}

req := httptest.NewRequest(http.MethodGet, "/api/v1/verify/keys", nil)
rec = httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("keys with open breaker: status = %d, want 503", rec.Code)
}
}

// TestVerifyHTTPRateLimit: the 61st request within a minute from one IP is
// rejected with 429; the verify limiter is independent of badgeLimiter.
func TestVerifyHTTPRateLimit(t *testing.T) {
t.Parallel()
_, mux := newVerifyMux(verifyCallbacks(t))

body := `{"envelope":"e","signature":"s"}`
for i := 0; i < 60; i++ {
rec := postVerify(mux, body)
if rec.Code != http.StatusOK {
t.Fatalf("request %d: status = %d, want 200", i+1, rec.Code)
}
}
rec := postVerify(mux, body)
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("request 61: status = %d, want 429", rec.Code)
}
}

// TestVerifyHTTPKeys: GET returns the issuer key list; POST is a 405.
func TestVerifyHTTPKeys(t *testing.T) {
t.Parallel()
_, mux := newVerifyMux(verifyCallbacks(t))

req := httptest.NewRequest(http.MethodGet, "/api/v1/verify/keys", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var payload struct {
Keys []map[string]string `json:"keys"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(payload.Keys) != 1 {
t.Fatalf("keys length = %d, want 1", len(payload.Keys))
}
k := payload.Keys[0]
if k["kid"] != "vfy-v1" || k["algo"] != "ed25519" || k["public_key"] == "" {
t.Fatalf("key entry = %v", k)
}

req = httptest.NewRequest(http.MethodPost, "/api/v1/verify/keys", bytes.NewReader(nil))
rec = httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("POST keys: status = %d, want 405", rec.Code)
}
}

// TestVerifyHTTPNoCallback: with no VerifyRequest wired the endpoint reports
// unavailability instead of panicking.
func TestVerifyHTTPNoCallback(t *testing.T) {
t.Parallel()
cb := verifyCallbacks(t)
cb.VerifyRequest = nil
_, mux := newVerifyMux(cb)

rec := postVerify(mux, `{"envelope":"e","signature":"s"}`)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", rec.Code)
}
}
8 changes: 8 additions & 0 deletions directory/directory.go
Original file line number Diff line number Diff line change
Expand Up @@ -1040,6 +1040,14 @@ func (st *Store) HandleLookup(msg map[string]interface{}) (map[string]interface{
"public_key": crypto.EncodePublicKey(node.PublicKey),
"public": node.Public,
}
// Additive enrichment for external verifiers (JSON path only — the
// binary lookup wire format is untouched).
if ls := node.GetLastSeen(); !ls.IsZero() {
resp["last_seen_unix"] = ls.Unix()
} else {
resp["last_seen_unix"] = int64(0)
}
resp["key_generation"] = node.KeyMeta.RotateCount
if node.Hostname != "" {
resp["hostname"] = node.Hostname
}
Expand Down
Loading
Loading