Compare commits

...

6 Commits

Author SHA1 Message Date
980f4a0e40 fix(gui): 缓存解封结果,否则每个请求都要 spawn 一个进程
The auth rule calls readAdminKey() on every outbound request so injection
never depends on ordering. Once the sealed-config path shells out to the
core, that turns each request into a process spawn: 200 simulated requests
took 1012ms and launched 200 cores.

Cache the unsealed key against config.yaml's mtime. Editing the config still
invalidates it, which is what the auth rule actually needs -- the port
rewrite, the first write, and a user edit all change mtime. Measured: 200
requests now cost 10ms and one spawn.

Only a successful unseal is cached. Caching a failure would pin an empty key
until the config next changes, turning a momentary spawn error into a locked
out user.
2026-10-01 19:23:32 +08:00
429afce67e fix(gui): 桌面版被自己的密钥封存挡住登录
The desktop build authenticates the embedded core by reading the admin key
out of config.yaml with a regex and injecting it as a gw_key cookie. The core
seals credentials at rest (enc:v1:...), so from the second start onward that
regex yields ciphertext, the cookie is worthless, and the app asks the user for
a key they never set. The key is generated and hidden by the app itself.

Reproduced end to end: first start writes a plaintext profile, the core seals
it, every later start reads back "enc:v1:..." and falls through to the login
prompt.

- when the stored value is sealed, ask the core to unseal it via
  -show-secrets, which only reads, prints and exits. Reimplementing the core's
  AEAD in JS would be a second source of truth for its key format.
- cwd must be the profile dir. The core locates master.key relative to the
  config's runtime_file, so a call made from anywhere else has it generate a
  second master key in the CWD and then fail to decrypt ("master key changed?").
  Electron's CWD is not the profile dir, so without this the desktop build
  cannot read its own key even after unsealing is wired up.
- the loose regex is kept as a fallback so a future change to the -show-secrets
  output degrades to a login prompt rather than to a wrong credential.

Verified: plaintext start -> core seals -> restart recovers the same key, with
the core running the whole time. Dropping cwd:PROFILE_DIR makes the unseal fail
and leaves a stray master.key in the CWD, so the cwd argument is load-bearing
rather than tidiness.
2026-10-01 19:19:40 +08:00
5639bb662c chore(version): 1.7.5 -> 1.7.6
PUT /api/sources/{name} 未实现却见于文档(返回 405),以及 POST upsert
会用占位符/空值覆盖真实 api_key —— 写入返回 200,源却在之后一直 401。
2026-10-01 18:29:11 +08:00
bf0657bb84 fix(sources): implement PUT and stop partial edits from clobbering api_key
Two defects on the admin source write path, both found while adding a model
to a live source by hand.

PUT /api/sources/{name} was advertised in the API index but never
implemented — handleSourcesAPI only switched on GET/POST/DELETE, so the
documented update verb answered 405 while the POST upsert behind it worked.

POST is an upsert that replaces the whole source, so a partial edit that did
not carry api_key persisted an empty or placeholder credential. The source
kept its name, base_url and models, the write returned 200, and the source
then answered 401 on the next request — long after the writing script exited
0. The WebUI had been routing around this by loading the real key through
?reveal=credentials; any script or partial update went straight into it.

- implement PUT, taking the name from the path and rejecting a body name
  that disagrees rather than silently resolving to one of them
- inherit the stored credential when api_key is omitted or sent as the
  literal "__KEEP__"; an explicit new key still rotates
- an empty api_key on a source that does not exist yet stays empty, since
  credential-less local upstreams are legitimate
- add model_ids, an additive shorthand, so "add these models" never has to
  read and echo the existing list back
- align the API index with the implementation

The model_ids merge had a first cut that dropped the existing list when the
request carried no models field; TestSourceModelIDsIsAdditive caught it.

Verified by mutation: removing PUT turns three tests red, flattening
resolveAPIKey into a pass-through turns TestSourceUpsertKeepsAPIKey red
on both subtests, and making model_ids replace instead of merge turns
TestSourceModelIDsIsAdditive red.
2026-10-01 18:15:29 +08:00
504c5ac9a0 chore(version): 1.7.4 -> 1.7.5
启动密钥警告误报的修复。
2026-09-28 23:42:48 +08:00
70f1c879bd fix(startup): 密钥警告改读真实生效的 key 集合
启动时那条「gateway_keys is EMPTY — without a key every request is rejected」
读的是 legacy 的 cfg.GatewayKeys 段,而鉴权实际用 cfg.Keys(core.ListKeys)。
seedKeys 首次启动把 gateway_keys 搬进 keys[] 之后,YAML 里那个列表就不再
被鉴权使用。于是在它被清空(例如轮换掉 starter key 之后)而 keys[] 仍有
7 把可用 key(含 admin)时,进程每次启动都谎报「所有请求都会被拒绝」。

