12 Commits

Author SHA1 Message Date
1878401d3a chore: v0.2.0 — 版本号收拢(6 处)
internal/version/version.go(ldflags 示例 + 默认值)、scripts/release.sh(注释 + 默认值)、
scripts/build_installers.sh、packaging/rpm/webui4frpc.spec(Version + changelog)、README.md
与 packaging/windows/README.md 的安装示例。

按 v0.1.4 的 6 处清单逐个核对,无遗漏。rpm changelog 新增 0.2.0-1 条目,汇总本次
「配置文件启动 + 口令脱敏」与「frpc user / transport 渲染修复」两组改动。
2026-10-23 09:50:12 +08:00
d83e5d8e70 fix(render,store): frpc user 支持 + 修正 useEncryption/useCompression 渲染位置
- store.Remote 新增 user 字段(DB 列名 usr,避开保留字;migrate() 自动加列),
  渲染为 frpc 全局 user。面板型 frps(如 ChhChongGou 实例)强制要求 user 登录,
  缺失时服务端返回 panel authentication unavailable: panel HTTP status 403
- ★ 修复渲染 bug:useEncryption/useCompression/bandwidthLimit 必须内嵌在 proxy 的
  transport 对象(frpc ProxyTransport)。此前渲染在 proxy 顶层,frpc 默认
  --strict-config 直接拒载:unknown field "useEncryption"。任何启用加密/压缩
  的转发都无法启动。poolCount 无 proxy 级表达(官方 ProxyTransport 无此字段),
  保留在 store 但不再渲染
- 前端:RemoteNode 画布节点与 StatusView 表单加 user 输入(画布此前会静默
  清掉未声明的字段),带『面板型 frps 必填』提示
- 测试:user 往返(含迁移路径)、渲染含/不含 user、transport 内嵌与顶层
  非法键回归守卫
2026-10-23 09:49:04 +08:00
b29cd0d186 !1 merge feat/config-file-startup into main
feat: 支持从配置文件启动,口令不再出现在命令行

Created-by: JianFeeeee
Commit-by: JianFeeeee
Merged-by: JianFeeeee
Description: ## 背景
口令原先只能通过 -password 命令行 flag 传入,明文可见于 /proc/<pid>/cmdline 与 systemd 单元文件。

## 改动
- 新增 internal/appconfig:配置解析 / ${VAR} 与 file: 密钥展开 / flag > 文件 > 默认 三级合并(18 个单测)
- main.go 接 -config,默认 <workdir>/config.json($W4F_CONFIG 兜底);日志只打字段来源与脱敏摘要
- 口令与 join_key 在日志中固定 <redacted>;实测启动日志与 /proc/<pid>/cmdline 均无明文
- packaging/(systemd 单元、deb/rpm、macOS pkg、Windows NSIS)与 README 同步改为「只传 -config」
- scripts/criteria/config-startup.sh 判据脚本(正向 + 6 条变异全部咬住)

## 兼容性
未配置口令时保持旧行为(回落内置默认 + 一行 WARNING);配置文件缺失不算错误。坏 JSON / 未知键 / 尾部多余内容一律非零退出,不静默降级成空口令。

## 验证
复核 pass:正向全绿 + 6/6 变异被咬住;与 main 可干净合并(merge-tree,基点 21eb52df)。
注意:CI 里跑判据需先构建前端(internal/httpapi/dist 是 go:embed 的构建期产物,基线即如此)。

See merge request: HeNanMU-EEA/webui4frpc!1
2026-10-23 09:22:13 +08:00
42765282a3 test(criteria): 判据自行补齐前端嵌入产物,保证在干净 clone 上可复现
internal/httpapi 用 //go:embed all:dist 嵌入前端,dist 是构建期产物且被
.gitignore 排除。干净 clone 上直接 go build 会报
"pattern all:dist: no matching files found"——这是基线既有状态,与本判据
要验证的东西无关,但会让判据在任意 checkout 上都红,失去可复现性。

现在若 dist 缺失就先 cd web 构建并拷回 internal/httpapi/dist(无 npm 时
给出明确的失败原因与手工步骤)。已实测:删掉 dist 后跑判据可自动补齐并全绿。
2026-10-23 09:19:27 +08:00
f6c877b736 test(criteria): 收紧失败语义判据——只认「没进入监听」,并改用空闲端口
复核发现「坏 JSON 静默忽略」这条变异没被咬住:原判据只看退出码,而错误串
里本来就含 invalid JSON、端口又固定在 17699,实现把 Fatal 改成打日志后仍
可能因端口冲突拿到非零退出码,于是被误判为"已正确报错"。

- 新增 assert_died_before_listen:只要日志里出现 "listening on",就判失败。
  配置非法时进程必须启动前退出,不能带着默认口令继续提供服务。
- 所有启动分支改用 free_port 分配空闲端口——判据不能依赖宿主机上恰好没有
  进程占用固定端口(默认 7500 上通常就有生产实例)。
- 顺带断言配置文件里的 addr 真的生效(必须在该端口上监听),避免"进程活着"
  被当成"配置生效"。
2026-10-23 09:17:09 +08:00
ed62fd97a4 test(criteria): 坏 JSON / 未知键分支加超时兜底
判据要求「文件存在但不合法时非零退出」。若实现退化成只记日志继续启动,
进程会一直运行而不是报错——原写法会把判据自己挂死,现在用 timeout 兜底
并把 exit 124 明确判为失败(静默忽略),保证反向变异能被咬住。
2026-10-23 09:14:32 +08:00
f4d4e3cd52 test(criteria): 新增「配置文件启动 + 口令不泄漏」判据脚本
scripts/criteria/config-startup.sh 一条命令跑完正向判据:
构建与全量测试、优先级回归测试、配置文件启动后端到端验证
(/proc/<pid>/cmdline 与启动日志均不得出现明文口令)、
非法 JSON 必须非零退出、未知键必须被拒绝、文件缺失必须回落纯 flag、
交付的单元与示例配置不得含 -password。

退出码 0 = 通过,可直接在 CI / 复核里跑。
2026-10-23 09:13:29 +08:00
2f0f7d8b45 chore(gitignore): 忽略 pi 会话状态目录 .pi-sessions/
agent 运行时会话日志落在工作目录下,属于本地状态,不应进入版本控制。
2026-10-23 09:12:47 +08:00
1eeffd8434 docs(readme): 补「从配置文件启动」章节与优先级说明
- 新增「从配置文件启动」小节:优先级(显式 flag > 配置文件 > 内置默认值)
  及其判定方式(flag.Visit,附回归测试名)、字段与 flag 对照表、
  ${VAR} 与 file: 两种口令写法、未知键/坏 JSON/未定义变量的失败语义、
  可直接抄的 config.json 与 systemd 片段。
- 安装与运行示例改为配置文件启动;参数表补 -config。
- 说明「未配口令」时保持旧行为但会打 WARNING。
2026-10-23 09:12:34 +08:00
ee46b758fa feat(packaging): 单元与安装包改为配置文件启动,口令不再写入 ExecStart
所有交付形态统一成「只传 -config」,凭据留在配置文件 / secrets 文件里。

- systemd 单元:ExecStart 改为 `-config /etc/webui4frpc/config.json`,
  EnvironmentFile 加 `-` 前缀变成可选;新增 ReadOnlyPaths=-/etc/webui4frpc/secret。
- 新增 packaging/common/config.json 示例:口令默认用
  file:/etc/webui4frpc/secret/password 引用,内嵌以 "_" 开头的说明键。
- webui4frpc.env 瘦身为可选环境变量(${VAR} 取值来源),不再承载明文口令。
- deb/rpm:安装 config.json 与 0750 的 secret 目录,config.json 进 conffiles,
  安装后提示改成「设置 password 文件」。
- release.sh 的分发单元模板同步改 -config;tar.gz 附 config.json.example。
- macOS pkg:写 config.json + secret/password,launch 包装脚本只传 -config。
- Windows NSIS:安装时把口令写进 secret-password.txt(icacls 限制为
  SYSTEM/Administrators),生成 data/config.json 用 file: 引用,启动脚本只传
  -config;顺带按 JSON 规则转义 addr/user。makensis 实测可编译通过。
