mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
feat: 密钥静态加密 + /api/v1 agent 管理 API
密钥加密(写侧封存 / 读侧解封)
- config.yaml 的 sources[].api_key、sources[].headers、keys[].key 落盘即
AES-256-GCM 密文(enc:v1: 前缀),master.key 复用 runtime store 那把
- 内存里永远是明文:鉴权比对、API 返回新建 key、WebUI 编辑回填都不受影响
- 启动时一次性封存现存明文(幂等,已封存则不写盘);-check 不写文件
- UpsertSourceInYAML 增加 box 参数,新加的源不再以明文落盘
- 解密失败改为硬错误:原先 MustDecrypt 返回密文会被下次 Save 二次封存
(实测:源 key 18→20、静默损坏),现在启动即失败且配置分毫不动
/api/v1:面向 agent 的管理 API(WebUI 零影响)
- GET /api/v1 机器可读索引,列出每个端点的方法/权限/用途
- GET /api/v1/overview 一次调用看全貌:源 + AUTO 链 + 密钥数 + 健康度
- GET /api/v1/health 仅健康快照
- GET /api/v1/models 按源分组的可路由模型清单
- GET /api/v1/sources[/{name}] 凭据遮蔽后的源
- GET /api/v1/auto 调度链与实时槽位状态
- GET /api/v1/keys admin only,密钥元数据,绝不回显密钥本身
- 沿用同一套网关 key 鉴权;读端点任意角色,写仍需 admin
测试:15 个新用例(含负向:泄密、越权、写操作必须被拒)
变异验证:maskKey 不遮蔽→红、去掉 admin 校验→红
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -27,6 +27,11 @@ type Config struct {
|
|||||||
Auto []ModelScope `yaml:"auto,omitempty"` // AUTO 调度链规则(WebUI 优先级页编辑,chat)
|
Auto []ModelScope `yaml:"auto,omitempty"` // AUTO 调度链规则(WebUI 优先级页编辑,chat)
|
||||||
AutoImage []ModelScope `yaml:"auto_image,omitempty"` // AUTO 生图调度链规则(WebUI 优先级页·生图)
|
AutoImage []ModelScope `yaml:"auto_image,omitempty"` // AUTO 生图调度链规则(WebUI 优先级页·生图)
|
||||||
Keys []GWKey `yaml:"keys,omitempty"` // 网关密钥(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.
|
// 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.
|
// 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.
|
// 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 == "" {
|
if path == "" {
|
||||||
return fmt.Errorf("config path is empty")
|
return fmt.Errorf("config path is empty")
|
||||||
}
|
}
|
||||||
|
if err := sealSource(&src, box); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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
|
// Save writes the current config back to the YAML file (preserving comments
|
||||||
// via yaml.Node round-trip when possible, or full marshaling as fallback).
|
// 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 {
|
func (c *Config) Save() error {
|
||||||
if c.Path == "" {
|
if c.Path == "" {
|
||||||
return fmt.Errorf("config path is empty")
|
return fmt.Errorf("config path is empty")
|
||||||
}
|
}
|
||||||
|
if err := c.sealInPlace(c.box); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
out, err := yaml.Marshal(c)
|
out, err := yaml.Marshal(c)
|
||||||
|
c.unsealAfterWrite(c.box)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("marshal config: %w", err)
|
return fmt.Errorf("marshal config: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
227
internal/config/secret_config.go
Normal file
227
internal/config/secret_config.go
Normal file
@ -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
|
||||||
|
}
|
||||||
283
internal/config/secret_config_test.go
Normal file
283
internal/config/secret_config_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
@ -53,6 +54,10 @@ func NewFromConfig(cfg *config.Config) (*Core, error) {
|
|||||||
return nil, fmt.Errorf("lua vm: %w", err)
|
return nil, fmt.Errorf("lua vm: %w", err)
|
||||||
}
|
}
|
||||||
c.store = config.NewStore(cfg.RuntimeFile)
|
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 {
|
if err := c.store.Load(); err != nil {
|
||||||
return nil, fmt.Errorf("runtime store: %w", err)
|
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 {
|
if err := c.seedPresetTemplates(); err != nil {
|
||||||
return nil, fmt.Errorf("seed preset templates: %w", err)
|
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
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -439,6 +450,11 @@ func (c *Core) mergedSources() []config.Source {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// resolveSourceKey applies api_key_env and decrypts enc:v1: ciphertext.
|
// 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 {
|
func (c *Core) resolveSourceKey(s config.Source) config.Source {
|
||||||
if s.APIKeyEnv != "" {
|
if s.APIKeyEnv != "" {
|
||||||
if v := os.Getenv(s.APIKeyEnv); v != "" {
|
if v := os.Getenv(s.APIKeyEnv); v != "" {
|
||||||
@ -447,7 +463,11 @@ func (c *Core) resolveSourceKey(s config.Source) config.Source {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
if box := c.store.SecretBox(); box != nil && strings.HasPrefix(s.APIKey, "enc:v1:") {
|
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
|
return s
|
||||||
}
|
}
|
||||||
@ -655,7 +675,7 @@ func (c *Core) AddSource(src config.Source) error {
|
|||||||
if err := normalizeSource(&src); err != nil {
|
if err := normalizeSource(&src); err != nil {
|
||||||
return err
|
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
|
return err
|
||||||
}
|
}
|
||||||
// Update in-memory Sources so mergedSources() finds the entry.
|
// Update in-memory Sources so mergedSources() finds the entry.
|
||||||
|
|||||||
399
internal/gateway/apiv1.go
Normal file
399
internal/gateway/apiv1.go
Normal file
@ -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 <key>', '?api_key=<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\": <code>, \"message\": <text> } }",
|
||||||
|
"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 \"<source>:<model>\" 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")
|
||||||
273
internal/gateway/apiv1_test.go
Normal file
273
internal/gateway/apiv1_test.go
Normal file
@ -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
|
||||||
|
}
|
||||||
@ -222,6 +222,8 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) {
|
|||||||
g.handleModels(w, r)
|
g.handleModels(w, r)
|
||||||
case r.URL.Path == "/api/adapters" || strings.HasPrefix(r.URL.Path, "/api/adapters/"):
|
case r.URL.Path == "/api/adapters" || strings.HasPrefix(r.URL.Path, "/api/adapters/"):
|
||||||
g.handleAdaptersAPI(w, r)
|
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/"):
|
case r.URL.Path == "/api/sources" || strings.HasPrefix(r.URL.Path, "/api/sources/"):
|
||||||
g.handleSourcesAPI(w, r)
|
g.handleSourcesAPI(w, r)
|
||||||
case r.URL.Path == "/api/source_templates" || strings.HasPrefix(r.URL.Path, "/api/source_templates/"):
|
case r.URL.Path == "/api/source_templates" || strings.HasPrefix(r.URL.Path, "/api/source_templates/"):
|
||||||
|
|||||||
Reference in New Issue
Block a user