mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 08:57:57 +00:00
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
This commit is contained in:
@ -40,7 +40,16 @@ func main() {
|
|||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
}
|
}
|
||||||
go func() {
|
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()))
|
c.Listen(), c.DefaultModel(), c.Registry().ModelList(), len(c.ListAdapters()))
|
||||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
log.Fatalf("[llmsproxy] server: %v", err)
|
log.Fatalf("[llmsproxy] server: %v", err)
|
||||||
|
|||||||
@ -20,6 +20,10 @@ adapter_dir: adapters
|
|||||||
# 运行时持久化文件(WebUI 新增/编辑的源会写入此文件,重启后仍生效)
|
# 运行时持久化文件(WebUI 新增/编辑的源会写入此文件,重启后仍生效)
|
||||||
runtime_file: runtime.json
|
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 = 不限)
|
# 全局并发上限(0 = 不限)
|
||||||
max_concurrent: 0
|
max_concurrent: 0
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,8 @@ type Config struct {
|
|||||||
AdapterDir string `yaml:"adapter_dir"`
|
AdapterDir string `yaml:"adapter_dir"`
|
||||||
RuntimeFile string `yaml:"runtime_file"`
|
RuntimeFile string `yaml:"runtime_file"`
|
||||||
MaxConcurrent int `yaml:"max_concurrent"` // global inflight cap, 0 = unlimited
|
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"`
|
Sources []Source `yaml:"sources"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -191,6 +193,7 @@ type GWKey struct {
|
|||||||
Models []ModelScope `json:"models,omitempty"`
|
Models []ModelScope `json:"models,omitempty"`
|
||||||
Note string `json:"note,omitempty"`
|
Note string `json:"note,omitempty"`
|
||||||
CreatedAt int64 `json:"created_at,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,
|
// ModelScope is one allowed model for a key, or one AUTO scheduling slot,
|
||||||
|
|||||||
@ -115,6 +115,7 @@ func (c *Core) seedKeys() error {
|
|||||||
Role: "admin",
|
Role: "admin",
|
||||||
Name: name,
|
Name: name,
|
||||||
CreatedAt: time.Now().Unix(),
|
CreatedAt: time.Now().Unix(),
|
||||||
|
Seed: true,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -143,6 +144,11 @@ func (c *Core) GatewayKeys() []string { return c.cfg.GatewayKeys }
|
|||||||
|
|
||||||
func (c *Core) Listen() string { return c.cfg.Listen }
|
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) ----
|
// ---- gateway key management (web UI) ----
|
||||||
|
|
||||||
// ListKeys returns all gateway keys (admin view).
|
// ListKeys returns all gateway keys (admin view).
|
||||||
|
|||||||
@ -114,6 +114,14 @@ func (g *Gateway) handleKeyMe(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusUnauthorized, "invalid_api_key", "key not found")
|
writeError(w, http.StatusUnauthorized, "invalid_api_key", "key not found")
|
||||||
return
|
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})
|
writeJSON(w, http.StatusOK, map[string]interface{}{"key": rec})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -351,12 +351,16 @@ func (g *Gateway) handleStatusAPI(w http.ResponseWriter, r *http.Request) {
|
|||||||
Name: k.Name,
|
Name: k.Name,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
scheme := "http"
|
||||||
|
if r.TLS != nil {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
"default_model": g.core.DefaultModel(),
|
"default_model": g.core.DefaultModel(),
|
||||||
"models": g.core.Registry().ModelList(),
|
"models": g.core.Registry().ModelList(),
|
||||||
"sources": g.core.Registry().Status(),
|
"sources": g.core.Registry().Status(),
|
||||||
"adapters": g.core.ListAdapters(),
|
"adapters": g.core.ListAdapters(),
|
||||||
"base_url": "http://" + host + "/v1",
|
"base_url": scheme + "://" + host + "/v1",
|
||||||
"gateway_keys": ks,
|
"gateway_keys": ks,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -468,6 +468,7 @@ const STR = {
|
|||||||
toastDelOk:'已删除', confirmDelSrc:'确定删除源 %s 吗?', confirmDelAdp:'确定删除适配器 %s 吗?',
|
toastDelOk:'已删除', confirmDelSrc:'确定删除源 %s 吗?', confirmDelAdp:'确定删除适配器 %s 吗?',
|
||||||
u:'用户', a:'助手', at:'助手[思考]', aEmpty:'(空回复)', aErr:'请求失败: %s', cErr:'错误: %s',
|
u:'用户', a:'助手', at:'助手[思考]', aEmpty:'(空回复)', aErr:'请求失败: %s', cErr:'错误: %s',
|
||||||
chatMeta:'模型=%s · 耗时 %s ms · %d 字符', connPinned:'# 固定到模型: %s · %s', connAuto:'# 模型名 AUTO',
|
chatMeta:'模型=%s · 耗时 %s ms · %d 字符', connPinned:'# 固定到模型: %s · %s', connAuto:'# 模型名 AUTO',
|
||||||
|
seedWarnTitle:'请更换初始管理员密钥', seedWarnText:'当前登录的是配置文件中的初始密钥,明文写入 config.yaml、存在泄露风险。请在下方创建新的管理员密钥,用新密钥登录后删除此初始密钥。', seedWarnGo:'去更换密钥', seedWarnLater:'稍后', seedWarnDismiss:'本次不再提示',
|
||||||
sortTitle:'拖拽积木配置模型优先级', sortHint:'每行 = 一个优先级档位,行从上到下优先级递减;同一行的模型并排,视为同优先级。按住积木右侧 ⠿ 把手拖动:拖到行内 = 放入该档位或调整同档顺序,拖到行与行之间的缝隙 = 提升或降低到新档位。生图模型不参与排序。', sortDragGrip:'拖拽前须按住把手',
|
sortTitle:'拖拽积木配置模型优先级', sortHint:'每行 = 一个优先级档位,行从上到下优先级递减;同一行的模型并排,视为同优先级。按住积木右侧 ⠿ 把手拖动:拖到行内 = 放入该档位或调整同档顺序,拖到行与行之间的缝隙 = 提升或降低到新档位。生图模型不参与排序。', sortDragGrip:'拖拽前须按住把手',
|
||||||
sortSave:'保存排序', sortReset:'重置', sortAdd:'添加档位', sortSaved:'排序已保存并热重载', sortNoChange:'无变更', sortHintSave:'点击保存排序后生效',
|
sortSave:'保存排序', sortReset:'重置', sortAdd:'添加档位', sortSaved:'排序已保存并热重载', sortNoChange:'无变更', sortHintSave:'点击保存排序后生效',
|
||||||
sortSource:'源', sortPrio:'优先级 %s', sortEmpty:'该源暂无模型',
|
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?',
|
toastDelOk:'Deleted', confirmDelSrc:'Delete source %s?', confirmDelAdp:'Delete adapter %s?',
|
||||||
u:'You:', a:'Assistant:', at:'Assistant [thinking]', aEmpty:'empty', aErr:'Request failed: %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',
|
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',
|
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',
|
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',
|
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.getElementById('btn-logout').onclick = () => { location.href = '/api/logout'; };
|
||||||
document.querySelectorAll('nav button').forEach(b => {
|
document.querySelectorAll('nav button').forEach(b => {
|
||||||
b.onclick = () => {
|
b.onclick = () => goTab(b.dataset.tab);
|
||||||
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);
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
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();
|
applyI18n();
|
||||||
|
|
||||||
/* ---------- helpers ---------- */
|
/* ---------- helpers ---------- */
|
||||||
@ -1736,6 +1739,20 @@ async function renderKeys() {
|
|||||||
window._me = me.key;
|
window._me = me.key;
|
||||||
return me.key.role === 'admin' ? renderKeysAdmin(me.key) : renderKeysUser(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 = `<div class="card" style="width:460px;max-width:100%">
|
||||||
|
<h2>⚠ ${esc(t('seedWarnTitle'))}</h2>
|
||||||
|
<p class="muted" style="line-height:1.6">${esc(t('seedWarnText'))}</p>
|
||||||
|
<p><button onclick="this.closest('#modal-wrap').remove();goTab('keys')">${esc(t('seedWarnGo'))}</button>
|
||||||
|
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${esc(t('seedWarnLater'))}</button>
|
||||||
|
<button class="ghost" onclick="localStorage.setItem('llms-proxy.seedWarn','1');this.closest('#modal-wrap').remove()">${esc(t('seedWarnDismiss'))}</button></p>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(wrap);
|
||||||
|
}
|
||||||
async function renderKeysUser(me) {
|
async function renderKeysUser(me) {
|
||||||
$('#tab-keys').innerHTML = `
|
$('#tab-keys').innerHTML = `
|
||||||
<div class="card"><h2>${t('kMeTitle')}</h2>
|
<div class="card"><h2>${t('kMeTitle')}</h2>
|
||||||
@ -2067,6 +2084,7 @@ function refresh(tab) {
|
|||||||
try {
|
try {
|
||||||
const me = await api('/api/keys/me');
|
const me = await api('/api/keys/me');
|
||||||
window._me = me.key;
|
window._me = me.key;
|
||||||
|
maybeWarnSeed(me.key);
|
||||||
if (me.key.role !== 'admin') {
|
if (me.key.role !== 'admin') {
|
||||||
['sort', 'sources', 'adapters'].forEach(tn => {
|
['sort', 'sources', 'adapters'].forEach(tn => {
|
||||||
const b = document.querySelector(`nav button[data-tab="${tn}"]`);
|
const b = document.querySelector(`nav button[data-tab="${tn}"]`);
|
||||||
|
|||||||
Reference in New Issue
Block a user