diff --git a/internal/appconfig/appconfig.go b/internal/appconfig/appconfig.go new file mode 100644 index 0000000..6d88d7a --- /dev/null +++ b/internal/appconfig/appconfig.go @@ -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: +// /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") } diff --git a/internal/appconfig/appconfig_test.go b/internal/appconfig/appconfig_test.go new file mode 100644 index 0000000..326d059 --- /dev/null +++ b/internal/appconfig/appconfig_test.go @@ -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) + } +} diff --git a/internal/appconfig/resolve.go b/internal/appconfig/resolve.go new file mode 100644 index 0000000..5675a35 --- /dev/null +++ b/internal/appconfig/resolve.go @@ -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= workdir=%s bin=%s frpc=%s peers=%s join-key=", + 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() }