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
53 changes: 48 additions & 5 deletions cmd/agentledger/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"context"
"crypto/tls"
"database/sql"
"fmt"
"log/slog"
Expand Down Expand Up @@ -110,6 +111,7 @@ func runServe(configPath string) error {
})
}
budgetMgr = budget.NewManager(store, budgetCfg, logger)
defer budgetMgr.Close()
if budgetMgr.Enabled() {
logger.Info("budget enforcement enabled")
}
Expand Down Expand Up @@ -269,6 +271,7 @@ func runServe(configPath string) error {
})
}
limiter = ratelimit.New(rlCfg)
defer limiter.Close()
if limiter.Enabled() {
logger.Info("rate limiting enabled",
"default_rpm", rlCfg.Default.RequestsPerMinute,
Expand Down Expand Up @@ -323,29 +326,38 @@ func runServe(configPath string) error {

// Admin API (optional).
if adminStore != nil && cfg.Admin.Token != "" {
adminHandler := admin.NewHandler(adminStore, store, budgetMgr, cfg.Admin.Token, blocklist)
adminHandler := admin.NewHandler(adminStore, store, budgetMgr, cfg.Admin.Token, blocklist, logger)
adminHandler.RegisterRoutes(mux)
logger.Info("admin API enabled")
}

if cfg.Dashboard.Enabled {
dashHandler := dashboard.NewHandler(store, tracker)
dashHandler := dashboard.NewHandler(store, tracker, logger)
dashHandler.RegisterRoutes(mux)
mux.Handle("/", dashboard.StaticHandler())
logger.Info("dashboard enabled")
}

// Apply CORS middleware.
handler := corsMiddleware(cfg.CORS.AllowOrigins, mux)

srv := &http.Server{
Addr: cfg.Listen,
Handler: mux,
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
}

// Graceful shutdown
errCh := make(chan error, 1)
go func() {
logger.Info("proxy listening", "addr", cfg.Listen)
errCh <- srv.ListenAndServe()
if cfg.TLS.CertFile != "" && cfg.TLS.KeyFile != "" {
srv.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS12}
logger.Info("starting HTTPS server", "listen", cfg.Listen)
errCh <- srv.ListenAndServeTLS(cfg.TLS.CertFile, cfg.TLS.KeyFile)
} else {
logger.Info("starting HTTP server", "listen", cfg.Listen)
errCh <- srv.ListenAndServe()
}
}()

quit := make(chan os.Signal, 1)
Expand All @@ -372,6 +384,37 @@ func runServe(configPath string) error {
return nil
}

func corsMiddleware(origins []string, next http.Handler) http.Handler {
if len(origins) == 0 {
return next
}

allowed := make(map[string]bool, len(origins))
for _, o := range origins {
allowed[o] = true
}

return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" && allowed[origin] {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
}

if r.Method == http.MethodOptions {
if origin != "" && allowed[origin] {
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Requested-With")
w.Header().Set("Access-Control-Max-Age", "86400")
}
w.WriteHeader(http.StatusNoContent)
return
}

next.ServeHTTP(w, r)
})
}

