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
32 changes: 31 additions & 1 deletion cmd/agentgw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
"github.com/Clawdlinux/agentgate/internal/delegation"
"github.com/Clawdlinux/agentgate/internal/gateway"
"github.com/Clawdlinux/agentgate/internal/oauth"
"github.com/Clawdlinux/agentgate/internal/org"
"github.com/Clawdlinux/agentgate/internal/ratelimit"
"github.com/Clawdlinux/agentgate/internal/receipt"
"github.com/Clawdlinux/agentgate/internal/registry"
Expand Down Expand Up @@ -95,6 +96,11 @@ func main() {
logger.Error("bootstrap agent key", "error", err)
os.Exit(1)
}
orgStore := org.NewStore(database)
if err := bootstrapAdmin(context.Background(), orgStore, logger); err != nil {
logger.Error("bootstrap admin", "error", err)
os.Exit(1)
}

// Signer derives its own purpose-specific encryption key from
// masterKey — it never uses masterKey directly (internal/signer's own
Expand Down Expand Up @@ -148,7 +154,8 @@ func main() {
}

oauthHandler := oauth.NewCallbackHandler(oauthProviders, vaultStore, masterKey, publicURL, logger)
adminHandler := admin.NewHandler(keyStore, oauthHandler, vaultStore, reg, adminSecret, logger)
sessionManager := admin.NewSessionManager(masterKey, publicURL)
adminHandler := admin.NewHandler(keyStore, oauthHandler, vaultStore, reg, orgStore, sessionManager, adminSecret, logger)

mux := http.NewServeMux()
mux.Handle("/", srv)
Expand All @@ -159,6 +166,9 @@ func main() {
mux.Handle("POST /admin/tokens", adminHandler.RequireAdmin(http.HandlerFunc(adminHandler.ConnectBearerToken)))
mux.Handle("GET /admin/tokens/{user_id}", adminHandler.RequireAdmin(http.HandlerFunc(adminHandler.ListTokens)))
mux.Handle("GET /v1/receipts/export", adminHandler.RequireAdmin(receipt.ExportHandler(database, signerStore)))
mux.HandleFunc("GET /admin/login", adminHandler.LoginPage)
mux.HandleFunc("POST /admin/login", adminHandler.Login)
mux.Handle("POST /admin/logout", adminHandler.RequireAdmin(http.HandlerFunc(adminHandler.Logout)))
mux.HandleFunc("GET /auth/callback/{service}", oauthHandler.ServeHTTP)

// The dashboard is a static single-page app served same-origin so its
Expand Down Expand Up @@ -244,6 +254,26 @@ func bootstrapAgentKey(ctx context.Context, keyStore *auth.KeyStore, logger *slo
return nil
}

func bootstrapAdmin(ctx context.Context, orgStore *org.Store, logger *slog.Logger) error {
email := strings.TrimSpace(os.Getenv("AGENTGATE_BOOTSTRAP_ADMIN_EMAIL"))
password := os.Getenv("AGENTGATE_BOOTSTRAP_ADMIN_PASSWORD")
if email == "" && password == "" {
return nil
}
if email == "" || password == "" {
return fmt.Errorf("AGENTGATE_BOOTSTRAP_ADMIN_EMAIL and AGENTGATE_BOOTSTRAP_ADMIN_PASSWORD must both be set")
}
organization, admin, created, err := orgStore.BootstrapAdmin(ctx, "Default organization", email, password)
if err != nil {
return err
}
if !created {
return nil
}
logger.Info("bootstrapped initial admin", "org_id", organization.ID, "admin_email", admin.Email)
return nil
}

// buildOAuthProviders constructs one oauth.Provider per registered service
// whose auth.type is oauth2 and whose <SERVICE>_CLIENT_ID/_CLIENT_SECRET
// env vars are both set. A service missing either value is skipped with a
Expand Down
60 changes: 60 additions & 0 deletions cmd/agentgw/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package main

import (
"context"
"io"
"log/slog"
"path/filepath"
"testing"

agentgatedb "github.com/Clawdlinux/agentgate/internal/db"
"github.com/Clawdlinux/agentgate/internal/org"
)

func TestBootstrapAdmin(t *testing.T) {
database, err := agentgatedb.Open(filepath.Join(t.TempDir(), "agentgate.db"))
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { database.Close() })
if err := agentgatedb.RunMigrations(database); err != nil {
t.Fatalf("run migrations: %v", err)
}
t.Setenv("AGENTGATE_BOOTSTRAP_ADMIN_EMAIL", "admin@example.com")
t.Setenv("AGENTGATE_BOOTSTRAP_ADMIN_PASSWORD", "correct horse battery staple")
store := org.NewStore(database)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))

