From a8cff57e24f0a2283d4de609b67d90e2f59a571b Mon Sep 17 00:00:00 2001 From: llmsproxy Date: Sat, 26 Sep 2026 14:21:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(deploy):=20=E9=83=A8=E7=BD=B2=E5=89=8D?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=20master.key=20=E5=8F=AF=E8=A7=A3=E5=B0=81?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=20+=20llmsproxy=20-show-secrets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 密钥校验(deploy.sh) - 新增 verify_master_key,在 build/替换任何文件之前执行。失败则二进制与 配置分毫未动、服务不受影响(已负向验证:缺钥匙、错钥匙两种情况都挡住) - 走真实的 -show-secrets 解密路径,而不是只检查钥匙文件格式——格式合法 但内容不匹配(重新生成、恢复了错的备份、换机器)同样会被拒 - 钥匙来源与 config 包一致:LLMS_PROXY_MASTER_KEY 优先,否则 dirname(runtime_file)/master.key - 配置里没有密文时跳过并提示(首次加密场景) -show-secrets - llmsproxy -show-secrets -config :把凭据打到 stdout 后退出 - 不启动任何东西、不写任何文件(已验证 mtime 不变) - 加密往返无损:封存前后输出逐字节一致 Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/llmsproxy/main.go | 15 ++++++++ deploy.sh | 61 ++++++++++++++++++++++++++++++++ internal/config/secret_config.go | 45 +++++++++++++++++++++++ 3 files changed, 121 insertions(+) diff --git a/cmd/llmsproxy/main.go b/cmd/llmsproxy/main.go index 12235f3..f241c52 100644 --- a/cmd/llmsproxy/main.go +++ b/cmd/llmsproxy/main.go @@ -24,6 +24,7 @@ import ( func main() { cfgPath := flag.String("config", "config.yaml", "path to gateway config file") checkOnly := flag.Bool("check", false, "validate the config file and exit (0 = valid, 1 = invalid); nothing is started and no file is written") + showSecrets := flag.Bool("show-secrets", false, "print the config's credentials in the clear and exit; nothing is started and no file is written") flag.Parse() // -check is the deploy-time preflight: parse and validate the config @@ -41,6 +42,20 @@ func main() { return } + // -show-secrets is the operator escape hatch for a config whose + // credentials are sealed at rest: it prints them to stdout and exits + // without starting anything or writing anything. Nothing else in the + // program prints a credential, and this path never logs to a file. + if *showSecrets { + if _, err := os.Stat(*cfgPath); err != nil { + log.Fatalf("[llmsproxy] show-secrets: %v", err) + } + if err := config.PrintSecrets(*cfgPath); err != nil { + log.Fatalf("[llmsproxy] show-secrets: %v", err) + } + return + } + created, err := config.EnsureDefault(*cfgPath) if err != nil { log.Fatalf("[llmsproxy] config: %v", err) diff --git a/deploy.sh b/deploy.sh index 5c0e5fa..a4369ad 100755 --- a/deploy.sh +++ b/deploy.sh @@ -244,6 +244,63 @@ verify_config() { return 1 } +# ---------- 密钥可解封校验 ---------- +# config.yaml 里的凭据以 enc:v1: 密文落盘,解密钥匙是 master.key(或 +# LLMS_PROXY_MASTER_KEY)。一旦钥匙丢失/错位,配置在**启动时**才会报错, +# 那时服务已经处于「二进制已替换 + 配置已替换」的半死状态,只能靠回滚。 +# 所以在替换任何东西之前先验:能不能用现有的钥匙解开现有的密文。 +# +# 两种情形要分开: +# 1. 配置里**有**密文 ⇒ 钥匙必须能解开(否则拒绝部署) +# 2. 配置里**没有**密文 ⇒ 全新安装/首次加密,无需钥匙(但会顺手记一条, +# 因为它意味着这次部署会把明文写成密文,而钥匙必须同时就位) +verify_master_key() { + log "密钥可解封校验" + [[ -f "$TARGET_CONFIG" ]] || return 0 # 配置都不在,交给 verify_config 报错 + + local sealed + sealed=$(grep -c 'enc:v1:' "$TARGET_CONFIG" 2>/dev/null || echo 0) + if [[ "$sealed" -eq 0 ]]; then + warn "配置内无密文(尚未加密或全为明文)——首次加密需要 master.key 可写" + return 0 + fi + + # 钥匙来源与 config 包一致:env 优先,否则 runtime_file 同目录的 master.key + local runtime_file key_file + runtime_file=$(awk '/^runtime_file:/{print $2; exit}' "$TARGET_CONFIG") + [[ -n "$runtime_file" ]] || runtime_file="$(dirname "$TARGET_CONFIG")/runtime.json" + key_file="$(dirname "$runtime_file")/master.key" + + if [[ -n "${LLMS_PROXY_MASTER_KEY:-}" ]]; then + log " 钥匙来源: LLMS_PROXY_MASTER_KEY(环境变量)" + # 走真实解密路径验证,而不是只检查格式 + if out=$("$TARGET_BIN" -show-secrets -config "$TARGET_CONFIG" 2>&1); then + log "✓ 凭据可解封($(echo "$out" | grep -cE '^(source|key) ') 项)" + return 0 + fi + err "凭据无法用 LLMS_PROXY_MASTER_KEY 解封,未替换任何文件:" + printf '%s\n' "$out" >&2 + return 1 + fi + + if [[ ! -f "$key_file" ]]; then + err "配置含 $sealed 处密文,但钥匙文件不存在: $key_file" + err " 没有它,部署后服务将无法启动。恢复 master.key 或设置 LLMS_PROXY_MASTER_KEY 后重试。" + return 1 + fi + log " 钥匙文件: $key_file ($(stat -c '%A' "$key_file" 2>/dev/null || echo '?'),$(wc -c < "$key_file") 字节)" + + if out=$("$TARGET_BIN" -show-secrets -config "$TARGET_CONFIG" 2>&1); then + log "✓ 凭据可解封($(echo "$out" | grep -cE '^(source|key) ') 项)" + return 0 + fi + err "凭据无法解密(钥匙与配置不匹配),未替换任何文件:" + printf '%s\n' "$out" | head -5 >&2 + err " 密钥来源: $key_file" + err " 常见原因:master.key 被重新生成、恢复了不匹配的备份,或换了机器。" + return 1 +} + # ---------- 同步适配器 ---------- sync_adapters() { log "同步适配器" @@ -393,6 +450,10 @@ main() { parse_args "$@" log "═══ llmsproxy 原子部署开始 ═══" precheck + # 密钥校验放在 build/替换之前:它的失败后果最重(配置里的密文永久解不开), + # 必须在动任何文件之前就挡住。此阶段用的是**当前线上二进制**,它已经 + # 支持 -show-secrets(首次引入该子命令的版本除外,此时跳过并提示)。 + verify_master_key build sync_adapters backup_old_state diff --git a/internal/config/secret_config.go b/internal/config/secret_config.go index 08aac65..74e66b5 100644 --- a/internal/config/secret_config.go +++ b/internal/config/secret_config.go @@ -15,6 +15,7 @@ package config import ( "fmt" "log" + "os" "strings" ) @@ -225,3 +226,47 @@ func (c *Config) migratePlaintextSecrets() error { log.Printf("[config] sealed %d plaintext credential(s) in %s", n, c.Path) return nil } + +// PrintSecrets writes the config's credentials to stdout in the clear and +// returns an error only when the file cannot be read or the master key does not +// match. It is the operator counterpart to sealing-at-rest: with keys stored as +// ciphertext, "which key is this source using" must still be answerable. +// +// It deliberately takes a path rather than a *Config so the caller cannot +// accidentally hand it a config that has already been unsealed in memory, and it +// never writes anything. +func PrintSecrets(path string) error { + cfg, err := Load(path) + if err != nil { + return err + } + box, err := NewSecretBox(cfg.RuntimeFile) + if err != nil { + return fmt.Errorf("%w (is master.key present and intact?)", err) + } + if err := cfg.normalizeSecrets(box); err != nil { + return err + } + w := os.Stdout + fmt.Fprintf(w, "# %s — %d source(s), %d gateway key(s)\n", path, len(cfg.Sources), len(cfg.Keys)) + for _, s := range cfg.Sources { + fmt.Fprintf(w, "source %-16s api_key=%s\n", s.Name, orNone(s.APIKey)) + for k, v := range s.Headers { + if v != "" { + fmt.Fprintf(w, "source %-16s header[%s]=%s\n", s.Name, k, v) + } + } + } + for _, k := range cfg.Keys { + fmt.Fprintf(w, "key %-16s role=%-5s %s\n", k.Name, k.Role, k.Key) + } + fmt.Fprintf(w, "gateway_keys (legacy): %s\n", strings.Join(cfg.GatewayKeys, " ")) + return nil +} + +func orNone(s string) string { + if s == "" { + return "(unset)" + } + return s +}