func newLogger(cfg config.LogConfig) *slog.Logger {
var level slog.Level
switch cfg.Level {
Expand Down
11 changes: 11 additions & 0 deletions configs/agentledger.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,17 @@ recording:
# enabled: true
# token: "your-secret-admin-token" # Bearer token for auth

# CORS (optional — omit to use same-origin only)
# cors:
# allow_origins:
# - "https://dashboard.example.com"
# - "https://admin.example.com"

# TLS (optional — omit for plain HTTP)
# tls:
# cert_file: "/path/to/cert.pem"
# key_file: "/path/to/key.pem"

# MCP (Model Context Protocol) tool call metering (optional — omit to disable)
# mcp:
# enabled: true # enable HTTP proxy for MCP servers
Expand Down
1 change: 1 addition & 0 deletions configs/demo.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ agent:

admin:
enabled: true
# WARNING: Demo token only. Generate a real one: openssl rand -hex 32
token: "demo-admin-token"

budgets:
Expand Down
12 changes: 12 additions & 0 deletions docs/assets/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
32 changes: 31 additions & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,37 @@ hide:

<div class="al-hero" markdown>

<div class="al-logo-mark">AL</div>
<div class="al-logo-mark">
<svg viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg" width="64" height="64">
<defs><clipPath id="al-hero-clip"><circle cx="40" cy="40" r="12"/></clipPath></defs>
<path d="M 52,40 Q 61,45 70,40" stroke="#3fb950" stroke-width="1.8" stroke-linecap="round"/>
<path d="M 48.5,48.5 Q 51.3,58.3 61.2,61.2" stroke="#388bfd" stroke-width="1.8" stroke-linecap="round" opacity="0.8"/>
<path d="M 40,52 Q 35,61 40,70" stroke="#388bfd" stroke-width="1.8" stroke-linecap="round" opacity="0.8"/>
<path d="M 31.5,48.5 Q 20.6,50.3 18.8,61.2" stroke="#388bfd" stroke-width="1.8" stroke-linecap="round" opacity="0.6"/>
<path d="M 28,40 Q 19,35 10,40" stroke="#388bfd" stroke-width="1.8" stroke-linecap="round" opacity="0.6"/>
<path d="M 31.5,31.5 Q 29.6,20.6 18.8,18.8" stroke="#388bfd" stroke-width="1.8" stroke-linecap="round" opacity="0.7"/>
<path d="M 40,28 Q 45,19 40,10" stroke="#388bfd" stroke-width="1.8" stroke-linecap="round" opacity="0.7"/>
<path d="M 48.5,31.5 Q 59.3,29.6 61.2,18.8" stroke="#388bfd" stroke-width="1.8" stroke-linecap="round" opacity="0.9"/>
<circle cx="70" cy="40" r="2.8" fill="#3fb950"/>
<circle cx="61.2" cy="61.2" r="2.5" fill="#388bfd" opacity="0.7"/>
<circle cx="40" cy="70" r="2.5" fill="#388bfd" opacity="0.7"/>
<circle cx="18.8" cy="61.2" r="2.5" fill="#388bfd" opacity="0.55"/>
<circle cx="10" cy="40" r="2.5" fill="#388bfd" opacity="0.55"/>
<circle cx="18.8" cy="18.8" r="2.5" fill="#388bfd" opacity="0.65"/>
<circle cx="40" cy="10" r="2.5" fill="#388bfd" opacity="0.65"/>
<circle cx="61.2" cy="18.8" r="2.5" fill="#388bfd" opacity="0.85"/>
<circle cx="40" cy="40" r="12.5" fill="#0a0e14"/>
<g clip-path="url(#al-hero-clip)">
<line x1="28" y1="48" x2="52" y2="48" stroke="#388bfd" stroke-width="0.8" opacity="0.45"/>
<rect x="32" y="38" width="4" height="10" rx="0.8" fill="#388bfd" opacity="0.6"/>
<rect x="38" y="42" width="4" height="6" rx="0.8" fill="#3fb950"/>
<rect x="44" y="34" width="4" height="14" rx="0.8" fill="#388bfd" opacity="0.92"/>
<line x1="28" y1="40" x2="52" y2="40" stroke="#e6edf3" stroke-width="0.9" stroke-dasharray="2 1.5" opacity="0.55"/>
<line x1="30.5" y1="37.5" x2="30.5" y2="42.5" stroke="#e6edf3" stroke-width="1" stroke-linecap="round" opacity="0.55"/>
</g>
<circle cx="40" cy="40" r="12.5" fill="none" stroke="#388bfd" stroke-width="2"/>
</svg>
</div>

# AgentLedger

Expand Down
9 changes: 1 addition & 8 deletions docs/stylesheets/extra.css
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,8 @@

.al-logo-mark {
display: inline-block;
font-family: "JetBrains Mono", monospace;
font-size: 1.4rem;
font-weight: 700;
color: #0a0e14;
background: #3fb950;
border-radius: 8px;
padding: 0.4rem 0.8rem;
margin-bottom: 1.5rem;
letter-spacing: 0.05em;
line-height: 0;
}

.al-hero h1 {
Expand Down
81 changes: 77 additions & 4 deletions internal/admin/admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"context"
"database/sql"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
Expand Down Expand Up @@ -34,6 +36,10 @@ func setupTestDB(t *testing.T) *sql.DB {
return db
}

func testLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}

func TestStore_GetSetDelete(t *testing.T) {
db := setupTestDB(t)
s := admin.NewStore(db)
Expand Down Expand Up @@ -127,7 +133,7 @@ func TestStore_ListAll(t *testing.T) {
func TestHandler_RequiresAuth(t *testing.T) {
db := setupTestDB(t)
store := admin.NewStore(db)
handler := admin.NewHandler(store, nil, nil, "secret-token", nil)
handler := admin.NewHandler(store, nil, nil, "secret-token", nil, testLogger())

mux := http.NewServeMux()
handler.RegisterRoutes(mux)
Expand Down Expand Up @@ -162,7 +168,7 @@ func TestHandler_RequiresAuth(t *testing.T) {
func TestHandler_CRUDRules(t *testing.T) {
db := setupTestDB(t)
store := admin.NewStore(db)
handler := admin.NewHandler(store, nil, nil, "token", nil)
handler := admin.NewHandler(store, nil, nil, "token", nil, testLogger())

mux := http.NewServeMux()
handler.RegisterRoutes(mux)
Expand All @@ -185,6 +191,7 @@ func TestHandler_CRUDRules(t *testing.T) {
body, _ := json.Marshal(rule)
req = httptest.NewRequest("POST", "/api/admin/budgets/rules", bytes.NewReader(body))
auth(req)
req.Header.Set("X-Requested-With", "XMLHttpRequest")
rec = httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
Expand All @@ -205,6 +212,7 @@ func TestHandler_CRUDRules(t *testing.T) {
// Delete.
req = httptest.NewRequest("DELETE", "/api/admin/budgets/rules?pattern=sk-prod-*", nil)
auth(req)
req.Header.Set("X-Requested-With", "XMLHttpRequest")
rec = httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
Expand All @@ -223,27 +231,92 @@ func TestHandler_CRUDRules(t *testing.T) {
}
}

func TestHandler_CSRFProtection(t *testing.T) {
db := setupTestDB(t)
store := admin.NewStore(db)
handler := admin.NewHandler(store, nil, nil, "token", nil, testLogger())

mux := http.NewServeMux()
handler.RegisterRoutes(mux)

// POST without X-Requested-With should be rejected.
rule := budget.Rule{APIKeyPattern: "sk-*", DailyLimitUSD: 10.0, Action: "block"}
body, _ := json.Marshal(rule)
req := httptest.NewRequest("POST", "/api/admin/budgets/rules", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer token")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 without X-Requested-With, got %d", rec.Code)
}

// DELETE without X-Requested-With should be rejected.
req = httptest.NewRequest("DELETE", "/api/admin/budgets/rules?pattern=sk-*", nil)
req.Header.Set("Authorization", "Bearer token")
rec = httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 without X-Requested-With on DELETE, got %d", rec.Code)
}

// GET without X-Requested-With should be allowed.
req = httptest.NewRequest("GET", "/api/admin/budgets/rules", nil)
req.Header.Set("Authorization", "Bearer token")
rec = httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 for GET without X-Requested-With, got %d", rec.Code)
}
}

func TestHandler_DeleteNonExistent(t *testing.T) {
db := setupTestDB(t)
store := admin.NewStore(db)
handler := admin.NewHandler(store, nil, nil, "token", nil)
handler := admin.NewHandler(store, nil, nil, "token", nil, testLogger())

mux := http.NewServeMux()
handler.RegisterRoutes(mux)

req := httptest.NewRequest("DELETE", "/api/admin/budgets/rules?pattern=nonexistent", nil)
req.Header.Set("Authorization", "Bearer token")
req.Header.Set("X-Requested-With", "XMLHttpRequest")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", rec.Code)
}
}

func TestHandler_BudgetStatus(t *testing.T) {
db := setupTestDB(t)
store := admin.NewStore(db)
handler := admin.NewHandler(store, nil, nil, "token", nil, testLogger())

mux := http.NewServeMux()
handler.RegisterRoutes(mux)

// Budget status should return an empty array when no ledger is configured.
req := httptest.NewRequest("GET", "/api/admin/budgets/status", nil)
req.Header.Set("Authorization", "Bearer token")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}

var statuses []json.RawMessage
if err := json.NewDecoder(rec.Body).Decode(&statuses); err != nil {
t.Fatal(err)
}
if len(statuses) != 0 {
t.Fatalf("expected 0 statuses with nil ledger, got %d", len(statuses))
}
}

func TestHandler_NoToken(t *testing.T) {
db := setupTestDB(t)
store := admin.NewStore(db)
handler := admin.NewHandler(store, nil, nil, "", nil)
handler := admin.NewHandler(store, nil, nil, "", nil, testLogger())

mux := http.NewServeMux()
handler.RegisterRoutes(mux)
Expand Down
Loading
Loading