Compare commits

...

10 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
04e544c823 chore(version): 1.7.3 -> 1.7.4
发行包不再内置可用 admin key 的修复,走 patch 发布。
2026-09-28 23:27:54 +08:00
7e33d11d15 fix(packaging): 发行包不再内置可用的 admin key
打包时把本地 config.yaml(gitignored,含运维真实密钥)原样复制成
config.example.yaml,而 postinst 在首次安装且 /etc 无配置时又把它
cp 成生产配置 ⇒ 每次安装都得到一个同值的、公开已知的 admin key。
实测该 key(sk-gw-local-0001)在生产上真实有效(/v1/models 返回 200,
而网关监听 0.0.0.0)。

三处修正:
- 新增 packaging/config.example.yaml(受 git 跟踪的净化模板),
  gateway_keys 留空、sources 留空,并写明不要填死值。
- core-dist.sh / nfpm.yaml 改为打包该模板,不再碰本地 config.yaml。
- postinst.sh 不再投递示例配置:留空文件会让网关启动但拒绝所有请求
  (无门可入)。改为让二进制首启时自行生成随机 admin key 并打印 ——
  每次安装都不同,且开箱可用。示例文件仅作为 /usr/share 下的参考保留。

实测首启:生成 sk-gw-838d66a1... 并打印,与旧的共享固定值不同。
2026-09-28 23:27:42 +08:00
cd82835f25 chore(version): 1.7.2 -> 1.7.3
启动重复播种 admin key 与配置封存非幂等的修复,走 patch 发布。
2026-09-28 22:53:19 +08:00
26ea782350 fix(core): 修复启动重复播种 admin key + 配置封存非幂等
根因是 unseal 时序:NewFromConfig 把解密放在最后,而之前几步已经在读凭据。

1. seedKeys 重复播种(生产已累积 4 个同名 admin key)
   seedKeys 用 cfg.Keys[i].Key 与明文 gateway_keys 比对去重,但此时内存里的
   key 还是密文 enc:v1:…,比对永不命中 ⇒ 每次重启追加一个同值 admin key。
   实测:core.New(path) 连续重启,seeded key 数 2→3→4 递增。
   (旧测试用 NewFromConfig 构造全新内存对象,没有「盘上已有密文」这个前提,
    复现不出 —— 必须走 core.New 这条读盘的生产路径。)

2. 启动恒重写 config.yaml
   migratePlaintextSecrets 按内存状态判断,而 Save() 末尾会把内存恢复为明文,
   于是每次调用都判定「还有明文」并重写;注释却自称幂等。
   改为 UnsealSecrets 在解密前记录「盘上是否明文」,SealIfNeeded 据此决定
   是否写回 ⇒ 已封存的配置启动不再落盘。

原测试 TestMigratePlaintextSecretsIsIdempotent 用 ModTime 比较,两次写落在同一
时间戳刻度内就看不出来,所以表现为 ~1/6 概率的 flake 而非稳定失败。已改为比较
文件内容并走真实启动路径(UnsealSecrets + SealIfNeeded),并顺带消除该 flake。

附带更正:先前判断「rebuildRegistry 也会拿到密文 API key」不成立 ——
mergedSources → resolveSourceKey 对每个 source 独立解密(belt-and-braces),
provider 始终拿到明文。unseal 前置仍予保留,以消除对该兜底路径的隐性依赖、
并让 seedKeys 在明文下比较。

