feat(startup): 从配置文件启动,口令不再出现在命令行

main() 改用 internal/appconfig 合并后的配置,所有 flag 指针只在构造
Values 时读取一次,后续一律使用合并结果,杜绝「某处漏改又读回默认值」。

- 新增 -config <path>;未传时按 $W4F_CONFIG → <workdir>/config.json 查找。
- 启动日志只打印每个字段的来源与脱敏摘要,口令与 join-key 固定输出为
  <redacted>;配置文件解析失败、未知键、未定义环境变量直接 Fatal 退出。
- 配置文件与命令行都没给口令时保持旧行为(回落到内置默认值),但打一行
  明确的 WARNING,并区分「空口令」这一更危险的形态。

systemd 单元因此可以只写 `webui4frpc -config /etc/webui4frpc/config.json`,
`ps` 与单元文件里都不再出现明文口令。
This commit is contained in:
JianFeeeee
2026-10-23 09:12:24 +08:00
parent e0e2bf9e44
commit a96335dbf1

View File

@ -20,6 +20,7 @@ import (
"syscall" "syscall"
"time" "time"
"webui4frpc/internal/appconfig"
"webui4frpc/internal/cluster" "webui4frpc/internal/cluster"
"webui4frpc/internal/httpapi" "webui4frpc/internal/httpapi"
"webui4frpc/internal/install" "webui4frpc/internal/install"
@ -30,18 +31,68 @@ import (
) )
func main() { func main() {
addr := flag.String("addr", "127.0.0.1:7500", "listen address") addrF := flag.String("addr", "127.0.0.1:7500", "listen address")
user := flag.String("user", "admin", "basic auth user") userF := flag.String("user", "admin", "basic auth user")
pass := flag.String("password", "admin", "basic auth password") passF := flag.String("password", "admin", "basic auth password")
workdir := flag.String("workdir", "./webui-frpc", "data directory (db, configs, logs, bin)") workdirF := 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)") binF := 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)") frpcF := flag.String("frpc", "", "default worker frpc binary path (fallback when settings has none)")
var peers multiFlag 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)")
flag.Var(&peers, "peer", "cluster peer host:port (repeatable; same creds)") var peersF multiFlag
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)") flag.Var(&peersF, "peer", "cluster peer host:port (repeatable; same creds)")
configF := flag.String("config", "", "config file path (default <workdir>/config.json, or $W4F_CONFIG; a missing file is not an error)")
flag.Parse() 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 { if err != nil {
log.Fatalf("resolve workdir: %v", err) 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 // account setup. The auth middleware's Basic branch still falls back to the
// raw flag creds, so inter-node cluster traffic (which uses SetBasicAuth // raw flag creds, so inter-node cluster traffic (which uses SetBasicAuth
// with these flags) never breaks even before this row exists. // 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) log.Fatalf("sync system user: %v", err)
} }
// Seed settings with the initial binary path if provided. // Seed settings with the initial binary path if provided.
if *bin != "" { if v.Bin != "" {
cfg, _ := st.Settings() cfg, _ := st.Settings()
cfg.BinaryPath = *bin cfg.BinaryPath = v.Bin
_ = st.UpdateSettings(cfg) _ = st.UpdateSettings(cfg)
} }
@ -78,10 +129,10 @@ func main() {
// own webui binary as frpc. So fallback is empty unless -bin is set; the UI // own webui binary as frpc. So fallback is empty unless -bin is set; the UI
// then asks the user to install or provide frpc. // then asks the user to install or provide frpc.
fallbackBin := "" fallbackBin := ""
if *frpc != "" { if v.FRPC != "" {
fallbackBin = *frpc fallbackBin = v.FRPC
} else if *bin != "" { } else if v.Bin != "" {
fallbackBin = *bin fallbackBin = v.Bin
} }
pm := process.NewManager(process.Options{ pm := process.NewManager(process.Options{
@ -113,15 +164,15 @@ func main() {
reg := cluster.NewRegistry(filepath.Join(wd, "bin")) reg := cluster.NewRegistry(filepath.Join(wd, "bin"))
// M6: register cluster peers from -peer flags (same creds as this node). // M6: register cluster peers from -peer flags (same creds as this node).
for _, p := range peers { for _, p := range v.Peers {
reg.RegisterPeer(p, p, *user, *pass) reg.RegisterPeer(p, p, v.User, v.Password)
} }
// M6 token ring: this node is the initial leader of the cluster ring // M6 token ring: this node is the initial leader of the cluster ring
// (creation node). Engine drives two-phase cycles; transport POSTs the // (creation node). Engine drives two-phase cycles; transport POSTs the
// token to the successor over HTTP. // token to the successor over HTTP.
selfID := reachableAddr(*addr) selfID := reachableAddr(v.Addr)
ringTransport := &cluster.TokenTransport{User: *user, Pass: *pass} ringTransport := &cluster.TokenTransport{User: v.User, Pass: v.Password}
// nodeKey is this node's cluster admission key. It is NOT generated at // 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. // 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 // A creation node (no -peer) generates its key on CreateCluster; a joiner
@ -134,7 +185,7 @@ func main() {
} }
var ring *cluster.Engine var ring *cluster.Engine
ring = cluster.NewEngine( ring = cluster.NewEngine(
selfID, selfID, *user, *pass, version.Version, nil, selfID, selfID, v.User, v.Password, version.Version, nil,
&cluster.AppHandler{ &cluster.AppHandler{
LoadFn: func() (float64, float64) { LoadFn: func() (float64, float64) {
// NetPct: real NIC saturation from /proc/net/dev deltas vs // NetPct: real NIC saturation from /proc/net/dev deltas vs
@ -354,7 +405,7 @@ func main() {
} }
return nodeID 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 // Install the persistence callbacks so ensureNodeKey (called from
// CreateCluster / AdoptState) and detachAsStandalone can persist/clear // 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 // 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 // would break peer reconnect, so fall back to the hostname (container
// name inside docker is a routable addr on the compose network). // name inside docker is a routable addr on the compose network).
SelfAddr: reachableAddr(*addr), SelfAddr: reachableAddr(v.Addr),
User: *user, User: v.User,
Password: *pass, Password: v.Password,
BinaryPath: func() string { BinaryPath: func() string {
s, _ := st.Settings() s, _ := st.Settings()
if s.BinaryPath != "" { if s.BinaryPath != "" {
@ -530,10 +581,10 @@ func main() {
if err != nil { if err != nil {
log.Fatalf("build handler: %v", err) log.Fatalf("build handler: %v", err)
} }
srv := &http.Server{Addr: *addr, Handler: handler} srv := &http.Server{Addr: v.Addr, Handler: handler}
go func() { 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 { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server error: %v", err) log.Fatalf("server error: %v", err)
} }
@ -566,8 +617,8 @@ func main() {
} }
if persistedPeers != "" { if persistedPeers != "" {
go retryRejoinCached(clusterCtx, ring, persistedPeers) go retryRejoinCached(clusterCtx, ring, persistedPeers)
} else if len(peers) > 0 { } else if len(v.Peers) > 0 {
go retryJoinCluster(clusterCtx, ring, peers[0], *joinKey) go retryJoinCluster(clusterCtx, ring, v.Peers[0], v.JoinKey)
} else { } else {
if err := ring.CreateCluster(); err != nil { if err := ring.CreateCluster(); err != nil {
log.Printf("ring[%s] create cluster at startup: %v", selfID, err) log.Printf("ring[%s] create cluster at startup: %v", selfID, err)