2026-10-23 09:12:30 +08:00
a96335dbf1 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` 与单元文件里都不再出现明文口令。
2026-10-23 09:12:24 +08:00
e0e2bf9e44 feat(appconfig): 配置文件解析与 flag/file/default 三级合并
新增 internal/appconfig:把「从配置文件启动」所需的解析、密钥展开和
优先级合并抽成可单测的包,避免这块逻辑散落在 main 里。

- 优先级严格为 显式 flag > 配置文件 > 内置默认值;「是否显式传入」由
  flag.Visit 判定,绝不拿解析后的值与默认值比较——Go 的 flag 会无条件
  把默认值写进变量,直接读变量就会让默认值压过配置文件。
- 字段覆盖现有全部 flag:addr/user/password/workdir/bin/frpc/peers/join_key
  (join_key 兼容 joinKey 写法)。
- 字符串字段支持 ${VAR} 与 file:/path 两种展开;环境变量未定义/为空、
  secrets 文件读不到都直接报错,不静默降级成空口令。$$ 转义字面量 $。
- 文件缺失返回 (nil,nil) 不算错误;JSON 非法、未知键(如把 password 拼成
  passwd)、尾部多余内容一律报错,交给调用方响亮退出——静默回落到
  admin/admin 才是真正的安全问题。以 "_" 开头的键作注释键忽略,
  示例配置因此可以内嵌说明。
- -peer 显式传入时整体替换文件里的 peers,而不是追加;-join-key 的内置
  默认来自 $W4F_JOIN_KEY,属于更具体的进程级覆盖,仍优先于文件。
- Values 实现 String()/Summary() 并对手令与 join-key 脱敏,即使误用
  %v 打印也不会泄漏。

回归测试覆盖上述每一条,其中 TestResolveFileBeatsFlagDefaults 专门守
「默认值压过配置文件」这个坑。
2026-10-23 09:12:19 +08:00
25 changed files with 1385 additions and 124 deletions

1
.gitignore vendored
View File

@ -22,6 +22,7 @@ data/
# pi agent local state
.pi/
.pi-sessions/
# docker demo staging (frp binaries)
frp-bin/

121
README.md
View File

@ -87,16 +87,16 @@ cd web && npm run build && rm -rf internal/httpapi/dist && cp -r web/dist intern
### Linux (Debian / Ubuntu)
```bash
sudo dpkg -i webui4frpc-0.1.4-linux-amd64.deb # 或 linux-arm64.deb
sudo nano /etc/webui4frpc/webui4frpc.env # 改掉默认密码
sudo dpkg -i webui4frpc-0.2.0-linux-amd64.deb # 或 linux-arm64.deb
sudo nano /etc/webui4frpc/config.json # 改掉默认密码(见「从配置文件启动」)
sudo systemctl restart webui4frpc
```
### Linux (RHEL / Fedora / openEuler / 默认 / 龙蜥)
```bash
sudo rpm -i webui4frpc-0.1.4-linux-x86_64.rpm
sudo vi /etc/webui4frpc/webui4frpc.env
sudo rpm -i webui4frpc-0.2.0-linux-x86_64.rpm
sudo vi /etc/webui4frpc/config.json
sudo systemctl enable --now webui4frpc
```
@ -105,7 +105,7 @@ sudo systemctl enable --now webui4frpc
### Windows
双击 `webui4frpc-0.1.4-windows-amd64-setup.exe`(ARM 设备用 `windows-arm64-setup.exe`)。
双击 `webui4frpc-0.2.0-windows-amd64-setup.exe`(ARM 设备用 `windows-arm64-setup.exe`)。
安装向导会询问监听地址与管理员凭据,完成后:
- 程序装到 `C:\Program Files\webui4frpc`
@ -115,8 +115,8 @@ sudo systemctl enable --now webui4frpc
### macOS
```bash
sudo installer -pkg webui4frpc-0.1.4-darwin-arm64.pkg -target / # Apple Silicon
sudo installer -pkg webui4frpc-0.1.4-darwin-amd64.pkg -target / # Intel
sudo installer -pkg webui4frpc-0.2.0-darwin-arm64.pkg -target / # Apple Silicon
sudo installer -pkg webui4frpc-0.2.0-darwin-amd64.pkg -target / # Intel
```
或直接双击 `.pkg` 走图形安装器。安装后 launchd 开机自启,配置在
@ -134,7 +134,7 @@ sudo launchctl kickstart -k system/com.jianfeeeee.webui4frpc
不想装服务只想跑一下,用对应的 `.tar.gz` / `.zip`,解压即用:
```bash
tar xzf webui4frpc-0.1.4-linux-amd64.tar.gz
tar xzf webui4frpc-0.2.0-linux-amd64.tar.gz
./webui4frpc -addr 127.0.0.1:7500 -user admin -password 你的密码 -workdir ./data
```
@ -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
```
打开 <http://127.0.0.1:7500/> ,输入账号密码进入画布。首次使用可到「设置」页一键安装 frpc(或手动指定路径),然后回到画布创建 local / remote 并连线、保存。
### 参数
| 参数 | 默认 | 说明 |
| --- | --- | --- |
| `-config` | `<workdir>/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` → `<workdir>/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=<redacted>`、`join-key=<redacted>`),`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
```
### 负载均衡参数
每节点独立生效;集群页的开关**优先于**这里的默认值(在页面上选过就以页面为准,重启不丢)。

View File

@ -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 <workdir>/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)

View File