if err := bootstrapAdmin(context.Background(), store, logger); err != nil {
t.Fatalf("bootstrapAdmin: %v", err)
}
if count, err := store.AdminCount(context.Background()); err != nil || count != 1 {
t.Fatalf("AdminCount = (%d, %v), want (1, nil)", count, err)
}
if _, err := store.Authenticate(context.Background(), "admin@example.com", "correct horse battery staple"); err != nil {
t.Fatalf("Authenticate bootstrap admin: %v", err)
}
if err := bootstrapAdmin(context.Background(), store, logger); err != nil {
t.Fatalf("second bootstrapAdmin: %v", err)
}
if count, err := store.AdminCount(context.Background()); err != nil || count != 1 {
t.Fatalf("AdminCount after second bootstrap = (%d, %v), want (1, nil)", count, err)
}
}

func TestBootstrapAdminRequiresBothEnvironmentValues(t *testing.T) {
database, err := agentgatedb.Open(filepath.Join(t.TempDir(), "agentgate.db"))
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { database.Close() })
if err := agentgatedb.RunMigrations(database); err != nil {
t.Fatalf("run migrations: %v", err)
}
t.Setenv("AGENTGATE_BOOTSTRAP_ADMIN_EMAIL", "admin@example.com")
t.Setenv("AGENTGATE_BOOTSTRAP_ADMIN_PASSWORD", "")

if err := bootstrapAdmin(context.Background(), org.NewStore(database), slog.New(slog.NewTextHandler(io.Discard, nil))); err == nil {
t.Fatal("bootstrapAdmin succeeded with only email configured")
}
}
3 changes: 3 additions & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ services:
- AGENTGATE_VAULT_KEY=dev-key-change-in-production-32b
- AGENTGATE_ADMIN_SECRET=admin-dev-secret-change-me!!
- AGENTGATE_PUBLIC_URL=http://localhost:8080
# Optional. Both values create the first admin account on an empty database. Remove them after first startup.
- AGENTGATE_BOOTSTRAP_ADMIN_EMAIL=${AGENTGATE_BOOTSTRAP_ADMIN_EMAIL:-}
- AGENTGATE_BOOTSTRAP_ADMIN_PASSWORD=${AGENTGATE_BOOTSTRAP_ADMIN_PASSWORD:-}
# Agent API keys are bootstrapped automatically on first boot; see
# the container logs for the one-time plaintext key.
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-}
Expand Down
108 changes: 104 additions & 4 deletions internal/admin/handler.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
package admin

import (
"context"
"crypto/subtle"
"encoding/json"
"html/template"
"log/slog"
"net"
"net/http"
"strings"
"sync"
"time"

"github.com/Clawdlinux/agentgate/internal/auth"
"github.com/Clawdlinux/agentgate/internal/oauth"
"github.com/Clawdlinux/agentgate/internal/org"
"github.com/Clawdlinux/agentgate/internal/registry"
"github.com/Clawdlinux/agentgate/internal/vault"
"golang.org/x/time/rate"
)

// Handler provides admin API endpoints for key management and user linking.
Expand All @@ -18,12 +26,15 @@ type Handler struct {
oauthHandler *oauth.CallbackHandler
vault vault.Store
registry *registry.Registry
orgStore *org.Store
sessions *SessionManager
adminSecret string
logger *slog.Logger
loginLimits sync.Map
}

