Files
webui4frpc/internal/appconfig/appconfig.go
JianFeeeee e0e2bf9e44 feat(appconfig): 配置文件解析与 flag/file/default 三级合并
新增 internal/appconfig:把「从配置文件启动」所需的解析、密钥展开和
优先级合并抽成可单测的包,避免这块逻辑散落在 main 里。

- 优先级严格为 显式 flag > 配置文件 > 内置默认值;「是否显式传入」由
  flag.Visit 判定,绝不拿解析后的值与默认值比较——Go 的 flag 会无条件
  把默认值写进变量,直接读变量就会让默认值压过配置文件。
- 字段覆盖现有全部 flag:addr/user/password/workdir/bin/frpc/peers/join_key
  (join_key 兼容 joinKey 写法)。
- 字符串字段支持 ${VAR} 与 file:/path 两种展开;环境变量未定义/为空、
  secrets 文件读不到都直接报错,不静默降级成空口令。$$ 转义字面量 $。
- 文件缺失返回 (nil,nil) 不算错误;JSON 非法、未知键(如把 password 拼成
  passwd)、尾部多余内容一律报错,交给调用方响亮退出——静默回落到
  admin/admin 才是真正的安全问题。以 "_" 开头的键作注释键忽略,
  示例配置因此可以内嵌说明。
- -peer 显式传入时整体替换文件里的 peers,而不是追加;-join-key 的内置
  默认来自 $W4F_JOIN_KEY,属于更具体的进程级覆盖,仍优先于文件。
- Values 实现 String()/Summary() 并对手令与 join-key 脱敏,即使误用
  %v 打印也不会泄漏。

回归测试覆盖上述每一条,其中 TestResolveFileBeatsFlagDefaults 专门守
「默认值压过配置文件」这个坑。
2026-10-23 09:12:19 +08:00

198 lines
6.3 KiB
Go