@ -0,0 +1,197 @@
// Package appconfig implements webui4frpc's startup config file support.
//
// Resolution order (documented in README):
//
// command-line flag > config file > built-in default
//
// A flag only overrides the file when it was explicitly passed on the command
// line (flag.Visit). Go's flag package would otherwise hand us a default value
// and silently clobber whatever the file said — that is the single easiest way
// to get this wrong, so Resolve never compares values against defaults.
//
// Two value expansion forms are supported in every string field:
//
// ${VAR} -> environment variable (undefined => loud error)
// file:/path -> first line of /path (trailing CR/LF trimmed)
//
// Expanded values are never logged; callers print field *sources* only
// (see Summary).
package appconfig
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// File is the on-disk schema of config.json. Every field is optional; the
// pointers are not used here because callers treat "" / nil slice as "unset"
// (a config file cannot express a meaningful empty addr or empty user).
type File struct {
Addr string `json:"addr"`
User string `json:"user"`
Password string `json:"password"`
WorkDir string `json:"workdir"`
Bin string `json:"bin"`
FRPC string `json:"frpc"`
Peers []string `json:"peers"`
// join_key is the documented spelling; joinKey is accepted as an alias
// because the rest of the project uses camelCase in JSON payloads.
JoinKey string `json:"join_key"`
JoinKeyCamel string `json:"joinKey"`
}
// UnmarshalJSON decodes the config object, ignoring keys that start with "_".
//
// Sample files ship with "_comment"/"_optional" documentation inside the JSON,
// and comment keys must not be able to mask a real typo: everything else is
// still decoded with DisallowUnknownFields (see decodeStrict).
func (f *File) UnmarshalJSON(data []byte) error {
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
for k := range raw {
if strings.HasPrefix(k, "_") {
delete(raw, k)
}
}
clean, err := json.Marshal(raw)
if err != nil {
return err
}
type alias File // avoid recursing into this method
if err := decodeStrict(clean, (*alias)(f)); err != nil {
return err
}
// decodeStrict expects an object; re-validate that at least an object was
// given (a bare number/array would already have failed above).
return nil
}
// decodeStrict decodes exactly one JSON value, rejecting unknown fields and
// trailing data.
func decodeStrict(data []byte, v any) error {
dec := json.NewDecoder(bytes.NewReader(data))
// Reject unknown keys: a typo such as "passwd" would otherwise silently
// leave the password at its built-in default, which is a security bug
// disguised as a config typo.
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
return err
}
// Reject trailing junk so a half-edited file (two objects, leftover
// fragment) cannot look like it loaded successfully.
if _, err := dec.Token(); !errors.Is(err, io.EOF) {
return errors.New("unexpected trailing data after the JSON object")
}
return nil
}
// Load reads path and returns the merged+expanded config.
//
// A missing file is NOT an error: (nil, nil) tells the caller to fall back to
// pure flag behaviour. Anything else — unreadable, malformed JSON, unknown
// key, trailing junk, undefined ${VAR}, unreadable file: reference — is
// returned as an error, which main() turns into a fatal exit.
func Load(path string) (*File, error) {
raw, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, fmt.Errorf("config file %s: read: %w", path, err)
}
var f File
if err := decodeStrict(raw, &f); err != nil {
return nil, fmt.Errorf("config file %s: invalid JSON: %w (known keys: addr, user, password, workdir, bin, frpc, peers, join_key; keys starting with \"_\" are ignored)", path, err)
}
if f.JoinKey == "" {
f.JoinKey = f.JoinKeyCamel
}
if err := expandAll(&f); err != nil {
return nil, fmt.Errorf("config file %s: %w", path, err)
}
return &f, nil
}
// expand resolves ${VAR} / $VAR and a leading file: reference. $${...} and $$
// produce a literal '$'.
//
// An undefined or empty environment variable is an error rather than an empty
// expansion: booting with an empty password looks like "wrong password" later
// and leaves the node unauthenticated in the meantime.
func expand(s string) (string, error) {
var missing []string
out := os.Expand(s, func(name string) string {
if name == "$" { // "$$" escapes a literal dollar sign
return "$"
}
v, ok := os.LookupEnv(name)
if !ok || v == "" {
missing = append(missing, name)
return ""
}
return v
})
if len(missing) > 0 {
return "", fmt.Errorf("undefined environment variable(s): %s (use $$ for a literal $)", strings.Join(missing, ", "))
}
if strings.HasPrefix(out, "file:") {
p := strings.TrimPrefix(out, "file:")
if p == "" {
return "", errors.New("file: reference with an empty path")
}
raw, err := os.ReadFile(p)
if err != nil {
return "", fmt.Errorf("file reference %q: %w", out, err)
}
// Secrets files are conventionally "one value, one newline"; take the
// first line so a trailing comment/newline cannot corrupt the value.
line, _, _ := strings.Cut(string(raw), "\n")
return strings.TrimRight(line, "\r"), nil
}
return out, nil
}
func expandAll(f *File) error {
fields := []struct {
name string
ptr *string
}{
{"addr", &f.Addr},
{"user", &f.User},
{"password", &f.Password},
{"workdir", &f.WorkDir},
{"bin", &f.Bin},
{"frpc", &f.FRPC},
{"join_key", &f.JoinKey},
}
for _, fd := range fields {
raw := *fd.ptr
v, err := expand(raw)
if err != nil {
return fmt.Errorf("field %s: %w", fd.name, err)
}
*fd.ptr = v
}
for i, p := range f.Peers {
v, err := expand(p)
if err != nil {
return fmt.Errorf("peers[%d]: %w", i, err)
}
f.Peers[i] = v
}
return nil
}
// DefaultPath is the config file used when -config is not given:
// <workdir>/config.json. workdir here is the *flag* value (explicit or
// built-in default) because the file must be locatable before it can be
// parsed — a workdir that only appears inside the file cannot move it.
func DefaultPath(workdir string) string { return filepath.Join(workdir, "config.json") }

View File

