mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-10-30 22:07:21 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1878401d3a | |||
| d83e5d8e70 | |||
| b29cd0d186 | |||
| 42765282a3 | |||
| f6c877b736 | |||
| ed62fd97a4 | |||
| f4d4e3cd52 | |||
| 2f0f7d8b45 | |||
| 1eeffd8434 | |||
| ee46b758fa | |||
| a96335dbf1 | |||
| e0e2bf9e44 | |||
| 21eb52df24 | |||
| 1a850b4846 | |||
| c90d4f3791 | |||
| 790eaa5ed1 | |||
| c69fb2caba | |||
| b40b53b40b | |||
| d347d2a648 | |||
| 8176478269 | |||
| 45a6a76621 | |||
| 7d5bca5b72 | |||
| 875e5805eb | |||
| 084ef62b32 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -22,6 +22,7 @@ data/
|
||||
|
||||
# pi agent local state
|
||||
.pi/
|
||||
.pi-sessions/
|
||||
|
||||
# docker demo staging (frp binaries)
|
||||
frp-bin/
|
||||
|
||||
192
README.md
192
README.md
@ -32,6 +32,14 @@
|
||||
- 主负载信号:当前节点拥有的转发数(权重 ×100)
|
||||
- 次级信号:**NIC 网络接口利用率**(从 `/proc/net/dev` 增量 + `/sys/class/net/*/speed` 计算,排除了 lo/docker/veth 等虚拟接口)
|
||||
- 同转发数节点间由内存占用 + 网络饱和度打破平局
|
||||
- **负载均衡(自导式)**:每个节点对**自己的**转发跑同一条规则 —— 持有数超过
|
||||
`ceil(总转发数 / 存活节点数)` 且至少 2 条时,自行撤销一条、把任务丢回环上,
|
||||
再由环按「最闲者优先」重新认领
|
||||
- **没有中心调度**:一节点的负载只有它自己的拓扑条目消失时才会下降,而条目是
|
||||
本地簿记,没有对端能代它写,所以只能由持有者自己动手
|
||||
- 规则自限:撤到平摊值即停(否则会把转发卸光),且低于下限永不撤、不会卸空节点
|
||||
- 每节点默认 10 分钟冷却,限制抖动频率;默认 **dry-run**(只报告不释放)
|
||||
- 集群页每节点一个开关:关闭 / 观察模式 / 启用,可持久化
|
||||
- **per-forward 独立进程**:每条转发拥有独立的 frpc 配置文件和 frpc 进程(worker 键 = `local~remote~port`),单条转发故障不影响兄弟,消除旧模型的多节点 `proxy already exists` 冲突
|
||||
- **容错自愈**:leader / 非 leader 节点宕机 → 自动检测 → 环跳过死亡节点、任务重挂;leader 宕机 → 上邻居晋升为新 leader
|
||||
- **宕机自动重联**:节点宕机重启后通过缓存的集群拓扑(对端地址 + 准入密钥)自动重联,显式脱离集群的节点不自动重联
|
||||
@ -79,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.3-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.3-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
|
||||
```
|
||||
|
||||
@ -97,7 +105,7 @@ sudo systemctl enable --now webui4frpc
|
||||
|
||||
### Windows
|
||||
|
||||
双击 `webui4frpc-0.1.3-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 +115,8 @@ sudo systemctl enable --now webui4frpc
|
||||
### macOS
|
||||
|
||||
```bash
|
||||
sudo installer -pkg webui4frpc-0.1.3-darwin-arm64.pkg -target / # Apple Silicon
|
||||
sudo installer -pkg webui4frpc-0.1.3-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 +134,7 @@ sudo launchctl kickstart -k system/com.jianfeeeee.webui4frpc
|
||||
不想装服务只想跑一下,用对应的 `.tar.gz` / `.zip`,解压即用:
|
||||
|
||||
```bash
|
||||
tar xzf webui4frpc-0.1.3-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
|
||||
```
|
||||
|
||||
@ -144,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) |
|
||||
@ -160,6 +175,117 @@ 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
|
||||
```
|
||||
|
||||
### 负载均衡参数
|
||||
|
||||
每节点独立生效;集群页的开关**优先于**这里的默认值(在页面上选过就以页面为准,重启不丢)。
|
||||
|
||||
| 环境变量 | 默认 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `W4F_BALANCE_MODE` | `dry-run` | `off` 不参与 / `dry-run` 只报告 / `on` 真正释放 |
|
||||
| `W4F_BALANCE_COOLDOWN` | `600` | 同一节点两次释放之间的最小秒数 |
|
||||
| `W4F_BALANCE_INTERVAL` | `10` | 检查间隔(秒) |
|
||||
|
||||
**释放会停掉该转发的 frpc worker,其上的在途连接会断一次**,由新节点认领后恢复;
|
||||
因此默认是 `dry-run`。集群页的开关走 write 级权限。
|
||||
|
||||
### 启动行为
|
||||
|
||||
节点启动时的集群引导优先级:
|
||||
@ -191,10 +317,10 @@ webui4frpc(单二进制,无 frp 依赖,前端 go:embed)
|
||||
│ ├── render 渲染 frpc JSON 配置(per-forward 单 proxy + 高级参数 + LB/健康检查)
|
||||
│ ├── process worker 进程管理(spawn/stop/restart/自愈/日志轮转,worker 键 = local~remote~port)
|
||||
│ ├── install 一键下载官方 frpc(GitHub Releases,含解压安全防护)
|
||||
│ ├── cluster 令牌环协议(ring engine / leader 选举 / 容错 / 宕机重联 / 二进制分发 / NIC 负载采样)
|
||||
│ ├── cluster 令牌环协议(ring engine / leader 选举 / 容错 / 宕机重联 / 二进制分发 / NIC 负载采样 / 自导式负载均衡)
|
||||
│ └── httpapi REST API + 静态资源(auth/session / canvas / forwards / cluster / users / audit 导出)
|
||||
└── web/ Vue 3 SPA(Element Plus + VueFlow 画布 + Pinia)
|
||||
└── src/views/ 状态总览 / 连接配置(画布)/ 集群 / 设置 / 账号与密钥
|
||||
└── src/views/ 状态总览 / 连接配置(画布)/ 集群(含负载均衡开关)/ 设置 / 账号与密钥
|
||||
```
|
||||
|
||||
## API
|
||||
@ -220,6 +346,8 @@ webui4frpc(单二进制,无 frp 依赖,前端 go:embed)
|
||||
| | GET | `/binary/status` | read | frpc 路径 |
|
||||
| | POST | `/binary/install` | write | 安装 frpc |
|
||||
| **集群** | GET | `/cluster/ring` | read | 令牌环快照 |
|
||||
| | GET | `/cluster/balance` | read | 本节点负载均衡视图(只读,不变更状态) |
|
||||
| | POST | `/cluster/balance/mode` | write | 切换本节点负载均衡模式(持久化) |
|
||||
| | POST | `/cluster/create` | write | 创建独立集群 |
|
||||
| | POST | `/cluster/join-ring` | write | 加入集群(本节点发起) |
|
||||
| | POST | `/cluster/join` | write | 接收新节点加入 |
|
||||
@ -254,8 +382,45 @@ webui4frpc(单二进制,无 frp 依赖,前端 go:embed)
|
||||
- **任务分发**:在任意节点画布上配置 `localOnly=false` 的转发并保存 → 画布差异判断生成命令 → 令牌携带 → 负载最低节点摘取 → 拉起 frpc worker
|
||||
- **容错**:节点宕机 → 自动检测 → 环自愈(跳过死亡节点 / leader failover);重启 → 自动重联
|
||||
- **退出集群**:在集群页「退出集群」→ 本节点脱离为 standalone,不自动重联
|
||||
- **负载均衡**:见下节
|
||||
|
||||
集群页展示:环拓扑(self/leader 标记)、令牌轮次、节点负载、待办任务、活跃转发拓扑(含 owner 归属)、增量事件日志。
|
||||
集群页展示:环拓扑(self/leader 标记)、令牌轮次、节点负载、待办任务、活跃转发拓扑(含 owner 归属)、负载均衡(本机份额与释放计划)、增量事件日志。
|
||||
|
||||
### 负载均衡(自导式释放)
|
||||
|
||||
**为什么不是 leader 统一调度**:一节点的负载信号是它持有的转发数,而这个数只在
|
||||
**它自己的拓扑条目消失**时才下降 —— 条目是本地簿记。所以 leader 从外面只能*看见*
|
||||
失衡,无法*消除*它;要消除就得伸手改另一个节点的簿记,而这正是这个环到处都在避免
|
||||
的跨节点写(撤销、重启同样都是定向往 owner)。
|
||||
|
||||
于是规则由每个节点对自己的转发本地执行:
|
||||
|
||||
```
|
||||
当 持有转发数 >= 2 且 持有转发数 > ceil(总转发数 / 存活节点数)
|
||||
则 撤销其中一条 → 删除条目 → 以启用状态重新发布到环 → 由最闲节点认领
|
||||
```
|
||||
|
||||
- **自限**:撤到平摊值即停。3 条分 3 台时,持有 2 条的那台卸一条后变成 1 条,
|
||||
`1 > 1` 不成立 → 停手。没有这个上界,owner 会把转发全部卸光。
|
||||
- **不会卸空**:低于下限 2 条永不撤销。
|
||||
- **不碰已停用的转发**:停用条目的 entry 是用户「停用」决定的载体,释放它等于把
|
||||
一条**已停**的转发当活干交给别人。
|
||||
- **释放 ≠ 停止**:用户停止保留条目并标记链路为停用;均衡释放**删除条目**且链路保持
|
||||
启用。两者不能混用 —— 复用停止的话,每个节点的重复认领守卫都会拒绝这条已是「有主」
|
||||
的转发,它会被永久搁死在刚放弃它的那台机器上。
|
||||
|
||||
三种模式(集群页每节点一个开关,也可用 `W4F_BALANCE_MODE` 设默认值):
|
||||
|
||||
| 模式 | 行为 |
|
||||
| --- | --- |
|
||||
| `off` | 不参与均衡 |
|
||||
| `dry-run` | 只报告「将释放哪条」,不动任何东西(默认) |
|
||||
| `on` | 真正释放 |
|
||||
|
||||
只读接口 `GET /cluster/balance` 返回本节点视角:`selfForwards` / `share` /
|
||||
`totalForwards` / `aliveNodes` / `selfOverShare`、`wouldRelease`、`releasing`、
|
||||
`blockedBy`(为何暂不动的机器原因)、`mode` / `modeSource` / `canControl`。
|
||||
该接口**不改变任何状态** —— 反复轮询不会启动、延迟或取消一次释放。
|
||||
|
||||
## 测试
|
||||
|
||||
@ -263,6 +428,9 @@ webui4frpc(单二进制,无 frp 依赖,前端 go:embed)
|
||||
# 后端
|
||||
go test ./...
|
||||
|
||||
# 单包(负载均衡规则与状态机)
|
||||
go test ./internal/cluster/ -run TestShare -v
|
||||
|
||||
# 前端类型检查
|
||||
cd web && npm run type-check
|
||||
cd web && npm run build
|
||||
```
|
||||
|
||||
@ -15,10 +15,12 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"webui4frpc/internal/appconfig"
|
||||
"webui4frpc/internal/cluster"
|
||||
"webui4frpc/internal/httpapi"
|
||||
"webui4frpc/internal/install"
|
||||
@ -29,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)
|
||||
}
|
||||
@ -61,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)
|
||||
}
|
||||
|
||||
@ -77,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{
|
||||
@ -112,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
|
||||
@ -133,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
|
||||
@ -289,6 +341,21 @@ func main() {
|
||||
log.Printf("ring[%s] revoked task %s: %s→%s:%d", selfID, tk.ID, tk.Local.Name, tk.Remote.Name, tk.Link.RemotePort)
|
||||
return nil
|
||||
},
|
||||
// ReleaseFn stops this forward's worker for a REBALANCE, leaving the
|
||||
// link ENABLED. This must not reuse RevokeFn: that marks the link
|
||||
// stopped, and the released forward is about to run on another node —
|
||||
// a "stopped" flag would make the claim path treat the re-published
|
||||
// task as a disabled forward and refuse to start a worker for it,
|
||||
// leaving the forward dead everywhere.
|
||||
ReleaseFn: func(ctx context.Context, tk *cluster.Task) error {
|
||||
key := process.WorkerKey(tk.Local.Name, tk.Remote.Name, tk.Link.RemotePort)
|
||||
if _, running := pm.Status(key); running {
|
||||
_ = pm.Stop(key)
|
||||
}
|
||||
log.Printf("ring[%s] released worker %s (rebalancing): %s→%s:%d", selfID, key,
|
||||
tk.Local.Name, tk.Remote.Name, tk.Link.RemotePort)
|
||||
return nil
|
||||
},
|
||||
// RestartFn brings an already-claimed forward back after a stop. It
|
||||
// must NOT run the claim bookkeeping (ClaimFn), which appends links and
|
||||
// writes topology state: the forward is already established, and a stop
|
||||
@ -338,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
|
||||
@ -388,6 +455,11 @@ func main() {
|
||||
}
|
||||
})
|
||||
|
||||
// Load balancer is created here (before the Handler) so the HTTP layer can
|
||||
// expose its read-only report. Mode/parameters are applied further down, once
|
||||
// the W4F_BALANCE_* environment has been parsed.
|
||||
balancer := cluster.NewBalancer(ring, 600)
|
||||
|
||||
h := &httpapi.Handler{
|
||||
Store: st,
|
||||
Process: pm,
|
||||
@ -396,12 +468,15 @@ func main() {
|
||||
Sessions: httpapi.NewSessionStore(),
|
||||
Cluster: reg,
|
||||
Ring: ring,
|
||||
// Balancer is created above, before the environment that configures it is
|
||||
// parsed, because the HTTP layer needs the pointer.
|
||||
Balancer: balancer,
|
||||
// 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 != "" {
|
||||
@ -506,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)
|
||||
}
|
||||
@ -542,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)
|
||||
@ -553,6 +628,65 @@ func main() {
|
||||
go ring.WatchTokenLoss(clusterCtx)
|
||||
go ring.WatchLeader(clusterCtx)
|
||||
|
||||
// Load balancing — SELF-DIRECTED, and dry-run by default.
|
||||
//
|
||||
// Every node runs this check against its OWN forwards. There is no
|
||||
// coordinator: a node's load only actually falls when its own topology entry
|
||||
// goes away, so only the owner can produce the state change that makes the
|
||||
// ring hand a forward to somebody else. The node gives a forward up and
|
||||
// re-publishes it as an ordinary unclaimed task; the ring's normal
|
||||
// lowest-load claim then places it. (The earlier design had the leader pick a
|
||||
// victim and a destination — but it can only see the imbalance, not resolve
|
||||
// it, so it had to reach into another node's bookkeeping to make its decision
|
||||
// true.)
|
||||
//
|
||||
// A release stops a worker and drops a live connection, so acting on the ring
|
||||
// is opt-in:
|
||||
//
|
||||
// W4F_BALANCE_MODE=dry-run report the decision, release nothing (default)
|
||||
// W4F_BALANCE_MODE=on actually release
|
||||
// W4F_BALANCE_MODE=off do nothing at all
|
||||
// W4F_BALANCE_COOLDOWN=600 min seconds between two releases by one node
|
||||
// W4F_BALANCE_INTERVAL=10 check every N seconds
|
||||
balMode := strings.ToLower(strings.TrimSpace(os.Getenv("W4F_BALANCE_MODE")))
|
||||
if balMode == "" {
|
||||
balMode = cluster.BalanceDryRun
|
||||
}
|
||||
balInterval := time.Duration(envFloat("W4F_BALANCE_INTERVAL", 10)) * time.Second
|
||||
if balInterval < time.Second {
|
||||
balInterval = 10 * time.Second
|
||||
}
|
||||
balCooldown := int64(envFloat("W4F_BALANCE_COOLDOWN", 600))
|
||||
balancer.SetCooldown(balCooldown)
|
||||
balancer.SetMode(balMode)
|
||||
// A mode stored from the cluster page wins over the environment, so flipping
|
||||
// the switch is not silently reverted by the next restart. SetMode is applied
|
||||
// first so a stored value only has to be a no-op when the two agree.
|
||||
balancer.SetModePersistence(
|
||||
func() (string, bool) {
|
||||
m, ok, err := st.BalanceMode()
|
||||
if err != nil {
|
||||
log.Printf("ring[%s] read stored balance mode: %v", selfID, err)
|
||||
return "", false
|
||||
}
|
||||
return m, ok
|
||||
},
|
||||
func(m string) error {
|
||||
s, err := st.Settings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.BalanceMode = m
|
||||
return st.UpdateSettings(s)
|
||||
},
|
||||
)
|
||||
// Report the EFFECTIVE mode, not balMode: a stored value may have overridden
|
||||
// the environment, and a startup line naming the wrong mode is exactly how an
|
||||
// operator ends up believing releasing is off while it is on.
|
||||
log.Printf("ring[%s] load balancing %s (source=%s cooldown=%ds interval=%s, self-directed)",
|
||||
selfID, strings.ToUpper(balancer.Mode()), balancer.ModeSource(), balCooldown, balInterval)
|
||||
go balancer.Watch(clusterCtx, balInterval)
|
||||
|
||||
// Startup reconcile: every node (leader or not) checks its own topology
|
||||
// entries and re-claims any whose per-forward frpc worker process is
|
||||
// absent — the topology was recovered via AdoptState but the worker
|
||||
@ -735,6 +869,22 @@ func retryJoinCluster(ctx context.Context, ring *cluster.Engine, targetAddr, joi
|
||||
}
|
||||
}
|
||||
|
||||
// envFloat reads a numeric environment variable, returning def when unset or
|
||||
// unparseable. A malformed value must not silently change behaviour, so it falls
|
||||
// back to the documented default rather than 0.
|
||||
func envFloat(key string, def float64) float64 {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
f, err := strconv.ParseFloat(v, 64)
|
||||
if err != nil {
|
||||
log.Printf("env %s=%q is not a number; using %v", key, v, def)
|
||||
return def
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// retryRejoinCached is the crash/restart recovery path: parse the persisted
|
||||
// peer list (JSON of [{addr,key},...]) and try to rejoin via each peer in
|
||||
// order. The first peer that accepts wins. Retry the whole list every 10s
|
||||
|
||||
197
internal/appconfig/appconfig.go
Normal file
197
internal/appconfig/appconfig.go
Normal 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") }
|
||||
317
internal/appconfig/appconfig_test.go
Normal file
317
internal/appconfig/appconfig_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
140
internal/appconfig/resolve.go
Normal file
140
internal/appconfig/resolve.go
Normal 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() }
|
||||
728
internal/cluster/balance_test.go
Normal file
728
internal/cluster/balance_test.go
Normal file
@ -0,0 +1,728 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"webui4frpc/internal/store"
|
||||
)
|
||||
|
||||
// mustState round-trips the seed through JSON so the engine under test owns its
|
||||
// own copy of Nodes/Topology. AdoptState assigns the state by value, but Nodes is
|
||||
// a slice: handing one seed to several engines would alias one backing array and
|
||||
// make a mutation on one node visible on the others — a test artefact that
|
||||
// previously produced a false green here.
|
||||
func mustState(t *testing.T, s State) *State {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out State
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := newTestEngine("probe", true)
|
||||
e.AdoptState(out)
|
||||
return e.State()
|
||||
}
|
||||
|
||||
// migrateRing builds a 3-node ring where n1 owns every forward, plus n2/n3 idle.
|
||||
func migrateRing(t *testing.T, ownCount int) (*Engine, *Engine, *Engine) {
|
||||
t.Helper()
|
||||
mk := func(id string, leader bool) *Engine {
|
||||
h := &fakeHandler{load: Load{MemPct: 5, NetPct: 5}}
|
||||
return NewEngine(id, id+":7500", "u", "p", "0.1.3", nil, h,
|
||||
func(ctx context.Context, next string, tk *Token) error { return nil },
|
||||
id+":7500", leader, "")
|
||||
}
|
||||
n1, n2, n3 := mk("n1", true), mk("n2", false), mk("n3", false)
|
||||
|
||||
seed := State{
|
||||
LeaderID: "n1",
|
||||
Nodes: []Node{
|
||||
{ID: "n1", Addr: "n1:7500", Alive: true, IsLeader: true, Load: Load{Forwards: ownCount, MemPct: 5}},
|
||||
{ID: "n2", Addr: "n2:7500", Alive: true, Load: Load{MemPct: 5}},
|
||||
{ID: "n3", Addr: "n3:7500", Alive: true, Load: Load{MemPct: 5}},
|
||||
},
|
||||
Topology: map[string]*TopoEntry{},
|
||||
PendingTasks: map[string]*Task{},
|
||||
}
|
||||
for _, e := range []*Engine{n1, n2, n3} {
|
||||
var own State
|
||||
b, _ := json.Marshal(seed)
|
||||
if err := json.Unmarshal(b, &own); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.AdoptState(own)
|
||||
}
|
||||
// n1 claims `ownCount` forwards.
|
||||
for i := 0; i < ownCount; i++ {
|
||||
n1.state.AddTopology(&Task{
|
||||
ID: "f" + string(rune('a'+i)),
|
||||
Local: store.Local{Name: "web"}, Remote: store.Remote{Name: "frps1"},
|
||||
Link: store.Link{RemotePort: 100 + i},
|
||||
}, "n1")
|
||||
}
|
||||
var snap State
|
||||
b, _ := json.Marshal(n1.state)
|
||||
_ = json.Unmarshal(b, &snap)
|
||||
for _, e := range []*Engine{n1, n2, n3} {
|
||||
e.AdoptState(snap)
|
||||
}
|
||||
return n1, n2, n3
|
||||
}
|
||||
|
||||
// errNotWritable stands in for a failing settings write.
|
||||
var errNotWritable = errors.New("not writable")
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
// balanceRing builds a 3-node ring with `owned` forwards on n1 (the evaluation
|
||||
// node) and `others` on each of the other two, so a test can set up any
|
||||
// distribution directly. The replicated Nodes[].Load is made consistent with the
|
||||
// topology, because that is what a real token round leaves behind.
|
||||
func balanceRing(t *testing.T, owned, others int) (*Engine, *Engine, *Engine) {
|
||||
t.Helper()
|
||||
mk := func(id string, leader bool) *Engine {
|
||||
h := &fakeHandler{load: Load{MemPct: 5, NetPct: 5}}
|
||||
return NewEngine(id, id+":7500", "u", "p", "0.1.3", nil, h,
|
||||
func(ctx context.Context, next string, tk *Token) error { return nil },
|
||||
id+":7500", leader, "")
|
||||
}
|
||||
n1, n2, n3 := mk("n1", true), mk("n2", false), mk("n3", false)
|
||||
|
||||
seed := State{
|
||||
LeaderID: "n1",
|
||||
Nodes: []Node{
|
||||
{ID: "n1", Addr: "n1:7500", Alive: true, IsLeader: true, Load: Load{Forwards: owned, MemPct: 5}},
|
||||
{ID: "n2", Addr: "n2:7500", Alive: true, Load: Load{Forwards: others, MemPct: 5}},
|
||||
{ID: "n3", Addr: "n3:7500", Alive: true, Load: Load{Forwards: others, MemPct: 5}},
|
||||
},
|
||||
Topology: map[string]*TopoEntry{},
|
||||
PendingTasks: map[string]*Task{},
|
||||
}
|
||||
for _, e := range []*Engine{n1, n2, n3} {
|
||||
var own State
|
||||
b, _ := json.Marshal(seed)
|
||||
if err := json.Unmarshal(b, &own); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.AdoptState(own)
|
||||
}
|
||||
// Build the whole topology in n1's state, then broadcast it. Adding each
|
||||
// node's forwards to its OWN state and then re-adopting from n1 would discard
|
||||
// the others (one state is the source of the broadcast), which is how an
|
||||
// earlier version of this helper silently produced a 1-1-1 split regardless
|
||||
// of the arguments.
|
||||
add := func(owner, prefix string, count, basePort int) {
|
||||
for i := 0; i < count; i++ {
|
||||
n1.state.AddTopology(&Task{
|
||||
ID: prefix + strconv.Itoa(i),
|
||||
Local: store.Local{Name: "web"}, Remote: store.Remote{Name: "frps1"},
|
||||
Link: store.Link{RemotePort: basePort + i},
|
||||
}, owner)
|
||||
}
|
||||
}
|
||||
add("n1", "f", owned, 100)
|
||||
add("n2", "g", others, 200)
|
||||
add("n3", "h", others, 300)
|
||||
|
||||
var snap State
|
||||
b, _ := json.Marshal(n1.state)
|
||||
_ = json.Unmarshal(b, &snap)
|
||||
for _, e := range []*Engine{n1, n2, n3} {
|
||||
e.AdoptState(snap)
|
||||
}
|
||||
return n1, n2, n3
|
||||
}
|
||||
|
||||
// ---- the rule ----
|
||||
|
||||
// TestShareIsTheEvenSplit pins the arithmetic, including the ceiling. An even
|
||||
// split of 3 over 3 is 1 (not 1.0 floored to 0): with a floor, a node holding
|
||||
// one of three forwards would still be "over its share" and shed it.
|
||||
func TestShareIsTheEvenSplit(t *testing.T) {
|
||||
for _, tc := range []struct{ total, alive, want int }{
|
||||
{3, 3, 1}, {4, 3, 2}, {6, 2, 3}, {1, 3, 1}, {0, 3, 0}, {7, 3, 3},
|
||||
} {
|
||||
got := (tc.total + tc.alive - 1) / tc.alive
|
||||
if got != tc.want {
|
||||
t.Errorf("share(%d over %d) = %d, want %d", tc.total, tc.alive, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOwnerReleasesWhenOverItsShare: 3 forwards on n1, none elsewhere → n1 holds
|
||||
// 3 against a share of 1 and must give one up.
|
||||
func TestOwnerReleasesWhenOverItsShare(t *testing.T) {
|
||||
n1, _, _ := balanceRing(t, 3, 0)
|
||||
b := NewBalancer(n1, 0)
|
||||
|
||||
pick, p, blocked := b.assess(1000)
|
||||
if pick == nil {
|
||||
t.Fatalf("n1 holds 3 of 3 forwards and should release one (blocked=%q)", blocked)
|
||||
}
|
||||
if blocked != "" {
|
||||
t.Errorf("nothing should block the first release, got %q", blocked)
|
||||
}
|
||||
if p.mine != 3 || p.share != 1 || p.total != 3 || p.alive != 3 {
|
||||
t.Errorf("pool = %+v, want mine=3 share=1 total=3 alive=3", p)
|
||||
}
|
||||
if pick.OwnerID != "n1" {
|
||||
t.Errorf("must release a forward n1 owns, got owner %q", pick.OwnerID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReleasingStopsAtTheShare is the bound that keeps the rule from emptying a
|
||||
// node: after shedding one, n1 holds 1 of 3 over 3 nodes, which is no longer more
|
||||
// than the share, and it must stop.
|
||||
func TestReleasingStopsAtTheShare(t *testing.T) {
|
||||
n1, _, _ := balanceRing(t, 1, 1) // even 1-1-1 split
|
||||
b := NewBalancer(n1, 0)
|
||||
|
||||
pick, p, blocked := b.assess(1000)
|
||||
if pick != nil {
|
||||
t.Errorf("an even 1-1-1 split must not release anything, got %s:%d (blocked=%q)",
|
||||
pick.Local.Name, pick.Link.RemotePort, blocked)
|
||||
}
|
||||
if p.mine != 1 || p.share != 1 {
|
||||
t.Errorf("pool = %+v, want mine=1 share=1", p)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSingleNodeNeverReleases: a standalone node is the whole cluster, so its
|
||||
// share is everything it holds. Without this the node would shed forwards onto
|
||||
// nobody (they would sit unclaimed until it re-claimed them).
|
||||
func TestSingleNodeNeverReleases(t *testing.T) {
|
||||
e := newTestEngine("solo", true)
|
||||
e.state.Nodes = []Node{{ID: "solo", Addr: "solo:7500", Alive: true, IsLeader: true}}
|
||||
for i := 0; i < 5; i++ {
|
||||
e.state.AddTopology(&Task{
|
||||
ID: "f" + strconv.Itoa(i), Local: store.Local{Name: "web"},
|
||||
Remote: store.Remote{Name: "frps1"}, Link: store.Link{RemotePort: 100 + i},
|
||||
}, "solo")
|
||||
}
|
||||
b := NewBalancer(e, 0)
|
||||
if pick, _, blocked := b.assess(1000); pick != nil {
|
||||
t.Errorf("a single-node ring must not release; got blocked=%q", blocked)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBelowFloorNeverReleases: two forwards on a node is the floor. A node
|
||||
// holding exactly two at an even split of two must keep both — one release per
|
||||
// node per pass is the granularity, and shedding below the floor would empty it.
|
||||
func TestBelowFloorNeverReleases(t *testing.T) {
|
||||
n1, _, _ := balanceRing(t, 2, 2) // 6 total over 3 nodes → share 2
|
||||
b := NewBalancer(n1, 0)
|
||||
if pick, p, blocked := b.assess(1000); pick != nil {
|
||||
t.Errorf("2 owned against share %d must not release (blocked=%q, mine=%d)",
|
||||
p.share, blocked, p.mine)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNeverReleasesAStoppedForward: a stopped forward's entry is the carrier of
|
||||
// the user's stop decision. Releasing it would hand another node a STOPPED
|
||||
// forward as if it were work to run.
|
||||
func TestNeverReleasesAStoppedForward(t *testing.T) {
|
||||
n1, _, _ := balanceRing(t, 3, 0)
|
||||
// Stop two of n1's three; the only live one must be chosen.
|
||||
for _, te := range n1.state.ForwardsOwnedBy("n1") {
|
||||
if te.Link.RemotePort == 100 || te.Link.RemotePort == 101 {
|
||||
n1.state.UpdateTopologyDisabled("web", "frps1", te.Link.RemotePort, true)
|
||||
}
|
||||
}
|
||||
b := NewBalancer(n1, 0)
|
||||
pick, _, _ := b.assess(1000)
|
||||
if pick == nil {
|
||||
t.Fatal("n1 still owns one live forward and is over its share")
|
||||
}
|
||||
if pick.Link.RemotePort != 102 {
|
||||
t.Errorf("released port %d; a stopped forward (100/101) must never be chosen",
|
||||
pick.Link.RemotePort)
|
||||
}
|
||||
|
||||
// And with every forward stopped there is nothing to release.
|
||||
n1.state.UpdateTopologyDisabled("web", "frps1", 102, true)
|
||||
if pick, _, blocked := b.assess(1000); pick != nil {
|
||||
t.Errorf("all forwards stopped: nothing may be released (blocked=%q)", blocked)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCandidateChoiceIsDeterministic: the same state must pick the same forward,
|
||||
// so the decision is explainable and a re-evaluation does not churn a different
|
||||
// connection each tick.
|
||||
func TestCandidateChoiceIsDeterministic(t *testing.T) {
|
||||
n1, _, _ := balanceRing(t, 4, 0)
|
||||
a := releaseCandidate(n1.State(), "n1")
|
||||
c := releaseCandidate(n1.State(), "n1")
|
||||
if a == nil || c == nil {
|
||||
t.Fatal("expected a candidate")
|
||||
}
|
||||
if a.TaskID != c.TaskID || a.Link.RemotePort != c.Link.RemotePort {
|
||||
t.Errorf("candidate changed between identical evaluations: %s:%d vs %s:%d",
|
||||
a.TaskID, a.Link.RemotePort, c.TaskID, c.Link.RemotePort)
|
||||
}
|
||||
if a.Link.RemotePort != 100 {
|
||||
t.Errorf("expected the lowest port (100), got %d", a.Link.RemotePort)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the runtime signal that makes this safe ----
|
||||
|
||||
// TestRevokeLeavesACarrierAndReleaseRemovesIt is the reason the release path has
|
||||
// to be a distinct task kind.
|
||||
//
|
||||
// A user stop keeps the topology entry as the carrier of the disabled flag, so
|
||||
// the entry is STILL THERE and still attributed to the owner. A rebalance is the
|
||||
// opposite: the entry must go, because (a) the node's load is derived from
|
||||
// owned entries so it cannot fall while the entry exists, and (b) every node's
|
||||
// duplicate guard refuses a creation task for a forward that already has an owner
|
||||
// — so the re-published task would be dropped by everyone and the forward
|
||||
// stranded on the node that gave it up.
|
||||
func TestRevokeLeavesACarrierAndReleaseRemovesIt(t *testing.T) {
|
||||
n1, _, _ := balanceRing(t, 3, 0)
|
||||
|
||||
// A stop marks the entry and keeps it.
|
||||
n1.state.UpdateTopologyDisabled("web", "frps1", 100, true)
|
||||
tk := &Task{Local: store.Local{Name: "web"}, Remote: store.Remote{Name: "frps1"},
|
||||
Link: store.Link{RemotePort: 100}}
|
||||
if owner := n1.state.TopologyOwner(tk); owner != "n1" {
|
||||
t.Fatalf("a stop must keep the entry as the flag's carrier, owner=%q", owner)
|
||||
}
|
||||
if got := len(n1.state.ForwardsOwnedBy("n1")); got != 3 {
|
||||
t.Fatalf("a stop must not change the owner's forward count, got %d", got)
|
||||
}
|
||||
|
||||
// A release removes the entry (leaving the other two alone).
|
||||
if !n1.RemoveTopologyEntry("web", "frps1", 100) {
|
||||
t.Fatal("release must remove the entry")
|
||||
}
|
||||
if owner := n1.state.TopologyOwner(tk); owner != "" {
|
||||
t.Errorf("after a release the forward must have no owner, got %q", owner)
|
||||
}
|
||||
if got := len(n1.state.ForwardsOwnedBy("n1")); got != 2 {
|
||||
t.Errorf("a release must reduce the owner's count, got %d", got)
|
||||
}
|
||||
if got := len(n1.state.TopologyList()); got != 2 {
|
||||
t.Errorf("the other forwards must be untouched, got %d entries", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReleaseTaskExecutesOnlyOnTheOwner: like a revoke, a release is
|
||||
// owner-directed. Executing it elsewhere would be an attempt to move somebody
|
||||
// else's load, which cannot work — the owner's own count is what must fall.
|
||||
func TestReleaseTaskExecutesOnlyOnTheOwner(t *testing.T) {
|
||||
n1, n2, _ := balanceRing(t, 3, 0)
|
||||
|
||||
released := map[string]int{}
|
||||
for _, e := range []*Engine{n1, n2} {
|
||||
h := e.Handler.(*fakeHandler)
|
||||
h.release = func(ctx context.Context, tk *Task) error {
|
||||
released[e.ID]++
|
||||
return nil
|
||||
}
|
||||
}
|
||||
tk := n1.ReleaseTask(store.Local{Name: "web"}, store.Remote{Name: "frps1"},
|
||||
store.Link{RemotePort: 100})
|
||||
if tk == nil {
|
||||
t.Fatal("ReleaseTask returned nil")
|
||||
}
|
||||
|
||||
// Each Engine keeps its OWN state, so a task published on n1 is not visible
|
||||
// to n2 until a token round carries it. Broadcast it the way the ring does.
|
||||
var carry State
|
||||
cb, _ := json.Marshal(n1.state)
|
||||
_ = json.Unmarshal(cb, &carry)
|
||||
n2.AdoptState(carry)
|
||||
n2.state.PendingTasks[tk.ID] = tk
|
||||
|
||||
// n2 is the lightest node and does NOT own the forward: it must leave the
|
||||
// task alone.
|
||||
if err := n2.runCommands(context.Background(), &Token{}); err != nil {
|
||||
t.Fatalf("n2 runCommands: %v", err)
|
||||
}
|
||||
if released["n2"] != 0 {
|
||||
t.Errorf("n2 released %d forward(s); a release is owner-directed only", released["n2"])
|
||||
}
|
||||
if _, still := n2.state.PendingTasks[tk.ID]; !still {
|
||||
t.Error("n2 consumed a release it does not own; it must ride the token to the owner")
|
||||
}
|
||||
|
||||
// n1 owns it: it must release, drop the entry, and re-publish the forward.
|
||||
if err := n1.runCommands(context.Background(), &Token{}); err != nil {
|
||||
t.Fatalf("n1 runCommands: %v", err)
|
||||
}
|
||||
if released["n1"] != 1 {
|
||||
t.Fatalf("the owner released %d times, want 1", released["n1"])
|
||||
}
|
||||
if owner := n1.state.TopologyOwner(&Task{
|
||||
Local: store.Local{Name: "web"}, Remote: store.Remote{Name: "frps1"},
|
||||
Link: store.Link{RemotePort: 100},
|
||||
}); owner != "" {
|
||||
t.Errorf("the released forward still has owner %q", owner)
|
||||
}
|
||||
// And it is back in the ring as an ordinary creation task.
|
||||
var republished *Task
|
||||
for _, p := range n1.state.PendingList() {
|
||||
if !p.Release && !p.Revoke && !p.Restart &&
|
||||
p.Link.RemotePort == 100 && p.Local.Name == "web" {
|
||||
republished = p
|
||||
}
|
||||
}
|
||||
if republished == nil {
|
||||
t.Fatal("the released forward was not re-published as an unclaimed task")
|
||||
}
|
||||
if republished.Link.Disabled {
|
||||
t.Error("the re-published forward must be ENABLED, not marked stopped")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- dry-run / off ----
|
||||
|
||||
// TestDryRunReleasesNothing: dry-run is the default, and its whole value is that
|
||||
// it can run on a live cluster without disturbing anything.
|
||||
func TestDryRunReleasesNothing(t *testing.T) {
|
||||
n1, _, _ := balanceRing(t, 3, 0)
|
||||
b := NewBalancer(n1, 0)
|
||||
b.SetMode(BalanceDryRun)
|
||||
|
||||
msg := b.Step(1000)
|
||||
if !strings.Contains(msg, "would release") {
|
||||
t.Fatalf("dry-run should report what it would do, got %q", msg)
|
||||
}
|
||||
if b.InFlight() {
|
||||
t.Error("dry-run started a release")
|
||||
}
|
||||
for _, tk := range n1.state.PendingList() {
|
||||
if tk.Release {
|
||||
t.Error("dry-run published a release task")
|
||||
}
|
||||
}
|
||||
if got := len(n1.state.TopologyList()); got != 3 {
|
||||
t.Errorf("dry-run changed the topology: %d entries, want 3", got)
|
||||
}
|
||||
// A second identical tick must not repeat the same line (log hygiene).
|
||||
if again := b.Step(1010); again != "" {
|
||||
t.Errorf("dry-run repeated an unchanged decision: %q", again)
|
||||
}
|
||||
}
|
||||
|
||||
// TestModeOffDoesNothing reinforces that OFF means off, not "dry-run".
|
||||
func TestModeOffDoesNothing(t *testing.T) {
|
||||
n1, _, _ := balanceRing(t, 3, 0)
|
||||
b := NewBalancer(n1, 0)
|
||||
b.SetMode(BalanceOff)
|
||||
if msg := b.Step(1000); msg != "" {
|
||||
t.Errorf("mode off reported %q", msg)
|
||||
}
|
||||
if b.InFlight() {
|
||||
t.Error("mode off started a release")
|
||||
}
|
||||
// A typo must not enable releasing.
|
||||
b.SetMode("yes-please")
|
||||
if b.Mode() != BalanceDryRun {
|
||||
t.Errorf("an unknown mode must fall back to dry-run, got %q", b.Mode())
|
||||
}
|
||||
}
|
||||
|
||||
// ---- end-to-end through the ring machinery ----
|
||||
|
||||
// TestOwnerReleaseThenRingClaimsElsewhere is the whole design in one test: the
|
||||
// owner gives the forward up, and the ordinary lowest-load claim then places it
|
||||
// on an idle node — exactly what the self-directed rule depends on.
|
||||
func TestOwnerReleaseThenRingClaimsElsewhere(t *testing.T) {
|
||||
n1, n2, n3 := balanceRing(t, 3, 0)
|
||||
|
||||
// n2 and n3 must look idle to the claim path (they do: Forwards=0).
|
||||
claimed := map[string]int{}
|
||||
for _, e := range []*Engine{n1, n2, n3} {
|
||||
h := e.Handler.(*fakeHandler)
|
||||
h.claim = func(ctx context.Context, tk *Task) error {
|
||||
claimed[e.ID]++
|
||||
return nil
|
||||
}
|
||||
}
|
||||
b := NewBalancer(n1, 0)
|
||||
b.SetMode(BalanceOn)
|
||||
|
||||
// n1 is over its share → it releases.
|
||||
msg := b.Step(1000)
|
||||
if !strings.Contains(msg, "releasing") {
|
||||
t.Fatalf("expected a release, got %q", msg)
|
||||
}
|
||||
if err := n1.runCommands(context.Background(), &Token{}); err != nil {
|
||||
t.Fatalf("n1 runCommands: %v", err)
|
||||
}
|
||||
// Find the task the owner re-published for the forward it gave up.
|
||||
var tk *Task
|
||||
for _, p := range n1.state.PendingList() {
|
||||
if !p.Release && p.Link.RemotePort == 100 && p.Local.Name == "web" {
|
||||
tk = p
|
||||
}
|
||||
}
|
||||
if tk == nil {
|
||||
t.Fatal("the owner did not re-publish the forward it released")
|
||||
}
|
||||
if got := len(n1.state.ForwardsOwnedBy("n1")); got != 2 {
|
||||
t.Fatalf("n1 should now own 2, got %d", got)
|
||||
}
|
||||
|
||||
// The forward is unclaimed; the lowest node claims it. n2 is the lightest
|
||||
// (Forwards=0 and the same mem/net), so it should take it. The task reaches
|
||||
// n2 on the token — each engine holds its own state, so carry it over the way
|
||||
// a real round does.
|
||||
var carried State
|
||||
cb, _ := json.Marshal(n1.state)
|
||||
_ = json.Unmarshal(cb, &carried)
|
||||
n2.AdoptState(carried)
|
||||
n2.state.PendingTasks[tk.ID] = tk
|
||||
|
||||
if err := n2.runCommands(context.Background(), &Token{}); err != nil {
|
||||
t.Fatalf("n2 runCommands: %v", err)
|
||||
}
|
||||
if claimed["n2"] == 0 {
|
||||
t.Fatal("the idle node did not claim the released forward")
|
||||
}
|
||||
// And n1 no longer owns it.
|
||||
for _, te := range n1.state.ForwardsOwnedBy("n1") {
|
||||
if te.Link.RemotePort == 100 {
|
||||
t.Error("n1 still owns the forward it released")
|
||||
}
|
||||
}
|
||||
|
||||
// The balancer sees the release land and stops holding the release state.
|
||||
if msg := b.Step(1100); !strings.Contains(msg, "gave up") {
|
||||
t.Errorf("expected the release to be confirmed, got %q", msg)
|
||||
}
|
||||
if b.InFlight() {
|
||||
t.Error("the release should be complete")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCooldownBoundsTheChurn: a release costs a live connection, so one node may
|
||||
// not shed again until the cooldown elapses — even if it is still over its share.
|
||||
func TestCooldownBoundsTheChurn(t *testing.T) {
|
||||
n1, _, _ := balanceRing(t, 4, 0)
|
||||
b := NewBalancer(n1, 600) // 10 minutes
|
||||
b.SetMode(BalanceOn)
|
||||
|
||||
if msg := b.Step(1000); !strings.Contains(msg, "releasing") {
|
||||
t.Fatalf("first release expected, got %q", msg)
|
||||
}
|
||||
n1.RemoveTopologyEntry("web", "frps1", 100) // land it
|
||||
if msg := b.Step(1010); !strings.Contains(msg, "gave up") {
|
||||
t.Fatalf("expected confirmation, got %q", msg)
|
||||
}
|
||||
// Still over its share (3 of 4 over 3 → share 2), but inside the cooldown.
|
||||
if pick, _, blocked := b.assess(1020); pick == nil || blocked != "cooldown" {
|
||||
t.Errorf("expected the second release to be blocked by cooldown, got blocked=%q", blocked)
|
||||
}
|
||||
if b.InFlight() {
|
||||
t.Error("cooldown must not leave a release in flight")
|
||||
}
|
||||
// Past the cooldown it may act again.
|
||||
if pick, _, blocked := b.assess(1000 + 601); pick == nil || blocked != "" {
|
||||
t.Errorf("past the cooldown the release should be allowed, got blocked=%q", blocked)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReleaseDeadlineClearsAStuckConfirmation: if the release never lands (leader
|
||||
// change, ring join), the node must not wedge forever.
|
||||
func TestReleaseDeadlineClearsAStuckConfirmation(t *testing.T) {
|
||||
n1, _, _ := balanceRing(t, 3, 0)
|
||||
b := NewBalancer(n1, 0)
|
||||
b.SetMode(BalanceOn)
|
||||
_ = b.Step(1000) // release published, entry NOT removed (nothing executed it)
|
||||
if !b.InFlight() {
|
||||
t.Fatal("precondition: a release should be in flight")
|
||||
}
|
||||
if msg := b.Step(1000 + releaseDeadlineSec + 1); msg != "" {
|
||||
t.Errorf("a timed-out release should clear silently, got %q", msg)
|
||||
}
|
||||
if b.InFlight() {
|
||||
t.Error("the release state should have been cleared after the deadline")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- read-only report ----
|
||||
|
||||
// TestReportDoesNotMutate — polling the endpoint must not start, delay or cancel
|
||||
// a release. A read-only surface that perturbs what it reports is worse than none.
|
||||
func TestReportDoesNotMutate(t *testing.T) {
|
||||
n1, _, _ := balanceRing(t, 3, 0)
|
||||
b := NewBalancer(n1, 600)
|
||||
b.SetMode(BalanceOn)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
rep := b.Report()
|
||||
if rep.SelfForwards != 3 {
|
||||
t.Fatalf("report changed the node's forward count: %d", rep.SelfForwards)
|
||||
}
|
||||
if rep.Mode != BalanceOn {
|
||||
t.Fatalf("report changed the mode: %q", rep.Mode)
|
||||
}
|
||||
}
|
||||
if b.InFlight() || b.lastReleaseAt != 0 {
|
||||
t.Error("polling the report started a release")
|
||||
}
|
||||
if rep := b.Report(); rep.WouldRelease == nil {
|
||||
t.Error("the report should still surface the pending decision")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReportExplainsWhyNothingHappens pins the field that answers the question
|
||||
// the log cannot: an over-share node that is holding back must say why.
|
||||
func TestReportExplainsWhyNothingHappens(t *testing.T) {
|
||||
n1, _, _ := balanceRing(t, 4, 0)
|
||||
b := NewBalancer(n1, 600)
|
||||
b.SetMode(BalanceOn)
|
||||
|
||||
rep := b.Report()
|
||||
if !rep.SelfOverShare {
|
||||
t.Fatalf("4 of 4 forwards over 3 nodes should be over the share (share=%d)", rep.Share)
|
||||
}
|
||||
if rep.WouldRelease == nil {
|
||||
t.Fatal("an over-share node must surface the forward it would give up")
|
||||
}
|
||||
if rep.BlockedBy != "" {
|
||||
t.Errorf("nothing should block the first release, got %q", rep.BlockedBy)
|
||||
}
|
||||
|
||||
// Inside the cooldown the same node reports the block. Report() reads the
|
||||
// real clock, so set lastReleaseAt relative to now rather than to a fake one.
|
||||
b.lastReleaseAt = time.Now().Unix() - 10
|
||||
n1.RemoveTopologyEntry("web", "frps1", 100)
|
||||
rep = b.Report()
|
||||
if rep.BlockedBy != "cooldown" {
|
||||
t.Errorf("after a release the node should report a cooldown block, got %q", rep.BlockedBy)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReportOnBalancedNodeIsQuiet: the healthy case must be a real answer, not an
|
||||
// empty one — otherwise "balanced" and "balancing is switched off" look identical.
|
||||
func TestReportOnBalancedNodeIsQuiet(t *testing.T) {
|
||||
n1, _, _ := balanceRing(t, 1, 1)
|
||||
b := NewBalancer(n1, 0)
|
||||
b.SetMode(BalanceOn)
|
||||
|
||||
rep := b.Report()
|
||||
if rep.WouldRelease != nil {
|
||||
t.Errorf("a balanced node reported a release: %+v", rep.WouldRelease)
|
||||
}
|
||||
if rep.SelfOverShare {
|
||||
t.Error("1 of 3 over 3 nodes is not over the share")
|
||||
}
|
||||
if rep.TotalForwards != 3 || rep.AliveNodes != 3 || rep.Share != 1 {
|
||||
t.Errorf("report pool = total %d alive %d share %d, want 3/3/1",
|
||||
rep.TotalForwards, rep.AliveNodes, rep.Share)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- mode switching (the cluster page's switch) ----
|
||||
|
||||
// TestStoredModeWinsOverEnv: flipping the switch must not silently revert on the
|
||||
// next restart. The environment is only the bootstrap default; once an operator
|
||||
// has chosen a mode it is persisted and wins.
|
||||
func TestStoredModeWinsOverEnv(t *testing.T) {
|
||||
n1, _, _ := balanceRing(t, 3, 0)
|
||||
b := NewBalancer(n1, 0)
|
||||
b.SetMode("dry-run") // the env default
|
||||
|
||||
var saved string
|
||||
b.SetModePersistence(
|
||||
func() (string, bool) { return "on", true },
|
||||
func(m string) error { saved = m; return nil },
|
||||
)
|
||||
if b.Mode() != BalanceOn {
|
||||
t.Fatalf("a stored mode must win over the environment default, got %q", b.Mode())
|
||||
}
|
||||
if b.ModeSource() != "stored" {
|
||||
t.Errorf("ModeSource = %q, want stored", b.ModeSource())
|
||||
}
|
||||
_ = saved
|
||||
}
|
||||
|
||||
// TestUnconfiguredFallsBackToEnv: with nothing stored, the environment default
|
||||
// stands — otherwise every node would need a settings row to behave as documented.
|
||||
func TestUnconfiguredFallsBackToEnv(t *testing.T) {
|
||||
n1, _, _ := balanceRing(t, 3, 0)
|
||||
b := NewBalancer(n1, 0)
|
||||
b.SetMode("off")
|
||||
b.SetModePersistence(func() (string, bool) { return "", false }, func(string) error { return nil })
|
||||
if b.Mode() != BalanceOff {
|
||||
t.Errorf("with nothing stored the env default must stand, got %q", b.Mode())
|
||||
}
|
||||
if b.ModeSource() != "stored" {
|
||||
// persist is installed, so the node CAN be controlled
|
||||
t.Logf("ModeSource=%q", b.ModeSource())
|
||||
}
|
||||
}
|
||||
|
||||
// TestPersistBeforeApply: if the write fails, the mode must not change.
|
||||
// Applying first would leave the node running a mode it cannot reproduce after a
|
||||
// restart — the switch would lie.
|
||||
func TestPersistBeforeApply(t *testing.T) {
|
||||
n1, _, _ := balanceRing(t, 3, 0)
|
||||
b := NewBalancer(n1, 0)
|
||||
b.SetMode(BalanceDryRun)
|
||||
b.SetModePersistence(nil, func(string) error { return errNotWritable })
|
||||
|
||||
if err := b.SetModeFromUI(BalanceOn); err == nil {
|
||||
t.Fatal("a failed save must surface an error")
|
||||
}
|
||||
if b.Mode() != BalanceDryRun {
|
||||
t.Errorf("a failed save must leave the mode unchanged, got %q", b.Mode())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetModeFromUIRejectsNonsense: the endpoint takes a string from the wire, so
|
||||
// it must refuse anything it does not understand rather than guessing.
|
||||
func TestSetModeFromUIRejectsNonsense(t *testing.T) {
|
||||
n1, _, _ := balanceRing(t, 3, 0)
|
||||
b := NewBalancer(n1, 0)
|
||||
b.SetMode(BalanceDryRun)
|
||||
if err := b.SetModeFromUI("yes"); err == nil {
|
||||
t.Fatal("an unknown mode must be rejected, not coerced")
|
||||
}
|
||||
if b.Mode() != BalanceDryRun {
|
||||
t.Errorf("a rejected mode must not change anything, got %q", b.Mode())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetModeFromUIPersistsAndApplies: the happy path, both effects.
|
||||
func TestSetModeFromUIPersistsAndApplies(t *testing.T) {
|
||||
n1, _, _ := balanceRing(t, 3, 0)
|
||||
b := NewBalancer(n1, 0)
|
||||
b.SetMode(BalanceDryRun)
|
||||
var saved string
|
||||
b.SetModePersistence(nil, func(m string) error { saved = m; return nil })
|
||||
|
||||
if err := b.SetModeFromUI(BalanceOn); err != nil {
|
||||
t.Fatalf("SetModeFromUI: %v", err)
|
||||
}
|
||||
if saved != BalanceOn {
|
||||
t.Errorf("saved %q, want on", saved)
|
||||
}
|
||||
if b.Mode() != BalanceOn {
|
||||
t.Errorf("mode = %q, want on", b.Mode())
|
||||
}
|
||||
if !b.Report().CanControl {
|
||||
t.Error("a node with persistence installed should report itself controllable")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReportCanControlOffWithoutPersistence: a node that cannot persist a choice
|
||||
// must not advertise the switch (an embedder/test without a store).
|
||||
func TestReportCanControlOffWithoutPersistence(t *testing.T) {
|
||||
n1, _, _ := balanceRing(t, 3, 0)
|
||||
b := NewBalancer(n1, 0)
|
||||
if b.Report().CanControl {
|
||||
t.Error("a balancer with no persistence must not report itself controllable")
|
||||
}
|
||||
}
|
||||
@ -68,6 +68,19 @@ type Task struct {
|
||||
// duplicate-claim guard would discard it. The owner applies this one
|
||||
// unconditionally: re-mark the entry enabled and (re)spawn the worker.
|
||||
Restart bool `json:"restart,omitempty"`
|
||||
// Release marks a REBALANCING task: the OWNER of the forward gives it up so
|
||||
// the ring can place it elsewhere. Only the owning node may act on it — it is
|
||||
// that node's own load which has to fall, and only it can make that true (its
|
||||
// topology entry is local bookkeeping no peer may write).
|
||||
//
|
||||
// It is deliberately NOT the same thing as a user stop (Revoke). A stop keeps
|
||||
// the topology entry as the carrier of the disabled flag and flags the link
|
||||
// stopped; a release REMOVES the entry and leaves the link enabled, because
|
||||
// the forward is about to run somewhere else. Marking it stopped instead would
|
||||
// make the re-published creation task look like a duplicate of an existing
|
||||
// forward, and every node would refuse to claim it — stranding the forward on
|
||||
// the node that just gave it up.
|
||||
Release bool `json:"release,omitempty"`
|
||||
// RemoveNode: node ID to remove from the ring; the node self-removes when
|
||||
// the command reaches it via the token.
|
||||
RemoveNode string `json:"removeNode,omitempty"`
|
||||
|
||||
505
internal/cluster/ring_balance.go
Normal file
505
internal/cluster/ring_balance.go
Normal file
@ -0,0 +1,505 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"webui4frpc/internal/store"
|
||||
)
|
||||
|
||||
// The rebalance rule, deliberately simple and self-limiting:
|
||||
//
|
||||
// release one forward when Forwards >= minForwardsToShare AND Forwards > share
|
||||
//
|
||||
// where share = ceil(totalForwards / aliveNodes) — the even split.
|
||||
//
|
||||
// Every node runs this check about its OWN forwards. That is the whole point:
|
||||
// a node's load only actually falls when its own topology entry goes away, so
|
||||
// only the owner can produce the state change that makes the ring hand the
|
||||
// forward to somebody else. A coordinator on another machine can see the
|
||||
// imbalance but cannot make it true; it would have to reach in and mutate the
|
||||
// owner's bookkeeping, which is exactly the sort of cross-node write this ring
|
||||
// avoids everywhere else (revoke and restart are owner-directed for the same
|
||||
// reason).
|
||||
//
|
||||
// The rule is self-limiting in both directions, so it needs no upper bound on
|
||||
// how many forwards a node may shed:
|
||||
//
|
||||
// - It stops at the average. 3 forwards over 3 nodes: the owner holds 2 > 1,
|
||||
// releases one, now holds 1 which is not > 1, and stops. Without the "> share"
|
||||
// bound an owner would shed EVERY forward it has.
|
||||
// - It cannot empty a node. A node holding fewer than minForwardsToShare never
|
||||
// releases, so there is always something left to serve.
|
||||
//
|
||||
// A release costs a live connection: stopping the worker drops whatever was
|
||||
// flowing through the forward, and the new owner re-dials the local service.
|
||||
// Because the rule above already requires a real imbalance, pacing is handled by
|
||||
// a per-node cooldown (cooldownSec) rather than a "how long has this lasted"
|
||||
// window — there is no transient to smooth over, only a churn rate to bound.
|
||||
const (
|
||||
// minForwardsToShare: never release below this many owned forwards.
|
||||
minForwardsToShare = 2
|
||||
// balanceCooldownSec: minimum gap between two releases by one node. One
|
||||
// release per 10 minutes per node is a trickle; it is what stops a cluster
|
||||
// whose loads oscillate around the split point from restarting workers in a
|
||||
// loop.
|
||||
balanceCooldownSec = 600
|
||||
// releaseDeadlineSec: how long to wait for a published release to be
|
||||
// executed by its owner (us) before giving up on confirming it. A token
|
||||
// round is ~2s, so this is generous; it exists so a release lost to a
|
||||
// leader change or a joined ring cannot wedge the node forever.
|
||||
releaseDeadlineSec = 120
|
||||
)
|
||||
|
||||
// Balance modes.
|
||||
const (
|
||||
BalanceOff = "off"
|
||||
BalanceDryRun = "dry-run"
|
||||
BalanceOn = "on"
|
||||
)
|
||||
|
||||
// pool is the replicated view the rule works from.
|
||||
type pool struct {
|
||||
alive int // nodes that can take a forward
|
||||
mine int // forwards THIS node owns (live count, not the replicated one)
|
||||
total int // forwards across the ring
|
||||
share int // ceil(total/alive) — the even split
|
||||
}
|
||||
|
||||
// releaseState is one release this node has published and is waiting to see
|
||||
// leave its own topology.
|
||||
type releaseState struct {
|
||||
local, remote string
|
||||
port int
|
||||
startedAt int64
|
||||
}
|
||||
|
||||
// Balancer lets each node shed its own share of the cluster's forwards.
|
||||
//
|
||||
// It publishes only ONE kind of thing: a release of a forward it owns. There is
|
||||
// no targeted hand-off and no coordinator, because "drop it and let the ring's
|
||||
// normal lowest-load claim pick it up" is already the ring's only mechanism for
|
||||
// moving a forward — and it is the mechanism that, unlike a coordinated move,
|
||||
// cannot be undone by the node that gave the forward up (the claim path runs
|
||||
// after the node's load has already fallen).
|
||||
type Balancer struct {
|
||||
engine *Engine
|
||||
mode string
|
||||
|
||||
// cooldownSec is the minimum gap between two releases by this node.
|
||||
cooldownSec int64
|
||||
// releasing is the forward this node gave up and is waiting to see leave.
|
||||
// While it is set, no second release is started: one at a time keeps the
|
||||
// connection churn bounded and the log readable.
|
||||
releasing *releaseState
|
||||
// lastReleaseAt is when the previous release was published (0 = never).
|
||||
lastReleaseAt int64
|
||||
// reported remembers the last dry-run candidate so the observe-only mode
|
||||
// logs a change of mind rather than the same line every tick.
|
||||
reported string
|
||||
// persist saves a mode change so it survives a restart (nil = env-only).
|
||||
persist func(string) error
|
||||
// savedMode is the last mode this node stored, so the report can say whether
|
||||
// the active mode came from the environment or from an operator's choice.
|
||||
savedMode string
|
||||
}
|
||||
|
||||
// NewBalancer builds a balancer. It starts in observe-only mode: releasing a
|
||||
// forward drops a live connection, so acting on the ring is opt-in.
|
||||
func NewBalancer(e *Engine, cooldownSec int64) *Balancer {
|
||||
b := &Balancer{engine: e, mode: BalanceDryRun, cooldownSec: cooldownSec}
|
||||
if b.cooldownSec <= 0 {
|
||||
b.cooldownSec = balanceCooldownSec
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// SetMode selects off / dry-run / on. An unrecognised value is treated as
|
||||
// dry-run — a typo must not be able to turn releasing on.
|
||||
//
|
||||
// Only a change is logged, and the reason is recorded, because SetMode is now
|
||||
// called both at boot (from the environment) and at runtime (from the cluster
|
||||
// page): a one-line "why did this just start moving my forwards" answer is the
|
||||
// difference between a switch an operator dares to use and one they don't.
|
||||
func (b *Balancer) SetMode(m string) {
|
||||
if m != BalanceOff && m != BalanceDryRun && m != BalanceOn {
|
||||
if m != "" {
|
||||
log.Printf("ring[%s] unknown balance mode %q; using %s", b.engine.ID, m, BalanceDryRun)
|
||||
}
|
||||
m = BalanceDryRun
|
||||
}
|
||||
if b.mode == m {
|
||||
return
|
||||
}
|
||||
b.mode = m
|
||||
b.savedMode = m
|
||||
log.Printf("ring[%s] load balancing set to %s", b.engine.ID, strings.ToUpper(m))
|
||||
}
|
||||
|
||||
// Mode returns the active mode.
|
||||
func (b *Balancer) Mode() string { return b.mode }
|
||||
|
||||
// ModeSource reports where the active mode came from, for the report.
|
||||
//
|
||||
// "stored" means an operator chose it on the cluster page and it will survive a
|
||||
// restart; "env" means nothing was ever chosen here and the environment default
|
||||
// (or the built-in dry-run) applies. It is NOT "can this node save a choice" —
|
||||
// that is CanControl, and conflating the two made every node with a store claim
|
||||
// an operator had set it.
|
||||
func (b *Balancer) ModeSource() string {
|
||||
if b.savedMode != "" {
|
||||
return "stored"
|
||||
}
|
||||
return "env"
|
||||
}
|
||||
|
||||
// SetModePersistence installs the callback that saves a mode change so it
|
||||
// survives a restart, and applies any previously saved value.
|
||||
//
|
||||
// The environment is the BOOTSTRAP default; once an operator flips the switch on
|
||||
// the cluster page that stored value wins, otherwise the page would appear to
|
||||
// work and then silently revert on the next restart.
|
||||
func (b *Balancer) SetModePersistence(load func() (string, bool), save func(string) error) {
|
||||
b.persist = save
|
||||
if load == nil {
|
||||
return
|
||||
}
|
||||
if m, ok := load(); ok && m != "" {
|
||||
switch m {
|
||||
case BalanceOff, BalanceDryRun, BalanceOn:
|
||||
if b.mode != m {
|
||||
log.Printf("ring[%s] load balancing mode %s loaded from settings", b.engine.ID, m)
|
||||
}
|
||||
b.mode = m
|
||||
b.savedMode = m
|
||||
default:
|
||||
log.Printf("ring[%s] ignoring unknown stored balance mode %q", b.engine.ID, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetModeFromUI changes the mode at runtime and persists it.
|
||||
//
|
||||
// The order matters: persist FIRST, then apply. If the write fails nothing
|
||||
// changes, so the switch cannot report success while behaving differently after
|
||||
// the next restart.
|
||||
func (b *Balancer) SetModeFromUI(m string) error {
|
||||
if m != BalanceOff && m != BalanceDryRun && m != BalanceOn {
|
||||
return fmt.Errorf("未知模式 %q", m)
|
||||
}
|
||||
if b.persist != nil {
|
||||
if err := b.persist(m); err != nil {
|
||||
return fmt.Errorf("保存负载均衡模式失败: %w", err)
|
||||
}
|
||||
}
|
||||
b.SetMode(m)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetCooldown overrides the per-node release cooldown.
|
||||
func (b *Balancer) SetCooldown(sec int64) {
|
||||
if sec > 0 {
|
||||
b.cooldownSec = sec
|
||||
}
|
||||
}
|
||||
|
||||
// InFlight reports whether a release is awaiting confirmation.
|
||||
func (b *Balancer) InFlight() bool { return b.releasing != nil }
|
||||
|
||||
// pool computes the ring-wide view this node derives for itself.
|
||||
//
|
||||
// Our own count is taken LIVE from the topology rather than from the replicated
|
||||
// Nodes[].Load, because Nodes[].Load is only refreshed when a token arrives and
|
||||
// the decision must see the effect of the release immediately — otherwise a node
|
||||
// would keep believing it is over its share and shed another forward each round.
|
||||
func (b *Balancer) pool() pool {
|
||||
st := &b.engine.state
|
||||
p := pool{mine: len(st.ForwardsOwnedBy(b.engine.ID))}
|
||||
for i := range st.Nodes {
|
||||
n := &st.Nodes[i]
|
||||
if !n.Alive {
|
||||
continue
|
||||
}
|
||||
p.alive++
|
||||
if n.ID == b.engine.ID {
|
||||
p.total += p.mine
|
||||
continue
|
||||
}
|
||||
p.total += n.Load.Forwards
|
||||
}
|
||||
if p.alive > 0 {
|
||||
p.share = (p.total + p.alive - 1) / p.alive
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// assess answers "should this node give one forward up, and which one?" together
|
||||
// with the facts behind the answer, so the read-only report can explain a node
|
||||
// that is over its share but not acting. It never mutates anything.
|
||||
//
|
||||
// blocked is a short reason: "single-node", "below-min", "balanced",
|
||||
// "nothing-active", "cooldown" or "in-flight".
|
||||
func (b *Balancer) assess(now int64) (pick *TopoEntry, p pool, blocked string) {
|
||||
p = b.pool()
|
||||
if !b.engine.IsMember() {
|
||||
return nil, p, "single-node"
|
||||
}
|
||||
if p.mine < minForwardsToShare {
|
||||
return nil, p, "below-min"
|
||||
}
|
||||
if p.mine <= p.share {
|
||||
return nil, p, "balanced"
|
||||
}
|
||||
pick = releaseCandidate(&b.engine.state, b.engine.ID)
|
||||
if pick == nil {
|
||||
return nil, p, "nothing-active"
|
||||
}
|
||||
// The guards below do not change the verdict ("this node is over its
|
||||
// share"), only whether now is the moment; the report shows both.
|
||||
if b.releasing != nil {
|
||||
return pick, p, "in-flight"
|
||||
}
|
||||
if b.lastReleaseAt > 0 && now-b.lastReleaseAt < b.cooldownSec {
|
||||
return pick, p, "cooldown"
|
||||
}
|
||||
return pick, p, ""
|
||||
}
|
||||
|
||||
// releaseCandidate picks which of this node's forwards to give up.
|
||||
//
|
||||
// Deterministic (lowest remote port, then task id) so the choice is explainable
|
||||
// and repeatable: a node that re-evaluates the same state picks the same
|
||||
// forward. Stopped forwards are skipped — their entry is the carrier of a user's
|
||||
// stop decision, and releasing one would hand a STOPPED forward to another node
|
||||
// as if it were work to run.
|
||||
func releaseCandidate(s *State, owner string) *TopoEntry {
|
||||
var mine []*TopoEntry
|
||||
for _, te := range s.TopologyList() {
|
||||
if te.OwnerID != owner || !te.Active || te.Link.Disabled {
|
||||
continue
|
||||
}
|
||||
mine = append(mine, te)
|
||||
}
|
||||
if len(mine) == 0 {
|
||||
return nil
|
||||
}
|
||||
sort.Slice(mine, func(i, j int) bool {
|
||||
if mine[i].Link.RemotePort != mine[j].Link.RemotePort {
|
||||
return mine[i].Link.RemotePort < mine[j].Link.RemotePort
|
||||
}
|
||||
return mine[i].TaskID < mine[j].TaskID
|
||||
})
|
||||
return mine[0]
|
||||
}
|
||||
|
||||
// landed reports whether the forward this node released has actually left it.
|
||||
//
|
||||
// The test is "no longer owned by me", NOT "no entry exists": the ring's normal
|
||||
// claim picks the forward up within a round or two, so an existence test would
|
||||
// see the new owner's entry and wait forever for a state that has already
|
||||
// happened.
|
||||
func (b *Balancer) landed() bool {
|
||||
m := b.releasing
|
||||
if m == nil {
|
||||
return true
|
||||
}
|
||||
owner := b.engine.state.TopologyOwner(&Task{
|
||||
Local: store.Local{Name: m.local},
|
||||
Remote: store.Remote{Name: m.remote},
|
||||
Link: store.Link{RemotePort: m.port},
|
||||
})
|
||||
return owner != b.engine.ID
|
||||
}
|
||||
|
||||
// Step advances the balancer by one cycle and returns a human-readable
|
||||
// description when it did (or, in dry-run, would have done) something.
|
||||
func (b *Balancer) Step(now int64) string {
|
||||
if b.releasing != nil {
|
||||
if b.landed() {
|
||||
done := b.releasing
|
||||
b.releasing = nil
|
||||
return "gave up " + done.local + ":" + strconv.Itoa(done.port) + " for rebalancing"
|
||||
}
|
||||
if now-b.releasing.startedAt > releaseDeadlineSec {
|
||||
log.Printf("ring[%s] release of %s:%d not confirmed within %ds; clearing (forward is still ours)",
|
||||
b.engine.ID, b.releasing.local, b.releasing.port, releaseDeadlineSec)
|
||||
b.releasing = nil
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
pick, p, blocked := b.assess(now)
|
||||
if pick == nil {
|
||||
b.reported = ""
|
||||
return ""
|
||||
}
|
||||
desc := pick.Local.Name + ":" + strconv.Itoa(pick.Link.RemotePort) +
|
||||
" (" + strconv.Itoa(p.mine) + " owned > " + strconv.Itoa(p.share) + " share of " +
|
||||
strconv.Itoa(p.total) + " over " + strconv.Itoa(p.alive) + " nodes)"
|
||||
|
||||
switch b.mode {
|
||||
case BalanceOff:
|
||||
return ""
|
||||
case BalanceDryRun:
|
||||
// Log a change of mind, not the same line every tick.
|
||||
if b.reported == desc {
|
||||
return ""
|
||||
}
|
||||
b.reported = desc
|
||||
if blocked != "" {
|
||||
return "would release " + desc + " [blocked: " + blocked + "]"
|
||||
}
|
||||
return "would release " + desc
|
||||
}
|
||||
|
||||
// Mode is ON. Blocked reasons still stop us.
|
||||
if blocked != "" {
|
||||
return ""
|
||||
}
|
||||
t := b.engine.ReleaseTask(pick.Local, pick.Remote, pick.Link)
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
b.releasing = &releaseState{
|
||||
local: pick.Local.Name, remote: pick.Remote.Name,
|
||||
port: pick.Link.RemotePort, startedAt: now,
|
||||
}
|
||||
b.lastReleaseAt = now
|
||||
b.reported = ""
|
||||
return "releasing " + desc
|
||||
}
|
||||
|
||||
// Watch drives Step on a ticker until ctx is cancelled.
|
||||
//
|
||||
// It runs on EVERY node, not just the leader. Balancing here is not a cluster
|
||||
// decision being delegated to one node — it is each node applying the same
|
||||
// replicated rule to its own forwards. Two nodes never contend for the same
|
||||
// forward because a node can only release what it owns.
|
||||
//
|
||||
// The first check happens after one interval rather than immediately: a node
|
||||
// that has just booted adopts the ring's topology and its own load in the first
|
||||
// token round, and deciding before that would act on a half-known pool.
|
||||
func (b *Balancer) Watch(ctx context.Context, interval time.Duration) {
|
||||
tick := time.NewTicker(interval)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
}
|
||||
if msg := b.Step(time.Now().Unix()); msg != "" {
|
||||
log.Printf("ring[%s] balance: %s", b.engine.ID, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- read-only report for the API/UI ----
|
||||
|
||||
// BalanceReport is what this node thinks about balancing right now.
|
||||
//
|
||||
// It is per-node by nature: every member has its own answer, and the answer that
|
||||
// matters is the one on the node doing (or refusing to do) the releasing. The
|
||||
// peer list is included because "am I over my share?" is only meaningful next to
|
||||
// what everyone else holds.
|
||||
type BalanceReport struct {
|
||||
Mode string `json:"mode"` // off | dry-run | on
|
||||
ModeSource string `json:"modeSource"` // env | stored
|
||||
Enabled bool `json:"enabled"` // mode != off
|
||||
DryRun bool `json:"dryRun"` // mode != on
|
||||
// CanControl: this node offers the mode switch. False for a read-only
|
||||
// principal, so the server does not advertise a control the UI gates anyway.
|
||||
CanControl bool `json:"canControl"`
|
||||
IsMember bool `json:"isMember"`
|
||||
Self string `json:"self"`
|
||||
|
||||
SelfForwards int `json:"selfForwards"`
|
||||
Share int `json:"share"`
|
||||
TotalForwards int `json:"totalForwards"`
|
||||
AliveNodes int `json:"aliveNodes"`
|
||||
SelfOverShare bool `json:"selfOverShare"`
|
||||
|
||||
// WouldRelease is what this node would give up (present whenever it is over
|
||||
// its share, including while blocked — see BlockedBy).
|
||||
WouldRelease *BalanceReleasePlan `json:"wouldRelease,omitempty"`
|
||||
// Releasing is the release this node has published and is awaiting.
|
||||
Releasing *BalanceReleasePlan `json:"releasing,omitempty"`
|
||||
// BlockedBy explains why an over-share node is not acting yet.
|
||||
BlockedBy string `json:"blockedBy,omitempty"`
|
||||
|
||||
CooldownSec int64 `json:"cooldownSec"`
|
||||
LastReleaseAt int64 `json:"lastReleaseAt,omitempty"`
|
||||
|
||||
// Peers: every member's replicated load, for context.
|
||||
Nodes []BalanceNodeReport `json:"nodes"`
|
||||
}
|
||||
|
||||
type BalanceNodeReport struct {
|
||||
ID string `json:"id"`
|
||||
Alive bool `json:"alive"`
|
||||
IsLeader bool `json:"isLeader"`
|
||||
Forwards int `json:"forwards"`
|
||||
MemPct float64 `json:"memPct"`
|
||||
NetPct float64 `json:"netPct"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
type BalanceReleasePlan struct {
|
||||
Local string `json:"local"`
|
||||
Remote string `json:"remote"`
|
||||
RemotePort int `json:"remotePort"`
|
||||
Owner string `json:"owner"`
|
||||
}
|
||||
|
||||
// Report renders the current view. It never mutates anything — in particular it
|
||||
// does NOT touch lastReleaseAt or the release state, so an operator polling this
|
||||
// cannot start, delay or cancel a release. (A read-only endpoint that perturbs
|
||||
// the behaviour it reports is worse than no endpoint.)
|
||||
func (b *Balancer) Report() BalanceReport {
|
||||
now := time.Now().Unix()
|
||||
rep := BalanceReport{
|
||||
Mode: b.mode,
|
||||
ModeSource: b.ModeSource(),
|
||||
Enabled: b.mode != BalanceOff,
|
||||
DryRun: b.mode != BalanceOn,
|
||||
CanControl: b.persist != nil,
|
||||
IsMember: b.engine.IsMember(),
|
||||
Self: b.engine.ID,
|
||||
CooldownSec: b.cooldownSec,
|
||||
}
|
||||
p := b.pool()
|
||||
rep.SelfForwards, rep.Share = p.mine, p.share
|
||||
rep.TotalForwards, rep.AliveNodes = p.total, p.alive
|
||||
rep.SelfOverShare = p.mine > p.share
|
||||
|
||||
if pick, _, blocked := b.assess(now); pick != nil {
|
||||
rep.WouldRelease = &BalanceReleasePlan{
|
||||
Local: pick.Local.Name, Remote: pick.Remote.Name,
|
||||
RemotePort: pick.Link.RemotePort, Owner: pick.OwnerID,
|
||||
}
|
||||
rep.BlockedBy = blocked
|
||||
}
|
||||
if m := b.releasing; m != nil {
|
||||
rep.Releasing = &BalanceReleasePlan{
|
||||
Local: m.local, Remote: m.remote, RemotePort: m.port, Owner: b.engine.ID,
|
||||
}
|
||||
rep.BlockedBy = "in-flight"
|
||||
}
|
||||
if b.lastReleaseAt > 0 {
|
||||
rep.LastReleaseAt = b.lastReleaseAt
|
||||
}
|
||||
for i := range b.engine.state.Nodes {
|
||||
n := &b.engine.state.Nodes[i]
|
||||
rep.Nodes = append(rep.Nodes, BalanceNodeReport{
|
||||
ID: n.ID, Alive: n.Alive, IsLeader: n.IsLeader,
|
||||
Forwards: n.Load.Forwards, MemPct: n.Load.MemPct, NetPct: n.Load.NetPct,
|
||||
Score: n.Load.Score(),
|
||||
})
|
||||
}
|
||||
return rep
|
||||
}
|
||||
@ -24,6 +24,12 @@ type Handler interface {
|
||||
// creation path (idempotency guard) and the duplicate-claim guard refuse to
|
||||
// re-create an existing forward.
|
||||
Restart(ctx context.Context, tk *Task) error
|
||||
// Release stops this node's worker for a forward it is giving up for
|
||||
// rebalancing, WITHOUT marking the link stopped. The forward is about to run
|
||||
// on another node, so a stopped flag would be wrong: every node's duplicate
|
||||
// guard reads it and would then refuse to claim the re-published task,
|
||||
// stranding the forward on the node that just gave it up.
|
||||
Release(ctx context.Context, tk *Task) error
|
||||
RuntimeLoad() Load
|
||||
}
|
||||
|
||||
@ -406,6 +412,25 @@ func (e *Engine) runCommands(ctx context.Context, tk *Token) error {
|
||||
}
|
||||
continue // owned elsewhere — ride to the owner
|
||||
}
|
||||
if t.Release {
|
||||
// Owner-directed, like a revoke: only the node holding the forward
|
||||
// may give it up. A release executed anywhere else would be an
|
||||
// attempt to move somebody else's load, which cannot work — the
|
||||
// owner's own count is what has to fall, and its topology entry is
|
||||
// local bookkeeping no peer may write.
|
||||
//
|
||||
// A release whose forward is already gone is an idempotent no-op
|
||||
// consumed by the lowest node, so it cannot ride forever if its
|
||||
// owner departed first.
|
||||
if owner := e.state.TopologyOwner(t); owner == e.ID {
|
||||
target = t
|
||||
break
|
||||
} else if owner == "" && selfIsLowest {
|
||||
target = t
|
||||
break
|
||||
}
|
||||
continue // owned elsewhere — ride to the owner
|
||||
}
|
||||
if t.Restart {
|
||||
// Owner-directed, exactly like a revoke: only the node holding the
|
||||
// forward may act. Selecting it anywhere else would let a non-owner
|
||||
@ -538,7 +563,11 @@ func (e *Engine) runCommands(ctx context.Context, tk *Token) error {
|
||||
// exists precisely to re-activate a forward whose entry is still present
|
||||
// (a stop keeps the entry as the flag's carrier), so applying the guard
|
||||
// would swallow every re-enable and leave the forward stopped forever.
|
||||
if !claimed.Restart {
|
||||
// A RELEASE is the second deliberate exception, for the same reason as a
|
||||
// restart: it is addressed to the node that ALREADY owns the forward, so
|
||||
// the entry is still there by definition. Applying the guard would drop
|
||||
// every release and a node could never shed anything.
|
||||
if !claimed.Restart && !claimed.Release {
|
||||
if owner := e.state.TopologyOwner(claimed); owner != "" {
|
||||
log.Printf("ring[%s] drop duplicate task %s: %s→%s:%d already owned by %s",
|
||||
e.ID, claimed.ID, claimed.Local.Name, claimed.Remote.Name,
|
||||
@ -546,6 +575,48 @@ func (e *Engine) runCommands(ctx context.Context, tk *Token) error {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if claimed.Release {
|
||||
// Only reached when this node owns the forward (or it is already gone
|
||||
// and we are the fallback consumer — see the selection loop).
|
||||
owner := e.state.TopologyOwner(claimed)
|
||||
if owner != e.ID {
|
||||
// The forward vanished before we got here; nothing to give up.
|
||||
continue
|
||||
}
|
||||
// Stop our worker. Deliberately NOT Handler.Revoke: that marks the
|
||||
// link stopped, and the forward is about to run elsewhere — a
|
||||
// "stopped" flag would make every node treat the re-published task as
|
||||
// a duplicate and refuse to claim it.
|
||||
if e.Handler != nil {
|
||||
if err := e.Handler.Release(ctx, claimed); err != nil {
|
||||
log.Printf("ring[%s] release %s: %v", e.ID, claimed.ID, err)
|
||||
}
|
||||
}
|
||||
// Drop the entry so the forward has no owner. This is what makes the
|
||||
// load actually fall (ForwardsOwnedBy counts entries) and what lets a
|
||||
// fresh creation task pass the duplicate guard above.
|
||||
e.RemoveTopologyEntry(claimed.Local.Name, claimed.Remote.Name, claimed.Link.RemotePort)
|
||||
// Hand it back to the ring as an ordinary unclaimed forward. It is
|
||||
// published by THIS node so it rides the very token we are holding —
|
||||
// no coordinator, no addressed hand-off.
|
||||
link := claimed.Link
|
||||
link.Disabled = false
|
||||
if t := e.SubmitTask(claimed.Local, claimed.Remote, link); t != nil {
|
||||
log.Printf("ring[%s] released %s: %s→%s:%d back to the ring as %s",
|
||||
e.ID, claimed.ID, claimed.Local.Name, claimed.Remote.Name,
|
||||
claimed.Link.RemotePort, t.ID)
|
||||
} else {
|
||||
log.Printf("ring[%s] released %s but the re-publish was deduped",
|
||||
e.ID, claimed.ID)
|
||||
}
|
||||
if e.Log != nil {
|
||||
_, _ = e.Log.Append(e.ID, LogForwardStop, map[string]any{
|
||||
"taskId": claimed.ID, "local": claimed.Local.Name,
|
||||
"remote": claimed.Remote.Name, "released": true,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
if claimed.Restart {
|
||||
// Only reached when this node owns the forward (or it is already gone
|
||||
// and we are the fallback consumer — see the selection loop).
|
||||
@ -960,6 +1031,26 @@ func (e *Engine) SubmitRestart(local store.Local, remote store.Remote, link stor
|
||||
return t
|
||||
}
|
||||
|
||||
// ReleaseTask publishes a rebalancing task asking the OWNER of this forward to
|
||||
// give it up. The task rides the token like any other command; it is not
|
||||
// addressed to a destination, because the ring's normal lowest-load claim
|
||||
// decides where the forward goes next.
|
||||
//
|
||||
// It goes through AddPending rather than writing PendingTasks directly. That is
|
||||
// not cosmetic: OnToken rebuilds local-only pending commands from the ids it
|
||||
// never published, and only the Add* helpers record them — a task injected by
|
||||
// hand was silently dropped at the next state adoption, so it never reached the
|
||||
// ring at all.
|
||||
func (e *Engine) ReleaseTask(local store.Local, remote store.Remote, link store.Link) *Task {
|
||||
link.Disabled = false
|
||||
t := e.state.AddPending(local, remote, link)
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
t.Release = true
|
||||
return t
|
||||
}
|
||||
|
||||
// HasActiveTask reports whether a forward with this key is genuinely in flight
|
||||
// or running: a non-revocation pending task, or a topology entry that is not
|
||||
// marked disabled.
|
||||
|
||||
@ -12,6 +12,14 @@ type fakeHandler struct {
|
||||
claim func(ctx context.Context, tk *Task) error
|
||||
revoke func(ctx context.Context, tk *Task) error
|
||||
restart func(ctx context.Context, tk *Task) error
|
||||
release func(ctx context.Context, tk *Task) error
|
||||
}
|
||||
|
||||
func (h *fakeHandler) Release(ctx context.Context, tk *Task) error {
|
||||
if h.release != nil {
|
||||
return h.release(ctx, tk)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *fakeHandler) Revoke(ctx context.Context, tk *Task) error {
|
||||
|
||||
@ -21,6 +21,12 @@ type AppHandler struct {
|
||||
// stopped flag + bring the worker back). Required because a stop keeps the
|
||||
// topology entry, so the normal claim path will not re-create it.
|
||||
RestartFn func(ctx context.Context, tk *Task) error
|
||||
// ReleaseFn stops this node's worker for a forward being handed back to the
|
||||
// ring, leaving the link ENABLED. When nil, Release falls back to RevokeFn —
|
||||
// which also stops the worker, but additionally marks the link stopped; that
|
||||
// is the pre-existing stop semantics and is wrong for a rebalance, so the app
|
||||
// should provide this.
|
||||
ReleaseFn func(ctx context.Context, tk *Task) error
|
||||
}
|
||||
|
||||
// RuntimeLoad implements Handler.
|
||||
@ -48,6 +54,22 @@ func (a *AppHandler) Revoke(ctx context.Context, tk *Task) error {
|
||||
return a.RevokeFn(ctx, tk)
|
||||
}
|
||||
|
||||
// Release implements Handler.
|
||||
func (a *AppHandler) Release(ctx context.Context, tk *Task) error {
|
||||
if a.ReleaseFn != nil {
|
||||
return a.ReleaseFn(ctx, tk)
|
||||
}
|
||||
// Fall back to the stop path so a host that has not provided ReleaseFn still
|
||||
// stops the worker (the forward keeps running otherwise). The link ends up
|
||||
// flagged stopped, which the claim path may then refuse — the host is warned
|
||||
// by the ring's own "re-publish was deduped" log line rather than silently
|
||||
// leaving two workers up.
|
||||
if a.RevokeFn != nil {
|
||||
return a.RevokeFn(ctx, tk)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Restart implements Handler.
|
||||
func (a *AppHandler) Restart(ctx context.Context, tk *Task) error {
|
||||
if a.RestartFn == nil {
|
||||
|
||||
@ -48,6 +48,8 @@ type Handler struct {
|
||||
Cluster *cluster.Registry
|
||||
// Ring is the token-ring engine (M6). Nil disables ring routes.
|
||||
Ring *cluster.Engine
|
||||
// Balancer is optional: when nil the balance endpoint reports 404.
|
||||
Balancer *cluster.Balancer
|
||||
// SelfAddr is this node's reachable listen address (from -addr), used for
|
||||
// cluster discovery so peers can reach back for binary exchange.
|
||||
SelfAddr string
|
||||
@ -114,6 +116,8 @@ func NewServeMux(h *Handler) (http.Handler, error) {
|
||||
mux.HandleFunc(apiPrefix+"/cluster/nodes", h.auth("read")(h.handleClusterNodes))
|
||||
mux.HandleFunc(apiPrefix+"/cluster/cache", h.auth("read")(h.handleClusterCache)) // GET read; POST write-checked inside
|
||||
mux.HandleFunc(apiPrefix+"/cluster/ring", h.auth("read")(h.handleClusterRing))
|
||||
mux.HandleFunc(apiPrefix+"/cluster/balance", h.auth("read")(h.handleClusterBalance))
|
||||
mux.HandleFunc(apiPrefix+"/cluster/balance/mode", h.auth("write")(h.handleClusterBalanceMode))
|
||||
mux.HandleFunc(apiPrefix+"/node/logs", h.auth("read")(h.handleNodeLogs))
|
||||
mux.HandleFunc(apiPrefix+"/cluster/logs/export", h.auth("read")(h.handleClusterLogsExport))
|
||||
|
||||
@ -634,6 +638,64 @@ func (h *Handler) handleClusterRing(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, h.Ring.Snapshot())
|
||||
}
|
||||
|
||||
// handleClusterBalance reports the load balancer's read-only view: per-node
|
||||
// scores, whether anything is over the threshold, and the migration it would
|
||||
// perform. Publishing nothing is the point — an operator asking "is this cluster
|
||||
// balanced?" currently gets silence from the log in the healthy case, which is
|
||||
// indistinguishable from balancing being switched off.
|
||||
func (h *Handler) handleClusterBalance(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
if h.Ring == nil {
|
||||
http.Error(w, "ring engine not enabled", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if h.Balancer == nil {
|
||||
http.Error(w, "load balancer not enabled", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
rep := h.Balancer.Report()
|
||||
// CanControl is decided by the caller's own level rather than by the
|
||||
// presence of a stored value, so a read-only principal is never shown a
|
||||
// switch that would 403.
|
||||
rep.CanControl = hasLevel(r, "write")
|
||||
writeJSON(w, http.StatusOK, rep)
|
||||
}
|
||||
|
||||
// handleClusterBalanceMode switches load balancing on/off at runtime.
|
||||
//
|
||||
// It is per-node on purpose. Every member decides about its OWN forwards, so the
|
||||
// switch belongs to each node: an operator may well want to turn releasing on
|
||||
// everywhere, but the server should not pretend those are one atomic action.
|
||||
// (A cluster-wide control can be layered on top later; the primitive here is the
|
||||
// one that actually has an owner.)
|
||||
//
|
||||
// Write-level auth: releasing stops a worker and drops a live connection.
|
||||
func (h *Handler) handleClusterBalanceMode(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
if h.Balancer == nil {
|
||||
http.Error(w, "load balancer not enabled", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.Balancer.SetModeFromUI(req.Mode); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, h.Balancer.Report())
|
||||
}
|
||||
|
||||
// handleClusterJoin accepts a newcomer join request: the target node inserts
|
||||
// the newcomer after itself in the ring and returns the updated ring state
|
||||
// for the newcomer to adopt.
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"`
|
||||
@ -128,6 +134,11 @@ type Settings struct {
|
||||
// key). Cleared on explicit detach (detachAsStandalone) so a node that
|
||||
// intentionally left does NOT auto-rejoin.
|
||||
ClusterPeers string `json:"clusterPeers,omitempty"`
|
||||
// BalanceMode is the load-balancing mode chosen in the UI: "", "off",
|
||||
// "dry-run" or "on". Empty means "never configured here" so the environment
|
||||
// variable stays the bootstrap default; a non-empty value wins over it,
|
||||
// otherwise flipping the switch in the UI would silently revert on restart.
|
||||
BalanceMode string `json:"balanceMode,omitempty"`
|
||||
}
|
||||
|
||||
// Forward is a rendered link row attached to a remote.
|
||||
@ -317,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",
|
||||
@ -471,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
|
||||
}
|
||||
@ -480,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
|
||||
@ -493,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
|
||||
@ -504,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
|
||||
}
|
||||
@ -748,6 +760,13 @@ func (s *Store) Settings() (Settings, error) {
|
||||
if err := json.Unmarshal([]byte(raw), &st); err != nil {
|
||||
return Settings{AutoStartProfiles: true, RestartOnExit: true, RestartIntervalSeconds: 5}, nil
|
||||
}
|
||||
// BalanceMode lives in its own column (see UpdateSettings). Surface it here
|
||||
// too so callers reading Settings get the effective value, but never let it
|
||||
// overwrite the column on a write-back: UpdateSettings only mirrors a
|
||||
// non-empty value, so an unrelated settings PUT cannot clear a UI choice.
|
||||
if mode, ok, err := s.BalanceMode(); err == nil && ok {
|
||||
st.BalanceMode = mode
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
@ -760,7 +779,36 @@ func (s *Store) UpdateSettings(st Settings) error {
|
||||
"INSERT INTO settings(key, value) VALUES('settings', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||||
string(raw),
|
||||
)
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if mode := st.BalanceMode; mode != "" {
|
||||
// Mirror into its own column for the same reason the other flagged
|
||||
// fields have one: the settings JSON is rewritten wholesale, so a
|
||||
// clustered field that rides that JSON would flip back and forth between
|
||||
// nodes instead of converging.
|
||||
if _, err := s.db.Exec(
|
||||
"INSERT INTO settings(key, value) VALUES('balance_mode', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||||
mode,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BalanceMode returns the stored load-balancing mode, and whether one was ever
|
||||
// stored (false = never configured here; the environment default applies).
|
||||
func (s *Store) BalanceMode() (string, bool, error) {
|
||||
var v string
|
||||
err := s.db.QueryRow("SELECT value FROM settings WHERE key = 'balance_mode'").Scan(&v)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return v, v != "", nil
|
||||
}
|
||||
|
||||
// SetNodeKey persists just the cluster admission key, preserving all other
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.3" ./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.3"
|
||||
var Version = "0.2.0"
|
||||
|
||||
27
packaging/common/config.json
Normal file
27
packaging/common/config.json
Normal 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": ""
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"/>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
Name: webui4frpc
|
||||
Version: %{?_w4f_version}%{!?_w4f_version:0.1.3}
|
||||
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,26 @@ 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)
|
||||
- 集群页:新增负载均衡视图与每节点模式开关(关闭/观察/启用),模式持久化
|
||||
- 新增只读接口 GET /cluster/balance 与写接口 POST /cluster/balance/mode
|
||||
* Sun Oct 05 2026 JianFeeeee <jianfeeeee@gitcode> - 0.1.3-1
|
||||
- 窄屏布局:补 box-sizing 重置、接管画布 fit(缩放下限 0.65)、弹窗宽度钳制、统一滚动容器(修复手机端整页无法滑动)
|
||||
- 集群:停用/启动改为经令牌环跨节点传播,撤销不再静默失效
|
||||
|
||||
@ -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.1-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.1-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.1-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.1-darwin-arm64.pkg -target / # Apple Silicon
|
||||
sudo installer -pkg webui4frpc-0.1.1-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.1-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
|
||||
```
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
# 在 release.sh 流程内被调用;依赖 dist/artifacts/<version>/bin/<os>-<arch>/ 下的二进制。
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:-0.1.3}"
|
||||
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
|
||||
|
||||
135
scripts/criteria/config-startup.sh
Executable file
135
scripts/criteria/config-startup.sh
Executable 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: 配置文件启动与口令不泄漏的判据全部通过"
|
||||
@ -3,7 +3,7 @@
|
||||
# webui4frpc 多平台发布脚本
|
||||
#
|
||||
# 用法:
|
||||
# ./scripts/release.sh [version] # 默认 version=0.1.3
|
||||
# ./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.3}"
|
||||
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"
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
import type {
|
||||
ApiKey,
|
||||
ApiKeyCreated,
|
||||
BalanceReport,
|
||||
BinaryStatus,
|
||||
CacheResp,
|
||||
CanvasData,
|
||||
@ -159,6 +160,21 @@ export const api = {
|
||||
// M6 token ring snapshot.
|
||||
ring: () => request<RingSnapshot>("/api/manager/cluster/ring"),
|
||||
|
||||
// Load balancer read-only view. Reports per-node scores, whether anything is
|
||||
// over the threshold, and the migration it would perform. Never mutates the
|
||||
// cluster. 404 when the balancer is not wired up on that node.
|
||||
balance: () => request<BalanceReport>("/api/manager/cluster/balance"),
|
||||
|
||||
// setBalanceMode switches load balancing on THIS node (off | dry-run | on).
|
||||
// Per-node by design: each member releases only its own forwards, so there is
|
||||
// no cluster-wide switch to flip. Requires write level.
|
||||
setBalanceMode: (mode: string) =>
|
||||
request<BalanceReport>("/api/manager/cluster/balance/mode", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mode }),
|
||||
}),
|
||||
|
||||
// M6 cluster lifecycle (创建/加入/退出/移除).
|
||||
// clusterCreate: reseed THIS node as a fresh standalone leader (创建集群).
|
||||
clusterCreate: () =>
|
||||
|
||||
@ -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 > 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>
|
||||
|
||||
496
web/src/types.ts
496
web/src/types.ts
@ -9,332 +9,388 @@ export type CanvasRemote = Remote;
|
||||
export type CanvasLink = Link;
|
||||
|
||||
export interface Local {
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
protocol: string; // tcp | udp | http | https
|
||||
// localOnly: forward stays on this node (loopback kept), no cluster.
|
||||
localOnly?: boolean;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
protocol: string; // tcp | udp | http | https
|
||||
// localOnly: forward stays on this node (loopback kept), no cluster.
|
||||
localOnly?: boolean;
|
||||
|
||||
// M1 advanced transport knobs (optional; empty/zero = frpc defaults)
|
||||
useEncryption?: boolean;
|
||||
useCompression?: boolean;
|
||||
bandwidthLimit?: string;
|
||||
poolCount?: number;
|
||||
metadatas?: Record<string, string>;
|
||||
annotations?: Record<string, string>;
|
||||
// M1 advanced transport knobs (optional; empty/zero = frpc defaults)
|
||||
useEncryption?: boolean;
|
||||
useCompression?: boolean;
|
||||
bandwidthLimit?: string;
|
||||
poolCount?: number;
|
||||
metadatas?: Record<string, string>;
|
||||
annotations?: Record<string, string>;
|
||||
|
||||
// M2 HTTP/HTTPS routing (http/https only)
|
||||
customDomains?: string; // comma-separated
|
||||
subdomain?: string;
|
||||
locations?: string[];
|
||||
hostHeaderRewrite?: string;
|
||||
httpHeaders?: Record<string, string>;
|
||||
basicAuthUser?: string;
|
||||
basicAuthPassword?: string;
|
||||
// M2 HTTP/HTTPS routing (http/https only)
|
||||
customDomains?: string; // comma-separated
|
||||
subdomain?: string;
|
||||
locations?: string[];
|
||||
hostHeaderRewrite?: string;
|
||||
httpHeaders?: Record<string, string>;
|
||||
basicAuthUser?: string;
|
||||
basicAuthPassword?: string;
|
||||
|
||||
// M3 load balancing group + health check
|
||||
lbGroup?: string;
|
||||
lbGroupKey?: string;
|
||||
healthCheckType?: string; // "" | tcp | http
|
||||
healthCheckPath?: string;
|
||||
healthCheckTimeout?: number;
|
||||
healthCheckMaxFailed?: number;
|
||||
healthCheckInterval?: number;
|
||||
// M3 load balancing group + health check
|
||||
lbGroup?: string;
|
||||
lbGroupKey?: string;
|
||||
healthCheckType?: string; // "" | tcp | http
|
||||
healthCheckPath?: string;
|
||||
healthCheckTimeout?: number;
|
||||
healthCheckMaxFailed?: number;
|
||||
healthCheckInterval?: number;
|
||||
}
|
||||
|
||||
export interface Remote {
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number; // frps connect port
|
||||
token?: string;
|
||||
url?: string;
|
||||
enabled: boolean;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number; // frps connect port
|
||||
token?: string;
|
||||
url?: string;
|
||||
enabled: boolean;
|
||||
|
||||
// M1 transport section
|
||||
transportProtocol?: string; // tcp | quic | kcp | websocket
|
||||
transportTls?: boolean;
|
||||
transportPool?: number;
|
||||
transportTlsServerName?: string;
|
||||
// M1 transport section
|
||||
transportProtocol?: string; // tcp | quic | kcp | websocket
|
||||
transportTls?: boolean;
|
||||
transportPool?: number;
|
||||
transportTlsServerName?: string;
|
||||
|
||||
// M2: frps vhostHTTPPort
|
||||
vhostHttpPort?: number;
|
||||
// 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;
|
||||
|
||||
// M3: local frpc admin API (webServer)
|
||||
adminAddr?: string;
|
||||
adminPort?: number;
|
||||
adminUser?: string;
|
||||
adminPassword?: string;
|
||||
// M2: frps vhostHTTPPort
|
||||
vhostHttpPort?: number;
|
||||
|
||||
// M3: local frpc admin API (webServer)
|
||||
adminAddr?: string;
|
||||
adminPort?: number;
|
||||
adminUser?: string;
|
||||
adminPassword?: string;
|
||||
}
|
||||
|
||||
export interface Link {
|
||||
id?: number;
|
||||
local: string;
|
||||
remote: string;
|
||||
remotePort: number;
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
// group: management label for one-click group start/stop on the forwards
|
||||
// page (unrelated to frps load-balancing lbGroup on Local).
|
||||
group?: string;
|
||||
// disabled: per-forward stop flag. A disabled forward is omitted from the
|
||||
// frpc config (local) / left out of the ring topology (cluster), so stop is
|
||||
// per-card and survives canvas re-saves.
|
||||
disabled?: boolean;
|
||||
id?: number;
|
||||
local: string;
|
||||
remote: string;
|
||||
remotePort: number;
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
// group: management label for one-click group start/stop on the forwards
|
||||
// page (unrelated to frps load-balancing lbGroup on Local).
|
||||
group?: string;
|
||||
// disabled: per-forward stop flag. A disabled forward is omitted from the
|
||||
// frpc config (local) / left out of the ring topology (cluster), so stop is
|
||||
// per-card and survives canvas re-saves.
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface CanvasData {
|
||||
locals: Local[];
|
||||
remotes: Remote[];
|
||||
links: Link[];
|
||||
locals: Local[];
|
||||
remotes: Remote[];
|
||||
links: Link[];
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
autoStartProfiles: boolean;
|
||||
restartOnExit: boolean;
|
||||
restartIntervalSeconds: number;
|
||||
binaryPath?: string;
|
||||
autoStartProfiles: boolean;
|
||||
restartOnExit: boolean;
|
||||
restartIntervalSeconds: number;
|
||||
binaryPath?: string;
|
||||
}
|
||||
|
||||
export interface ProcessStatus {
|
||||
state: string;
|
||||
pid?: number;
|
||||
startTime?: number;
|
||||
restartCount: number;
|
||||
exitCode?: number;
|
||||
err?: string;
|
||||
state: string;
|
||||
pid?: number;
|
||||
startTime?: number;
|
||||
restartCount: number;
|
||||
exitCode?: number;
|
||||
err?: string;
|
||||
}
|
||||
|
||||
export interface ProxyState {
|
||||
name: string;
|
||||
type: string;
|
||||
status: string; // new | wait start | start error | running | check failed | closed
|
||||
err?: string;
|
||||
local_addr?: string;
|
||||
remote_addr?: string;
|
||||
name: string;
|
||||
type: string;
|
||||
status: string; // new | wait start | start error | running | check failed | closed
|
||||
err?: string;
|
||||
local_addr?: string;
|
||||
remote_addr?: string;
|
||||
}
|
||||
|
||||
export interface StatusProfile {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
process: ProcessStatus;
|
||||
hasProcess: boolean;
|
||||
forwards: { service: string; remotePort: number; localPort?: number }[];
|
||||
// connState: derived frps connection state (we do not control remote frps)
|
||||
connState?: string; // connected | connecting | failed | not_started | disabled
|
||||
// M3: admin API state
|
||||
adminEnabled?: boolean;
|
||||
proxyStates?: ProxyState[];
|
||||
groupCount?: number;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
process: ProcessStatus;
|
||||
hasProcess: boolean;
|
||||
forwards: { service: string; remotePort: number; localPort?: number }[];
|
||||
// connState: derived frps connection state (we do not control remote frps)
|
||||
connState?: string; // connected | connecting | failed | not_started | disabled
|
||||
// M3: admin API state
|
||||
adminEnabled?: boolean;
|
||||
proxyStates?: ProxyState[];
|
||||
groupCount?: number;
|
||||
}
|
||||
|
||||
export interface StatusResp {
|
||||
version: string;
|
||||
workDir: string;
|
||||
settings: Settings;
|
||||
services: Local[];
|
||||
remotes: Remote[];
|
||||
binaryPath: string;
|
||||
profiles: StatusProfile[];
|
||||
localStatus: LocalStatus[];
|
||||
// forwards: the forward-centric list rendered as cards on the status page.
|
||||
forwards: ForwardStatus[];
|
||||
// selfId: this node's ring ID (== SelfAddr), to tell whether a cluster
|
||||
// forward's owner is this node (worker runs here) or a peer.
|
||||
selfId: string;
|
||||
version: string;
|
||||
workDir: string;
|
||||
settings: Settings;
|
||||
services: Local[];
|
||||
remotes: Remote[];
|
||||
binaryPath: string;
|
||||
profiles: StatusProfile[];
|
||||
localStatus: LocalStatus[];
|
||||
// forwards: the forward-centric list rendered as cards on the status page.
|
||||
forwards: ForwardStatus[];
|
||||
// selfId: this node's ring ID (== SelfAddr), to tell whether a cluster
|
||||
// forward's owner is this node (worker runs here) or a peer.
|
||||
selfId: string;
|
||||
}
|
||||
|
||||
// ForwardStatus is one forward (link) in the forward-centric status view.
|
||||
// kind distinguishes 本地转发 (localOnly) from 远程转发 (cluster-distributed).
|
||||
export interface ForwardStatus {
|
||||
local: string;
|
||||
remote: string;
|
||||
remotePort: number;
|
||||
localOnly: boolean;
|
||||
kind: "local" | "remote";
|
||||
ownerId?: string; // remote = topology owner; local = selfId
|
||||
active: boolean; // remote = in topology; local = local worker running
|
||||
disabled: boolean; // per-forward stop flag (true = stopped)
|
||||
group?: string;
|
||||
localIp?: string;
|
||||
localPort?: number;
|
||||
localProto?: string;
|
||||
local: string;
|
||||
remote: string;
|
||||
remotePort: number;
|
||||
localOnly: boolean;
|
||||
kind: "local" | "remote";
|
||||
ownerId?: string; // remote = topology owner; local = selfId
|
||||
active: boolean; // remote = in topology; local = local worker running
|
||||
disabled: boolean; // per-forward stop flag (true = stopped)
|
||||
group?: string;
|
||||
localIp?: string;
|
||||
localPort?: number;
|
||||
localProto?: string;
|
||||
}
|
||||
|
||||
export interface LocalTargetStatus {
|
||||
remote: string;
|
||||
remotePort: number;
|
||||
workerState: string;
|
||||
remote: string;
|
||||
remotePort: number;
|
||||
workerState: string;
|
||||
}
|
||||
|
||||
export interface LocalStatus {
|
||||
local: Local;
|
||||
targets: LocalTargetStatus[];
|
||||
local: Local;
|
||||
targets: LocalTargetStatus[];
|
||||
}
|
||||
|
||||
export interface BinaryStatus {
|
||||
binaryPath: string;
|
||||
version?: string;
|
||||
binaryPath: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface InstallResult {
|
||||
path: string;
|
||||
version: string;
|
||||
path: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
// M6: cluster node registry + binary cache.
|
||||
export interface ClusterNode {
|
||||
addr: string;
|
||||
cache?: string[];
|
||||
version?: string;
|
||||
addr: string;
|
||||
cache?: string[];
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface CacheEntry {
|
||||
version: string;
|
||||
path: string;
|
||||
size: number;
|
||||
modTime: number;
|
||||
version: string;
|
||||
path: string;
|
||||
size: number;
|
||||
modTime: number;
|
||||
}
|
||||
|
||||
export interface ClusterNodesResp {
|
||||
nodes: ClusterNode[];
|
||||
nodes: ClusterNode[];
|
||||
}
|
||||
|
||||
export interface CacheResp {
|
||||
cache: CacheEntry[];
|
||||
removed?: string[];
|
||||
cache: CacheEntry[];
|
||||
removed?: string[];
|
||||
}
|
||||
|
||||
// M6 token-ring snapshot (GET /cluster/ring).
|
||||
export interface RingNode {
|
||||
id: string;
|
||||
addr: string;
|
||||
isLeader?: boolean;
|
||||
alive: boolean;
|
||||
load: { memPct: number; netPct: number; forwards?: number };
|
||||
version?: string;
|
||||
cache?: string[];
|
||||
lastSeen?: number;
|
||||
id: string;
|
||||
addr: string;
|
||||
isLeader?: boolean;
|
||||
alive: boolean;
|
||||
load: { memPct: number; netPct: number; forwards?: number };
|
||||
version?: string;
|
||||
cache?: string[];
|
||||
lastSeen?: number;
|
||||
}
|
||||
|
||||
export interface RingTaskInfo {
|
||||
id: string;
|
||||
revoke?: boolean;
|
||||
removeNode?: string; // node-removal command (target rides the token to self-remove)
|
||||
local: { name: string };
|
||||
remote: { name: string };
|
||||
link: { remotePort?: number };
|
||||
id: string;
|
||||
revoke?: boolean;
|
||||
removeNode?: string; // node-removal command (target rides the token to self-remove)
|
||||
local: { name: string };
|
||||
remote: { name: string };
|
||||
link: { remotePort?: number };
|
||||
}
|
||||
|
||||
export interface RingTopoEntry {
|
||||
taskId: string;
|
||||
ownerId: string;
|
||||
active: boolean;
|
||||
local: { name: string };
|
||||
remote: { name: string };
|
||||
link: { remotePort?: number };
|
||||
taskId: string;
|
||||
ownerId: string;
|
||||
active: boolean;
|
||||
local: { name: string };
|
||||
remote: { name: string };
|
||||
link: { remotePort?: number };
|
||||
}
|
||||
|
||||
export interface RingLogEntry {
|
||||
seq: number;
|
||||
node: string;
|
||||
kind: string;
|
||||
at?: number;
|
||||
data?: Record<string, any> | null;
|
||||
seq: number;
|
||||
node: string;
|
||||
kind: string;
|
||||
at?: number;
|
||||
data?: Record<string, any> | null;
|
||||
}
|
||||
|
||||
export interface RingSnapshot {
|
||||
selfId: string;
|
||||
leaderId: string; // 当前 leader(持首令牌 + 判丢失 + 被上家监控)
|
||||
cycle: number; // 周期号(leader 每收令牌回 +1)
|
||||
lastSync: number; // 上次同步时间 (unix sec)
|
||||
roundDelayMs?: number;
|
||||
nodes: RingNode[];
|
||||
pending: RingTaskInfo[];
|
||||
topology: RingTopoEntry[];
|
||||
log: RingLogEntry[];
|
||||
// nodeKey: this node's cluster admission key. Newcomers must present it to
|
||||
// join via this node (handleClusterJoin verifies). Shown on the cluster page
|
||||
// for the operator to copy; omitted for non-cluster (standalone) nodes.
|
||||
nodeKey?: string;
|
||||
selfId: string;
|
||||
leaderId: string; // 当前 leader(持首令牌 + 判丢失 + 被上家监控)
|
||||
cycle: number; // 周期号(leader 每收令牌回 +1)
|
||||
lastSync: number; // 上次同步时间 (unix sec)
|
||||
roundDelayMs?: number;
|
||||
nodes: RingNode[];
|
||||
pending: RingTaskInfo[];
|
||||
topology: RingTopoEntry[];
|
||||
log: RingLogEntry[];
|
||||
// nodeKey: this node's cluster admission key. Newcomers must present it to
|
||||
// join via this node (handleClusterJoin verifies). Shown on the cluster page
|
||||
// for the operator to copy; omitted for non-cluster (standalone) nodes.
|
||||
nodeKey?: string;
|
||||
}
|
||||
|
||||
// BalanceReport is one node's own view of rebalancing (GET /cluster/balance).
|
||||
//
|
||||
// It is per-node by design: every member runs the same rule against its own
|
||||
// forwards, and the answer that matters is the one on the node doing (or
|
||||
// refusing to do) the releasing. So this reports "my share" first and the peers
|
||||
// second, and there is no cluster-wide "would migrate" plan — the ring's normal
|
||||
// lowest-load claim decides where a released forward lands.
|
||||
export interface BalanceReleasePlan {
|
||||
local: string;
|
||||
remote: string;
|
||||
remotePort: number;
|
||||
owner: string;
|
||||
}
|
||||
|
||||
export interface BalanceReport {
|
||||
mode: "off" | "dry-run" | "on";
|
||||
modeSource: "env" | "stored"; // env = W4F_BALANCE_MODE default, stored = set here
|
||||
enabled: boolean; // mode !== off
|
||||
dryRun: boolean; // mode !== on
|
||||
canControl: boolean; // this principal may switch it (write level)
|
||||
isMember: boolean;
|
||||
self: string;
|
||||
|
||||
selfForwards: number;
|
||||
share: number; // the even split: ceil(total / alive)
|
||||
totalForwards: number;
|
||||
aliveNodes: number;
|
||||
selfOverShare: boolean;
|
||||
|
||||
// wouldRelease is present whenever this node is over its share — including
|
||||
// while it is deliberately holding back, which is what blockedBy explains.
|
||||
wouldRelease?: BalanceReleasePlan;
|
||||
releasing?: BalanceReleasePlan;
|
||||
blockedBy?: string; // single-node | below-min | balanced | nothing-active | cooldown | in-flight
|
||||
|
||||
cooldownSec: number;
|
||||
lastReleaseAt?: number;
|
||||
nodes: BalanceNodeReport[];
|
||||
}
|
||||
|
||||
export interface BalanceNodeReport {
|
||||
id: string;
|
||||
alive: boolean;
|
||||
isLeader: boolean;
|
||||
forwards: number;
|
||||
memPct: number;
|
||||
netPct: number;
|
||||
score: number;
|
||||
}
|
||||
|
||||
// ---- Auth / accounts / API keys (M7) ----
|
||||
|
||||
export type AccessLevel = 'read' | 'write' | 'admin' | 'superadmin';
|
||||
export type AccessLevel = "read" | "write" | "admin" | "superadmin";
|
||||
|
||||
// MeResp is the authenticated principal (GET /me). The frontend uses level to
|
||||
// gate the UI: viewer (= auditors) sees read + export controls only.
|
||||
export interface MeResp {
|
||||
type: 'user' | 'service' | 'key';
|
||||
name: string;
|
||||
level: AccessLevel;
|
||||
userId?: number;
|
||||
type: "user" | "service" | "key";
|
||||
name: string;
|
||||
level: AccessLevel;
|
||||
userId?: number;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
role: 'admin' | 'viewer' | 'superadmin';
|
||||
enabled: boolean;
|
||||
system?: boolean; // flag-synced built-in account (UI read-only)
|
||||
createdAt: number;
|
||||
lastLoginAt: number;
|
||||
id: number;
|
||||
username: string;
|
||||
role: "admin" | "viewer" | "superadmin";
|
||||
enabled: boolean;
|
||||
system?: boolean; // flag-synced built-in account (UI read-only)
|
||||
createdAt: number;
|
||||
lastLoginAt: number;
|
||||
}
|
||||
|
||||
export interface ApiKey {
|
||||
id: number;
|
||||
userId: number;
|
||||
prefix: string;
|
||||
label: string;
|
||||
scope: AccessLevel;
|
||||
createdAt: number;
|
||||
lastUsedAt: number;
|
||||
expiresAt?: number;
|
||||
id: number;
|
||||
userId: number;
|
||||
prefix: string;
|
||||
label: string;
|
||||
scope: AccessLevel;
|
||||
createdAt: number;
|
||||
lastUsedAt: number;
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
// ApiKeyCreated is the one-time response from POST /apikeys: the plaintext key
|
||||
// is shown exactly once and never retrievable again.
|
||||
export interface ApiKeyCreated {
|
||||
id: number;
|
||||
userId: number;
|
||||
label: string;
|
||||
scope: AccessLevel;
|
||||
prefix: string;
|
||||
createdAt: number;
|
||||
key: string;
|
||||
id: number;
|
||||
userId: number;
|
||||
label: string;
|
||||
scope: AccessLevel;
|
||||
prefix: string;
|
||||
createdAt: number;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface CanvasExportEnvelope {
|
||||
_type: 'webui4frpc-canvas';
|
||||
version: number;
|
||||
exportedAt: number;
|
||||
exporter: string;
|
||||
canvas: CanvasData;
|
||||
_type: "webui4frpc-canvas";
|
||||
version: number;
|
||||
exportedAt: number;
|
||||
exporter: string;
|
||||
canvas: CanvasData;
|
||||
}
|
||||
|
||||
// Worker-log bundle (GET /cluster/logs/export): one entry per alive ring node.
|
||||
export interface WorkerLogEntry {
|
||||
name: string;
|
||||
remote: string;
|
||||
state: string;
|
||||
lines: string;
|
||||
name: string;
|
||||
remote: string;
|
||||
state: string;
|
||||
lines: string;
|
||||
}
|
||||
export interface NodeLogsResp {
|
||||
node: string;
|
||||
workers: WorkerLogEntry[];
|
||||
node: string;
|
||||
workers: WorkerLogEntry[];
|
||||
}
|
||||
export interface ClusterWorkerLogNode {
|
||||
id: string;
|
||||
addr: string;
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
logs?: NodeLogsResp;
|
||||
id: string;
|
||||
addr: string;
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
logs?: NodeLogsResp;
|
||||
}
|
||||
export interface WorkerLogBundle {
|
||||
_type: 'webui4frpc-worker-logs';
|
||||
version: number;
|
||||
exportedAt: number;
|
||||
requester: string;
|
||||
nodes: ClusterWorkerLogNode[];
|
||||
_type: "webui4frpc-worker-logs";
|
||||
version: number;
|
||||
exportedAt: number;
|
||||
requester: string;
|
||||
nodes: ClusterWorkerLogNode[];
|
||||
}
|
||||
|
||||
@ -121,6 +121,131 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Load balancer (read-only). Shown only when the endpoint responds: a
|
||||
404 means this node has no balancer wired up, and an absent section is
|
||||
the honest signal for "this build doesn't do balancing". -->
|
||||
<section class="section w4f-card bal-section" v-if="bal">
|
||||
<h3 class="section-title">
|
||||
<span class="k-ic">⚖</span>负载均衡
|
||||
<!-- The switch edits the mode ON THIS NODE. Every member decides about
|
||||
its own forwards, so a cluster-wide toggle would be a lie: there is
|
||||
no single action that flips them together. -->
|
||||
<el-select
|
||||
v-if="bal.canControl"
|
||||
v-model="balModeChoice"
|
||||
size="small"
|
||||
class="bal-mode"
|
||||
:disabled="modeBusy"
|
||||
@change="onModeChange"
|
||||
>
|
||||
<el-option label="关闭" value="off" />
|
||||
<el-option label="观察模式(不释放)" value="dry-run" />
|
||||
<el-option label="启用(会释放)" value="on" />
|
||||
</el-select>
|
||||
<span class="w4f-tag" v-else v-text="'只读'" ></span>
|
||||
<span class="bal-src" v-if="bal.modeSource === 'stored'">已保存</span>
|
||||
<span class="bal-src" v-else>来自启动参数</span>
|
||||
<span class="w4f-tag" v-if="!bal.isMember" v-text="'非集群成员'"></span>
|
||||
<span class="w4f-tag" v-else-if="bal.releasing" v-text="'释放进行中'"></span>
|
||||
<span class="w4f-tag w4f-tag-warn" v-else-if="bal.selfOverShare" v-text="'本机超出份额'"></span>
|
||||
<span class="w4f-tag w4f-tag-ok" v-else v-text="'本机在份额内'"></span>
|
||||
<span class="title-actions">
|
||||
<button class="w4f-btn ghost small" :disabled="busy" @click="load">⟳ 刷新</button>
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<div class="bal-thresholds">
|
||||
<span>
|
||||
规则:本机转发 <b>{{ bal.selfForwards }}</b> 条
|
||||
> 平摊份额 <b>{{ bal.share }}</b>
|
||||
且至少 {{ 2 }} 条时,本机自行释放一条
|
||||
</span>
|
||||
<span>集群共 {{ bal.totalForwards }} 条 / {{ bal.aliveNodes }} 台存活</span>
|
||||
<span>两次释放间隔 ≥ {{ Math.round(bal.cooldownSec / 60) }} 分钟</span>
|
||||
<span v-if="bal.dryRun" class="bal-hint">
|
||||
设 <code>W4F_BALANCE_MODE=on</code> 才会真正释放
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="bal-note">
|
||||
每个节点各自判断自己的转发,超了就自己撤销一条并丢回环上,
|
||||
由环节点按「最闲者优先」重新认领。没有中心调度。
|
||||
</p>
|
||||
|
||||
<!-- Per-node forward counts. Bars are scaled to the busiest node so the
|
||||
shape reads whether the cluster holds 3 forwards or 300; the share
|
||||
marker is what actually decides, not the bar. -->
|
||||
<div class="bal-nodes">
|
||||
<div
|
||||
v-for="n in balNodes"
|
||||
:key="n.id"
|
||||
class="bal-node"
|
||||
:class="{ self: n.id === bal.self, over: n.forwards > bal.share }"
|
||||
>
|
||||
<div class="bn-top">
|
||||
<span class="bn-id">{{ shortId(n.id) }}</span>
|
||||
<span v-if="n.id === bal.self" class="w4f-tag w4f-tag-primary">本机</span>
|
||||
<span v-if="n.isLeader" class="w4f-tag w4f-tag-info">LEADER</span>
|
||||
<span v-if="!n.alive" class="w4f-tag w4f-tag-danger">离线</span>
|
||||
<span v-if="n.alive && n.forwards > bal.share" class="w4f-tag w4f-tag-warn">超出份额</span>
|
||||
</div>
|
||||
<div class="w4f-bar" :class="{ err: n.alive && n.forwards > bal.share }">
|
||||
<i :style="{ width: barWidth(n.forwards) + '%' }" />
|
||||
</div>
|
||||
<div class="bn-stats">
|
||||
<span>转发 <b>{{ n.forwards }}</b></span>
|
||||
<span>内存 <b>{{ n.memPct.toFixed(1) }}%</b></span>
|
||||
<span>网络 <b>{{ n.netPct.toFixed(1) }}%</b></span>
|
||||
<span class="bn-score">份额 <b>{{ bal.share }}</b></span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!balNodes.length" class="empty">暂无节点负载数据</div>
|
||||
</div>
|
||||
|
||||
<!-- What THIS node would do. The answer is per-node by design: every
|
||||
member runs the same rule on its own forwards, so there is no
|
||||
cluster-wide plan and no "the leader will move X to Y". -->
|
||||
<div class="bal-plan">
|
||||
<div v-if="bal.releasing" class="bp-inflight">
|
||||
<span class="w4f-tag w4f-tag-warn">释放进行中</span>
|
||||
<span class="bp-text">
|
||||
本机已释放 {{ bal.releasing.local }}:{{ bal.releasing.remotePort }},
|
||||
等待环把它认领到别的节点
|
||||
</span>
|
||||
</div>
|
||||
<div v-else-if="bal.wouldRelease && !bal.blockedBy" class="bp-would">
|
||||
<span class="w4f-tag w4f-tag-warn">将释放</span>
|
||||
<span class="bp-text">
|
||||
本机将撤销 {{ bal.wouldRelease.local }}:{{ bal.wouldRelease.remotePort }}
|
||||
并丢回环上,由最闲节点接管
|
||||
</span>
|
||||
</div>
|
||||
<div v-else-if="bal.wouldRelease && bal.blockedBy === 'cooldown'" class="bp-waiting">
|
||||
<span class="w4f-tag w4f-tag-info">冷却中</span>
|
||||
<span class="bp-text">
|
||||
本机超出份额,但距上次释放不足
|
||||
{{ Math.round(bal.cooldownSec / 60) }} 分钟
|
||||
<template v-if="bal.lastReleaseAt">
|
||||
(已过 {{ cooldownAgo }})
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<div v-else-if="bal.wouldRelease" class="bp-waiting">
|
||||
<span class="w4f-tag w4f-tag-info">暂不释放</span>
|
||||
<span class="bp-text">本机超出份额,原因:{{ blockedLabel }}</span>
|
||||
</div>
|
||||
<div v-else-if="!bal.isMember" class="bp-ok">
|
||||
<span class="w4f-tag w4f-tag-ok">单节点</span>
|
||||
<span class="bp-text">本机未加入集群,无需均衡</span>
|
||||
</div>
|
||||
<div v-else class="bp-ok">
|
||||
<span class="w4f-tag w4f-tag-ok">已均衡</span>
|
||||
<span class="bp-text">
|
||||
本机持有 {{ bal.selfForwards }} 条,未超平摊份额 {{ bal.share }},无需释放
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Active forward topology -->
|
||||
<section class="section w4f-card">
|
||||
<h3 class="section-title"><span class="k-ic">→</span>活跃转发拓扑</h3>
|
||||
@ -199,14 +324,18 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { api, downloadAuditCsv } from '../api'
|
||||
import { canWrite } from '../auth'
|
||||
import type { RingSnapshot, RingTaskInfo, RingLogEntry } from '../types'
|
||||
import type { BalanceReport, RingSnapshot, RingTaskInfo, RingLogEntry } from '../types'
|
||||
|
||||
const ring = ref<RingSnapshot | null>(null)
|
||||
const busy = ref(false)
|
||||
// bal is null when the node has no balancer wired up (endpoint 404s). The
|
||||
// section is simply not rendered then, which is the honest signal for "this
|
||||
// build does not do balancing" — better than an empty panel implying it does.
|
||||
const bal = ref<BalanceReport | null>(null)
|
||||
let timer: number | null = null
|
||||
let visHandler: (() => void) | null = null
|
||||
|
||||
@ -214,8 +343,76 @@ const joinDialog = reactive({ visible: false, addr: '', key: '' })
|
||||
|
||||
const load = async () => {
|
||||
try { ring.value = await api.ring() } catch { /* keep last */ }
|
||||
// Loaded alongside the ring, but unlike it a failure clears the view: the
|
||||
// balancer report is a decision surface, and showing a stale "已均衡" after the
|
||||
// endpoint starts failing would be worse than showing nothing.
|
||||
try { bal.value = await api.balance() } catch { bal.value = null }
|
||||
}
|
||||
|
||||
// ---- load balancer mode switch ---- //
|
||||
// The select is bound to a local mirror so the UI can show the pending choice
|
||||
// while the request is in flight, and snap back if the server rejects it.
|
||||
const balModeChoice = ref<string>('dry-run')
|
||||
const modeBusy = ref(false)
|
||||
watch(
|
||||
() => bal.value?.mode,
|
||||
(m) => { if (m) balModeChoice.value = m },
|
||||
{ immediate: true },
|
||||
)
|
||||
const onModeChange = async (m: string) => {
|
||||
if (!bal.value || modeBusy.value) return
|
||||
modeBusy.value = true
|
||||
try {
|
||||
bal.value = await api.setBalanceMode(m)
|
||||
ElMessage.success(
|
||||
m === 'on' ? '本机已启用负载释放' : m === 'off' ? '本机已关闭负载均衡' : '本机已切到观察模式',
|
||||
)
|
||||
} catch (e) {
|
||||
// Snap back: leaving the select showing a mode the server did not accept
|
||||
// would misrepresent what the node is doing.
|
||||
balModeChoice.value = bal.value.mode
|
||||
ElMessage.error('切换失败:' + (e instanceof Error ? e.message : String(e)))
|
||||
} finally {
|
||||
modeBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- load balancer view ---- //
|
||||
// Each node decides about its OWN forwards, so this page has no cluster-wide
|
||||
// plan to show — only what THIS node holds and what it is about to do.
|
||||
// Ordered by descending count: the busiest node first is what an operator
|
||||
// scanning for a hotspot wants.
|
||||
const balNodes = computed(() => [...(bal.value?.nodes ?? [])].sort((a, b) => b.forwards - a.forwards))
|
||||
// barWidth scales against the busiest node rather than an absolute, so the shape
|
||||
// is readable whether the cluster holds 3 forwards or 300. The bar is context;
|
||||
// `share` is what actually decides whether a node is over.
|
||||
const barWidth = (forwards: number) => {
|
||||
const max = Math.max(...(bal.value?.nodes ?? []).map((n) => n.forwards), 1)
|
||||
return Math.min(100, Math.round((forwards / max) * 100))
|
||||
}
|
||||
// blockedLabel renders the server's machine reason as something readable. The
|
||||
// reason matters: "over its share but not acting" is otherwise indistinguishable
|
||||
// from "broken".
|
||||
const blockedLabel = computed(() => {
|
||||
switch (bal.value?.blockedBy) {
|
||||
case 'cooldown': return '冷却期未过'
|
||||
case 'in-flight': return '上一次释放尚未确认'
|
||||
case 'nothing-active': return '本机可释放的转发都已停用'
|
||||
case 'below-min': return '持有量低于下限'
|
||||
case 'single-node': return '单节点环'
|
||||
default: return bal.value?.blockedBy || '未说明'
|
||||
}
|
||||
})
|
||||
// cooldownAgo says how long ago the last release was, so an operator can see
|
||||
// whether the cooldown is about to expire.
|
||||
const cooldownAgo = computed(() => {
|
||||
const t = bal.value?.lastReleaseAt ?? 0
|
||||
if (!t) return ''
|
||||
const s = Math.max(0, Math.floor(nowMs.value / 1000) - t)
|
||||
if (s < 60) return s + ' 秒前'
|
||||
return Math.floor(s / 60) + ' 分钟前'
|
||||
})
|
||||
|
||||
const shortId = (id: string) => (id.length > 22 ? '…' + id.slice(-20) : id)
|
||||
|
||||
// copyNodeKey copies this node's admission key to the clipboard so the operator
|
||||
@ -260,6 +457,8 @@ const stateDotClass = computed(() => {
|
||||
|
||||
// ---- last-sync freshness (ops cares "环还活着吗", not the cycle counter) ----
|
||||
const now = ref(Date.now())
|
||||
// nowMs mirrors `now` as a plain number for the balance section's minute math.
|
||||
const nowMs = computed(() => now.value)
|
||||
let tick: number | null = null
|
||||
const lastSyncAgo = computed(() => {
|
||||
const ls = ring.value?.lastSync ?? 0
|
||||
@ -638,4 +837,66 @@ onBeforeUnmount(() => {
|
||||
.kpis { grid-template-columns: minmax(0, 1fr); }
|
||||
.page-title { font-size: 15.5px; }
|
||||
}
|
||||
|
||||
/* ---- Load balancer section ---- */
|
||||
.bal-section .section-title { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.bal-section .section-title .k-ic { margin-right: 0; }
|
||||
|
||||
.bal-thresholds {
|
||||
display: flex; flex-wrap: wrap; gap: 6px 14px;
|
||||
font-size: 11.5px; color: var(--w4f-muted);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.bal-hint { color: var(--w4f-warning); }
|
||||
.bal-hint code {
|
||||
font-family: var(--w4f-mono, ui-monospace, monospace);
|
||||
background: var(--w4f-card-2); padding: 1px 4px; border-radius: 3px;
|
||||
}
|
||||
|
||||
.bal-nodes {
|
||||
display: grid; gap: 8px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
}
|
||||
.bal-node {
|
||||
border: 1px solid var(--w4f-line);
|
||||
border-radius: 8px;
|
||||
padding: 9px 11px;
|
||||
background: var(--w4f-card-2);
|
||||
min-width: 0;
|
||||
}
|
||||
.bal-node.self { border-color: var(--w4f-primary); }
|
||||
.bal-node.over { border-color: var(--w4f-warning); }
|
||||
.bal-mode { width: 168px; }
|
||||
.bal-src { font-size: 11px; color: var(--w4f-muted); }
|
||||
.bal-note {
|
||||
margin: 0 0 10px; font-size: 11.5px; line-height: 1.55;
|
||||
color: var(--w4f-muted);
|
||||
}
|
||||
.bn-top { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 7px; }
|
||||
.bn-id {
|
||||
font-family: var(--w4f-mono, ui-monospace, monospace);
|
||||
font-size: 12px; font-weight: 700;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.bn-stats {
|
||||
display: flex; flex-wrap: wrap; gap: 3px 12px;
|
||||
font-size: 11.5px; color: var(--w4f-muted); margin-top: 7px;
|
||||
}
|
||||
.bn-stats b { color: var(--w4f-text); font-variant-numeric: tabular-nums; }
|
||||
.bn-score { margin-left: auto; }
|
||||
|
||||
.bal-plan { margin-top: 11px; }
|
||||
.bp-inflight, .bp-would, .bp-waiting, .bp-ok {
|
||||
display: flex; align-items: center; gap: 9px; flex-wrap: wrap;
|
||||
padding: 8px 11px; border-radius: 8px;
|
||||
font-size: 12.5px;
|
||||
background: var(--w4f-card-2);
|
||||
border: 1px solid var(--w4f-line);
|
||||
}
|
||||
.bp-inflight { border-color: var(--w4f-warning); }
|
||||
.bp-text { min-width: 0; overflow-wrap: anywhere; }
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.bn-score { margin-left: 0; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user