// Package appconfig implements webui4frpc's startup config file support.
//
// Resolution order (documented in README):
//
// command-line flag > config file > built-in default
//
// A flag only overrides the file when it was explicitly passed on the command
// line (flag.Visit). Go's flag package would otherwise hand us a default value
// and silently clobber whatever the file said — that is the single easiest way
// to get this wrong, so Resolve never compares values against defaults.
//
// Two value expansion forms are supported in every string field:
//
// ${VAR} -> environment variable (undefined => loud error)
// file:/path -> first line of /path (trailing CR/LF trimmed)
//
// Expanded values are never logged; callers print field *sources* only
// (see Summary).
package appconfig
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// File is the on-disk schema of config.json. Every field is optional; the
// pointers are not used here because callers treat "" / nil slice as "unset"
// (a config file cannot express a meaningful empty addr or empty user).
type File struct {
Addr string `json:"addr"`
User string `json:"user"`
Password string `json:"password"`
WorkDir string `json:"workdir"`
Bin string `json:"bin"`
FRPC string `json:"frpc"`
Peers []string `json:"peers"`
// join_key is the documented spelling; joinKey is accepted as an alias
// because the rest of the project uses camelCase in JSON payloads.
JoinKey string `json:"join_key"`
JoinKeyCamel string `json:"joinKey"`
}
// UnmarshalJSON decodes the config object, ignoring keys that start with "_".
//
// Sample files ship with "_comment"/"_optional" documentation inside the JSON,
// and comment keys must not be able to mask a real typo: everything else is
// still decoded with DisallowUnknownFields (see decodeStrict).
func (f *File) UnmarshalJSON(data []byte) error {
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
for k := range raw {
if strings.HasPrefix(k, "_") {
delete(raw, k)
}
}
clean, err := json.Marshal(raw)
if err != nil {
return err
}
type alias File // avoid recursing into this method
if err := decodeStrict(clean, (*alias)(f)); err != nil {
return err
}
// decodeStrict expects an object; re-validate that at least an object was
// given (a bare number/array would already have failed above).
return nil
}
// decodeStrict decodes exactly one JSON value, rejecting unknown fields and
// trailing data.
func decodeStrict(data []byte, v any) error {
dec := json.NewDecoder(bytes.NewReader(data))
// Reject unknown keys: a typo such as "passwd" would otherwise silently
// leave the password at its built-in default, which is a security bug
// disguised as a config typo.
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
return err
}
// Reject trailing junk so a half-edited file (two objects, leftover
// fragment) cannot look like it loaded successfully.
if _, err := dec.Token(); !errors.Is(err, io.EOF) {
return errors.New("unexpected trailing data after the JSON object")
}
return nil
}
// Load reads path and returns the merged+expanded config.
//
// A missing file is NOT an error: (nil, nil) tells the caller to fall back to
// pure flag behaviour. Anything else — unreadable, malformed JSON, unknown
// key, trailing junk, undefined ${VAR}, unreadable file: reference — is
// returned as an error, which main() turns into a fatal exit.
func Load(path string) (*File, error) {
raw, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, fmt.Errorf("config file %s: read: %w", path, err)
}
var f File
if err := decodeStrict(raw, &f); err != nil {
return nil, fmt.Errorf("config file %s: invalid JSON: %w (known keys: addr, user, password, workdir, bin, frpc, peers, join_key; keys starting with \"_\" are ignored)", path, err)
}
if f.JoinKey == "" {
f.JoinKey = f.JoinKeyCamel
}
if err := expandAll(&f); err != nil {
return nil, fmt.Errorf("config file %s: %w", path, err)
}
return &f, nil
}
// expand resolves ${VAR} / $VAR and a leading file: reference. $${...} and $$
// produce a literal '$'.
//
// An undefined or empty environment variable is an error rather than an empty
// expansion: booting with an empty password looks like "wrong password" later
// and leaves the node unauthenticated in the meantime.
func expand(s string) (string, error) {
var missing []string
out := os.Expand(s, func(name string) string {
if name == "$" { // "$$" escapes a literal dollar sign
return "$"
}
v, ok := os.LookupEnv(name)
if !ok || v == "" {
missing = append(missing, name)
return ""
}
return v
})
if len(missing) > 0 {
return "", fmt.Errorf("undefined environment variable(s): %s (use $$ for a literal $)", strings.Join(missing, ", "))
}
if strings.HasPrefix(out, "file:") {
p := strings.TrimPrefix(out, "file:")
if p == "" {
return "", errors.New("file: reference with an empty path")
}
raw, err := os.ReadFile(p)
if err != nil {
return "", fmt.Errorf("file reference %q: %w", out, err)
}
// Secrets files are conventionally "one value, one newline"; take the
// first line so a trailing comment/newline cannot corrupt the value.
line, _, _ := strings.Cut(string(raw), "\n")
return strings.TrimRight(line, "\r"), nil
}
return out, nil
}
func expandAll(f *File) error {
fields := []struct {
name string
ptr *string
}{
{"addr", &f.Addr},
{"user", &f.User},
{"password", &f.Password},
{"workdir", &f.WorkDir},
{"bin", &f.Bin},
{"frpc", &f.FRPC},
{"join_key", &f.JoinKey},
}
for _, fd := range fields {
raw := *fd.ptr
v, err := expand(raw)
if err != nil {
return fmt.Errorf("field %s: %w", fd.name, err)
}
*fd.ptr = v
}
for i, p := range f.Peers {
v, err := expand(p)
if err != nil {
return fmt.Errorf("peers[%d]: %w", i, err)
}
f.Peers[i] = v
}
return nil
}
// DefaultPath is the config file used when -config is not given:
// <workdir>/config.json. workdir here is the *flag* value (explicit or
// built-in default) because the file must be locatable before it can be
// parsed — a workdir that only appears inside the file cannot move it.
func DefaultPath(workdir string) string { return filepath.Join(workdir, "config.json") }