diff --git a/internal/config/config.go b/internal/config/config.go index 7fbdfe4..816d6b7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -27,6 +27,11 @@ type Config struct { Auto []ModelScope `yaml:"auto,omitempty"` // AUTO 调度链规则(WebUI 优先级页编辑,chat) AutoImage []ModelScope `yaml:"auto_image,omitempty"` // AUTO 生图调度链规则(WebUI 优先级页·生图) Keys []GWKey `yaml:"keys,omitempty"` // 网关密钥(WebUI 密钥页管理) + // box seals credentials (sources' api_key/headers, keys' key) at rest. + // In-memory values are always plaintext; only the bytes on disk are sealed. + // Wired by AttachSecretBox — Load leaves it nil so `-check` and tests stay + // filesystem-free. + box *SecretBox } // Defaults applied to any source (YAML or runtime) that leaves a field unset. @@ -192,10 +197,15 @@ func RemoveSourceFromYAML(path, name string) error { // UpsertSourceInYAML adds or updates a source entry in the YAML config file. // Uses yaml.Node to preserve the rest of the file's comments and formatting. -func UpsertSourceInYAML(path, name string, src Source) error { +// When box is non-nil the source's credentials are sealed before writing, so a +// newly added source never lands in the file as plaintext. +func UpsertSourceInYAML(path, name string, src Source, box *SecretBox) error { if path == "" { return fmt.Errorf("config path is empty") } + if err := sealSource(&src, box); err != nil { + return err + } data, err := os.ReadFile(path) if err != nil { return err @@ -253,11 +263,17 @@ func UpsertSourceInYAML(path, name string, src Source) error { // Save writes the current config back to the YAML file (preserving comments // via yaml.Node round-trip when possible, or full marshaling as fallback). +// Credentials are sealed on the way out and the in-memory copy is restored to +// plaintext afterwards, so callers keep working with usable values. func (c *Config) Save() error { if c.Path == "" { return fmt.Errorf("config path is empty") } + if err := c.sealInPlace(c.box); err != nil { + return err + } out, err := yaml.Marshal(c) + c.unsealAfterWrite(c.box) if err != nil { return fmt.Errorf("marshal config: %w", err) } diff --git a/internal/config/secret_config.go b/internal/config/secret_config.go new file mode 100644 index 0000000..08aac65 --- /dev/null +++ b/internal/config/secret_config.go @@ -0,0 +1,227 @@ +package config + +// Secret handling for config.yaml. +// +// config.yaml is 0644 world-readable by design (ops need to inspect it), so any +// credential in it must not sit there in plaintext. Sources' api_key / headers +// and gateway keys are therefore sealed at rest with the same SecretBox used by +// the runtime store, and unsealed in memory at load time. +// +// The invariant that makes this safe: **in-memory values are always plaintext**, +// and the enc:v1: prefix is what marks a file value as sealed. Read paths that +// predate this (core.resolveSourceKey) already unseal, so only the write side and +// the load-time normalize step are new. + +import ( + "fmt" + "log" + "strings" +) + +// sealSource seals one source's credentials in place (used by the YAML upsert +// path, which is a package function and therefore has no Config to borrow a box +// from). +func sealSource(s *Source, box *SecretBox) error { + if box == nil { + return nil + } + if s.APIKey != "" && !strings.HasPrefix(s.APIKey, encPrefix) { + v, err := box.Encrypt(s.APIKey) + if err != nil { + return err + } + s.APIKey = v + } + for k, v := range s.Headers { + if v == "" || strings.HasPrefix(v, encPrefix) { + continue + } + e, err := box.Encrypt(v) + if err != nil { + return err + } + s.Headers[k] = e + } + return nil +} + +// normalizeSecrets unseals every credential in the freshly parsed config so the +// rest of the program only ever sees plaintext. A value without the enc:v1: +// prefix is left untouched, which keeps hand-written plaintext configs working +// (and is what a pre-encryption config file looks like). +// +// A value that carries the prefix but fails to decrypt is a hard error, not +// something to paper over: returning the ciphertext (MustDecrypt's behavior) +// would let the next Save re-seal it and turn one bad value into permanent, +// compounding corruption. Losing the master key must be loud. +func (c *Config) normalizeSecrets(box *SecretBox) error { + if box == nil { + return nil + } + for i := range c.Sources { + s := &c.Sources[i] + if strings.HasPrefix(s.APIKey, encPrefix) { + v, err := box.Decrypt(s.APIKey) + if err != nil { + return fmt.Errorf("source %q api_key: %w", s.Name, err) + } + s.APIKey = v + } + for k, v := range s.Headers { + if strings.HasPrefix(v, encPrefix) { + d, err := box.Decrypt(v) + if err != nil { + return fmt.Errorf("source %q header %q: %w", s.Name, k, err) + } + s.Headers[k] = d + } + } + } + for i := range c.Keys { + if strings.HasPrefix(c.Keys[i].Key, encPrefix) { + v, err := box.Decrypt(c.Keys[i].Key) + if err != nil { + return fmt.Errorf("gateway key %q: %w", c.Keys[i].Name, err) + } + c.Keys[i].Key = v + } + } + return nil +} + +// sealInPlace replaces plaintext credentials with ciphertext for writing. It is +// deliberately a separate step from Marshal: callers that need the plaintext +// (auth comparisons, log output, returning a key to the operator who just +// created it) must not be handed a sealed config by accident. +func (c *Config) sealInPlace(box *SecretBox) error { + if box == nil { + return nil + } + for i := range c.Sources { + s := &c.Sources[i] + if s.APIKey != "" && !strings.HasPrefix(s.APIKey, encPrefix) { + v, err := box.Encrypt(s.APIKey) + if err != nil { + return err + } + s.APIKey = v + } + if len(s.Headers) > 0 { + sealed := make(map[string]string, len(s.Headers)) + for k, v := range s.Headers { + if v == "" || strings.HasPrefix(v, encPrefix) { + sealed[k] = v + continue + } + e, err := box.Encrypt(v) + if err != nil { + return err + } + sealed[k] = e + } + s.Headers = sealed + } + } + for i := range c.Keys { + k := &c.Keys[i] + if k.Key != "" && !strings.HasPrefix(k.Key, encPrefix) { + v, err := box.Encrypt(k.Key) + if err != nil { + return err + } + k.Key = v + } + } + return nil +} + +// unsealAfterWrite restores plaintext after a sealed marshal so the live process +// keeps working on plaintext values (mirrors Store.persistLocked's dance). +func (c *Config) unsealAfterWrite(box *SecretBox) { + // Save just encrypted every value it can see, so a failure here is + // impossible; ignore the error rather than panic in a write path. + _ = c.normalizeSecrets(box) +} + +// hasPlaintextSecrets reports whether any credential in the config is still in +// the clear. Used to decide whether a startup migration write is needed, and to +// warn (without leaking values) when no master key is available. +func (c *Config) hasPlaintextSecrets() bool { + for _, s := range c.Sources { + if s.APIKey != "" && !strings.HasPrefix(s.APIKey, encPrefix) { + return true + } + for _, v := range s.Headers { + if v != "" && !strings.HasPrefix(v, encPrefix) { + return true + } + } + } + for _, k := range c.Keys { + if k.Key != "" && !strings.HasPrefix(k.Key, encPrefix) { + return true + } + } + return false +} + +// countPlaintextSecrets returns how many credentials are still in the clear, for +// an operator-facing migration log line that must not print the values. +func (c *Config) countPlaintextSecrets() int { + n := 0 + for _, s := range c.Sources { + if s.APIKey != "" && !strings.HasPrefix(s.APIKey, encPrefix) { + n++ + } + for _, v := range s.Headers { + if v != "" && !strings.HasPrefix(v, encPrefix) { + n++ + } + } + } + for _, k := range c.Keys { + if k.Key != "" && !strings.HasPrefix(k.Key, encPrefix) { + n++ + } + } + return n +} + +// NormalizeSecretsForRun unseals the loaded config and then seals it back on +// disk if anything was still in the clear. Order matters: Load() read the file +// with ciphertext still in place, so the unseal has to happen before the +// registry (and any Save the startup path performs) sees the values. +func (c *Config) NormalizeSecretsForRun(box *SecretBox) error { + c.AttachSecretBox(box) + if err := c.normalizeSecrets(box); err != nil { + return err + } + return c.migratePlaintextSecrets() +} + +// AttachSecretBox wires the encryption box into the config so Save can seal +// credentials. Kept as an explicit call (rather than a constructor argument) so +// config.Load stays usable in contexts that have no filesystem secrets (tests, +// `-check`). +func (c *Config) AttachSecretBox(box *SecretBox) { c.box = box } + +// SecretBox returns the wired encryption box, or nil when none is attached. +func (c *Config) SecretBox() *SecretBox { return c.box } + +// migratePlaintextSecrets seals any credential still in the clear and writes the +// file once. It is idempotent: a config that is already sealed (or has no +// secrets) is left alone and nothing is written. +func (c *Config) migratePlaintextSecrets() error { + if c.box == nil || c.Path == "" { + return nil + } + if !c.hasPlaintextSecrets() { + return nil + } + n := c.countPlaintextSecrets() + if err := c.Save(); err != nil { + return err + } + log.Printf("[config] sealed %d plaintext credential(s) in %s", n, c.Path) + return nil +} diff --git a/internal/config/secret_config_test.go b/internal/config/secret_config_test.go new file mode 100644 index 0000000..b926070 --- /dev/null +++ b/internal/config/secret_config_test.go @@ -0,0 +1,283 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// writeMasterKey plants a deterministic master.key next to the given runtime +// file so tests exercise the real box instead of the env-var shortcut. +func writeMasterKey(t *testing.T, runtimeFile string) *SecretBox { + t.Helper() + if err := os.WriteFile(filepath.Join(filepath.Dir(runtimeFile), "master.key"), + []byte(strings.Repeat("ab", 32)), 0600); err != nil { + t.Fatal(err) + } + box, err := NewSecretBox(runtimeFile) + if err != nil { + t.Fatalf("NewSecretBox: %v", err) + } + return box +} + +func TestSaveSealsCredentialsAndLoadUnseals(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + runtime := filepath.Join(dir, "runtime.json") + box := writeMasterKey(t, runtime) + + cfg := &Config{ + Path: path, + Listen: "127.0.0.1:0", + Sources: []Source{{ + Name: "up", + BaseURL: "http://up/v1", + APIKey: "sk-plaintext-secret", + Adapter: "openai", + Headers: map[string]string{"X-Extra": "header-secret"}, + Models: []Model{{ID: "m", Kind: "chat"}}, + }}, + Keys: []GWKey{{Key: "sk-gw-plain", Role: "admin", Name: "admin"}}, + } + cfg.AttachSecretBox(box) + if err := cfg.Save(); err != nil { + t.Fatalf("Save: %v", err) + } + + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + text := string(raw) + if strings.Contains(text, "sk-plaintext-secret") { + t.Error("source api_key written in plaintext") + } + if strings.Contains(text, "header-secret") { + t.Error("source header written in plaintext") + } + if strings.Contains(text, "sk-gw-plain") { + t.Error("gateway key written in plaintext") + } + if n := strings.Count(text, encPrefix); n != 3 { + t.Errorf("expected 3 sealed values, found %d", n) + } + + // The live in-memory config must still hold plaintext after Save. + if cfg.Sources[0].APIKey != "sk-plaintext-secret" { + t.Errorf("in-memory api_key mutated by Save: %q", cfg.Sources[0].APIKey) + } + if cfg.Keys[0].Key != "sk-gw-plain" { + t.Errorf("in-memory gateway key mutated by Save: %q", cfg.Keys[0].Key) + } + + // A fresh load must hand back plaintext again. + loaded, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + loaded.AttachSecretBox(box) + loaded.normalizeSecrets(box) + if loaded.Sources[0].APIKey != "sk-plaintext-secret" { + t.Errorf("loaded api_key = %q, want plaintext", loaded.Sources[0].APIKey) + } + if loaded.Sources[0].Headers["X-Extra"] != "header-secret" { + t.Errorf("loaded header = %q, want plaintext", loaded.Sources[0].Headers["X-Extra"]) + } + if loaded.Keys[0].Key != "sk-gw-plain" { + t.Errorf("loaded gateway key = %q, want plaintext", loaded.Keys[0].Key) + } +} + +func TestPlaintextConfigStillLoads(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + // A hand-written, pre-encryption config: no enc:v1: anywhere. + body := `listen: 127.0.0.1:0 +sources: + - name: legacy + base_url: http://legacy/v1 + api_key: sk-written-by-hand + adapter: openai + models: + - id: m + kind: chat +` + if err := os.WriteFile(path, []byte(body), 0644); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + box := writeMasterKey(t, filepath.Join(dir, "runtime.json")) + cfg.AttachSecretBox(box) + cfg.normalizeSecrets(box) + if cfg.Sources[0].APIKey != "sk-written-by-hand" { + t.Errorf("plaintext config value changed: %q", cfg.Sources[0].APIKey) + } +} + +func TestMigratePlaintextSecretsIsIdempotent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + runtime := filepath.Join(dir, "runtime.json") + box := writeMasterKey(t, runtime) + + cfg := &Config{ + Path: path, + Listen: "127.0.0.1:0", + Sources: []Source{{Name: "up", BaseURL: "http://up/v1", APIKey: "sk-clear", Adapter: "openai", Models: []Model{{ID: "m"}}}}, + } + cfg.AttachSecretBox(box) + + if err := cfg.migratePlaintextSecrets(); err != nil { + t.Fatalf("first migrate: %v", err) + } + first, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(first), encPrefix) { + t.Fatal("migration did not seal the value") + } + // In-memory must be plaintext so the running process keeps working. + if cfg.Sources[0].APIKey != "sk-clear" { + t.Errorf("in-memory api_key = %q, want plaintext", cfg.Sources[0].APIKey) + } + + // Second run: already sealed => no write. + before, _ := os.Stat(path) + if err := cfg.migratePlaintextSecrets(); err != nil { + t.Fatalf("second migrate: %v", err) + } + after, _ := os.Stat(path) + if before.ModTime() != after.ModTime() { + t.Error("second migrate rewrote an already-sealed config") + } +} + +func TestSealedValueDoesNotDoubleEncrypt(t *testing.T) { + dir := t.TempDir() + box := writeMasterKey(t, filepath.Join(dir, "runtime.json")) + cfg := &Config{Path: filepath.Join(dir, "config.yaml"), Sources: []Source{{Name: "a", BaseURL: "http://a"}}} + cfg.AttachSecretBox(box) + + once, err := box.Encrypt("sk-x") + if err != nil { + t.Fatal(err) + } + cfg.Sources[0].APIKey = once + if err := cfg.Save(); err != nil { + t.Fatal(err) + } + got, err := box.Decrypt(cfg.Sources[0].APIKey) + if err != nil { + t.Fatalf("value became undecryptable: %v", err) + } + if got != "sk-x" { + t.Errorf("round trip = %q, want sk-x", got) + } +} + +func TestUpsertSourceInYAMLSealsCredentials(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + box := writeMasterKey(t, filepath.Join(dir, "runtime.json")) + + if err := os.WriteFile(path, []byte("listen: 127.0.0.1:0\nsources: []\n"), 0644); err != nil { + t.Fatal(err) + } + src := Source{Name: "new", BaseURL: "http://new/v1", APIKey: "sk-fresh", Adapter: "openai", Models: []Model{{ID: "m"}}} + if err := UpsertSourceInYAML(path, src.Name, src, box); err != nil { + t.Fatalf("UpsertSourceInYAML: %v", err) + } + raw, _ := os.ReadFile(path) + if strings.Contains(string(raw), "sk-fresh") { + t.Error("newly added source stored its api_key in plaintext") + } + if !strings.Contains(string(raw), encPrefix) { + t.Error("newly added source was not sealed") + } +} + +func TestCountPlaintextSecrets(t *testing.T) { + cfg := &Config{ + Sources: []Source{ + {Name: "a", APIKey: encPrefix + "abc", Headers: map[string]string{"H": encPrefix + "x"}}, + {Name: "b", APIKey: "sk-in-clear", Headers: map[string]string{"H2": "clear-too"}}, + }, + Keys: []GWKey{{Key: "sk-gw-clear"}}, + } + if got := cfg.countPlaintextSecrets(); got != 3 { + t.Errorf("countPlaintextSecrets = %d, want 3", got) + } + if !cfg.hasPlaintextSecrets() { + t.Error("hasPlaintextSecrets = false, want true") + } + cfg.Sources[1].APIKey = encPrefix + "y" + cfg.Sources[1].Headers["H2"] = encPrefix + "z" + cfg.Keys[0].Key = encPrefix + "k" + if cfg.hasPlaintextSecrets() { + t.Error("hasPlaintextSecrets = true after sealing everything") + } +} + +// TestNormalizeSecretsFailsLoudlyOnWrongMasterKey is the negative case that +// guards the migration: with a wrong key, a sealed value must NOT be handed +// back as ciphertext for a later re-seal (that compounds corruption and turns +// one lost key file into permanently unusable config). +func TestNormalizeSecretsFailsLoudlyOnWrongMasterKey(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + runtime := filepath.Join(dir, "runtime.json") + good := writeMasterKey(t, runtime) + + cfg := &Config{ + Path: path, + Listen: "127.0.0.1:0", + Sources: []Source{{Name: "up", BaseURL: "http://up/v1", APIKey: "sk-secret", Adapter: "openai", Models: []Model{{ID: "m"}}}}, + } + cfg.AttachSecretBox(good) + if err := cfg.Save(); err != nil { + t.Fatal(err) + } + sealedOnce, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + // A different master key must be rejected, loudly. NewSecretBox derives the + // key path from the RUNTIME file's directory, so the wrong key has to live + // in a different directory — otherwise it would read the good master.key. + otherDir := t.TempDir() + if err := os.WriteFile(filepath.Join(otherDir, "master.key"), + []byte(strings.Repeat("cd", 32)), 0600); err != nil { + t.Fatal(err) + } + badBox, err := NewSecretBox(filepath.Join(otherDir, "runtime.json")) + if err != nil { + t.Fatal(err) + } + loaded, err := Load(path) + if err != nil { + t.Fatal(err) + } + loaded.AttachSecretBox(badBox) + if err := loaded.NormalizeSecretsForRun(badBox); err == nil { + t.Fatal("expected an error with the wrong master key, got nil") + } + + // Crucially: the file must be untouched — no second seal on top. + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(after) != string(sealedOnce) { + t.Error("config file was rewritten despite the decrypt failure") + } + if n := strings.Count(string(after), encPrefix); n != strings.Count(string(sealedOnce), encPrefix) { + t.Errorf("sealed count changed %d -> %d (double encryption)", strings.Count(string(sealedOnce), encPrefix), n) + } +} diff --git a/internal/core/core.go b/internal/core/core.go index 30252c5..905c836 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -7,6 +7,7 @@ import ( "crypto/rand" "encoding/hex" "fmt" + "log" "os" "path/filepath" "sort" @@ -53,6 +54,10 @@ func NewFromConfig(cfg *config.Config) (*Core, error) { return nil, fmt.Errorf("lua vm: %w", err) } c.store = config.NewStore(cfg.RuntimeFile) + // Share one box between the runtime store and config.yaml so a single + // master.key seals both files. config.Load left the config holding + // ciphertext (if it was sealed); unseal it now that the box exists. + cfg.AttachSecretBox(c.store.SecretBox()) if err := c.store.Load(); err != nil { return nil, fmt.Errorf("runtime store: %w", err) } @@ -74,6 +79,12 @@ func NewFromConfig(cfg *config.Config) (*Core, error) { if err := c.seedPresetTemplates(); err != nil { return nil, fmt.Errorf("seed preset templates: %w", err) } + // Seal any credential still in the clear in config.yaml. Idempotent: an + // already-sealed config is not rewritten, so a normal restart writes + // nothing. This is the only place that rewrites the file on startup. + if err := cfg.NormalizeSecretsForRun(c.store.SecretBox()); err != nil { + return nil, fmt.Errorf("normalize secrets: %w", err) + } return c, nil } @@ -439,6 +450,11 @@ func (c *Core) mergedSources() []config.Source { } // resolveSourceKey applies api_key_env and decrypts enc:v1: ciphertext. +// Config values are already unsealed at startup (Config.NormalizeSecretsForRun), +// so the decrypt branch is the belt-and-braces path for a source that arrived +// already sealed through another route. A failure there leaves the ciphertext in +// place, which makes the upstream reject the key loudly rather than sending an +// empty Authorization header that might look like a config-less source. func (c *Core) resolveSourceKey(s config.Source) config.Source { if s.APIKeyEnv != "" { if v := os.Getenv(s.APIKeyEnv); v != "" { @@ -447,7 +463,11 @@ func (c *Core) resolveSourceKey(s config.Source) config.Source { return s } if box := c.store.SecretBox(); box != nil && strings.HasPrefix(s.APIKey, "enc:v1:") { - s.APIKey = box.MustDecrypt(s.APIKey) + if v, err := box.Decrypt(s.APIKey); err == nil { + s.APIKey = v + } else { + log.Printf("[core] source %s: api_key decrypt failed: %v", s.Name, err) + } } return s } @@ -655,7 +675,7 @@ func (c *Core) AddSource(src config.Source) error { if err := normalizeSource(&src); err != nil { return err } - if err := config.UpsertSourceInYAML(c.cfg.Path, src.Name, src); err != nil { + if err := config.UpsertSourceInYAML(c.cfg.Path, src.Name, src, c.cfg.SecretBox()); err != nil { return err } // Update in-memory Sources so mergedSources() finds the entry. diff --git a/internal/gateway/apiv1.go b/internal/gateway/apiv1.go new file mode 100644 index 0000000..2f601ea --- /dev/null +++ b/internal/gateway/apiv1.go @@ -0,0 +1,399 @@ +package gateway + +// Agent-facing management API. +// +// The Web UI has always driven the gateway through /api/*, so the management +// surface already exists. What it lacked was anything an agent could *rely* on: +// no way to discover the contract, no single call that answers "what is the +// current state", and per-endpoint response shapes that make scripting brittle. +// +// This file adds a versioned, self-describing facade under /api/v1 without +// touching the existing endpoints, so the Web UI keeps working unchanged while +// agents get a stable contract: +// +// GET /api/v1 — machine-readable index of every endpoint +// GET /api/v1/overview — one call: sources + auto chain + keys + health +// GET /api/v1/sources — sources, credentials masked +// GET /api/v1/sources/{name} — one source +// GET /api/v1/auto — scheduling chain + live slot states +// GET /api/v1/keys — key metadata (never the secret) +// GET /api/v1/models — every model id the gateway can route +// GET /api/v1/health — per-source health, no auth needed beyond the key +// +// Auth is the same gateway key as everywhere else (Authorization: Bearer, or +// ?api_key=, or the gw_key cookie). Read endpoints accept any role; writes +// still require admin, enforced by the same middleware the UI goes through. + +import ( + "errors" + "net/http" + "net/url" + "sort" + "strings" + + "llmsproxy/internal/config" +) + +// apiV1Routes dispatches /api/v1/*. Kept separate from routes() so the original +// switch stays readable and the versioned surface can grow on its own. +func (g *Gateway) apiV1Routes(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/api/v1") + path = strings.Trim(path, "/") + + switch { + case path == "": + g.apiV1Index(w, r) + case path == "overview": + g.apiV1Overview(w, r) + case path == "health": + g.apiV1Health(w, r) + case path == "models": + g.apiV1Models(w, r) + case path == "sources": + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET") + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"sources": maskSources(g.core.Sources())}) + case strings.HasPrefix(path, "sources/"): + name, err := decodePathSegment(strings.TrimPrefix(path, "sources/")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", + "use PUT/DELETE on /api/sources/{name} to modify a source") + return + } + for _, s := range g.core.Sources() { + if s.Name == name { + writeJSON(w, http.StatusOK, map[string]interface{}{"source": maskSource(s)}) + return + } + } + writeError(w, http.StatusNotFound, "not_found", "no such source: "+name) + case path == "auto": + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET") + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "rules": g.core.AutoRules(), + "image_rules": g.core.AutoImageRules(), + "states": g.core.AutoSlotStates(), + }) + case path == "keys": + if reqRole(r.Context()) != "admin" { + writeError(w, http.StatusForbidden, "forbidden", "admin role required") + return + } + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", + "use POST/PUT/DELETE on /api/keys to manage keys") + return + } + keys := g.core.ListKeys() + out := make([]map[string]interface{}, 0, len(keys)) + for _, k := range keys { + out = append(out, map[string]interface{}{ + "name": k.Name, + "role": k.Role, + "models": k.Models, + "note": k.Note, + "created_at": k.CreatedAt, + "seed": k.Seed, + // The secret itself is never echoed. An operator that needs it + // already has it from creation time or from config.yaml. + "key_prefix": maskKey(k.Key), + }) + } + writeJSON(w, http.StatusOK, map[string]interface{}{"keys": out}) + default: + g.apiV1Index(w, r) + } +} + +// apiV1Index is the discovery document: it names every endpoint with its +// method, auth requirement and purpose, so an agent does not have to scrape the +// HTML to find out what it can call. +func (g *Gateway) apiV1Index(w http.ResponseWriter, r *http.Request) { + type ep struct { + Method string `json:"method"` + Path string `json:"path"` + Auth string `json:"auth"` + Summary string `json:"summary"` + WriteEffect string `json:"writes,omitempty"` + } + index := map[string]interface{}{ + "api_version": "v1", + "description": "llmsproxy management API. Authenticate with a gateway key: " + + "'Authorization: Bearer ', '?api_key=', or the gw_key cookie. " + + "Read endpoints accept any role; mutations require an admin key.", + "discovery": []ep{ + {Method: "GET", Path: "/api/v1", Auth: "any", Summary: "this document"}, + {Method: "GET", Path: "/api/v1/overview", Auth: "any", + Summary: "current state in one call: sources, auto chain, key count, source health"}, + {Method: "GET", Path: "/api/v1/health", Auth: "any", + Summary: "per-source health snapshot only"}, + {Method: "GET", Path: "/api/v1/models", Auth: "any", + Summary: "every model id the gateway can route, grouped by owning source"}, + {Method: "GET", Path: "/api/v1/sources", Auth: "any", + Summary: "all sources with credentials masked"}, + {Method: "GET", Path: "/api/v1/sources/{name}", Auth: "any", Summary: "one source"}, + {Method: "GET", Path: "/api/v1/auto", Auth: "any", + Summary: "AUTO scheduling chain and live per-slot state"}, + {Method: "GET", Path: "/api/v1/keys", Auth: "admin", + Summary: "gateway key metadata; secrets are never returned"}, + + {Method: "GET", Path: "/api/sources", Auth: "admin", Summary: "raw source list (includes api_key)"}, + {Method: "POST", Path: "/api/sources", Auth: "admin", Summary: "add or replace a source", + WriteEffect: "writes config.yaml (api_key sealed at rest)"}, + {Method: "PUT", Path: "/api/sources/{name}", Auth: "admin", Summary: "update one source", + WriteEffect: "writes config.yaml"}, + {Method: "DELETE", Path: "/api/sources/{name}", Auth: "admin", Summary: "delete a source", + WriteEffect: "writes config.yaml"}, + + {Method: "GET", Path: "/api/auto", Auth: "any", Summary: "scheduling chain (readable by any role)"}, + {Method: "PUT", Path: "/api/auto", Auth: "admin", Summary: "replace the scheduling chain", + WriteEffect: "writes config.yaml (the AUTO chain lives here)"}, + + {Method: "GET", Path: "/api/keys", Auth: "admin", Summary: "gateway keys"}, + {Method: "POST", Path: "/api/keys", Auth: "admin", Summary: "create a gateway key", + WriteEffect: "writes config.yaml"}, + {Method: "DELETE", Path: "/api/keys/{name}", Auth: "admin", Summary: "delete a gateway key", + WriteEffect: "writes config.yaml"}, + + {Method: "GET", Path: "/api/status", Auth: "any", Summary: "per-source health detail"}, + {Method: "GET", Path: "/api/stats", Auth: "any", Summary: "usage aggregates"}, + {Method: "GET", Path: "/api/stats/records", Auth: "any", Summary: "paged request records"}, + {Method: "GET", Path: "/api/adapters", Auth: "admin", Summary: "installed Lua adapters"}, + {Method: "GET", Path: "/api/source_templates", Auth: "admin", Summary: "source templates"}, + {Method: "POST", Path: "/v1/chat/completions", Auth: "any", Summary: "OpenAI-compatible inference"}, + }, + "conventions": map[string]interface{}{ + "errors": "{ \"error\": { \"type\": , \"message\": } }", + "path_escape": "URL-encode source and key names; {name} is a single path segment", + "idempotency": "POST /api/sources and PUT /api/sources/{name} both upsert by name", + "config_truth": "all configuration lives in config.yaml; API writes are persisted immediately", + }, + } + writeJSON(w, http.StatusOK, index) +} + +// apiV1Overview is the "what is the current state" call an agent makes first. +// One round trip instead of five. +func (g *Gateway) apiV1Overview(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET") + return + } + sources := g.core.Sources() + names := make([]string, 0, len(sources)) + modelCount := 0 + for _, s := range sources { + names = append(names, s.Name) + modelCount += len(s.Models) + } + keys := g.core.ListKeys() + adminCount, userCount := 0, 0 + for _, k := range keys { + if k.Role == "admin" { + adminCount++ + } else { + userCount++ + } + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "api_version": "v1", + "summary": map[string]interface{}{ + "source_count": len(sources), + "model_count": modelCount, + "gateway_key_count": len(keys), + "admin_key_count": adminCount, + "user_key_count": userCount, + "auto_slots": len(g.core.AutoRules()), + }, + "sources": maskSources(sources), + "auto": g.core.AutoRules(), + "auto_image": g.core.AutoImageRules(), + "health": g.sourceHealthBrief(), + "caller": map[string]interface{}{ + "role": reqRole(r.Context()), + "key": maskKey(reqKey(r.Context())), + }, + }) +} + +func (g *Gateway) apiV1Health(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET") + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"sources": g.sourceHealthBrief()}) +} + +// apiV1Models lists routable model ids per source, which is what an agent needs +// to build a valid request — /v1/models flattens them and hides the owner. +func (g *Gateway) apiV1Models(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET") + return + } + bySource := map[string][]string{} + var all []string + for _, s := range g.core.Sources() { + for _, m := range s.Models { + bySource[s.Name] = append(bySource[s.Name], m.ID) + all = append(all, m.ID) + } + } + sort.Strings(all) + all = dedupeStrings(all) + writeJSON(w, http.StatusOK, map[string]interface{}{ + "count": len(all), + "models": all, + "by_source": bySource, + "note": "request a model as \":\" to pin one source, or the bare id to let the gateway choose", + }) +} + +// ---- helpers ---- + +// maskSources returns sources with credentials replaced by a presence marker. +// The UI still uses /api/sources (which returns real values, because its edit +// form round-trips them); this is the safe view for programmatic callers. +func maskSources(srcs []config.Source) []map[string]interface{} { + out := make([]map[string]interface{}, 0, len(srcs)) + for _, s := range srcs { + out = append(out, maskSource(s)) + } + return out +} + +func maskSource(s config.Source) map[string]interface{} { + return map[string]interface{}{ + "name": s.Name, + "base_url": s.BaseURL, + "adapter": s.Adapter, + "endpoint": s.Endpoint, + "image_endpoint": s.ImageEndpoint, + "api_key": maskKey(s.APIKey), + "api_key_set": s.APIKey != "", + "models": s.Models, + "headers": maskHeaders(s.Headers), + "proxy_url": s.ProxyURL, + "meta": s.Meta, + "temperature": s.Temperature, + "max_tokens": s.MaxTokens, + "max_concurrent": s.MaxConcurrent, + "rpm": s.RPM, + } +} + +func maskKey(k string) string { + if k == "" { + return "" + } + if strings.HasPrefix(k, "enc:v1:") { + return "(sealed)" + } + if len(k) <= 10 { + return k[:2] + "…" + } + return k[:6] + "…" + k[len(k)-4:] +} + +func maskHeaders(h map[string]string) map[string]string { + if h == nil { + return nil + } + out := make(map[string]string, len(h)) + for k, v := range h { + out[k] = maskKey(v) + } + return out +} + +// sourceHealthBrief reuses the same registry status the UI's /api/status shows, +// so the agent view and the UI view cannot drift apart. Recent-traffic counters +// come from the same stats window, because a source that is actually serving +// traffic must never look down just because a probe was rate-limited. +func (g *Gateway) sourceHealthBrief() []map[string]interface{} { + sts := g.core.Registry().Status() + recent := g.stats.SourceRecent(300) + avgs := g.stats.SourceAverages(300) + for i := range sts { + if v, ok := recent[sts[i].Name]; ok { + sts[i].RecentOK = v[0] + sts[i].RecentErr = v[1] + } + if a, ok := avgs[sts[i].Name]; ok { + sts[i].AvgFirstByteMs = a.AvgFirstByteMs + sts[i].AvgTokPerS = a.AvgTokPerS + } + } + out := make([]map[string]interface{}, 0, len(sts)) + for _, s := range sts { + row := map[string]interface{}{ + "name": s.Name, + "adapter": s.Adapter, + "healthy": s.Healthy, + "available": s.Available, + "live_available": s.LiveAvailable, + "model_count": len(s.Models), + } + if s.LastError != "" { + row["last_error"] = s.LastError + } + if s.LastChecked > 0 { + row["last_checked"] = s.LastChecked + } + if s.RecentOK > 0 || s.RecentErr > 0 { + row["recent_ok"] = s.RecentOK + row["recent_err"] = s.RecentErr + } + if s.AvgFirstByteMs > 0 { + row["avg_first_byte_ms"] = s.AvgFirstByteMs + } + if s.AvgTokPerS > 0 { + row["avg_tok_per_s"] = s.AvgTokPerS + } + if s.FailCount > 0 { + row["fail_count"] = s.FailCount + } + if s.Permanent { + row["permanent"] = true + } + out = append(out, row) + } + return out +} + +// decodePathSegment URL-decodes one path segment and rejects an empty result, +// so a name containing a slash cannot be silently mis-resolved. +func decodePathSegment(seg string) (string, error) { + s, err := url.PathUnescape(seg) + if err != nil { + return "", err + } + if s == "" { + return "", errEmptySegment + } + return s, nil +} + +func dedupeStrings(in []string) []string { + out := in[:0] + var last string + for i, s := range in { + if i == 0 || s != last { + out = append(out, s) + } + last = s + } + return out +} + +// errEmptySegment marks a path like /api/v1/sources/ with no name after it. +var errEmptySegment = errors.New("empty path segment") diff --git a/internal/gateway/apiv1_test.go b/internal/gateway/apiv1_test.go new file mode 100644 index 0000000..b190ab9 --- /dev/null +++ b/internal/gateway/apiv1_test.go @@ -0,0 +1,273 @@ +package gateway + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "llmsproxy/internal/config" +) + +// v1Gateway builds a gateway with one source and two keys, and returns the +// secrets so a test can assert they never appear in a response. +func v1Gateway(t *testing.T) (*Gateway, string, string) { + t.Helper() + g := newTestGateway(t, config.Source{ + Name: "up", + BaseURL: "http://up.example/v1", + APIKey: "sk-up-secret", + Adapter: "openai", + Models: []config.Model{{ID: "m1", Kind: "chat"}}, + }) + // The shared helper only seeds one admin key; add a user key through the + // core so role-gated endpoints have something to reject. + if _, err := g.core.CreateKey("agent", "user", nil, "test agent key"); err != nil { + t.Fatalf("CreateKey: %v", err) + } + rec := doReq(t, g, http.MethodGet, "/api/keys", "") + var doc struct { + Keys []config.GWKey `json:"keys"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil { + t.Fatalf("decode keys: %v", err) + } + userKey := "" + for _, k := range doc.Keys { + if k.Role == "user" { + userKey = k.Key + } + } + if userKey == "" { + t.Fatal("no user key was created") + } + return g, "sk-test", userKey +} + +func decodeJSON(t *testing.T, body string, v interface{}) { + t.Helper() + if err := json.Unmarshal([]byte(body), v); err != nil { + t.Fatalf("decode %s: %v", body, err) + } +} + +func TestAPIV1IndexIsDiscoverable(t *testing.T) { + g, _, _ := v1Gateway(t) + rec := doReq(t, g, http.MethodGet, "/api/v1", "") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + var doc struct { + APIVersion string `json:"api_version"` + Description string `json:"description"` + Discovery []struct { + Method string `json:"method"` + Path string `json:"path"` + Auth string `json:"auth"` + Summary string `json:"summary"` + } `json:"discovery"` + Conventions map[string]interface{} `json:"conventions"` + } + decodeJSON(t, rec.Body.String(), &doc) + if doc.APIVersion != "v1" { + t.Errorf("api_version = %q", doc.APIVersion) + } + if doc.Description == "" { + t.Error("index should carry a usage description") + } + if len(doc.Discovery) == 0 { + t.Fatal("discovery list is empty") + } + want := map[string]bool{ + "/api/v1/overview": false, "/api/v1/sources": false, + "/api/v1/auto": false, "/api/v1/models": false, + "/api/v1/health": false, "/api/v1/keys": false, + "/api/sources": false, "/api/auto": false, "/v1/chat/completions": false, + } + for _, e := range doc.Discovery { + if _, ok := want[e.Path]; ok { + want[e.Path] = true + } + if e.Method == "" || e.Path == "" || e.Auth == "" || e.Summary == "" { + t.Errorf("incomplete discovery entry: %+v", e) + } + } + for p, found := range want { + if !found { + t.Errorf("discovery is missing %s", p) + } + } + if doc.Conventions["errors"] == nil { + t.Error("conventions should document the error shape") + } +} + +func TestAPIV1OverviewSummarisesState(t *testing.T) { + g, _, _ := v1Gateway(t) + rec := doReq(t, g, http.MethodGet, "/api/v1/overview", "") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d: %s", rec.Code, rec.Body.String()) + } + var doc struct { + Summary struct { + SourceCount int `json:"source_count"` + ModelCount int `json:"model_count"` + GatewayKeyCount int `json:"gateway_key_count"` + } `json:"summary"` + Sources []map[string]interface{} `json:"sources"` + Auto []map[string]interface{} `json:"auto"` + Health []map[string]interface{} `json:"health"` + Caller struct { + Role string `json:"role"` + } `json:"caller"` + } + decodeJSON(t, rec.Body.String(), &doc) + if doc.Summary.SourceCount != 1 { + t.Errorf("source_count = %d, want 1", doc.Summary.SourceCount) + } + if doc.Summary.ModelCount != 1 { + t.Errorf("model_count = %d, want 1", doc.Summary.ModelCount) + } + if doc.Summary.GatewayKeyCount < 2 { + t.Errorf("gateway_key_count = %d, want >= 2", doc.Summary.GatewayKeyCount) + } + if len(doc.Sources) != 1 || doc.Sources[0]["name"] != "up" { + t.Errorf("sources = %+v", doc.Sources) + } + if doc.Caller.Role != "admin" { + t.Errorf("caller.role = %q, want admin", doc.Caller.Role) + } +} + +func TestAPIV1NeverEchoesSecrets(t *testing.T) { + g, admin, user := v1Gateway(t) + for _, path := range []string{"/api/v1", "/api/v1/overview", "/api/v1/sources", "/api/v1/sources/up", "/api/v1/keys", "/api/v1/models", "/api/v1/health", "/api/v1/auto"} { + rec := doReq(t, g, http.MethodGet, path, "") + if rec.Code != http.StatusOK { + t.Fatalf("%s status = %d: %s", path, rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "sk-up-secret") { + t.Errorf("%s leaked the source api_key", path) + } + if admin != "" && strings.Contains(rec.Body.String(), admin) { + t.Errorf("%s leaked the admin gateway key", path) + } + if strings.Contains(rec.Body.String(), user) { + t.Errorf("%s leaked the user gateway key", path) + } + } + // Masking must still be useful: it says a key is configured. + rec := doReq(t, g, http.MethodGet, "/api/v1/sources/up", "") + if !strings.Contains(rec.Body.String(), `"api_key_set":true`) { + t.Error("masked source should report that a key is configured") + } +} + +func TestAPIV1KeysRequiresAdminAndMasks(t *testing.T) { + g, _, user := v1Gateway(t) + // Authenticate as the user key by swapping the shared helper's header. + req := newAuthedRequest(t, http.MethodGet, "/api/v1/keys", user) + rec := serveViaHandler(t, g, req) + if rec.Code != http.StatusForbidden { + t.Errorf("user role on /api/v1/keys = %d, want 403: %s", rec.Code, rec.Body.String()) + } +} + +func TestAPIV1SourcesNotFound(t *testing.T) { + g, _, _ := v1Gateway(t) + rec := doReq(t, g, http.MethodGet, "/api/v1/sources/nope", "") + if rec.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404: %s", rec.Code, rec.Body.String()) + } +} + +func TestAPIV1RejectsWrites(t *testing.T) { + g, _, _ := v1Gateway(t) + for _, m := range []string{http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} { + rec := doReq(t, g, m, "/api/v1/sources", `{}`) + if rec.Code == http.StatusOK { + t.Errorf("%s /api/v1/sources returned 200; the v1 read facade must not mutate", m) + } + } + // And the source must be untouched. + rec := doReq(t, g, http.MethodGet, "/api/v1/sources", "") + if !strings.Contains(rec.Body.String(), `"name":"up"`) { + t.Error("the source disappeared after a rejected write") + } +} + +func TestAPIV1ModelsGroupsBySource(t *testing.T) { + g, _, _ := v1Gateway(t) + rec := doReq(t, g, http.MethodGet, "/api/v1/models", "") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + var doc struct { + Count int `json:"count"` + Models []string `json:"models"` + BySource map[string][]string `json:"by_source"` + } + decodeJSON(t, rec.Body.String(), &doc) + if doc.Count != 1 || doc.Models[0] != "m1" { + t.Errorf("models = %+v", doc.Models) + } + if len(doc.BySource["up"]) != 1 || doc.BySource["up"][0] != "m1" { + t.Errorf("by_source = %+v", doc.BySource) + } +} + +func TestAPIV1RequiresAuthentication(t *testing.T) { + g, _, _ := v1Gateway(t) + for _, path := range []string{"/api/v1", "/api/v1/overview", "/api/v1/sources", "/api/v1/health", "/api/v1/models"} { + req := newRequest(http.MethodGet, path, "") + rec := serveViaHandler(t, g, req) + if rec.Code != http.StatusUnauthorized { + t.Errorf("%s without a key = %d, want 401", path, rec.Code) + } + } +} + +func TestMaskKeyHidesTheSecret(t *testing.T) { + cases := map[string]string{ + "": "", + "ab": "ab…", + "sk-gw-abcdefghijkl": "sk-gw-…ijkl", + "enc:v1:AAAA": "(sealed)", + } + for in, want := range cases { + if got := maskKey(in); got != want { + t.Errorf("maskKey(%q) = %q, want %q", in, got, want) + } + } + // The masked form must not reveal the middle of the secret. + full := maskKey("sk-gw-1234567890abcdef") + if strings.Contains(full, "4567890abcdef") { + t.Errorf("maskKey leaked the middle: %q", full) + } +} + +// newRequest builds an unauthenticated request. +func newRequest(method, path, body string) *http.Request { + req, _ := http.NewRequest(method, path, strings.NewReader(body)) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + return req +} + +// newAuthedRequest builds a request carrying the given bearer key. +func newAuthedRequest(t *testing.T, method, path, key string) *http.Request { + t.Helper() + req := newRequest(method, path, "") + req.Header.Set("Authorization", "Bearer "+key) + return req +} + +// serveViaHandler pushes a request through the full handler chain (auth included). +func serveViaHandler(t *testing.T, g *Gateway, req *http.Request) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + g.Handler().ServeHTTP(rec, req) + return rec +} diff --git a/internal/gateway/server.go b/internal/gateway/server.go index 4f6dec0..4edc466 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -222,6 +222,8 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) { g.handleModels(w, r) case r.URL.Path == "/api/adapters" || strings.HasPrefix(r.URL.Path, "/api/adapters/"): g.handleAdaptersAPI(w, r) + case r.URL.Path == "/api/v1" || strings.HasPrefix(r.URL.Path, "/api/v1/"): + g.apiV1Routes(w, r) case r.URL.Path == "/api/sources" || strings.HasPrefix(r.URL.Path, "/api/sources/"): g.handleSourcesAPI(w, r) case r.URL.Path == "/api/source_templates" || strings.HasPrefix(r.URL.Path, "/api/source_templates/"):