@ -0,0 +1,317 @@
package appconfig
import (
"encoding/json"
"flag"
"os"
"path/filepath"
"strings"
"testing"
)
func write(t *testing.T, dir, name, body string) string {
t.Helper()
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
return p
}
func TestLoadMissingFileIsNotAnError(t *testing.T) {
f, err := Load(filepath.Join(t.TempDir(), "nope.json"))
if err != nil {
t.Fatalf("missing file must not error, got %v", err)
}
if f != nil {
t.Fatalf("missing file must yield nil config, got %+v", f)
}
}
func TestLoadRejectsBadJSON(t *testing.T) {
dir := t.TempDir()
for name, body := range map[string]string{
"truncated.json": `{"addr": "0.0.0.0:7500"`,
"trailing.json": `{"addr":"a"}{"addr":"b"}`,
"garbage.json": `not json at all`,
} {
p := write(t, dir, name, body)
if _, err := Load(p); err == nil {
t.Errorf("%s: expected a loud parse error, got nil", name)
}
}
}
func TestLoadRejectsUnknownKey(t *testing.T) {
// A typo like "passwd" must not silently leave the password at its default.
p := write(t, t.TempDir(), "c.json", `{"passwd": "x"}`)
if _, err := Load(p); err == nil {
t.Fatal("unknown key must be rejected")
}
}
func TestLoadExpandsEnv(t *testing.T) {
t.Setenv("W4F_TEST_PASSWORD", "s3cret")
p := write(t, t.TempDir(), "c.json", `{"password": "${W4F_TEST_PASSWORD}", "user": "u"}`)
f, err := Load(p)
if err != nil {
t.Fatal(err)
}
if f.Password != "s3cret" {
t.Fatalf("password not expanded: %q", f.Password)
}
}
func TestLoadUndefinedEnvIsAnError(t *testing.T) {
// Secrets are normally behind ${VAR}; an unset var must fail instead of
// booting with an empty password.
p := write(t, t.TempDir(), "c.json", `{"password": "${W4F_DEFINITELY_UNSET}"}`)
os.Unsetenv("W4F_DEFINITELY_UNSET")
_, err := Load(p)
if err == nil {
t.Fatal("undefined env var must error")
}
if !strings.Contains(err.Error(), "W4F_DEFINITELY_UNSET") {
t.Fatalf("error should name the variable: %v", err)
}
}
func TestLoadFileReference(t *testing.T) {
dir := t.TempDir()
secret := write(t, dir, "pw", "from-file\nignored-second-line\n")
p := write(t, dir, "c.json", `{"password": "file:`+secret+`"}`)
f, err := Load(p)
if err != nil {
t.Fatal(err)
}
if f.Password != "from-file" {
t.Fatalf("file: reference = %q, want first line only", f.Password)
}
}
func TestLoadMissingFileReferenceIsAnError(t *testing.T) {
p := write(t, t.TempDir(), "c.json", `{"password": "file:/nonexistent/secret"}`)
if _, err := Load(p); err == nil {
t.Fatal("unreadable file: reference must error")
}
}
func TestLoadJoinKeyAlias(t *testing.T) {
p := write(t, t.TempDir(), "c.json", `{"joinKey": "k1"}`)
f, err := Load(p)
if err != nil {
t.Fatal(err)
}
if f.JoinKey != "k1" {
t.Fatalf("joinKey alias not honoured: %q", f.JoinKey)
}
}
func TestLoadPeersExpanded(t *testing.T) {
t.Setenv("W4F_TEST_HOST", "10.0.0.2")
p := write(t, t.TempDir(), "c.json", `{"peers": ["${W4F_TEST_HOST}:7500", "10.0.0.3:7500"]}`)
f, err := Load(p)
if err != nil {
t.Fatal(err)
}
if len(f.Peers) != 2 || f.Peers[0] != "10.0.0.2:7500" || f.Peers[1] != "10.0.0.3:7500" {
t.Fatalf("peers = %v", f.Peers)
}
}
// parseFlags parses args on a fresh FlagSet with the same defaults as main,
// so ExplicitSet sees exactly what the operator typed.
func parseFlags(t *testing.T, args ...string) (Values, map[string]bool) {
t.Helper()
fs := flag.NewFlagSet("test", flag.ContinueOnError)
addr := fs.String("addr", "127.0.0.1:7500", "")
user := fs.String("user", "admin", "")
pass := fs.String("password", "admin", "")
workdir := fs.String("workdir", "./webui-frpc", "")
bin := fs.String("bin", "", "")
frpc := fs.String("frpc", "", "")
joinKey := fs.String("join-key", "", "")
var peers multi
fs.Var(&peers, "peer", "")
if err := fs.Parse(args); err != nil {
t.Fatal(err)
}
return Values{
Addr: *addr, User: *user, Password: *pass, WorkDir: *workdir,
Bin: *bin, FRPC: *frpc, Peers: peers, JoinKey: *joinKey,
}, ExplicitSet(fs)
}
type multi []string
func (m *multi) String() string { return strings.Join(*m, ",") }
func (m *multi) Set(v string) error { *m = append(*m, v); return nil }
var defs = Defaults{Addr: "127.0.0.1:7500", User: "admin", Password: "admin", WorkDir: "./webui-frpc"}
// This is THE regression test for the task's main pitfall: with no flags
// passed, Go's flag defaults must not win over the config file.
func TestResolveFileBeatsFlagDefaults(t *testing.T) {
flags, explicit := parseFlags(t) // no args at all
f := &File{Addr: "0.0.0.0:7500", User: "jianf", Password: "pw", WorkDir: "/var/lib/webui4frpc",
Bin: "/opt/frpc", FRPC: "/opt/frpc2", Peers: []string{"1.2.3.4:7500"}, JoinKey: "k"}
v, src := Resolve(defs, flags, explicit, f)
if v.Addr != "0.0.0.0:7500" || v.User != "jianf" || v.Password != "pw" || v.WorkDir != "/var/lib/webui4frpc" {
t.Fatalf("file values lost to flag defaults: %+v", v)
}
if v.Bin != "/opt/frpc" || v.FRPC != "/opt/frpc2" || v.JoinKey != "k" || len(v.Peers) != 1 {
t.Fatalf("file optional values lost: %+v", v)
}
for name, got := range map[string]string{"addr": src.Addr, "user": src.User, "password": src.Password, "workdir": src.WorkDir} {
if got != SourceFile {
t.Errorf("%s source = %q, want file", name, got)
}
}
}
func TestResolveExplicitFlagBeatsFile(t *testing.T) {
flags, explicit := parseFlags(t, "-addr", "127.0.0.1:9000", "-password", "cli-pw")
f := &File{Addr: "0.0.0.0:7500", Password: "file-pw"}
v, src := Resolve(defs, flags, explicit, f)
if v.Addr != "127.0.0.1:9000" {
t.Fatalf("explicit -addr ignored: %q", v.Addr)
}
if v.Password != "cli-pw" {
t.Fatalf("explicit -password ignored: %q", v.Password)
}
if src.Addr != SourceFlag || src.Password != SourceFlag {
t.Fatalf("sources = %+v", src)
}
}
func TestResolveDefaultsWhenNoFile(t *testing.T) {
flags, explicit := parseFlags(t)
v, src := Resolve(defs, flags, explicit, nil)
if v.Addr != "127.0.0.1:7500" || v.User != "admin" || v.Password != "admin" || v.WorkDir != "./webui-frpc" {
t.Fatalf("defaults not applied: %+v", v)
}
if src.Password != SourceDefault {
t.Fatalf("password source = %q, want default", src.Password)
}
}
func TestResolveEmptyFileFieldFallsBackToDefault(t *testing.T) {
flags, explicit := parseFlags(t)
f := &File{Addr: "0.0.0.0:7500"} // no user/password/workdir
v, src := Resolve(defs, flags, explicit, f)
if v.User != "admin" || v.Password != "admin" || v.WorkDir != "./webui-frpc" {
t.Fatalf("absent file fields must fall back to defaults: %+v", v)
}
if src.User != SourceDefault {
t.Fatalf("user source = %q", src.User)
}
}
func TestResolvePeerFlagReplacesFileList(t *testing.T) {
flags, explicit := parseFlags(t, "-peer", "9.9.9.9:7500")
f := &File{Peers: []string{"1.1.1.1:7500", "2.2.2.2:7500"}}
v, _ := Resolve(defs, flags, explicit, f)
if len(v.Peers) != 1 || v.Peers[0] != "9.9.9.9:7500" {
t.Fatalf("explicit -peer must replace the file list, got %v", v.Peers)
}
}
// The password and join key must never appear in anything we log.
func TestSummaryRedactsSecrets(t *testing.T) {
v := Values{
Addr: "0.0.0.0:7500", User: "jianf", Password: "sup3rs3cret",
WorkDir: "/var/lib/webui4frpc", JoinKey: "joinkey-value",
}
for _, s := range []string{v.Summary(), v.String()} {
if strings.Contains(s, "sup3rs3cret") {
t.Fatalf("password leaked into summary: %s", s)
}
if strings.Contains(s, "joinkey-value") {
t.Fatalf("join key leaked into summary: %s", s)
}
if !strings.Contains(s, "0.0.0.0:7500") || !strings.Contains(s, "jianf") {
t.Fatalf("summary dropped non-secret fields: %s", s)
}
}
// %v must go through String() as well.
if strings.Contains(strings.Join([]string{v.Summary()}, ""), "sup3rs3cret") {
t.Fatal("leak")
}
}
func TestDefaultPath(t *testing.T) {
if got := DefaultPath("/var/lib/webui4frpc"); got != "/var/lib/webui4frpc/config.json" {
t.Fatalf("DefaultPath = %q", got)
}
}
// Sample files carry "_comment"/"_optional" documentation keys; they must load
// without weakening the unknown-key check for real fields.
func TestLoadIgnoresCommentKeys(t *testing.T) {
p := write(t, t.TempDir(), "c.json", `{"_comment": ["doc"], "_optional": ["more doc"], "addr": "0.0.0.0:7500"}`)
f, err := Load(p)
if err != nil {
t.Fatalf("underscore comment keys must be ignored, got %v", err)
}
if f.Addr != "0.0.0.0:7500" {
t.Fatalf("addr = %q", f.Addr)
}
}
// The shipped example must actually be loadable, otherwise the documented
// "copy this file" path fails at first start. It references a secrets file that
// does not exist in a test environment, so only the schema is checked here by
// rewriting the password to a literal.
func TestShippedSampleConfigParses(t *testing.T) {
raw, err := os.ReadFile("../../packaging/common/config.json")
if err != nil {
t.Skipf("sample not found: %v", err)
}
var probe map[string]any
if err := json.Unmarshal(raw, &probe); err != nil {
t.Fatalf("sample config is not valid JSON: %v", err)
}
// Remove the file: reference (no /etc secrets in tests) and reload through
// the real loader so schema drift is caught.
probe["password"] = "literal"
dir := t.TempDir()
p := filepath.Join(dir, "config.json")
out, _ := json.Marshal(probe)
if err := os.WriteFile(p, out, 0o600); err != nil {
t.Fatal(err)
}
if _, err := Load(p); err != nil {
t.Fatalf("shipped sample config rejected: %v", err)
}
}
// -join-key's flag default comes from $W4F_JOIN_KEY; when the operator does not
// type the flag, that environment value must still win over the config file
// (it is the more specific, per-process setting).
func TestResolveJoinKeyEnvBeatsFile(t *testing.T) {
flags, explicit := parseFlags(t) // not passed on the command line
flags.JoinKey = "env-key"
v, src := Resolve(defs, flags, explicit, &File{JoinKey: "file-key"})
if v.JoinKey != "env-key" {
t.Fatalf("env join key lost: %q", v.JoinKey)
}
if src.JoinKey != SourceEnv {
t.Fatalf("source = %q, want env", src.JoinKey)
}
// Explicit flag still wins over both.
flags, explicit = parseFlags(t, "-join-key", "cli-key")
flags.JoinKey = "cli-key"
v, _ = Resolve(defs, flags, explicit, &File{JoinKey: "file-key"})
if v.JoinKey != "cli-key" {
t.Fatalf("explicit -join-key ignored: %q", v.JoinKey)
}
// No env, no flag -> the file value is used.
flags, explicit = parseFlags(t)
v, src = Resolve(defs, flags, explicit, &File{JoinKey: "file-key"})
if v.JoinKey != "file-key" || src.JoinKey != SourceFile {
t.Fatalf("file join key not used: %q (%s)", v.JoinKey, src.JoinKey)
}
}

View File

