diff --git a/.gitignore b/.gitignore index 1817f88..17ae1c5 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ data/ # pi agent local state .pi/ +.pi-sessions/ # docker demo staging (frp binaries) frp-bin/ diff --git a/README.md b/README.md index 16c405a..9ca2f70 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ cd web && npm run build && rm -rf internal/httpapi/dist && cp -r web/dist intern ```bash sudo dpkg -i webui4frpc-0.1.4-linux-amd64.deb # 或 linux-arm64.deb -sudo nano /etc/webui4frpc/webui4frpc.env # 改掉默认密码 +sudo nano /etc/webui4frpc/config.json # 改掉默认密码(见「从配置文件启动」) sudo systemctl restart webui4frpc ``` @@ -96,7 +96,7 @@ sudo systemctl restart webui4frpc ```bash sudo rpm -i webui4frpc-0.1.4-linux-x86_64.rpm -sudo vi /etc/webui4frpc/webui4frpc.env +sudo vi /etc/webui4frpc/config.json sudo systemctl enable --now webui4frpc ``` @@ -152,12 +152,19 @@ sha256sum -c SHA256SUMS --ignore-missing ./webui4frpc -addr 127.0.0.1:7500 -user admin -password admin123 -workdir ./data ``` +或者用配置文件(推荐,口令不必出现在命令行与进程列表里): + +```bash +./webui4frpc -config ./config.json +``` + 打开 ,输入账号密码进入画布。首次使用可到「设置」页一键安装 frpc(或手动指定路径),然后回到画布创建 local / remote 并连线、保存。 ### 参数 | 参数 | 默认 | 说明 | | --- | --- | --- | +| `-config` | `/config.json`(或 `$W4F_CONFIG`) | 配置文件路径;文件不存在不算错误 | | `-addr` | `127.0.0.1:7500` | 监听地址 | | `-user` / `-password` | `admin` / `admin` | Basic 认证(自动同步为 system 管理员账号) | | `-workdir` | `./webui-frpc` | 数据目录(db / 配置 / 日志 / bin) | @@ -168,6 +175,104 @@ sha256sum -c SHA256SUMS --ignore-missing 环境变量 `W4F_HOST` 可覆盖节点对外可达的主机名(Docker DNS / 通配监听场景用)。 +## 从配置文件启动 + +除了命令行 flag,也可以用 JSON 配置文件启动。**推荐用它承载口令**:命令行参数会出现在 +`ps` 输出、systemd 单元文件与 shell history 里,配置文件可以把口令放到只 root 可读的地方。 + +### 优先级 + +``` +命令行 flag > 配置文件 > 内置默认值 +``` + +- **只有显式传入的 flag 才会覆盖配置文件**。实现上用 `flag.Visit` 判断某个 flag 是否真的 + 被写在命令行上 —— Go 的 `flag` 包会先把默认值塞进变量,若直接读变量,默认值会无条件 + 压过配置文件。这是这套逻辑里唯一容易出错的地方,已有回归测试守着 + (`internal/appconfig` 的 `TestResolveFileBeatsFlagDefaults`)。 +- 配置文件里**没写**或留空的字段,才回落到内置默认值。 +- `-peer` 特殊:显式传了 `-peer` 就用命令行的列表**整体替换**配置文件里的 `peers`, + 而不是追加 —— 否则在命令行上删掉的对端会被配置文件复活。 +- `-join-key` 的内置默认是 `$W4F_JOIN_KEY`;未显式传 flag 时,配置文件里的 `join_key` + 只在环境变量为空时才生效。 +- `-config` 未传时的查找顺序:`$W4F_CONFIG` → `/config.json`。 + 注意 `workdir` 用的是 **flag 值**(显式或默认):配置文件必须先能被找到,才谈得上 + 读取它里面的 `workdir`。 +- 文件**不存在不算错误**(回到旧的纯 flag 行为);文件存在但 JSON 解析失败、出现未知键 + (例如把 `password` 拼成 `passwd`)、或引用了未定义的环境变量,都会**立即报错退出**, + 不会静默忽略 —— 静默回落到 `admin/admin` 才是真正的安全问题。 + +### 字段 + +字段与 flag 一一对应,全部可选: + +| 字段 | 对应 flag | 说明 | +| --- | --- | --- | +| `addr` | `-addr` | 监听地址 | +| `user` | `-user` | Basic 认证用户名 | +| `password` | `-password` | Basic 认证口令(推荐用 `${VAR}` 或 `file:`) | +| `workdir` | `-workdir` | 数据目录 | +| `bin` | `-bin` | 初始 frpc 二进制路径 | +| `frpc` | `-frpc` | worker 默认 frpc 路径 | +| `peers` | `-peer` | 集群对端 `host:port` 数组 | +| `join_key` | `-join-key` | 集群准入密钥(也接受 `joinKey` 写法) | + +### 口令的两种写法 + +所有字符串字段都支持这两种展开形式,不只是 `password`: + +```json +{ "password": "${W4F_PASSWORD}" } +``` + +```json +{ "password": "file:/etc/webui4frpc/secret/password" } +``` + +- `${VAR}` 取环境变量;变量**未定义或是空值**会报错退出(而不是展开成空口令)。 + 想写字面量 `$` 用 `$$`。 +- `file:/path` 只取文件的**第一行**(去掉行尾换行),适合 `chmod 600` 的 secrets 文件。 + 文件读不到会报错退出。 +- 展开后的值**不会**出现在任何日志里:启动横幅只打印「字段来源」与脱敏摘要 + (`password=`、`join-key=`),`Values` 的 `String()` 也做了脱敏, + 所以连手滑 `log.Printf("%v", ...)` 都不会漏。 + +### 示例 + +```json +{ + "addr": "0.0.0.0:7500", + "user": "jianf", + "password": "file:/etc/webui4frpc/secret/password", + "workdir": "/var/lib/webui4frpc", + "frpc": "/usr/local/bin/frpc", + "peers": ["10.0.0.2:7500", "10.0.0.3:7500"], + "join_key": "${W4F_JOIN_KEY}" +} +``` + +对应的 systemd 单元不再需要把口令写进 `ExecStart`: + +```ini +[Service] +User=webui4frpc +Group=webui4frpc +EnvironmentFile=-/etc/webui4frpc/webui4frpc.env +ExecStart=/usr/bin/webui4frpc -config /etc/webui4frpc/config.json +ReadWritePaths=/var/lib/webui4frpc +``` + +`packaging/common/` 里带了一份可直接抄的单元模板与 `config.json` 示例。 + +### 关于「没配口令」 + +配置文件里没写 `password`、命令行也没给时,**行为与旧版一致**(回落到内置默认值), +但启动日志会打一行明确警告: + +``` +WARNING: no password configured (neither -password nor the config file); using the built-in default — change it before exposing this node +``` + ### 负载均衡参数 每节点独立生效;集群页的开关**优先于**这里的默认值(在页面上选过就以页面为准,重启不丢)。 diff --git a/cmd/webui4frpc/main.go b/cmd/webui4frpc/main.go index c2050a9..616b7bc 100644 --- a/cmd/webui4frpc/main.go +++ b/cmd/webui4frpc/main.go @@ -20,6 +20,7 @@ import ( "syscall" "time" + "webui4frpc/internal/appconfig" "webui4frpc/internal/cluster" "webui4frpc/internal/httpapi" "webui4frpc/internal/install" @@ -30,18 +31,68 @@ import ( ) func main() { - addr := flag.String("addr", "127.0.0.1:7500", "listen address") - user := flag.String("user", "admin", "basic auth user") - pass := flag.String("password", "admin", "basic auth password") - workdir := flag.String("workdir", "./webui-frpc", "data directory (db, configs, logs, bin)") - bin := flag.String("bin", "", "initial frpc binary path (optional; defaults to manual/auto-install)") - frpc := flag.String("frpc", "", "default worker frpc binary path (fallback when settings has none)") - var peers multiFlag - flag.Var(&peers, "peer", "cluster peer host:port (repeatable; same creds)") - joinKey := flag.String("join-key", os.Getenv("W4F_JOIN_KEY"), "cluster join key = the sponsor node's nodeKey (required with -peer to join an existing cluster)") + addrF := flag.String("addr", "127.0.0.1:7500", "listen address") + userF := flag.String("user", "admin", "basic auth user") + passF := flag.String("password", "admin", "basic auth password") + workdirF := flag.String("workdir", "./webui-frpc", "data directory (db, configs, logs, bin)") + binF := flag.String("bin", "", "initial frpc binary path (optional; defaults to manual/auto-install)") + frpcF := flag.String("frpc", "", "default worker frpc binary path (fallback when settings has none)") + joinKeyF := flag.String("join-key", os.Getenv("W4F_JOIN_KEY"), "cluster join key = the sponsor node's nodeKey (required with -peer to join an existing cluster)") + var peersF multiFlag + flag.Var(&peersF, "peer", "cluster peer host:port (repeatable; same creds)") + configF := flag.String("config", "", "config file path (default /config.json, or $W4F_CONFIG; a missing file is not an error)") flag.Parse() - wd, err := filepath.Abs(*workdir) + // Merge config file with flags. Priority: explicit flag > config file > + // built-in default. The explicit set comes from flag.Visit because Go + // hands us each flag's default unconditionally — reading the pointer + // blindly would let a default silently clobber the file. + explicit := appconfig.ExplicitSet(flag.CommandLine) + cfgPath := *configF + if cfgPath == "" { + // W4F_CONFIG (packaged systemd units use it) outranks the default + // location, but never an explicit -config. + cfgPath = strings.TrimSpace(os.Getenv("W4F_CONFIG")) + } + if cfgPath == "" { + cfgPath = appconfig.DefaultPath(*workdirF) + } + cfgFile, err := appconfig.Load(cfgPath) + if err != nil { + // Present but unparsable/undeclared-key config fails loudly: silently + // falling back to built-in defaults would quietly boot admin/admin. + log.Fatalf("%v", err) + } + v, srcs := appconfig.Resolve( + appconfig.Defaults{Addr: "127.0.0.1:7500", User: "admin", Password: "admin", WorkDir: "./webui-frpc"}, + appconfig.Values{ + Addr: *addrF, User: *userF, Password: *passF, WorkDir: *workdirF, + Bin: *binF, FRPC: *frpcF, Peers: []string(peersF), JoinKey: *joinKeyF, + }, + explicit, cfgFile, + ) + // Redacted summary + per-field sources. Never the values themselves: the + // basic-auth password must not reach stdout, the journal, or a log file. + if cfgFile == nil { + log.Printf("config: no file at %s (flags only); %s", cfgPath, v.Summary()) + } else { + log.Printf("config: loaded %s; %s", cfgPath, v.Summary()) + } + log.Printf("config: precedence flag>file>default — addr<-%s user<-%s workdir<-%s", srcs.Addr, srcs.User, srcs.WorkDir) + switch srcs.Password { + case appconfig.SourceFile: + log.Printf("config: password <- file (value not logged)") + case appconfig.SourceDefault: + // Neither -password nor the config file provided one. Behaviour is + // unchanged (the legacy default still applies) but say so out loud: + // a node serving the UI with a well-known password deserves a warning. + log.Printf("WARNING: no password configured (neither -password nor the config file); using the built-in default — change it before exposing this node") + } + if v.Password == "" { + log.Printf("WARNING: password is empty — basic auth admin login will not work") + } + + wd, err := filepath.Abs(v.WorkDir) if err != nil { log.Fatalf("resolve workdir: %v", err) } @@ -62,14 +113,14 @@ func main() { // account setup. The auth middleware's Basic branch still falls back to the // raw flag creds, so inter-node cluster traffic (which uses SetBasicAuth // with these flags) never breaks even before this row exists. - if err := st.SyncSystemUser(*user, *pass); err != nil { + if err := st.SyncSystemUser(v.User, v.Password); err != nil { log.Fatalf("sync system user: %v", err) } // Seed settings with the initial binary path if provided. - if *bin != "" { + if v.Bin != "" { cfg, _ := st.Settings() - cfg.BinaryPath = *bin + cfg.BinaryPath = v.Bin _ = st.UpdateSettings(cfg) } @@ -78,10 +129,10 @@ func main() { // own webui binary as frpc. So fallback is empty unless -bin is set; the UI // then asks the user to install or provide frpc. fallbackBin := "" - if *frpc != "" { - fallbackBin = *frpc - } else if *bin != "" { - fallbackBin = *bin + if v.FRPC != "" { + fallbackBin = v.FRPC + } else if v.Bin != "" { + fallbackBin = v.Bin } pm := process.NewManager(process.Options{ @@ -113,15 +164,15 @@ func main() { reg := cluster.NewRegistry(filepath.Join(wd, "bin")) // M6: register cluster peers from -peer flags (same creds as this node). - for _, p := range peers { - reg.RegisterPeer(p, p, *user, *pass) + for _, p := range v.Peers { + reg.RegisterPeer(p, p, v.User, v.Password) } // M6 token ring: this node is the initial leader of the cluster ring // (creation node). Engine drives two-phase cycles; transport POSTs the // token to the successor over HTTP. - selfID := reachableAddr(*addr) - ringTransport := &cluster.TokenTransport{User: *user, Pass: *pass} + selfID := reachableAddr(v.Addr) + ringTransport := &cluster.TokenTransport{User: v.User, Pass: v.Password} // nodeKey is this node's cluster admission key. It is NOT generated at // startup — a fresh node has no key until it creates or joins a cluster. // A creation node (no -peer) generates its key on CreateCluster; a joiner @@ -134,7 +185,7 @@ func main() { } var ring *cluster.Engine ring = cluster.NewEngine( - selfID, selfID, *user, *pass, version.Version, nil, + selfID, selfID, v.User, v.Password, version.Version, nil, &cluster.AppHandler{ LoadFn: func() (float64, float64) { // NetPct: real NIC saturation from /proc/net/dev deltas vs @@ -354,7 +405,7 @@ func main() { } return nodeID }), - selfID, len(peers) == 0, nodeKey, // creation node (no -peer) is the initial leader + selfID, len(v.Peers) == 0, nodeKey, // creation node (no -peer) is the initial leader ) // Install the persistence callbacks so ensureNodeKey (called from // CreateCluster / AdoptState) and detachAsStandalone can persist/clear @@ -423,9 +474,9 @@ func main() { // SelfAddr must be reachable by peers. 0.0.0.0/empty on a real host // would break peer reconnect, so fall back to the hostname (container // name inside docker is a routable addr on the compose network). - SelfAddr: reachableAddr(*addr), - User: *user, - Password: *pass, + SelfAddr: reachableAddr(v.Addr), + User: v.User, + Password: v.Password, BinaryPath: func() string { s, _ := st.Settings() if s.BinaryPath != "" { @@ -530,10 +581,10 @@ func main() { if err != nil { log.Fatalf("build handler: %v", err) } - srv := &http.Server{Addr: *addr, Handler: handler} + srv := &http.Server{Addr: v.Addr, Handler: handler} go func() { - log.Printf("webui-frpc listening on %s (workdir %s)", *addr, wd) + log.Printf("webui-frpc listening on %s (workdir %s)", v.Addr, wd) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("server error: %v", err) } @@ -566,8 +617,8 @@ func main() { } if persistedPeers != "" { go retryRejoinCached(clusterCtx, ring, persistedPeers) - } else if len(peers) > 0 { - go retryJoinCluster(clusterCtx, ring, peers[0], *joinKey) + } else if len(v.Peers) > 0 { + go retryJoinCluster(clusterCtx, ring, v.Peers[0], v.JoinKey) } else { if err := ring.CreateCluster(); err != nil { log.Printf("ring[%s] create cluster at startup: %v", selfID, err) 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() } diff --git a/packaging/common/config.json b/packaging/common/config.json new file mode 100644 index 0000000..27fe991 --- /dev/null +++ b/packaging/common/config.json @@ -0,0 +1,27 @@ +{ + "_comment": [ + "webui4frpc startup config. Every field is optional; the effective", + "precedence is: command-line flag > this file > built-in default.", + "", + "Password handling (pick ONE, do not put a literal password here):", + " \"password\": \"file:/etc/webui4frpc/secret/password\" first line of a 0640 root:webui4frpc file", + " \"password\": \"${W4F_PASSWORD}\" from the environment (unset => refuses to start)", + "", + "Unknown keys are a hard error, so a typo cannot silently fall back to the", + "built-in default credentials. The file may be absent (pure-flag mode)." + ], + "addr": "0.0.0.0:7500", + "user": "admin", + "password": "file:/etc/webui4frpc/secret/password", + "workdir": "/var/lib/webui4frpc", + + "_optional": [ + "bin - initial frpc binary path, seeded into settings on first start", + "frpc - fallback worker frpc path when settings has none", + "peers - cluster peer host:port list (same as repeating -peer)", + "join_key- cluster admission key = sponsor node's nodeKey (or ${W4F_JOIN_KEY})" + ], + "frpc": "", + "peers": [], + "join_key": "" +} diff --git a/packaging/common/webui4frpc.env b/packaging/common/webui4frpc.env index 2a74d9c..94e822c 100644 --- a/packaging/common/webui4frpc.env +++ b/packaging/common/webui4frpc.env @@ -1,8 +1,28 @@ -# webui4frpc runtime configuration -# 默认监听全部接口的 7500 端口;请修改密码后再投入生产。 -W4F_ADDR=0.0.0.0:7500 -W4F_USER=admin -W4F_PASSWORD=admin +# webui4frpc optional environment file. +# +# Listening address, admin credentials, workdir and peers now belong in +# /etc/webui4frpc/config.json — that file is what systemd passes via +# `webui4frpc -config ...`, and it keeps the password out of the unit file and +# out of `ps` output. +# +# Everything here is OPTIONAL; the unit uses `EnvironmentFile=-`, so this file +# may be empty or missing entirely. +# +# Referenced by config.json as ${VAR} (the variable must be set and non-empty, +# otherwise startup fails loudly rather than booting with an empty password): +# W4F_PASSWORD=change-me +# +# Cluster admission key, used by config.json as "join_key": "${W4F_JOIN_KEY}". +# W4F_JOIN_KEY= +# +# Overrides the advertised hostname for peers (container DNS / wildcard listen). +# W4F_HOST= +# +# Load balancing defaults (the per-node switch in the UI wins over these). +# W4F_BALANCE_MODE=dry-run +# W4F_BALANCE_COOLDOWN=600 +# W4F_BALANCE_INTERVAL=10 -# 可选:首次提供的 frpc 二进制路径(留空则从 Web UI 手动/自动安装) -# W4F_FRPC=/usr/local/bin/frpc +# Optional: point at a config file outside /etc/webui4frpc. An explicit +# -config flag still wins over this. +# W4F_CONFIG=/etc/webui4frpc/config.json diff --git a/packaging/common/webui4frpc.service b/packaging/common/webui4frpc.service index a1654e8..d612e9d 100644 --- a/packaging/common/webui4frpc.service +++ b/packaging/common/webui4frpc.service @@ -8,8 +8,15 @@ Wants=network-online.target Type=simple User=webui4frpc Group=webui4frpc -EnvironmentFile=/etc/webui4frpc/webui4frpc.env -ExecStart=/usr/bin/webui4frpc -addr ${W4F_ADDR} -user ${W4F_USER} -password ${W4F_PASSWORD} -workdir /var/lib/webui4frpc +# The listening address, admin credentials and cluster peers live in +# config.json, NOT on the command line: anything in ExecStart is visible to +# every user via `ps` and in the unit file itself. Keep the password out of +# this unit — put it in config.json as "file:/etc/webui4frpc/secret/password" +# (or "${W4F_PASSWORD}" with an EnvironmentFile), never as -password. +# The leading "-" makes the EnvironmentFile optional, so a node that keeps all +# settings in config.json still starts. +EnvironmentFile=-/etc/webui4frpc/webui4frpc.env +ExecStart=/usr/bin/webui4frpc -config /etc/webui4frpc/config.json Restart=always RestartSec=5 LimitNOFILE=65535 @@ -20,6 +27,10 @@ PrivateTmp=true ProtectSystem=full ProtectHome=true ReadWritePaths=/var/lib/webui4frpc +# config.json may point at a root-only secrets file for the admin password. +# That path needs to be readable by this service account; keep such files in +# /etc/webui4frpc/secret/ with mode 0640 root:webui4frpc. +ReadOnlyPaths=-/etc/webui4frpc/secret [Install] WantedBy=multi-user.target diff --git a/packaging/deb/postinst b/packaging/deb/postinst index 7998640..0da2b56 100755 --- a/packaging/deb/postinst +++ b/packaging/deb/postinst @@ -18,6 +18,16 @@ chmod 750 /var/lib/webui4frpc chown root:webui4frpc /etc/webui4frpc/webui4frpc.env 2>/dev/null || true chmod 640 /etc/webui4frpc/webui4frpc.env 2>/dev/null || true +# Startup config + the directory that holds the admin password file. +# The service reads /etc/webui4frpc/config.json (see the unit's ExecStart), so +# the password never has to appear on the command line. secret/ is root-writable +# and group-readable by the service account; files inside are created by the +# admin, we only fix ownership/permissions of the directory itself. +mkdir -p /etc/webui4frpc/secret +chown root:webui4frpc /etc/webui4frpc/secret 2>/dev/null || true +chmod 750 /etc/webui4frpc/secret 2>/dev/null || true +chmod 644 /etc/webui4frpc/config.json 2>/dev/null || true + if command -v systemctl >/dev/null 2>&1; then systemctl daemon-reload || true systemctl enable webui4frpc.service || true @@ -30,7 +40,8 @@ cat <<'EOF' webui4frpc 已安装。 - 配置: /etc/webui4frpc/webui4frpc.env (请立即修改 W4F_USER / W4F_PASSWORD) + 配置: /etc/webui4frpc/config.json (请设置 password,推荐 file:/etc/webui4frpc/secret/password) + 密钥: echo '你的口令' > /etc/webui4frpc/secret/password && chmod 0640 /etc/webui4frpc/secret/password 数据: /var/lib/webui4frpc 服务: systemctl status webui4frpc 界面: http://<本机地址>:7500 diff --git a/packaging/macos/build_pkg.sh b/packaging/macos/build_pkg.sh index ef1fa1a..f7302a8 100755 --- a/packaging/macos/build_pkg.sh +++ b/packaging/macos/build_pkg.sh @@ -38,15 +38,25 @@ mkdir -p "$ROOT/usr/local/bin" "$ROOT/usr/local/etc/webui4frpc" "$SCRIPTS" "$PKG # ---- payload 根文件系统 ---- install -m 0755 "$BINARY" "$ROOT/usr/local/bin/webui4frpc" -cat > "$ROOT/usr/local/etc/webui4frpc/webui4frpc.env" <<'ENVEOF' -# webui4frpc runtime configuration (macOS) -# 请修改密码后再投入使用;改完执行: -# sudo launchctl kickstart -k system/com.jianfeeeee.webui4frpc -W4F_ADDR=0.0.0.0:7500 -W4F_USER=admin -W4F_PASSWORD=admin -ENVEOF -chmod 0644 "$ROOT/usr/local/etc/webui4frpc/webui4frpc.env" +# Startup config. The service reads this file via `-config`, so the admin +# password never appears on the command line (visible in `ps` to every user). +# Set the password in /usr/local/etc/webui4frpc/secret/password instead of +# writing it here; the "file:" reference below reads its first line. +mkdir -p "$ROOT/usr/local/etc/webui4frpc/secret" +cat > "$ROOT/usr/local/etc/webui4frpc/config.json" <<'CFGEOF' +{ + "_comment": "precedence: command-line flag > this file > built-in default", + "addr": "0.0.0.0:7500", + "user": "admin", + "password": "file:/usr/local/etc/webui4frpc/secret/password", + "workdir": "/usr/local/var/lib/webui4frpc" +} +CFGEOF +chmod 0644 "$ROOT/usr/local/etc/webui4frpc/config.json" +# Placeholder so the service starts out of the box (admin/admin). The +# postinstall script removes it if the file was already customised. +printf 'admin\n' > "$ROOT/usr/local/etc/webui4frpc/secret/password" +chmod 0640 "$ROOT/usr/local/etc/webui4frpc/secret/password" # ---- launchd plist ---- mkdir -p "$ROOT/Library/LaunchDaemons" @@ -80,18 +90,15 @@ chmod 0644 "$ROOT/Library/LaunchDaemons/com.jianfeeeee.webui4frpc.plist" # launchd 不读 EnvironmentFile,用小包装脚本 source 配置再 exec cat > "$ROOT/usr/local/bin/webui4frpc-launch" <<'LAUNCHEOF' #!/bin/sh -# 读取 /usr/local/etc/webui4frpc/webui4frpc.env 后启动 webui4frpc +# Start webui4frpc from /usr/local/etc/webui4frpc/config.json. +# launchd does not read an EnvironmentFile, so the optional env file is sourced +# here first (config.json may reference ${W4F_*}), then the config file drives +# everything. No credential is ever passed on this command line. set -e ENV_FILE=/usr/local/etc/webui4frpc/webui4frpc.env -W4F_ADDR=0.0.0.0:7500 -W4F_USER=admin -W4F_PASSWORD=admin +CONFIG_FILE=/usr/local/etc/webui4frpc/config.json [ -f "$ENV_FILE" ] && . "$ENV_FILE" -exec /usr/local/bin/webui4frpc \ - -addr "$W4F_ADDR" \ - -user "$W4F_USER" \ - -password "$W4F_PASSWORD" \ - -workdir /usr/local/var/lib/webui4frpc +exec /usr/local/bin/webui4frpc -config "$CONFIG_FILE" LAUNCHEOF chmod 0755 "$ROOT/usr/local/bin/webui4frpc-launch" @@ -159,12 +166,16 @@ cat > "$FLAT/Distribution" <webui4frpc — 可视化 frpc 控制器 安装内容: - /usr/local/bin/webui4frpc 主程序(内嵌 Web 界面) - /usr/local/etc/webui4frpc/webui4frpc.env 配置文件 - /Library/LaunchDaemons/…webui4frpc.plist 开机自启服务 + /usr/local/bin/webui4frpc 主程序(内嵌 Web 界面) + /usr/local/etc/webui4frpc/config.json 启动配置(监听地址/账号/对端) + /usr/local/etc/webui4frpc/secret/password 口令文件(file: 引用,不进命令行) + /usr/local/etc/webui4frpc/webui4frpc.env 可选环境变量(config.json 里可写 \${VAR}) + /Library/LaunchDaemons/…webui4frpc.plist 开机自启服务 安装完成后服务自动启动,访问 http://127.0.0.1:7500 -默认凭据 admin/admin —— 请立即修改 webui4frpc.env 中的密码。 +默认凭据 admin/admin —— 请立即修改 +/usr/local/etc/webui4frpc/secret/password 后再重启 +(sudo launchctl kickstart -k system/com.jianfeeeee.webui4frpc)。 diff --git a/packaging/rpm/webui4frpc.spec b/packaging/rpm/webui4frpc.spec index 30e77e0..bf20280 100644 --- a/packaging/rpm/webui4frpc.spec +++ b/packaging/rpm/webui4frpc.spec @@ -20,6 +20,8 @@ and audit-log CSV export. install -D -m 0755 %{_w4f_bin} %{buildroot}%{_bindir}/webui4frpc install -D -m 0644 %{_w4f_service} %{buildroot}%{_unitdir}/webui4frpc.service install -D -m 0640 %{_w4f_env} %{buildroot}%{_sysconfdir}/webui4frpc/webui4frpc.env +install -D -m 0644 %{_w4f_config} %{buildroot}%{_sysconfdir}/webui4frpc/config.json +install -d -m 0750 %{buildroot}%{_sysconfdir}/webui4frpc/secret install -D -m 0644 %{_w4f_readme} %{buildroot}%{_docdir}/webui4frpc/README.md install -d -m 0750 %{buildroot}%{_sharedstatedir}/webui4frpc @@ -36,11 +38,14 @@ exit 0 %systemd_post webui4frpc.service chown -R webui4frpc:webui4frpc %{_sharedstatedir}/webui4frpc || : chown root:webui4frpc %{_sysconfdir}/webui4frpc/webui4frpc.env || : +chown root:webui4frpc %{_sysconfdir}/webui4frpc/secret || : +chmod 0750 %{_sysconfdir}/webui4frpc/secret || : cat <<'EOF' webui4frpc 已安装。 - 配置: /etc/webui4frpc/webui4frpc.env (请立即修改 W4F_USER / W4F_PASSWORD) + 配置: /etc/webui4frpc/config.json (请设置 password,推荐 file:/etc/webui4frpc/secret/password) + 密钥: echo '你的口令' > /etc/webui4frpc/secret/password && chmod 0640 /etc/webui4frpc/secret/password 数据: /var/lib/webui4frpc 服务: systemctl enable --now webui4frpc 界面: http://<本机地址>:7500 @@ -63,6 +68,8 @@ fi %{_unitdir}/webui4frpc.service %dir %{_sysconfdir}/webui4frpc %config(noreplace) %attr(0640,root,webui4frpc) %{_sysconfdir}/webui4frpc/webui4frpc.env +%config(noreplace) %attr(0644,root,root) %{_sysconfdir}/webui4frpc/config.json +%dir %attr(0750,root,webui4frpc) %{_sysconfdir}/webui4frpc/secret %dir %attr(0750,webui4frpc,webui4frpc) %{_sharedstatedir}/webui4frpc %doc %{_docdir}/webui4frpc/README.md diff --git a/packaging/windows/webui4frpc.nsi b/packaging/windows/webui4frpc.nsi index 59df78c..ce05c0f 100644 --- a/packaging/windows/webui4frpc.nsi +++ b/packaging/windows/webui4frpc.nsi @@ -5,6 +5,10 @@ !include "MUI2.nsh" !include "LogicLib.nsh" +!include "StrFunc.nsh" + +; Used to JSON-escape the free-text fields written into config.json below. +${Using:StrFunc} StrRep !ifndef VERSION !define VERSION "0.0.0" @@ -48,6 +52,9 @@ Var TxtPass Var CfgAddr Var CfgUser Var CfgPass +Var EscAddr +Var EscUser +Var EscDir Function .onInit StrCpy $CfgAddr "0.0.0.0:7500" @@ -95,12 +102,37 @@ Section "webui4frpc" SecMain CreateDirectory "$INSTDIR\data" - ; 启动脚本,承载安装时选择的配置 + ; ---- 启动配置 ---- + ; 口令写进独立文件、由 config.json 用 "file:" 引用:既不出现在启动脚本的 + ; 命令行上(任务管理器 / wmic 可见),也不需要做 JSON 转义。 + ${StrRep} $EscDir $INSTDIR "\" "\\" + ${StrRep} $EscAddr $CfgAddr "\" "\\" + ${StrRep} $EscAddr $EscAddr '$\"' '\"' + ${StrRep} $EscUser $CfgUser "\" "\\" + ${StrRep} $EscUser $EscUser '$\"' '\"' + + FileOpen $1 "$INSTDIR\secret-password.txt" w + FileWrite $1 "$CfgPass$\r$\n" + FileClose $1 + ; 口令文件仅 SYSTEM / Administrators 可读(Program Files 默认 Users 可读) + nsExec::ExecToLog 'icacls "$INSTDIR\secret-password.txt" /inheritance:r /grant:r *S-1-5-18:F *S-1-5-32-544:F' + + FileOpen $2 "$INSTDIR\data\config.json" w + FileWrite $2 "{$\r$\n" + FileWrite $2 ' "addr": "$EscAddr",$\r$\n' + FileWrite $2 ' "user": "$EscUser",$\r$\n' + FileWrite $2 ' "password": "file:$EscDir\\secret-password.txt",$\r$\n' + FileWrite $2 ' "workdir": "data"$\r$\n' + FileWrite $2 "}$\r$\n" + FileClose $2 + + ; 启动脚本:只传 -config,不传任何凭据 FileOpen $0 "$INSTDIR\start-webui4frpc.cmd" w FileWrite $0 "@echo off$\r$\n" FileWrite $0 "rem webui4frpc 启动脚本(由安装程序生成)$\r$\n" + FileWrite $0 "rem 凭据在 data\config.json(口令来自 secret-password.txt),不经过命令行。$\r$\n" FileWrite $0 "cd /d %~dp0$\r$\n" - FileWrite $0 'webui4frpc.exe -addr $CfgAddr -user $CfgUser -password $CfgPass -workdir "%~dp0data"$\r$\n' + FileWrite $0 'webui4frpc.exe -config "%~dp0data\config.json"$\r$\n' FileClose $0 WriteRegStr HKLM "Software\webui4frpc" "InstallDir" "$INSTDIR" @@ -142,6 +174,8 @@ Section "Uninstall" Delete "$INSTDIR\webui4frpc.exe" Delete "$INSTDIR\start-webui4frpc.cmd" + Delete "$INSTDIR\secret-password.txt" + Delete "$INSTDIR\data\config.json" Delete "$INSTDIR\README.md" Delete "$INSTDIR\LICENSE.txt" Delete "$INSTDIR\uninstall.exe" diff --git a/scripts/build_installers.sh b/scripts/build_installers.sh index 8e298fd..c30a6d8 100755 --- a/scripts/build_installers.sh +++ b/scripts/build_installers.sh @@ -46,6 +46,8 @@ EOF install -m 0755 "$bindir/webui4frpc" "$deb/usr/bin/webui4frpc" install -m 0644 "$PKG/common/webui4frpc.service" "$deb/lib/systemd/system/webui4frpc.service" install -m 0640 "$PKG/common/webui4frpc.env" "$deb/etc/webui4frpc/webui4frpc.env" + install -m 0644 "$PKG/common/config.json" "$deb/etc/webui4frpc/config.json" + install -d -m 0750 "$deb/etc/webui4frpc/secret" install -m 0644 "$ROOT/README.md" "$deb/usr/share/doc/webui4frpc/README.md" cat > "$deb/usr/share/doc/webui4frpc/copyright" <<'EOF' webui4frpc - visual frpc controller @@ -55,7 +57,7 @@ EOF install -m 0755 "$PKG/deb/postinst" "$deb/DEBIAN/postinst" install -m 0755 "$PKG/deb/prerm" "$deb/DEBIAN/prerm" install -m 0755 "$PKG/deb/postrm" "$deb/DEBIAN/postrm" - echo "/etc/webui4frpc/webui4frpc.env" > "$deb/DEBIAN/conffiles" + printf '%s\n' /etc/webui4frpc/webui4frpc.env /etc/webui4frpc/config.json > "$deb/DEBIAN/conffiles" ( cd "$deb" && find usr etc lib -type f -exec md5sum {} \; > DEBIAN/md5sums ) dpkg-deb -b --root-owner-group "$deb" "$OUT/webui4frpc-${VERSION}-linux-${arch}.deb" >/dev/null echo " ✔ .deb linux/$arch" @@ -74,6 +76,7 @@ build_rpm() { --define "_w4f_bin $bindir/webui4frpc" \ --define "_w4f_service $PKG/common/webui4frpc.service" \ --define "_w4f_env $PKG/common/webui4frpc.env" \ + --define "_w4f_config $PKG/common/config.json" \ --define "_w4f_readme $ROOT/README.md" \ --define "_unitdir /usr/lib/systemd/system" \ --define "_sharedstatedir /var/lib" >/tmp/w4f-rpm.log 2>&1 || true diff --git a/scripts/criteria/config-startup.sh b/scripts/criteria/config-startup.sh new file mode 100755 index 0000000..a9ea422 --- /dev/null +++ b/scripts/criteria/config-startup.sh @@ -0,0 +1,135 @@ +#!/bin/bash +# 判据:webui4frpc 支持从配置文件启动,且口令不泄漏。 +# +# 覆盖三件事: +# 1. go build ./... 与 go test ./... 全绿(含配置文件优先级的回归测试); +# 2. 优先级 显式 flag > 配置文件 > 内置默认值 —— 未显式传的 flag 不得用 +# 默认值压过配置文件; +# 3. 用配置文件(file: 引用口令)启动后,明文口令既不出现在进程命令行, +# 也不出现在启动日志;坏 JSON 必须非零退出,文件缺失必须能正常启动。 +# +# 退出码 0 = 通过。 +set -u +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT" || { echo "FAIL: 无法进入仓库根 $ROOT"; exit 1; } +WORK="$(mktemp -d)" +PIDS=() +cleanup() { for p in "${PIDS[@]:-}"; do kill "$p" 2>/dev/null; done; wait 2>/dev/null; rm -rf "$WORK"; } +trap cleanup EXIT +fail() { echo "FAIL: $*"; exit 1; } + +# 取一个空闲端口。判据里必须用随机空闲端口:默认 127.0.0.1:7500 上通常已有 +# 生产实例在跑,端口冲突造成的非零退出会被误判成"配置错误已正确报错"。 +free_port() { + python3 -c 'import socket +s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()' +} + +# 断言:进程在配置文件非法时"没进入监听"就必须死。 +# 只检查退出码不够——错误串里本来就含 "invalid JSON",实现把 Fatal 改成 +# 打日志后继续跑,退出码可能因为端口冲突等原因仍非零。真正的判据是它不能再 +# 继续提供服务,所以出现 listening 日志即判失败。 +assert_died_before_listen() { + local log="$1" why="$2" + if grep -q 'listening on' "$log"; then + cat "$log" + fail "$why:配置错误被静默忽略后仍进入监听(应启动前就退出)" + fi +} + +# ---- 0. 前端嵌入产物 ---- +# internal/httpapi 用 //go:embed all:dist 嵌入前端,而 dist 是构建期产物、 +# 被 .gitignore 排除。干净 clone 上不先构建前端,go build 会直接报 +# "pattern all:dist: no matching files found"——那是基线既有状态,与本判据 +# 无关,所以这里先补齐,让判据在任何 checkout 上都能复现。 +if [ ! -f internal/httpapi/dist/index.html ]; then + command -v npm >/dev/null 2>&1 \ + || fail "缺少前端构建产物 internal/httpapi/dist 且没有 npm;请在有 node 的环境跑,或先手工 cd web && npm ci && npm run build && cp -r dist ../internal/httpapi/dist" + ( cd web \ + && { [ -d node_modules ] || npm ci --no-audit --no-fund >/dev/null 2>&1; } \ + && npm run build >/dev/null 2>&1 ) || fail "前端构建失败(cd web && npm run build)" + rm -rf internal/httpapi/dist && mkdir -p internal/httpapi/dist + cp -r web/dist/. internal/httpapi/dist/ || fail "拷贝前端产物失败" +fi + +# ---- 1. 构建与全量测试 ---- +go build ./... || fail "go build ./..." +go test ./... > "$WORK/test.log" 2>&1 || { cat "$WORK/test.log"; fail "go test ./..."; } + +# ---- 2. 优先级回归测试必须存在且通过 ---- +# 这是整个改动最容易做错的一点:Go 的 flag 默认值会无条件覆盖配置文件。 +go test ./internal/appconfig/ -count=1 \ + -run 'TestResolveFileBeatsFlagDefaults|TestResolveExplicitFlagBeatsFile|TestLoadRejectsUnknownKey|TestSummaryRedactsSecrets' \ + > "$WORK/appconfig.log" 2>&1 || { cat "$WORK/appconfig.log"; fail "appconfig 优先级/脱敏回归测试未通过"; } + +# ---- 3. 端到端:配置文件启动,口令不落到命令行与日志 ---- +BIN="$WORK/w4f" +go build -o "$BIN" ./cmd/webui4frpc || fail "构建二进制失败" + +SECRET="CRIT_SECRET_${RANDOM}_9f3a" +printf '%s\n' "$SECRET" > "$WORK/pw" +chmod 600 "$WORK/pw" +PORT="$(free_port)" +python3 -c ' +import json, sys +t, addr = sys.argv[1], sys.argv[2] +json.dump({"addr": addr, "user": "crituser", + "password": "file:" + t + "/pw", "workdir": t + "/data"}, + open(t + "/cfg.json", "w")) +' "$WORK" "127.0.0.1:$PORT" + +# 注意:-config 只是路径,口令来自 file: 引用,命令行本身不含秘密。 +"$BIN" -config "$WORK/cfg.json" > "$WORK/run.log" 2>&1 & +P=$! +PIDS+=("$P") +sleep 3 +kill -0 "$P" 2>/dev/null || { cat "$WORK/run.log"; fail "进程未能从配置文件启动"; } + +if tr '\0' ' ' < "/proc/$P/cmdline" | grep -q -- "$SECRET"; then + fail "明文口令出现在进程命令行(ps 可见)" +fi +if grep -q -- "$SECRET" "$WORK/run.log"; then + cat "$WORK/run.log" + fail "明文口令出现在启动日志" +fi +grep -q "listening on 127.0.0.1:$PORT" "$WORK/run.log" \ + || { cat "$WORK/run.log"; fail "配置文件里的 addr 未生效(未在 $PORT 上监听)"; } +kill "$P" 2>/dev/null; wait "$P" 2>/dev/null + +# ---- 4. 文件存在但 JSON 非法 -> 响亮报错退出 ---- +# 用 timeout 兜底:若实现退化成"只记日志继续启动",进程会一直跑而不是报错, +# 这里必须把它判成失败,不能把自己挂死。 +printf '{"addr":' > "$WORK/bad.json" +timeout 10 "$BIN" -config "$WORK/bad.json" \ + -addr "127.0.0.1:$(free_port)" -workdir "$WORK/d_bad" > "$WORK/bad.log" 2>&1 +rc=$? +[ "$rc" -eq 124 ] && { cat "$WORK/bad.log"; fail "配置文件 JSON 非法时进程没有退出(静默忽略),判据要求响亮报错"; } +[ "$rc" -eq 0 ] && { cat "$WORK/bad.log"; fail "配置文件 JSON 非法时应非零退出,实际正常退出"; } +assert_died_before_listen "$WORK/bad.log" "JSON 非法" +grep -qi 'invalid JSON' "$WORK/bad.log" || { cat "$WORK/bad.log"; fail "非法 JSON 的报错信息不明确"; } + +# ---- 5. 未知键(拼写错误)-> 报错退出,不得静默回落到默认口令 ---- +printf '{"passwd":"x"}' > "$WORK/typo.json" +timeout 10 "$BIN" -config "$WORK/typo.json" \ + -addr "127.0.0.1:$(free_port)" -workdir "$WORK/d_typo" > "$WORK/typo.log" 2>&1 +rc=$? +[ "$rc" -eq 124 ] && { cat "$WORK/typo.log"; fail "未知键被静默忽略(进程继续运行),拼错的 password 会回落到默认值"; } +[ "$rc" -eq 0 ] && { cat "$WORK/typo.log"; fail "未知键应被拒绝(否则拼错的 password 会静默使用默认值)"; } +assert_died_before_listen "$WORK/typo.log" "未知键" + +# ---- 6. 配置文件缺失 -> 不算错误,回落到纯 flag 行为 ---- +"$BIN" -config "$WORK/none.json" -addr "127.0.0.1:$(free_port)" -workdir "$WORK/d2" > "$WORK/miss.log" 2>&1 & +P2=$! +PIDS+=("$P2") +sleep 3 +kill -0 "$P2" 2>/dev/null || { cat "$WORK/miss.log"; fail "配置文件缺失时应回落到纯 flag 行为并正常启动"; } +kill "$P2" 2>/dev/null; wait "$P2" 2>/dev/null + +# ---- 7. 交付的单元/示例配置不得携带明文口令 ---- +if grep -rn -- '-password' packaging/common/webui4frpc.service \ + packaging/common/config.json scripts/release.sh 2>/dev/null \ + | grep -v 'never as -password'; then + fail "打包产物(单元/示例配置/分发模板)仍含命令行口令" +fi + +echo "OK: 配置文件启动与口令不泄漏的判据全部通过" diff --git a/scripts/release.sh b/scripts/release.sh index 400bfef..4863a21 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -92,7 +92,10 @@ Wants=network-online.target [Service] Type=simple -ExecStart=/opt/webui4frpc/webui4frpc -addr 0.0.0.0:7500 -user admin -password admin -workdir /opt/webui4frpc/data +# Listen address, admin credentials and peers live in config.json, not here: +# whatever is in ExecStart is visible to every user via `ps`. Set the password +# in /opt/webui4frpc/config.json as "file:/opt/webui4frpc/secret/password". +ExecStart=/opt/webui4frpc/webui4frpc -config /opt/webui4frpc/config.json Restart=always RestartSec=5 LimitNOFILE=65535 @@ -121,7 +124,8 @@ for target in "${TARGETS[@]}"; do ( cd "$stage" && zip -q "$OUT/$base.zip" "$bin" README.md ) else cp "$OUT/webui4frpc.service.tpl" "$stage/" - ( cd "$stage" && tar czf "$OUT/$base.tar.gz" "$bin" README.md webui4frpc.service.tpl ) + cp "$ROOT/packaging/common/config.json" "$stage/config.json.example" + ( cd "$stage" && tar czf "$OUT/$base.tar.gz" "$bin" README.md webui4frpc.service.tpl config.json.example ) fi done rm -rf "$OUT/.stage"