From f46b02089c368219af87fa65c4a6d41edafc539d Mon Sep 17 00:00:00 2001 From: root Date: Mon, 10 Aug 2026 11:52:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(delete):=20real=20deletes=20=E2=80=94=20ad?= =?UTF-8?q?apters=20seeded=20once=20into=20adapter=5Fdir=20(existing=20dir?= =?UTF-8?q?=20is=20authoritative),=20sources=20removed=20from=20config.yam?= =?UTF-8?q?l=20on=20delete;=20drop=20tombstone=20mechanism=20for=20both.?= =?UTF-8?q?=20Docs:=20multi-key=20architecture,=20gateway=5Fkeys=20as=20se?= =?UTF-8?q?ed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 23 +++++++++++---- README_EN.md | 26 +++++++++++++---- config.example.yaml | 4 ++- internal/config/config.go | 52 ++++++++++++++++++++++++++++++++++ internal/config/config_test.go | 51 ++++++++++++++++++++++++++++++++- internal/config/store.go | 34 ++-------------------- internal/core/core.go | 44 +++++++++++++++------------- internal/lua/vm.go | 17 ++++++++--- 8 files changed, 184 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index e25a359..8fe6c48 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ curl -N -H "Authorization: Bearer sk-gw-local-0001" \ http://127.0.0.1:8080/v1/chat/completions ``` -任何 OpenAI SDK 把 `base_url` 指到网关地址、`api_key` 用 `gateway_keys` 之一即可。 +任何 OpenAI SDK 把 `base_url` 指到网关地址、`api_key` 用任意一个已创建并授权的网关 key 即可。 ## 配置 @@ -76,10 +76,10 @@ curl -N -H "Authorization: Bearer sk-gw-local-0001" \ ```yaml listen: 127.0.0.1:8080 # 网关监听地址(建议绑内网/回环) -gateway_keys: [sk-gw-0001] # 客户端访问网关的 key;留空=不鉴权 -default_model: AUTO # model 无法路由时自动按 priority 选源 -adapter_dir: adapters # Lua 适配目录,首启自动写入内置适配器 -runtime_file: runtime.json # WebUI 编辑的源持久化到此文件 +gateway_keys: [sk-gw-0001] # 初始 admin 密钥种子,仅首启时写入运行时存储用 +default_model: AUTO # model 无法路由时自动按优先级链选源 +adapter_dir: adapters # Lua 适配目录;目录不存在时首启创建并 seed 内置,存在则只读 +runtime_file: runtime.json # WebUI 编辑的源/密钥/AUTO 链持久化到此文件 sources: - name: deepseek @@ -111,6 +111,19 @@ sources: - 升级时旧明文文件自动兼容:首次运行正常读取,任何 UI 保存操作触发全文件加密迁移。 - 注意:**master.key 丢失后密文无法解密**,请随配置一起备份;切勿提交到版本库。 +### 网关密钥(多密钥) + +网关鉴权采用「多密钥 + 角色 + 模型范围」架构,密钥持久化在 `runtime_file` 的 +`keys` 字段(加密存储): + +- `gateway_keys` 配置只是**初始 admin 密钥种子**:首次启动迁移为运行时 admin + key,之后不再参与鉴权管理。 +- WebUI **密钥页**可创建/删除密钥;每个密钥可指定 `admin`(管理全部)或 + `user`(仅看自己的 key)角色,并配置**模型范围**(模型 + 源 + token 配额 + + 重置周期)。 +- 客户端用任意一个已授权的密钥明文作为 Bearer(`Authorization: Bearer `)。 +- 删除密钥即从运行时存储移除,立即失效。 + ### 模型路由 `/v1/chat/completions` 的 `model` 解析顺序: diff --git a/README_EN.md b/README_EN.md index 525c4eb..3493959 100644 --- a/README_EN.md +++ b/README_EN.md @@ -88,8 +88,8 @@ curl -N -H "Authorization: Bearer sk-gw-local-0001" \ http://127.0.0.1:8080/v1/chat/completions ``` -Any OpenAI SDK works: point `base_url` at the gateway, use one of `gateway_keys` -as the API key. +Any OpenAI SDK works: point `base_url` at the gateway, use any authorized +gateway key as the API key. ## Configuration @@ -97,10 +97,10 @@ See [`config.example.yaml`](config.example.yaml). Core fields: ```yaml listen: 127.0.0.1:8080 # bind address (keep internal/loopback) -gateway_keys: [sk-gw-0001] # keys clients use; empty = no auth -default_model: AUTO # when model is unroutable, pick source by priority -adapter_dir: adapters # Lua adapter dir; built-ins written on first start -runtime_file: runtime.json # WebUI-edited sources persist here +gateway_keys: [sk-gw-0001] # initial admin key seed, migrated to the store on first start +default_model: AUTO # when model is unroutable, follow the AUTO chain +adapter_dir: adapters # Lua adapter dir; created+seeded if missing, read-only otherwise +runtime_file: runtime.json # WebUI-edited sources/keys/AUTO chain persist here sources: - name: deepseek @@ -135,6 +135,20 @@ Sensitive fields in the runtime file (`runtime_file`) are encrypted at rest: - Back up `master.key` with your config — losing it makes the secrets undecryptable. Do not commit it. +### Gateway keys (multi-key) + +Gateway auth uses a "multi-key + role + model scope" model. Keys are persisted +under the `keys` field of the runtime file (encrypted at rest): + +- The `gateway_keys` config is only an **initial admin key seed** — it is + migrated into the runtime store on first start and no longer drives auth. +- The WebUI **Keys page** creates/deletes keys. Each key has a role (`admin` + manages everything, `user` sees only its own key) and an optional **model + scope** (model + source + token quota + reset period). +- Clients authenticate with any authorized key's plaintext as + `Authorization: Bearer `. +- Deleting a key removes it from the store immediately. + ### Model routing `/v1/chat/completions` `model` resolution order: diff --git a/config.example.yaml b/config.example.yaml index b4aa4f1..1694247 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -3,7 +3,9 @@ # 网关监听地址(默认 :8080,建议绑内网/回环) listen: 127.0.0.1:8080 -# 客户端访问本网关所需的 API Key(Bearer)。留空数组 = 不鉴权(仅内网)。 +# 网关自身鉴权密钥。现在为「多密钥」架构:此项仅作为初始 admin 密钥种子, +# 首次启动写入运行时存储(runtime_file 的 keys 字段,加密存储)。之后请在 +# WebUI「密钥」页创建、删除密钥并配置其模型范围。留空 = 首启无 admin 密钥。 gateway_keys: - sk-gw-local-0001 diff --git a/internal/config/config.go b/internal/config/config.go index 726a1e4..c23852f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,6 +13,7 @@ import ( // Config is the top-level gateway configuration. type Config struct { + Path string `yaml:"-" json:"-"` Listen string `yaml:"listen"` GatewayKeys []string `yaml:"gateway_keys"` DefaultModel string `yaml:"default_model"` // e.g. "AUTO" or a model id @@ -60,12 +61,63 @@ func Load(path string) (*Config, error) { if err := yaml.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("parse config: %w", err) } + cfg.Path = path if err := cfg.ApplyDefaults(); err != nil { return nil, err } return &cfg, nil } +// RemoveSourceFromYAML deletes the named source entry from the config file so +// the delete is a real one (no tombstone needed). Uses yaml.Node to preserve +// the rest of the file's comments and formatting. +func RemoveSourceFromYAML(path, name string) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + var doc yaml.Node + if err := yaml.Unmarshal(data, &doc); err != nil { + return err + } + content := doc.Content + if len(content) == 0 { + return nil + } + root := content[0] + if root.Kind != yaml.MappingNode { + return nil + } + for i := 0; i+1 < len(root.Content); i += 2 { + key, val := root.Content[i], root.Content[i+1] + if key.Value != "sources" || val.Kind != yaml.SequenceNode { + continue + } + kept := val.Content[:0] + for _, item := range val.Content { + if item.Kind != yaml.MappingNode { + continue + } + found := false + for j := 0; j+1 < len(item.Content); j += 2 { + if item.Content[j].Value == "name" && item.Content[j+1].Value == name { + found = true + break + } + } + if !found { + kept = append(kept, item) + } + } + val.Content = kept + } + out, err := yaml.Marshal(&doc) + if err != nil { + return err + } + return os.WriteFile(path, out, 0644) +} + // ApplyDefaults sets missing values and validates the config. func (c *Config) ApplyDefaults() error { if c.Listen == "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 36bb769..b902885 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -196,4 +196,53 @@ func TestSecretBoxRoundTrip(t *testing.T) { if _, err := bad.Decrypt(v); err == nil { t.Fatal("expected decrypt failure with wrong key") } -} \ No newline at end of file +} +func TestRemoveSourceFromYAML(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "cfg.yaml") + content := ` +listen: 127.0.0.1:9999 +gateway_keys: [sk-1] +default_model: AUTO +adapter_dir: adapters +runtime_file: runtime.json +sources: + - name: deepseek + base_url: https://api.deepseek.com + api_key: sk-d + adapter: deepseek + models: + - id: deepseek-v4-flash + - name: ollama + base_url: http://127.0.0.1:11434 + adapter: ollama + models: + - id: llama3 +` + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + if err := RemoveSourceFromYAML(path, "deepseek"); err != nil { + t.Fatalf("remove: %v", err) + } + reloaded, err := Load(path) + if err != nil { + t.Fatalf("reload: %v", err) + } + if len(reloaded.Sources) != 1 { + t.Fatalf("sources = %d, want 1", len(reloaded.Sources)) + } + if reloaded.Sources[0].Name != "ollama" { + t.Fatalf("remaining = %q, want ollama", reloaded.Sources[0].Name) + } + if reloaded.Sources[0].Models[0].ID != "llama3" { + t.Fatalf("remaining models broken: %+v", reloaded.Sources[0].Models) + } + // removing a non-existent name is a no-op that keeps the file valid + if err := RemoveSourceFromYAML(path, "nope"); err != nil { + t.Fatalf("remove missing: %v", err) + } + if cfg, err := Load(path); err != nil || len(cfg.Sources) != 1 { + t.Fatalf("after no-op: %v %v", len(cfg.Sources), err) + } +} diff --git a/internal/config/store.go b/internal/config/store.go index c251ca6..153dad9 100644 --- a/internal/config/store.go +++ b/internal/config/store.go @@ -94,7 +94,8 @@ func (s *Store) Upsert(src Source) error { return s.persistLocked() } -// Remove deletes a runtime source or hides a base YAML source and persists. +// Remove deletes a runtime source from the store and persists. Base YAML +// sources are handled (truly removed from the config file) by the caller. func (s *Store) Remove(name string) (bool, error) { s.mu.Lock() defer s.mu.Unlock() @@ -108,10 +109,7 @@ func (s *Store) Remove(name string) (bool, error) { kept = append(kept, src) } s.data.Sources = kept - if !containsString(s.data.DeletedSources, name) { - s.data.DeletedSources = append(s.data.DeletedSources, name) - } - return removed || containsString(s.data.DeletedSources, name), s.persistLocked() + return removed, s.persistLocked() } func (s *Store) DeletedSources() map[string]bool { @@ -124,32 +122,6 @@ func (s *Store) DeletedSources() map[string]bool { return out } -func (s *Store) DeleteAdapter(name string) error { - s.mu.Lock() - defer s.mu.Unlock() - if !containsString(s.data.DeletedAdapters, name) { - s.data.DeletedAdapters = append(s.data.DeletedAdapters, name) - } - return s.persistLocked() -} - -func (s *Store) RestoreAdapter(name string) error { - s.mu.Lock() - defer s.mu.Unlock() - s.data.DeletedAdapters = removeString(s.data.DeletedAdapters, name) - return s.persistLocked() -} - -func (s *Store) DeletedAdapters() map[string]bool { - s.mu.Lock() - defer s.mu.Unlock() - out := map[string]bool{} - for _, name := range s.data.DeletedAdapters { - out[name] = true - } - return out -} - func (s *Store) persistLocked() error { if s.box != nil { s.encryptLocked() diff --git a/internal/core/core.go b/internal/core/core.go index 5275f4a..feb0b2d 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -239,13 +239,9 @@ func (c *Core) Config() *config.Config { return c.cfg } // mergedSources = base YAML sources + runtime sources (runtime wins by name). func (c *Core) mergedSources() []config.Source { - deleted := c.store.DeletedSources() byName := map[string]config.Source{} order := []string{} for _, s := range c.cfg.Sources { - if deleted[s.Name] { - continue - } byName[s.Name] = s order = append(order, s.Name) } @@ -313,15 +309,7 @@ func (c *Core) Reload() error { // ---- adapter management (web UI) ---- func (c *Core) ListAdapters() []lua.APIAdapter { - deleted := c.store.DeletedAdapters() - list := c.vm.ListAdapters() - out := make([]lua.APIAdapter, 0, len(list)) - for _, a := range list { - if !deleted[a.Name] { - out = append(out, a) - } - } - return out + return c.vm.ListAdapters() } // UploadAdapter saves a new Lua adapter script to the adapter dir and loads it. @@ -336,18 +324,17 @@ func (c *Core) UploadAdapter(name, code string) error { if err := os.WriteFile(path, []byte(code), 0644); err != nil { return err } - if err := c.vm.LoadAdapter(path); err != nil { - return fmt.Errorf("load adapter: %w", err) - } - return c.store.RestoreAdapter(name) + return c.vm.LoadAdapter(path) } -// RemoveAdapter deletes an adapter script and evicts it from the VM. +// RemoveAdapter deletes an adapter script and evicts it from the VM. The file +// is removed for real (adapter dir is authoritative after first run), so the +// adapter stays gone across restarts. func (c *Core) RemoveAdapter(name string) error { path := filepath.Join(c.cfg.AdapterDir, name+".lua") _ = os.Remove(path) c.vm.RemoveAdapter(name) - return c.store.DeleteAdapter(name) + return nil } // ---- source management (web UI) ---- @@ -363,12 +350,31 @@ func (c *Core) AddSource(src config.Source) error { } func (c *Core) RemoveSource(name string) error { + for _, s := range c.cfg.Sources { + if s.Name == name { + if err := config.RemoveSourceFromYAML(c.cfg.Path, name); err != nil { + return err + } + c.cfg.Sources = c.removeCfgSource(name) + break + } + } if _, err := c.store.Remove(name); err != nil { return err } return c.rebuildRegistry() } +func (c *Core) removeCfgSource(name string) []config.Source { + out := c.cfg.Sources[:0] + for _, s := range c.cfg.Sources { + if s.Name != name { + out = append(out, s) + } + } + return out +} + func (c *Core) Sources() []config.Source { return c.mergedSources() } func normalizeSource(s *config.Source) error { diff --git a/internal/lua/vm.go b/internal/lua/vm.go index 8e1e50d..f5bffae 100644 --- a/internal/lua/vm.go +++ b/internal/lua/vm.go @@ -183,11 +183,20 @@ func (v *VM) Start() error { if v.dir == "" { return nil } - if err := os.MkdirAll(v.dir, 0755); err != nil { - return fmt.Errorf("mkdir adapter dir: %w", err) + // First-run seeding: a brand-new adapter dir is created and populated with + // the bundled adapters. If the dir already exists it is treated as + // authoritative and never rewritten — deleting a file there is a real delete. + firstRun := false + if _, err := os.Stat(v.dir); os.IsNotExist(err) { + firstRun = true + if err := os.MkdirAll(v.dir, 0755); err != nil { + return fmt.Errorf("mkdir adapter dir: %w", err) + } } - if err := v.writeBundledAdapters(); err != nil { - return err + if firstRun { + if err := v.writeBundledAdapters(); err != nil { + return err + } } entries, err := os.ReadDir(v.dir) if err != nil {