实测:生产日志出现该警告,而同一个 key 请求 /v1/models 返回 200。

- main.go 改为检查 c.ListKeys(),文案改成不绑定字段名。
- 顺带删掉 gateway.New 的 gatewayKeys 参数:函数体从未使用它,
  只读 ListKeys(),留着会继续诱导人以为鉴权来自那个列表。

判据:e2e/TestStartupWarningReflectsRealKeysNotLegacyList —— 构造
「gateway_keys 空 + keys[] 有 key」的真实形态,先断言该 key 确实能鉴权,
再断言日志里不再出现那句谎报。变异验证:回退成 GatewayKeys() 即变红。
2026-09-28 23:42:48 +08:00
12 changed files with 620 additions and 38 deletions

View File

@ -17,7 +17,7 @@ const fs = require("fs");
const http = require("http");
const os = require("os");
const crypto = require("crypto");
const { spawn } = require("child_process");
const { spawn, execFileSync } = require("child_process");
const LOG_FILE = path.join(app.getPath("userData"), "gui.log");
function log(msg) {
@ -97,24 +97,99 @@ function readAdminKey() {
// gateway_keys entries get marked seed:true by the core (-> "replace the
// initial key" warning in the web UI), while a plain keys entry authenticates
// without any seed warning.
const raw = readConfigRaw();
if (!raw) return "";
// keys:
// - key: sk-gw-...
const k = /^\s*keys:\s*\n(?:\s*-\s*key:\s*"?([^\s"#]+)"?[^\n]*\n)+/m.exec(
raw,
);
let v = k && k[1] ? k[1] : "";
if (!v) {
// legacy: gateway_keys list (pre-migration configs)
const g = /^\s*gateway_keys:\s*\n(?:\s*-\s*"?([^\s"#]+)"?\s*\n)+/m.exec(raw);
if (g && g[1]) v = g[1];
if (!v) {
const g2 = /gateway_keys:[^[\n]*\[\s*"?([^\s"\]]+)"?/m.exec(raw);
if (g2 && g2[1]) v = g2[1];
}
}
// The core seals credentials at rest (enc:v1:...), so from the second start
// onward the file holds ciphertext. Injecting that as a cookie is a
// guaranteed 401, which surfaces to the user as "enter your key" for a key
// they never set. Ask the core to unseal rather than reimplementing its
// crypto here. -show-secrets only reads, prints and exits: it starts nothing
// and writes nothing.
if (v && v.indexOf(SEALED_PREFIX) === 0) return unsealViaCore();
return v;
}
const SEALED_PREFIX = "enc:v1:";
function readConfigRaw() {
try {
if (!fs.existsSync(CONFIG_FILE)) return "";
const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
// keys:
// - key: sk-gw-...
const k = /^\s*keys:\s*\n(?:\s*-\s*key:\s*"?([^\s"#]+)"?[^\n]*\n)+/m.exec(
raw,
return fs.readFileSync(CONFIG_FILE, "utf-8");
} catch (e) {
return "";
}
}
// Returns the admin key in the clear, or "" if the core cannot be asked.
//
// cwd must be the directory that holds master.key. The core derives that path
// from the config's runtime_file, so a call made from any other directory has
// the core generate a *second* master key in the CWD and then fail to decrypt
// ("master key changed?"). Electron's CWD is not the profile dir, so this is
// not optional: without it the desktop build cannot read its own key back.
//
// readAdminKey runs on every outbound request, and this spawns a process, so
// the result is cached against the config's mtime. Editing the config (or the
// port rewrite, or the first write) changes the mtime and invalidates it, which
// keeps the "read live so ordering never matters" property the auth rule
// depends on.
function unsealViaCore() {
const st = safeStat(CONFIG_FILE);
const stamp = st ? st.mtimeMs : 0;
if (unsealCache && unsealCache.stamp === stamp) return unsealCache.key;
let key = "";
try {
const out = execFileSync(
CORE_EXE,
["-config", CONFIG_FILE, "-show-secrets"],
{
encoding: "utf-8",
timeout: 10000,
windowsHide: true,
cwd: PROFILE_DIR,
},
);
if (k && k[1]) return k[1];
// legacy: gateway_keys list (pre-migration configs)
const g = /^\s*gateway_keys:\s*\n(?:\s*-\s*"?([^\s"#]+)"?\s*\n)+/m.exec(
raw,
);
if (g && g[1]) return g[1];
const g2 = /gateway_keys:[^[\n]*\[\s*"?([^\s"\]]+)"?/m.exec(raw);
if (g2 && g2[1]) return g2[1];
} catch (e) {}
return "";
// -show-secrets prints one line per credential:
// key <name> role=<role> <value>
const admin = /^key\s+\S+\s+role=admin\s+(\S+)\s*$/m.exec(out);
if (admin && admin[1]) key = admin[1];
// Tolerate a field-order or spacing change rather than locking the user out.
if (!key) {
const loose = /role=admin\s+(\S+)/.exec(out);
if (loose && loose[1]) key = loose[1];
}
} catch (e) {
console.error("unseal via core failed:", e.message);
}
// Only cache a success. A transient failure must not pin an empty key until
// the config next changes, or a momentary spawn error locks the user out.
if (key) unsealCache = { stamp, key };
return key;
}
let unsealCache = null;
function safeStat(p) {
try {
return fs.statSync(p);
} catch (e) {
return null;
}
}
const embeddedBaseUrl = () =>

