Why: The sandbox hides the host home directory and has no network, so pip install --user cannot supply python-docx, and the service PATH resolved python3 to a linuxbrew path that does not exist inside the sandbox. Evidence: SELF go test of execenv, tools, store, app, server, runmgr, config, and cmd/agent-system on this worktree. CI has not run. Impact: Sessions inherit a read-only base layer. Extra packages are requested through python_env, auto-approved only from the allowlist, and mounted per conversation. Schema 35 adds the request and binding tables. Refs: .task/feature-layered-python-env.md Co-authored-by: Cursor <cursoragent@cursor.com>
576 lines
16 KiB
Go
576 lines
16 KiB
Go
// Package config 提供 agent-system 启动层配置的单一解析管道:
|
||
// 内置默认 → 显式配置文件 → 环境变量 → 显式 CLI。
|
||
// 规范依据:Development-Guidelines standards/configuration.md(单一解析管道/启动依赖);
|
||
// 运行时业务设置(/api/settings)是独立的"产品业务策略"层,不在此合并。
|
||
// 只读诊断见 Resolved.Explain:不建数据库、不写业务目录,DSN 脱敏输出。
|
||
package config
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net"
|
||
"os"
|
||
"strconv"
|
||
"strings"
|
||
)
|
||
|
||
// Config 启动部署连接类字段(与 app.Options 一一对应)。
|
||
type Config struct {
|
||
Addr string `json:"addr"`
|
||
DataDir string `json:"data"`
|
||
PluginsDir string `json:"plugins"`
|
||
StorageDSN string `json:"dsn"`
|
||
ArtifactsDir string `json:"artifacts"`
|
||
AuthMode string `json:"auth"`
|
||
Mock bool `json:"mock"`
|
||
Instance string `json:"instance"`
|
||
InsecureListen bool `json:"insecureListen"`
|
||
AllowedHostsRaw string `json:"allowedHosts"`
|
||
// DisableExecTools 启动级硬关闭执行原语(用户设置 runtime.exec 无法覆盖)。
|
||
DisableExecTools bool `json:"disableExecTools"`
|
||
// ExecBackend 执行后端(EXT-16):direct(空 = 现状直启)| sandbox(bwrap
|
||
// 隔离档)。DisableExecTools 优先级高于后端选择。
|
||
ExecBackend string `json:"execBackend"`
|
||
// ExecDepsDir sandbox 档只读依赖层目录(空 = 不挂载)。
|
||
ExecDepsDir string `json:"execDepsDir"`
|
||
// ExecWorkspace sandbox 档工作区模式:空=直写;overlay=冻结基底+upper。
|
||
ExecWorkspace string `json:"execWorkspace"`
|
||
// ExecEnvRoot 分层 Python 环境根。空 = 不启用申请代理。
|
||
ExecEnvRoot string `json:"execEnvRoot"`
|
||
// Tier 部署档(协作存储方案 KTD1):空=auto(DSN→PG,否则 SQLite);
|
||
// local=强制 SQLite(不允许 DSN);team=强制 PG(缺 DSN/未开认证拒绝启动,
|
||
// PG 故障拒绝就绪,绝不回落 SQLite)。
|
||
Tier string `json:"tier"`
|
||
// PG 连接预算(KTD12):零值走默认(16/8/300s/不限/不限);负值非法。
|
||
// PGQueryTimeoutSec>0 时以 statement_timeout 附着到 PG DSN。
|
||
PGMaxOpen int `json:"pgMaxOpen"`
|
||
PGMaxIdle int `json:"pgMaxIdle"`
|
||
PGConnMaxIdleSec int `json:"pgConnMaxIdleSec"`
|
||
PGConnMaxLifetimeSec int `json:"pgConnMaxLifetimeSec"`
|
||
PGQueryTimeoutSec int `json:"pgQueryTimeoutSec"`
|
||
}
|
||
|
||
var defaults = Config{
|
||
Addr: "127.0.0.1:8787",
|
||
DataDir: "./data",
|
||
PluginsDir: "./plugins",
|
||
AuthMode: "auto",
|
||
}
|
||
|
||
// envKey 字段 → 环境变量名(AGENT_SYSTEM_ 前缀)。
|
||
var envKey = map[string]string{
|
||
"addr": "AGENT_SYSTEM_ADDR",
|
||
"data": "AGENT_SYSTEM_DATA",
|
||
"plugins": "AGENT_SYSTEM_PLUGINS",
|
||
"dsn": "AGENT_SYSTEM_DSN",
|
||
"artifacts": "AGENT_SYSTEM_ARTIFACTS",
|
||
"auth": "AGENT_SYSTEM_AUTH",
|
||
"mock": "AGENT_SYSTEM_MOCK",
|
||
"instance": "AGENT_SYSTEM_INSTANCE",
|
||
"execBackend": "AGENT_SYSTEM_EXEC_BACKEND",
|
||
"execDepsDir": "AGENT_SYSTEM_EXEC_DEPS_DIR",
|
||
"execWorkspace": "AGENT_SYSTEM_EXEC_WORKSPACE",
|
||
"execEnvRoot": "AGENT_SYSTEM_EXEC_ENV_ROOT",
|
||
"insecureListen": "AGENT_SYSTEM_INSECURE_LISTEN",
|
||
"allowedHosts": "AGENT_SYSTEM_ALLOWED_HOSTS",
|
||
"disableExecTools": "AGENT_SYSTEM_DISABLE_EXEC_TOOLS",
|
||
"tier": "AGENT_SYSTEM_TIER",
|
||
"pgMaxOpen": "AGENT_SYSTEM_PG_MAX_OPEN",
|
||
"pgMaxIdle": "AGENT_SYSTEM_PG_MAX_IDLE",
|
||
"pgConnMaxIdleSec": "AGENT_SYSTEM_PG_CONN_MAX_IDLE_SEC",
|
||
"pgConnMaxLifetimeSec": "AGENT_SYSTEM_PG_CONN_MAX_LIFETIME_SEC",
|
||
"pgQueryTimeoutSec": "AGENT_SYSTEM_PG_QUERY_TIMEOUT_SEC",
|
||
}
|
||
|
||
// Source 配置来源层级(数值越大优先级越高)。
|
||
type Source int
|
||
|
||
const (
|
||
SrcDefault Source = iota
|
||
SrcFile
|
||
SrcEnv
|
||
SrcCLI
|
||
)
|
||
|
||
func (s Source) String() string {
|
||
switch s {
|
||
case SrcFile:
|
||
return "file"
|
||
case SrcEnv:
|
||
return "env"
|
||
case SrcCLI:
|
||
return "cli"
|
||
}
|
||
return "default"
|
||
}
|
||
|
||
// Resolved 解析结果:配置快照 + 每字段来源(供 explain 与测试断言)。
|
||
type Resolved struct {
|
||
Config
|
||
sources map[string]Source
|
||
}
|
||
|
||
// SourceOf 返回字段来源;未知字段名返回 SrcDefault。
|
||
func (r Resolved) SourceOf(field string) Source { return r.sources[field] }
|
||
|
||
// Load 合并四源。file 为空表示无配置文件层;非空而缺失/未知键/类型错误均为错误,
|
||
// 错误信息须定位到字段。cliSet 记录显式传入的 CLI 字段(flag.Visit 语义):
|
||
// 未显式传入的 CLI 字段不参与覆盖(区分"未设置"与零值)。
|
||
func Load(file string, envLookup func(string) (string, bool), cli Config, cliSet map[string]bool) (Resolved, error) {
|
||
cfg := defaults
|
||
src := map[string]Source{}
|
||
for f := range envKey {
|
||
src[f] = SrcDefault
|
||
}
|
||
if file != "" {
|
||
fc, present, err := loadFile(file)
|
||
if err != nil {
|
||
return Resolved{}, err
|
||
}
|
||
// 文件层按"键存在"覆盖(区分未出现与显式 false/空串;空值有效性由字段语义判定)。
|
||
for f := range present {
|
||
setField(&cfg, f, fc)
|
||
src[f] = SrcFile
|
||
}
|
||
}
|
||
// 环境层:变量存在即参与覆盖(空串按显式空值处理,字段 schema 自行判定)。
|
||
for f, key := range envKey {
|
||
if v, ok := envLookup(key); ok {
|
||
if isBoolField(f) {
|
||
b, err := parseBool(key, v)
|
||
if err != nil {
|
||
return Resolved{}, err
|
||
}
|
||
setBool(&cfg, f, b)
|
||
} else if isIntField(f) {
|
||
n, err := parseInt(key, v)
|
||
if err != nil {
|
||
return Resolved{}, err
|
||
}
|
||
setInt(&cfg, f, n)
|
||
} else {
|
||
setString(&cfg, f, v)
|
||
}
|
||
src[f] = SrcEnv
|
||
}
|
||
}
|
||
// CLI 层:仅显式传入。
|
||
for f := range cliSet {
|
||
src[f] = SrcCLI
|
||
setField(&cfg, f, cli)
|
||
}
|
||
return Resolved{Config: cfg, sources: src}, nil
|
||
}
|
||
|
||
// setField 从同类型快照覆盖单个字段(文件/CLI 层复用;字段类型由字段名决定)。
|
||
func setField(dst *Config, f string, src Config) {
|
||
if isBoolField(f) {
|
||
setBool(dst, f, boolField(src, f))
|
||
} else if isIntField(f) {
|
||
setInt(dst, f, intField(src, f))
|
||
} else {
|
||
setString(dst, f, stringField(src, f))
|
||
}
|
||
}
|
||
|
||
func loadFile(path string) (Config, map[string]bool, error) {
|
||
raw, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return Config{}, nil, fmt.Errorf("config file %s: %w", path, err)
|
||
}
|
||
var m map[string]json.RawMessage
|
||
if err := json.Unmarshal(raw, &m); err != nil {
|
||
return Config{}, nil, fmt.Errorf("config file %s: %w", path, err)
|
||
}
|
||
present := map[string]bool{}
|
||
var c Config
|
||
for k, v := range m {
|
||
var dst any
|
||
switch k {
|
||
case "addr":
|
||
dst = &c.Addr
|
||
case "data":
|
||
dst = &c.DataDir
|
||
case "plugins":
|
||
dst = &c.PluginsDir
|
||
case "dsn":
|
||
dst = &c.StorageDSN
|
||
case "artifacts":
|
||
dst = &c.ArtifactsDir
|
||
case "auth":
|
||
dst = &c.AuthMode
|
||
case "mock":
|
||
dst = &c.Mock
|
||
case "instance":
|
||
dst = &c.Instance
|
||
case "insecureListen":
|
||
dst = &c.InsecureListen
|
||
case "allowedHosts":
|
||
dst = &c.AllowedHostsRaw
|
||
case "disableExecTools":
|
||
dst = &c.DisableExecTools
|
||
case "execBackend":
|
||
dst = &c.ExecBackend
|
||
case "execDepsDir":
|
||
dst = &c.ExecDepsDir
|
||
case "execWorkspace":
|
||
dst = &c.ExecWorkspace
|
||
case "execEnvRoot":
|
||
dst = &c.ExecEnvRoot
|
||
case "tier":
|
||
dst = &c.Tier
|
||
case "pgMaxOpen":
|
||
dst = &c.PGMaxOpen
|
||
case "pgMaxIdle":
|
||
dst = &c.PGMaxIdle
|
||
case "pgConnMaxIdleSec":
|
||
dst = &c.PGConnMaxIdleSec
|
||
case "pgConnMaxLifetimeSec":
|
||
dst = &c.PGConnMaxLifetimeSec
|
||
case "pgQueryTimeoutSec":
|
||
dst = &c.PGQueryTimeoutSec
|
||
default:
|
||
return Config{}, nil, fmt.Errorf("config file %s: unknown key %q", path, k)
|
||
}
|
||
if err := json.Unmarshal(v, dst); err != nil {
|
||
return Config{}, nil, fmt.Errorf("config file %s: key %q: %w", path, k, err)
|
||
}
|
||
present[k] = true
|
||
}
|
||
return c, present, nil
|
||
}
|
||
|
||
func stringField(c Config, f string) string {
|
||
switch f {
|
||
case "addr":
|
||
return c.Addr
|
||
case "data":
|
||
return c.DataDir
|
||
case "plugins":
|
||
return c.PluginsDir
|
||
case "dsn":
|
||
return c.StorageDSN
|
||
case "artifacts":
|
||
return c.ArtifactsDir
|
||
case "auth":
|
||
return c.AuthMode
|
||
case "instance":
|
||
return c.Instance
|
||
case "allowedHosts":
|
||
return c.AllowedHostsRaw
|
||
case "execBackend":
|
||
return c.ExecBackend
|
||
case "execDepsDir":
|
||
return c.ExecDepsDir
|
||
case "execWorkspace":
|
||
return c.ExecWorkspace
|
||
case "execEnvRoot":
|
||
return c.ExecEnvRoot
|
||
case "tier":
|
||
return c.Tier
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func isBoolField(f string) bool {
|
||
return f == "mock" || f == "insecureListen" || f == "disableExecTools"
|
||
}
|
||
|
||
func isIntField(f string) bool {
|
||
switch f {
|
||
case "pgMaxOpen", "pgMaxIdle", "pgConnMaxIdleSec", "pgConnMaxLifetimeSec", "pgQueryTimeoutSec":
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func intField(c Config, f string) int {
|
||
switch f {
|
||
case "pgMaxOpen":
|
||
return c.PGMaxOpen
|
||
case "pgMaxIdle":
|
||
return c.PGMaxIdle
|
||
case "pgConnMaxIdleSec":
|
||
return c.PGConnMaxIdleSec
|
||
case "pgConnMaxLifetimeSec":
|
||
return c.PGConnMaxLifetimeSec
|
||
case "pgQueryTimeoutSec":
|
||
return c.PGQueryTimeoutSec
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func setInt(c *Config, f string, v int) {
|
||
switch f {
|
||
case "pgMaxOpen":
|
||
c.PGMaxOpen = v
|
||
case "pgMaxIdle":
|
||
c.PGMaxIdle = v
|
||
case "pgConnMaxIdleSec":
|
||
c.PGConnMaxIdleSec = v
|
||
case "pgConnMaxLifetimeSec":
|
||
c.PGConnMaxLifetimeSec = v
|
||
case "pgQueryTimeoutSec":
|
||
c.PGQueryTimeoutSec = v
|
||
}
|
||
}
|
||
|
||
func boolField(c Config, f string) bool {
|
||
switch f {
|
||
case "insecureListen":
|
||
return c.InsecureListen
|
||
case "disableExecTools":
|
||
return c.DisableExecTools
|
||
default:
|
||
return c.Mock
|
||
}
|
||
}
|
||
|
||
func setBool(c *Config, f string, v bool) {
|
||
switch f {
|
||
case "insecureListen":
|
||
c.InsecureListen = v
|
||
case "disableExecTools":
|
||
c.DisableExecTools = v
|
||
default:
|
||
c.Mock = v
|
||
}
|
||
}
|
||
|
||
func setString(c *Config, f, v string) {
|
||
switch f {
|
||
case "addr":
|
||
c.Addr = v
|
||
case "data":
|
||
c.DataDir = v
|
||
case "plugins":
|
||
c.PluginsDir = v
|
||
case "dsn":
|
||
c.StorageDSN = v
|
||
case "artifacts":
|
||
c.ArtifactsDir = v
|
||
case "auth":
|
||
c.AuthMode = v
|
||
case "instance":
|
||
c.Instance = v
|
||
case "allowedHosts":
|
||
c.AllowedHostsRaw = v
|
||
case "execBackend":
|
||
c.ExecBackend = v
|
||
case "execDepsDir":
|
||
c.ExecDepsDir = v
|
||
case "execWorkspace":
|
||
c.ExecWorkspace = v
|
||
case "execEnvRoot":
|
||
c.ExecEnvRoot = v
|
||
case "tier":
|
||
c.Tier = v
|
||
}
|
||
}
|
||
|
||
func parseBool(name, v string) (bool, error) {
|
||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||
case "true", "1":
|
||
return true, nil
|
||
case "false", "0":
|
||
return false, nil
|
||
}
|
||
return false, fmt.Errorf("env %s: invalid bool %q", name, v)
|
||
}
|
||
|
||
func parseInt(name, v string) (int, error) {
|
||
n, err := strconv.Atoi(strings.TrimSpace(v))
|
||
if err != nil {
|
||
return 0, fmt.Errorf("env %s: invalid int %q", name, v)
|
||
}
|
||
return n, nil
|
||
}
|
||
|
||
// Explain 只读诊断:逐字段打印解析值与来源层级,DSN 脱敏。
|
||
func (r Resolved) Explain(w io.Writer, file string) {
|
||
if file != "" {
|
||
fmt.Fprintf(w, "config file: %s\n", file)
|
||
} else {
|
||
fmt.Fprintln(w, "config file: (none)")
|
||
}
|
||
fmt.Fprintln(w, "field value source")
|
||
for _, f := range []string{"addr", "data", "plugins", "dsn", "artifacts", "auth", "mock", "instance", "insecureListen", "allowedHosts", "disableExecTools", "execBackend", "execDepsDir", "execWorkspace", "execEnvRoot", "tier", "pgMaxOpen", "pgMaxIdle", "pgConnMaxIdleSec", "pgConnMaxLifetimeSec", "pgQueryTimeoutSec"} {
|
||
var v string
|
||
if isBoolField(f) {
|
||
v = fmt.Sprintf("%t", boolField(r.Config, f))
|
||
} else if isIntField(f) {
|
||
n := intField(r.Config, f)
|
||
if d := intDefault(f); n == 0 && d != 0 {
|
||
v = fmt.Sprintf("%d (默认)", d)
|
||
} else {
|
||
v = fmt.Sprintf("%d", n)
|
||
}
|
||
} else {
|
||
v = stringField(r.Config, f)
|
||
}
|
||
if f == "dsn" {
|
||
v = redact(v)
|
||
}
|
||
fmt.Fprintf(w, "%-16s %-56s %s\n", f, v, r.sources[f])
|
||
}
|
||
if msgs := r.TierProblems(); len(msgs) > 0 {
|
||
for _, msg := range msgs {
|
||
fmt.Fprintf(w, "tier guard: 拒绝启动(%s)\n", msg)
|
||
}
|
||
} else {
|
||
fmt.Fprintln(w, "tier guard: 放行")
|
||
}
|
||
if msg := r.ListenRefusal(); msg != "" {
|
||
fmt.Fprintf(w, "listen guard: 拒绝启动(%s)\n", msg)
|
||
} else if r.InsecureListen && !IsLoopbackListen(r.Addr) && !r.AuthEnabled() {
|
||
fmt.Fprintln(w, "listen guard: 放行(已显式 insecureListen,无认证监听非回环地址)")
|
||
} else {
|
||
fmt.Fprintln(w, "listen guard: 放行")
|
||
}
|
||
}
|
||
|
||
// AuthEnabled 是认证开关的唯一判定:显式 on,或 auto 且配置了 PostgreSQL。
|
||
func (r Resolved) AuthEnabled() bool {
|
||
return r.AuthMode == "on" || (r.AuthMode != "off" && r.StorageDSN != "")
|
||
}
|
||
|
||
// intDefault 返回 int 字段的默认值(0 表示零值合法、无默认替换)。
|
||
func intDefault(f string) int {
|
||
switch f {
|
||
case "pgMaxOpen":
|
||
return DefaultPGMaxOpen
|
||
case "pgMaxIdle":
|
||
return DefaultPGMaxIdle
|
||
case "pgConnMaxIdleSec":
|
||
return DefaultPGConnMaxIdleSec
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// 连接预算默认值(KTD12;与 store 层默认保持一致,两处同时修改时以测试对齐)。
|
||
const (
|
||
DefaultPGMaxOpen = 16
|
||
DefaultPGMaxIdle = 8
|
||
DefaultPGConnMaxIdleSec = 300
|
||
)
|
||
|
||
// TierProblems 部署档语义校验:返回阻止启动的问题列表(空 = 放行)。
|
||
// team 档 fail-closed:缺 DSN、未开认证直接拒绝;local 档不允许 DSN(KTD1)。
|
||
// 执行隔离在 team 档是启动警告而非拒绝(本增量先可见、U3/U5 再硬化)。
|
||
func (r Resolved) TierProblems() []string {
|
||
var msgs []string
|
||
switch r.Tier {
|
||
case "", "local", "team":
|
||
default:
|
||
msgs = append(msgs, fmt.Sprintf("未知部署档 %q(local | team)", r.Tier))
|
||
}
|
||
if r.Tier == "team" {
|
||
if r.StorageDSN == "" {
|
||
msgs = append(msgs, "team 档需要 PostgreSQL DSN(dsn / AGENT_SYSTEM_DSN);不会回落 SQLite")
|
||
}
|
||
if !r.AuthEnabled() {
|
||
msgs = append(msgs, "team 档必须开启认证(auth=on)")
|
||
}
|
||
}
|
||
if r.Tier == "local" && r.StorageDSN != "" {
|
||
msgs = append(msgs, "local 档不使用 PostgreSQL DSN(改用 team 档,或去掉 dsn)")
|
||
}
|
||
if v := r.PGMaxOpen; v < 0 {
|
||
msgs = append(msgs, fmt.Sprintf("pgMaxOpen 不能为负(当前 %d)", v))
|
||
}
|
||
if v := r.PGMaxIdle; v < 0 {
|
||
msgs = append(msgs, fmt.Sprintf("pgMaxIdle 不能为负(当前 %d)", v))
|
||
}
|
||
if v := r.PGConnMaxIdleSec; v < 0 {
|
||
msgs = append(msgs, fmt.Sprintf("pgConnMaxIdleSec 不能为负(当前 %d)", v))
|
||
}
|
||
if v := r.PGConnMaxLifetimeSec; v < 0 {
|
||
msgs = append(msgs, fmt.Sprintf("pgConnMaxLifetimeSec 不能为负(当前 %d)", v))
|
||
}
|
||
if v := r.PGQueryTimeoutSec; v < 0 {
|
||
msgs = append(msgs, fmt.Sprintf("pgQueryTimeoutSec 不能为负(当前 %d)", v))
|
||
}
|
||
if idle, open := r.EffectivePGMaxIdle(), r.EffectivePGMaxOpen(); idle > open {
|
||
msgs = append(msgs, fmt.Sprintf("pgMaxIdle(%d) 大于 pgMaxOpen(%d)", idle, open))
|
||
}
|
||
return msgs
|
||
}
|
||
|
||
// EffectivePGMaxOpen/Idle/ConnMaxIdleSec 返回连接预算有效值(零值落默认,KTD12)。
|
||
func (r Resolved) EffectivePGMaxOpen() int {
|
||
if r.PGMaxOpen > 0 {
|
||
return r.PGMaxOpen
|
||
}
|
||
return DefaultPGMaxOpen
|
||
}
|
||
|
||
func (r Resolved) EffectivePGMaxIdle() int {
|
||
if r.PGMaxIdle > 0 {
|
||
return r.PGMaxIdle
|
||
}
|
||
return DefaultPGMaxIdle
|
||
}
|
||
|
||
func (r Resolved) EffectivePGConnMaxIdleSec() int {
|
||
if r.PGConnMaxIdleSec > 0 {
|
||
return r.PGConnMaxIdleSec
|
||
}
|
||
return DefaultPGConnMaxIdleSec
|
||
}
|
||
|
||
// AllowedHosts 把逗号分隔的主机名拆开,忽略空段。
|
||
func (r Resolved) AllowedHosts() []string {
|
||
var out []string
|
||
for _, part := range strings.Split(r.AllowedHostsRaw, ",") {
|
||
part = strings.TrimSpace(part)
|
||
if part != "" {
|
||
out = append(out, part)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ListenRefusal 在非回环、未开认证、且没有显式放行时返回拒绝原因。空串表示可以启动。
|
||
func (r Resolved) ListenRefusal() string {
|
||
if r.AuthEnabled() || r.InsecureListen || IsLoopbackListen(r.Addr) {
|
||
return ""
|
||
}
|
||
return "非回环且未开认证。改用 127.0.0.1、加上 --auth on,或显式 --insecure-listen"
|
||
}
|
||
|
||
// IsLoopbackListen 判断监听地址是否只绑回环。空主机、0.0.0.0、:: 视为所有网卡。
|
||
func IsLoopbackListen(addr string) bool {
|
||
host := addr
|
||
if h, _, err := net.SplitHostPort(addr); err == nil {
|
||
host = h
|
||
}
|
||
host = strings.Trim(strings.ToLower(host), "[]")
|
||
switch host {
|
||
case "", "0.0.0.0", "::":
|
||
return false
|
||
case "localhost":
|
||
return true
|
||
}
|
||
ip := net.ParseIP(host)
|
||
return ip != nil && ip.IsLoopback()
|
||
}
|
||
|
||
// redact 脱敏 DSN:scheme://user:password@… → scheme://user:***@…。
|
||
func redact(dsn string) string {
|
||
i := strings.Index(dsn, "://")
|
||
if i < 0 {
|
||
return dsn
|
||
}
|
||
rest := dsn[i+3:]
|
||
at := strings.Index(rest, "@")
|
||
if at < 0 {
|
||
return dsn
|
||
}
|
||
userpart := rest[:at]
|
||
if c := strings.Index(userpart, ":"); c >= 0 {
|
||
return dsn[:i+3] + userpart[:c] + ":***" + rest[at:]
|
||
}
|
||
return dsn
|
||
}
|