// NewHandler creates the admin handler.
func NewHandler(ks *auth.KeyStore, oh *oauth.CallbackHandler, v vault.Store, reg *registry.Registry, adminSecret string, logger *slog.Logger) *Handler {
func NewHandler(ks *auth.KeyStore, oh *oauth.CallbackHandler, v vault.Store, reg *registry.Registry, orgStore *org.Store, sessions *SessionManager, adminSecret string, logger *slog.Logger) *Handler {
if logger == nil {
logger = slog.Default()
}
Expand All @@ -32,26 +43,115 @@ func NewHandler(ks *auth.KeyStore, oh *oauth.CallbackHandler, v vault.Store, reg
oauthHandler: oh,
vault: v,
registry: reg,
orgStore: orgStore,
sessions: sessions,
adminSecret: adminSecret,
logger: logger,
}
}

// RequireAdmin is middleware that checks the X-Admin-Secret header.
type adminContextKey string

const adminIdentityContextKey adminContextKey = "admin_identity"

const csrfHeaderName = "X-Requested-With"

const csrfHeaderValue = "AgentGate"

func AdminFromContext(ctx context.Context) (SessionIdentity, bool) {
identity, ok := ctx.Value(adminIdentityContextKey).(SessionIdentity)
return identity, ok
}

// RequireAdmin accepts the legacy X-Admin-Secret header or an authenticated session cookie.
func (h *Handler) RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
secret := r.Header.Get("X-Admin-Secret")
if secret == "" || secret != h.adminSecret {
if secret != "" && subtle.ConstantTimeCompare([]byte(secret), []byte(h.adminSecret)) == 1 {
next.ServeHTTP(w, r)
return
}

identity, err := h.sessions.Authenticate(r)
if err != nil || (isMutatingRequest(r) && r.Header.Get(csrfHeaderName) != csrfHeaderValue) {
writeJSON(w, http.StatusUnauthorized, map[string]string{
"error": "invalid admin secret",
"code": "unauthorized",
})
return
}
next.ServeHTTP(w, r)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), adminIdentityContextKey, identity)))
})
}

func isMutatingRequest(r *http.Request) bool {
return r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodPatch || r.Method == http.MethodDelete
}

func (h *Handler) LoginPage(w http.ResponseWriter, r *http.Request) {
renderLoginPage(w, http.StatusOK, false, false)
}

func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
if !h.allowLogin(r) {
renderLoginPage(w, http.StatusTooManyRequests, false, true)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
if err := r.ParseForm(); err != nil {
renderLoginPage(w, http.StatusBadRequest, false, false)
return
}
admin, err := h.orgStore.Authenticate(r.Context(), r.FormValue("email"), r.FormValue("password"))
if err != nil {
if err == org.ErrInvalidCredentials {
renderLoginPage(w, http.StatusUnauthorized, true, false)
return
}
h.logger.Error("admin login failed", "error", err)
http.Error(w, "login failed", http.StatusInternalServerError)
return
}
cookie, err := h.sessions.CreateCookie(SessionIdentity{AdminID: admin.ID, OrgID: admin.OrgID})
if err != nil {
h.logger.Error("create admin session", "error", err)
http.Error(w, "login failed", http.StatusInternalServerError)
return
}
http.SetCookie(w, cookie)
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
}

func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, h.sessions.ClearCookie())
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
}

func (h *Handler) allowLogin(r *http.Request) bool {
remoteAddress := r.RemoteAddr
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
remoteAddress = host
}
limiter, _ := h.loginLimits.LoadOrStore(remoteAddress, rate.NewLimiter(rate.Every(12*time.Second), 5))
return limiter.(*rate.Limiter).Allow()
}

var loginTemplate = template.Must(template.New("login").Parse(`<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>AgentGate Admin Login</title><style>body{margin:0;background:#101318;color:#e9edf1;font:16px system-ui,sans-serif;display:grid;min-height:100vh;place-items:center}main{width:min(26rem,calc(100% - 2rem));border:1px solid #3a424c;padding:2rem;background:#151a20}h1{margin-top:0}label,input,button{display:block;width:100%;box-sizing:border-box}label{margin-top:1rem}input{margin-top:.4rem;padding:.7rem;background:#0d1117;border:1px solid #59636f;color:#e9edf1}button{margin-top:1.5rem;padding:.75rem;border:0;background:#60d6c5;color:#07110f;font-weight:700}.error{color:#ffb4ab}</style></head>
<body><main><h1>AgentGate Admin</h1>{{if .InvalidCredentials}}<p class="error">Email or password is incorrect.</p>{{end}}{{if .RateLimited}}<p class="error">Too many sign-in attempts. Try again later.</p>{{end}}<form method="post" action="/admin/login"><label>Email<input type="email" name="email" autocomplete="username" required></label><label>Password<input type="password" name="password" autocomplete="current-password" required></label><button type="submit">Sign in</button></form></main></body></html>`))

func renderLoginPage(w http.ResponseWriter, status int, invalidCredentials, rateLimited bool) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if err := loginTemplate.Execute(w, struct {
InvalidCredentials bool
RateLimited bool
}{InvalidCredentials: invalidCredentials, RateLimited: rateLimited}); err != nil {
http.Error(w, "render login page", http.StatusInternalServerError)
}
}

// CreateKey handles POST /admin/keys.
func (h *Handler) CreateKey(w http.ResponseWriter, r *http.Request) {
var req struct {
Expand Down
Loading
Loading