@ -0,0 +1,140 @@
package appconfig
import (
"flag"
"fmt"
"strings"
)
// Field sources, for logging only — never for values.
const (
SourceFlag = "flag"
SourceEnv = "env"
SourceFile = "file"
SourceDefault = "default"
)
// Defaults are the built-in defaults, i.e. exactly what the flags declare.
type Defaults struct {
Addr string
User string
Password string
WorkDir string
}
// Values is the effective runtime configuration after merging.
type Values struct {
Addr string
User string
Password string
WorkDir string
Bin string
FRPC string
Peers []string
JoinKey string
}
// Sources records where each resolved field came from ("" = not configured).
type Sources struct {
Addr string
User string
Password string
WorkDir string
Bin string
FRPC string
Peers string
JoinKey string
}
// ExplicitSet returns the flag names the operator actually typed.
//
// flag.Visit walks only flags that were set on the command line, which is
// precisely the test required here: an unset flag must NOT override a config
// file value with its default.
func ExplicitSet(fs *flag.FlagSet) map[string]bool {
set := map[string]bool{}
fs.Visit(func(f *flag.Flag) { set[f.Name] = true })
return set
}
// Resolve merges per field: explicit flag > config file (non-empty) >
// built-in default.
func Resolve(def Defaults, flags Values, explicit map[string]bool, f *File) (Values, Sources) {
var out Values
var src Sources
var file File
if f != nil {
file = *f
}
// pick covers the four fields that have a built-in default.
pick := func(flagName, flagVal, defVal, fileVal string) (string, string) {
if explicit[flagName] {
return flagVal, SourceFlag
}
if fileVal != "" {
return fileVal, SourceFile
}
return defVal, SourceDefault
}
out.Addr, src.Addr = pick("addr", flags.Addr, def.Addr, file.Addr)
out.User, src.User = pick("user", flags.User, def.User, file.User)
out.Password, src.Password = pick("password", flags.Password, def.Password, file.Password)
out.WorkDir, src.WorkDir = pick("workdir", flags.WorkDir, def.WorkDir, file.WorkDir)
// No built-in default for these: empty means "not configured".
pickOpt := func(flagName, flagVal, fileVal string) (string, string) {
if explicit[flagName] {
return flagVal, SourceFlag
}
if fileVal != "" {
return fileVal, SourceFile
}
return "", ""
}
out.Bin, src.Bin = pickOpt("bin", flags.Bin, file.Bin)
out.FRPC, src.FRPC = pickOpt("frpc", flags.FRPC, file.FRPC)
// join-key is special: its flag default comes from $W4F_JOIN_KEY, i.e. a
// per-process override that is more specific than a value baked into the
// config file. Dropping it here would silently disable env-based cluster
// joining whenever a file happened to be present.
switch {
case explicit["join-key"]:
out.JoinKey, src.JoinKey = flags.JoinKey, SourceFlag
case flags.JoinKey != "":
out.JoinKey, src.JoinKey = flags.JoinKey, SourceEnv
case file.JoinKey != "":
out.JoinKey, src.JoinKey = file.JoinKey, SourceFile
}
// peers is repeatable (-peer a -peer b). An explicit flag REPLACES the
// file's list instead of appending: appending would resurrect peers the
// operator deliberately dropped on the command line.
if explicit["peer"] {
out.Peers, src.Peers = flags.Peers, SourceFlag
} else if len(file.Peers) > 0 {
out.Peers, src.Peers = file.Peers, SourceFile
}
return out, src
}
// Summary renders the resolved configuration for the startup log with every
// secret redacted. Using this instead of ad-hoc formatting means an accidental
// log.Printf("%v", values) still cannot leak the basic-auth password or the
// cluster join key.
func (v Values) Summary() string {
return fmt.Sprintf("addr=%s user=%s password=<redacted> workdir=%s bin=%s frpc=%s peers=%s join-key=<redacted>",
v.Addr, v.User, v.WorkDir, orDash(v.Bin), orDash(v.FRPC), orDash(strings.Join(v.Peers, ",")))
}
func orDash(s string) string {
if s == "" {
return "-"
}
return s
}
// String implements fmt.Stringer so %v/%s on Values is always redacted.
func (v Values) String() string { return v.Summary() }

View File

