// 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") }