feat(config): 运行时配置体系②——渠道配置落 channels.json + 面板落盘 + 热更新
- runtimeconfig/channels.go: channels.json 承载 map[platform][]ChannelAccount(token/baseURL/黑白名单/群聊限流), LoadChannels 优先文件/回退env, SaveChannels(0600+tmp原子写), Upsert/Delete/Masked脱敏
- channelbridge/reload.go: adapterManager 热替换——工厂重建新adapter先连接, 再原子断旧adapter(独立readCtx/进行中不中断); Bridge.Reload(platform) 逐账号增删
- 面板 PUT/DELETE /admin/channels/{platform}/config(owner+审计) 落盘channels.json+Reload热更新; GET /admin/channels 附账号掩码不泄裸token
- 前端 Channels.tsx 配置表单(id/token/baseURL/黑白名单/限流+config JSON)+保存热更新提示+掩码展示/删除
- .gitignore 加 channels.json; .env 退兜底(.env.example注明); token不打印/Config()脱敏
- channelbridge.go 拆 logic→reload.go(742→731); 全仓 go build/vet/test绿+前端build绿
- 未做真token端到端(需渠道凭据,不读.env)——如实标注待凭据
This commit is contained in:
1 parent
2d7151f979
commit
aedae2190b
18 files changed
+1743
-126
No files matched your search
@@ -0,0 +1,206 @@
|
||||
// channels.go loads and persists the runtime-editable channel-adapter
|
||||
// configuration: the per-platform account list in channels.json (design/04
|
||||
// §3.1). The .env channel block (CHANNELS_JSON + CHANNEL_<PLATFORM>_*) is the
|
||||
// final fallback; a present-and-valid channels.json takes precedence and is
|
||||
// hot-reloaded at runtime without a restart.
|
||||
//
|
||||
// channels.json is gitignored (local runtime config) and holds bot tokens, so
|
||||
// this package never logs a token and never exposes one un-masked to a caller
|
||||
// that only wants a display snapshot (Masked).
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
|
||||
// channelsFileName is the channels.json basename stored under the data dir.
|
||||
const channelsFileName = "channels.json"
|
||||
|
||||
// ErrInvalidChannels is returned when a channels.json fails schema validation.
|
||||
var ErrInvalidChannels = errors.New("runtimeconfig: invalid channels config")
|
||||
|
||||
// tokenKeys are the config-map keys whose value is a secret and must never be
|
||||
// emitted verbatim by Masked (or a log line). A key is treated as sensitive when
|
||||
// its lower-cased form contains any of these substrings.
|
||||
var tokenKeys = []string{"token", "secret", "password", "api_key", "apikey", "access_key", "key"}
|
||||
|
||||
// DefaultChannelsPath returns the channels.json path under dataDir.
|
||||
func DefaultChannelsPath(dataDir string) string {
|
||||
return filepath.Join(dataDir, channelsFileName)
|
||||
}
|
||||
|
||||
// LoadChannels returns the effective per-platform account map. When path is a
|
||||
// present-and-valid channels.json it is authoritative; otherwise it falls back
|
||||
// to envFallback (the .env/CHANNELS_JSON channel block computed by the caller).
|
||||
// A present-but-invalid file falls back to env and returns a non-nil warning so
|
||||
// the caller can log it. The returned map is non-nil.
|
||||
func LoadChannels(path string, envFallback map[string][]config.ChannelAccount) (map[string][]config.ChannelAccount, error) {
|
||||
accounts, readErr := loadChannelsFile(path)
|
||||
if readErr == nil {
|
||||
return accounts, nil
|
||||
}
|
||||
var warn error
|
||||
if !os.IsNotExist(readErr) {
|
||||
warn = fmt.Errorf("runtimeconfig: %s: %w", path, readErr)
|
||||
}
|
||||
if envFallback == nil {
|
||||
envFallback = map[string][]config.ChannelAccount{}
|
||||
}
|
||||
return envFallback, warn
|
||||
}
|
||||
|
||||
// loadChannelsFile decodes and validates the channels.json map. A nil/blank
|
||||
// file, a JSON decode error or an empty map all return an error so the caller
|
||||
// falls back to env — an empty config file should never silently wipe the
|
||||
// channels that env still provides.
|
||||
func loadChannelsFile(path string) (map[string][]config.ChannelAccount, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(strings.TrimSpace(string(raw))) == 0 {
|
||||
return nil, fmt.Errorf("%w: empty file", ErrInvalidChannels)
|
||||
}
|
||||
accounts, err := config.ParseChannelAccounts(string(raw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidChannels, err)
|
||||
}
|
||||
if len(accounts) == 0 {
|
||||
return nil, fmt.Errorf("%w: no accounts", ErrInvalidChannels)
|
||||
}
|
||||
return accounts, nil
|
||||
}
|
||||
|
||||
// SaveChannels writes accounts as a pretty-printed JSON map to path with mode
|
||||
// 0600 (the file holds bot tokens). It writes to a temp file then renames so a
|
||||
// concurrent reader never sees a partial file.
|
||||
func SaveChannels(path string, accounts map[string][]config.ChannelAccount) error {
|
||||
if accounts == nil {
|
||||
accounts = map[string][]config.ChannelAccount{}
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
if dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("runtimeconfig: mkdir: %w", err)
|
||||
}
|
||||
}
|
||||
raw, err := json.MarshalIndent(accounts, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("runtimeconfig: marshal: %w", err)
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
||||
return fmt.Errorf("runtimeconfig: write %s: %w", tmp, err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return fmt.Errorf("runtimeconfig: rename: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpsertChannel returns accounts with acct inserted or replaced in place within
|
||||
// its platform by ID, preserving order. A blank ID is assigned a generated id so
|
||||
// a panel save without an explicit id still produces a stable, addressable account.
|
||||
func UpsertChannel(accounts map[string][]config.ChannelAccount, platform string, acct config.ChannelAccount) map[string][]config.ChannelAccount {
|
||||
if accounts == nil {
|
||||
accounts = map[string][]config.ChannelAccount{}
|
||||
}
|
||||
if strings.TrimSpace(acct.ID) == "" {
|
||||
acct.ID = NewID()
|
||||
}
|
||||
list := accounts[platform]
|
||||
out := make([]config.ChannelAccount, 0, len(list)+1)
|
||||
replaced := false
|
||||
for _, cur := range list {
|
||||
if cur.ID == acct.ID {
|
||||
out = append(out, acct)
|
||||
replaced = true
|
||||
continue
|
||||
}
|
||||
out = append(out, cur)
|
||||
}
|
||||
if !replaced {
|
||||
out = append(out, acct)
|
||||
}
|
||||
accounts[platform] = out
|
||||
return accounts
|
||||
}
|
||||
|
||||
// DeleteChannel returns accounts with the account of the given id removed from
|
||||
// its platform. When the platform has no remaining accounts the platform key is
|
||||
// dropped entirely, so a reload of that platform yields no adapters.
|
||||
func DeleteChannel(accounts map[string][]config.ChannelAccount, platform, id string) map[string][]config.ChannelAccount {
|
||||
if accounts == nil {
|
||||
return map[string][]config.ChannelAccount{}
|
||||
}
|
||||
out := make([]config.ChannelAccount, 0, len(accounts[platform]))
|
||||
for _, cur := range accounts[platform] {
|
||||
if cur.ID == id {
|
||||
continue
|
||||
}
|
||||
out = append(out, cur)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
delete(accounts, platform)
|
||||
} else {
|
||||
accounts[platform] = out
|
||||
}
|
||||
return accounts
|
||||
}
|
||||
|
||||
// Masked returns a copy of acct whose Config has every secret value redacted,
|
||||
// for display/logging. It never reveals more than the trailing 4 characters of a
|
||||
// token, so a secret never lands verbatim in a wire response or a log line. The
|
||||
// original account (and the channels.json on disk) is untouched.
|
||||
func Masked(acct config.ChannelAccount) config.ChannelAccount {
|
||||
out := acct
|
||||
out.Config = maskConfig(acct.Config)
|
||||
return out
|
||||
}
|
||||
|
||||
// maskConfig redacts the values of sensitive keys in a config map. If a sensitive
|
||||
// value is not a string it is passed through unchanged (never a token).
|
||||
func maskConfig(m map[string]any) map[string]any {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(m))
|
||||
for k, v := range m {
|
||||
if isSensitiveKey(k) {
|
||||
if s, ok := v.(string); ok {
|
||||
out[k] = maskToken(s)
|
||||
continue
|
||||
}
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isSensitiveKey reports whether a config key holds a secret. It matches the
|
||||
// bot_token/access_token/secret/api_key family used across the adapters.
|
||||
func isSensitiveKey(k string) bool {
|
||||
lk := strings.ToLower(k)
|
||||
for _, sub := range tokenKeys {
|
||||
if strings.Contains(lk, sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// maskToken redacts a token to the trailing 4 characters (or all-asterisks when
|
||||
// it is 4 characters or fewer).
|
||||
func maskToken(s string) string {
|
||||
if len(s) <= 4 {
|
||||
return "****"
|
||||
}
|
||||
return "****" + s[len(s)-4:]
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
|
||||
func TestLoadChannelsFileWins(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "channels.json")
|
||||
if err := os.WriteFile(path, []byte(`{"kook":[{"id":"a","config":{"bot_token":"tk"}}]}`), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
accounts, warn := LoadChannels(path, map[string][]config.ChannelAccount{
|
||||
"telegram": {{ID: "env", Config: map[string]any{"bot_token": "env"}}},
|
||||
})
|
||||
if warn != nil {
|
||||
t.Fatalf("warn = %v, want nil", warn)
|
||||
}
|
||||
if len(accounts["kook"]) != 1 || accounts["kook"][0].ID != "a" || accounts["kook"][0].Config["bot_token"] != "tk" {
|
||||
t.Fatalf("file accounts not used: %+v", accounts)
|
||||
}
|
||||
if _, ok := accounts["telegram"]; ok {
|
||||
t.Fatalf("file should win over env fallback, telegram leaked in: %+v", accounts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadChannelsMissingFallsBackToEnv(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "nope.json")
|
||||
fallback := map[string][]config.ChannelAccount{"telegram": {{ID: "env", Config: map[string]any{"bot_token": "tk"}}}}
|
||||
accounts, warn := LoadChannels(path, fallback)
|
||||
if warn != nil {
|
||||
t.Fatalf("warn = %v, want nil for missing file", warn)
|
||||
}
|
||||
if len(accounts["telegram"]) != 1 || accounts["telegram"][0].ID != "env" {
|
||||
t.Fatalf("fallback not used: %+v", accounts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadChannelsInvalidFallsBackWithWarn(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "channels.json")
|
||||
if err := os.WriteFile(path, []byte(`{not-json`), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
fallback := map[string][]config.ChannelAccount{"kook": {{Config: map[string]any{}}}}
|
||||
accounts, warn := LoadChannels(path, fallback)
|
||||
if warn == nil {
|
||||
t.Fatal("warn = nil, want a warning for an invalid file")
|
||||
}
|
||||
if len(accounts["kook"]) != 1 {
|
||||
t.Fatalf("invalid file did not fall back to env: %+v", accounts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadChannelsEmptyFileFallsBackWithWarn(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "channels.json")
|
||||
if err := os.WriteFile(path, []byte(" \n"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
fallback := map[string][]config.ChannelAccount{"kook": {{Config: map[string]any{}}}}
|
||||
accounts, warn := LoadChannels(path, fallback)
|
||||
if warn == nil {
|
||||
t.Fatal("warn = nil, want a warning for an empty file")
|
||||
}
|
||||
if len(accounts["kook"]) != 1 {
|
||||
t.Fatalf("empty file did not fall back to env: %+v", accounts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadChannelsEmptyMapFallsBackWithWarn(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "channels.json")
|
||||
if err := os.WriteFile(path, []byte(`{}`), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
fallback := map[string][]config.ChannelAccount{"kook": {{Config: map[string]any{}}}}
|
||||
accounts, warn := LoadChannels(path, fallback)
|
||||
if warn == nil {
|
||||
t.Fatal("warn = nil, want a warning for an empty map")
|
||||
}
|
||||
if len(accounts["kook"]) != 1 {
|
||||
t.Fatalf("empty map did not fall back to env: %+v", accounts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveChannelsRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "channels.json")
|
||||
accts := map[string][]config.ChannelAccount{
|
||||
"kook": {{ID: "a", Config: map[string]any{"bot_token": "tk"}}},
|
||||
}
|
||||
if err := SaveChannels(path, accts); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
got, warn := LoadChannels(path, nil)
|
||||
if warn != nil {
|
||||
t.Fatalf("load warn = %v", warn)
|
||||
}
|
||||
if len(got["kook"]) != 1 || got["kook"][0].Config["bot_token"] != "tk" {
|
||||
t.Fatalf("round trip mismatch: %+v", got)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if info.Mode().Perm()&0o077 != 0 {
|
||||
t.Fatalf("channels.json mode = %v, want owner-only (0600)", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertChannelAssignsIDAndReplaces(t *testing.T) {
|
||||
t.Parallel()
|
||||
accounts := map[string][]config.ChannelAccount{
|
||||
"kook": {{ID: "a", Config: map[string]any{"bot_token": "old"}}},
|
||||
}
|
||||
// Blank ID → generated.
|
||||
added := UpsertChannel(accounts, "kook", config.ChannelAccount{Config: map[string]any{"bot_token": "new"}})
|
||||
if len(added["kook"]) != 2 {
|
||||
t.Fatalf("want 2 accounts after add, got %d", len(added["kook"]))
|
||||
}
|
||||
if added["kook"][1].ID == "" {
|
||||
t.Fatal("new account should get a generated id")
|
||||
}
|
||||
// Same ID → replaced in place.
|
||||
replaced := UpsertChannel(accounts, "kook", config.ChannelAccount{ID: "a", Config: map[string]any{"bot_token": "updated"}})
|
||||
if len(replaced["kook"]) != 2 {
|
||||
t.Fatalf("want 2 accounts after replace, got %d", len(replaced["kook"]))
|
||||
}
|
||||
if replaced["kook"][0].Config["bot_token"] != "updated" {
|
||||
t.Fatalf("replace did not update in place: %+v", replaced["kook"][0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteChannelRemovesAccountAndDropsPlatform(t *testing.T) {
|
||||
t.Parallel()
|
||||
accounts := map[string][]config.ChannelAccount{
|
||||
"kook": {{ID: "a", Config: map[string]any{}}, {ID: "b", Config: map[string]any{}}},
|
||||
"telegram": {{ID: "t", Config: map[string]any{}}},
|
||||
}
|
||||
out := DeleteChannel(accounts, "kook", "a")
|
||||
if len(out["kook"]) != 1 || out["kook"][0].ID != "b" {
|
||||
t.Fatalf("kook after delete: %+v", out["kook"])
|
||||
}
|
||||
// Deleting the last account drops the platform key entirely.
|
||||
out2 := DeleteChannel(out, "kook", "b")
|
||||
if _, ok := out2["kook"]; ok {
|
||||
t.Fatalf("kook should be dropped when empty: %+v", out2)
|
||||
}
|
||||
// Other platforms untouched.
|
||||
if len(out2["telegram"]) != 1 {
|
||||
t.Fatalf("telegram affected: %+v", out2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskedRedactsTokensKeepsOriginal(t *testing.T) {
|
||||
t.Parallel()
|
||||
acct := config.ChannelAccount{
|
||||
ID: "a",
|
||||
Config: map[string]any{"bot_token": "super-secret-token", "base_url": "https://x", "access_token": "abc", "secret": "s"},
|
||||
DenyUsers: []string{"u1"},
|
||||
}
|
||||
masked := Masked(acct)
|
||||
if got := masked.Config["bot_token"]; got != "****oken" {
|
||||
t.Fatalf("bot_token = %v, want masked", got)
|
||||
}
|
||||
if got := masked.Config["access_token"]; got != "****" {
|
||||
t.Fatalf("access_token = %v, want fully masked", got)
|
||||
}
|
||||
if got := masked.Config["secret"]; got != "****" {
|
||||
t.Fatalf("secret = %v, want fully masked", got)
|
||||
}
|
||||
if got := masked.Config["base_url"]; got != "https://x" {
|
||||
t.Fatalf("base_url = %v, want unchanged", got)
|
||||
}
|
||||
if masked.DenyUsers[0] != "u1" {
|
||||
t.Fatalf("deny user list lost: %+v", masked)
|
||||
}
|
||||
// The original account still carries the raw token.
|
||||
if acct.Config["bot_token"] != "super-secret-token" {
|
||||
t.Fatalf("Masked mutated the original account")
|
||||
}
|
||||
if strings.Contains(masked.Config["bot_token"].(string), "super-secret") {
|
||||
t.Fatalf("masked leaked the full token: %v", masked.Config["bot_token"])
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user