判据:
- TestRestartDoesNotDuplicateSeededKeys(敏感:回退顺序必红)
- TestSealingIsIdempotentAcrossStarts(12/12 稳定,原先 1/6 flake)
- TestProvidersGetPlaintextCredentials(钉 provider 必须拿到明文这一不变量)
2026-09-28 22:52:51 +08:00
20 changed files with 976 additions and 65 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.2",
"version": "1.7.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "modelrouter-gui",
"version": "1.7.2",
"version": "1.7.6",
"license": "Proprietary",
"devDependencies": {
"electron": "^33.0.0",
@ -582,7 +582,7 @@
}
},
"node_modules/@peculiar/webcrypto": {
"version": "1.7.2",
"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.2",
"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.2",
"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

@ -193,11 +193,55 @@ func (c *Config) countPlaintextSecrets() int {
// 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 {
hadPlaintext, err := c.UnsealSecrets(box)
if err != nil {
return err
}
return c.migratePlaintextSecrets()
return c.SealIfNeeded(hadPlaintext)
}
// UnsealSecrets decrypts every sealed credential in memory and reports whether
// the config ON DISK still held plaintext (i.e. whether a sealing write is
// needed). It never writes; a failed decrypt (wrong master key) is returned so
// the process refuses to start instead of running with unusable credentials.
//
// The return value must be computed BEFORE unsealing and from the disk state,
// not from memory: after a Save the in-memory values are always plaintext, so a
// "is anything plaintext?" test run afterwards is unconditionally true and a
// caller would rewrite the file on every start. That was the actual behaviour -
// migratePlaintextSecrets() claimed to be idempotent in a comment but rewrote
// config.yaml on every boot.
//
// Callers that consume credentials (the provider registry, key seeding) must
// unseal FIRST. Seeding compares cfg.Keys[i].Key against the plaintext
// gateway_keys entries; running it while keys are still ciphertext made the
// dedupe never match, so every restart appended another copy of the same admin
// key (production accumulated four).
func (c *Config) UnsealSecrets(box *SecretBox) (bool, error) {
if box == nil {
return false, nil
}
c.AttachSecretBox(box)
hadPlaintext := c.hasPlaintextSecrets()
if err := c.normalizeSecrets(box); err != nil {
return hadPlaintext, err
}
return hadPlaintext, nil
}
// SealIfNeeded writes the config back once if hadPlaintext reported that the
// file still held clear-text credentials. When it is false the file is left
// untouched, which is what makes startup a no-op for an already-sealed config.
func (c *Config) SealIfNeeded(hadPlaintext bool) error {
if !hadPlaintext || c.box == nil || c.Path == "" {
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
}
// AttachSecretBox wires the encryption box into the config so Save can seal
@ -210,8 +254,9 @@ func (c *Config) AttachSecretBox(box *SecretBox) { c.box = box }
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.
// file once. Kept for callers that attach the box themselves; it decides from
// the in-memory state, which is why UnsealSecrets + SealIfNeeded (which decide
// from the on-disk state) are preferred on the startup path.
func (c *Config) migratePlaintextSecrets() error {
if c.box == nil || c.Path == "" {
return nil

View File

@ -119,7 +119,24 @@ sources:
}
}
func TestMigratePlaintextSecretsIsIdempotent(t *testing.T) {
// Sealing a config is a one-time migration: the FIRST run writes, every later
// start must leave the file alone.
//
// This test previously called migratePlaintextSecrets() twice and compared
// ModTime. That assertion was both weak and wrong:
//
// - migratePlaintextSecrets decides from IN-MEMORY state, and Save() ends by
// unsealing memory so the running process keeps working. So every call sees
// "plaintext present" and rewrites the file. Comparing ModTime hid this
// because two writes inside one filesystem timestamp tick look identical —
// the test flaked (~1 in 6) instead of failing.
// - it also exercised a function production no longer calls on the startup
// path, which now uses UnsealSecrets + SealIfNeeded (they decide from the
// ON-DISK state).
//
// So the test targets that real path and compares file CONTENT, which cannot be
// fooled by timestamp granularity.
func TestSealingIsIdempotentAcrossStarts(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
runtime := filepath.Join(dir, "runtime.json")
@ -132,29 +149,52 @@ func TestMigratePlaintextSecretsIsIdempotent(t *testing.T) {
}
cfg.AttachSecretBox(box)
if err := cfg.migratePlaintextSecrets(); err != nil {
t.Fatalf("first migrate: %v", err)
hadPlaintext, err := cfg.UnsealSecrets(box)
if err != nil {
t.Fatalf("unseal: %v", err)
}
if !hadPlaintext {
t.Fatal("a config written with a clear-text credential must report plaintext")
}
if err := cfg.SealIfNeeded(hadPlaintext); err != nil {
t.Fatalf("seal: %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")
t.Fatal("sealing did not encrypt the value on disk")
}
// 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)
// Simulate the next start: load from disk, unseal, seal-if-needed. The file
// was already sealed, so nothing may be written.
cfg2, err := Load(path)
if err != nil {
t.Fatal(err)
}
after, _ := os.Stat(path)
if before.ModTime() != after.ModTime() {
t.Error("second migrate rewrote an already-sealed config")
cfg2.AttachSecretBox(box)
hadPlaintext2, err := cfg2.UnsealSecrets(box)
if err != nil {
t.Fatalf("second unseal: %v", err)
}
if hadPlaintext2 {
t.Error("a sealed config reported plaintext — SealIfNeeded would rewrite " +
"the file on every start")
}
if err := cfg2.SealIfNeeded(hadPlaintext2); err != nil {
t.Fatalf("second seal: %v", err)
}
second, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(first) != string(second) {
t.Error("the second start rewrote an already-sealed config; startup must be a no-op")
}
}

View File

@ -56,8 +56,26 @@ func NewFromConfig(cfg *config.Config) (*Core, error) {
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.
// ciphertext (if it was sealed); unseal it NOW, before anything reads a
// credential.
//
// ORDER IS LOAD-BEARING. These steps each consume secrets and must run
// after the unseal:
// - seedKeys compares cfg.Keys[i].Key against the plaintext
// gateway_keys entries; with ciphertext keys the comparison never
// matched and every restart appended another duplicate admin key
// (production had four copies of the same admin key).
// - rebuildRegistry hands cfg.Sources[i].APIKey to the providers; with
// ciphertext it built every upstream client with "enc:v1:..." as its
// bearer token.
// Previously the unseal happened at the END of this function, and things
// only appeared to work because seedKeys' Save() unsealed memory as a side
// effect. Removing the redundant saves exposed the real ordering bug.
cfg.AttachSecretBox(c.store.SecretBox())
hadPlaintextSecrets, err := cfg.UnsealSecrets(c.store.SecretBox())
if err != nil {
return nil, fmt.Errorf("unseal secrets: %w", err)
}
if err := c.store.Load(); err != nil {
return nil, fmt.Errorf("runtime store: %w", err)
}
@ -79,10 +97,10 @@ 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 {
// Seal any credential still in the clear in config.yaml. Startup writes
// nothing when the file was already sealed (hadPlaintextSecrets is decided
// from the on-disk state, before the unseal above).
if err := cfg.SealIfNeeded(hadPlaintextSecrets); err != nil {
return nil, fmt.Errorf("normalize secrets: %w", err)
}
return c, nil

View File

@ -0,0 +1,178 @@
package core
import (
"os"
"path/filepath"
"strings"
"testing"
"llmsproxy/internal/config"
)
// Startup must be idempotent, and every credential consumer must see PLAINTEXT.
//
// Two bugs shared one root cause: NewFromConfig unsealed the config at the END,
// after the steps that read credentials had already run.
//
// 1. seedKeys() deduped cfg.Keys[i].Key against the plaintext gateway_keys
// entries. With ciphertext keys the comparison never matched, so every
// restart appended another copy of the same admin key — production reached
// four identical admin keys.
// 2. rebuildRegistry() handed cfg.Sources[i].APIKey to provider.New, so with
// ciphertext it built every upstream client with "enc:v1:..." as its bearer
// token and every upstream call would 401.
//
// They masked each other: seedKeys' Save() unsealed memory as a side effect, so
// bug 2 was invisible until the redundant saves were removed. These tests drive
// the real entry point (core.New -> config.Load off disk), because building a
// fresh in-memory config per attempt hides both bugs — that is exactly how the
// first draft of this test failed to reproduce anything.
// writeSealedInstall creates a config on disk the way a real install looks
// after its first run: credentials already sealed, gateway_keys still plaintext.
func writeSealedInstall(t *testing.T, dir string) string {
t.Helper()
path := filepath.Join(dir, "config.yaml")
cfg := &config.Config{
Path: path,
AdapterDir: filepath.Join(dir, "adapters"),
RuntimeFile: filepath.Join(dir, "runtime.json"),
Listen: "127.0.0.1:0",
DefaultModel: "AUTO",
GatewayKeys: []string{"sk-gw-seed-me"},
Sources: []config.Source{{
Name: "up", BaseURL: "http://127.0.0.1:1/v1", APIKey: "sk-upstream-secret",
Adapter: "openai", Models: []config.Model{{ID: "gpt-4o", Priority: 10}},
}},
}
c, err := NewFromConfig(cfg)
if err != nil {
t.Fatalf("initial install: %v", err)
}
c.Close()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), "enc:v1:") {
t.Fatalf("fixture is wrong: on-disk config holds no sealed credential, "+
"so this test cannot detect ciphertext leaking into consumers:\n%s", raw)
}
return path
}
// writeSealedInstallNoSeed is writeSealedInstall without gateway_keys, so no
// key is ever seeded and no startup Save() can unseal the config as a side
// effect. That isolation is what makes the ciphertext leak observable.
func writeSealedInstallNoSeed(t *testing.T, dir string) string {
t.Helper()
path := filepath.Join(dir, "config.yaml")
cfg := &config.Config{
Path: path,
AdapterDir: filepath.Join(dir, "adapters"),
RuntimeFile: filepath.Join(dir, "runtime.json"),
Listen: "127.0.0.1:0",
DefaultModel: "AUTO",
Sources: []config.Source{{
Name: "up", BaseURL: "http://127.0.0.1:1/v1", APIKey: "sk-upstream-secret",
Adapter: "openai", Models: []config.Model{{ID: "gpt-4o", Priority: 10}},
}},
}
c, err := NewFromConfig(cfg)
if err != nil {
t.Fatalf("initial install: %v", err)
}
c.Close()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), "enc:v1:") {
t.Fatalf("fixture is wrong: no sealed credential on disk:\n%s", raw)
}
return path
}
// countKeysWithSecret loads the config, unseals it, and counts keys whose
// plaintext equals one of the gateway_keys entries.
func countKeysWithSecret(t *testing.T, path string) (total, matching int) {
t.Helper()
cfg, err := config.Load(path)
if err != nil {
t.Fatal(err)
}
box, err := config.NewSecretBox(cfg.RuntimeFile)
if err != nil {
t.Fatal(err)
}
if _, err := cfg.UnsealSecrets(box); err != nil {
t.Fatal(err)
}
want := map[string]bool{}
for _, g := range cfg.GatewayKeys {
want[g] = true
}
for _, k := range cfg.Keys {
total++
if want[k.Key] {
matching++
}
}
return total, matching
}
// Restarting must not grow the key list. Before the fix this went 2 -> 3 -> 4.
func TestRestartDoesNotDuplicateSeededKeys(t *testing.T) {
dir := t.TempDir()
path := writeSealedInstall(t, dir)
if _, n := countKeysWithSecret(t, path); n != 1 {
t.Fatalf("after install: %d keys carry the seeded secret, want 1", n)
}
for i := 2; i <= 4; i++ {
c, err := New(path) // production path: reads the file
if err != nil {
t.Fatalf("restart %d: %v", i, err)
}
c.Close()
total, n := countKeysWithSecret(t, path)
if n != 1 {
t.Fatalf("restart %d: %d keys carry the seeded secret (total %d), want "+
"exactly 1 — seedKeys is comparing ciphertext against plaintext and "+
"appending a duplicate on every start", i, n, total)
}
}
}
// Every provider must hold the real upstream credential, not its ciphertext.
//
// This deliberately runs with gateway_keys EMPTY. The buggy order was masked
// whenever seedKeys had work to do, because its Save() unsealed memory as a
// side effect — providers then happened to see plaintext. With no key to seed,
// no Save runs, and the ciphertext reaches the registry directly. An earlier
// version of this test kept gateway_keys populated and was insensitive: it
// passed even with the bug present.
func TestProvidersGetPlaintextCredentials(t *testing.T) {
dir := t.TempDir()
path := writeSealedInstallNoSeed(t, dir)
c, err := New(path)
if err != nil {
t.Fatalf("restart: %v", err)
}
defer c.Close()
for _, p := range c.registry.Providers() {
key := p.Config().APIKey
if strings.HasPrefix(key, "enc:v1:") {
t.Errorf("provider %q holds CIPHERTEXT api_key %q — every upstream call "+
"would 401", p.Name(), key)
}
if key != "sk-upstream-secret" {
t.Errorf("provider %q api_key = %q, want the plaintext upstream secret",
p.Name(), key)
}
}
}

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")
}
}

View File

@ -0,0 +1,30 @@
# llmsproxy — 统一 OpenAI 兼容网关配置(示例模板)
#
# 这个是打包进发行包、供首次安装使用的模板。它故意不含任何可用的凭据:
# 首次启动时程序会生成一个随机 admin key 并写入实际配置(WebUI「密钥」页可见)。
#
# ⚠ 不要在本文件里填一个写死的具体 key。示例文件是公开的,写进去的任何值
# 都会成为一个全网皆知的 admin key(旧版示例里的 sk-gw-local-0001 就踩过
# 这个坑:它被直接抄进生产配置并真实可用)。
# 网关监听地址(默认 :8080,建议绑内网/回环)
listen: 127.0.0.1:8080
# 客户端访问本网关所需的 API Key(Bearer)。留空数组 = 不鉴权(仅内网)。
gateway_keys: []
# 默认模型选择:具体模型 id 或 AUTO(按各源模型的 priority 自动选最高可用源)
default_model: AUTO
# Lua 适配器目录(默认 adapters/,首次启动自动写入内置适配器)
adapter_dir: adapters
# 运行时持久化文件(WebUI 新增/编辑的源会写入此文件,重启后仍生效)
runtime_file: runtime.json
# 全局并发上限(0 = 不限)
max_concurrent: 0
# ---- 上游 LLM 源列表 ----
# 留空:安装后到 WebUI 添加源,或从内置模板一键创建。
sources: []

View File

@ -68,7 +68,12 @@ log "packaging tar.gz..."
TAR_DIR="$DIST/llmsproxy-$VERSION-linux-amd64"
rm -rf "$TAR_DIR"; mkdir -p "$TAR_DIR"
cp "$BUILD_BIN" "$TAR_DIR/llmsproxy"
cp "$ROOT/config.yaml" "$TAR_DIR/config.example.yaml"
# Use the tracked sanitised template, NOT the local config.yaml: that file is
# gitignored and carries the operator's real gateway_keys / upstream keys. It
# was previously copied verbatim as config.example.yaml, which shipped a real,
# working admin key (sk-gw-local-0001) to every install — and postinst.sh copies
# the example to /etc when none exists, so it ended up live in production.
cp "$ROOT/packaging/config.example.yaml" "$TAR_DIR/config.example.yaml"
cp "$ROOT/packaging/llmsproxy.service" "$TAR_DIR/llmsproxy.service"
mkdir -p "$TAR_DIR/adapters"
cp "$ROOT"/internal/lua/adapters/*.lua "$TAR_DIR/adapters/"

View File

@ -28,8 +28,11 @@ contents:
dst: /etc/systemd/system/llmsproxy.service
file_info:
mode: 0644
# example config (seeded to /etc/llmsproxy on first install by postinst)
- src: ./config.yaml
# example config (seeded to /etc/llmsproxy on first install by postinst).
# Must be the tracked sanitised template: ./config.yaml is the operator's own
# gitignored config and shipping it would leak real credentials — it shipped a
# working admin key (sk-gw-local-0001) to every install for a while.
- src: ./packaging/config.example.yaml
dst: /usr/share/llmsproxy/config.example.yaml
file_info:
mode: 0644

View File

@ -9,9 +9,19 @@ mkdir -p /etc/llmsproxy/adapters
if [ -f /etc/llmsproxy/config.yaml ]; then
echo "llmsproxy: keeping existing /etc/llmsproxy/config.yaml"
else
# seed a starter config from the packaged example
cp -f /usr/share/llmsproxy/config.example.yaml /etc/llmsproxy/config.yaml 2>/dev/null || true
chmod 0644 /etc/llmsproxy/config.yaml
# Do NOT seed a starter config here. Packaging/installing one used to hand
# everyone the same file, and while it was copied from the maintainer's
# live config it shipped a real, working admin key (sk-gw-local-0001) to
# every install.
#
# Instead, leave the file absent: on first start the binary's EnsureDefault
# path creates config.yaml with a FRESH RANDOM admin key and logs it, which
# is both unique per install and actually usable. The packaged
# config.example.yaml stays in /usr/share as a reference template only.
#
# (Seeding it with gateway_keys: [] would be worse: the gateway would start
# but reject every request, with no way in except editing the file.)
echo "llmsproxy: no /etc/llmsproxy/config.yaml — a random admin key will be generated on first start"
fi
# systemd