@ -25,13 +25,19 @@ type Proxy struct {
// CustomDomains is used by http/https proxies when set.
CustomDomains []string
// Advanced transport knobs (M1): proxy-level encryption/compression/limit.
// Advanced transport knobs (M1). These are rendered INSIDE the proxy's
// `transport` object (frpc's ProxyTransport), not at the proxy top level.
UseEncryption bool
UseCompression bool
BandwidthLimit string
PoolCount int
Metadatas map[string]string
Annotations map[string]string
// PoolCount has no per-proxy representation in frpc (ProxyTransport has no
// poolCount field; only the global `transport.poolCount` exists), so this
// value is deliberately not rendered — use the remote's TransportPool for
// connection pooling. Kept here so existing canvases keep loading.
PoolCount int
Metadatas map[string]string
Annotations map[string]string
// M2 HTTP/HTTPS routing (http/https only).
Locations []string // path routing
@ -73,9 +79,15 @@ type frpcTLS struct {
ServerName string `json:"serverName,omitempty"`
}
// frpcProxyTransport is the per-proxy transport section (frpc >= 0.52)
// where bandwidthLimit is defined.
// frpcProxyTransport is the per-proxy transport section (frpc >= 0.52).
//
// useEncryption/useCompression/bandwidthLimit all live HERE, not at the proxy's
// top level: frpc's ProxyTransport struct owns them, and frpc runs with
// --strict-config by default, so a top-level spelling fails the whole config
// with `unmarshal ProxyConfig error: json: unknown field "useEncryption"`.
type frpcProxyTransport struct {
UseEncryption bool `json:"useEncryption,omitempty"`
UseCompression bool `json:"useCompression,omitempty"`
BandwidthLimit string `json:"bandwidthLimit,omitempty"`
}
@ -96,13 +108,9 @@ type frpcProxy struct {
CustomDomains []string `json:"customDomains,omitempty"`
SubDomain string `json:"subdomain,omitempty"`
// M1 advanced transport fields. bandwidthLimit lives in transport.
UseEncryption bool `json:"useEncryption,omitempty"`
UseCompression bool `json:"useCompression,omitempty"`
PoolCount int `json:"poolCount,omitempty"`
Metadatas map[string]string `json:"metadatas,omitempty"`
Annotations map[string]string `json:"annotations,omitempty"`
Transport *frpcProxyTransport `json:"transport,omitempty"`
Metadatas map[string]string `json:"metadatas,omitempty"`
Annotations map[string]string `json:"annotations,omitempty"`
Transport *frpcProxyTransport `json:"transport,omitempty"`
// M2 HTTP/HTTPS routing.
Locations []string `json:"locations,omitempty"`
@ -148,8 +156,12 @@ type frpcHTTPHeader struct {
// Config is a complete frpc configuration document.
type Config struct {
ServerAddr string `json:"serverAddr"`
ServerPort int `json:"serverPort"`
ServerAddr string `json:"serverAddr"`
ServerPort int `json:"serverPort"`
// User is frpc's global `user` field. frps prefixes every proxy name with it
// (`<user>.<name>`), and panel-managed frps requires it to admit the login.
// Omitted when empty so plain frps sees an unchanged config.
User string `json:"user,omitempty"`
Auth frpcAuth `json:"auth"`
Transport frpcTransport `json:"transport,omitempty"`
WebServer *frpcWebServer `json:"webServer,omitempty"`
@ -163,6 +175,7 @@ func Render(remote store.Remote, proxies []Proxy) ([]byte, error) {
cfg := Config{
ServerAddr: remote.IP,
ServerPort: remote.Port,
User: remote.User,
Auth: frpcAuth{Method: "token", Token: remote.Token},
LoginFailExit: false,
Proxies: make([]frpcProxy, 0, len(proxies)),
@ -200,17 +213,21 @@ func Render(remote store.Remote, proxies []Proxy) ([]byte, error) {
LocalIP: p.LocalIP,
LocalPort: p.LocalPort,
SubDomain: p.SubDomain,
UseEncryption: p.UseEncryption,
UseCompression: p.UseCompression,
PoolCount: p.PoolCount,
Metadatas: p.Metadatas,
Annotations: p.Annotations,
Locations: p.Locations,
HostHeaderRewrite: p.HostHeaderRewrite,
HTTPHeaders: p.HTTPHeaders,
}
if p.BandwidthLimit != "" {
frpcP.Transport = &frpcProxyTransport{BandwidthLimit: p.BandwidthLimit}
// useEncryption/useCompression/bandwidthLimit are all members of frpc's
// per-proxy `transport` object — emitting them at the proxy top level
// makes frpc reject the entire config under --strict-config.
if p.UseEncryption || p.UseCompression || p.BandwidthLimit != "" {
frpcP.Transport = &frpcProxyTransport{
UseEncryption: p.UseEncryption,
UseCompression: p.UseCompression,
BandwidthLimit: p.BandwidthLimit,
}
}
if p.BasicAuthUser != "" || p.BasicAuthPassword != "" {
frpcP.HTTPBasicAuth = &frpcBasicAuth{User: p.BasicAuthUser, Password: p.BasicAuthPassword}

View File

@ -74,14 +74,31 @@ func TestRenderTransportAdvanced(t *testing.T) {
t.Fatalf("transport.poolCount = %d, want 3", cfg.Transport.PoolCount)
}
p := cfg.Proxies[0]
if !p.UseEncryption || !p.UseCompression || p.PoolCount != 2 {
t.Fatalf("proxy advanced fields = %+v", p)
// useEncryption/useCompression/bandwidthLimit belong to the proxy's nested
// `transport` object. Asserting them on the proxy top level is what let the
// old renderer ship a config frpc rejects outright (`unknown field
// "useEncryption"` under --strict-config).
if p.Transport == nil {
t.Fatalf("proxy transport section missing: %+v", p)
}
if !p.Transport.UseEncryption || !p.Transport.UseCompression || p.Transport.BandwidthLimit != "1MB" {
t.Fatalf("proxy transport = %+v", p.Transport)
}
if p.Metadatas["env"] != "prod" || p.Annotations["owner"] != "ops" {
t.Fatalf("proxy metadatas/annotations = %+v", p)
}
if p.Transport == nil || p.Transport.BandwidthLimit != "1MB" {
t.Fatalf("proxy transport.bandwidthLimit = %+v", p.Transport)
// Regression guard: the top level must carry no encryption/pool keys, since
// frpc runs with --strict-config and rejects unknown fields.
var raw struct {
Proxies []map[string]any `json:"proxies"`
}
if err := json.Unmarshal(data, &raw); err != nil {
t.Fatal(err)
}
for _, bad := range []string{"useEncryption", "useCompression", "poolCount"} {
if _, ok := raw.Proxies[0][bad]; ok {
t.Fatalf("proxy top level must not carry %q: %s", bad, data)
}
}
}
@ -100,8 +117,8 @@ func TestRenderTransportOmittedWhenEmpty(t *testing.T) {
if cfg.Transport.Protocol != "" || cfg.Transport.TLS != nil || cfg.Transport.PoolCount != 0 {
t.Fatalf("transport should be empty, got %+v", cfg.Transport)
}
if cfg.Proxies[0].UseEncryption || cfg.Proxies[0].Transport != nil {
t.Fatalf("proxy advanced should be empty, got %+v", cfg.Proxies[0])
if cfg.Proxies[0].Transport != nil {
t.Fatalf("proxy transport should be empty when no proxy-level knobs are set, got %+v", cfg.Proxies[0].Transport)
}
}
@ -264,3 +281,47 @@ func TestRenderAdminWebServerOmitted(t *testing.T) {
t.Fatalf("webServer should be omitted, got %+v", cfg.WebServer)
}
}
// A panel-managed frps refuses logins without `user`, so the rendered config
// must carry it verbatim when set.
func TestRenderEmitsUserWhenSet(t *testing.T) {
remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000, Token: "tok", User: "e8f52adfaf0e5c95"}
data, err := Render(remote, []Proxy{
{Name: "club", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 80, RemotePort: 62962},
})
if err != nil {
t.Fatal(err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatal(err)
}
if cfg.User != "e8f52adfaf0e5c95" {
t.Fatalf("user = %q, want e8f52adfaf0e5c95", cfg.User)
}
// The key must be present on the wire, not merely in the parsed struct:
// an omitempty regression would silently break panel-type frps again.
var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil {
t.Fatal(err)
}
if _, ok := raw["user"]; !ok {
t.Fatalf("rendered JSON has no user key: %s", data)
}
}
// Plain frps rejects unknown fields, so an empty user must be omitted entirely.
func TestRenderOmitsUserWhenEmpty(t *testing.T) {
remote := store.Remote{Name: "srv", IP: "1.2.3.4", Port: 7000}
data, err := Render(remote, nil)
if err != nil {
t.Fatal(err)
}
var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil {
t.Fatal(err)
}
if _, ok := raw["user"]; ok {
t.Fatalf("empty user must be omitted, got: %s", data)
}
}

View File

@ -73,6 +73,12 @@ type Remote struct {
URL string `json:"url,omitempty"`
Enabled bool `json:"enabled"`
// User is frpc's global `user` field: the proxy names reported to frps become
// spellings. Panel-managed frps installations require it for login (without
// it the server answers `panel authentication unavailable: panel HTTP status
// 403`), while plain frps ignores it. Empty = omitted from the rendered config.
User string `json:"user,omitempty"`
// Transport section (M1). Protocol: tcp | quic | kcp | websocket.
TransportProtocol string `json:"transportProtocol,omitempty"`
TransportTLS bool `json:"transportTls,omitempty"`
@ -322,6 +328,7 @@ func (s *Store) migrate() error {
"transport_tls INTEGER NOT NULL DEFAULT 0",
"transport_pool INTEGER NOT NULL DEFAULT 0",
"transport_tls_server_name TEXT NOT NULL DEFAULT ''",
"usr TEXT NOT NULL DEFAULT ''",
"vhost_http_port INTEGER NOT NULL DEFAULT 0",
"admin_addr TEXT NOT NULL DEFAULT ''",
"admin_port INTEGER NOT NULL DEFAULT 0",
@ -476,7 +483,7 @@ func (s *Store) DeleteLocal(name string) error {
// ---- Remotes ----
func (s *Store) ListRemotes() ([]Remote, error) {
rows, err := s.db.Query("SELECT name, ip, port, token, url, enabled, transport_protocol, transport_tls, transport_pool, transport_tls_server_name, vhost_http_port, admin_addr, admin_port, admin_user, admin_password FROM remotes ORDER BY name")
rows, err := s.db.Query("SELECT name, ip, port, token, url, enabled, transport_protocol, transport_tls, transport_pool, transport_tls_server_name, usr, vhost_http_port, admin_addr, admin_port, admin_user, admin_password FROM remotes ORDER BY name")
if err != nil {
return nil, err
}
@ -485,7 +492,7 @@ func (s *Store) ListRemotes() ([]Remote, error) {
for rows.Next() {
var r Remote
var en, tls int
if err := rows.Scan(&r.Name, &r.IP, &r.Port, &r.Token, &r.URL, &en, &r.TransportProtocol, &tls, &r.TransportPool, &r.TransportTLSServerName, &r.VhostHTTPPort, &r.AdminAddr, &r.AdminPort, &r.AdminUser, &r.AdminPassword); err != nil {
if err := rows.Scan(&r.Name, &r.IP, &r.Port, &r.Token, &r.URL, &en, &r.TransportProtocol, &tls, &r.TransportPool, &r.TransportTLSServerName, &r.User, &r.VhostHTTPPort, &r.AdminAddr, &r.AdminPort, &r.AdminUser, &r.AdminPassword); err != nil {
return nil, err
}
r.Enabled = en != 0
@ -498,8 +505,8 @@ func (s *Store) ListRemotes() ([]Remote, error) {
func (s *Store) GetRemote(name string) (Remote, bool) {
var r Remote
var en, tls int
row := s.db.QueryRow("SELECT name, ip, port, token, url, enabled, transport_protocol, transport_tls, transport_pool, transport_tls_server_name, vhost_http_port, admin_addr, admin_port, admin_user, admin_password FROM remotes WHERE name = ?", name)
if err := row.Scan(&r.Name, &r.IP, &r.Port, &r.Token, &r.URL, &en, &r.TransportProtocol, &tls, &r.TransportPool, &r.TransportTLSServerName, &r.VhostHTTPPort, &r.AdminAddr, &r.AdminPort, &r.AdminUser, &r.AdminPassword); err != nil {
row := s.db.QueryRow("SELECT name, ip, port, token, url, enabled, transport_protocol, transport_tls, transport_pool, transport_tls_server_name, usr, vhost_http_port, admin_addr, admin_port, admin_user, admin_password FROM remotes WHERE name = ?", name)
if err := row.Scan(&r.Name, &r.IP, &r.Port, &r.Token, &r.URL, &en, &r.TransportProtocol, &tls, &r.TransportPool, &r.TransportTLSServerName, &r.User, &r.VhostHTTPPort, &r.AdminAddr, &r.AdminPort, &r.AdminUser, &r.AdminPassword); err != nil {
return Remote{}, false
}
r.Enabled = en != 0
@ -509,9 +516,9 @@ func (s *Store) GetRemote(name string) (Remote, bool) {
func (s *Store) UpsertRemote(r Remote) error {
_, err := s.db.Exec(
"INSERT INTO remotes(name, ip, port, token, url, enabled, transport_protocol, transport_tls, transport_pool, transport_tls_server_name, vhost_http_port, admin_addr, admin_port, admin_user, admin_password) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "+
"ON CONFLICT(name) DO UPDATE SET ip=excluded.ip, port=excluded.port, token=excluded.token, url=excluded.url, enabled=excluded.enabled, transport_protocol=excluded.transport_protocol, transport_tls=excluded.transport_tls, transport_pool=excluded.transport_pool, transport_tls_server_name=excluded.transport_tls_server_name, vhost_http_port=excluded.vhost_http_port, admin_addr=excluded.admin_addr, admin_port=excluded.admin_port, admin_user=excluded.admin_user, admin_password=excluded.admin_password",
r.Name, r.IP, r.Port, r.Token, r.URL, boolToInt(r.Enabled), r.TransportProtocol, boolToInt(r.TransportTLS), r.TransportPool, r.TransportTLSServerName, r.VhostHTTPPort, r.AdminAddr, r.AdminPort, r.AdminUser, r.AdminPassword,
"INSERT INTO remotes(name, ip, port, token, url, enabled, transport_protocol, transport_tls, transport_pool, transport_tls_server_name, usr, vhost_http_port, admin_addr, admin_port, admin_user, admin_password) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "+
"ON CONFLICT(name) DO UPDATE SET ip=excluded.ip, port=excluded.port, token=excluded.token, url=excluded.url, enabled=excluded.enabled, transport_protocol=excluded.transport_protocol, transport_tls=excluded.transport_tls, transport_pool=excluded.transport_pool, transport_tls_server_name=excluded.transport_tls_server_name, usr=excluded.usr, vhost_http_port=excluded.vhost_http_port, admin_addr=excluded.admin_addr, admin_port=excluded.admin_port, admin_user=excluded.admin_user, admin_password=excluded.admin_password",
r.Name, r.IP, r.Port, r.Token, r.URL, boolToInt(r.Enabled), r.TransportProtocol, boolToInt(r.TransportTLS), r.TransportPool, r.TransportTLSServerName, r.User, r.VhostHTTPPort, r.AdminAddr, r.AdminPort, r.AdminUser, r.AdminPassword,
)
return err
}

View File

@ -58,3 +58,63 @@ func TestReplaceLinksClearsOld(t *testing.T) {
t.Fatalf("links = %+v", links)
}
}
// `usr` is a reserved SQL keyword in several dialects, so the remotes column is
// named `usr`. This guards the mapping in both directions, including the
// migrate() path that adds the column to pre-existing databases.
func TestRemoteUserRoundTrip(t *testing.T) {
st, err := New(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
// A remote written without user (existing installs) must read back empty.
if err := st.UpsertRemote(Remote{Name: "plain", IP: "1.2.3.4", Port: 7000, Enabled: true}); err != nil {
t.Fatal(err)
}
if got, ok := st.GetRemote("plain"); !ok || got.User != "" {
t.Fatalf("plain remote user = %q ok=%v, want empty", got.User, ok)
}
want := Remote{Name: "panel", IP: "103.40.13.126", Port: 59999, Token: "tk", User: "e8f52adfaf0e5c95", Enabled: true}
if err := st.UpsertRemote(want); err != nil {
t.Fatal(err)
}
got, ok := st.GetRemote("panel")
if !ok || got.User != want.User {
t.Fatalf("user not persisted: %+v", got)
}
list, err := st.ListRemotes()
if err != nil {
t.Fatal(err)
}
var found bool
for _, r := range list {
if r.Name == "panel" {
found = true
if r.User != want.User {
t.Fatalf("ListRemotes user = %q, want %q", r.User, want.User)
}
}
}
if !found {
t.Fatal("panel remote missing from ListRemotes")
}
// Updating in place must not drop the column, and clearing it must work.
want.Token = "tk2"
if err := st.UpsertRemote(want); err != nil {
t.Fatal(err)
}
if got, _ := st.GetRemote("panel"); got.User != want.User || got.Token != "tk2" {
t.Fatalf("upsert lost fields: %+v", got)
}
want.User = ""
if err := st.UpsertRemote(want); err != nil {
t.Fatal(err)
}
if got, _ := st.GetRemote("panel"); got.User != "" {
t.Fatalf("user not cleared: %q", got.User)
}
}

View File

@ -2,11 +2,11 @@
//
// Value is overridable at build time so release artifacts carry the real tag:
//
// go build -ldflags="-X webui4frpc/internal/version.Version=0.1.4" ./cmd/webui4frpc
// go build -ldflags="-X webui4frpc/internal/version.Version=0.2.0" ./cmd/webui4frpc
//
// Kept in its own package because both cmd/ and internal/httpapi report it
// (main can't be imported, and httpapi must not depend on cmd).
package version
// Version is the running build's semantic version, without a leading "v".
var Version = "0.1.4"
var Version = "0.2.0"

View File

@ -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": ""
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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" <<DISTEOF
<welcome language="zh_CN" mime-type="text/plain">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 中的密码。</welcome>
默认凭据 admin/admin —— 请立即修改
/usr/local/etc/webui4frpc/secret/password 后再重启
(sudo launchctl kickstart -k system/com.jianfeeeee.webui4frpc)。</welcome>
<choices-outline>
<line choice="default">
<line choice="com.jianfeeeee.webui4frpc"/>

View File

@ -1,5 +1,5 @@
Name: webui4frpc
Version: %{?_w4f_version}%{!?_w4f_version:0.1.4}
Version: %{?_w4f_version}%{!?_w4f_version:0.2.0}
Release: 1%{?dist}
Summary: Visual frpc controller with token-ring cluster
License: MIT
@ -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,10 +68,21 @@ 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
%changelog
* Fri Oct 23 2026 JianFeeeee <jianfeeeee@gitcode> - 0.2.0-1
- 从配置文件启动:新增 internal/appconfig,-config 指定配置文件(默认 <workdir>/config.json,$W4F_CONFIG 兜底)
- 从配置文件启动:优先级 flag > 文件 > 内置默认,仅显式传入的 flag 参与覆盖
- 从配置文件启动:支持 ${ENV} 与 file:<path> 两种密钥引用,口令/join-key 在日志与启动摘要中固定脱敏
- 从配置文件启动:未配口令保持旧行为并打 WARNING;文件 JSON 损坏或含未知键则报错退出(防口令拼错静默回落)
- 打包:systemd 单元与 deb/rpm/pkg/NSIS 全部改为 ExecStart=-config /etc/webui4frpc/config.json,命令行不再出现口令
- 修复渲染:useEncryption/useCompression/bandwidthLimit 内嵌进 proxy 的 transport 对象(此前渲染在顶层,frpc --strict-config 拒载)
- 新增 frpc 全局 user 字段(store 列名 usr,自动加列迁移),支持面板型 frps 强制 user 登录
- 前端:RemoteNode 画布与 StatusView 表单新增 user 输入,带「面板型 frps 必填」提示
* Mon Oct 06 2026 JianFeeeee <jianfeeeee@gitcode> - 0.1.4-1
- 负载均衡:改为每节点自主释放(持有数超过平摊份额且 >=2 时自撤销一条丢回环,由最闲节点认领),不再由 leader 统一调度
- 负载均衡:新增自限上界与下限(撤到平摊值即停、低于 2 条不撤),10 分钟冷却,默认不动作(dry-run)

View File

@ -79,7 +79,7 @@ cd web && npm run build && rm -rf internal/httpapi/dist && cp -r web/dist intern
### Linux (Debian / Ubuntu)
```bash
sudo dpkg -i webui4frpc-0.1.4-linux-amd64.deb # 或 linux-arm64.deb
sudo dpkg -i webui4frpc-0.2.0-linux-amd64.deb # 或 linux-arm64.deb
sudo nano /etc/webui4frpc/webui4frpc.env # 改掉默认密码
sudo systemctl restart webui4frpc
```
@ -87,7 +87,7 @@ sudo systemctl restart webui4frpc
### Linux (RHEL / Fedora / openEuler / 默认 / 龙蜥)
```bash
sudo rpm -i webui4frpc-0.1.4-linux-x86_64.rpm
sudo rpm -i webui4frpc-0.2.0-linux-x86_64.rpm
sudo vi /etc/webui4frpc/webui4frpc.env
sudo systemctl enable --now webui4frpc
```
@ -97,7 +97,7 @@ sudo systemctl enable --now webui4frpc
### Windows
双击 `webui4frpc-0.1.4-windows-amd64-setup.exe`(ARM 设备用 `windows-arm64-setup.exe`)。
双击 `webui4frpc-0.2.0-windows-amd64-setup.exe`(ARM 设备用 `windows-arm64-setup.exe`)。
安装向导会询问监听地址与管理员凭据,完成后:
- 程序装到 `C:\Program Files\webui4frpc`
@ -107,8 +107,8 @@ sudo systemctl enable --now webui4frpc
### macOS
```bash
sudo installer -pkg webui4frpc-0.1.4-darwin-arm64.pkg -target / # Apple Silicon
sudo installer -pkg webui4frpc-0.1.4-darwin-amd64.pkg -target / # Intel
sudo installer -pkg webui4frpc-0.2.0-darwin-arm64.pkg -target / # Apple Silicon
sudo installer -pkg webui4frpc-0.2.0-darwin-amd64.pkg -target / # Intel
```
或直接双击 `.pkg` 走图形安装器。安装后 launchd 开机自启,配置在
@ -126,7 +126,7 @@ sudo launchctl kickstart -k system/com.jianfeeeee.webui4frpc
不想装服务只想跑一下,用对应的 `.tar.gz` / `.zip`,解压即用:
```bash
tar xzf webui4frpc-0.1.4-linux-amd64.tar.gz
tar xzf webui4frpc-0.2.0-linux-amd64.tar.gz
./webui4frpc -addr 127.0.0.1:7500 -user admin -password 你的密码 -workdir ./data
```

View File

@ -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"

View File

@ -12,7 +12,7 @@
# 在 release.sh 流程内被调用;依赖 dist/artifacts/<version>/bin/<os>-<arch>/ 下的二进制。
set -euo pipefail
VERSION="${1:-0.1.4}"
VERSION="${1:-0.2.0}"
VERSION="${VERSION#v}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
OUT="$ROOT/dist/artifacts/$VERSION"
@ -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

View File

@ -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: 配置文件启动与口令不泄漏的判据全部通过"

View File

@ -3,7 +3,7 @@
# webui4frpc 多平台发布脚本
#
# 用法:
# ./scripts/release.sh [version] # 默认 version=0.1.4
# ./scripts/release.sh [version] # 默认 version=0.2.0
#
# 行为:
# 1. 构建前端 (web/dist) 并同步到 internal/httpapi/dist (go:embed 用)
@ -19,7 +19,7 @@
# NO_BUILD_FRONTEND 设为 1 跳过前端构建 (用现有 dist)
set -euo pipefail
VERSION="${1:-0.1.4}"
VERSION="${1:-0.2.0}"
VERSION="${VERSION#v}" # 去掉可能的前导 v
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
OUT="$ROOT/dist/artifacts/$VERSION"
@ -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"

View File

@ -51,6 +51,12 @@
<label class="adv-sep">HTTP 访问端口 (frps vhostHTTPPort)</label>
<label>vhostHTTPPort <input v-model.number="vhostHttpPortField" type="number" min="0" /></label>
<label class="adv-sep">用户标识 (frpc user)</label>
<label>user <input v-model="userField" :placeholder="userHint" /></label>
<div v-if="needsUser" class="user-warn">
面板型 frps 必须填 user,否则登录被拒(panel HTTP status 403);留空则该项不出现在生成的配置里。
</div>
<label class="adv-sep">本地 frpc 管理 API (M3)</label>
<label class="adv-check">启用后状态页按真实代理状态展示
<span class="hint-txt">(adminPort &gt; 0)</span>
@ -96,6 +102,7 @@ const portField = field('port')
const tokenField = field('token')
const urlField = field('url')
const enabledField = field('enabled')
const userField = field('user')
const transportProtocolField = field('transportProtocol')
const transportTlsField = field('transportTls')
const transportTlsServerNameField = field('transportTlsServerName')
@ -106,6 +113,13 @@ const adminPortField = field('adminPort')
const adminUserField = field('adminUser')
const adminPasswordField = field('adminPassword')
const showAdv = ref(false)
// A panel-managed frps rejects password-only logins, so surface the field with
// a hint instead of leaving the failure to a frpc log line nobody reads.
const needsUser = computed(() => !String(userField.value || '').trim())
const userHint = computed(() =>
needsUser.value ? '必填(面板型 frps)' : '与 frps 侧一致',
)
</script>
<style scoped lang="scss">
@ -299,6 +313,14 @@ const showAdv = ref(false)
border-top: 1px dashed var(--w4f-warn-border);
padding-top: 4px;
}
// warning shown while the required `user` field is still empty
.user-warn {
grid-column: 1 / -1;
font-size: 11px;
line-height: 1.4;
color: var(--w4f-warn-text);
}
}
}
</style>

View File

@ -54,8 +54,14 @@ export interface Remote {
// M1 transport section
transportProtocol?: string; // tcp | quic | kcp | websocket
transportTls?: boolean;
transportPool?: number;
transportTlsServerName?: string;
transportPool?: number;
transportTlsServerName?: string;
// user: frpc's global `user` field. frps prefixes every proxy name with it,
// and panel-managed frps requires it for login (otherwise the server answers
// `panel authentication unavailable: panel HTTP status 403`).
// Empty = omitted from the rendered config (plain frps).
user?: string;
// M2: frps vhostHTTPPort
vhostHttpPort?: number;

View File

@ -187,6 +187,10 @@
<label>Token</label>
<el-input v-model="dialog.remote.token" :disabled="!canWrite" placeholder="可选" />
</div>
<div class="form-row">
<label>user</label>
<el-input v-model="dialog.remote.user" :disabled="!canWrite" placeholder="面板型 frps 必填,普通 frps 可留空" />
</div>
<div class="form-row">
<label>URL</label>
<el-input v-model="dialog.remote.url" :disabled="!canWrite" placeholder="可选" />
@ -226,6 +230,7 @@ const newRemote = (): Remote => ({
port: 7000,
token: '',
url: '',
user: '',
enabled: true,
})
@ -246,7 +251,7 @@ const openEdit = (p: { name: string }) => {
dialog.isNew = false
dialog.remote = found
? { ...found }
: { name: p.name, ip: '', port: 7000, token: '', url: '', enabled: true }
: { name: p.name, ip: '', port: 7000, token: '', url: '', user: '', enabled: true }
dialog.visible = true
}