View File

@ -1,12 +1,12 @@
{
"name": "modelrouter-gui",
"version": "1.7.4",
"version": "1.7.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "modelrouter-gui",
"version": "1.7.4",
"version": "1.7.6",
"license": "Proprietary",
"devDependencies": {
"electron": "^33.0.0",
@ -582,7 +582,7 @@
}
},
"node_modules/@peculiar/webcrypto": {
"version": "1.7.4",
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz",
"integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==",
"dev": true,
@ -3335,7 +3335,7 @@
}
},
"node_modules/resedit": {
"version": "1.7.4",
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz",
"integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==",
"dev": true,

View File

@ -1,6 +1,6 @@
{
"name": "modelrouter-gui",
"version": "1.7.4",
"version": "1.7.6",
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
"author": "ModelRouter",
"main": "main.js",

View File

@ -73,19 +73,26 @@ func main() {
}
}
gw, err := gateway.New(c, c.GatewayKeys())
gw, err := gateway.New(c)
if err != nil {
log.Fatalf("[llmsproxy] gateway: %v", err)
}
// Ops hygiene: surface the two most common footguns instead of silently
// running with them.
if keys := c.GatewayKeys(); len(keys) == 0 {
log.Printf("[llmsproxy] WARNING: gateway_keys is EMPTY — without a key every request is rejected")
//
// Read the AUTHORITATIVE source. Auth uses core.ListKeys() (cfg.Keys), not
// the legacy cfg.GatewayKeys list: seedKeys copies gateway_keys into keys[]
// on first start and the YAML list is ignored for auth afterwards. Checking
// GatewayKeys() here made the warning lie — once the legacy list was empty
// (e.g. after rotating the starter key away) it reported "every request is
// rejected" while seven working keys, one of them admin, were in service.
if keys := c.ListKeys(); len(keys) == 0 {
log.Printf("[llmsproxy] WARNING: no gateway key configured — every request will be rejected. Add one in the WebUI or set gateway_keys")
} else {
for _, k := range keys {
if k == "sk-gw-local-0001" || k == "sk-local-0001" {
log.Printf("[llmsproxy] WARNING: gateway key %q looks like the starter/example key — rotate it before exposing the gateway", k)
if k.Key == "sk-gw-local-0001" || k.Key == "sk-local-0001" {
log.Printf("[llmsproxy] WARNING: gateway key %q looks like the starter/example key — rotate it before exposing the gateway", k.Key)
}
}
}

View File

@ -326,3 +326,144 @@ func statusOf(resp *http.Response) int {
}
return resp.StatusCode
}
// The startup warning about key configuration must describe the key set that
// AUTH actually uses (cfg.Keys, via core.ListKeys), not the legacy
// gateway_keys YAML list.
//
// It used to check GatewayKeys() only. seedKeys copies gateway_keys into keys[]
// on first start and the YAML list stops being consulted, so once that list was
// emptied — e.g. after rotating the starter key away — the process logged
// "every request will be rejected" while seven working keys, one of them admin,
// were in service. Observed in production: the warning appeared and that same
// key returned HTTP 200 on /v1/models.
//
// The fixture reproduces exactly that shape: gateway_keys EMPTY, with the
// working key present only in keys[].
func TestStartupWarningReflectsRealKeysNotLegacyList(t *testing.T) {
up := newMockUpstream(t)
dir := t.TempDir()
// Seed a real install once so keys[] gets populated from gateway_keys, then
// rewrite the file to look like a rotated install: legacy list cleared, the
// seeded key still present in keys[].
path := writeConfig(t, dir, "127.0.0.1:0", map[string]*mockUpstream{"good": up})
bin := buildBinary(t)
// First run creates the sealed config with keys[] populated.
seedPort := freePort(t)
cfg1 := writeConfig(t, dir, "127.0.0.1:"+seedPort, map[string]*mockUpstream{"good": up})
g1 := startGateway(t, bin, "127.0.0.1:"+seedPort, cfg1)
g1.stop()
data, err := os.ReadFile(cfg1)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "keys:") {
t.Fatalf("first run did not populate keys[]:\n%s", data)
}
// Now clear the legacy gateway_keys list, keeping keys[] intact. The
// rewritten file may use different indentation, so match loosely.
cleared, n := clearGatewayKeys(string(data))
if n == 0 {
t.Fatalf("could not find a gateway_keys entry to clear in:\n%s", data)
}
if err := os.WriteFile(cfg1, []byte(cleared), 0o644); err != nil {
t.Fatal(err)
}
// Start again and inspect the log. The config pins its listen address, so
// rewrite that field to the port we will actually poll.
port2 := freePort(t)
rehosted, ok := replaceListen(string(cleared), "127.0.0.1:"+port2)
if !ok {
t.Fatalf("could not rewrite listen in:\n%s", cleared)
}
cfg2 := filepath.Join(dir, "config2.yaml")
if err := os.WriteFile(cfg2, []byte(rehosted), 0o644); err != nil {
t.Fatal(err)
}
g2 := startGateway(t, bin, "127.0.0.1:"+port2, cfg2)
defer g2.stop()
// Sanity: the key really does authenticate, so the warning would be a lie.
resp, _ := g2.do("GET", "/v1/models", "", true)
if resp == nil || resp.StatusCode != http.StatusOK {
t.Fatalf("key from keys[] did not authenticate (status %v); test fixture is wrong",
resp)
}
logs := g2.logString()
if strings.Contains(logs, "gateway_keys is EMPTY") {
t.Errorf("startup logged 'gateway_keys is EMPTY ... every request is rejected' while "+
"a working admin key was in service — the warning must read the authoritative "+
"key set (core.ListKeys), not the legacy YAML list.\nlog:\n%s", logs)
}
_ = path
_ = g1
}
func freePort(t *testing.T) string {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer l.Close()
_, port, _ := net.SplitHostPort(l.Addr().String())
return port
}
// stop terminates the gateway and waits, so its log buffer is complete.
func (g *gatewayUnderTest) stop() {
if g == nil || g.cmd == nil || g.cmd.Process == nil {
return
}
_ = g.cmd.Process.Kill()
_, _ = g.cmd.Process.Wait()
}
// logString returns everything the gateway has written so far.
func (g *gatewayUnderTest) logString() string { return g.log.String() }
// clearGatewayKeys empties the gateway_keys list in a config, tolerating any
// indentation the YAML writer chose. Returns the new text and how many entries
// it removed.
func clearGatewayKeys(src string) (string, int) {
lines := strings.Split(src, "\n")
var out []string
removed := 0
inList := false
for _, l := range lines {
trimmed := strings.TrimSpace(l)
if strings.HasPrefix(l, "gateway_keys:") {
out = append(out, "gateway_keys: []")
inList = true
removed++
continue
}
if inList {
if strings.HasPrefix(trimmed, "- ") {
removed++
continue
}
inList = false
}
out = append(out, l)
}
return strings.Join(out, "\n"), removed
}
// replaceListen rewrites the top-level listen: value.
func replaceListen(src, addr string) (string, bool) {
lines := strings.Split(src, "\n")
for i, l := range lines {
if strings.HasPrefix(l, "listen:") {
lines[i] = "listen: " + addr
return strings.Join(lines, "\n"), true
}
}
return src, false
}

View File

@ -78,6 +78,62 @@ type sourcePayload struct {
MaxTokens int `json:"max_tokens"`
MaxConcurrent int `json:"max_concurrent"`
RPM int `json:"rpm"` // optional requests-per-minute cap, 0 = unlimited
// ModelIDs is an additive-only shorthand: when set, the named models are
// merged into an existing source instead of Models replacing the list.
// It exists because "add one model" is the most common scripted edit and a
// full Models list cannot be written without reading the source first.
ModelIDs []string `json:"model_ids,omitempty"`
}
// keepExistingAPIKey is the mask a client sends when it means "keep the
// current credential". POST /api/sources is an upsert, so a script that edits
// one field and echoes a placeholder back would otherwise persist that
// placeholder as the live api_key — which fails closed as 401 on the next
// request, long after the script exited successfully. See TestSourceUpsertKeepsAPIKey.
const keepExistingAPIKey = "__KEEP__"
// resolveAPIKey returns the credential to store, inheriting the current one
// when the payload does not supply a new one.
//
// Two distinct "no new credential" signals exist and they must not collapse:
// - the explicit mask keepExistingAPIKey — always inherit.
// - the empty string — inherit only for a source that already has a
// credential. Empty is a legitimate value for a *new* credential-less
// source (dshcodebuddy uses "dsh-local-noauth", local servers use ""),
// so a fresh source with an empty key must stay empty rather than inherit
// nothing and fail differently.
func (g *Gateway) resolveAPIKey(name, want string, exists bool) string {
if want == keepExistingAPIKey {
return g.currentAPIKey(name)
}
if exists && want == "" {
return g.currentAPIKey(name)
}
return want
}
func (g *Gateway) currentAPIKey(name string) string {
if s := g.sourceByName(name); s != nil {
return s.APIKey
}
return ""
}
// sourceByName returns a live view of one source, or nil when it does not
// exist. It goes through core.Sources() so the key is already unsealed and
// defaults applied, matching what the routes actually use.
func (g *Gateway) sourceByName(name string) *config.Source {
for _, s := range g.core.Sources() {
if s.Name == name {
ss := s
return &ss
}
}
return nil
}
func (g *Gateway) sourceExists(name string) bool {
return g.sourceByName(name) != nil
}
func (g *Gateway) handleSourcesAPI(w http.ResponseWriter, r *http.Request) {
@ -91,17 +147,34 @@ func (g *Gateway) handleSourcesAPI(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, map[string]interface{}{"sources": g.core.Sources()})
case http.MethodPost:
case http.MethodPost, http.MethodPut:
var p sourcePayload
body, _ := io.ReadAll(r.Body)
if err := json.Unmarshal(body, &p); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
// PUT addresses the source by path; the body name is only a fallback so
// a single-field edit does not have to echo the name back. A body that
// disagrees with the path is a caller bug, not something to silently
// resolve — it usually means the wrong source is about to be written.
if r.Method == http.MethodPut {
if path == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "source name required in path")
return
}
if p.Name != "" && p.Name != path {
writeError(w, http.StatusBadRequest, "invalid_request",
"source name in body ("+p.Name+") does not match path ("+path+")")
return
}
p.Name = path
}
exists := g.sourceExists(p.Name)
src := config.Source{
Name: p.Name,
BaseURL: p.BaseURL,
APIKey: p.APIKey,
APIKey: g.resolveAPIKey(p.Name, p.APIKey, exists),
Adapter: p.Adapter,
Endpoint: p.Endpoint,
ImageEndpoint: p.ImageEndpoint,
@ -113,6 +186,29 @@ func (g *Gateway) handleSourcesAPI(w http.ResponseWriter, r *http.Request) {
MaxConcurrent: p.MaxConcurrent,
RPM: p.RPM,
}
// model_ids is additive: "add these models" is the common scripted edit
// and it must not require reading (and echoing) the whole list back.
// A request that omits models entirely is therefore a pure add, not a
// request to empty the list -- that distinction is the whole point.
if len(p.ModelIDs) > 0 {
if len(src.Models) == 0 {
if existing := g.sourceByName(p.Name); existing != nil {
src.Models = append(src.Models, existing.Models...)
}
}
have := map[string]bool{}
for _, m := range src.Models {
have[m.ID] = true
}
for _, id := range p.ModelIDs {
id = strings.TrimSpace(id)
if id == "" || have[id] {
continue
}
have[id] = true
src.Models = append(src.Models, config.Model{ID: id, Kind: "chat"})
}
}
if err := g.core.AddSource(src); err != nil {
writeError(w, http.StatusBadRequest, "source_error", err.Error())
return

View File

@ -165,7 +165,7 @@ func (g *Gateway) apiV1Index(w http.ResponseWriter, r *http.Request) {
{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"},
WriteEffect: "writes config.yaml (api_key sealed at rest)"},
{Method: "DELETE", Path: "/api/sources/{name}", Auth: "admin", Summary: "delete a source",
WriteEffect: "writes config.yaml"},
@ -187,9 +187,13 @@ func (g *Gateway) apiV1Index(w http.ResponseWriter, r *http.Request) {
{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",
"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; " +
"PUT takes the name from the path and rejects a body name that disagrees",
"partial_update": "api_key may be omitted or sent as the literal \"__KEEP__\" to inherit the " +
"current credential; model_ids adds models to the existing list instead of replacing it, " +
"so a one-field edit never needs to read the source first",
"config_truth": "all configuration lives in config.yaml; API writes are persisted immediately",
"credentials": "credentials are masked by default. GET /api/v1/sources/{name}?reveal=credentials " +
"returns them in the clear and is admin-only — the Web UI edit dialog uses it, because a form " +

View File

@ -58,7 +58,7 @@ func newTestGateway(t *testing.T, srcs ...config.Source) *Gateway {
t.Fatalf("core: %v", err)
}
t.Cleanup(c.Close)
g, err := New(c, []string{"sk-test"})
g, err := New(c)
if err != nil {
t.Fatalf("gateway: %v", err)
}

View File

@ -35,7 +35,7 @@ func adminGateway(t *testing.T, keys ...config.GWKey) *Gateway {
t.Fatalf("core: %v", err)
}
t.Cleanup(c.Close)
g, err := New(c, []string{"sk-admin"})
g, err := New(c)
if err != nil {
t.Fatalf("gateway: %v", err)
}

View File

@ -47,7 +47,7 @@ func quotaGateway(t *testing.T, keyA, keyB config.GWKey) (*Gateway, *upstreamCtr
t.Fatalf("core: %v", err)
}
t.Cleanup(c.Close)
g, err := New(c, []string{keyA.Key, keyB.Key})
g, err := New(c)
if err != nil {
t.Fatalf("gateway: %v", err)
}
@ -323,7 +323,7 @@ func newQuotaGW(t *testing.T, upURL string, keys ...config.GWKey) *Gateway {
for _, k := range keys {
secrets = append(secrets, k.Key)
}
g, err := New(c, secrets)
g, err := New(c)
if err != nil {
t.Fatalf("gateway: %v", err)
}
@ -368,7 +368,7 @@ func TestOneModelsQuotaDoesNotBlockAnother(t *testing.T) {
t.Fatalf("core: %v", err)
}
t.Cleanup(c.Close)
g, err := New(c, []string{"sk-a"})
g, err := New(c)
if err != nil {
t.Fatalf("gateway: %v", err)
}

View File

@ -115,7 +115,7 @@ func (g *Gateway) loginRecord(ip string, ok bool, now int64) {
}
}
func New(c *core.Core, gatewayKeys []string) (*Gateway, error) {
func New(c *core.Core) (*Gateway, error) {
sub, err := fs.Sub(uiFS, "ui")
if err != nil {
return nil, err

View File

@ -0,0 +1,259 @@
package gateway
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"llmsproxy/internal/config"
)
// These cover the two write-path defects found while adding a model to a live
// source by hand:
//
// 1. PUT /api/sources/{name} was documented in the API index but not
// implemented, so it answered 405 while POST (which does the same upsert)
// worked. A caller following the documented contract could not edit a
// source at all.
// 2. POST is an upsert that replaces the whole source, so a partial edit that
// did not carry api_key persisted an empty/placeholder credential. The
// source kept its name and models and answered 401 on the next call, well
// after the writing script had exited 0.
//
// The second one is the dangerous class: a write that reports success and
// breaks the source later.
// sourceAPIKey reads the stored key back the way an operator would, i.e. what
// the routes actually use, not what the file happens to contain.
func sourceAPIKey(t *testing.T, g *Gateway, name string) string {
t.Helper()
for _, s := range g.core.Sources() {
if s.Name == name {
return s.APIKey
}
}
t.Fatalf("source %q not found", name)
return ""
}
func seededGateway(t *testing.T) (*Gateway, string) {
t.Helper()
up := mockUpstream()
t.Cleanup(up.Close)
g := newTestGateway(t, config.Source{
Name: "seeded",
BaseURL: up.URL,
Adapter: "openai",
APIKey: "sk-real-credential",
Models: []config.Model{{ID: "m-one", Kind: "chat"}},
})
return g, up.URL
}
func TestSourcePutUpdatesSource(t *testing.T) {
g, up := seededGateway(t)
// A single-field edit: change one model's priority, name the source only in
// the path. This is the shape the API index documents for PUT.
rr := doReq(t, g, "PUT", "/api/sources/seeded", fmt.Sprintf(
`{"base_url":%q,"adapter":"openai","api_key":"sk-real-credential","models":[{"id":"m-one","priority":42,"kind":"chat"}]}`, up))
if rr.Code != http.StatusOK {
t.Fatalf("PUT status=%d body=%s", rr.Code, rr.Body.String())
}
var found bool
for _, s := range g.core.Sources() {
if s.Name != "seeded" {
continue
}
found = true
for _, m := range s.Models {
if m.ID == "m-one" && m.Priority != 42 {
t.Fatalf("priority not applied: %+v", s.Models)
}
}
}
if !found {
t.Fatal("source vanished after PUT")
}
// And it must be routable, not merely stored.
if rr := doReq(t, g, "GET", "/v1/models", ""); rr.Code != http.StatusOK ||
!strings.Contains(rr.Body.String(), "m-one") {
t.Fatalf("model not routable after PUT: %d %s", rr.Code, rr.Body.String())
}
}
func TestSourcePutRejectsNameMismatch(t *testing.T) {
g, _ := seededGateway(t)
// Body names a different source than the path. Silently preferring either
// one would let a client edit a source it did not name.
rr := doReq(t, g, "PUT", "/api/sources/seeded", `{"name":"other","base_url":"http://x","models":[{"id":"m"}]}`)
if rr.Code != http.StatusBadRequest {
t.Fatalf("name mismatch accepted: status=%d body=%s", rr.Code, rr.Body.String())
}
if sourceAPIKey(t, g, "seeded") != "sk-real-credential" {
t.Fatal("rejected PUT still mutated the source")
}
}
func TestSourcePutRequiresNameInPath(t *testing.T) {
g, _ := seededGateway(t)
rr := doReq(t, g, "PUT", "/api/sources", `{"base_url":"http://x","models":[{"id":"m"}]}`)
if rr.Code != http.StatusBadRequest {
t.Fatalf("PUT without a name in the path: status=%d body=%s", rr.Code, rr.Body.String())
}
}
func TestSourceUpsertKeepsAPIKey(t *testing.T) {
for _, tc := range []struct {
name string
sentKey string
}{
{"explicit mask", keepExistingAPIKey},
{"omitted key", ""},
} {
t.Run(tc.name, func(t *testing.T) {
g, up := seededGateway(t)
rr := doReq(t, g, "PUT", "/api/sources/seeded", fmt.Sprintf(
`{"base_url":%q,"adapter":"openai","api_key":%q,"models":[{"id":"m-one"},{"id":"m-two"}]}`,
up, tc.sentKey))
if rr.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
if got := sourceAPIKey(t, g, "seeded"); got != "sk-real-credential" {
t.Fatalf("api_key was clobbered: got %q, want the pre-existing credential", got)
}
// The failure this guards is a source that looks configured but
// cannot authenticate, so assert the route still works.
if rr := doReq(t, g, "GET", "/v1/models", ""); rr.Code != http.StatusOK {
t.Fatalf("source unusable after edit: %d %s", rr.Code, rr.Body.String())
}
})
}
}
func TestSourceUpsertStillWritesNewKey(t *testing.T) {
// The keep-existing path must not swallow a deliberate rotation.
g, up := seededGateway(t)
rr := doReq(t, g, "PUT", "/api/sources/seeded", fmt.Sprintf(
`{"base_url":%q,"adapter":"openai","api_key":"sk-rotated","models":[{"id":"m-one"}]}`, up))
if rr.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
if got := sourceAPIKey(t, g, "seeded"); got != "sk-rotated" {
t.Fatalf("rotation ignored: got %q", got)
}
}
func TestSourceUpsertNewSourceMayHaveNoKey(t *testing.T) {
// Empty is a real value for a credential-less source (local upstreams), so
// inherit-on-empty must not invent one for a source that does not exist yet.
up := mockUpstream()
defer up.Close()
g := newTestGateway(t)
rr := doReq(t, g, "POST", "/api/sources", fmt.Sprintf(
`{"name":"noauth","base_url":%q,"adapter":"openai","models":[{"id":"local-m"}]}`, up.URL))
if rr.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
if got := sourceAPIKey(t, g, "noauth"); got != "" {
t.Fatalf("unexpected key on a new credential-less source: %q", got)
}
}
func TestSourceModelIDsIsAdditive(t *testing.T) {
// "Add one model" is the edit that motivated the defect: it needs no read
// of the existing list, so it must not require one.
g, up := seededGateway(t)
rr := doReq(t, g, "PUT", "/api/sources/seeded", fmt.Sprintf(
`{"base_url":%q,"adapter":"openai","api_key":%q,"model_ids":["m-two","m-two"," m-three "]}`, up, keepExistingAPIKey))
if rr.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
var ids []string
for _, s := range g.core.Sources() {
if s.Name == "seeded" {
for _, m := range s.Models {
ids = append(ids, m.ID)
}
}
}
// m-one kept, m-two not duplicated, blank entries dropped, whitespace trimmed.
want := map[string]int{"m-one": 1, "m-two": 1, "m-three": 1}
if len(ids) != len(want) {
t.Fatalf("models=%v, want exactly %v", ids, want)
}
seen := map[string]int{}
for _, id := range ids {
seen[id]++
}
for id, n := range want {
if seen[id] != n {
t.Fatalf("models=%v: %q appears %d times, want %d", ids, id, seen[id], n)
}
}
}
func TestSourceWriteRejectsNonAdmin(t *testing.T) {
g, up := seededGateway(t)
for _, m := range []string{"POST", "PUT", "DELETE"} {
req, _ := http.NewRequest(m, "/api/sources/seeded", strings.NewReader(fmt.Sprintf(
`{"name":"seeded","base_url":%q,"models":[{"id":"m"}]}`, up)))
req.Header.Set("Authorization", "Bearer sk-not-a-real-key")
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
g.Handler().ServeHTTP(rr, req)
if rr.Code == http.StatusOK {
t.Fatalf("%s allowed for an unknown key: %s", m, rr.Body.String())
}
}
if sourceAPIKey(t, g, "seeded") != "sk-real-credential" {
t.Fatal("unauthenticated write mutated the source")
}
}
// TestSourceWritePersistsToDisk guards the difference between "the in-memory
// registry accepted it" and "it will survive a restart". The api_key must be
// sealed at rest, never the literal placeholder.
func TestSourceWritePersistsToDisk(t *testing.T) {
g, up := seededGateway(t)
rr := doReq(t, g, "PUT", "/api/sources/seeded", fmt.Sprintf(
`{"base_url":%q,"adapter":"openai","api_key":%q,"models":[{"id":"m-one"}]}`, up, keepExistingAPIKey))
if rr.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
raw, err := os.ReadFile(g.core.Config().Path)
if err != nil {
t.Fatalf("read config: %v", err)
}
text := string(raw)
if strings.Contains(text, keepExistingAPIKey) {
t.Fatal("the keep-mask was persisted as a literal api_key")
}
if strings.Contains(text, "sk-real-credential") {
t.Fatal("api_key stored in plaintext; it must be sealed at rest")
}
if !strings.Contains(text, "m-one") {
t.Fatal("source not persisted")
}
// The masked GET view must not leak the credential either.
rr = doReq(t, g, "GET", "/api/v1/sources/seeded", "")
if rr.Code != http.StatusOK {
t.Fatalf("get status=%d", rr.Code)
}
var view struct {
Source struct {
APIKey string `json:"api_key"`
} `json:"source"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &view); err != nil {
t.Fatalf("decode: %v", err)
}
if view.Source.APIKey == "sk-real-credential" {
t.Fatal("GET /api/v1/sources leaked the plaintext api_key")
}
}