masked sensitive
improved schema
This commit is contained in:
parent
84815818f7
commit
83682e203d
3 changed files with 284 additions and 16 deletions
|
|
@ -93,6 +93,7 @@
|
|||
"ChannelsConfig": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "Channel (messenger) integrations. Enable the channels you want to receive messages from.",
|
||||
"required": [
|
||||
"whatsapp",
|
||||
"telegram",
|
||||
|
|
@ -123,6 +124,7 @@
|
|||
"WhatsAppConfig": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "WhatsApp channel integration (via a bridge service).",
|
||||
"required": ["enabled", "bridge_url", "allow_from"],
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean", "default": false },
|
||||
|
|
@ -133,6 +135,7 @@
|
|||
"TelegramConfig": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "Telegram bot channel integration.",
|
||||
"required": ["enabled", "token", "proxy", "allow_from"],
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean", "default": false },
|
||||
|
|
@ -144,6 +147,7 @@
|
|||
"FeishuConfig": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "Feishu / Lark (飞书 / Lark) bot channel integration.",
|
||||
"required": [
|
||||
"enabled",
|
||||
"app_id",
|
||||
|
|
@ -164,6 +168,7 @@
|
|||
"DiscordConfig": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "Discord bot channel integration.",
|
||||
"required": ["enabled", "token", "allow_from"],
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean", "default": false },
|
||||
|
|
@ -174,6 +179,7 @@
|
|||
"MaixCamConfig": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "Sipeed MaixCam device channel integration.",
|
||||
"required": ["enabled", "host", "port", "allow_from"],
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean", "default": false },
|
||||
|
|
@ -190,6 +196,7 @@
|
|||
"QQConfig": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "Tencent QQ (腾讯 QQ) bot/channel integration.",
|
||||
"required": ["enabled", "app_id", "app_secret", "allow_from"],
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean", "default": false },
|
||||
|
|
@ -201,6 +208,7 @@
|
|||
"DingTalkConfig": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "DingTalk / DingDing (钉钉) bot/channel integration.",
|
||||
"required": ["enabled", "client_id", "client_secret", "allow_from"],
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean", "default": false },
|
||||
|
|
@ -212,6 +220,7 @@
|
|||
"SlackConfig": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "Slack bot channel integration.",
|
||||
"required": ["enabled", "bot_token", "app_token", "allow_from"],
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean", "default": false },
|
||||
|
|
@ -223,6 +232,7 @@
|
|||
"LINEConfig": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "LINE Messaging API channel integration.",
|
||||
"required": [
|
||||
"enabled",
|
||||
"channel_secret",
|
||||
|
|
@ -250,6 +260,7 @@
|
|||
"OneBotConfig": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "OneBot (CQHTTP) compatible channel integration (often used for QQ bots via external bridges).",
|
||||
"required": [
|
||||
"enabled",
|
||||
"ws_url",
|
||||
|
|
@ -274,6 +285,7 @@
|
|||
"WebUIConfig": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "Built-in web UI channel. Serves the browser chat UI and /admin config editor over HTTP.",
|
||||
"required": ["enabled"],
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean", "default": true }
|
||||
|
|
@ -282,6 +294,7 @@
|
|||
"ProvidersConfig": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "Model provider configuration. Each key enables and configures a provider integration (API key, base URL overrides, etc).",
|
||||
"required": [
|
||||
"anthropic",
|
||||
"openai",
|
||||
|
|
@ -297,18 +310,54 @@
|
|||
"github_copilot"
|
||||
],
|
||||
"properties": {
|
||||
"anthropic": { "$ref": "#/$defs/ProviderConfig" },
|
||||
"openai": { "$ref": "#/$defs/ProviderConfig" },
|
||||
"openrouter": { "$ref": "#/$defs/ProviderConfig" },
|
||||
"groq": { "$ref": "#/$defs/ProviderConfig" },
|
||||
"zhipu": { "$ref": "#/$defs/ProviderConfig" },
|
||||
"vllm": { "$ref": "#/$defs/ProviderConfig" },
|
||||
"gemini": { "$ref": "#/$defs/ProviderConfig" },
|
||||
"nvidia": { "$ref": "#/$defs/ProviderConfig" },
|
||||
"moonshot": { "$ref": "#/$defs/ProviderConfig" },
|
||||
"shengsuanyun": { "$ref": "#/$defs/ProviderConfig" },
|
||||
"deepseek": { "$ref": "#/$defs/ProviderConfig" },
|
||||
"github_copilot": { "$ref": "#/$defs/ProviderConfig" }
|
||||
"anthropic": {
|
||||
"allOf": [{ "$ref": "#/$defs/ProviderConfig" }],
|
||||
"description": "Anthropic (Claude) API provider."
|
||||
},
|
||||
"openai": {
|
||||
"allOf": [{ "$ref": "#/$defs/ProviderConfig" }],
|
||||
"description": "OpenAI API provider (GPT models)."
|
||||
},
|
||||
"openrouter": {
|
||||
"allOf": [{ "$ref": "#/$defs/ProviderConfig" }],
|
||||
"description": "OpenRouter aggregator provider (routes requests to many different model backends)."
|
||||
},
|
||||
"groq": {
|
||||
"allOf": [{ "$ref": "#/$defs/ProviderConfig" }],
|
||||
"description": "Groq hosted inference provider (fast LLM serving)."
|
||||
},
|
||||
"zhipu": {
|
||||
"allOf": [{ "$ref": "#/$defs/ProviderConfig" }],
|
||||
"description": "Zhipu AI (智谱AI) provider (GLM model family)."
|
||||
},
|
||||
"vllm": {
|
||||
"allOf": [{ "$ref": "#/$defs/ProviderConfig" }],
|
||||
"description": "vLLM-compatible OpenAI-style endpoint (typically self-hosted)."
|
||||
},
|
||||
"gemini": {
|
||||
"allOf": [{ "$ref": "#/$defs/ProviderConfig" }],
|
||||
"description": "Google Gemini API provider."
|
||||
},
|
||||
"nvidia": {
|
||||
"allOf": [{ "$ref": "#/$defs/ProviderConfig" }],
|
||||
"description": "NVIDIA hosted model API provider (where supported)."
|
||||
},
|
||||
"moonshot": {
|
||||
"allOf": [{ "$ref": "#/$defs/ProviderConfig" }],
|
||||
"description": "Moonshot AI provider (Kimi)."
|
||||
},
|
||||
"shengsuanyun": {
|
||||
"allOf": [{ "$ref": "#/$defs/ProviderConfig" }],
|
||||
"description": "Shengsuanyun (生数云) provider (where supported)."
|
||||
},
|
||||
"deepseek": {
|
||||
"allOf": [{ "$ref": "#/$defs/ProviderConfig" }],
|
||||
"description": "DeepSeek API provider."
|
||||
},
|
||||
"github_copilot": {
|
||||
"allOf": [{ "$ref": "#/$defs/ProviderConfig" }],
|
||||
"description": "GitHub Copilot provider (where supported by the integration)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"ProviderConfig": {
|
||||
|
|
@ -397,10 +446,17 @@
|
|||
"WebToolsConfig": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "Web search providers used by the web tool.",
|
||||
"required": ["brave", "duckduckgo"],
|
||||
"properties": {
|
||||
"brave": { "$ref": "#/$defs/BraveConfig" },
|
||||
"duckduckgo": { "$ref": "#/$defs/DuckDuckGoConfig" }
|
||||
"brave": {
|
||||
"allOf": [{ "$ref": "#/$defs/BraveConfig" }],
|
||||
"description": "Brave Search API provider. Requires an API key."
|
||||
},
|
||||
"duckduckgo": {
|
||||
"allOf": [{ "$ref": "#/$defs/DuckDuckGoConfig" }],
|
||||
"description": "DuckDuckGo search provider. No API key required."
|
||||
}
|
||||
}
|
||||
},
|
||||
"BraveConfig": {
|
||||
|
|
|
|||
|
|
@ -317,9 +317,14 @@ func (c *WebUIChannel) handleAdminConfig(w http.ResponseWriter, r *http.Request)
|
|||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
masked, err := maskConfigJSON(raw)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to mask config", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(raw)
|
||||
w.Write(masked)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -336,7 +341,18 @@ func (c *WebUIChannel) handleAdminConfig(w http.ResponseWriter, r *http.Request)
|
|||
http.Error(w, "Empty body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := c.configUpdateFn(body); err != nil {
|
||||
|
||||
mergedRaw := body
|
||||
if c.configReadFn != nil {
|
||||
oldRaw, rerr := c.configReadFn()
|
||||
if rerr == nil {
|
||||
if out, merr := mergePreserveMaskedSecrets(oldRaw, body); merr == nil {
|
||||
mergedRaw = out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := c.configUpdateFn(mergedRaw); err != nil {
|
||||
msg := err.Error()
|
||||
lower := strings.ToLower(msg)
|
||||
if strings.Contains(lower, "config is not writable") {
|
||||
|
|
@ -350,6 +366,106 @@ func (c *WebUIChannel) handleAdminConfig(w http.ResponseWriter, r *http.Request)
|
|||
w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
const maskedSentinel = "********"
|
||||
|
||||
func maskStringPrefix8(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
n := 8
|
||||
if len(s) < n {
|
||||
n = len(s)
|
||||
}
|
||||
return s[:n] + maskedSentinel
|
||||
}
|
||||
|
||||
func isSensitiveKey(k string) bool {
|
||||
switch strings.ToLower(k) {
|
||||
case "api_key", "token", "admin_token", "app_secret", "client_secret", "channel_secret", "channel_access_token", "access_token", "bot_token", "app_token", "encrypt_key", "verification_token":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func maskConfigJSON(raw []byte) ([]byte, error) {
|
||||
var v any
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
maskAny(v)
|
||||
return json.MarshalIndent(v, "", " ")
|
||||
}
|
||||
|
||||
func maskAny(v any) {
|
||||
switch x := v.(type) {
|
||||
case map[string]any:
|
||||
for k, vv := range x {
|
||||
if isSensitiveKey(k) {
|
||||
if s, ok := vv.(string); ok && s != "" {
|
||||
x[k] = maskStringPrefix8(s)
|
||||
continue
|
||||
}
|
||||
}
|
||||
maskAny(vv)
|
||||
}
|
||||
case []any:
|
||||
for i := range x {
|
||||
maskAny(x[i])
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func mergePreserveMaskedSecrets(oldRaw, newRaw []byte) ([]byte, error) {
|
||||
var oldV any
|
||||
var newV any
|
||||
if err := json.Unmarshal(oldRaw, &oldV); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(newRaw, &newV); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mergeAny(oldV, newV)
|
||||
return json.MarshalIndent(newV, "", " ")
|
||||
}
|
||||
|
||||
func mergeAny(oldV any, newV any) {
|
||||
oldMap, okOld := oldV.(map[string]any)
|
||||
newMap, okNew := newV.(map[string]any)
|
||||
if okOld && okNew {
|
||||
for k, nv := range newMap {
|
||||
ov, ok := oldMap[k]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if isSensitiveKey(k) {
|
||||
if s, ok := nv.(string); ok && strings.Contains(s, maskedSentinel) {
|
||||
if os, ok := ov.(string); ok {
|
||||
newMap[k] = os
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
mergeAny(ov, nv)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
oldArr, okOldArr := oldV.([]any)
|
||||
newArr, okNewArr := newV.([]any)
|
||||
if okOldArr && okNewArr {
|
||||
min := len(oldArr)
|
||||
if len(newArr) < min {
|
||||
min = len(newArr)
|
||||
}
|
||||
for i := 0; i < min; i++ {
|
||||
mergeAny(oldArr[i], newArr[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *WebUIChannel) handleAdminSchema(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.Header().Set("Allow", http.MethodGet)
|
||||
|
|
|
|||
96
pkg/channels/webui_admin_config_test.go
Normal file
96
pkg/channels/webui_admin_config_test.go
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
package channels
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMaskConfigJSON_MasksSensitiveValuesPrefix8(t *testing.T) {
|
||||
in := []byte(`{
|
||||
"gateway": {
|
||||
"token": "12345678ABCDEFGH",
|
||||
"admin_token": "ABCDEFGH12345678"
|
||||
},
|
||||
"providers": {
|
||||
"openai": {"api_key": "sk-12345678ZZZZ", "api_base": ""}
|
||||
},
|
||||
"channels": {
|
||||
"telegram": {"token": "99999999TTTT"}
|
||||
}
|
||||
}`)
|
||||
|
||||
out, err := maskConfigJSON(in)
|
||||
if err != nil {
|
||||
t.Fatalf("maskConfigJSON error: %v", err)
|
||||
}
|
||||
|
||||
var v map[string]any
|
||||
if err := json.Unmarshal(out, &v); err != nil {
|
||||
t.Fatalf("unmarshal masked output: %v", err)
|
||||
}
|
||||
|
||||
gateway := v["gateway"].(map[string]any)
|
||||
if got := gateway["token"].(string); got != "12345678********" {
|
||||
t.Fatalf("gateway.token = %q, want %q", got, "12345678********")
|
||||
}
|
||||
if got := gateway["admin_token"].(string); got != "ABCDEFGH********" {
|
||||
t.Fatalf("gateway.admin_token = %q, want %q", got, "ABCDEFGH********")
|
||||
}
|
||||
|
||||
providers := v["providers"].(map[string]any)
|
||||
openai := providers["openai"].(map[string]any)
|
||||
if got := openai["api_key"].(string); got != "sk-12345********" {
|
||||
t.Fatalf("providers.openai.api_key = %q, want %q", got, "sk-12345********")
|
||||
}
|
||||
|
||||
channels := v["channels"].(map[string]any)
|
||||
tg := channels["telegram"].(map[string]any)
|
||||
if got := tg["token"].(string); got != "99999999********" {
|
||||
t.Fatalf("channels.telegram.token = %q, want %q", got, "99999999********")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergePreserveMaskedSecrets_PreservesOnlyMaskedNotEmpty(t *testing.T) {
|
||||
oldRaw := []byte(`{
|
||||
"gateway": {"token": "TOK_OLD_1234567890", "admin_token": "ADM_OLD_1234567890"},
|
||||
"providers": {"openai": {"api_key": "sk-OLD-1234567890", "api_base": ""}},
|
||||
"channels": {"telegram": {"token": "TG_OLD_1234567890"}}
|
||||
}`)
|
||||
|
||||
newRaw := []byte(`{
|
||||
"gateway": {"token": "TOK_OLD_********", "admin_token": ""},
|
||||
"providers": {"openai": {"api_key": "sk-OLD-1********", "api_base": ""}},
|
||||
"channels": {"telegram": {"token": "TG_NEW_123"}}
|
||||
}`)
|
||||
|
||||
merged, err := mergePreserveMaskedSecrets(oldRaw, newRaw)
|
||||
if err != nil {
|
||||
t.Fatalf("mergePreserveMaskedSecrets error: %v", err)
|
||||
}
|
||||
|
||||
var v map[string]any
|
||||
if err := json.Unmarshal(merged, &v); err != nil {
|
||||
t.Fatalf("unmarshal merged: %v", err)
|
||||
}
|
||||
|
||||
gateway := v["gateway"].(map[string]any)
|
||||
if got := gateway["token"].(string); got != "TOK_OLD_1234567890" {
|
||||
t.Fatalf("gateway.token = %q, want old preserved", got)
|
||||
}
|
||||
// empty string should not be treated as "keep"; it should remain empty (delete)
|
||||
if got := gateway["admin_token"].(string); got != "" {
|
||||
t.Fatalf("gateway.admin_token = %q, want empty string", got)
|
||||
}
|
||||
|
||||
providers := v["providers"].(map[string]any)
|
||||
openai := providers["openai"].(map[string]any)
|
||||
if got := openai["api_key"].(string); got != "sk-OLD-1234567890" {
|
||||
t.Fatalf("providers.openai.api_key = %q, want old preserved", got)
|
||||
}
|
||||
|
||||
channels := v["channels"].(map[string]any)
|
||||
tg := channels["telegram"].(map[string]any)
|
||||
if got := tg["token"].(string); got != "TG_NEW_123" {
|
||||
t.Fatalf("channels.telegram.token = %q, want new value", got)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue