From fe06e0861aa6b04c3d52dca2bfa373e494152f41 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 10 Aug 2026 12:11:32 +0800 Subject: [PATCH] feat(seed-warn): dynamic seed key detection in UI modal; feat(https): TLS config (tls_cert_file/tls_key_file) with dynamic base_url; docs: rotate admin key reminder --- cmd/llmsproxy/main.go | 11 ++++++++++- config.example.yaml | 4 ++++ internal/config/config.go | 3 +++ internal/core/core.go | 6 ++++++ internal/gateway/keys.go | 8 ++++++++ internal/gateway/server.go | 6 +++++- internal/gateway/ui/index.html | 28 +++++++++++++++++++++++----- 7 files changed, 59 insertions(+), 7 deletions(-) diff --git a/cmd/llmsproxy/main.go b/cmd/llmsproxy/main.go index ea551f0..1aa5a04 100644 --- a/cmd/llmsproxy/main.go +++ b/cmd/llmsproxy/main.go @@ -40,7 +40,16 @@ func main() { ReadHeaderTimeout: 10 * time.Second, } go func() { - log.Printf("[llmsproxy] listening on %s (default_model=%s, models=%v, adapters=%d)", + cert, key := c.TLS() + if cert != "" && key != "" { + log.Printf("[llmsproxy] listening HTTPS on %s (cert=%q) (default_model=%s, models=%v, adapters=%d)", + c.Listen(), cert, c.DefaultModel(), c.Registry().ModelList(), len(c.ListAdapters())) + if err := srv.ListenAndServeTLS(cert, key); err != nil && err != http.ErrServerClosed { + log.Fatalf("[llmsproxy] server: %v", err) + } + return + } + log.Printf("[llmsproxy] listening HTTP on %s (default_model=%s, models=%v, adapters=%d)", c.Listen(), c.DefaultModel(), c.Registry().ModelList(), len(c.ListAdapters())) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("[llmsproxy] server: %v", err) diff --git a/config.example.yaml b/config.example.yaml index 23419de..33578ad 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -20,6 +20,10 @@ adapter_dir: adapters # 运行时持久化文件(WebUI 新增/编辑的源会写入此文件,重启后仍生效) runtime_file: runtime.json +# 可选 HTTPS:提供 PEM 证书与私钥文件路径后以 HTTPS 提供服务;留空 = 纯 HTTP。 +# tls_cert_file: /etc/llmsproxy/tls/fullchain.pem +# tls_key_file: /etc/llmsproxy/tls/privkey.pem + # 全局并发上限(0 = 不限) max_concurrent: 0 diff --git a/internal/config/config.go b/internal/config/config.go index c23852f..91d6c0a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,6 +20,8 @@ type Config struct { AdapterDir string `yaml:"adapter_dir"` RuntimeFile string `yaml:"runtime_file"` MaxConcurrent int `yaml:"max_concurrent"` // global inflight cap, 0 = unlimited + TLSCertFile string `yaml:"tls_cert_file,omitempty"` // PEM cert; when set together with tls_key_file, serve HTTPS + TLSKeyFile string `yaml:"tls_key_file,omitempty"` // PEM private key Sources []Source `yaml:"sources"` } @@ -191,6 +193,7 @@ type GWKey struct { Models []ModelScope `json:"models,omitempty"` Note string `json:"note,omitempty"` CreatedAt int64 `json:"created_at,omitempty"` + Seed bool `json:"seed,omitempty"` // true if migrated from config gateway_keys } // ModelScope is one allowed model for a key, or one AUTO scheduling slot, diff --git a/internal/core/core.go b/internal/core/core.go index feb0b2d..b4cb297 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -115,6 +115,7 @@ func (c *Core) seedKeys() error { Role: "admin", Name: name, CreatedAt: time.Now().Unix(), + Seed: true, }); err != nil { return err } @@ -143,6 +144,11 @@ func (c *Core) GatewayKeys() []string { return c.cfg.GatewayKeys } func (c *Core) Listen() string { return c.cfg.Listen } +// TLS returns the configured cert/key file paths. Empty strings mean HTTP only. +func (c *Core) TLS() (cert, key string) { + return c.cfg.TLSCertFile, c.cfg.TLSKeyFile +} + // ---- gateway key management (web UI) ---- // ListKeys returns all gateway keys (admin view). diff --git a/internal/gateway/keys.go b/internal/gateway/keys.go index 3b7bcd5..c2aebd9 100644 --- a/internal/gateway/keys.go +++ b/internal/gateway/keys.go @@ -114,6 +114,14 @@ func (g *Gateway) handleKeyMe(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusUnauthorized, "invalid_api_key", "key not found") return } + if !rec.Seed { + for _, s := range g.core.GatewayKeys() { + if s == rec.Key { + rec.Seed = true + break + } + } + } writeJSON(w, http.StatusOK, map[string]interface{}{"key": rec}) } diff --git a/internal/gateway/server.go b/internal/gateway/server.go index bfef46a..a72aadf 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -351,12 +351,16 @@ func (g *Gateway) handleStatusAPI(w http.ResponseWriter, r *http.Request) { Name: k.Name, }) } + scheme := "http" + if r.TLS != nil { + scheme = "https" + } writeJSON(w, http.StatusOK, map[string]interface{}{ "default_model": g.core.DefaultModel(), "models": g.core.Registry().ModelList(), "sources": g.core.Registry().Status(), "adapters": g.core.ListAdapters(), - "base_url": "http://" + host + "/v1", + "base_url": scheme + "://" + host + "/v1", "gateway_keys": ks, }) } diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index 9c0eaa7..eab19b5 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -468,6 +468,7 @@ const STR = { toastDelOk:'已删除', confirmDelSrc:'确定删除源 %s 吗?', confirmDelAdp:'确定删除适配器 %s 吗?', u:'用户', a:'助手', at:'助手[思考]', aEmpty:'(空回复)', aErr:'请求失败: %s', cErr:'错误: %s', chatMeta:'模型=%s · 耗时 %s ms · %d 字符', connPinned:'# 固定到模型: %s · %s', connAuto:'# 模型名 AUTO', + seedWarnTitle:'请更换初始管理员密钥', seedWarnText:'当前登录的是配置文件中的初始密钥,明文写入 config.yaml、存在泄露风险。请在下方创建新的管理员密钥,用新密钥登录后删除此初始密钥。', seedWarnGo:'去更换密钥', seedWarnLater:'稍后', seedWarnDismiss:'本次不再提示', sortTitle:'拖拽积木配置模型优先级', sortHint:'每行 = 一个优先级档位,行从上到下优先级递减;同一行的模型并排,视为同优先级。按住积木右侧 ⠿ 把手拖动:拖到行内 = 放入该档位或调整同档顺序,拖到行与行之间的缝隙 = 提升或降低到新档位。生图模型不参与排序。', sortDragGrip:'拖拽前须按住把手', sortSave:'保存排序', sortReset:'重置', sortAdd:'添加档位', sortSaved:'排序已保存并热重载', sortNoChange:'无变更', sortHintSave:'点击保存排序后生效', sortSource:'源', sortPrio:'优先级 %s', sortEmpty:'该源暂无模型', @@ -521,6 +522,7 @@ kMeTitle:'My key', kMeRole:'Role', kMeModels:'Models I can use', kMeHint:'Keys c toastDelOk:'Deleted', confirmDelSrc:'Delete source %s?', confirmDelAdp:'Delete adapter %s?', u:'You:', a:'Assistant:', at:'Assistant [thinking]', aEmpty:'empty', aErr:'Request failed: %s', cErr:'Error: %s', chatMeta:'Model=%s · %s ms · %d chars', connPinned:'# Pinned to model: %s · %s', connAuto:'# Model "AUTO" follows the Priority page AUTO chain', + seedWarnTitle:'Replace the initial admin key', seedWarnText:'You are logged in with the seed key from config.yaml. It is plaintext in the config file and a security risk. Create a new admin key below, log in with it, then delete this seed key.', seedWarnGo:'Change my key', seedWarnLater:'Later', seedWarnDismiss:'Don\'t ask again', sortTitle:'Drag blocks to set model priority', sortHint:'Each row = one priority tier, rows go high→low; models on the same row sit side by side and share that priority. Grab the ⠿ handle on the right of a block to drag: drop into a row = join that tier or reorder within it, drop into the gap between rows = move up/down a tier. Image models stay out.', sortDragGrip:'grab the handle to drag', sortSave:'Save order', sortReset:'Reset', sortAdd:'Add slot', sortSaved:'Order saved & hot-reloaded', sortNoChange:'No changes', sortHintSave:'Click Save for it to take effect', sortSource:'source', sortPrio:'priority %s', sortEmpty:'no models in this source', @@ -563,12 +565,13 @@ document.getElementById('btn-lang').onclick = () => { }; document.getElementById('btn-logout').onclick = () => { location.href = '/api/logout'; }; document.querySelectorAll('nav button').forEach(b => { - b.onclick = () => { - document.querySelectorAll('nav button').forEach(x => x.classList.toggle('active', x === b)); - ['status','chat','keys','sort','sources','adapters'].forEach(tn => $('#tab-' + tn).classList.toggle('hidden', tn !== b.dataset.tab)); - lastTab = b.dataset.tab; refresh(lastTab); - }; + b.onclick = () => goTab(b.dataset.tab); }); +function goTab(name) { + document.querySelectorAll('nav button').forEach(x => x.classList.toggle('active', x.dataset.tab === name)); + ['status','chat','keys','sort','sources','adapters'].forEach(tn => $('#tab-' + tn).classList.toggle('hidden', tn !== name)); + lastTab = name; refresh(lastTab); +} applyI18n(); /* ---------- helpers ---------- */ @@ -1736,6 +1739,20 @@ async function renderKeys() { window._me = me.key; return me.key.role === 'admin' ? renderKeysAdmin(me.key) : renderKeysUser(me.key); } +function maybeWarnSeed(k) { + if (!k || !k.seed) return; + if (localStorage.getItem('llms-proxy.seedWarn') === '1') return; + const wrap = document.createElement('div'); wrap.id = 'modal-wrap'; + wrap.style.cssText = 'position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50'; + wrap.innerHTML = `
+

⚠ ${esc(t('seedWarnTitle'))}

+

${esc(t('seedWarnText'))}

+

+ +

+
`; + document.body.appendChild(wrap); +} async function renderKeysUser(me) { $('#tab-keys').innerHTML = `

${t('kMeTitle')}

@@ -2067,6 +2084,7 @@ function refresh(tab) { try { const me = await api('/api/keys/me'); window._me = me.key; + maybeWarnSeed(me.key); if (me.key.role !== 'admin') { ['sort', 'sources', 'adapters'].forEach(tn => { const b = document.querySelector(`nav button[data-tab="${tn}"]`);