mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-10-31 06:17:20 +00:00
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 专门守
「默认值压过配置文件」这个坑。
This commit is contained in:
197
internal/appconfig/appconfig.go
Normal file
197
internal/appconfig/appconfig.go
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
// 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") }
|
||||||
317
internal/appconfig/appconfig_test.go
Normal file
317
internal/appconfig/appconfig_test.go
Normal file
@ -0,0 +1,317 @@
|
|||||||
|
package appconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func write(t *testing.T, dir, name, body string) string {
|
||||||
|
t.Helper()
|
||||||
|
p := filepath.Join(dir, name)
|
||||||
|
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadMissingFileIsNotAnError(t *testing.T) {
|
||||||
|
f, err := Load(filepath.Join(t.TempDir(), "nope.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("missing file must not error, got %v", err)
|
||||||
|
}
|
||||||
|
if f != nil {
|
||||||
|
t.Fatalf("missing file must yield nil config, got %+v", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRejectsBadJSON(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
for name, body := range map[string]string{
|
||||||
|
"truncated.json": `{"addr": "0.0.0.0:7500"`,
|
||||||
|
"trailing.json": `{"addr":"a"}{"addr":"b"}`,
|
||||||
|
"garbage.json": `not json at all`,
|
||||||
|
} {
|
||||||
|
p := write(t, dir, name, body)
|
||||||
|
if _, err := Load(p); err == nil {
|
||||||
|
t.Errorf("%s: expected a loud parse error, got nil", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRejectsUnknownKey(t *testing.T) {
|
||||||
|
// A typo like "passwd" must not silently leave the password at its default.
|
||||||
|
p := write(t, t.TempDir(), "c.json", `{"passwd": "x"}`)
|
||||||
|
if _, err := Load(p); err == nil {
|
||||||
|
t.Fatal("unknown key must be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadExpandsEnv(t *testing.T) {
|
||||||
|
t.Setenv("W4F_TEST_PASSWORD", "s3cret")
|
||||||
|
p := write(t, t.TempDir(), "c.json", `{"password": "${W4F_TEST_PASSWORD}", "user": "u"}`)
|
||||||
|
f, err := Load(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if f.Password != "s3cret" {
|
||||||
|
t.Fatalf("password not expanded: %q", f.Password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadUndefinedEnvIsAnError(t *testing.T) {
|
||||||
|
// Secrets are normally behind ${VAR}; an unset var must fail instead of
|
||||||
|
// booting with an empty password.
|
||||||
|
p := write(t, t.TempDir(), "c.json", `{"password": "${W4F_DEFINITELY_UNSET}"}`)
|
||||||
|
os.Unsetenv("W4F_DEFINITELY_UNSET")
|
||||||
|
_, err := Load(p)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("undefined env var must error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "W4F_DEFINITELY_UNSET") {
|
||||||
|
t.Fatalf("error should name the variable: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadFileReference(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
secret := write(t, dir, "pw", "from-file\nignored-second-line\n")
|
||||||
|
p := write(t, dir, "c.json", `{"password": "file:`+secret+`"}`)
|
||||||
|
f, err := Load(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if f.Password != "from-file" {
|
||||||
|
t.Fatalf("file: reference = %q, want first line only", f.Password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadMissingFileReferenceIsAnError(t *testing.T) {
|
||||||
|
p := write(t, t.TempDir(), "c.json", `{"password": "file:/nonexistent/secret"}`)
|
||||||
|
if _, err := Load(p); err == nil {
|
||||||
|
t.Fatal("unreadable file: reference must error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadJoinKeyAlias(t *testing.T) {
|
||||||
|
p := write(t, t.TempDir(), "c.json", `{"joinKey": "k1"}`)
|
||||||
|
f, err := Load(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if f.JoinKey != "k1" {
|
||||||
|
t.Fatalf("joinKey alias not honoured: %q", f.JoinKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadPeersExpanded(t *testing.T) {
|
||||||
|
t.Setenv("W4F_TEST_HOST", "10.0.0.2")
|
||||||
|
p := write(t, t.TempDir(), "c.json", `{"peers": ["${W4F_TEST_HOST}:7500", "10.0.0.3:7500"]}`)
|
||||||
|
f, err := Load(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(f.Peers) != 2 || f.Peers[0] != "10.0.0.2:7500" || f.Peers[1] != "10.0.0.3:7500" {
|
||||||
|
t.Fatalf("peers = %v", f.Peers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFlags parses args on a fresh FlagSet with the same defaults as main,
|
||||||
|
// so ExplicitSet sees exactly what the operator typed.
|
||||||
|
func parseFlags(t *testing.T, args ...string) (Values, map[string]bool) {
|
||||||
|
t.Helper()
|
||||||
|
fs := flag.NewFlagSet("test", flag.ContinueOnError)
|
||||||
|
addr := fs.String("addr", "127.0.0.1:7500", "")
|
||||||
|
user := fs.String("user", "admin", "")
|
||||||
|
pass := fs.String("password", "admin", "")
|
||||||
|
workdir := fs.String("workdir", "./webui-frpc", "")
|
||||||
|
bin := fs.String("bin", "", "")
|
||||||
|
frpc := fs.String("frpc", "", "")
|
||||||
|
joinKey := fs.String("join-key", "", "")
|
||||||
|
var peers multi
|
||||||
|
fs.Var(&peers, "peer", "")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return Values{
|
||||||
|
Addr: *addr, User: *user, Password: *pass, WorkDir: *workdir,
|
||||||
|
Bin: *bin, FRPC: *frpc, Peers: peers, JoinKey: *joinKey,
|
||||||
|
}, ExplicitSet(fs)
|
||||||
|
}
|
||||||
|
|
||||||
|
type multi []string
|
||||||
|
|
||||||
|
func (m *multi) String() string { return strings.Join(*m, ",") }
|
||||||
|
func (m *multi) Set(v string) error { *m = append(*m, v); return nil }
|
||||||
|
|
||||||
|
var defs = Defaults{Addr: "127.0.0.1:7500", User: "admin", Password: "admin", WorkDir: "./webui-frpc"}
|
||||||
|
|
||||||
|
// This is THE regression test for the task's main pitfall: with no flags
|
||||||
|
// passed, Go's flag defaults must not win over the config file.
|
||||||
|
func TestResolveFileBeatsFlagDefaults(t *testing.T) {
|
||||||
|
flags, explicit := parseFlags(t) // no args at all
|
||||||
|
f := &File{Addr: "0.0.0.0:7500", User: "jianf", Password: "pw", WorkDir: "/var/lib/webui4frpc",
|
||||||
|
Bin: "/opt/frpc", FRPC: "/opt/frpc2", Peers: []string{"1.2.3.4:7500"}, JoinKey: "k"}
|
||||||
|
v, src := Resolve(defs, flags, explicit, f)
|
||||||
|
|
||||||
|
if v.Addr != "0.0.0.0:7500" || v.User != "jianf" || v.Password != "pw" || v.WorkDir != "/var/lib/webui4frpc" {
|
||||||
|
t.Fatalf("file values lost to flag defaults: %+v", v)
|
||||||
|
}
|
||||||
|
if v.Bin != "/opt/frpc" || v.FRPC != "/opt/frpc2" || v.JoinKey != "k" || len(v.Peers) != 1 {
|
||||||
|
t.Fatalf("file optional values lost: %+v", v)
|
||||||
|
}
|
||||||
|
for name, got := range map[string]string{"addr": src.Addr, "user": src.User, "password": src.Password, "workdir": src.WorkDir} {
|
||||||
|
if got != SourceFile {
|
||||||
|
t.Errorf("%s source = %q, want file", name, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveExplicitFlagBeatsFile(t *testing.T) {
|
||||||
|
flags, explicit := parseFlags(t, "-addr", "127.0.0.1:9000", "-password", "cli-pw")
|
||||||
|
f := &File{Addr: "0.0.0.0:7500", Password: "file-pw"}
|
||||||
|
v, src := Resolve(defs, flags, explicit, f)
|
||||||
|
if v.Addr != "127.0.0.1:9000" {
|
||||||
|
t.Fatalf("explicit -addr ignored: %q", v.Addr)
|
||||||
|
}
|
||||||
|
if v.Password != "cli-pw" {
|
||||||
|
t.Fatalf("explicit -password ignored: %q", v.Password)
|
||||||
|
}
|
||||||
|
if src.Addr != SourceFlag || src.Password != SourceFlag {
|
||||||
|
t.Fatalf("sources = %+v", src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveDefaultsWhenNoFile(t *testing.T) {
|
||||||
|
flags, explicit := parseFlags(t)
|
||||||
|
v, src := Resolve(defs, flags, explicit, nil)
|
||||||
|
if v.Addr != "127.0.0.1:7500" || v.User != "admin" || v.Password != "admin" || v.WorkDir != "./webui-frpc" {
|
||||||
|
t.Fatalf("defaults not applied: %+v", v)
|
||||||
|
}
|
||||||
|
if src.Password != SourceDefault {
|
||||||
|
t.Fatalf("password source = %q, want default", src.Password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveEmptyFileFieldFallsBackToDefault(t *testing.T) {
|
||||||
|
flags, explicit := parseFlags(t)
|
||||||
|
f := &File{Addr: "0.0.0.0:7500"} // no user/password/workdir
|
||||||
|
v, src := Resolve(defs, flags, explicit, f)
|
||||||
|
if v.User != "admin" || v.Password != "admin" || v.WorkDir != "./webui-frpc" {
|
||||||
|
t.Fatalf("absent file fields must fall back to defaults: %+v", v)
|
||||||
|
}
|
||||||
|
if src.User != SourceDefault {
|
||||||
|
t.Fatalf("user source = %q", src.User)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolvePeerFlagReplacesFileList(t *testing.T) {
|
||||||
|
flags, explicit := parseFlags(t, "-peer", "9.9.9.9:7500")
|
||||||
|
f := &File{Peers: []string{"1.1.1.1:7500", "2.2.2.2:7500"}}
|
||||||
|
v, _ := Resolve(defs, flags, explicit, f)
|
||||||
|
if len(v.Peers) != 1 || v.Peers[0] != "9.9.9.9:7500" {
|
||||||
|
t.Fatalf("explicit -peer must replace the file list, got %v", v.Peers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The password and join key must never appear in anything we log.
|
||||||
|
func TestSummaryRedactsSecrets(t *testing.T) {
|
||||||
|
v := Values{
|
||||||
|
Addr: "0.0.0.0:7500", User: "jianf", Password: "sup3rs3cret",
|
||||||
|
WorkDir: "/var/lib/webui4frpc", JoinKey: "joinkey-value",
|
||||||
|
}
|
||||||
|
for _, s := range []string{v.Summary(), v.String()} {
|
||||||
|
if strings.Contains(s, "sup3rs3cret") {
|
||||||
|
t.Fatalf("password leaked into summary: %s", s)
|
||||||
|
}
|
||||||
|
if strings.Contains(s, "joinkey-value") {
|
||||||
|
t.Fatalf("join key leaked into summary: %s", s)
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, "0.0.0.0:7500") || !strings.Contains(s, "jianf") {
|
||||||
|
t.Fatalf("summary dropped non-secret fields: %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// %v must go through String() as well.
|
||||||
|
if strings.Contains(strings.Join([]string{v.Summary()}, ""), "sup3rs3cret") {
|
||||||
|
t.Fatal("leak")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultPath(t *testing.T) {
|
||||||
|
if got := DefaultPath("/var/lib/webui4frpc"); got != "/var/lib/webui4frpc/config.json" {
|
||||||
|
t.Fatalf("DefaultPath = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample files carry "_comment"/"_optional" documentation keys; they must load
|
||||||
|
// without weakening the unknown-key check for real fields.
|
||||||
|
func TestLoadIgnoresCommentKeys(t *testing.T) {
|
||||||
|
p := write(t, t.TempDir(), "c.json", `{"_comment": ["doc"], "_optional": ["more doc"], "addr": "0.0.0.0:7500"}`)
|
||||||
|
f, err := Load(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("underscore comment keys must be ignored, got %v", err)
|
||||||
|
}
|
||||||
|
if f.Addr != "0.0.0.0:7500" {
|
||||||
|
t.Fatalf("addr = %q", f.Addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The shipped example must actually be loadable, otherwise the documented
|
||||||
|
// "copy this file" path fails at first start. It references a secrets file that
|
||||||
|
// does not exist in a test environment, so only the schema is checked here by
|
||||||
|
// rewriting the password to a literal.
|
||||||
|
func TestShippedSampleConfigParses(t *testing.T) {
|
||||||
|
raw, err := os.ReadFile("../../packaging/common/config.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("sample not found: %v", err)
|
||||||
|
}
|
||||||
|
var probe map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &probe); err != nil {
|
||||||
|
t.Fatalf("sample config is not valid JSON: %v", err)
|
||||||
|
}
|
||||||
|
// Remove the file: reference (no /etc secrets in tests) and reload through
|
||||||
|
// the real loader so schema drift is caught.
|
||||||
|
probe["password"] = "literal"
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := filepath.Join(dir, "config.json")
|
||||||
|
out, _ := json.Marshal(probe)
|
||||||
|
if err := os.WriteFile(p, out, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := Load(p); err != nil {
|
||||||
|
t.Fatalf("shipped sample config rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -join-key's flag default comes from $W4F_JOIN_KEY; when the operator does not
|
||||||
|
// type the flag, that environment value must still win over the config file
|
||||||
|
// (it is the more specific, per-process setting).
|
||||||
|
func TestResolveJoinKeyEnvBeatsFile(t *testing.T) {
|
||||||
|
flags, explicit := parseFlags(t) // not passed on the command line
|
||||||
|
flags.JoinKey = "env-key"
|
||||||
|
v, src := Resolve(defs, flags, explicit, &File{JoinKey: "file-key"})
|
||||||
|
if v.JoinKey != "env-key" {
|
||||||
|
t.Fatalf("env join key lost: %q", v.JoinKey)
|
||||||
|
}
|
||||||
|
if src.JoinKey != SourceEnv {
|
||||||
|
t.Fatalf("source = %q, want env", src.JoinKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit flag still wins over both.
|
||||||
|
flags, explicit = parseFlags(t, "-join-key", "cli-key")
|
||||||
|
flags.JoinKey = "cli-key"
|
||||||
|
v, _ = Resolve(defs, flags, explicit, &File{JoinKey: "file-key"})
|
||||||
|
if v.JoinKey != "cli-key" {
|
||||||
|
t.Fatalf("explicit -join-key ignored: %q", v.JoinKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No env, no flag -> the file value is used.
|
||||||
|
flags, explicit = parseFlags(t)
|
||||||
|
v, src = Resolve(defs, flags, explicit, &File{JoinKey: "file-key"})
|
||||||
|
if v.JoinKey != "file-key" || src.JoinKey != SourceFile {
|
||||||
|
t.Fatalf("file join key not used: %q (%s)", v.JoinKey, src.JoinKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
140
internal/appconfig/resolve.go
Normal file
140
internal/appconfig/resolve.go
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
package appconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Field sources, for logging only — never for values.
|
||||||
|
const (
|
||||||
|
SourceFlag = "flag"
|
||||||
|
SourceEnv = "env"
|
||||||
|
SourceFile = "file"
|
||||||
|
SourceDefault = "default"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Defaults are the built-in defaults, i.e. exactly what the flags declare.
|
||||||
|
type Defaults struct {
|
||||||
|
Addr string
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
WorkDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Values is the effective runtime configuration after merging.
|
||||||
|
type Values struct {
|
||||||
|
Addr string
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
WorkDir string
|
||||||
|
Bin string
|
||||||
|
FRPC string
|
||||||
|
Peers []string
|
||||||
|
JoinKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sources records where each resolved field came from ("" = not configured).
|
||||||
|
type Sources struct {
|
||||||
|
Addr string
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
WorkDir string
|
||||||
|
Bin string
|
||||||
|
FRPC string
|
||||||
|
Peers string
|
||||||
|
JoinKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExplicitSet returns the flag names the operator actually typed.
|
||||||
|
//
|
||||||
|
// flag.Visit walks only flags that were set on the command line, which is
|
||||||
|
// precisely the test required here: an unset flag must NOT override a config
|
||||||
|
// file value with its default.
|
||||||
|
func ExplicitSet(fs *flag.FlagSet) map[string]bool {
|
||||||
|
set := map[string]bool{}
|
||||||
|
fs.Visit(func(f *flag.Flag) { set[f.Name] = true })
|
||||||
|
return set
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve merges per field: explicit flag > config file (non-empty) >
|
||||||
|
// built-in default.
|
||||||
|
func Resolve(def Defaults, flags Values, explicit map[string]bool, f *File) (Values, Sources) {
|
||||||
|
var out Values
|
||||||
|
var src Sources
|
||||||
|
var file File
|
||||||
|
if f != nil {
|
||||||
|
file = *f
|
||||||
|
}
|
||||||
|
|
||||||
|
// pick covers the four fields that have a built-in default.
|
||||||
|
pick := func(flagName, flagVal, defVal, fileVal string) (string, string) {
|
||||||
|
if explicit[flagName] {
|
||||||
|
return flagVal, SourceFlag
|
||||||
|
}
|
||||||
|
if fileVal != "" {
|
||||||
|
return fileVal, SourceFile
|
||||||
|
}
|
||||||
|
return defVal, SourceDefault
|
||||||
|
}
|
||||||
|
|
||||||
|
out.Addr, src.Addr = pick("addr", flags.Addr, def.Addr, file.Addr)
|
||||||
|
out.User, src.User = pick("user", flags.User, def.User, file.User)
|
||||||
|
out.Password, src.Password = pick("password", flags.Password, def.Password, file.Password)
|
||||||
|
out.WorkDir, src.WorkDir = pick("workdir", flags.WorkDir, def.WorkDir, file.WorkDir)
|
||||||
|
|
||||||
|
// No built-in default for these: empty means "not configured".
|
||||||
|
pickOpt := func(flagName, flagVal, fileVal string) (string, string) {
|
||||||
|
if explicit[flagName] {
|
||||||
|
return flagVal, SourceFlag
|
||||||
|
}
|
||||||
|
if fileVal != "" {
|
||||||
|
return fileVal, SourceFile
|
||||||
|
}
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
out.Bin, src.Bin = pickOpt("bin", flags.Bin, file.Bin)
|
||||||
|
out.FRPC, src.FRPC = pickOpt("frpc", flags.FRPC, file.FRPC)
|
||||||
|
|
||||||
|
// join-key is special: its flag default comes from $W4F_JOIN_KEY, i.e. a
|
||||||
|
// per-process override that is more specific than a value baked into the
|
||||||
|
// config file. Dropping it here would silently disable env-based cluster
|
||||||
|
// joining whenever a file happened to be present.
|
||||||
|
switch {
|
||||||
|
case explicit["join-key"]:
|
||||||
|
out.JoinKey, src.JoinKey = flags.JoinKey, SourceFlag
|
||||||
|
case flags.JoinKey != "":
|
||||||
|
out.JoinKey, src.JoinKey = flags.JoinKey, SourceEnv
|
||||||
|
case file.JoinKey != "":
|
||||||
|
out.JoinKey, src.JoinKey = file.JoinKey, SourceFile
|
||||||
|
}
|
||||||
|
|
||||||
|
// peers is repeatable (-peer a -peer b). An explicit flag REPLACES the
|
||||||
|
// file's list instead of appending: appending would resurrect peers the
|
||||||
|
// operator deliberately dropped on the command line.
|
||||||
|
if explicit["peer"] {
|
||||||
|
out.Peers, src.Peers = flags.Peers, SourceFlag
|
||||||
|
} else if len(file.Peers) > 0 {
|
||||||
|
out.Peers, src.Peers = file.Peers, SourceFile
|
||||||
|
}
|
||||||
|
return out, src
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary renders the resolved configuration for the startup log with every
|
||||||
|
// secret redacted. Using this instead of ad-hoc formatting means an accidental
|
||||||
|
// log.Printf("%v", values) still cannot leak the basic-auth password or the
|
||||||
|
// cluster join key.
|
||||||
|
func (v Values) Summary() string {
|
||||||
|
return fmt.Sprintf("addr=%s user=%s password=<redacted> workdir=%s bin=%s frpc=%s peers=%s join-key=<redacted>",
|
||||||
|
v.Addr, v.User, v.WorkDir, orDash(v.Bin), orDash(v.FRPC), orDash(strings.Join(v.Peers, ",")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func orDash(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// String implements fmt.Stringer so %v/%s on Values is always redacted.
|
||||||
|
func (v Values) String() string { return v.Summary() }
|
||||||
Reference in New Issue
Block a user