mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-10-02 15:14:01 +00:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 918ca5d5ba | |||
| c8ab2584a8 | |||
| 8ab52b238a | |||
| 5cdc052d01 | |||
| 1c835425de | |||
| 041cc04dd6 | |||
| 46e8bc3703 | |||
| f4fb964734 | |||
| 64d3359981 | |||
| 0559cc55bd | |||
| 609f0df517 | |||
| f9f18ae13c | |||
| f935cad33f | |||
| f9c2e9476a | |||
| 15b1dcce8f | |||
| 6ee901dc64 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -33,3 +33,6 @@ gui-test-screenshots/
|
|||||||
# vendored deps (regenerable; keep out of git)
|
# vendored deps (regenerable; keep out of git)
|
||||||
vendor/
|
vendor/
|
||||||
.pi-glla/
|
.pi-glla/
|
||||||
|
|
||||||
|
# 发布产物(由 scripts/release.sh 生成)
|
||||||
|
dist/
|
||||||
|
|||||||
66
README.md
66
README.md
@ -72,8 +72,74 @@ rm -rf internal/httpapi/dist && cp -r web/dist internal/httpapi/dist
|
|||||||
cd web && npm run build && rm -rf internal/httpapi/dist && cp -r web/dist internal/httpapi/dist && go build -o webui4frpc ./cmd/webui4frpc
|
cd web && npm run build && rm -rf internal/httpapi/dist && cp -r web/dist internal/httpapi/dist && go build -o webui4frpc ./cmd/webui4frpc
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
从 [Release 页](https://gitcode.com/JianFeeeee/webui4frpc/releases/latest) 下载对应平台的安装包。
|
||||||
|
|
||||||
|
### Linux (Debian / Ubuntu)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo dpkg -i webui4frpc-0.1.2-linux-amd64.deb # 或 linux-arm64.deb
|
||||||
|
sudo nano /etc/webui4frpc/webui4frpc.env # 改掉默认密码
|
||||||
|
sudo systemctl restart webui4frpc
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux (RHEL / Fedora / openEuler / 默认 / 龙蜥)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo rpm -i webui4frpc-0.1.2-linux-x86_64.rpm
|
||||||
|
sudo vi /etc/webui4frpc/webui4frpc.env
|
||||||
|
sudo systemctl enable --now webui4frpc
|
||||||
|
```
|
||||||
|
|
||||||
|
安装后自动建 `webui4frpc` 系统账号、注册 systemd 单元,数据目录在 `/var/lib/webui4frpc`。
|
||||||
|
服务以非 root 运行,并启用 `ProtectSystem=full` / `NoNewPrivileges` 等加固项。
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
双击 `webui4frpc-0.1.2-windows-amd64-setup.exe`(ARM 设备用 `windows-arm64-setup.exe`)。
|
||||||
|
安装向导会询问监听地址与管理员凭据,完成后:
|
||||||
|
|
||||||
|
- 程序装到 `C:\Program Files\webui4frpc`
|
||||||
|
- 开始菜单生成启动快捷方式与卸载项
|
||||||
|
- 自动添加防火墙入站规则
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo installer -pkg webui4frpc-0.1.2-darwin-arm64.pkg -target / # Apple Silicon
|
||||||
|
sudo installer -pkg webui4frpc-0.1.2-darwin-amd64.pkg -target / # Intel
|
||||||
|
```
|
||||||
|
|
||||||
|
或直接双击 `.pkg` 走图形安装器。安装后 launchd 开机自启,配置在
|
||||||
|
`/usr/local/etc/webui4frpc/webui4frpc.env`,改完执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo launchctl kickstart -k system/com.jianfeeeee.webui4frpc
|
||||||
|
```
|
||||||
|
|
||||||
|
> 该 `.pkg` 未经 Apple 公证(notarization)。首次打开若被 Gatekeeper 拦下,
|
||||||
|
> 在「系统设置 → 隐私与安全」选择仍要打开。
|
||||||
|
|
||||||
|
### 免安装(tar.gz / zip)
|
||||||
|
|
||||||
|
不想装服务只想跑一下,用对应的 `.tar.gz` / `.zip`,解压即用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tar xzf webui4frpc-0.1.2-linux-amd64.tar.gz
|
||||||
|
./webui4frpc -addr 127.0.0.1:7500 -user admin -password 你的密码 -workdir ./data
|
||||||
|
```
|
||||||
|
|
||||||
|
下载后可用 `SHA256SUMS` 校验完整性:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sha256sum -c SHA256SUMS --ignore-missing
|
||||||
|
```
|
||||||
|
|
||||||
## 运行
|
## 运行
|
||||||
|
|
||||||
|
从源码构建后直接跑:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./webui4frpc -addr 127.0.0.1:7500 -user admin -password admin123 -workdir ./data
|
./webui4frpc -addr 127.0.0.1:7500 -user admin -password admin123 -workdir ./data
|
||||||
```
|
```
|
||||||
|
|||||||
@ -25,6 +25,7 @@ import (
|
|||||||
"webui4frpc/internal/process"
|
"webui4frpc/internal/process"
|
||||||
"webui4frpc/internal/render"
|
"webui4frpc/internal/render"
|
||||||
"webui4frpc/internal/store"
|
"webui4frpc/internal/store"
|
||||||
|
"webui4frpc/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@ -132,7 +133,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
var ring *cluster.Engine
|
var ring *cluster.Engine
|
||||||
ring = cluster.NewEngine(
|
ring = cluster.NewEngine(
|
||||||
selfID, selfID, *user, *pass, "0.1.0", nil,
|
selfID, selfID, *user, *pass, version.Version, nil,
|
||||||
&cluster.AppHandler{
|
&cluster.AppHandler{
|
||||||
LoadFn: func() (float64, float64) {
|
LoadFn: func() (float64, float64) {
|
||||||
// NetPct: real NIC saturation from /proc/net/dev deltas vs
|
// NetPct: real NIC saturation from /proc/net/dev deltas vs
|
||||||
@ -174,13 +175,41 @@ func main() {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// A re-claim after a forward-centric stop leaves the link flagged
|
// A re-claim after a forward-centric stop leaves the link flagged
|
||||||
// disabled (by RevokeFn); clear it so renderRemote renders the
|
// disabled (by RevokeFn). Do NOT clear that flag here: the ring
|
||||||
// proxy back in. No-op for a fresh claim.
|
// re-claims automatically (startup reconcile re-spawns any owned
|
||||||
_ = st.SetLinkDisabled(tk.Local.Name, tk.Remote.Name, tk.Link.RemotePort, false)
|
// forward whose worker is missing), so clearing it on claim made
|
||||||
|
// "stopped" un-durable — every restart resurrected a forward the user
|
||||||
|
// had explicitly stopped, and it then failed forever against a local
|
||||||
|
// service that was intentionally not running.
|
||||||
|
//
|
||||||
|
// The flag is now owned by the two user-facing actions:
|
||||||
|
// startForward → SetLinkDisabled(false) before submitting the task
|
||||||
|
// stopForward → SetLinkDisabled(true) before revoking it
|
||||||
|
// Claim only READS it to decide whether to bring the worker up. A fresh
|
||||||
|
// claim of a link with no row still starts, because the lookup miss
|
||||||
|
// below is treated as "not disabled".
|
||||||
|
disabled := false
|
||||||
|
if existing, found, err := st.LinkByTriple(tk.Link.Local, tk.Link.Remote, tk.Link.RemotePort); err != nil {
|
||||||
|
return err
|
||||||
|
} else if found {
|
||||||
|
disabled = existing.Disabled
|
||||||
|
}
|
||||||
// Start (or restart) the per-forward worker for exactly this link.
|
// Start (or restart) the per-forward worker for exactly this link.
|
||||||
// Each forward has its own frpc process (keyed by the forward
|
// Each forward has its own frpc process (keyed by the forward
|
||||||
// triple); restarting only this key leaves sibling forwards'
|
// triple); restarting only this key leaves sibling forwards'
|
||||||
// processes untouched.
|
// processes untouched.
|
||||||
|
if disabled {
|
||||||
|
// Mark the entry stopped rather than deleting it. The entry is the
|
||||||
|
// carrier that keeps the flag travelling around the ring, and
|
||||||
|
// UpdateTopologyDisabled also clears Active — which is what makes
|
||||||
|
// this entry inert for OfflineReassign() and the startup
|
||||||
|
// reconcile, so it is not resurrected later.
|
||||||
|
if ring != nil {
|
||||||
|
ring.UpdateTopologyDisabled(tk.Local.Name, tk.Remote.Name, tk.Link.RemotePort, true)
|
||||||
|
}
|
||||||
|
log.Printf("ring[%s] claim %s skipped: %s→%s:%d is disabled", selfID, tk.ID, tk.Local.Name, tk.Remote.Name, tk.Link.RemotePort)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if tk.Remote.Enabled {
|
if tk.Remote.Enabled {
|
||||||
key := process.WorkerKey(tk.Local.Name, tk.Remote.Name, tk.Link.RemotePort)
|
key := process.WorkerKey(tk.Local.Name, tk.Remote.Name, tk.Link.RemotePort)
|
||||||
if _, has := pm.Status(key); has {
|
if _, has := pm.Status(key); has {
|
||||||
@ -200,13 +229,103 @@ func main() {
|
|||||||
// stop/start: it wiped sibling forwards sharing the local or remote.
|
// stop/start: it wiped sibling forwards sharing the local or remote.
|
||||||
RevokeFn: func(ctx context.Context, tk *cluster.Task) error {
|
RevokeFn: func(ctx context.Context, tk *cluster.Task) error {
|
||||||
_ = st.SetLinkDisabled(tk.Local.Name, tk.Remote.Name, tk.Link.RemotePort, true)
|
_ = st.SetLinkDisabled(tk.Local.Name, tk.Remote.Name, tk.Link.RemotePort, true)
|
||||||
|
// The links table is node-local: only the node that received the
|
||||||
|
// stop request has the flag persisted, while the node that OWNS the
|
||||||
|
// forward is the one holding the worker (and is often a different
|
||||||
|
// machine). Trusting only the local row meant the owner's copy could
|
||||||
|
// still read disabled=false. The task now carries the flag, so use it
|
||||||
|
// as the authority and write it here.
|
||||||
|
ln, found, err := st.LinkByTriple(tk.Local.Name, tk.Remote.Name, tk.Link.RemotePort)
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
return err
|
||||||
|
case found:
|
||||||
|
ln.Disabled = true
|
||||||
|
// Persist via the same rewrite path the canvas uses, so the flag
|
||||||
|
// survives a restart on THIS node too.
|
||||||
|
links, err := st.ListLinks()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := range links {
|
||||||
|
if links[i].Local == ln.Local && links[i].Remote == ln.Remote && links[i].RemotePort == ln.RemotePort {
|
||||||
|
links[i].Disabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := st.ReplaceLinks(links); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case tk.Link.Disabled:
|
||||||
|
// No local row yet (this node never claimed the forward) but the
|
||||||
|
// task asserts the stop — record it so a later claim on this node
|
||||||
|
// cannot resurrect the forward.
|
||||||
|
loc, rem := tk.Local, tk.Remote
|
||||||
|
links, err := st.ListLinks()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
links = append(links, store.Link{
|
||||||
|
Local: loc.Name, Remote: rem.Name, RemotePort: tk.Link.RemotePort,
|
||||||
|
OffsetX: tk.Link.OffsetX, OffsetY: tk.Link.OffsetY,
|
||||||
|
Group: tk.Link.Group, Disabled: true,
|
||||||
|
})
|
||||||
|
if err := st.ReplaceLinks(links); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
key := process.WorkerKey(tk.Local.Name, tk.Remote.Name, tk.Link.RemotePort)
|
key := process.WorkerKey(tk.Local.Name, tk.Remote.Name, tk.Link.RemotePort)
|
||||||
if _, running := pm.Status(key); running {
|
if _, running := pm.Status(key); running {
|
||||||
_ = pm.Stop(key)
|
_ = pm.Stop(key)
|
||||||
}
|
}
|
||||||
|
// Mark the entry stopped instead of deleting it: the entry carries
|
||||||
|
// the flag around the ring, and clearing Active keeps it inert for
|
||||||
|
// the rebalancing paths (OfflineReassign skips inactive entries, and
|
||||||
|
// the startup reconcile skips disabled forwards), so the worker is
|
||||||
|
// not re-spawned on the next restart. Deleting it here is what used
|
||||||
|
// to leave the stop with no carrier at all.
|
||||||
|
if ring != nil {
|
||||||
|
ring.UpdateTopologyDisabled(tk.Local.Name, tk.Remote.Name, tk.Link.RemotePort, true)
|
||||||
|
}
|
||||||
log.Printf("ring[%s] revoked task %s: %s→%s:%d", selfID, tk.ID, tk.Local.Name, tk.Remote.Name, tk.Link.RemotePort)
|
log.Printf("ring[%s] revoked task %s: %s→%s:%d", selfID, tk.ID, tk.Local.Name, tk.Remote.Name, tk.Link.RemotePort)
|
||||||
return nil
|
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
|
||||||
|
// deliberately keeps its topology entry, so all that is needed is to
|
||||||
|
// clear the flag and respawn the worker. The engine has already
|
||||||
|
// re-marked the topology entry enabled before calling this.
|
||||||
|
RestartFn: func(ctx context.Context, tk *cluster.Task) error {
|
||||||
|
// Clear the stop on this node's own store, using the same path the
|
||||||
|
// start handler uses, so the local copy cannot disagree with the
|
||||||
|
// ring and block a later restart.
|
||||||
|
if ln, found, err := st.LinkByTriple(tk.Link.Local, tk.Link.Remote, tk.Link.RemotePort); err != nil {
|
||||||
|
return err
|
||||||
|
} else if found && ln.Disabled {
|
||||||
|
links, err := st.ListLinks()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := range links {
|
||||||
|
if links[i].Local == ln.Local && links[i].Remote == ln.Remote && links[i].RemotePort == ln.RemotePort {
|
||||||
|
links[i].Disabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := st.ReplaceLinks(links); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tk.Remote.Enabled {
|
||||||
|
key := process.WorkerKey(tk.Local.Name, tk.Remote.Name, tk.Link.RemotePort)
|
||||||
|
if _, has := pm.Status(key); has {
|
||||||
|
_ = pm.Restart(key)
|
||||||
|
} else {
|
||||||
|
_ = pm.Start(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("ring[%s] restarted %s: %s→%s:%d", selfID, tk.ID, tk.Local.Name, tk.Remote.Name, tk.Link.RemotePort)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
},
|
},
|
||||||
ringTransport.SendTo(func(nodeID string) string {
|
ringTransport.SendTo(func(nodeID string) string {
|
||||||
if ring == nil {
|
if ring == nil {
|
||||||
@ -231,10 +350,24 @@ func main() {
|
|||||||
// left does NOT auto-rejoin.
|
// left does NOT auto-rejoin.
|
||||||
ring.SetPeerPersist(func(peersJSON string) error { return st.SetClusterPeers(peersJSON) })
|
ring.SetPeerPersist(func(peersJSON string) error { return st.SetClusterPeers(peersJSON) })
|
||||||
|
|
||||||
// Re-apply local store group labels onto the ring topology after each
|
// Re-apply local store state onto the ring topology after each state
|
||||||
// state adoption. Without this, group changes made via HTTP handlers
|
// adoption, and learn the ring's disabled decisions back into the store.
|
||||||
|
//
|
||||||
|
// Without the group half, group changes made via HTTP handlers
|
||||||
// (POST /forwards/assign) are overwritten by the next e.state = tk.State
|
// (POST /forwards/assign) are overwritten by the next e.state = tk.State
|
||||||
// and never propagate to other nodes.
|
// and never propagate.
|
||||||
|
//
|
||||||
|
// The disabled half makes the RING authoritative for stop/start. That is the
|
||||||
|
// only source all members can agree on: the links table is a per-node copy, so
|
||||||
|
// treating it as authoritative let two nodes disagree indefinitely (observed
|
||||||
|
// live: one node reading disabled=1 while the owner still read 0 and kept its
|
||||||
|
// worker running). This is a write-through, not a listener — it runs on every
|
||||||
|
// adoption, so any peer's decision lands here within a token cycle.
|
||||||
|
//
|
||||||
|
// Ordering matters in one direction only: the engine has already re-applied
|
||||||
|
// this node's not-yet-confirmed local decisions onto the adopted state (see
|
||||||
|
// reconcileLocalDisabled), so reading the ring here can never clobber an
|
||||||
|
// action the user just took on this node.
|
||||||
ring.SetTopologySync(func() {
|
ring.SetTopologySync(func() {
|
||||||
links, err := st.ListLinks()
|
links, err := st.ListLinks()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -243,6 +376,16 @@ func main() {
|
|||||||
for _, ln := range links {
|
for _, ln := range links {
|
||||||
ring.UpdateTopologyGroup(ln.Local, ln.Remote, ln.RemotePort, ln.Group)
|
ring.UpdateTopologyGroup(ln.Local, ln.Remote, ln.RemotePort, ln.Group)
|
||||||
}
|
}
|
||||||
|
for _, te := range ring.State().TopologyList() {
|
||||||
|
disabled, known := ring.TopologyDisabled(te.Local.Name, te.Remote.Name, te.Link.RemotePort)
|
||||||
|
if !known {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ln, found, err := st.LinkByTriple(te.Local.Name, te.Remote.Name, te.Link.RemotePort); err == nil && found && ln.Disabled == disabled {
|
||||||
|
continue // store already agrees with the ring
|
||||||
|
}
|
||||||
|
_ = st.ReconcileLinkDisabled(te.Local.Name, te.Remote.Name, te.Link.RemotePort, disabled)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
h := &httpapi.Handler{
|
h := &httpapi.Handler{
|
||||||
@ -436,6 +579,16 @@ func main() {
|
|||||||
if t.OwnerID != selfID {
|
if t.OwnerID != selfID {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// A forward the user stopped must stay stopped: skip it here so a
|
||||||
|
// restart does not re-spawn its worker. The ClaimFn enforces the
|
||||||
|
// same rule (belt and braces — this also avoids a pointless
|
||||||
|
// claim→skip round trip per disabled forward on every boot).
|
||||||
|
if ln, found, err := st.LinkByTriple(t.Local.Name, t.Remote.Name, t.Link.RemotePort); err != nil {
|
||||||
|
log.Printf("ring[%s] reconcile: lookup %s→%s:%d: %v", ring.ID, t.Local.Name, t.Remote.Name, t.Link.RemotePort, err)
|
||||||
|
continue
|
||||||
|
} else if found && ln.Disabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
key := process.WorkerKey(t.Local.Name, t.Remote.Name, t.Link.RemotePort)
|
key := process.WorkerKey(t.Local.Name, t.Remote.Name, t.Link.RemotePort)
|
||||||
if _, has := pm.Status(key); has {
|
if _, has := pm.Status(key); has {
|
||||||
continue // worker already running
|
continue // worker already running
|
||||||
@ -533,7 +686,7 @@ func main() {
|
|||||||
// CreateCluster so the node is at least functional as standalone.
|
// CreateCluster so the node is at least functional as standalone.
|
||||||
func retryJoinCluster(ctx context.Context, ring *cluster.Engine, targetAddr, joinKey string) {
|
func retryJoinCluster(ctx context.Context, ring *cluster.Engine, targetAddr, joinKey string) {
|
||||||
selfNode := ring.State().Nodes[0]
|
selfNode := ring.State().Nodes[0]
|
||||||
ji := cluster.JoinInfo{ID: selfNode.ID, Addr: selfNode.Addr, Version: "0.1.0", JoinKey: joinKey}
|
ji := cluster.JoinInfo{ID: selfNode.ID, Addr: selfNode.Addr, Version: version.Version, JoinKey: joinKey}
|
||||||
deadline := time.NewTimer(5 * time.Minute)
|
deadline := time.NewTimer(5 * time.Minute)
|
||||||
defer deadline.Stop()
|
defer deadline.Stop()
|
||||||
ticker := time.NewTicker(10 * time.Second)
|
ticker := time.NewTicker(10 * time.Second)
|
||||||
@ -593,7 +746,7 @@ func retryRejoinCached(ctx context.Context, ring *cluster.Engine, peersJSON stri
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, p := range peers {
|
for _, p := range peers {
|
||||||
ji := cluster.JoinInfo{ID: selfNode.ID, Addr: selfNode.Addr, Version: "0.1.0", JoinKey: p.Key}
|
ji := cluster.JoinInfo{ID: selfNode.ID, Addr: selfNode.Addr, Version: version.Version, JoinKey: p.Key}
|
||||||
jc, cancel := context.WithTimeout(ctx, 8*time.Second)
|
jc, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||||||
err := ring.JoinRing(jc, p.Addr, ji)
|
err := ring.JoinRing(jc, p.Addr, ji)
|
||||||
cancel()
|
cancel()
|
||||||
|
|||||||
91
internal/cluster/debug.go
Normal file
91
internal/cluster/debug.go
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
package cluster
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"sync/atomic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// logf is the package's logging seam: every cluster log line funnels through it
|
||||||
|
// so the debug gate in debug.go has a single place to hook.
|
||||||
|
func logf(format string, args ...any) { log.Printf(format, args...) }
|
||||||
|
|
||||||
|
// Token rotation is the ring's heartbeat: with a 2s round delay it fires
|
||||||
|
// continuously, and the default three log lines per round
|
||||||
|
// ("OnToken cycle=N" / "forward cycle=N to X" / "token-send ... -> 200")
|
||||||
|
// carry no information — no cycle number, address, timing or payload ever
|
||||||
|
// changes in the steady state. Measured on the live 3-node cluster that was
|
||||||
|
// ~510k lines/day on one node (3.5M lines in a week), which drowns every real
|
||||||
|
// event in the journal and fills the disk for a signal that is already
|
||||||
|
// available in structured form via GET /api/manager/cluster/ring (cycle,
|
||||||
|
// lastSync, roundDelayMs, node aliveness).
|
||||||
|
//
|
||||||
|
// So the steady-state lines are demoted to a debug level, off by default and
|
||||||
|
// enabled with W4F_DEBUG=token (or 1/all/true for every debug line). Failure
|
||||||
|
// paths are NOT demoted: a send error, a stale token, a timeout or a leader
|
||||||
|
// change is exactly what someone is grepping for, and losing those to a quiet
|
||||||
|
// default would be a bad trade.
|
||||||
|
const debugEnv = "W4F_DEBUG"
|
||||||
|
|
||||||
|
// debugToken logs a per-token-round heartbeat line. Suppressed unless
|
||||||
|
// W4F_DEBUG selects "token" (or a catch-all value).
|
||||||
|
func debugToken(format string, args ...any) {
|
||||||
|
if debugTokenOn.Load() {
|
||||||
|
logf(format, args...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// debugAll logs an ad-hoc diagnostic line. Suppressed unless W4F_DEBUG is set
|
||||||
|
// to a catch-all value (1/all/true/*).
|
||||||
|
func debugAll(format string, args ...any) {
|
||||||
|
if debugAllOn.Load() {
|
||||||
|
logf(format, args...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
debugTokenOn atomic.Bool
|
||||||
|
debugAllOn atomic.Bool
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() { ReloadDebug() }
|
||||||
|
|
||||||
|
// ReloadDebug re-reads W4F_DEBUG. Called once at init so tests can flip it
|
||||||
|
// without restarting, and available at runtime for an operator who wants to
|
||||||
|
// watch the ring without a redeploy.
|
||||||
|
func ReloadDebug() {
|
||||||
|
v := os.Getenv(debugEnv)
|
||||||
|
switch normalized := normalizeDebugValue(v); normalized {
|
||||||
|
case "token":
|
||||||
|
debugTokenOn.Store(true)
|
||||||
|
debugAllOn.Store(false)
|
||||||
|
case "all":
|
||||||
|
debugTokenOn.Store(true)
|
||||||
|
debugAllOn.Store(true)
|
||||||
|
default:
|
||||||
|
debugTokenOn.Store(false)
|
||||||
|
debugAllOn.Store(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeDebugValue(v string) string {
|
||||||
|
// Compare case-insensitively without pulling in strings just for this.
|
||||||
|
out := make([]rune, 0, len(v))
|
||||||
|
for _, r := range v {
|
||||||
|
if r >= 'A' && r <= 'Z' {
|
||||||
|
r += 'a' - 'A'
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
s := string(out)
|
||||||
|
switch s {
|
||||||
|
case "":
|
||||||
|
return ""
|
||||||
|
case "token", "tokens", "ring":
|
||||||
|
return "token"
|
||||||
|
}
|
||||||
|
// Any other non-empty value is a deliberate request for more output, so it
|
||||||
|
// is treated as a catch-all rather than silently muting the operator who
|
||||||
|
// set it. "0" lands here too: it was asked for, so honour it.
|
||||||
|
return "all"
|
||||||
|
}
|
||||||
108
internal/cluster/debug_test.go
Normal file
108
internal/cluster/debug_test.go
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
package cluster
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// captureLog redirects the standard logger into a buffer for the duration of
|
||||||
|
// fn and returns what was written.
|
||||||
|
func captureLog(t *testing.T, fn func()) string {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
orig := log.Writer()
|
||||||
|
origFlags := log.Flags()
|
||||||
|
log.SetOutput(&buf)
|
||||||
|
log.SetFlags(0)
|
||||||
|
defer func() {
|
||||||
|
log.SetOutput(orig)
|
||||||
|
log.SetFlags(origFlags)
|
||||||
|
}()
|
||||||
|
fn()
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDebugTokenSuppressedByDefault(t *testing.T) {
|
||||||
|
os.Unsetenv(debugEnv)
|
||||||
|
ReloadDebug()
|
||||||
|
|
||||||
|
out := captureLog(t, func() {
|
||||||
|
debugToken("ring[%s] OnToken cycle=%d", "node:7500", 42)
|
||||||
|
debugToken("ring[%s] forward cycle=%d to %s", "node:7500", 42, "next:7500")
|
||||||
|
debugToken("token-send %s: size=%dB elapsed=%v -> %d", "next:7500", 5605, "170ms", 200)
|
||||||
|
})
|
||||||
|
if out != "" {
|
||||||
|
t.Fatalf("steady-state token lines logged with W4F_DEBUG unset: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDebugTokenEnabledByEnv(t *testing.T) {
|
||||||
|
for _, v := range []string{"token", "TOKEN", "tokens", "ring", "1", "true", "yes", "all", "*", "yes-please"} {
|
||||||
|
t.Run(v, func(t *testing.T) {
|
||||||
|
t.Setenv(debugEnv, v)
|
||||||
|
ReloadDebug()
|
||||||
|
if !debugTokenOn.Load() {
|
||||||
|
t.Fatalf("W4F_DEBUG=%q should enable the token heartbeat", v)
|
||||||
|
}
|
||||||
|
out := captureLog(t, func() {
|
||||||
|
debugToken("ring[%s] OnToken cycle=%d", "node:7500", 7)
|
||||||
|
})
|
||||||
|
if !strings.Contains(out, "OnToken cycle=7") {
|
||||||
|
t.Fatalf("W4F_DEBUG=%q: expected the line to be logged, got %q", v, out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole point of the gate is to shrink the journal, so the failure paths
|
||||||
|
// must stay loud without any env var — losing a send error to a quiet default
|
||||||
|
// would be a bad trade.
|
||||||
|
func TestFailurePathsStayLoud(t *testing.T) {
|
||||||
|
os.Unsetenv(debugEnv)
|
||||||
|
ReloadDebug()
|
||||||
|
|
||||||
|
out := captureLog(t, func() {
|
||||||
|
logf("token-send %s: size=%dB elapsed=%v err=%v", "down:7500", 10, "5ms", "connection refused")
|
||||||
|
})
|
||||||
|
if !strings.Contains(out, "connection refused") {
|
||||||
|
t.Fatalf("send errors must never be gated: got %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDebugAllOffForTokenOnly(t *testing.T) {
|
||||||
|
t.Setenv(debugEnv, "token")
|
||||||
|
ReloadDebug()
|
||||||
|
if !debugTokenOn.Load() {
|
||||||
|
t.Fatal("token level should enable debugToken")
|
||||||
|
}
|
||||||
|
if debugAllOn.Load() {
|
||||||
|
t.Fatal("token level must not enable the catch-all debugAll")
|
||||||
|
}
|
||||||
|
out := captureLog(t, func() { debugAll("scratch diagnostic") })
|
||||||
|
if out != "" {
|
||||||
|
t.Fatalf("debugAll should stay off at token level, got %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeDebugValue(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"": "",
|
||||||
|
"token": "token",
|
||||||
|
"TOKEN": "token",
|
||||||
|
"Ring": "token",
|
||||||
|
"1": "all",
|
||||||
|
"true": "all",
|
||||||
|
"ALL": "all",
|
||||||
|
"*": "all",
|
||||||
|
"anything": "all", // unrecognised but deliberate → don't silence it
|
||||||
|
"0": "all", // 0 is still a deliberate request for output
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := normalizeDebugValue(in); got != want {
|
||||||
|
t.Errorf("normalizeDebugValue(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -62,6 +62,12 @@ type Task struct {
|
|||||||
// owning node cancels it (stop worker, drop from topology). Reuses the
|
// owning node cancels it (stop worker, drop from topology). Reuses the
|
||||||
// same publish channel as creation (round-1 inject, round-2 apply).
|
// same publish channel as creation (round-1 inject, round-2 apply).
|
||||||
Revoke bool `json:"revoke,omitempty"`
|
Revoke bool `json:"revoke,omitempty"`
|
||||||
|
// Restart marks a RE-ENABLE task: the forward is already claimed and its
|
||||||
|
// topology entry still exists (a stop keeps the entry as the flag's
|
||||||
|
// carrier), so the creation path would dedupe the submission and the
|
||||||
|
// 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"`
|
||||||
// RemoveNode: node ID to remove from the ring; the node self-removes when
|
// RemoveNode: node ID to remove from the ring; the node self-removes when
|
||||||
// the command reaches it via the token.
|
// the command reaches it via the token.
|
||||||
RemoveNode string `json:"removeNode,omitempty"`
|
RemoveNode string `json:"removeNode,omitempty"`
|
||||||
@ -333,10 +339,15 @@ func (s *State) AddTopology(t *Task, ownerID string) *TopoEntry {
|
|||||||
if s.Topology == nil {
|
if s.Topology == nil {
|
||||||
s.Topology = map[string]*TopoEntry{}
|
s.Topology = map[string]*TopoEntry{}
|
||||||
}
|
}
|
||||||
|
// Active mirrors the task's disabled flag rather than being unconditionally
|
||||||
|
// true. A claim can legitimately carry a stopped forward (e.g. a node
|
||||||
|
// re-claiming from a departed peer), and forcing Active=true there would
|
||||||
|
// resurrect it: OfflineReassign only re-queues Active entries, and every
|
||||||
|
// reader that filters on Active would treat it as running again.
|
||||||
e := &TopoEntry{
|
e := &TopoEntry{
|
||||||
TaskID: t.ID, OwnerID: ownerID,
|
TaskID: t.ID, OwnerID: ownerID,
|
||||||
Local: t.Local, Remote: t.Remote, Link: t.Link,
|
Local: t.Local, Remote: t.Remote, Link: t.Link,
|
||||||
Active: true,
|
Active: !t.Link.Disabled,
|
||||||
}
|
}
|
||||||
s.Topology[t.ID] = e
|
s.Topology[t.ID] = e
|
||||||
return e
|
return e
|
||||||
@ -408,6 +419,39 @@ func (s *State) ForwardsOwnedBy(owner string) []*TopoEntry {
|
|||||||
// (local, remote, port) triple. Returns true if found. The updated entry
|
// (local, remote, port) triple. Returns true if found. The updated entry
|
||||||
// propagates to all nodes via the next token cycle — group changes sync
|
// propagates to all nodes via the next token cycle — group changes sync
|
||||||
// through the ring without a dedicated command.
|
// through the ring without a dedicated command.
|
||||||
|
// UpdateTopologyDisabled sets the disabled flag on the topology entry matching
|
||||||
|
// the forward's natural key. Mirror of UpdateTopologyGroup: it makes a locally
|
||||||
|
// decided stop/start part of the topology so the next token cycle carries it to
|
||||||
|
// every other member (the alternative — a one-shot revoke task addressed at the
|
||||||
|
// owner — only converges if that owner happens to be online right then).
|
||||||
|
//
|
||||||
|
// Returns false when the forward is not in the topology, which is not an error:
|
||||||
|
// a stopped forward may legitimately have no entry yet.
|
||||||
|
func (s *State) UpdateTopologyDisabled(local, remote string, port int, disabled bool) bool {
|
||||||
|
for _, e := range s.Topology {
|
||||||
|
if e.Local.Name == local && e.Remote.Name == remote && e.Link.RemotePort == port {
|
||||||
|
e.Link.Disabled = disabled
|
||||||
|
// Keep Active consistent with the flag so readers that key off it
|
||||||
|
// (status page, load accounting) agree with Link.Disabled.
|
||||||
|
e.Active = !disabled
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// TopologyDisabled returns the disabled flag the cluster currently holds for a
|
||||||
|
// forward, and whether such an entry exists. Used by the adoption path to learn
|
||||||
|
// a peer's decision into the local store.
|
||||||
|
func (s *State) TopologyDisabled(local, remote string, port int) (bool, bool) {
|
||||||
|
for _, e := range s.Topology {
|
||||||
|
if e.Local.Name == local && e.Remote.Name == remote && e.Link.RemotePort == port {
|
||||||
|
return e.Link.Disabled, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
|
||||||
func (s *State) UpdateTopologyGroup(local, remote string, port int, group string) bool {
|
func (s *State) UpdateTopologyGroup(local, remote string, port int, group string) bool {
|
||||||
for _, e := range s.Topology {
|
for _, e := range s.Topology {
|
||||||
if e.Local.Name == local && e.Remote.Name == remote && e.Link.RemotePort == port {
|
if e.Local.Name == local && e.Remote.Name == remote && e.Link.RemotePort == port {
|
||||||
|
|||||||
157
internal/cluster/ring_disabled_test.go
Normal file
157
internal/cluster/ring_disabled_test.go
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
package cluster
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"webui4frpc/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// staleTokenWith copies the engine's current state but rewrites the given
|
||||||
|
// forward's disabled flag, standing in for a token a neighbour captured before
|
||||||
|
// this node's decision.
|
||||||
|
func staleTokenWith(eng *Engine, local, remote string, port int, disabled bool) State {
|
||||||
|
s := eng.state
|
||||||
|
out := State{
|
||||||
|
LeaderID: s.LeaderID,
|
||||||
|
Nodes: s.Nodes,
|
||||||
|
Cycle: s.Cycle,
|
||||||
|
Topology: map[string]*TopoEntry{},
|
||||||
|
}
|
||||||
|
for id, e := range s.Topology {
|
||||||
|
cp := *e
|
||||||
|
if cp.Local.Name == local && cp.Remote.Name == remote && cp.Link.RemotePort == port {
|
||||||
|
cp.Link.Disabled = disabled
|
||||||
|
cp.Active = !disabled
|
||||||
|
}
|
||||||
|
out.Topology[id] = &cp
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLocalDisableSurvivesAdoption is the regression test for the wipe that made
|
||||||
|
// the ring's authority unachievable.
|
||||||
|
//
|
||||||
|
// OnToken/AdoptState replace e.state wholesale. A stop decided between two token
|
||||||
|
// cycles was therefore erased by the next adoption whenever the incoming token
|
||||||
|
// had been captured before the decision — so the flag never reached the other
|
||||||
|
// members and the forward kept running. Written as a probe BEFORE the fix: it
|
||||||
|
// reported "after adopting a stale token: known=true disabled=false".
|
||||||
|
func TestLocalDisableSurvivesAdoption(t *testing.T) {
|
||||||
|
eng := newTestEngine("n1", true)
|
||||||
|
eng.state.AddPending(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
|
||||||
|
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state, SentAt: 1}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local decision: stop it.
|
||||||
|
eng.UpdateTopologyDisabled("web", "frps1", 18081, true)
|
||||||
|
if d, _ := eng.TopologyDisabled("web", "frps1", 18081); !d {
|
||||||
|
t.Fatal("precondition: the local stop should be visible")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A token captured BEFORE the decision arrives.
|
||||||
|
stale := staleTokenWith(eng, "web", "frps1", 18081, false)
|
||||||
|
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 2, State: stale, SentAt: 2}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Assert on the STATE, not the TopologyDisabled() accessor: the accessor
|
||||||
|
// short-circuits to the pending local decision, so it would report the stop
|
||||||
|
// even when the state about to be forwarded still says "enabled". What
|
||||||
|
// matters is that the token this node sends on carries the decision.
|
||||||
|
assertForwardedDisabled(t, eng, "web", "frps1", 18081, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertForwardedDisabled checks the disabled value a DOWNSTREAM node would see
|
||||||
|
// in the token this engine forwards — the value that actually propagates.
|
||||||
|
func assertForwardedDisabled(t *testing.T, eng *Engine, local, remote string, port int, want bool) {
|
||||||
|
t.Helper()
|
||||||
|
// Model one hop: a fresh engine adopts exactly what this one would send.
|
||||||
|
peer := newTestEngine("peer", false)
|
||||||
|
peer.AdoptState(eng.state)
|
||||||
|
got, known := peer.TopologyDisabled(local, remote, port)
|
||||||
|
if !known {
|
||||||
|
t.Fatalf("peer learned no entry for %s→%s:%d", local, remote, port)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("the forwarded token carries disabled=%v, want %v — the decision did not "+
|
||||||
|
"propagate, so the other members would keep the old state", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLocalReEnableSurvivesAdoption: a re-enable needs the same protection. The
|
||||||
|
// value false is easy to overlook — nothing "looks" stopped, yet losing it just
|
||||||
|
// as silently strands the forward in the stopped state.
|
||||||
|
func TestLocalReEnableSurvivesAdoption(t *testing.T) {
|
||||||
|
eng := newTestEngine("n1", true)
|
||||||
|
eng.state.AddPending(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
|
||||||
|
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state, SentAt: 1}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
eng.UpdateTopologyDisabled("web", "frps1", 18081, true)
|
||||||
|
eng.UpdateTopologyDisabled("web", "frps1", 18081, false)
|
||||||
|
|
||||||
|
// A token that still says disabled (captured mid-stop) arrives.
|
||||||
|
mid := staleTokenWith(eng, "web", "frps1", 18081, true)
|
||||||
|
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 2, State: mid, SentAt: 2}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertForwardedDisabled(t, eng, "web", "frps1", 18081, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfirmedDecisionStopsBeingAsserted: once the ring reports the same value
|
||||||
|
// the decision is confirmed everywhere, so this node must stop overriding —
|
||||||
|
// otherwise it would fight a later decision made elsewhere.
|
||||||
|
func TestConfirmedDecisionStopsBeingAsserted(t *testing.T) {
|
||||||
|
eng := newTestEngine("n1", true)
|
||||||
|
eng.state.AddPending(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
|
||||||
|
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state, SentAt: 1}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
eng.UpdateTopologyDisabled("web", "frps1", 18081, true)
|
||||||
|
if len(eng.localDisabled) != 1 {
|
||||||
|
t.Fatalf("a pending decision should be tracked, got %v", eng.localDisabled)
|
||||||
|
}
|
||||||
|
// The ring comes back agreeing.
|
||||||
|
agreed := staleTokenWith(eng, "web", "frps1", 18081, true)
|
||||||
|
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 2, State: agreed, SentAt: 2}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(eng.localDisabled) != 0 {
|
||||||
|
t.Fatalf("a confirmed decision must stop being asserted, still tracking %v", eng.localDisabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A peer now decides the opposite; this node must follow the ring.
|
||||||
|
peerSaysEnabled := staleTokenWith(eng, "web", "frps1", 18081, false)
|
||||||
|
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 3, State: peerSaysEnabled, SentAt: 3}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if d, _ := eng.TopologyDisabled("web", "frps1", 18081); d {
|
||||||
|
t.Fatal("this node kept overriding a peer's later decision; the ring is not authoritative")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLocalDecisionsDoNotAccumulate: the tracking map is swept against the live
|
||||||
|
// topology, so a long-lived cluster cannot grow it without bound.
|
||||||
|
func TestLocalDecisionsDoNotAccumulate(t *testing.T) {
|
||||||
|
eng := newTestEngine("n1", true)
|
||||||
|
eng.state.AddPending(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
|
||||||
|
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state, SentAt: 1}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decisions for forwards this node has never seen.
|
||||||
|
eng.UpdateTopologyDisabled("ghost1", "frps1", 1, true)
|
||||||
|
eng.UpdateTopologyDisabled("ghost2", "frps1", 2, true)
|
||||||
|
if len(eng.localDisabled) != 2 {
|
||||||
|
t.Fatalf("expected 2 tracked decisions, got %d", len(eng.localDisabled))
|
||||||
|
}
|
||||||
|
// A cycle runs: neither ghost is in the topology, so both are swept.
|
||||||
|
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 2, State: eng.state, SentAt: 2}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(eng.localDisabled) != 0 {
|
||||||
|
t.Fatalf("decisions for forwards outside the topology must be swept, got %v", eng.localDisabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -18,6 +19,11 @@ import (
|
|||||||
type Handler interface {
|
type Handler interface {
|
||||||
Claim(ctx context.Context, tk *Task) error
|
Claim(ctx context.Context, tk *Task) error
|
||||||
Revoke(ctx context.Context, tk *Task) error
|
Revoke(ctx context.Context, tk *Task) error
|
||||||
|
// Restart re-enables an already-claimed forward and brings its worker back.
|
||||||
|
// Needed because a stop keeps the topology entry, which makes both the
|
||||||
|
// creation path (idempotency guard) and the duplicate-claim guard refuse to
|
||||||
|
// re-create an existing forward.
|
||||||
|
Restart(ctx context.Context, tk *Task) error
|
||||||
RuntimeLoad() Load
|
RuntimeLoad() Load
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,6 +56,24 @@ type Engine struct {
|
|||||||
// group changes made via HTTP handlers between token cycles are overwritten
|
// group changes made via HTTP handlers between token cycles are overwritten
|
||||||
// by the next state adoption and never propagate to other nodes.
|
// by the next state adoption and never propagate to other nodes.
|
||||||
topologySync func()
|
topologySync func()
|
||||||
|
// localDisabled records this node's own enabled/disabled decisions that have
|
||||||
|
// not yet been confirmed by the ring. It is what makes the RING authoritative
|
||||||
|
// without losing a decision that was made locally and has not yet had a
|
||||||
|
// chance to reach the other members.
|
||||||
|
//
|
||||||
|
// The problem it solves: OnToken/AdoptState do `e.state = tk.State`, a
|
||||||
|
// wholesale replacement. A stop decided between two token cycles therefore
|
||||||
|
// disappears on the very next adoption if that token was captured before the
|
||||||
|
// decision — the flag never reaches the rest of the ring, and the forward
|
||||||
|
// keeps running. (Verified with a probe before writing this: adopting a
|
||||||
|
// stale token flipped disabled back to false.)
|
||||||
|
//
|
||||||
|
// A key stays in this map until an adoption reports it disabled, at which
|
||||||
|
// point the whole cluster agrees and the key is dropped. Keys for forwards
|
||||||
|
// that vanish from the topology are swept on every update, so the map cannot
|
||||||
|
// grow without bound. The value false is what keeps a RE-ENABLE alive for the
|
||||||
|
// same reason a stop needs protecting.
|
||||||
|
localDisabled map[string]bool
|
||||||
|
|
||||||
state State
|
state State
|
||||||
// myAddr maps our Node ID to the address peers dial.
|
// myAddr maps our Node ID to the address peers dial.
|
||||||
@ -182,7 +206,9 @@ func (e *Engine) OnToken(ctx context.Context, tk *Token) (*Token, error) {
|
|||||||
if tk.SentAt > e.lastTokenAt {
|
if tk.SentAt > e.lastTokenAt {
|
||||||
e.lastTokenAt = tk.SentAt
|
e.lastTokenAt = tk.SentAt
|
||||||
}
|
}
|
||||||
log.Printf("ring[%s] OnToken cycle=%d", e.ID, tk.Cycle)
|
// Per-round heartbeat — steady state, no information. See debug.go: this
|
||||||
|
// fired ~2x/second and dominated the journal on every node.
|
||||||
|
debugToken("ring[%s] OnToken cycle=%d", e.ID, tk.Cycle)
|
||||||
|
|
||||||
// Parallel rhythm timer: operations run while the pace clock ticks.
|
// Parallel rhythm timer: operations run while the pace clock ticks.
|
||||||
// Delay scales with alive node count (more nodes → lower per-hop delay,
|
// Delay scales with alive node count (more nodes → lower per-hop delay,
|
||||||
@ -229,6 +255,11 @@ func (e *Engine) OnToken(ctx context.Context, tk *Token) (*Token, error) {
|
|||||||
// Re-apply local store overrides (group labels, disabled flags) onto
|
// Re-apply local store overrides (group labels, disabled flags) onto
|
||||||
// the freshly adopted topology so they survive state adoption and
|
// the freshly adopted topology so they survive state adoption and
|
||||||
// propagate to all nodes via the next token forward.
|
// propagate to all nodes via the next token forward.
|
||||||
|
// Re-assert decisions this node made locally that the ring has not yet
|
||||||
|
// confirmed, BEFORE the host's topology sync runs: the sync reads
|
||||||
|
// TopologyDisabled to learn the cluster's view, so the local decision must
|
||||||
|
// already be applied or a stop could be reported as "enabled" and dropped.
|
||||||
|
e.reconcileLocalDisabled()
|
||||||
if e.topologySync != nil {
|
if e.topologySync != nil {
|
||||||
e.topologySync()
|
e.topologySync()
|
||||||
}
|
}
|
||||||
@ -375,6 +406,24 @@ func (e *Engine) runCommands(ctx context.Context, tk *Token) error {
|
|||||||
}
|
}
|
||||||
continue // owned elsewhere — ride to the owner
|
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
|
||||||
|
// spawn a worker for somebody else's forward (observed live: a
|
||||||
|
// non-owner logged "restarted t4" and ran the forward itself).
|
||||||
|
//
|
||||||
|
// A restart whose forward is already gone is a no-op consumed by the
|
||||||
|
// lowest node, so a task can never ride forever if its owner
|
||||||
|
// departed before seeing it.
|
||||||
|
if owner := e.state.TopologyOwner(t); owner == e.ID {
|
||||||
|
target = t
|
||||||
|
break
|
||||||
|
} else if owner == "" && selfIsLowest {
|
||||||
|
target = t // forward gone — consume and re-enable nothing
|
||||||
|
break
|
||||||
|
}
|
||||||
|
continue // owned elsewhere — ride to the owner
|
||||||
|
}
|
||||||
if selfIsLowest {
|
if selfIsLowest {
|
||||||
target = t // generic forward create — lowest-load claim
|
target = t // generic forward create — lowest-load claim
|
||||||
break
|
break
|
||||||
@ -388,18 +437,37 @@ func (e *Engine) runCommands(ctx context.Context, tk *Token) error {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
if claimed.Revoke {
|
if claimed.Revoke {
|
||||||
if e.state.RemoveTopology(claimed.Local.Name, claimed.Remote.Name, claimed.Link.RemotePort) {
|
// A user-requested stop is recorded as a FLAG on the topology entry,
|
||||||
|
// not as a removal. The entry is the carrier that makes the decision
|
||||||
|
// travel: TopoEntry.Link is a full store.Link and State rides the
|
||||||
|
// token every cycle, so keeping the entry is what lets a stop reach
|
||||||
|
// every member — including a node that was offline when the stop was
|
||||||
|
// issued. Removing the entry instead left the flag nowhere to live,
|
||||||
|
// which is why a stop used to converge only if the owner happened to
|
||||||
|
// be online to receive a one-shot revoke task.
|
||||||
|
//
|
||||||
|
// Marking Active=false is also what makes the entry inert for the
|
||||||
|
// rebalancing paths: OfflineReassign() skips inactive entries, and
|
||||||
|
// the startup reconcile skips them too, so a stopped forward is
|
||||||
|
// neither re-queued on a node departure nor re-claimed on a restart.
|
||||||
|
//
|
||||||
|
// Handler.Revoke still runs unconditionally — it is what actually
|
||||||
|
// stops the per-forward worker, and it must run even when there was no
|
||||||
|
// entry to mark (gating it on RemoveTopology()'s result made stops of
|
||||||
|
// already-absent forwards silently do nothing, so their workers ran
|
||||||
|
// forever: observed live as thousands of connection-refused lines
|
||||||
|
// against a local service that was intentionally down).
|
||||||
|
marked := e.state.UpdateTopologyDisabled(claimed.Local.Name, claimed.Remote.Name, claimed.Link.RemotePort, true)
|
||||||
if e.Handler != nil {
|
if e.Handler != nil {
|
||||||
if err := e.Handler.Revoke(ctx, claimed); err != nil {
|
if err := e.Handler.Revoke(ctx, claimed); err != nil {
|
||||||
log.Printf("ring[%s] revoke %s: %v", e.ID, claimed.ID, err)
|
log.Printf("ring[%s] revoke %s: %v", e.ID, claimed.ID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if e.Log != nil {
|
if marked && e.Log != nil {
|
||||||
_, _ = e.Log.Append(e.ID, LogForwardRemove, map[string]any{
|
_, _ = e.Log.Append(e.ID, LogForwardStop, map[string]any{
|
||||||
"taskId": claimed.ID, "local": claimed.Local.Name, "remote": claimed.Remote.Name,
|
"taskId": claimed.ID, "local": claimed.Local.Name, "remote": claimed.Remote.Name,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if claimed.RemoveNode != "" && claimed.RemoveNode == e.ID {
|
if claimed.RemoveNode != "" && claimed.RemoveNode == e.ID {
|
||||||
@ -465,12 +533,46 @@ func (e *Engine) runCommands(ctx context.Context, tk *Token) error {
|
|||||||
// different node. Safe for OfflineReassign: that path deletes the
|
// different node. Safe for OfflineReassign: that path deletes the
|
||||||
// topology entry BEFORE re-queueing, so TopologyOwner returns "" and
|
// topology entry BEFORE re-queueing, so TopologyOwner returns "" and
|
||||||
// the legitimate re-claim passes through.
|
// the legitimate re-claim passes through.
|
||||||
|
//
|
||||||
|
// A RESTART task is the deliberate exception this guard must not eat: it
|
||||||
|
// 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 {
|
||||||
if owner := e.state.TopologyOwner(claimed); owner != "" {
|
if owner := e.state.TopologyOwner(claimed); owner != "" {
|
||||||
log.Printf("ring[%s] drop duplicate task %s: %s→%s:%d already owned by %s",
|
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,
|
e.ID, claimed.ID, claimed.Local.Name, claimed.Remote.Name,
|
||||||
claimed.Link.RemotePort, owner)
|
claimed.Link.RemotePort, owner)
|
||||||
continue
|
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).
|
||||||
|
owner := e.state.TopologyOwner(claimed)
|
||||||
|
e.state.UpdateTopologyDisabled(claimed.Local.Name, claimed.Remote.Name, claimed.Link.RemotePort, false)
|
||||||
|
if owner != e.ID {
|
||||||
|
// Forward vanished before we got here: nothing to re-enable.
|
||||||
|
log.Printf("ring[%s] discard restart %s: no owner", e.ID, claimed.ID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if e.Handler != nil {
|
||||||
|
if err := e.Handler.Restart(ctx, claimed); err != nil {
|
||||||
|
e.state.PendingTasks[claimed.ID] = claimed
|
||||||
|
log.Printf("ring[%s] restart %s failed: %v", e.ID, claimed.ID, err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e.Log != nil {
|
||||||
|
_, _ = e.Log.Append(e.ID, LogForwardStart, map[string]any{
|
||||||
|
"taskId": claimed.ID, "local": claimed.Local.Name, "remote": claimed.Remote.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Logging is the app's job (RestartFn reports the actual worker
|
||||||
|
// outcome); the ring layer stays quiet so one restart is one line
|
||||||
|
// rather than two identical ones.
|
||||||
|
continue
|
||||||
|
}
|
||||||
if e.Handler != nil {
|
if e.Handler != nil {
|
||||||
if err := e.Handler.Claim(ctx, claimed); err != nil {
|
if err := e.Handler.Claim(ctx, claimed); err != nil {
|
||||||
e.state.PendingTasks[claimed.ID] = claimed
|
e.state.PendingTasks[claimed.ID] = claimed
|
||||||
@ -724,6 +826,11 @@ func (e *Engine) AdoptState(s State) {
|
|||||||
for id, t := range kept {
|
for id, t := range kept {
|
||||||
e.state.PendingTasks[id] = t
|
e.state.PendingTasks[id] = t
|
||||||
}
|
}
|
||||||
|
// Re-assert decisions this node made locally that the ring has not yet
|
||||||
|
// confirmed, BEFORE the host's topology sync runs: the sync reads
|
||||||
|
// TopologyDisabled to learn the cluster's view, so the local decision must
|
||||||
|
// already be applied or a stop could be reported as "enabled" and dropped.
|
||||||
|
e.reconcileLocalDisabled()
|
||||||
if e.topologySync != nil {
|
if e.topologySync != nil {
|
||||||
e.topologySync()
|
e.topologySync()
|
||||||
}
|
}
|
||||||
@ -792,6 +899,14 @@ func (e *Engine) RemoveNode(nodeID string) *Task {
|
|||||||
return e.state.AddRemoveNode(nodeID)
|
return e.state.AddRemoveNode(nodeID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RemoveTopologyEntry drops the active topology entry for a forward identified
|
||||||
|
// by its natural key. Exposed so the app's claim path can retire an entry it
|
||||||
|
// refuses to serve (see ClaimFn: a disabled forward must stop looking active,
|
||||||
|
// otherwise the startup reconcile keeps re-claiming it on every restart).
|
||||||
|
func (e *Engine) RemoveTopologyEntry(local, remote string, port int) bool {
|
||||||
|
return e.state.RemoveTopology(local, remote, port)
|
||||||
|
}
|
||||||
|
|
||||||
// RevokeTask publishes a revocation for an established forward through the
|
// RevokeTask publishes a revocation for an established forward through the
|
||||||
// same token channel; the owning node stops the worker and drops topology.
|
// same token channel; the owning node stops the worker and drops topology.
|
||||||
func (e *Engine) RevokeTask(local store.Local, remote store.Remote, link store.Link) *Task {
|
func (e *Engine) RevokeTask(local store.Local, remote store.Remote, link store.Link) *Task {
|
||||||
@ -799,14 +914,87 @@ func (e *Engine) RevokeTask(local store.Local, remote store.Remote, link store.L
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) SubmitTask(local store.Local, remote store.Remote, link store.Link) *Task {
|
func (e *Engine) SubmitTask(local store.Local, remote store.Remote, link store.Link) *Task {
|
||||||
if e.HasTask(local.Name, remote.Name, link.RemotePort) {
|
// Guard on an ACTIVE task only. A topology entry that is merely marked
|
||||||
|
// disabled is kept around as the carrier for the stop flag, so counting it
|
||||||
|
// here would make this a permanent no-op and a stopped forward could never
|
||||||
|
// be started again.
|
||||||
|
if e.HasActiveTask(local.Name, remote.Name, link.RemotePort) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return e.state.AddPending(local, remote, link)
|
return e.state.AddPending(local, remote, link)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasTask reports whether a forward with the same local/remote/remotePort is
|
// HasTopologyEntry reports whether the forward has a topology entry (whether or
|
||||||
// already pending or active in the topology (idempotency guard for resaves).
|
// not it is marked disabled). A stop keeps the entry as the flag's carrier, so
|
||||||
|
// "has an entry" is what distinguishes "already claimed by someone" from "never
|
||||||
|
// submitted" — the distinction startForward needs.
|
||||||
|
func (e *Engine) HasTopologyEntry(local, remote string, port int) bool {
|
||||||
|
_, found := e.state.TopologyDisabled(local, remote, port)
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitRestart publishes a task asking the current owner of an already-claimed
|
||||||
|
// forward to bring its worker back up.
|
||||||
|
//
|
||||||
|
// Neither of the existing channels can express "restart what already exists":
|
||||||
|
// SubmitTask is guarded by HasActiveTask and the entry is still present, so it
|
||||||
|
// dedupes; and the claim path drops any task for a forward that already has an
|
||||||
|
// owner (its defence against duplicate-claim collisions). Re-enabling a stopped
|
||||||
|
// forward therefore needs its own task kind, which the owner applies without
|
||||||
|
// re-running the claim bookkeeping.
|
||||||
|
func (e *Engine) SubmitRestart(local store.Local, remote store.Remote, link store.Link) *Task {
|
||||||
|
// The task must not carry the stop: the whole point is to re-enable.
|
||||||
|
link.Disabled = false
|
||||||
|
t := &Task{
|
||||||
|
ID: e.state.NextTaskID(),
|
||||||
|
Local: local,
|
||||||
|
Remote: remote,
|
||||||
|
Link: link,
|
||||||
|
Created: time.Now().Unix(),
|
||||||
|
Restart: true,
|
||||||
|
}
|
||||||
|
if e.state.PendingTasks == nil {
|
||||||
|
e.state.PendingTasks = map[string]*Task{}
|
||||||
|
}
|
||||||
|
e.state.PendingTasks[t.ID] = t
|
||||||
|
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.
|
||||||
|
//
|
||||||
|
// It is deliberately narrower than HasTask. Under mark-don't-remove a stopped
|
||||||
|
// forward keeps its topology entry (that entry is what carries the flag between
|
||||||
|
// nodes), so "an entry exists" no longer means "this forward is active".
|
||||||
|
// SubmitTask must use THIS predicate, otherwise starting a stopped forward —
|
||||||
|
// startForward() publishes the enable and then calls SubmitTask — would be
|
||||||
|
// swallowed by its own idempotency guard.
|
||||||
|
func (e *Engine) HasActiveTask(local, remote string, port int) bool {
|
||||||
|
for _, t := range e.state.PendingList() {
|
||||||
|
if t.Revoke {
|
||||||
|
continue // a revocation is the opposite of an active forward
|
||||||
|
}
|
||||||
|
if t.Local.Name == local && t.Remote.Name == remote && t.Link.RemotePort == port {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, t := range e.state.TopologyList() {
|
||||||
|
if t.Link.Disabled {
|
||||||
|
continue // stopped; the entry is only a flag carrier
|
||||||
|
}
|
||||||
|
if t.Local.Name == local && t.Remote.Name == remote && t.Link.RemotePort == port {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasTask reports whether ANY record for this forward exists — a pending task
|
||||||
|
// (including an in-flight revocation) or a topology entry (including one marked
|
||||||
|
// disabled). This is the "have we already handled this key" question, used to
|
||||||
|
// avoid re-issuing work on repeated canvas saves. For "is it actually running"
|
||||||
|
// use HasActiveTask instead.
|
||||||
func (e *Engine) HasTask(local, remote string, port int) bool {
|
func (e *Engine) HasTask(local, remote string, port int) bool {
|
||||||
for _, t := range e.state.PendingList() {
|
for _, t := range e.state.PendingList() {
|
||||||
if t.Local.Name == local && t.Remote.Name == remote && t.Link.RemotePort == port {
|
if t.Local.Name == local && t.Remote.Name == remote && t.Link.RemotePort == port {
|
||||||
@ -828,6 +1016,78 @@ func (e *Engine) UpdateTopologyGroup(local, remote string, port int, group strin
|
|||||||
return e.state.UpdateTopologyGroup(local, remote, port, group)
|
return e.state.UpdateTopologyGroup(local, remote, port, group)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateTopologyDisabled marks a forward enabled/disabled in the topology so
|
||||||
|
// the decision rides the next token cycle to every member. Paired with
|
||||||
|
// TopologyDisabled, which the adoption hook uses to learn peers' decisions.
|
||||||
|
//
|
||||||
|
// The decision is also remembered locally until the ring confirms it, because
|
||||||
|
// adoption replaces the whole state: a token captured before this call would
|
||||||
|
// otherwise wash the decision out on the next cycle and it would never reach
|
||||||
|
// the other members. See the localDisabled field.
|
||||||
|
func (e *Engine) UpdateTopologyDisabled(local, remote string, port int, disabled bool) bool {
|
||||||
|
if e.localDisabled == nil {
|
||||||
|
e.localDisabled = map[string]bool{}
|
||||||
|
}
|
||||||
|
e.localDisabled[disableKey(local, remote, port)] = disabled
|
||||||
|
return e.state.UpdateTopologyDisabled(local, remote, port, disabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TopologyDisabled reports the cluster's view of a forward's disabled flag.
|
||||||
|
// The bool is false when the forward has no topology entry (nothing to learn).
|
||||||
|
//
|
||||||
|
// A pending local decision takes precedence over the adopted state: it has not
|
||||||
|
// had a chance to reach the other members yet, and reporting the adopted value
|
||||||
|
// would make the local store (and the status page) disagree with the user's
|
||||||
|
// most recent action.
|
||||||
|
func (e *Engine) TopologyDisabled(local, remote string, port int) (bool, bool) {
|
||||||
|
if d, pending := e.localDisabled[disableKey(local, remote, port)]; pending {
|
||||||
|
return d, true
|
||||||
|
}
|
||||||
|
return e.state.TopologyDisabled(local, remote, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileLocalDisabled re-asserts this node's not-yet-confirmed disabled
|
||||||
|
// decisions onto the freshly adopted state. Called right after e.state is
|
||||||
|
// replaced, and paired with reconcileTopologySync (which pushes those decisions
|
||||||
|
// out to the ring).
|
||||||
|
//
|
||||||
|
// A decision is dropped once the adopted state reports the SAME value: at that
|
||||||
|
// point every member agrees and there is nothing left to protect. Entries whose
|
||||||
|
// forward no longer exists in the topology are dropped too, so the map tracks
|
||||||
|
// only live disagreements.
|
||||||
|
func (e *Engine) reconcileLocalDisabled() {
|
||||||
|
if len(e.localDisabled) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for id, te := range e.state.Topology {
|
||||||
|
_ = id
|
||||||
|
k := disableKey(te.Local.Name, te.Remote.Name, te.Link.RemotePort)
|
||||||
|
want, pending := e.localDisabled[k]
|
||||||
|
if !pending {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if te.Link.Disabled == want {
|
||||||
|
// The ring caught up (or agreed independently): stop tracking.
|
||||||
|
delete(e.localDisabled, k)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Still divergent: keep asserting our decision onto the adopted state.
|
||||||
|
te.Link.Disabled = want
|
||||||
|
te.Active = !want
|
||||||
|
}
|
||||||
|
// Forget decisions for forwards that left the topology entirely — otherwise
|
||||||
|
// a long-lived cluster would accumulate dead keys.
|
||||||
|
live := make(map[string]struct{}, len(e.state.Topology))
|
||||||
|
for _, te := range e.state.Topology {
|
||||||
|
live[disableKey(te.Local.Name, te.Remote.Name, te.Link.RemotePort)] = struct{}{}
|
||||||
|
}
|
||||||
|
for k := range e.localDisabled {
|
||||||
|
if _, ok := live[k]; !ok {
|
||||||
|
delete(e.localDisabled, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// IsLeader reports whether this node is the current ring leader.
|
// IsLeader reports whether this node is the current ring leader.
|
||||||
func (e *Engine) IsLeader() bool { return e.state.LeaderID == e.ID }
|
func (e *Engine) IsLeader() bool { return e.state.LeaderID == e.ID }
|
||||||
|
|
||||||
@ -850,6 +1110,14 @@ func (e *Engine) SetPeerPersist(fn func(peersJSON string) error) { e.peerPersist
|
|||||||
// HTTP handlers are overwritten by the next e.state = tk.State.
|
// HTTP handlers are overwritten by the next e.state = tk.State.
|
||||||
func (e *Engine) SetTopologySync(fn func()) { e.topologySync = fn }
|
func (e *Engine) SetTopologySync(fn func()) { e.topologySync = fn }
|
||||||
|
|
||||||
|
// disableKey builds the map key for a forward's disabled decision: the natural
|
||||||
|
// triple (local, remote, remotePort), which is how every other part of the code
|
||||||
|
// identifies a forward. A NUL separator keeps it unambiguous for names that
|
||||||
|
// could otherwise collide across the boundaries.
|
||||||
|
func disableKey(local, remote string, port int) string {
|
||||||
|
return local + "\x00" + remote + "\x00" + strconv.Itoa(port)
|
||||||
|
}
|
||||||
|
|
||||||
// persistPeers extracts all alive peers (addr + nodeKey, excluding self)
|
// persistPeers extracts all alive peers (addr + nodeKey, excluding self)
|
||||||
// from the current ring state and persists them via the peerPersist callback.
|
// from the current ring state and persists them via the peerPersist callback.
|
||||||
// Called on every token cycle (OnToken) and on AdoptState so a crashed node
|
// Called on every token cycle (OnToken) and on AdoptState so a crashed node
|
||||||
|
|||||||
@ -11,6 +11,7 @@ type fakeHandler struct {
|
|||||||
load Load
|
load Load
|
||||||
claim func(ctx context.Context, tk *Task) error
|
claim func(ctx context.Context, tk *Task) error
|
||||||
revoke func(ctx context.Context, tk *Task) error
|
revoke func(ctx context.Context, tk *Task) error
|
||||||
|
restart func(ctx context.Context, tk *Task) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *fakeHandler) Revoke(ctx context.Context, tk *Task) error {
|
func (h *fakeHandler) Revoke(ctx context.Context, tk *Task) error {
|
||||||
@ -20,6 +21,13 @@ func (h *fakeHandler) Revoke(ctx context.Context, tk *Task) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *fakeHandler) Restart(ctx context.Context, tk *Task) error {
|
||||||
|
if h.restart != nil {
|
||||||
|
return h.restart(ctx, tk)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (h *fakeHandler) RuntimeLoad() Load { return h.load }
|
func (h *fakeHandler) RuntimeLoad() Load { return h.load }
|
||||||
func (h *fakeHandler) Claim(ctx context.Context, tk *Task) error {
|
func (h *fakeHandler) Claim(ctx context.Context, tk *Task) error {
|
||||||
if h.claim != nil {
|
if h.claim != nil {
|
||||||
|
|||||||
@ -17,6 +17,10 @@ type AppHandler struct {
|
|||||||
ClaimFn func(ctx context.Context, tk *Task) error
|
ClaimFn func(ctx context.Context, tk *Task) error
|
||||||
// RevokeFn cancels the forward on this node (stop worker + drop from store).
|
// RevokeFn cancels the forward on this node (stop worker + drop from store).
|
||||||
RevokeFn func(ctx context.Context, tk *Task) error
|
RevokeFn func(ctx context.Context, tk *Task) error
|
||||||
|
// RestartFn re-enables an already-claimed forward on this node (clear the
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// RuntimeLoad implements Handler.
|
// RuntimeLoad implements Handler.
|
||||||
@ -44,6 +48,14 @@ func (a *AppHandler) Revoke(ctx context.Context, tk *Task) error {
|
|||||||
return a.RevokeFn(ctx, tk)
|
return a.RevokeFn(ctx, tk)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restart implements Handler.
|
||||||
|
func (a *AppHandler) Restart(ctx context.Context, tk *Task) error {
|
||||||
|
if a.RestartFn == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return a.RestartFn(ctx, tk)
|
||||||
|
}
|
||||||
|
|
||||||
// SampleMemLoad returns a cheap memory-usage percentage (0..100).
|
// SampleMemLoad returns a cheap memory-usage percentage (0..100).
|
||||||
func SampleMemLoad() float64 {
|
func SampleMemLoad() float64 {
|
||||||
var m runtime.MemStats
|
var m runtime.MemStats
|
||||||
|
|||||||
@ -128,7 +128,7 @@ func (e *Engine) forwardToNext(ctx context.Context, tk *Token) error {
|
|||||||
if e.send == nil {
|
if e.send == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
log.Printf("ring[%s] forward cycle=%d to %s", e.ID, tk.Cycle, next)
|
debugToken("ring[%s] forward cycle=%d to %s", e.ID, tk.Cycle, next)
|
||||||
err := e.send(ctx, next, tk)
|
err := e.send(ctx, next, tk)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if e.state.LeaderID == e.ID {
|
if e.state.LeaderID == e.ID {
|
||||||
@ -155,7 +155,9 @@ func (e *Engine) forwardToNext(ctx context.Context, tk *Token) error {
|
|||||||
}
|
}
|
||||||
// Non-leader neighbor death: continue to the next recipient.
|
// Non-leader neighbor death: continue to the next recipient.
|
||||||
}
|
}
|
||||||
e.inflight.clear()
|
// 全环遍历完毕,所有后继都不可达(或已全部尝试过)。
|
||||||
|
// 保持 inflight(不清空),让 WatchTokenLoss 超时后重发。
|
||||||
|
// 重发时 AliveSuccessor 重新计算,可能已被心跳复活。
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,8 @@ import (
|
|||||||
// LogKind enumerates operation-log entry types.
|
// LogKind enumerates operation-log entry types.
|
||||||
const (
|
const (
|
||||||
LogForwardAdd = "forward.add"
|
LogForwardAdd = "forward.add"
|
||||||
|
LogForwardStop = "forward.stop"
|
||||||
|
LogForwardStart = "forward.start"
|
||||||
LogForwardRemove = "forward.remove"
|
LogForwardRemove = "forward.remove"
|
||||||
LogNodeJoin = "node.join"
|
LogNodeJoin = "node.join"
|
||||||
LogNodeLeave = "node.leave"
|
LogNodeLeave = "node.leave"
|
||||||
@ -140,7 +142,7 @@ func DetailOf(e LogEntry) string {
|
|||||||
}
|
}
|
||||||
str := func(key string) string { s, _ := d[key].(string); return s }
|
str := func(key string) string { s, _ := d[key].(string); return s }
|
||||||
switch e.Kind {
|
switch e.Kind {
|
||||||
case LogForwardAdd, LogForwardRemove:
|
case LogForwardAdd, LogForwardStop, LogForwardStart, LogForwardRemove:
|
||||||
s := str("local") + " → " + str("remote")
|
s := str("local") + " → " + str("remote")
|
||||||
if id := str("taskId"); id != "" {
|
if id := str("taskId"); id != "" {
|
||||||
if len(id) > 8 {
|
if len(id) > 8 {
|
||||||
|
|||||||
@ -2,14 +2,22 @@ package cluster
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"webui4frpc/internal/store"
|
"webui4frpc/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestRevokeTaskRemovesTopology: a revoke task published to the ring removes
|
// TestRevokeTaskMarksTopologyDisabled: a stop delivered through the ring marks
|
||||||
// the forward from topology, calls Handler.Revoke, and logs forward.remove.
|
// the topology entry disabled instead of deleting it, calls Handler.Revoke, and
|
||||||
func TestRevokeTaskRemovesTopology(t *testing.T) {
|
// logs forward.stop.
|
||||||
|
//
|
||||||
|
// The entry is deliberately KEPT: TopoEntry.Link is the carrier that makes the
|
||||||
|
// stop travel, and State rides the token every cycle. Keeping it is what lets a
|
||||||
|
// stop reach a member that was offline when the stop was issued — deleting the
|
||||||
|
// entry left the flag nowhere to live, so a stop converged only if the owning
|
||||||
|
// node happened to be online for a one-shot revoke task.
|
||||||
|
func TestRevokeTaskMarksTopologyDisabled(t *testing.T) {
|
||||||
revoked := false
|
revoked := false
|
||||||
eng := NewEngine("n1", "n1:7500", "u", "p", "0.71.0", nil,
|
eng := NewEngine("n1", "n1:7500", "u", "p", "0.71.0", nil,
|
||||||
&fakeHandler{load: Load{MemPct: 5, NetPct: 5},
|
&fakeHandler{load: Load{MemPct: 5, NetPct: 5},
|
||||||
@ -26,33 +34,92 @@ func TestRevokeTaskRemovesTopology(t *testing.T) {
|
|||||||
if len(eng.state.TopologyList()) != 1 {
|
if len(eng.state.TopologyList()) != 1 {
|
||||||
t.Fatalf("topology after claim = %+v", eng.state.TopologyList())
|
t.Fatalf("topology after claim = %+v", eng.state.TopologyList())
|
||||||
}
|
}
|
||||||
|
if d, _ := eng.state.TopologyDisabled("web", "frps1", 18081); d {
|
||||||
|
t.Fatal("a freshly claimed forward must not start out disabled")
|
||||||
|
}
|
||||||
|
|
||||||
// publish a revoke task pointing at the same forward
|
// publish a revoke task pointing at the same forward
|
||||||
eng.state.AddRevoke(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
|
eng.state.AddRevoke(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
|
||||||
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 2, State: eng.state}); err != nil {
|
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 2, State: eng.state}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(eng.state.TopologyList()) != 0 {
|
|
||||||
t.Fatalf("topology after revoke = %+v", eng.state.TopologyList())
|
// The entry must SURVIVE, marked disabled, so the flag keeps travelling.
|
||||||
|
if len(eng.state.TopologyList()) != 1 {
|
||||||
|
t.Fatalf("topology entry was removed; the disabled flag would have no carrier: %+v",
|
||||||
|
eng.state.TopologyList())
|
||||||
|
}
|
||||||
|
disabled, known := eng.state.TopologyDisabled("web", "frps1", 18081)
|
||||||
|
if !known {
|
||||||
|
t.Fatal("topology entry missing after revoke")
|
||||||
|
}
|
||||||
|
if !disabled {
|
||||||
|
t.Fatal("revoke did not mark the topology entry disabled")
|
||||||
|
}
|
||||||
|
// Active must follow the flag, since OfflineReassign only re-queues Active
|
||||||
|
// entries — a stopped forward must not be resurrected by a node departure.
|
||||||
|
if eng.state.TopologyList()[0].Active {
|
||||||
|
t.Fatal("a stopped forward must not remain Active (OfflineReassign would re-queue it)")
|
||||||
}
|
}
|
||||||
if !revoked {
|
if !revoked {
|
||||||
t.Fatal("Handler.Revoke was not called")
|
t.Fatal("Handler.Revoke was not called")
|
||||||
}
|
}
|
||||||
// log should contain forward.remove
|
// log should contain forward.stop (not forward.remove — nothing was removed)
|
||||||
var sawRemove bool
|
var sawStop bool
|
||||||
for _, e := range eng.Log.Snapshot() {
|
for _, e := range eng.Log.Snapshot() {
|
||||||
if e.Kind == LogForwardRemove {
|
if e.Kind == LogForwardStop {
|
||||||
sawRemove = true
|
sawStop = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !sawRemove {
|
if !sawStop {
|
||||||
t.Fatalf("log missing forward.remove: %+v", eng.Log.Snapshot())
|
t.Fatalf("log missing forward.stop: %+v", eng.Log.Snapshot())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStoppedTopologyEntrySurvivesAdoption is the property that makes the whole
|
||||||
|
// mark-don't-remove design work: because the entry persists, a node that adopts
|
||||||
|
// ring state learns the stop. This is the offline-member case a one-shot revoke
|
||||||
|
// task could never cover.
|
||||||
|
func TestStoppedTopologyEntrySurvivesAdoption(t *testing.T) {
|
||||||
|
src := newTestEngine("n1", true)
|
||||||
|
src.state.AddPending(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
|
||||||
|
if _, err := src.OnToken(context.Background(), &Token{Cycle: 1, State: src.state}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
src.state.AddRevoke(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
|
||||||
|
if _, err := src.OnToken(context.Background(), &Token{Cycle: 2, State: src.state}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A different node adopts the ring state (what e.state = tk.State does).
|
||||||
|
peer := newTestEngine("n2", false)
|
||||||
|
peer.AdoptState(src.state)
|
||||||
|
|
||||||
|
disabled, known := peer.TopologyDisabled("web", "frps1", 18081)
|
||||||
|
if !known {
|
||||||
|
t.Fatal("peer did not receive the topology entry for the stopped forward")
|
||||||
|
}
|
||||||
|
if !disabled {
|
||||||
|
t.Fatal("peer adopted the entry but no longer sees it as disabled — the stop did not propagate")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRevokeIdempotent: revoking an already-missing forward does not error.
|
// TestRevokeIdempotent: revoking an already-missing forward does not error.
|
||||||
|
//
|
||||||
|
// It must ALSO still call Handler.Revoke. The handler is what actually stops
|
||||||
|
// the per-forward frpc worker; the topology entry is only bookkeeping. Gating
|
||||||
|
// the handler on RemoveTopology()'s return value (as this test used to permit)
|
||||||
|
// turned every revoke of an already-absent forward into a silent no-op — the
|
||||||
|
// worker kept running, which is how a forward the user had stopped kept
|
||||||
|
// dialling a local service that was intentionally down, forever.
|
||||||
func TestRevokeIdempotent(t *testing.T) {
|
func TestRevokeIdempotent(t *testing.T) {
|
||||||
eng := newTestEngine("n1", true)
|
revoked := 0
|
||||||
|
eng := NewEngine("n1", "n1:7500", "u", "p", "0.71.0", nil,
|
||||||
|
&fakeHandler{load: Load{MemPct: 5, NetPct: 5},
|
||||||
|
revoke: func(ctx context.Context, tk *Task) error { revoked++; return nil }},
|
||||||
|
func(ctx context.Context, next string, tk *Token) error { return nil },
|
||||||
|
"n1:7500", true, "")
|
||||||
|
|
||||||
eng.state.AddRevoke(store.Local{Name: "ghost"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 1})
|
eng.state.AddRevoke(store.Local{Name: "ghost"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 1})
|
||||||
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state}); err != nil {
|
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state}); err != nil {
|
||||||
t.Fatalf("revoke missing: %v", err)
|
t.Fatalf("revoke missing: %v", err)
|
||||||
@ -61,4 +128,318 @@ func TestRevokeIdempotent(t *testing.T) {
|
|||||||
if len(eng.state.TopologyList()) != 0 {
|
if len(eng.state.TopologyList()) != 0 {
|
||||||
t.Fatal("should be empty")
|
t.Fatal("should be empty")
|
||||||
}
|
}
|
||||||
|
// The stop side-effect must have happened even though there was no entry.
|
||||||
|
if revoked != 1 {
|
||||||
|
t.Fatalf("Handler.Revoke called %d times, want 1 — a revoke with no topology entry "+
|
||||||
|
"must still stop the worker, otherwise stopped forwards keep running", revoked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStoppedForwardNotRequeuedOnNodeDeparture pins why marking (rather than
|
||||||
|
// deleting) is safe: OfflineReassign only re-queues ACTIVE entries, so a
|
||||||
|
// stopped forward is not silently handed to another node when its owner goes
|
||||||
|
// away. Deleting the entry, or leaving it Active, would both resurrect it.
|
||||||
|
func TestStoppedForwardNotRequeuedOnNodeDeparture(t *testing.T) {
|
||||||
|
eng := newTestEngine("n1", true)
|
||||||
|
eng.state.AddPending(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
|
||||||
|
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
eng.state.AddRevoke(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
|
||||||
|
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 2, State: eng.state}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
owner := eng.state.TopologyList()[0].OwnerID
|
||||||
|
eng.state.OfflineReassign(owner)
|
||||||
|
|
||||||
|
if n := len(eng.state.PendingList()); n != 0 {
|
||||||
|
t.Fatalf("a stopped forward was re-queued on the owner's departure (pending=%d): %+v",
|
||||||
|
n, eng.state.PendingList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSubmitTaskNotBlockedByStoppedEntry is the regression test for the bug the
|
||||||
|
// mark-don't-remove change introduced: SubmitTask's idempotency guard used
|
||||||
|
// HasTask, which matches a disabled topology entry. Since a stop now KEEPS the
|
||||||
|
// entry, any submission for that forward while it is still stopped is swallowed
|
||||||
|
// by its own guard — so a stopped forward can never be started again.
|
||||||
|
//
|
||||||
|
// The probe deliberately submits WITHOUT re-enabling first. Re-enabling would
|
||||||
|
// clear the flag and make HasTask and HasActiveTask agree, hiding the defect;
|
||||||
|
// the guard has to be exercised at the moment the entry is still stopped.
|
||||||
|
func TestSubmitTaskNotBlockedByStoppedEntry(t *testing.T) {
|
||||||
|
eng := newTestEngine("n1", true)
|
||||||
|
eng.state.AddPending(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
|
||||||
|
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// stop it -> entry is kept, marked disabled
|
||||||
|
eng.state.AddRevoke(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
|
||||||
|
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 2, State: eng.state}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if d, _ := eng.state.TopologyDisabled("web", "frps1", 18081); !d {
|
||||||
|
t.Fatal("precondition: forward should be disabled")
|
||||||
|
}
|
||||||
|
// The stopped entry IS findable by the broad predicate — which is exactly why
|
||||||
|
// the narrow one has to exist.
|
||||||
|
if !eng.HasTask("web", "frps1", 18081) {
|
||||||
|
t.Fatal("precondition: the stopped entry should still be findable by HasTask")
|
||||||
|
}
|
||||||
|
if eng.HasActiveTask("web", "frps1", 18081) {
|
||||||
|
t.Fatal("precondition: a stopped forward must NOT count as active")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The regression: a submission must be published for a stopped forward.
|
||||||
|
// With a HasTask guard this returns nil and the forward is stuck forever.
|
||||||
|
tk := eng.SubmitTask(store.Local{Name: "web"}, store.Remote{Name: "frps1"},
|
||||||
|
store.Link{RemotePort: 18081})
|
||||||
|
if tk == nil {
|
||||||
|
t.Fatal("SubmitTask was swallowed by the stopped topology entry — " +
|
||||||
|
"a stopped forward could never be started again")
|
||||||
|
}
|
||||||
|
if tk.Link.RemotePort != 18081 {
|
||||||
|
t.Fatalf("unexpected task: %+v", tk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSubmitTaskStillDedupesActiveForward is the other half of the guard: the
|
||||||
|
// narrower predicate must not turn SubmitTask into a duplicate-task generator.
|
||||||
|
func TestSubmitTaskStillDedupesActiveForward(t *testing.T) {
|
||||||
|
eng := newTestEngine("n1", true)
|
||||||
|
eng.state.AddPending(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
|
||||||
|
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !eng.HasActiveTask("web", "frps1", 18081) {
|
||||||
|
t.Fatal("precondition: a claimed forward must count as active")
|
||||||
|
}
|
||||||
|
if tk := eng.SubmitTask(store.Local{Name: "web"}, store.Remote{Name: "frps1"},
|
||||||
|
store.Link{RemotePort: 18081}); tk != nil {
|
||||||
|
t.Fatalf("SubmitTask must stay idempotent for an active forward, got %+v", tk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStoppedForwardNotRequeuedOnNodeDeparture pins why marking (rather than
|
||||||
|
// deleting) is safe: OfflineReassign only re-queues ACTIVE entries, so a
|
||||||
|
// stopped forward is not silently handed to another node when its owner goes
|
||||||
|
|
||||||
|
// TestAddTopologyRespectsDisabledFlag: a claim that carries a stopped forward
|
||||||
|
// must not mark the new entry Active, or it would come back to life.
|
||||||
|
func TestAddTopologyRespectsDisabledFlag(t *testing.T) {
|
||||||
|
s := &State{Topology: map[string]*TopoEntry{}}
|
||||||
|
e := s.AddTopology(&Task{
|
||||||
|
ID: "t1", Local: store.Local{Name: "web"}, Remote: store.Remote{Name: "frps1"},
|
||||||
|
Link: store.Link{RemotePort: 18081, Disabled: true},
|
||||||
|
}, "n1")
|
||||||
|
if e.Active {
|
||||||
|
t.Fatal("AddTopology forced Active=true for a disabled claim — the forward would resurrect")
|
||||||
|
}
|
||||||
|
if !e.Link.Disabled {
|
||||||
|
t.Fatal("AddTopology dropped the disabled flag from the link")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRestartTaskBypassesDuplicateClaimGuard is the engine half of the
|
||||||
|
// re-enable path: a restart task for a forward that still HAS a topology entry
|
||||||
|
// must reach Handler.Restart.
|
||||||
|
//
|
||||||
|
// This is precisely what the duplicate-claim guard refuses — it exists to stop
|
||||||
|
// a stale task from spawning a second worker for an owned forward, and a restart
|
||||||
|
// is indistinguishable from that unless it is an explicit task kind. Marking
|
||||||
|
// the entry stopped (instead of deleting it) is what made this necessary: the
|
||||||
|
// entry the guard keys on now outlives a stop.
|
||||||
|
func TestRestartTaskBypassesDuplicateClaimGuard(t *testing.T) {
|
||||||
|
restarts := 0
|
||||||
|
claims := 0
|
||||||
|
eng := NewEngine("n1", "n1:7500", "u", "p", "0.71.0", nil,
|
||||||
|
&fakeHandler{load: Load{MemPct: 5, NetPct: 5},
|
||||||
|
claim: func(ctx context.Context, tk *Task) error { claims++; return nil },
|
||||||
|
restart: func(ctx context.Context, tk *Task) error { restarts++; return nil }},
|
||||||
|
func(ctx context.Context, next string, tk *Token) error { return nil },
|
||||||
|
"n1:7500", true, "")
|
||||||
|
|
||||||
|
// Establish + stop: entry survives, marked disabled.
|
||||||
|
eng.state.AddPending(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
|
||||||
|
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
eng.state.AddRevoke(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
|
||||||
|
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 2, State: eng.state}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if d, _ := eng.state.TopologyDisabled("web", "frps1", 18081); !d {
|
||||||
|
t.Fatal("precondition: should be disabled")
|
||||||
|
}
|
||||||
|
entryCount := len(eng.state.TopologyList())
|
||||||
|
|
||||||
|
// Start: publish a restart and run a cycle.
|
||||||
|
eng.state.UpdateTopologyDisabled("web", "frps1", 18081, false)
|
||||||
|
eng.SubmitRestart(store.Local{Name: "web"}, store.Remote{Name: "frps1"},
|
||||||
|
store.Link{RemotePort: 18081})
|
||||||
|
if _, err := eng.OnToken(context.Background(), &Token{Cycle: 3, State: eng.state}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if restarts != 1 {
|
||||||
|
t.Fatalf("Handler.Restart called %d times, want 1 — the restart task was swallowed "+
|
||||||
|
"by the duplicate-claim guard, so the forward would never come back", restarts)
|
||||||
|
}
|
||||||
|
if claims != 1 {
|
||||||
|
t.Fatalf("Handler.Claim called %d times, want 1 (the original claim only); "+
|
||||||
|
"a restart must not re-run the claim bookkeeping", claims)
|
||||||
|
}
|
||||||
|
// Re-enabling must not create a second entry.
|
||||||
|
if n := len(eng.state.TopologyList()); n != entryCount {
|
||||||
|
t.Fatalf("restart created a duplicate topology entry: %d -> %d", entryCount, n)
|
||||||
|
}
|
||||||
|
if d, _ := eng.state.TopologyDisabled("web", "frps1", 18081); d {
|
||||||
|
t.Fatal("entry is still disabled after the restart task was applied")
|
||||||
|
}
|
||||||
|
var sawStart bool
|
||||||
|
for _, e := range eng.Log.Snapshot() {
|
||||||
|
if e.Kind == LogForwardStart {
|
||||||
|
sawStart = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawStart {
|
||||||
|
t.Fatalf("log missing forward.start: %+v", eng.Log.Snapshot())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRestartFlagSurvivesTokenSerialization: the restart task travels to the
|
||||||
|
// owner inside the token, so the flag must round-trip through JSON. Without the
|
||||||
|
// struct tag it would silently deserialize as false and the owner would treat
|
||||||
|
// it as an ordinary claim — which the duplicate guard then discards.
|
||||||
|
func TestRestartFlagSurvivesTokenSerialization(t *testing.T) {
|
||||||
|
eng := newTestEngine("n1", true)
|
||||||
|
eng.SubmitRestart(store.Local{Name: "web"}, store.Remote{Name: "frps1"},
|
||||||
|
store.Link{RemotePort: 18081})
|
||||||
|
|
||||||
|
blob, err := json.Marshal(&Token{Cycle: 7, State: eng.state})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var back Token
|
||||||
|
if err := json.Unmarshal(blob, &back); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var found *Task
|
||||||
|
for _, tk := range back.State.PendingList() {
|
||||||
|
if tk.Local.Name == "web" && tk.Link.RemotePort == 18081 {
|
||||||
|
found = tk
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found == nil {
|
||||||
|
t.Fatal("restart task did not survive token serialization")
|
||||||
|
}
|
||||||
|
if !found.Restart {
|
||||||
|
t.Fatal("the restart flag was lost in JSON — the owner would see a plain claim " +
|
||||||
|
"and the duplicate guard would discard it")
|
||||||
|
}
|
||||||
|
// Revoke and Restart must stay distinguishable.
|
||||||
|
if found.Revoke {
|
||||||
|
t.Fatal("a restart task must not also read as a revocation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRestartTaskReachesNonLocalOwner.
|
||||||
|
func TestRestartOnlyAppliedByOwner(t *testing.T) {
|
||||||
|
restarts := 0
|
||||||
|
h := &fakeHandler{load: Load{MemPct: 5, NetPct: 5},
|
||||||
|
restart: func(ctx context.Context, tk *Task) error { restarts++; return nil }}
|
||||||
|
|
||||||
|
// Owner node claims the forward.
|
||||||
|
owner := NewEngine("n1", "n1:7500", "u", "p", "0.71.0", nil, h,
|
||||||
|
func(ctx context.Context, next string, tk *Token) error { return nil }, "n1:7500", true, "")
|
||||||
|
owner.state.AddPending(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
|
||||||
|
if _, err := owner.OnToken(context.Background(), &Token{Cycle: 1, State: owner.state, SentAt: 1}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(owner.state.TopologyList()) != 1 {
|
||||||
|
t.Fatalf("precondition: owner should hold the entry, got %+v", owner.state.TopologyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
// A peer adopts the same ring state but does not own the forward.
|
||||||
|
peer := NewEngine("n2", "n2:7500", "u", "p", "0.71.0", nil, h,
|
||||||
|
func(ctx context.Context, next string, tk *Token) error { return nil }, "n2:7500", false, "")
|
||||||
|
peer.AdoptState(owner.state)
|
||||||
|
tk := peer.SubmitRestart(store.Local{Name: "web"}, store.Remote{Name: "frps1"},
|
||||||
|
store.Link{RemotePort: 18081})
|
||||||
|
if tk == nil {
|
||||||
|
t.Fatal("SubmitRestart returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The peer processes one token: it must NOT apply the restart.
|
||||||
|
if _, err := peer.OnToken(context.Background(), &Token{Cycle: 2, State: peer.state, SentAt: 2}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if restarts != 0 {
|
||||||
|
t.Fatalf("a NON-owner applied the restart %d time(s) — it would spawn an orphaned "+
|
||||||
|
"worker for a forward owned by somebody else", restarts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRestartTaskReachesNonLocalOwner: a restart published on a node that is NOT
|
||||||
|
// the owner must survive the token round-trip and be applied by the owner.
|
||||||
|
//
|
||||||
|
// The naive "if not owner then continue" implementation CONSUMED the task
|
||||||
|
// (ClaimPending had already removed it), so the owner never received it and the
|
||||||
|
// forward stayed stopped with nothing logged as an error. Deferral must
|
||||||
|
// re-queue it.
|
||||||
|
//
|
||||||
|
// Each OnToken is fed a FRESH token (as the real ring does — the successor's
|
||||||
|
// OnToken receives the state the predecessor returned), so the deferral is
|
||||||
|
// exercised once per hop rather than re-processing one snapshot.
|
||||||
|
func TestRestartTaskReachesNonLocalOwner(t *testing.T) {
|
||||||
|
ownerApplied, submitterApplied := 0, 0
|
||||||
|
ownerEng := NewEngine("n2", "n2:7500", "u", "p", "0.71.0", nil,
|
||||||
|
&fakeHandler{load: Load{MemPct: 5, NetPct: 5},
|
||||||
|
restart: func(ctx context.Context, tk *Task) error { ownerApplied++; return nil }},
|
||||||
|
func(ctx context.Context, next string, tk *Token) error { return nil }, "n2:7500", false, "")
|
||||||
|
|
||||||
|
ownerEng.state.AddPending(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
|
||||||
|
if _, err := ownerEng.OnToken(context.Background(), &Token{Cycle: 1, State: ownerEng.state}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(ownerEng.state.TopologyList()) != 1 {
|
||||||
|
t.Fatalf("precondition: n2 should own it, got %+v", ownerEng.state.TopologyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
subEng := NewEngine("n1", "n1:7500", "u", "p", "0.71.0", nil,
|
||||||
|
&fakeHandler{load: Load{MemPct: 5, NetPct: 5},
|
||||||
|
restart: func(ctx context.Context, tk *Task) error { submitterApplied++; return nil }},
|
||||||
|
func(ctx context.Context, next string, tk *Token) error { return nil }, "n1:7500", true, "")
|
||||||
|
subEng.AdoptState(ownerEng.state)
|
||||||
|
|
||||||
|
subEng.SubmitRestart(store.Local{Name: "web"}, store.Remote{Name: "frps1"},
|
||||||
|
store.Link{RemotePort: 18081})
|
||||||
|
|
||||||
|
// One hop through the non-owner: it must defer, not apply and not drop.
|
||||||
|
out, err := subEng.OnToken(context.Background(), &Token{Cycle: 2, State: subEng.state, SentAt: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if submitterApplied != 0 {
|
||||||
|
t.Fatal("the non-owner applied a restart for a forward it does not own")
|
||||||
|
}
|
||||||
|
var carried *Task
|
||||||
|
for _, tk := range out.State.PendingList() {
|
||||||
|
if tk.Restart && tk.Local.Name == "web" {
|
||||||
|
carried = tk
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if carried == nil {
|
||||||
|
t.Fatal("the non-owner CONSUMED the restart task; the owner would never receive it")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The owner applies the carried task.
|
||||||
|
if _, err := ownerEng.OnToken(context.Background(), &Token{Cycle: 3, State: out.State, SentAt: 2}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ownerApplied != 1 {
|
||||||
|
t.Fatalf("the owner applied the restart %d times, want 1", ownerApplied)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@ -52,11 +51,16 @@ func (t *TokenTransport) SendTo(getAddr func(nodeID string) string) func(ctx con
|
|||||||
start := time.Now()
|
start := time.Now()
|
||||||
resp, err := cli.Do(req)
|
resp, err := cli.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("token-send %s: size=%dB elapsed=%v err=%v", addr, len(body), time.Since(start).Round(time.Millisecond), err)
|
// NOT demoted: a failed send is the "neighbor offline" signal the
|
||||||
|
// ring's fault paths are diagnosed from.
|
||||||
|
logf("token-send %s: size=%dB elapsed=%v err=%v", addr, len(body), time.Since(start).Round(time.Millisecond), err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
log.Printf("token-send %s: size=%dB elapsed=%v -> %d", addr, len(body), time.Since(start).Round(time.Millisecond), resp.StatusCode)
|
// Success path is a per-round heartbeat (size/elapsed/200 repeat
|
||||||
|
// verbatim every cycle) — debug only. The status-code check below stays
|
||||||
|
// loud on purpose, so a non-2xx still shows up without the debug flag.
|
||||||
|
debugToken("token-send %s: size=%dB elapsed=%v -> %d", addr, len(body), time.Since(start).Round(time.Millisecond), resp.StatusCode)
|
||||||
if resp.StatusCode >= 400 {
|
if resp.StatusCode >= 400 {
|
||||||
return fmt.Errorf("token POST %s -> HTTP %d", url, resp.StatusCode)
|
return fmt.Errorf("token POST %s -> HTTP %d", url, resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|||||||
204
internal/httpapi/forward_revoke_test.go
Normal file
204
internal/httpapi/forward_revoke_test.go
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"webui4frpc/internal/cluster"
|
||||||
|
"webui4frpc/internal/process"
|
||||||
|
"webui4frpc/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newRingTestHandler builds a Handler WITH a ring engine attached, so the
|
||||||
|
// paths that publish tasks into the token actually execute. newTestHandler
|
||||||
|
// leaves Ring nil, which silently skips them — a test built on it can pass
|
||||||
|
// while the publish side is completely broken.
|
||||||
|
func newRingTestHandler(t *testing.T) (*Handler, *cluster.Engine, *httptest.Server) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
st, err := store.New(filepath.Join(dir, "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = st.Close() })
|
||||||
|
|
||||||
|
pm := process.NewManager(process.Options{
|
||||||
|
ConfigsDir: filepath.Join(dir, "configs"),
|
||||||
|
LogsDir: filepath.Join(dir, "logs"),
|
||||||
|
BinaryPath: func() string { return "" },
|
||||||
|
Render: func(string) ([]byte, error) { return []byte(`{}`), nil },
|
||||||
|
AutoRestart: func(string) bool { return false },
|
||||||
|
RestartInterval: func() int { return 5 },
|
||||||
|
})
|
||||||
|
ring := cluster.NewEngine("n1", "n1:7500", "u", "p", "0.1.0", nil,
|
||||||
|
&cluster.AppHandler{},
|
||||||
|
func(ctx context.Context, next string, tk *cluster.Token) error { return nil },
|
||||||
|
"n1:7500", true, "")
|
||||||
|
h := &Handler{Store: st, Process: pm, WorkDir: dir, User: "admin", Password: "pw", Ring: ring}
|
||||||
|
mux, err := NewServeMux(h)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ts := httptest.NewServer(mux)
|
||||||
|
t.Cleanup(ts.Close)
|
||||||
|
return h, ring, ts
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveCanvas(t *testing.T, srv *httptest.Server, body string) {
|
||||||
|
t.Helper()
|
||||||
|
req, _ := http.NewRequest(http.MethodPut, srv.URL+"/api/manager/canvas", bytes.NewBufferString(body))
|
||||||
|
req.SetBasicAuth("admin", "pw")
|
||||||
|
resp, err := srv.Client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("canvas save status = %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStopForwardPublishesDisabledFlagInRevokeTask is the regression test for
|
||||||
|
// the actual defect.
|
||||||
|
//
|
||||||
|
// stopForward() read the link, called SetLinkDisabled(true), and then handed
|
||||||
|
// the STALE copy (disabled=false) to RevokeTask. The revoke travels to the
|
||||||
|
// node that OWNS the forward, and that node's Claim/Revoke path keys off the
|
||||||
|
// flag — so a stale false meant:
|
||||||
|
// - the owner could not tell the stop was deliberate, and
|
||||||
|
// - nothing retired the topology entry,
|
||||||
|
//
|
||||||
|
// so the next restart's reconcile re-claimed the forward and spawned a worker
|
||||||
|
// for something the user had stopped (seen live: endless connection-refused
|
||||||
|
// against an intentionally-down service).
|
||||||
|
//
|
||||||
|
// This asserts the flag ON THE PUBLISHED TASK, which is the value that was
|
||||||
|
// actually wrong. It cannot be satisfied by the store write alone.
|
||||||
|
func TestStopForwardPublishesDisabledFlagInRevokeTask(t *testing.T) {
|
||||||
|
_, ring, ts := newRingTestHandler(t)
|
||||||
|
|
||||||
|
saveCanvas(t, ts, `{
|
||||||
|
"locals": [{"name":"svc","ip":"127.0.0.1","port":59999,"protocol":"tcp"}],
|
||||||
|
"remotes": [{"name":"srv-a","ip":"1.2.3.4","port":7000,"enabled":true}],
|
||||||
|
"links": [{"local":"svc","remote":"srv-a","remotePort":45999}]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
// Stop the forward over the API.
|
||||||
|
b, _ := json.Marshal(stopForwardReq{"svc", "srv-a", 45999})
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/manager/forwards/stop", bytes.NewReader(b))
|
||||||
|
req.SetBasicAuth("admin", "pw")
|
||||||
|
resp, err := ts.Client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("stop status = %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the revoke task that was published into the token.
|
||||||
|
var revoke *cluster.Task
|
||||||
|
for _, tk := range ring.State().PendingList() {
|
||||||
|
if tk.Revoke && tk.Local.Name == "svc" && tk.Link.RemotePort == 45999 {
|
||||||
|
revoke = tk
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if revoke == nil {
|
||||||
|
t.Fatal("stop did not publish a revoke task for the forward")
|
||||||
|
}
|
||||||
|
if !revoke.Link.Disabled {
|
||||||
|
t.Fatal("the published revoke task carries disabled=false — the owner node cannot tell " +
|
||||||
|
"this stop was deliberate, which is the bug that let stopped forwards resurrect")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStopThenStartPublishesRestartTask guards the failure mode that
|
||||||
|
// mark-don't-remove introduces, and that only shows up on the WIRE.
|
||||||
|
//
|
||||||
|
// A stop keeps the topology entry (it is the carrier for the flag), so on
|
||||||
|
// re-enable:
|
||||||
|
// - SubmitTask dedupes, because the entry exists;
|
||||||
|
// - the claim path discards any task for a forward that already has an owner.
|
||||||
|
//
|
||||||
|
// Both channels therefore refuse, no task is published, the owner never learns
|
||||||
|
// about the re-enable, and the forward stays stopped forever — a forward the
|
||||||
|
// user can stop but never restart.
|
||||||
|
//
|
||||||
|
// Asserting the task is PUBLISHED is the point: asserting only that the flag
|
||||||
|
// cleared would pass while nothing reached the owner. (The earlier version of
|
||||||
|
// this test did exactly that and stayed green against the broken code.)
|
||||||
|
func TestStopThenStartPublishesRestartTask(t *testing.T) {
|
||||||
|
_, ring, ts := newRingTestHandler(t)
|
||||||
|
|
||||||
|
saveCanvas(t, ts, `{
|
||||||
|
"locals": [{"name":"svc","ip":"127.0.0.1","port":59999,"protocol":"tcp"}],
|
||||||
|
"remotes": [{"name":"srv-a","ip":"1.2.3.4","port":7000,"enabled":true}],
|
||||||
|
"links": [{"local":"svc","remote":"srv-a","remotePort":45999}]
|
||||||
|
}`)
|
||||||
|
// Claim it so a topology entry exists (that is what makes the two normal
|
||||||
|
// channels refuse later).
|
||||||
|
if _, err := ring.OnToken(context.Background(), &cluster.Token{Cycle: 1, State: *ring.State()}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !ring.HasActiveTask("svc", "srv-a", 45999) {
|
||||||
|
t.Fatalf("precondition: forward should be active, topo=%+v", ring.State().TopologyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
forwards := func(action string) {
|
||||||
|
t.Helper()
|
||||||
|
b, _ := json.Marshal(stopForwardReq{"svc", "srv-a", 45999})
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/manager/forwards/"+action, bytes.NewReader(b))
|
||||||
|
req.SetBasicAuth("admin", "pw")
|
||||||
|
resp, err := ts.Client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("%s status = %d", action, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- stop: entry kept, marked disabled -------------------------------
|
||||||
|
forwards("stop")
|
||||||
|
if d, known := ring.TopologyDisabled("svc", "srv-a", 45999); !known || !d {
|
||||||
|
t.Fatalf("stop did not mark the topology entry disabled (known=%v disabled=%v)", known, d)
|
||||||
|
}
|
||||||
|
if ring.HasActiveTask("svc", "srv-a", 45999) {
|
||||||
|
t.Fatal("a stopped forward must not count as active")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- start: a task MUST reach the owner ------------------------------
|
||||||
|
forwards("start")
|
||||||
|
if d, _ := ring.TopologyDisabled("svc", "srv-a", 45999); d {
|
||||||
|
t.Fatal("start did not clear the disabled flag")
|
||||||
|
}
|
||||||
|
if !ring.HasActiveTask("svc", "srv-a", 45999) {
|
||||||
|
t.Fatal("after start the forward must be active again")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The actual regression: something has to be queued for the owner. The
|
||||||
|
// re-enable must be published as a restart task, since a plain creation task
|
||||||
|
// would be deduped or discarded.
|
||||||
|
var restart *cluster.Task
|
||||||
|
for _, tk := range ring.State().PendingList() {
|
||||||
|
if tk.Restart && tk.Local.Name == "svc" && tk.Link.RemotePort == 45999 {
|
||||||
|
restart = tk
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if restart == nil {
|
||||||
|
t.Fatalf("start published no restart task — the owner would never bring the "+
|
||||||
|
"worker back, leaving the forward permanently stopped (pending=%+v)",
|
||||||
|
ring.State().PendingList())
|
||||||
|
}
|
||||||
|
if restart.Link.Disabled {
|
||||||
|
t.Fatal("the restart task carries disabled=true, so the owner would re-apply the stop")
|
||||||
|
}
|
||||||
|
}
|
||||||
141
internal/httpapi/forward_stop_test.go
Normal file
141
internal/httpapi/forward_stop_test.go
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stopForwardReq mirrors the forwards start/stop request body.
|
||||||
|
type stopForwardReq struct {
|
||||||
|
Local string `json:"local"`
|
||||||
|
Remote string `json:"remote"`
|
||||||
|
RemotePort int `json:"remotePort"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func postForwards(t *testing.T, srv *httptest.Server, action string, body stopForwardReq) int {
|
||||||
|
t.Helper()
|
||||||
|
b, _ := json.Marshal(body)
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/api/manager/forwards/"+action, bytes.NewReader(b))
|
||||||
|
req.SetBasicAuth("admin", "pw")
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := srv.Client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
return resp.StatusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStopForwardPersistsDisabledFlag is the regression test for the
|
||||||
|
// "stopped forwards resurrect on restart" bug.
|
||||||
|
//
|
||||||
|
// The original defect: stopForward() read the link, called
|
||||||
|
// SetLinkDisabled(true), but then handed the STALE (disabled=false) copy to
|
||||||
|
// RevokeTask — so the disabled flag never reached the node owning the forward,
|
||||||
|
// and nothing removed the topology entry. On the next restart the startup
|
||||||
|
// reconcile saw an owned forward with no worker and re-claimed it, spawning a
|
||||||
|
// worker for a forward the user had deliberately stopped (observed live:
|
||||||
|
// ~14k "proxy already exists" retries and endless connection-refused against a
|
||||||
|
// service that was intentionally down).
|
||||||
|
//
|
||||||
|
// The contract this pins: after a successful stop, the persisted link MUST be
|
||||||
|
// disabled — that flag is the single source of truth the claim path consults.
|
||||||
|
func TestStopForwardPersistsDisabledFlag(t *testing.T) {
|
||||||
|
h, ts := newTestHandler(t)
|
||||||
|
|
||||||
|
body := `{
|
||||||
|
"locals": [{"name":"svc","ip":"127.0.0.1","port":59999,"protocol":"tcp"}],
|
||||||
|
"remotes": [{"name":"srv-a","ip":"1.2.3.4","port":7000,"enabled":true}],
|
||||||
|
"links": [{"local":"svc","remote":"srv-a","remotePort":45999}]
|
||||||
|
}`
|
||||||
|
req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/manager/canvas", bytes.NewBufferString(body))
|
||||||
|
req.SetBasicAuth("admin", "pw")
|
||||||
|
resp, err := ts.Client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("canvas save status = %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The forward starts enabled.
|
||||||
|
ln, found, err := h.Store.LinkByTriple("svc", "srv-a", 45999)
|
||||||
|
if err != nil || !found {
|
||||||
|
t.Fatalf("link not persisted: found=%v err=%v", found, err)
|
||||||
|
}
|
||||||
|
if ln.Disabled {
|
||||||
|
t.Fatal("a freshly saved forward must start enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop it.
|
||||||
|
if code := postForwards(t, ts, "stop", stopForwardReq{"svc", "srv-a", 45999}); code != http.StatusOK {
|
||||||
|
t.Fatalf("stop status = %d, want 200", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persisted flag must now be set — this is what the claim path reads.
|
||||||
|
ln, found, err = h.Store.LinkByTriple("svc", "srv-a", 45999)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("link vanished after stop; stop must be non-destructive")
|
||||||
|
}
|
||||||
|
if !ln.Disabled {
|
||||||
|
t.Fatal("stop did not persist disabled=true — the startup reconcile would resurrect this forward")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start must clear it again (the user-facing re-enable path).
|
||||||
|
if code := postForwards(t, ts, "start", stopForwardReq{"svc", "srv-a", 45999}); code != http.StatusOK {
|
||||||
|
t.Fatalf("start status = %d, want 200", code)
|
||||||
|
}
|
||||||
|
ln, _, err = h.Store.LinkByTriple("svc", "srv-a", 45999)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ln.Disabled {
|
||||||
|
t.Fatal("start did not clear disabled; a re-enabled forward would stay stopped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStopForwardIsNonDestructive pins the per-forward stop semantics the
|
||||||
|
// revoke path was specifically rewritten for: stopping one forward must not
|
||||||
|
// touch a sibling forward that shares the same local or remote.
|
||||||
|
func TestStopForwardIsNonDestructive(t *testing.T) {
|
||||||
|
h, ts := newTestHandler(t)
|
||||||
|
|
||||||
|
body := `{
|
||||||
|
"locals": [{"name":"svc","ip":"127.0.0.1","port":59999,"protocol":"tcp"}],
|
||||||
|
"remotes": [{"name":"srv-a","ip":"1.2.3.4","port":7000,"enabled":true}],
|
||||||
|
"links": [
|
||||||
|
{"local":"svc","remote":"srv-a","remotePort":45999},
|
||||||
|
{"local":"svc","remote":"srv-a","remotePort":46000}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/manager/canvas", bytes.NewBufferString(body))
|
||||||
|
req.SetBasicAuth("admin", "pw")
|
||||||
|
resp, err := ts.Client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
if code := postForwards(t, ts, "stop", stopForwardReq{"svc", "srv-a", 45999}); code != http.StatusOK {
|
||||||
|
t.Fatalf("stop status = %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
stopped, _, _ := h.Store.LinkByTriple("svc", "srv-a", 45999)
|
||||||
|
sibling, found, _ := h.Store.LinkByTriple("svc", "srv-a", 46000)
|
||||||
|
if !found {
|
||||||
|
t.Fatal("sibling forward was destroyed by stopping its neighbour")
|
||||||
|
}
|
||||||
|
if !stopped.Disabled {
|
||||||
|
t.Error("the stopped forward should be disabled")
|
||||||
|
}
|
||||||
|
if sibling.Disabled {
|
||||||
|
t.Error("the sibling forward must stay enabled — per-forward stop, not per-local/remote")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -245,7 +245,12 @@ func (h *Handler) applyCanvas(w http.ResponseWriter, r *http.Request, canvas *ca
|
|||||||
}
|
}
|
||||||
if ln.Disabled {
|
if ln.Disabled {
|
||||||
// Stopped on the forwards page: make sure it leaves the topology.
|
// Stopped on the forwards page: make sure it leaves the topology.
|
||||||
if h.Ring.HasTask(ln.Local, ln.Remote, ln.RemotePort) {
|
// Guard on an ACTIVE task — an entry that is already marked
|
||||||
|
// disabled has nothing left to revoke, and re-issuing a revocation
|
||||||
|
// for it on every canvas save would be pure noise. (HasTask, which
|
||||||
|
// also matches disabled entries, is the right predicate for the
|
||||||
|
// "already handled" question this branch is not asking.)
|
||||||
|
if h.Ring.HasActiveTask(ln.Local, ln.Remote, ln.RemotePort) {
|
||||||
h.Ring.RevokeTask(loc, rem, ln)
|
h.Ring.RevokeTask(loc, rem, ln)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
|
|||||||
@ -84,6 +84,25 @@ func (h *Handler) startForward(local, remote string, port int) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if h.Ring != nil {
|
||||||
|
// Publish the enable into the topology so it rides the ring (a peer that
|
||||||
|
// still holds the stale "stopped" copy learns about it on adoption).
|
||||||
|
h.Ring.UpdateTopologyDisabled(local, remote, port, false)
|
||||||
|
}
|
||||||
|
// A cluster forward whose topology entry still exists needs its OWNER to
|
||||||
|
// bring the worker back, and neither of the normal channels can do it:
|
||||||
|
// - SubmitTask is idempotency-guarded, and the entry still exists (a stop
|
||||||
|
// keeps it as the flag's carrier), so the submission is deduped away;
|
||||||
|
// - the claim path drops any task for a forward that already has an
|
||||||
|
// owner, as a defence against duplicate-claim collisions.
|
||||||
|
// So a re-enable of an existing entry is published as a dedicated RESTART
|
||||||
|
// task, which the owner applies unconditionally. Without it a forward the
|
||||||
|
// user can stop but not restart — which is what marking-instead-of-removing
|
||||||
|
// would otherwise have produced.
|
||||||
|
if h.Ring != nil && h.Ring.HasTopologyEntry(local, remote, port) {
|
||||||
|
h.Ring.SubmitRestart(loc, rem, ln)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if h.Ring != nil {
|
if h.Ring != nil {
|
||||||
h.Ring.SubmitTask(loc, rem, ln)
|
h.Ring.SubmitTask(loc, rem, ln)
|
||||||
}
|
}
|
||||||
@ -109,6 +128,24 @@ func (h *Handler) stopForward(local, remote string, port int) error {
|
|||||||
} else {
|
} else {
|
||||||
_ = h.Store.SetLinkDisabled(local, remote, port, true)
|
_ = h.Store.SetLinkDisabled(local, remote, port, true)
|
||||||
}
|
}
|
||||||
|
// The revoke task travels to whichever node OWNS the forward, and that node
|
||||||
|
// re-reads the disabled flag from its own store before starting a worker —
|
||||||
|
// so the flag has to be set on every node that has a copy of this link, not
|
||||||
|
// just the one handling this request. Propagating Disabled on the task lets
|
||||||
|
// the owner's RevokeFn stop the worker even if its own store row is stale.
|
||||||
|
//
|
||||||
|
// This also fixes a latent inconsistency: `ln` was read BEFORE the
|
||||||
|
// SetLinkDisabled(true) above, so the link published into the token still
|
||||||
|
// carried disabled=false and got copied into the topology entry verbatim.
|
||||||
|
ln.Disabled = true
|
||||||
|
// Publish the stop into the topology BEFORE revoking: the revoke retires the
|
||||||
|
// own-side worker/entry, so the flag must already exist somewhere that
|
||||||
|
// survives it and travels the ring. This is the send half of cluster-wide
|
||||||
|
// disabled propagation (see store.ReconcileLinkDisabled for the receive
|
||||||
|
// half).
|
||||||
|
if h.Ring != nil {
|
||||||
|
h.Ring.UpdateTopologyDisabled(local, remote, port, true)
|
||||||
|
}
|
||||||
if loc.LocalOnly {
|
if loc.LocalOnly {
|
||||||
if h.Process != nil {
|
if h.Process != nil {
|
||||||
key := process.WorkerKey(local, remote, port)
|
key := process.WorkerKey(local, remote, port)
|
||||||
|
|||||||
@ -9,6 +9,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@ -18,6 +20,7 @@ import (
|
|||||||
"webui4frpc/internal/cluster"
|
"webui4frpc/internal/cluster"
|
||||||
"webui4frpc/internal/process"
|
"webui4frpc/internal/process"
|
||||||
"webui4frpc/internal/store"
|
"webui4frpc/internal/store"
|
||||||
|
"webui4frpc/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed all:dist
|
//go:embed all:dist
|
||||||
@ -411,7 +414,7 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resp := map[string]any{
|
resp := map[string]any{
|
||||||
"version": "0.1.0",
|
"version": version.Version,
|
||||||
"workDir": h.WorkDir,
|
"workDir": h.WorkDir,
|
||||||
"settings": settings,
|
"settings": settings,
|
||||||
"services": locals,
|
"services": locals,
|
||||||
@ -538,11 +541,31 @@ func (h *Handler) handleClusterToken(w http.ResponseWriter, r *http.Request) {
|
|||||||
// predecessor's WatchLeader heartbeat fail → MarkOffline → becomeLeader
|
// predecessor's WatchLeader heartbeat fail → MarkOffline → becomeLeader
|
||||||
// → StartRing, healing the ring. Per design: "心跳拒绝应当发生在leader
|
// → StartRing, healing the ring. Per design: "心跳拒绝应当发生在leader
|
||||||
// 退出节点时,让leader上邻居意识到当前环已经没有节点了".
|
// 退出节点时,让leader上邻居意识到当前环已经没有节点了".
|
||||||
|
//
|
||||||
|
// 同时,心跳到达说明前驱还活着,leader 应在地自己的拓扑中把它标记为
|
||||||
|
// alive。否则 forwardToNext 一次超时把前驱 MarkOffline 后就再没有
|
||||||
|
// 途径恢复它——前驱明明活着,leader 却认为它 offline → AliveSuccessor
|
||||||
|
// 找不到后继,令牌冻结在 leader 手上。心跳是 leader 侧纠正误判的唯一
|
||||||
|
// 入口(因为心跳只有前驱发给 leader 这一条边)。
|
||||||
if r.Body == nil {
|
if r.Body == nil {
|
||||||
if s := h.Ring.State(); len(s.Nodes) <= 1 {
|
if s := h.Ring.State(); len(s.Nodes) <= 1 {
|
||||||
http.Error(w, "standalone node", http.StatusConflict)
|
http.Error(w, "standalone node", http.StatusConflict)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 心跳到达 = 前驱活着,标记 alive(纠正 forwardToNext 的误判)
|
||||||
|
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||||
|
s := h.Ring.State()
|
||||||
|
for i := range s.Nodes {
|
||||||
|
if h, _, e := net.SplitHostPort(s.Nodes[i].Addr); e == nil && h == host {
|
||||||
|
if !s.Nodes[i].Alive {
|
||||||
|
log.Printf("heartbeat from %s revives it (was offline)", s.Nodes[i].ID)
|
||||||
|
}
|
||||||
|
s.Nodes[i].Alive = true
|
||||||
|
s.Nodes[i].LastSeen = time.Now().Unix()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -552,6 +575,20 @@ func (h *Handler) handleClusterToken(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "standalone node", http.StatusConflict)
|
http.Error(w, "standalone node", http.StatusConflict)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 同上:心跳到达 = 前驱活着
|
||||||
|
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||||
|
s := h.Ring.State()
|
||||||
|
for i := range s.Nodes {
|
||||||
|
if h, _, e := net.SplitHostPort(s.Nodes[i].Addr); e == nil && h == host {
|
||||||
|
if !s.Nodes[i].Alive {
|
||||||
|
log.Printf("heartbeat from %s revives it (was offline)", s.Nodes[i].ID)
|
||||||
|
}
|
||||||
|
s.Nodes[i].Alive = true
|
||||||
|
s.Nodes[i].LastSeen = time.Now().Unix()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
288
internal/store/link_test.go
Normal file
288
internal/store/link_test.go
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seed inserts the local + remote rows a link's foreign keys require.
|
||||||
|
// (links.local / links.remote reference their own tables, so a link cannot
|
||||||
|
// exist on its own — the same reason ClaimFn upserts them before ReplaceLinks.)
|
||||||
|
func seed(t *testing.T, st *Store, locals []string, remote string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, n := range locals {
|
||||||
|
if err := st.UpsertLocal(Local{Name: n, IP: "127.0.0.1", Port: 8080, Protocol: "tcp"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := st.UpsertRemote(Remote{Name: remote, IP: "1.2.3.4", Port: 7000, Token: "tok", Enabled: true}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLinkByTripleSurvivesReplaceLinks pins the reason LinkByTriple exists.
|
||||||
|
//
|
||||||
|
// ReplaceLinks() rewrites the whole table with DELETE + re-INSERT, so sqlite
|
||||||
|
// hands every row a FRESH autoincrement id. A Link captured before such a
|
||||||
|
// write (e.g. one riding inside a ring token) therefore carries an id that
|
||||||
|
// either matches a different forward or matches nothing. The natural key
|
||||||
|
// (local, remote, remotePort) is what every caller actually identifies a
|
||||||
|
// forward by, and it must survive those rewrites.
|
||||||
|
func TestLinkByTripleSurvivesReplaceLinks(t *testing.T) {
|
||||||
|
st, err := New(filepath.Join(t.TempDir(), "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
seed(t, st, []string{"alpha", "beta", "gamma"}, "srv")
|
||||||
|
links := []Link{
|
||||||
|
{Local: "alpha", Remote: "srv", RemotePort: 100},
|
||||||
|
{Local: "beta", Remote: "srv", RemotePort: 200},
|
||||||
|
{Local: "gamma", Remote: "srv", RemotePort: 300},
|
||||||
|
}
|
||||||
|
if err := st.ReplaceLinks(links); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture the ids as the ring would have them.
|
||||||
|
before := map[string]int64{}
|
||||||
|
all, err := st.ListLinks()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, l := range all {
|
||||||
|
before[l.Local] = l.ID
|
||||||
|
}
|
||||||
|
if len(before) != 3 {
|
||||||
|
t.Fatalf("expected 3 links, got %d", len(before))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewrite the table (this is what saveCanvas and ClaimFn both do).
|
||||||
|
if err := st.ReplaceLinks(links); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
after, err := st.ListLinks()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(after) != 3 {
|
||||||
|
t.Fatalf("expected 3 links after rewrite, got %d", len(after))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The natural key must still resolve to the right forward, with its
|
||||||
|
// disabled flag and group intact.
|
||||||
|
for _, l := range after {
|
||||||
|
if l.Disabled {
|
||||||
|
t.Errorf("link %s unexpectedly disabled after a plain rewrite", l.Local)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got, found, err := st.LinkByTriple("beta", "srv", 200)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("LinkByTriple failed to find beta after ReplaceLinks")
|
||||||
|
}
|
||||||
|
if got.Local != "beta" || got.RemotePort != 200 {
|
||||||
|
t.Fatalf("LinkByTriple returned the wrong row: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLinkByTripleNotFoundIsNotError documents the contract callers rely on:
|
||||||
|
// "no persisted opinion yet" is (Link{}, false, nil), not an error. A fresh
|
||||||
|
// claim of a link with no row must be allowed to start.
|
||||||
|
func TestLinkByTripleNotFoundIsNotError(t *testing.T) {
|
||||||
|
st, err := New(filepath.Join(t.TempDir(), "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
got, found, err := st.LinkByTriple("nope", "srv", 1234)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("missing link must not be an error, got %v", err)
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
t.Fatalf("missing link reported as found: %+v", got)
|
||||||
|
}
|
||||||
|
if got.Local != "" || got.RemotePort != 0 {
|
||||||
|
t.Fatalf("expected zero Link on miss, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLinkByTripleReadsDisabledFlag is the store-level half of the
|
||||||
|
// "stopped forwards resurrect on restart" bug: the claim path asks the store
|
||||||
|
// whether the user disabled this forward, so this lookup must return the flag
|
||||||
|
// as persisted.
|
||||||
|
func TestLinkByTripleReadsDisabledFlag(t *testing.T) {
|
||||||
|
st, err := New(filepath.Join(t.TempDir(), "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
seed(t, st, []string{"mc"}, "srv")
|
||||||
|
if err := st.ReplaceLinks([]Link{{Local: "mc", Remote: "srv", RemotePort: 25565, Group: "game"}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.SetLinkDisabled("mc", "srv", 25565, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ln, found, err := st.LinkByTriple("mc", "srv", 25565)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("expected to find the link")
|
||||||
|
}
|
||||||
|
if !ln.Disabled {
|
||||||
|
t.Fatal("expected Disabled=true to be visible through LinkByTriple")
|
||||||
|
}
|
||||||
|
if ln.Group != "game" {
|
||||||
|
t.Fatalf("group should survive, got %q", ln.Group)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...and the user-facing start path must be able to clear it again.
|
||||||
|
if err := st.SetLinkDisabled("mc", "srv", 25565, false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ln, _, _ := st.LinkByTriple("mc", "srv", 25565); ln.Disabled {
|
||||||
|
t.Fatal("expected Disabled=false after clearing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetLinkByIDIsStaleAfterReplaceLinks documents WHY callers must not use
|
||||||
|
// GetLink(id) with a previously captured id. It is not a fix — it is the trap
|
||||||
|
// being pinned shut, so the hazard stays visible if someone reintroduces it.
|
||||||
|
func TestGetLinkByIDIsStaleAfterReplaceLinks(t *testing.T) {
|
||||||
|
st, err := New(filepath.Join(t.TempDir(), "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
seed(t, st, []string{"alpha", "beta", "gamma"}, "srv")
|
||||||
|
if err := st.ReplaceLinks([]Link{
|
||||||
|
{Local: "alpha", Remote: "srv", RemotePort: 100},
|
||||||
|
{Local: "beta", Remote: "srv", RemotePort: 200},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
all, _ := st.ListLinks()
|
||||||
|
var staleID int64
|
||||||
|
for _, l := range all {
|
||||||
|
if l.Local == "alpha" {
|
||||||
|
staleID = l.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := st.ReplaceLinks([]Link{
|
||||||
|
{Local: "alpha", Remote: "srv", RemotePort: 100},
|
||||||
|
{Local: "beta", Remote: "srv", RemotePort: 200},
|
||||||
|
{Local: "gamma", Remote: "srv", RemotePort: 300},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The old id may still resolve, but to whatever row now occupies that
|
||||||
|
// id — which is exactly the silent-mis-target hazard. Assert that the
|
||||||
|
// natural key remains the only safe handle.
|
||||||
|
if ln, ok := st.GetLink(staleID); ok && ln.Local != "alpha" {
|
||||||
|
t.Logf("stale id %d now points at %q (hazard confirmed; use LinkByTriple)", staleID, ln.Local)
|
||||||
|
}
|
||||||
|
if got, found, _ := st.LinkByTriple("alpha", "srv", 100); !found || got.Local != "alpha" {
|
||||||
|
t.Fatalf("natural key must stay reliable, got %+v found=%v", got, found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReconcileLinkDisabledLearnsPeerDecision is the store half of
|
||||||
|
// cluster-wide stop propagation. A node that did NOT serve the stop request has
|
||||||
|
// no reason to know about it, and its links table is node-local — so the flag
|
||||||
|
// arrives via the ring and lands here. The case that matters is the OWNER of a
|
||||||
|
// forward on a different machine: before this existed, that node's copy still
|
||||||
|
// read "enabled", so it kept (or re-spawned) the worker for a forward the user
|
||||||
|
// had explicitly stopped.
|
||||||
|
func TestReconcileLinkDisabledLearnsPeerDecision(t *testing.T) {
|
||||||
|
st, err := New(filepath.Join(t.TempDir(), "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
seed(t, st, []string{"mc"}, "srv")
|
||||||
|
if err := st.ReplaceLinks([]Link{{Local: "mc", Remote: "srv", RemotePort: 25565, Group: "game"}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A peer's stop arrives.
|
||||||
|
if err := st.ReconcileLinkDisabled("mc", "srv", 25565, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ln, found, err := st.LinkByTriple("mc", "srv", 25565)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("link disappeared during reconcile")
|
||||||
|
}
|
||||||
|
if !ln.Disabled {
|
||||||
|
t.Fatal("the peer's stop did not land in the local store")
|
||||||
|
}
|
||||||
|
if ln.Group != "game" {
|
||||||
|
t.Fatalf("reconcile must not clobber other fields, group=%q", ln.Group)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A peer's re-enable arrives.
|
||||||
|
if err := st.ReconcileLinkDisabled("mc", "srv", 25565, false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ln, _, _ := st.LinkByTriple("mc", "srv", 25565); ln.Disabled {
|
||||||
|
t.Fatal("the peer's re-enable did not land")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReconcileLinkDisabledCreatesPlaceholderForUnknownForward: a node that has
|
||||||
|
// never seen the forward still must remember that it is stopped, otherwise a
|
||||||
|
// later claim on that node would resurrect it.
|
||||||
|
func TestReconcileLinkDisabledCreatesPlaceholderForUnknownForward(t *testing.T) {
|
||||||
|
st, err := New(filepath.Join(t.TempDir(), "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
seed(t, st, []string{"ghost"}, "srv")
|
||||||
|
|
||||||
|
if err := st.ReconcileLinkDisabled("ghost", "srv", 9999, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ln, found, err := st.LinkByTriple("ghost", "srv", 9999)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("a stopped-but-unknown forward must be remembered, or a later claim resurrects it")
|
||||||
|
}
|
||||||
|
if !ln.Disabled {
|
||||||
|
t.Fatal("placeholder is not marked disabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReconcileLinkDisabledIgnoresEnableForUnknown: an enable for a forward this
|
||||||
|
// node has never seen must NOT create a row. Creating one would invent forwards
|
||||||
|
// out of ring state.
|
||||||
|
func TestReconcileLinkDisabledIgnoresEnableForUnknown(t *testing.T) {
|
||||||
|
st, err := New(filepath.Join(t.TempDir(), "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
seed(t, st, []string{"other"}, "srv")
|
||||||
|
|
||||||
|
if err := st.ReconcileLinkDisabled("unknown", "srv", 1234, false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, found, _ := st.LinkByTriple("unknown", "srv", 1234); found {
|
||||||
|
t.Fatal("an enable for an unknown forward must not materialise a row")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -560,6 +560,30 @@ func (s *Store) GetLink(id int64) (Link, bool) {
|
|||||||
return l, true
|
return l, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LinkByTriple looks a link up by its natural key (local, remote, remotePort).
|
||||||
|
//
|
||||||
|
// Prefer this over GetLink(id) whenever the caller only knows the forward's
|
||||||
|
// identity: ReplaceLinks() rewrites the whole table with DELETE + re-INSERT, so
|
||||||
|
// every row gets a fresh autoincrement id. Any id captured before such a write
|
||||||
|
// (e.g. a Link carried inside a ring token) is stale by definition and will
|
||||||
|
// either miss or — worse — match a different forward. The natural key is
|
||||||
|
// stable across those rewrites.
|
||||||
|
//
|
||||||
|
// Returns (link, found). A missing row is (Link{}, false) and is NOT an error:
|
||||||
|
// callers use that to mean "no persisted opinion yet".
|
||||||
|
func (s *Store) LinkByTriple(local, remote string, port int) (Link, bool, error) {
|
||||||
|
var l Link
|
||||||
|
err := s.db.QueryRow("SELECT id, local, remote, remote_port, offset_x, offset_y, grp, disabled FROM links WHERE local = ? AND remote = ? AND remote_port = ?", local, remote, port).
|
||||||
|
Scan(&l.ID, &l.Local, &l.Remote, &l.RemotePort, &l.OffsetX, &l.OffsetY, &l.Group, &l.Disabled)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return Link{}, false, nil
|
||||||
|
}
|
||||||
|
return Link{}, false, err
|
||||||
|
}
|
||||||
|
return l, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteLink removes a single link by id.
|
// DeleteLink removes a single link by id.
|
||||||
func (s *Store) DeleteLink(id int64) error {
|
func (s *Store) DeleteLink(id int64) error {
|
||||||
_, err := s.db.Exec("DELETE FROM links WHERE id = ?", id)
|
_, err := s.db.Exec("DELETE FROM links WHERE id = ?", id)
|
||||||
@ -639,6 +663,11 @@ func (s *Store) ReplaceLinks(links []Link) error {
|
|||||||
// SetLinkDisabled flips the disabled flag of a forward identified by its
|
// SetLinkDisabled flips the disabled flag of a forward identified by its
|
||||||
// (local, remote, remotePort) natural key. This is the persistence half of the
|
// (local, remote, remotePort) natural key. This is the persistence half of the
|
||||||
// forwards-page start/stop toggle; the caller also drives the worker/ring side.
|
// forwards-page start/stop toggle; the caller also drives the worker/ring side.
|
||||||
|
//
|
||||||
|
// Kept as a targeted UPDATE rather than a ReplaceLinks rewrite on purpose:
|
||||||
|
// ReplaceLinks deletes and reinserts every row, handing out fresh autoincrement
|
||||||
|
// ids and invalidating any Link a caller captured earlier (they travel inside
|
||||||
|
// ring tokens). Flipping one flag must not perturb other rows' identity.
|
||||||
func (s *Store) SetLinkDisabled(local, remote string, port int, disabled bool) error {
|
func (s *Store) SetLinkDisabled(local, remote string, port int, disabled bool) error {
|
||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
"UPDATE links SET disabled = ? WHERE local = ? AND remote = ? AND remote_port = ?",
|
"UPDATE links SET disabled = ? WHERE local = ? AND remote = ? AND remote_port = ?",
|
||||||
@ -647,6 +676,52 @@ func (s *Store) SetLinkDisabled(local, remote string, port int, disabled bool) e
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReconcileLinkDisabled applies a cluster-wide view of one forward's disabled
|
||||||
|
// flag into the local store, creating a placeholder row when this node has none
|
||||||
|
// yet.
|
||||||
|
//
|
||||||
|
// This is the receive half of disabled-flag propagation. The stop decision is
|
||||||
|
// made on whichever node served the request, then rides the token ring in the
|
||||||
|
// topology entry; every other member calls this on adoption so its own
|
||||||
|
// links table agrees. Without it the flag lived only on the node that handled
|
||||||
|
// the request, and the node actually OWNS the forward — usually a different
|
||||||
|
// machine — still believed the forward was enabled and re-spawned its worker.
|
||||||
|
//
|
||||||
|
// A placeholder row is deliberate: a node that has never seen the forward still
|
||||||
|
// needs to remember "this is stopped" so a later claim on this node cannot
|
||||||
|
// resurrect it. The placeholder carries the same natural key, so a subsequent
|
||||||
|
// real claim fills in the rest.
|
||||||
|
func (s *Store) ReconcileLinkDisabled(local, remote string, port int, disabled bool) error {
|
||||||
|
cur, found, err := s.LinkByTriple(local, remote, port)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
if cur.Disabled == disabled {
|
||||||
|
return nil // already agrees; avoid needless writes every token cycle
|
||||||
|
}
|
||||||
|
return s.SetLinkDisabled(local, remote, port, disabled)
|
||||||
|
}
|
||||||
|
if !disabled {
|
||||||
|
// Nothing to remember: an unknown forward with no entry is simply
|
||||||
|
// "not stopped", which is the default the claim path already assumes.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Need a placeholder, which requires the local/remote foreign keys to exist.
|
||||||
|
if _, ok := s.GetLocal(local); !ok {
|
||||||
|
return nil // cannot materialise a link without its local peer row
|
||||||
|
}
|
||||||
|
if _, ok := s.GetRemote(remote); !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
links, err := s.ListLinks()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
links = append(links, Link{Local: local, Remote: remote, RemotePort: port, Disabled: true})
|
||||||
|
return s.ReplaceLinks(links)
|
||||||
|
}
|
||||||
|
|
||||||
// SetLinkGroup assigns a management group label to a forward identified by its
|
// SetLinkGroup assigns a management group label to a forward identified by its
|
||||||
// (local, remote, remotePort) natural key. Empty string clears the group
|
// (local, remote, remotePort) natural key. Empty string clears the group
|
||||||
// (moves the forward to 未分组). This is the persistence half of the
|
// (moves the forward to 未分组). This is the persistence half of the
|
||||||
|
|||||||
12
internal/version/version.go
Normal file
12
internal/version/version.go
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
// Package version holds the single source of truth for the build version.
|
||||||
|
//
|
||||||
|
// Value is overridable at build time so release artifacts carry the real tag:
|
||||||
|
//
|
||||||
|
// go build -ldflags="-X webui4frpc/internal/version.Version=0.1.2" ./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.2"
|
||||||
21
packaging/common/LICENSE.txt
Normal file
21
packaging/common/LICENSE.txt
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 JianFeeeee
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
8
packaging/common/webui4frpc.env
Normal file
8
packaging/common/webui4frpc.env
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# webui4frpc runtime configuration
|
||||||
|
# 默认监听全部接口的 7500 端口;请修改密码后再投入生产。
|
||||||
|
W4F_ADDR=0.0.0.0:7500
|
||||||
|
W4F_USER=admin
|
||||||
|
W4F_PASSWORD=admin
|
||||||
|
|
||||||
|
# 可选:首次提供的 frpc 二进制路径(留空则从 Web UI 手动/自动安装)
|
||||||
|
# W4F_FRPC=/usr/local/bin/frpc
|
||||||
25
packaging/common/webui4frpc.service
Normal file
25
packaging/common/webui4frpc.service
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=webui4frpc - visual frpc controller (cluster node)
|
||||||
|
Documentation=https://gitcode.com/JianFeeeee/webui4frpc
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
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
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
LimitNOFILE=65535
|
||||||
|
|
||||||
|
# hardening
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=full
|
||||||
|
ProtectHome=true
|
||||||
|
ReadWritePaths=/var/lib/webui4frpc
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
42
packaging/deb/postinst
Executable file
42
packaging/deb/postinst
Executable file
@ -0,0 +1,42 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# webui4frpc deb/rpm 安装后脚本:建用户、建目录、装服务
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if ! getent group webui4frpc >/dev/null 2>&1; then
|
||||||
|
groupadd --system webui4frpc
|
||||||
|
fi
|
||||||
|
if ! getent passwd webui4frpc >/dev/null 2>&1; then
|
||||||
|
useradd --system --gid webui4frpc \
|
||||||
|
--home-dir /var/lib/webui4frpc --no-create-home \
|
||||||
|
--shell /usr/sbin/nologin \
|
||||||
|
--comment "webui4frpc service account" webui4frpc
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p /var/lib/webui4frpc
|
||||||
|
chown -R webui4frpc:webui4frpc /var/lib/webui4frpc
|
||||||
|
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
|
||||||
|
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
systemctl daemon-reload || true
|
||||||
|
systemctl enable webui4frpc.service || true
|
||||||
|
if [ -z "${DPKG_ROOT:-}" ]; then
|
||||||
|
systemctl restart webui4frpc.service || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<'EOF'
|
||||||
|
|
||||||
|
webui4frpc 已安装。
|
||||||
|
|
||||||
|
配置: /etc/webui4frpc/webui4frpc.env (请立即修改 W4F_USER / W4F_PASSWORD)
|
||||||
|
数据: /var/lib/webui4frpc
|
||||||
|
服务: systemctl status webui4frpc
|
||||||
|
界面: http://<本机地址>:7500
|
||||||
|
|
||||||
|
改完配置后执行: systemctl restart webui4frpc
|
||||||
|
|
||||||
|
EOF
|
||||||
|
|
||||||
|
exit 0
|
||||||
20
packaging/deb/postrm
Executable file
20
packaging/deb/postrm
Executable file
@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# webui4frpc deb 卸载后脚本:purge 时清数据与账号
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
systemctl daemon-reload || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$1" = "purge" ]; then
|
||||||
|
rm -rf /var/lib/webui4frpc
|
||||||
|
rm -rf /etc/webui4frpc
|
||||||
|
if getent passwd webui4frpc >/dev/null 2>&1; then
|
||||||
|
userdel webui4frpc || true
|
||||||
|
fi
|
||||||
|
if getent group webui4frpc >/dev/null 2>&1; then
|
||||||
|
groupdel webui4frpc || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
10
packaging/deb/prerm
Executable file
10
packaging/deb/prerm
Executable file
@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# webui4frpc deb 卸载前脚本:停服务、撤 enable
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
systemctl stop webui4frpc.service || true
|
||||||
|
systemctl disable webui4frpc.service || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
187
packaging/macos/build_pkg.sh
Executable file
187
packaging/macos/build_pkg.sh
Executable file
@ -0,0 +1,187 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# 在 Linux 上手工组装 macOS .pkg (flat package)。
|
||||||
|
# macOS 的 pkgbuild/productbuild 不可用,因此用 xar + mkbom 手搓 flat package:
|
||||||
|
#
|
||||||
|
# archive.xar
|
||||||
|
# ├── Distribution 安装器界面描述 (choices / title)
|
||||||
|
# └── webui4frpc.pkg/
|
||||||
|
# ├── PackageInfo 组件元数据 + postinstall 声明
|
||||||
|
# ├── Bom 文件清单 (mkbom 生成)
|
||||||
|
# ├── Payload 根文件系统 cpio.gz
|
||||||
|
# └── Scripts 安装脚本 cpio.gz
|
||||||
|
#
|
||||||
|
# 用法: build_pkg.sh <version> <arch:amd64|arm64> <binary> <outfile>
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION="$1"; ARCH="$2"
|
||||||
|
# xar 与 cpio 都在临时目录里执行,输入输出必须先转成绝对路径
|
||||||
|
BINARY="$(cd "$(dirname "$3")" && pwd)/$(basename "$3")"
|
||||||
|
mkdir -p "$(dirname "$4")"
|
||||||
|
OUTFILE="$(cd "$(dirname "$4")" && pwd)/$(basename "$4")"
|
||||||
|
|
||||||
|
case "$ARCH" in
|
||||||
|
amd64) PKG_ARCH="x86_64" ;;
|
||||||
|
arm64) PKG_ARCH="arm64" ;;
|
||||||
|
*) echo "未知架构: $ARCH" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
WORK="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$WORK"' EXIT
|
||||||
|
|
||||||
|
ROOT="$WORK/root"
|
||||||
|
SCRIPTS="$WORK/scripts"
|
||||||
|
FLAT="$WORK/flat"
|
||||||
|
PKGDIR="$FLAT/webui4frpc.pkg"
|
||||||
|
mkdir -p "$ROOT/usr/local/bin" "$ROOT/usr/local/etc/webui4frpc" "$SCRIPTS" "$PKGDIR"
|
||||||
|
|
||||||
|
# ---- 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"
|
||||||
|
|
||||||
|
# ---- launchd plist ----
|
||||||
|
mkdir -p "$ROOT/Library/LaunchDaemons"
|
||||||
|
cat > "$ROOT/Library/LaunchDaemons/com.jianfeeeee.webui4frpc.plist" <<'PLISTEOF'
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||||
|
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>com.jianfeeeee.webui4frpc</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>/usr/local/bin/webui4frpc-launch</string>
|
||||||
|
</array>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<true/>
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>/usr/local/var/log/webui4frpc.log</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>/usr/local/var/log/webui4frpc.err.log</string>
|
||||||
|
<key>WorkingDirectory</key>
|
||||||
|
<string>/usr/local/var/lib/webui4frpc</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
PLISTEOF
|
||||||
|
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
|
||||||
|
set -e
|
||||||
|
ENV_FILE=/usr/local/etc/webui4frpc/webui4frpc.env
|
||||||
|
W4F_ADDR=0.0.0.0:7500
|
||||||
|
W4F_USER=admin
|
||||||
|
W4F_PASSWORD=admin
|
||||||
|
[ -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
|
||||||
|
LAUNCHEOF
|
||||||
|
chmod 0755 "$ROOT/usr/local/bin/webui4frpc-launch"
|
||||||
|
|
||||||
|
# ---- postinstall ----
|
||||||
|
cat > "$SCRIPTS/postinstall" <<'POSTEOF'
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
mkdir -p /usr/local/var/lib/webui4frpc /usr/local/var/log
|
||||||
|
chmod 750 /usr/local/var/lib/webui4frpc
|
||||||
|
|
||||||
|
PLIST=/Library/LaunchDaemons/com.jianfeeeee.webui4frpc.plist
|
||||||
|
chown root:wheel "$PLIST"
|
||||||
|
chmod 644 "$PLIST"
|
||||||
|
|
||||||
|
# 覆盖安装时先卸载旧的
|
||||||
|
launchctl bootout system "$PLIST" 2>/dev/null || true
|
||||||
|
launchctl bootstrap system "$PLIST" 2>/dev/null || \
|
||||||
|
launchctl load -w "$PLIST" 2>/dev/null || true
|
||||||
|
|
||||||
|
exit 0
|
||||||
|
POSTEOF
|
||||||
|
chmod 0755 "$SCRIPTS/postinstall"
|
||||||
|
|
||||||
|
# ---- Payload / Scripts (cpio.gz) ----
|
||||||
|
( cd "$ROOT" && find . -print | cpio -o --format odc --owner 0:0 2>/dev/null ) \
|
||||||
|
| gzip -9 > "$PKGDIR/Payload"
|
||||||
|
( cd "$SCRIPTS" && find . -print | cpio -o --format odc --owner 0:0 2>/dev/null ) \
|
||||||
|
| gzip -9 > "$PKGDIR/Scripts"
|
||||||
|
|
||||||
|
# ---- Bom ----
|
||||||
|
mkbom -u 0 -g 80 "$ROOT" "$PKGDIR/Bom"
|
||||||
|
|
||||||
|
NFILES=$(find "$ROOT" | wc -l)
|
||||||
|
INSTALL_KB=$(du -sk "$ROOT" | cut -f1)
|
||||||
|
|
||||||
|
# ---- PackageInfo ----
|
||||||
|
cat > "$PKGDIR/PackageInfo" <<INFOEOF
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<pkg-info format-version="2"
|
||||||
|
identifier="com.jianfeeeee.webui4frpc"
|
||||||
|
version="$VERSION"
|
||||||
|
install-location="/"
|
||||||
|
auth="root"
|
||||||
|
relocatable="false"
|
||||||
|
overwrite-permissions="true">
|
||||||
|
<payload installKBytes="$INSTALL_KB" numberOfFiles="$NFILES"/>
|
||||||
|
<scripts>
|
||||||
|
<postinstall file="./postinstall"/>
|
||||||
|
</scripts>
|
||||||
|
<bundle-version/>
|
||||||
|
</pkg-info>
|
||||||
|
INFOEOF
|
||||||
|
|
||||||
|
# ---- Distribution ----
|
||||||
|
cat > "$FLAT/Distribution" <<DISTEOF
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<installer-gui-script minSpecVersion="1">
|
||||||
|
<title>webui4frpc $VERSION</title>
|
||||||
|
<organization>com.jianfeeeee</organization>
|
||||||
|
<options customize="never" require-scripts="false" hostArchitectures="$PKG_ARCH"/>
|
||||||
|
<domains enable_localSystem="true" enable_anywhere="false" enable_currentUserHome="false"/>
|
||||||
|
<volume-check>
|
||||||
|
<allowed-os-versions><os-version min="11.0"/></allowed-os-versions>
|
||||||
|
</volume-check>
|
||||||
|
<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 开机自启服务
|
||||||
|
|
||||||
|
安装完成后服务自动启动,访问 http://127.0.0.1:7500
|
||||||
|
默认凭据 admin/admin —— 请立即修改 webui4frpc.env 中的密码。</welcome>
|
||||||
|
<choices-outline>
|
||||||
|
<line choice="default">
|
||||||
|
<line choice="com.jianfeeeee.webui4frpc"/>
|
||||||
|
</line>
|
||||||
|
</choices-outline>
|
||||||
|
<choice id="default"/>
|
||||||
|
<choice id="com.jianfeeeee.webui4frpc" visible="false">
|
||||||
|
<pkg-ref id="com.jianfeeeee.webui4frpc"/>
|
||||||
|
</choice>
|
||||||
|
<pkg-ref id="com.jianfeeeee.webui4frpc"
|
||||||
|
version="$VERSION"
|
||||||
|
onConclusion="none">#webui4frpc.pkg</pkg-ref>
|
||||||
|
</installer-gui-script>
|
||||||
|
DISTEOF
|
||||||
|
|
||||||
|
# ---- 打成 xar (Distribution 必须是第一个成员) ----
|
||||||
|
rm -f "$OUTFILE"
|
||||||
|
( cd "$FLAT" && xar --compression none -cf "$OUTFILE" Distribution webui4frpc.pkg )
|
||||||
|
|
||||||
|
echo "已生成: $OUTFILE ($(du -h "$OUTFILE" | cut -f1))"
|
||||||
82
packaging/rpm/webui4frpc.spec
Normal file
82
packaging/rpm/webui4frpc.spec
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
Name: webui4frpc
|
||||||
|
Version: %{?_w4f_version}%{!?_w4f_version:0.1.2}
|
||||||
|
Release: 1%{?dist}
|
||||||
|
Summary: Visual frpc controller with token-ring cluster
|
||||||
|
License: MIT
|
||||||
|
URL: https://gitcode.com/JianFeeeee/webui4frpc
|
||||||
|
BuildArch: %{?_w4f_arch}%{!?_w4f_arch:x86_64}
|
||||||
|
|
||||||
|
Requires(pre): shadow-utils
|
||||||
|
%{?systemd_requires}
|
||||||
|
BuildRequires: systemd-rpm-macros
|
||||||
|
|
||||||
|
%description
|
||||||
|
A web UI to manage FRP forwards across a cluster of nodes. Ships as a single
|
||||||
|
statically-linked binary with an embedded Vue frontend. Includes a token-ring
|
||||||
|
based cluster for distributed forward management, NIC network load sampling,
|
||||||
|
and audit-log CSV export.
|
||||||
|
|
||||||
|
%install
|
||||||
|
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_readme} %{buildroot}%{_docdir}/webui4frpc/README.md
|
||||||
|
install -d -m 0750 %{buildroot}%{_sharedstatedir}/webui4frpc
|
||||||
|
|
||||||
|
%pre
|
||||||
|
getent group webui4frpc >/dev/null || groupadd --system webui4frpc
|
||||||
|
getent passwd webui4frpc >/dev/null || \
|
||||||
|
useradd --system --gid webui4frpc \
|
||||||
|
--home-dir %{_sharedstatedir}/webui4frpc --no-create-home \
|
||||||
|
--shell /sbin/nologin \
|
||||||
|
--comment "webui4frpc service account" webui4frpc
|
||||||
|
exit 0
|
||||||
|
|
||||||
|
%post
|
||||||
|
%systemd_post webui4frpc.service
|
||||||
|
chown -R webui4frpc:webui4frpc %{_sharedstatedir}/webui4frpc || :
|
||||||
|
chown root:webui4frpc %{_sysconfdir}/webui4frpc/webui4frpc.env || :
|
||||||
|
cat <<'EOF'
|
||||||
|
|
||||||
|
webui4frpc 已安装。
|
||||||
|
|
||||||
|
配置: /etc/webui4frpc/webui4frpc.env (请立即修改 W4F_USER / W4F_PASSWORD)
|
||||||
|
数据: /var/lib/webui4frpc
|
||||||
|
服务: systemctl enable --now webui4frpc
|
||||||
|
界面: http://<本机地址>:7500
|
||||||
|
|
||||||
|
EOF
|
||||||
|
|
||||||
|
%preun
|
||||||
|
%systemd_preun webui4frpc.service
|
||||||
|
|
||||||
|
%postun
|
||||||
|
%systemd_postun_with_restart webui4frpc.service
|
||||||
|
if [ $1 -eq 0 ]; then
|
||||||
|
rm -rf %{_sharedstatedir}/webui4frpc
|
||||||
|
getent passwd webui4frpc >/dev/null && userdel webui4frpc || :
|
||||||
|
getent group webui4frpc >/dev/null && groupdel webui4frpc || :
|
||||||
|
fi
|
||||||
|
|
||||||
|
%files
|
||||||
|
%{_bindir}/webui4frpc
|
||||||
|
%{_unitdir}/webui4frpc.service
|
||||||
|
%dir %{_sysconfdir}/webui4frpc
|
||||||
|
%config(noreplace) %attr(0640,root,webui4frpc) %{_sysconfdir}/webui4frpc/webui4frpc.env
|
||||||
|
%dir %attr(0750,webui4frpc,webui4frpc) %{_sharedstatedir}/webui4frpc
|
||||||
|
%doc %{_docdir}/webui4frpc/README.md
|
||||||
|
|
||||||
|
%changelog
|
||||||
|
* Wed Sep 03 2026 JianFeeeee <jianfeeeee@gitcode> - 0.1.2-1
|
||||||
|
- 修复窄屏底部导航栏错位到顶部:顶栏的 backdrop-filter 会为
|
||||||
|
position:fixed 后代创建包含块,使 tab bar 的 bottom:0 相对顶栏而
|
||||||
|
非视口定位。窄屏下关掉顶栏模糊,改用近实色底。
|
||||||
|
|
||||||
|
* Wed Sep 02 2026 JianFeeeee <jianfeeeee@gitcode> - 0.1.1-1
|
||||||
|
- 修复令牌环冻结:心跳到达时复活被误判 offline 的前驱;
|
||||||
|
全环无可达后继时保留 inflight,让 leader 超时重发令牌。
|
||||||
|
- WebUI 窄屏适配:侧栅转底部 tab bar,各页响应式重排。
|
||||||
|
|
||||||
|
* Fri Aug 28 2026 JianFeeeee <jianfeeeee@gitcode> - 0.1.0-1
|
||||||
|
- 首次发布:per-forward worker 模型、NIC 负载采样、审计 CSV 导出、
|
||||||
|
session 登录与三级角色。
|
||||||
21
packaging/windows/LICENSE.txt
Normal file
21
packaging/windows/LICENSE.txt
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 JianFeeeee
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
268
packaging/windows/README.md
Normal file
268
packaging/windows/README.md
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
# webui4frpc
|
||||||
|
|
||||||
|
> **AI-assisted**: 本项目使用 AI 辅助编程(代码与文档由 AI 协作完成,经人工复核)。
|
||||||
|
一个**独立于 frp 源码**的可视化 frpc 控制器。用画布方式把「本地转发项」连到「远程服务器」,自动为每台远程服务器生成 frpc 配置并拉起独立 worker 进程,免去运维反复手写 frpc 配置的麻烦。支持多节点组成令牌环集群,协同分发转发任务。
|
||||||
|
|
||||||
|
> 本仓库**不捆绑、不包含任何 frp 源码**。frpc 可执行文件由用户一键从官方 GitHub Releases 下载,或手动指定路径。
|
||||||
|
|
||||||
|
## 特性
|
||||||
|
|
||||||
|
### 画布配置
|
||||||
|
|
||||||
|
- **Scratch 风格画布**:本地转发项(local)与远程服务器(remote)可视化连线,一个 local 可连多个 remote,一个 remote 可连多个 local
|
||||||
|
- **曲线连线 + 端口标签**:每条连线独立曲线,标签为远程端口,可拖拽调整曲线位置,端口可点开编辑
|
||||||
|
- **画布冲突检查**:保存时自动检测远程端口/域名重复,避免配置冲突
|
||||||
|
- **边开关与分组**:画布边上可一键禁用/启用单条转发,可打分组标签(状态页按分组管理、一键启停整组)
|
||||||
|
- **Del 键/× 按钮删除**:选中连线按 Delete 键或点击端口标签红 × 删除,确认后标记脏
|
||||||
|
- **一键生成配置**:保存画布即渲染每台 remote 的 frpc JSON 配置,自动拉起/重启对应 worker
|
||||||
|
- **画布导入导出**:支持 JSON 信封备份/恢复(含 provenance 元数据)
|
||||||
|
|
||||||
|
### frpc 能力
|
||||||
|
|
||||||
|
- **代理类型**:tcp(完整)、http/https(customDomains / subdomain / locations / basicAuth / headerRewrite)
|
||||||
|
- **传输参数**:useEncryption / useCompression / bandwidthLimit / poolCount / transport.protocol(tcp/quic/kcp/websocket)/ TLS
|
||||||
|
- **负载均衡与健康检查**:lbGroup + healthCheck 渲染输出;状态页通过 frpc admin API 拉取 per-proxy 真实状态(running / check failed / wait start …)
|
||||||
|
- **worker 自愈**:崩溃自动重启(指数退避),随 webui 启停
|
||||||
|
- **frpc 一键安装**:从官方 Releases 下载任意版本 frpc,或手动指定二进制路径
|
||||||
|
|
||||||
|
### 集群模式(令牌环)
|
||||||
|
|
||||||
|
- **令牌环协议**:若干 webui4frpc 节点组成协作网络;令牌按固定顺序传递,每节点持完整集群信息,单轮即全网收敛
|
||||||
|
- **负载最低者摘取**:新转发任务附加在令牌中,由负载最低的节点自行摘取并创建 frpc worker
|
||||||
|
- 主负载信号:当前节点拥有的转发数(权重 ×100)
|
||||||
|
- 次级信号:**NIC 网络接口利用率**(从 `/proc/net/dev` 增量 + `/sys/class/net/*/speed` 计算,排除了 lo/docker/veth 等虚拟接口)
|
||||||
|
- 同转发数节点间由内存占用 + 网络饱和度打破平局
|
||||||
|
- **per-forward 独立进程**:每条转发拥有独立的 frpc 配置文件和 frpc 进程(worker 键 = `local~remote~port`),单条转发故障不影响兄弟,消除旧模型的多节点 `proxy already exists` 冲突
|
||||||
|
- **容错自愈**:leader / 非 leader 节点宕机 → 自动检测 → 环跳过死亡节点、任务重挂;leader 宕机 → 上邻居晋升为新 leader
|
||||||
|
- **宕机自动重联**:节点宕机重启后通过缓存的集群拓扑(对端地址 + 准入密钥)自动重联,显式脱离集群的节点不自动重联
|
||||||
|
- **准入密钥**:每节点有 nodeKey,新节点加入需提供对端密钥;密钥随令牌环交换,全节点互通
|
||||||
|
- **集群二进制分发**:节点间优先互传 frpc 二进制,外部 URL 兜底
|
||||||
|
|
||||||
|
### 认证与权限
|
||||||
|
|
||||||
|
- **Session-cookie 登录 + Basic Auth**:UI 使用 session-cookie(支持登出),API 保持 Basic Auth + Bearer API Key 兼容
|
||||||
|
- **三级角色**:superadmin(账号管理)、admin(全功能除账号)、viewer(只读状态/集群/审计导出)
|
||||||
|
- **用户管理**:superadmin 角色创建/管理用户,bcrypt 存储,flag 内置账号为 system 只读
|
||||||
|
- **API Key**:read / write / admin 三级 scope,`w4f_` 前缀,sha256 存储,创建时明文仅展示一次
|
||||||
|
- **审计导出**:read 级 view 即可导出 CSV(账号登录记录 / API Key 使用记录 / 集群操作日志 / worker 运行日志)
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
### 后端(Go 1.25+)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o webui4frpc ./cmd/webui4frpc
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端(Node 20+)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web && npm install && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
前端产物在 `web/dist`,需拷贝到 `internal/httpapi/dist` 以便 Go `embed` 打包进单一二进制:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf internal/httpapi/dist && cp -r web/dist internal/httpapi/dist
|
||||||
|
```
|
||||||
|
|
||||||
|
一键全量构建(dist 不进入 git,编译前必须构建前端):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web && npm run build && rm -rf internal/httpapi/dist && cp -r web/dist internal/httpapi/dist && go build -o webui4frpc ./cmd/webui4frpc
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
从 [Release 页](https://gitcode.com/JianFeeeee/webui4frpc/releases/latest) 下载对应平台的安装包。
|
||||||
|
|
||||||
|
### Linux (Debian / Ubuntu)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo dpkg -i webui4frpc-0.1.1-linux-amd64.deb # 或 linux-arm64.deb
|
||||||
|
sudo nano /etc/webui4frpc/webui4frpc.env # 改掉默认密码
|
||||||
|
sudo systemctl restart webui4frpc
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux (RHEL / Fedora / openEuler / 默认 / 龙蜥)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo rpm -i webui4frpc-0.1.1-linux-x86_64.rpm
|
||||||
|
sudo vi /etc/webui4frpc/webui4frpc.env
|
||||||
|
sudo systemctl enable --now webui4frpc
|
||||||
|
```
|
||||||
|
|
||||||
|
安装后自动建 `webui4frpc` 系统账号、注册 systemd 单元,数据目录在 `/var/lib/webui4frpc`。
|
||||||
|
服务以非 root 运行,并启用 `ProtectSystem=full` / `NoNewPrivileges` 等加固项。
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
双击 `webui4frpc-0.1.1-windows-amd64-setup.exe`(ARM 设备用 `windows-arm64-setup.exe`)。
|
||||||
|
安装向导会询问监听地址与管理员凭据,完成后:
|
||||||
|
|
||||||
|
- 程序装到 `C:\Program Files\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
|
||||||
|
```
|
||||||
|
|
||||||
|
或直接双击 `.pkg` 走图形安装器。安装后 launchd 开机自启,配置在
|
||||||
|
`/usr/local/etc/webui4frpc/webui4frpc.env`,改完执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo launchctl kickstart -k system/com.jianfeeeee.webui4frpc
|
||||||
|
```
|
||||||
|
|
||||||
|
> 该 `.pkg` 未经 Apple 公证(notarization)。首次打开若被 Gatekeeper 拦下,
|
||||||
|
> 在「系统设置 → 隐私与安全」选择仍要打开。
|
||||||
|
|
||||||
|
### 免安装(tar.gz / zip)
|
||||||
|
|
||||||
|
不想装服务只想跑一下,用对应的 `.tar.gz` / `.zip`,解压即用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tar xzf webui4frpc-0.1.1-linux-amd64.tar.gz
|
||||||
|
./webui4frpc -addr 127.0.0.1:7500 -user admin -password 你的密码 -workdir ./data
|
||||||
|
```
|
||||||
|
|
||||||
|
下载后可用 `SHA256SUMS` 校验完整性:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sha256sum -c SHA256SUMS --ignore-missing
|
||||||
|
```
|
||||||
|
|
||||||
|
## 运行
|
||||||
|
|
||||||
|
从源码构建后直接跑:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./webui4frpc -addr 127.0.0.1:7500 -user admin -password admin123 -workdir ./data
|
||||||
|
```
|
||||||
|
|
||||||
|
打开 <http://127.0.0.1:7500/> ,输入账号密码进入画布。首次使用可到「设置」页一键安装 frpc(或手动指定路径),然后回到画布创建 local / remote 并连线、保存。
|
||||||
|
|
||||||
|
### 参数
|
||||||
|
|
||||||
|
| 参数 | 默认 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `-addr` | `127.0.0.1:7500` | 监听地址 |
|
||||||
|
| `-user` / `-password` | `admin` / `admin` | Basic 认证(自动同步为 system 管理员账号) |
|
||||||
|
| `-workdir` | `./webui-frpc` | 数据目录(db / 配置 / 日志 / bin) |
|
||||||
|
| `-bin` | 空 | 初始 frpc 二进制路径(可选,首次启动写入 settings) |
|
||||||
|
| `-frpc` | 空 | worker 默认 frpc 路径(settings 无值时的回退) |
|
||||||
|
| `-peer` | 空 | 集群对端地址 `host:port`(可重复,仅首次引导加入用) |
|
||||||
|
| `-join-key` | `$W4F_JOIN_KEY` | 集群准入密钥 = 对端节点的 nodeKey(与 `-peer` 配合使用) |
|
||||||
|
|
||||||
|
环境变量 `W4F_HOST` 可覆盖节点对外可达的主机名(Docker DNS / 通配监听场景用)。
|
||||||
|
|
||||||
|
### 启动行为
|
||||||
|
|
||||||
|
节点启动时的集群引导优先级:
|
||||||
|
|
||||||
|
1. **缓存拓扑优先**:读取持久化的 `ClusterPeers`(宕机前最后令牌周期保存的对端列表),逐个尝试 rejoin
|
||||||
|
2. **`-peer` 引导**:无缓存拓扑时,用 `-peer` + `-join-key` 加入指定对端(10s 重试、5min 超时)
|
||||||
|
3. **新建集群**:无缓存无 `-peer` → `CreateCluster` 成为独立 leader
|
||||||
|
|
||||||
|
显式脱离集群(detachAsStandalone)会清除 `ClusterPeers` → 重启后不自动重联。
|
||||||
|
|
||||||
|
## 数据目录
|
||||||
|
|
||||||
|
```
|
||||||
|
<workdir>
|
||||||
|
├── manager.db # SQLite(locals / remotes / links / settings / users / api_keys)
|
||||||
|
├── configs/<local>~<remote>~<port>.json # 每条转发的独立 frpc 配置(per-forward 模型)
|
||||||
|
├── logs/<local>~<remote>~<port>.log # 每条转发的独立 worker 日志(按大小轮转)
|
||||||
|
└── bin/frpc-<v>/frpc # 一键安装的 frpc
|
||||||
|
```
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
webui4frpc(单二进制,无 frp 依赖,前端 go:embed)
|
||||||
|
├── cmd/webui4frpc 入口:flag 解析 → store/process/cluster 装配 → HTTP 服务 + 生命周期
|
||||||
|
├── internal/
|
||||||
|
│ ├── store SQLite 持久化(locals / remotes / links / settings / users / api_keys / sessions)
|
||||||
|
│ ├── canvas 画布模型(local / remote / link 多对多)
|
||||||
|
│ ├── 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 负载采样)
|
||||||
|
│ └── httpapi REST API + 静态资源(auth/session / canvas / forwards / cluster / users / audit 导出)
|
||||||
|
└── web/ Vue 3 SPA(Element Plus + VueFlow 画布 + Pinia)
|
||||||
|
└── src/views/ 状态总览 / 连接配置(画布)/ 集群 / 设置 / 账号与密钥
|
||||||
|
```
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
所有接口前缀 `/api/manager`。UI 使用 session-cookie 登录(`/login` `/logout`),API 兼容 Basic Auth 或 Bearer API Key。权限分 read / write / admin 三级,角色 superadmin / admin / viewer 映射到不同 UI 可见性与操作能力。
|
||||||
|
|
||||||
|
| 类别 | 方法 | 路径 | 权限 | 说明 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| **状态** | GET | `/status` | read | 总览(版本/服务/节点/转发/profiles) |
|
||||||
|
| **画布** | GET/PUT | `/canvas` | read/write | 读写完整画布 |
|
||||||
|
| | GET | `/canvas/export` | read | 导出画布备份 |
|
||||||
|
| | POST | `/canvas/import` | write | 导入画布 |
|
||||||
|
| **转发** | POST | `/forwards/start\|stop` | write | 启停单条转发 |
|
||||||
|
| | POST | `/forwards/group/start\|stop` | write | 启停整组 |
|
||||||
|
| | POST | `/forwards/assign` | write | 修改转发分组 |
|
||||||
|
| | POST | `/forwards/group/delete` | write | 删除分组 |
|
||||||
|
| **单资源** | PUT/DELETE | `/locals[/{name}]` | write | 增删 local |
|
||||||
|
| | PUT/DELETE | `/remotes[/{name}]` | write | 增删 remote |
|
||||||
|
| | POST/DELETE | `/links[/{id}]` | write | 增删 link |
|
||||||
|
| **Profile** | POST | `/profiles/{name}/start\|stop\|restart` | write | worker 启停重启 |
|
||||||
|
| | GET | `/profiles/{name}/config\|logs` | read | 配置/日志 |
|
||||||
|
| **设置** | GET/PUT | `/settings` | read/write | 运行策略 |
|
||||||
|
| | GET | `/binary/status` | read | frpc 路径 |
|
||||||
|
| | POST | `/binary/install` | write | 安装 frpc |
|
||||||
|
| **集群** | GET | `/cluster/ring` | read | 令牌环快照 |
|
||||||
|
| | POST | `/cluster/create` | write | 创建独立集群 |
|
||||||
|
| | POST | `/cluster/join-ring` | write | 加入集群(本节点发起) |
|
||||||
|
| | POST | `/cluster/join` | write | 接收新节点加入 |
|
||||||
|
| | POST | `/cluster/task` | write | 提交转发任务到环 |
|
||||||
|
| | POST | `/cluster/node-remove` | write | 移除节点 |
|
||||||
|
| | POST | `/cluster/token` | write | 令牌中继 + 心跳 |
|
||||||
|
| | GET | `/cluster/nodes` | read | 集群节点 + 缓存 |
|
||||||
|
| | GET/POST | `/cluster/cache` | read/write | 二进制缓存管理 |
|
||||||
|
| | GET | `/node/logs` | read | 本节点 worker 日志 |
|
||||||
|
| | GET | `/cluster/logs/export` | read | 导出全节点 worker 日志(JSON bundle) |
|
||||||
|
| **审计导出** | GET | `/audit/users.csv` | read | 账号清单 CSV(含最后登录) |
|
||||||
|
| | GET | `/audit/apikeys.csv` | read | API Key 清单 CSV(含最后使用/过期) |
|
||||||
|
| | GET | `/audit/cluster-log.csv` | read | 集群操作日志 CSV(时间线 + 原始载荷) |
|
||||||
|
| | GET | `/audit/worker-logs.csv` | read | 全节点 worker 运行日志 CSV(逐行展开) |
|
||||||
|
| | GET | `/frpc/{version}` | read | 节点间 frpc 二进制分发 |
|
||||||
|
| **认证** | POST | `/login` | 无 | 登录(表单凭证 → session cookie) |
|
||||||
|
| | POST | `/logout` | 无 | 登出(清除 session) |
|
||||||
|
| | GET | `/me` | read | 当前身份 |
|
||||||
|
| | GET/POST | `/users` | admin | 用户列表/创建 |
|
||||||
|
| | PUT/DELETE | `/users/{name}` | admin | 修改/删除用户 |
|
||||||
|
| | GET/POST | `/apikeys` | admin | API Key 列表/创建 |
|
||||||
|
| | DELETE | `/apikeys/{id}` | admin | 吊销 API Key |
|
||||||
|
|
||||||
|
> 集群管理 API 的详细说明(请求体、响应格式、错误码、示例)见 [docs/cluster-api.md](docs/cluster-api.md)。
|
||||||
|
|
||||||
|
## 集群模式
|
||||||
|
|
||||||
|
多个 webui4frpc 节点可组成令牌环集群协同工作:
|
||||||
|
|
||||||
|
- **创建集群**:首个节点 `CreateCluster` 成为 leader,生成准入密钥(nodeKey)
|
||||||
|
- **加入集群**:在集群页复制对端的 nodeKey,填入「加入集群」对话框(对端地址 + 密钥)
|
||||||
|
- **任务分发**:在任意节点画布上配置 `localOnly=false` 的转发并保存 → 画布差异判断生成命令 → 令牌携带 → 负载最低节点摘取 → 拉起 frpc worker
|
||||||
|
- **容错**:节点宕机 → 自动检测 → 环自愈(跳过死亡节点 / leader failover);重启 → 自动重联
|
||||||
|
- **退出集群**:在集群页「退出集群」→ 本节点脱离为 standalone,不自动重联
|
||||||
|
|
||||||
|
集群页展示:环拓扑(self/leader 标记)、令牌轮次、节点负载、待办任务、活跃转发拓扑(含 owner 归属)、增量事件日志。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 后端
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
# 前端类型检查
|
||||||
|
cd web && npm run type-check
|
||||||
|
```
|
||||||
162
packaging/windows/webui4frpc.nsi
Normal file
162
packaging/windows/webui4frpc.nsi
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
; webui4frpc Windows 安装程序 (NSIS)
|
||||||
|
;
|
||||||
|
; 由 scripts/build_installers.sh 调用,需在命令行传入:
|
||||||
|
; -DVERSION=0.1.0 -DARCH=amd64 -DBINSRC=<webui4frpc.exe 路径> -DOUTFILE=<输出 exe>
|
||||||
|
|
||||||
|
!include "MUI2.nsh"
|
||||||
|
!include "LogicLib.nsh"
|
||||||
|
|
||||||
|
!ifndef VERSION
|
||||||
|
!define VERSION "0.0.0"
|
||||||
|
!endif
|
||||||
|
!ifndef ARCH
|
||||||
|
!define ARCH "amd64"
|
||||||
|
!endif
|
||||||
|
|
||||||
|
Name "webui4frpc ${VERSION}"
|
||||||
|
OutFile "${OUTFILE}"
|
||||||
|
Unicode true
|
||||||
|
RequestExecutionLevel admin
|
||||||
|
InstallDir "$PROGRAMFILES64\webui4frpc"
|
||||||
|
InstallDirRegKey HKLM "Software\webui4frpc" "InstallDir"
|
||||||
|
|
||||||
|
VIProductVersion "${VERSION}.0"
|
||||||
|
VIAddVersionKey "ProductName" "webui4frpc"
|
||||||
|
VIAddVersionKey "FileDescription" "Visual frpc controller with token-ring cluster"
|
||||||
|
VIAddVersionKey "FileVersion" "${VERSION}"
|
||||||
|
VIAddVersionKey "ProductVersion" "${VERSION}"
|
||||||
|
VIAddVersionKey "LegalCopyright" "JianFeeeee"
|
||||||
|
|
||||||
|
!define MUI_ABORTWARNING
|
||||||
|
!insertmacro MUI_PAGE_LICENSE "LICENSE.txt"
|
||||||
|
!insertmacro MUI_PAGE_DIRECTORY
|
||||||
|
Page custom ConfigPage ConfigPageLeave
|
||||||
|
!insertmacro MUI_PAGE_INSTFILES
|
||||||
|
!insertmacro MUI_PAGE_FINISH
|
||||||
|
!insertmacro MUI_UNPAGE_CONFIRM
|
||||||
|
!insertmacro MUI_UNPAGE_INSTFILES
|
||||||
|
!insertmacro MUI_LANGUAGE "SimpChinese"
|
||||||
|
!insertmacro MUI_LANGUAGE "English"
|
||||||
|
|
||||||
|
Var Dialog
|
||||||
|
Var LblAddr
|
||||||
|
Var TxtAddr
|
||||||
|
Var LblUser
|
||||||
|
Var TxtUser
|
||||||
|
Var LblPass
|
||||||
|
Var TxtPass
|
||||||
|
Var CfgAddr
|
||||||
|
Var CfgUser
|
||||||
|
Var CfgPass
|
||||||
|
|
||||||
|
Function .onInit
|
||||||
|
StrCpy $CfgAddr "0.0.0.0:7500"
|
||||||
|
StrCpy $CfgUser "admin"
|
||||||
|
StrCpy $CfgPass "admin"
|
||||||
|
FunctionEnd
|
||||||
|
|
||||||
|
Function ConfigPage
|
||||||
|
!insertmacro MUI_HEADER_TEXT "服务配置" "设置监听地址与登录凭据"
|
||||||
|
nsDialogs::Create 1018
|
||||||
|
Pop $Dialog
|
||||||
|
${If} $Dialog == error
|
||||||
|
Abort
|
||||||
|
${EndIf}
|
||||||
|
|
||||||
|
${NSD_CreateLabel} 0 0 100% 12u "监听地址 (host:port):"
|
||||||
|
Pop $LblAddr
|
||||||
|
${NSD_CreateText} 0 14u 100% 12u "$CfgAddr"
|
||||||
|
Pop $TxtAddr
|
||||||
|
|
||||||
|
${NSD_CreateLabel} 0 34u 100% 12u "管理员用户名:"
|
||||||
|
Pop $LblUser
|
||||||
|
${NSD_CreateText} 0 48u 100% 12u "$CfgUser"
|
||||||
|
Pop $TxtUser
|
||||||
|
|
||||||
|
${NSD_CreateLabel} 0 68u 100% 12u "管理员密码(请勿沿用默认值):"
|
||||||
|
Pop $LblPass
|
||||||
|
${NSD_CreatePassword} 0 82u 100% 12u "$CfgPass"
|
||||||
|
Pop $TxtPass
|
||||||
|
|
||||||
|
nsDialogs::Show
|
||||||
|
FunctionEnd
|
||||||
|
|
||||||
|
Function ConfigPageLeave
|
||||||
|
${NSD_GetText} $TxtAddr $CfgAddr
|
||||||
|
${NSD_GetText} $TxtUser $CfgUser
|
||||||
|
${NSD_GetText} $TxtPass $CfgPass
|
||||||
|
FunctionEnd
|
||||||
|
|
||||||
|
Section "webui4frpc" SecMain
|
||||||
|
SetOutPath "$INSTDIR"
|
||||||
|
File "/oname=webui4frpc.exe" "${BINSRC}"
|
||||||
|
File "README.md"
|
||||||
|
File "LICENSE.txt"
|
||||||
|
|
||||||
|
CreateDirectory "$INSTDIR\data"
|
||||||
|
|
||||||
|
; 启动脚本,承载安装时选择的配置
|
||||||
|
FileOpen $0 "$INSTDIR\start-webui4frpc.cmd" w
|
||||||
|
FileWrite $0 "@echo off$\r$\n"
|
||||||
|
FileWrite $0 "rem webui4frpc 启动脚本(由安装程序生成)$\r$\n"
|
||||||
|
FileWrite $0 "cd /d %~dp0$\r$\n"
|
||||||
|
FileWrite $0 'webui4frpc.exe -addr $CfgAddr -user $CfgUser -password $CfgPass -workdir "%~dp0data"$\r$\n'
|
||||||
|
FileClose $0
|
||||||
|
|
||||||
|
WriteRegStr HKLM "Software\webui4frpc" "InstallDir" "$INSTDIR"
|
||||||
|
WriteRegStr HKLM "Software\webui4frpc" "Version" "${VERSION}"
|
||||||
|
|
||||||
|
; 添加/删除程序
|
||||||
|
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\webui4frpc" \
|
||||||
|
"DisplayName" "webui4frpc ${VERSION}"
|
||||||
|
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\webui4frpc" \
|
||||||
|
"UninstallString" "$\"$INSTDIR\uninstall.exe$\""
|
||||||
|
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\webui4frpc" \
|
||||||
|
"DisplayVersion" "${VERSION}"
|
||||||
|
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\webui4frpc" \
|
||||||
|
"Publisher" "JianFeeeee"
|
||||||
|
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\webui4frpc" \
|
||||||
|
"URLInfoAbout" "https://gitcode.com/JianFeeeee/webui4frpc"
|
||||||
|
WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\webui4frpc" \
|
||||||
|
"NoModify" 1
|
||||||
|
WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\webui4frpc" \
|
||||||
|
"NoRepair" 1
|
||||||
|
|
||||||
|
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||||
|
|
||||||
|
; 开始菜单
|
||||||
|
CreateDirectory "$SMPROGRAMS\webui4frpc"
|
||||||
|
CreateShortcut "$SMPROGRAMS\webui4frpc\webui4frpc.lnk" \
|
||||||
|
"$INSTDIR\start-webui4frpc.cmd" "" "$INSTDIR\webui4frpc.exe" 0
|
||||||
|
CreateShortcut "$SMPROGRAMS\webui4frpc\打开管理界面.lnk" \
|
||||||
|
"http://127.0.0.1:7500"
|
||||||
|
CreateShortcut "$SMPROGRAMS\webui4frpc\卸载 webui4frpc.lnk" "$INSTDIR\uninstall.exe"
|
||||||
|
|
||||||
|
; 防火墙放行(失败不阻断安装)
|
||||||
|
nsExec::ExecToLog 'netsh advfirewall firewall add rule name="webui4frpc" \
|
||||||
|
dir=in action=allow program="$INSTDIR\webui4frpc.exe" enable=yes profile=any'
|
||||||
|
SectionEnd
|
||||||
|
|
||||||
|
Section "Uninstall"
|
||||||
|
nsExec::ExecToLog 'netsh advfirewall firewall delete rule name="webui4frpc"'
|
||||||
|
|
||||||
|
Delete "$INSTDIR\webui4frpc.exe"
|
||||||
|
Delete "$INSTDIR\start-webui4frpc.cmd"
|
||||||
|
Delete "$INSTDIR\README.md"
|
||||||
|
Delete "$INSTDIR\LICENSE.txt"
|
||||||
|
Delete "$INSTDIR\uninstall.exe"
|
||||||
|
|
||||||
|
; data 目录保留(含数据库与日志),由用户手动删除
|
||||||
|
MessageBox MB_YESNO|MB_ICONQUESTION \
|
||||||
|
"是否同时删除数据目录 $INSTDIR\data(数据库、转发配置、日志)?" \
|
||||||
|
IDNO KeepData
|
||||||
|
RMDir /r "$INSTDIR\data"
|
||||||
|
KeepData:
|
||||||
|
RMDir "$INSTDIR"
|
||||||
|
|
||||||
|
Delete "$SMPROGRAMS\webui4frpc\*.lnk"
|
||||||
|
RMDir "$SMPROGRAMS\webui4frpc"
|
||||||
|
|
||||||
|
DeleteRegKey HKLM "Software\webui4frpc"
|
||||||
|
DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\webui4frpc"
|
||||||
|
SectionEnd
|
||||||
131
scripts/build_installers.sh
Executable file
131
scripts/build_installers.sh
Executable file
@ -0,0 +1,131 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# 从 release.sh 的交叉编译产物构建原生安装包:
|
||||||
|
# - linux amd64/arm64 → .deb
|
||||||
|
# - linux amd64/arm64 → .rpm
|
||||||
|
# - windows amd64/arm64 → NSIS .exe 安装器
|
||||||
|
# - darwin amd64/arm64 → macOS .pkg
|
||||||
|
#
|
||||||
|
# 用法: build_installers.sh <version> [arch...]
|
||||||
|
# arch 默认: linux amd64 arm64
|
||||||
|
#
|
||||||
|
# 在 release.sh 流程内被调用;依赖 dist/artifacts/<version>/bin/<os>-<arch>/ 下的二进制。
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION="${1:-0.1.2}"
|
||||||
|
VERSION="${VERSION#v}"
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
OUT="$ROOT/dist/artifacts/$VERSION"
|
||||||
|
PKG="$ROOT/packaging"
|
||||||
|
|
||||||
|
# 工具检测(缺工具则给出明确提示后跳过该平台)
|
||||||
|
have() { command -v "$1" >/dev/null 2>&1; }
|
||||||
|
|
||||||
|
build_deb() {
|
||||||
|
local arch="$1" key="$2" bindir
|
||||||
|
bindir="$OUT/bin/linux-$arch"
|
||||||
|
[ -x "$bindir/webui4frpc" ] || { echo " ! 缺二进制 linux-$arch,跳过 deb"; return; }
|
||||||
|
local deb=/tmp/w4f-deb-build; rm -rf "$deb"
|
||||||
|
mkdir -p "$deb/DEBIAN" "$deb/usr/bin" "$deb/etc/webui4frpc" \
|
||||||
|
"$deb/lib/systemd/system" "$deb/usr/share/doc/webui4frpc"
|
||||||
|
# control
|
||||||
|
cat > "$deb/DEBIAN/control" <<EOF
|
||||||
|
Package: webui4frpc
|
||||||
|
Version: $VERSION
|
||||||
|
Section: net
|
||||||
|
Priority: optional
|
||||||
|
Architecture: $arch
|
||||||
|
Maintainer: JianFeeeee <jianfeeeee@gitcode>
|
||||||
|
Description: visual frpc controller with token-ring cluster
|
||||||
|
A web UI to manage FRP forwards across a cluster of nodes. Single
|
||||||
|
statically-linked binary with an embedded Vue frontend. Includes a
|
||||||
|
token-ring based cluster for distributed forward management, NIC network
|
||||||
|
load sampling, and audit-log CSV export.
|
||||||
|
Homepage: https://gitcode.com/JianFeeeee/webui4frpc
|
||||||
|
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 "$ROOT/README.md" "$deb/usr/share/doc/webui4frpc/README.md"
|
||||||
|
cat > "$deb/usr/share/doc/webui4frpc/copyright" <<'EOF'
|
||||||
|
webui4frpc - visual frpc controller
|
||||||
|
Copyright (C) 2026 JianFeeeee
|
||||||
|
SPDX-License-Identifier: MIT
|
||||||
|
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"
|
||||||
|
( 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"
|
||||||
|
}
|
||||||
|
|
||||||
|
build_rpm() {
|
||||||
|
local arch="$1" rarch key="$2" bindir
|
||||||
|
case "$arch" in amd64) rarch=x86_64;; arm64) rarch=aarch64;; *) return;; esac
|
||||||
|
bindir="$OUT/bin/linux-$arch"
|
||||||
|
[ -x "$bindir/webui4frpc" ] || { echo " ! 缺二进制 linux-$arch,跳过 rpm"; return; }
|
||||||
|
local R=/tmp/w4f-rpmbuild; rm -rf "$R"; mkdir -p "$R"/{BUILD,RPMS,SPECS,SOURCES}
|
||||||
|
rpmbuild -bb "$PKG/rpm/webui4frpc.spec" --nodeps --target "$rarch" \
|
||||||
|
--define "_topdir $R" \
|
||||||
|
--define "_w4f_version $VERSION" \
|
||||||
|
--define "_w4f_arch $rarch" \
|
||||||
|
--define "_w4f_bin $bindir/webui4frpc" \
|
||||||
|
--define "_w4f_service $PKG/common/webui4frpc.service" \
|
||||||
|
--define "_w4f_env $PKG/common/webui4frpc.env" \
|
||||||
|
--define "_w4f_readme $ROOT/README.md" \
|
||||||
|
--define "_unitdir /usr/lib/systemd/system" \
|
||||||
|
--define "_sharedstatedir /var/lib" >/tmp/w4f-rpm.log 2>&1 || true
|
||||||
|
cp "$R"/RPMS/$rarch/*.rpm "$OUT/webui4frpc-${VERSION}-linux-${rarch}.rpm" 2>/dev/null \
|
||||||
|
&& echo " ✔ .rpm linux/$rarch" || { echo " ! rpm 失败: $(tail -3 /tmp/w4f-rpm.log)"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
build_nsis() {
|
||||||
|
local arch="$1" key="$2" bindir
|
||||||
|
bindir="$OUT/bin/windows-$arch"
|
||||||
|
[ -f "$bindir/webui4frpc.exe" ] || { echo " ! 缺二进制 windows-$arch,跳过 NSIS"; return; }
|
||||||
|
[ -f "$PKG/windows/LICENSE.txt" ] || { echo " ! 缺 $PKG/windows/LICENSE.txt"; return; }
|
||||||
|
cp -f "$ROOT/README.md" "$PKG/windows/README.md"
|
||||||
|
makensis "-DVERSION=$VERSION" "-DARCH=$arch" \
|
||||||
|
"-DBINSRC=$bindir/webui4frpc.exe" \
|
||||||
|
"-DOUTFILE=$OUT/webui4frpc-${VERSION}-windows-${arch}-setup.exe" \
|
||||||
|
"$PKG/windows/webui4frpc.nsi" >/tmp/w4f-nsis.log 2>&1 \
|
||||||
|
&& echo " ✔ NSIS setup.exe windows/$arch" \
|
||||||
|
|| { echo " ! NSIS 失败: $(tail -4 /tmp/w4f-nsis.log)"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
build_pkg() {
|
||||||
|
local arch="$1" key="$2" bindir
|
||||||
|
bindir="$OUT/bin/darwin-$arch"
|
||||||
|
[ -x "$bindir/webui4frpc" ] || { echo " ! 缺二进制 darwin-$arch,跳过 pkg"; return; }
|
||||||
|
"$PKG/macos/build_pkg.sh" "$VERSION" "$arch" \
|
||||||
|
"$bindir/webui4frpc" \
|
||||||
|
"$OUT/webui4frpc-${VERSION}-darwin-${arch}.pkg" >/dev/null \
|
||||||
|
&& echo " ✔ .pkg darwin/$arch" \
|
||||||
|
|| echo " ! macOS pkg 失败 darwin/$arch"
|
||||||
|
}
|
||||||
|
|
||||||
|
ARCHES=("${@:2}")
|
||||||
|
[ ${#ARCHES[@]} -eq 0 ] && ARCHES=(amd64 arm64)
|
||||||
|
|
||||||
|
echo "==> 原生安装包 (v$VERSION):"
|
||||||
|
for arch in "${ARCHES[@]}"; do
|
||||||
|
key="linux-$arch"
|
||||||
|
if have dpkg-deb; then build_deb "$arch" "$key"; else echo " . dpkg-deb 不可用,跳过 deb"; fi
|
||||||
|
if have rpmbuild; then build_rpm "$arch" "$key"; else echo " . rpmbuild 不可用,跳过 rpm"; fi
|
||||||
|
done
|
||||||
|
if have makensis; then
|
||||||
|
for arch in "${ARCHES[@]}"; do build_nsis "$arch" "windows-$arch"; done
|
||||||
|
else
|
||||||
|
echo " . makensis 不可用,跳过 windows setup.exe"
|
||||||
|
fi
|
||||||
|
if have xar && have mkbom && have cpio; then
|
||||||
|
for arch in "${ARCHES[@]}"; do build_pkg "$arch" "darwin-$arch"; done
|
||||||
|
else
|
||||||
|
echo " . xar/mkbom 不可用,跳过 macOS pkg"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "==> 安装包产物:"
|
||||||
|
ls -lh "$OUT"/*.deb "$OUT"/*.rpm "$OUT"/*-setup.exe "$OUT"/*.pkg 2>/dev/null || true
|
||||||
150
scripts/release.sh
Executable file
150
scripts/release.sh
Executable file
@ -0,0 +1,150 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# webui4frpc 多平台发布脚本
|
||||||
|
#
|
||||||
|
# 用法:
|
||||||
|
# ./scripts/release.sh [version] # 默认 version=0.1.2
|
||||||
|
#
|
||||||
|
# 行为:
|
||||||
|
# 1. 构建前端 (web/dist) 并同步到 internal/httpapi/dist (go:embed 用)
|
||||||
|
# 2. 交叉编译全平台单二进制 (linux/windows/darwin × amd64/arm64)
|
||||||
|
# 3. 打包各平台安装包:
|
||||||
|
# - linux/darwin: tar.gz (二进制 + README + systemd 模板)
|
||||||
|
# - windows: zip (exe + README)
|
||||||
|
# 4. 生成 SHA256SUMS
|
||||||
|
# 5. 输出到 dist/artifacts/<version>/
|
||||||
|
#
|
||||||
|
# 环境变量:
|
||||||
|
# GOPROXY_OVERRIDE 覆盖 go module 代理 (默认 goproxy.cn,direct, 走本地 7890 代理)
|
||||||
|
# NO_BUILD_FRONTEND 设为 1 跳过前端构建 (用现有 dist)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION="${1:-0.1.2}"
|
||||||
|
VERSION="${VERSION#v}" # 去掉可能的前导 v
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
OUT="$ROOT/dist/artifacts/$VERSION"
|
||||||
|
GOFLAGS_ENV=""
|
||||||
|
|
||||||
|
# ---- 0. Go module 代理 (内网需走本地代理访问 goproxy.cn) ----
|
||||||
|
if [ -n "${GOPROXY_OVERRIDE:-}" ]; then
|
||||||
|
GOFLAGS_ENV="GOPROXY=${GOPROXY_OVERRIDE}"
|
||||||
|
else
|
||||||
|
GOFLAGS_ENV="GOPROXY=https://goproxy.cn,direct"
|
||||||
|
fi
|
||||||
|
# 若本地 7890 代理存在则导出 (模块下载走代理)
|
||||||
|
if curl -s -o /dev/null -m 2 -x http://127.0.0.1:7890 https://goproxy.cn >/dev/null 2>&1; then
|
||||||
|
export HTTPS_PROXY=http://127.0.0.1:7890 HTTP_PROXY=http://127.0.0.1:7890
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> 版本: $VERSION"
|
||||||
|
mkdir -p "$OUT"
|
||||||
|
|
||||||
|
# ---- 1. 构建前端 ----
|
||||||
|
if [ "${NO_BUILD_FRONTEND:-0}" != "1" ]; then
|
||||||
|
echo "==> 构建前端"
|
||||||
|
( cd "$ROOT/web" && npm run build )
|
||||||
|
rm -rf "$ROOT/internal/httpapi/dist"
|
||||||
|
cp -r "$ROOT/web/dist" "$ROOT/internal/httpapi/dist"
|
||||||
|
else
|
||||||
|
echo "==> 跳过前端构建 (用现有 dist)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 2. 交叉编译全平台 ----
|
||||||
|
# target 格式: os arch 扩展名 [tar|zip]
|
||||||
|
TARGETS=(
|
||||||
|
"linux amd64" "linux arm64"
|
||||||
|
"windows amd64" "windows arm64"
|
||||||
|
"darwin amd64" "darwin arm64"
|
||||||
|
)
|
||||||
|
|
||||||
|
declare -A PLATFORM_EXT=(
|
||||||
|
[linux-amd64]=tgz [linux-arm64]=tgz
|
||||||
|
[darwin-amd64]=tgz [darwin-arm64]=tgz
|
||||||
|
[windows-amd64]=zip [windows-arm64]=zip
|
||||||
|
)
|
||||||
|
declare -A PLATFORM_BIN=(
|
||||||
|
[linux-amd64]=webui4frpc [linux-arm64]=webui4frpc
|
||||||
|
[darwin-amd64]=webui4frpc [darwin-arm64]=webui4frpc
|
||||||
|
[windows-amd64]=webui4frpc.exe [windows-arm64]=webui4frpc.exe
|
||||||
|
)
|
||||||
|
|
||||||
|
for target in "${TARGETS[@]}"; do
|
||||||
|
os="${target%% *}"; arch="${target##* }"
|
||||||
|
key="$os-$arch"
|
||||||
|
bin="${PLATFORM_BIN[$key]}"
|
||||||
|
# 每个平台编译到独立子目录,避免 darwin/linux 同名二进制互相覆盖
|
||||||
|
bindir="$OUT/bin/$key"
|
||||||
|
mkdir -p "$bindir"
|
||||||
|
echo "==> 编译 $os/$arch"
|
||||||
|
env $GOFLAGS_ENV CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" \
|
||||||
|
go build -trimpath \
|
||||||
|
-ldflags="-s -w -X webui4frpc/internal/version.Version=$VERSION" \
|
||||||
|
-o "$bindir/$bin" "$ROOT/cmd/webui4frpc"
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---- 3. 打包各平台 ----
|
||||||
|
# 整理 systemd 模板
|
||||||
|
cat > "$OUT/webui4frpc.service.tpl" <<'TPL'
|
||||||
|
[Unit]
|
||||||
|
Description=webui4frpc - visual frpc controller (cluster node)
|
||||||
|
After=network-online.target
|
||||||
|
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
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
LimitNOFILE=65535
|
||||||
|
NoNewPrivileges=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
TPL
|
||||||
|
|
||||||
|
for target in "${TARGETS[@]}"; do
|
||||||
|
os="${target%% *}"; arch="${target##* }"
|
||||||
|
key="$os-$arch"
|
||||||
|
bin="${PLATFORM_BIN[$key]}"
|
||||||
|
ext="${PLATFORM_EXT[$key]}"
|
||||||
|
base="webui4frpc-${VERSION}-${os}-${arch}"
|
||||||
|
|
||||||
|
echo "==> 打包 $key -> $base.$ext"
|
||||||
|
cp "$ROOT/README.md" "$OUT/README.md"
|
||||||
|
bindir="$OUT/bin/$key"
|
||||||
|
# staging 目录: 把二进制 + README (+systemd) 放一起再一次性打包
|
||||||
|
stage="$OUT/.stage/$key"
|
||||||
|
rm -rf "$stage"; mkdir -p "$stage"
|
||||||
|
cp "$bindir/$bin" "$stage/$bin"
|
||||||
|
cp "$OUT/README.md" "$stage/README.md"
|
||||||
|
if [ "$ext" = "zip" ]; then
|
||||||
|
( 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 )
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
rm -rf "$OUT/.stage"
|
||||||
|
|
||||||
|
# ---- 4. 校验和 ----
|
||||||
|
# 先生成免安装包的校验和;原生安装包构建完后在 4.6 统一重算,
|
||||||
|
# 保证 SHA256SUMS 覆盖 deb/rpm/setup.exe/pkg(否则用户校验会 "no file was verified")。
|
||||||
|
|
||||||
|
# ---- 4.5 原生安装包 (deb/rpm/setup.exe/pkg) ----
|
||||||
|
if [ "${NO_INSTALLERS:-0}" != "1" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "==> 构建原生安装包"
|
||||||
|
"$ROOT/scripts/build_installers.sh" "$VERSION"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- 4.6 校验和(覆盖全部产物)----
|
||||||
|
echo ""
|
||||||
|
echo "==> 生成 SHA256SUMS"
|
||||||
|
( cd "$OUT" && sha256sum *.tar.gz *.zip *.deb *.rpm *-setup.exe *.pkg 2>/dev/null | tee SHA256SUMS )
|
||||||
|
|
||||||
|
# ---- 5. 汇总 ----
|
||||||
|
echo ""
|
||||||
|
echo "==> 发布产物位于: $OUT"
|
||||||
|
ls -lh "$OUT"
|
||||||
|
echo ""
|
||||||
|
echo "==> 完成"
|
||||||
88
scripts/upload_assets.py
Executable file
88
scripts/upload_assets.py
Executable file
@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""上传 release 资产到 gitcode(两步:取签名 URL → PUT 到 OBS)。
|
||||||
|
|
||||||
|
用法: upload_assets.py <version> <token> [file...]
|
||||||
|
不传 file 时上传该版本目录下全部发布产物:
|
||||||
|
tar.gz / zip / deb / rpm / pkg / -setup.exe / SHA256SUMS
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
REPO = "JianFeeeee/webui4frpc"
|
||||||
|
API = "https://gitcode.com/api/v5/repos"
|
||||||
|
|
||||||
|
|
||||||
|
def get_upload_url(version: str, token: str, filename: str) -> tuple[str, dict]:
|
||||||
|
q = urllib.parse.urlencode({"file_name": filename})
|
||||||
|
url = f"{API}/{REPO}/releases/v{version}/upload_url?{q}"
|
||||||
|
req = urllib.request.Request(url, headers={"private-token": token})
|
||||||
|
with urllib.request.urlopen(req, timeout=20) as r:
|
||||||
|
data = json.loads(r.read())
|
||||||
|
return data["url"], data.get("headers", {})
|
||||||
|
|
||||||
|
|
||||||
|
def put_file(url: str, headers: dict, path: str) -> tuple[int, str]:
|
||||||
|
size = os.path.getsize(path)
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
body = f.read()
|
||||||
|
req = urllib.request.Request(url, data=body, method="PUT")
|
||||||
|
for k, v in headers.items():
|
||||||
|
req.add_header(k, v)
|
||||||
|
req.add_header("Content-Length", str(size))
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=300) as r:
|
||||||
|
return r.status, r.read().decode("utf-8", "replace")[:300]
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, e.read().decode("utf-8", "replace")[:300]
|
||||||
|
|
||||||
|
|
||||||
|
# 发布产物后缀:免安装包 + 原生安装包。
|
||||||
|
# 注意 -setup.exe 而不是裸 .exe,避开 bin/ 里的裸二进制。
|
||||||
|
ARTIFACT_SUFFIXES = (
|
||||||
|
".tar.gz", ".zip", # 免安装
|
||||||
|
".deb", ".rpm", ".pkg", # linux / macOS 安装包
|
||||||
|
"-setup.exe", # windows 安装器
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_artifact(name: str) -> bool:
|
||||||
|
return name == "SHA256SUMS" or name.endswith(ARTIFACT_SUFFIXES)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print(__doc__)
|
||||||
|
return 2
|
||||||
|
version, token = sys.argv[1], sys.argv[2]
|
||||||
|
outdir = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||||
|
"dist", "artifacts", version,
|
||||||
|
)
|
||||||
|
files = sys.argv[3:] or sorted(f for f in os.listdir(outdir) if is_artifact(f))
|
||||||
|
failed = 0
|
||||||
|
for name in files:
|
||||||
|
path = os.path.join(outdir, name)
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
print(f"skip (missing): {name}")
|
||||||
|
continue
|
||||||
|
print(f"==> {name} ({os.path.getsize(path)/1048576:.1f} MiB)")
|
||||||
|
try:
|
||||||
|
url, headers = get_upload_url(version, token, name)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
print(f" upload_url FAILED: {e}")
|
||||||
|
failed += 1
|
||||||
|
continue
|
||||||
|
status, body = put_file(url, headers, path)
|
||||||
|
ok = 200 <= status < 300
|
||||||
|
print(f" PUT -> {status} {'OK' if ok else body}")
|
||||||
|
if not ok:
|
||||||
|
failed += 1
|
||||||
|
print(f"\n{'ALL OK' if failed == 0 else f'{failed} FAILED'}")
|
||||||
|
return 1 if failed else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@ -2,7 +2,7 @@
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
<title>webui4frpc</title>
|
<title>webui4frpc</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
149
web/src/App.vue
149
web/src/App.vue
@ -363,4 +363,153 @@ const currentLabel = computed(() => nav.value.find((n) => n.key === view.value)?
|
|||||||
.breadcrumb { padding: 12px 16px 6px; }
|
.breadcrumb { padding: 12px 16px 6px; }
|
||||||
.content { padding: 8px 14px 32px; }
|
.content { padding: 8px 14px 32px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- narrow screens: sidebar becomes a bottom tab bar ----------
|
||||||
|
窄屏(≤ 720px)下 210px 侧栅会吃掉手机一半宽度,改为:
|
||||||
|
- shell 竖向排列,侧栅沉到底部变成固定 tab bar(图标在上文字在下)
|
||||||
|
- 品牌区/身份区/主题切换折叠进顶部窄条,不占垂直空间
|
||||||
|
- 内容区预留底部安全区 + tab bar 高度,避开 iOS 手势条
|
||||||
|
- 每个 tab 至少 44px 高(触控目标下限) */
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.app-shell {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部窄条:logo + 身份 + 主题,横向排一行 */
|
||||||
|
.sidebar {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
order: -1;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
/* 兵家必争:任何子项算错都不得弄出横向滚动条 */
|
||||||
|
overflow-x: hidden;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--w4f-line);
|
||||||
|
padding-top: max(10px, env(safe-area-inset-top));
|
||||||
|
/* 关键:backdrop-filter 会为 position:fixed 后代创建【包含块】
|
||||||
|
(与 transform/filter 同理)。若保留它,下面 .sb-nav 的
|
||||||
|
bottom:0 就是相对这条顶栏定位而非视口,底部 tab bar 会贴在
|
||||||
|
顶栏下沿、看起来固定在屏幕顶部。因此窄屏必须关掉顶栏的玻璃
|
||||||
|
模糊,改用接近实色的底,模糊效果留在 .sb-nav 自己身上
|
||||||
|
(元素自身的 backdrop-filter 不影响它自己的定位)。 */
|
||||||
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
|
background: color-mix(in srgb, var(--w4f-card-solid) 92%, transparent);
|
||||||
|
}
|
||||||
|
.sb-brand {
|
||||||
|
padding: 0;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
/* 品牌文字必须可收缩:“webui4frpc”是不可断行单词,不给 min-width:0
|
||||||
|
+ overflow:hidden 的话它的 flex 自动最小尺寸等于 min-content(≨85px),
|
||||||
|
顶栏就拿不出空间给右侧身份区,溢出为横向滚动。 */
|
||||||
|
.sb-brand-text { min-width: 0; overflow: hidden; }
|
||||||
|
.sb-brand-text b {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.sb-logo { width: 30px; height: 30px; border-radius: 10px; }
|
||||||
|
.sb-brand-text b { font-size: 14.5px; }
|
||||||
|
.sb-brand-text small { display: none; }
|
||||||
|
|
||||||
|
/* 导航从侧栅里抽出来,固定到底部 */
|
||||||
|
.sb-nav {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 40;
|
||||||
|
flex: none;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 0;
|
||||||
|
padding: 4px 4px max(4px, env(safe-area-inset-bottom));
|
||||||
|
background: var(--w4f-card);
|
||||||
|
border-top: 1px solid var(--w4f-line);
|
||||||
|
backdrop-filter: blur(calc(var(--w4f-glass) * 0.9)) saturate(1.3);
|
||||||
|
-webkit-backdrop-filter: blur(calc(var(--w4f-glass) * 0.9)) saturate(1.3);
|
||||||
|
box-shadow: 0 -4px 20px rgb(0 0 0 / 6%);
|
||||||
|
}
|
||||||
|
.sb-i {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 44px;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 5px 2px;
|
||||||
|
border-radius: 11px;
|
||||||
|
/* Keep the tab label at the theme's 11px floor; the 10.5px here (and 9.5px
|
||||||
|
* at ≤380px) read as noise on a phone. "账号与密钥" is still handled by the
|
||||||
|
* .label ellipsis (width:100% + overflow:hidden). */
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.sb-i .label {
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.sb-i.active {
|
||||||
|
/* 底部 tab 不用渐变底,只高亮图标与文字(避免拥挤) */
|
||||||
|
background: var(--w4f-primary-50);
|
||||||
|
border-color: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 身份与主题折进顶条右侧。
|
||||||
|
不再用 vw 限宽:vw 含垂直滚动条宽度,会比实际可用内容宽大;而且
|
||||||
|
max-width 只封顶盒子,内部 flex:0 0 auto 的徒章/按钮仍会撑出去。
|
||||||
|
改为全链路可收缩(min-width:0 + overflow:hidden),由 flex 自行分配。 */
|
||||||
|
.sb-foot {
|
||||||
|
margin-top: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.sb-identity {
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.sb-identity .id-name { min-width: 0; max-width: none; }
|
||||||
|
.sb-theme { margin-bottom: 0; flex: 0 0 auto; }
|
||||||
|
.sb-theme .sw { width: 22px; height: 22px; border-radius: 7px; }
|
||||||
|
|
||||||
|
.main { height: auto; flex: 1; min-height: 0; }
|
||||||
|
.breadcrumb { padding: 10px 14px 4px; font-size: 14px; }
|
||||||
|
.content {
|
||||||
|
padding: 6px 12px 24px;
|
||||||
|
/* 底部 tab bar(44px + padding)+ 安全区 */
|
||||||
|
padding-bottom: calc(64px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 极窄(≤ 380px,iPhone SE / 小屏安卓):进一步压缩顶条。
|
||||||
|
角色徒章是 flex:0 0 auto 不可收缩的(“超级管理员”≨70px),极窄下隐去,
|
||||||
|
用户名与退出按钮保留。
|
||||||
|
注意:底部 tab 文字不再随之降到 9.5px —— 它在手机上低于可读下限
|
||||||
|
(见 .sb-i 的 11px 注释),长标签交给 .label 的省略号处理。 */
|
||||||
|
@media (max-width: 380px) {
|
||||||
|
.sb-brand-text b { font-size: 13px; }
|
||||||
|
.sb-identity { padding: 4px 7px; }
|
||||||
|
.sb-identity .id-level { display: none; }
|
||||||
|
.content { padding-left: 10px; padding-right: 10px; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -184,6 +184,20 @@
|
|||||||
|
|
||||||
/* ---- blue theme (ocean × frost) ---- */
|
/* ---- blue theme (ocean × frost) ---- */
|
||||||
|
|
||||||
|
/* ---- box model reset ----
|
||||||
|
* This app had NO box-sizing reset, so every element using `width: 100%` with
|
||||||
|
* horizontal padding overflowed by exactly its padding under the default
|
||||||
|
* content-box model. The narrow-screen top bar was the visible victim:
|
||||||
|
* `.sidebar { width: 100%; padding: 10px 12px }` rendered 375+24 = 399px wide
|
||||||
|
* on a 375px viewport (344px at 320px), pushing the theme swatches and logout
|
||||||
|
* button off the right edge where `overflow-x: hidden` silently clipped them.
|
||||||
|
* It also made the desktop sidebar 246+28 = 274px wide instead of 246px.
|
||||||
|
* border-box makes width include padding+border, which every layout here
|
||||||
|
* already assumes (Element Plus sets it on its own components already). */
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after { box-sizing: border-box; }
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
#app {
|
#app {
|
||||||
@ -346,4 +360,14 @@ a { color: var(--w4f-primary-h); }
|
|||||||
/* ---- Element Plus dialog tweaks to read as glass ---- */
|
/* ---- Element Plus dialog tweaks to read as glass ---- */
|
||||||
.el-overlay { backdrop-filter: blur(3px); }
|
.el-overlay { backdrop-filter: blur(3px); }
|
||||||
.el-dialog { border-radius: var(--w4f-radius) !important; box-shadow: var(--w4f-sh-lg) !important; }
|
.el-dialog { border-radius: var(--w4f-radius) !important; box-shadow: var(--w4f-sh-lg) !important; }
|
||||||
|
|
||||||
|
/* Dialogs pass a fixed px width (width="420px" / "440px"), which Element Plus
|
||||||
|
* writes into an INLINE `--el-dialog-width` custom property. On phones that is
|
||||||
|
* wider than the viewport (420px on a 375px screen), so the dialog's right edge
|
||||||
|
* — including the header close button — starts off-screen and is only reachable
|
||||||
|
* by scrolling the overlay horizontally. Clamp it to the viewport; !important
|
||||||
|
* is required to beat the inline custom property. */
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.el-dialog { --el-dialog-width: calc(100vw - 24px) !important; }
|
||||||
|
}
|
||||||
.el-message { border-radius: var(--w4f-radius-sm) !important; box-shadow: var(--w4f-sh-lg) !important; }
|
.el-message { border-radius: var(--w4f-radius-sm) !important; box-shadow: var(--w4f-sh-lg) !important; }
|
||||||
|
|||||||
@ -25,16 +25,17 @@
|
|||||||
<VueFlow
|
<VueFlow
|
||||||
v-model:nodes="nodes"
|
v-model:nodes="nodes"
|
||||||
v-model:edges="edges"
|
v-model:edges="edges"
|
||||||
:default-viewport="{ zoom: 0.85 }"
|
:default-viewport="{ zoom: FIT_MAX_ZOOM }"
|
||||||
:min-zoom="0.3"
|
:min-zoom="MIN_ZOOM"
|
||||||
:max-zoom="2"
|
:max-zoom="2"
|
||||||
fit-view-on-init
|
|
||||||
:delete-key-code="null"
|
:delete-key-code="null"
|
||||||
:nodes-draggable="canWrite"
|
:nodes-draggable="canWrite"
|
||||||
:nodes-connectable="canWrite"
|
:nodes-connectable="canWrite"
|
||||||
:edges-updatable="false"
|
:edges-updatable="false"
|
||||||
:edges-reconnectable="false"
|
:edges-reconnectable="false"
|
||||||
class="flow-canvas"
|
class="flow-canvas"
|
||||||
|
@init="onFlowInit"
|
||||||
|
@nodes-initialized="reframe"
|
||||||
@connect="onConnect"
|
@connect="onConnect"
|
||||||
@pane-click="deselectAll"
|
@pane-click="deselectAll"
|
||||||
@edge-click="onEdgeClick"
|
@edge-click="onEdgeClick"
|
||||||
@ -118,7 +119,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import '@vue-flow/core/dist/style.css'
|
import '@vue-flow/core/dist/style.css'
|
||||||
import '@vue-flow/core/dist/theme-default.css'
|
import '@vue-flow/core/dist/theme-default.css'
|
||||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||||
import { VueFlow } from '@vue-flow/core'
|
import { VueFlow } from '@vue-flow/core'
|
||||||
import { Background } from '@vue-flow/background'
|
import { Background } from '@vue-flow/background'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
@ -134,6 +135,103 @@ const nodes = ref<any[]>([])
|
|||||||
const edges = ref<Edge[]>([])
|
const edges = ref<Edge[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// ---- viewport / zoom floor ----
|
||||||
|
// The canvas is laid out in absolute graph units (local column at x=60, remote
|
||||||
|
// column at x=760, and BOTH columns grow downward on every added forward), so
|
||||||
|
// its bounding box is far wider and taller than a phone viewport. Vue Flow's
|
||||||
|
// `fit-view-on-init` frames that whole box, which on a phone produced a scale of
|
||||||
|
// ~0.31: node cards measure 248px in graph units but rendered 85px wide at an
|
||||||
|
// effective font size of ~4.4px — unreadable. Even a 1280px desktop landed at
|
||||||
|
// ~0.60 (8.4px) with five forwards stacked.
|
||||||
|
//
|
||||||
|
// So the canvas is framed by frameCanvas() below, clamped by a zoom floor, and
|
||||||
|
// the canvas pans when the content no longer fits. 0.65 renders a 240px local
|
||||||
|
// card at ~156px with a legible ~9px font.
|
||||||
|
//
|
||||||
|
// NOTE: `fit-view-on-init` is deliberately NOT set. It resolves on its own
|
||||||
|
// schedule and overwrote the explicit fit performed here (observed: this code
|
||||||
|
// measured the unfitted layout, set the viewport, and then the deferred init fit
|
||||||
|
// landed last and won). Owning the fit outright is the only way to keep it
|
||||||
|
// deterministic.
|
||||||
|
//
|
||||||
|
// The store arrives via the `init` event. That is deliberately preferred over
|
||||||
|
// `useVueFlow()`: this component is the PARENT of <VueFlow>, and calling
|
||||||
|
// useVueFlow() in a parent without a shared id can resolve to a different store
|
||||||
|
// than the one the rendered flow uses. The event cannot misfire that way.
|
||||||
|
const MIN_ZOOM = 0.65
|
||||||
|
const FIT_MAX_ZOOM = 1
|
||||||
|
type FlowStore = {
|
||||||
|
fitView: (opts?: Record<string, unknown>) => Promise<boolean>
|
||||||
|
getViewport?: () => { x: number; y: number; zoom: number }
|
||||||
|
setViewport?: (
|
||||||
|
t: { x: number; y: number; zoom: number },
|
||||||
|
opts?: Record<string, unknown>,
|
||||||
|
) => Promise<boolean>
|
||||||
|
}
|
||||||
|
let flowStore: FlowStore | null = null
|
||||||
|
|
||||||
|
// frameCanvas fits the graph, then re-anchors it to the top-left padding edge.
|
||||||
|
//
|
||||||
|
// Two reasons it is not just fitView():
|
||||||
|
// 1. the zoom floor (MIN_ZOOM) — fitView alone shrank 5 stacked forwards to
|
||||||
|
// ~0.31 on a phone, rendering 4.4px text;
|
||||||
|
// 2. fitView centres the bounding box, which on a narrow screen puts the seam
|
||||||
|
// between the local and remote columns mid-viewport — the user sees half of
|
||||||
|
// each. Anchoring the top-left node to the padding edge means whole local
|
||||||
|
// cards are visible immediately and the remote column is a pan away.
|
||||||
|
//
|
||||||
|
// duration:0 on both transitions — we measure node rects in between, and a
|
||||||
|
// running animation would hand back mid-flight transforms and skew the anchor.
|
||||||
|
const PANE_PAD_RATIO = 0.06
|
||||||
|
const frameCanvas = async () => {
|
||||||
|
if (!flowStore) return
|
||||||
|
await flowStore.fitView({
|
||||||
|
padding: 0.12,
|
||||||
|
minZoom: MIN_ZOOM,
|
||||||
|
maxZoom: FIT_MAX_ZOOM,
|
||||||
|
duration: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const pane = document.querySelector<HTMLElement>('.vue-flow')
|
||||||
|
const nodeEls = Array.from(document.querySelectorAll<HTMLElement>('.vue-flow__node'))
|
||||||
|
if (!pane || !nodeEls.length) return
|
||||||
|
|
||||||
|
const paneRect = pane.getBoundingClientRect()
|
||||||
|
const rects = nodeEls.map((el) => el.getBoundingClientRect())
|
||||||
|
const dx =
|
||||||
|
paneRect.left + pane.clientWidth * PANE_PAD_RATIO - Math.min(...rects.map((r) => r.left))
|
||||||
|
const dy =
|
||||||
|
paneRect.top + pane.clientHeight * PANE_PAD_RATIO - Math.min(...rects.map((r) => r.top))
|
||||||
|
if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return
|
||||||
|
|
||||||
|
const vp = flowStore.getViewport?.()
|
||||||
|
if (!vp) return
|
||||||
|
await flowStore.setViewport?.({ x: vp.x + dx, y: vp.y + dy, zoom: vp.zoom }, { duration: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// The flow store and the node list become available at different times: the
|
||||||
|
// store at component init, the nodes only once the async canvas fetch resolves
|
||||||
|
// (plus a further tick for Vue Flow to measure them). Framing on any single
|
||||||
|
// signal would fit an EMPTY canvas, so every path calls reframe() and it no-ops
|
||||||
|
// until there is a store and something to frame.
|
||||||
|
//
|
||||||
|
// `nodes-initialized` also fires whenever a node is added later. Auto-framing on
|
||||||
|
// that would yank the viewport out from under whoever is mid-pan, so the initial
|
||||||
|
// frame is claimed exactly once; rearranging is the only thing that re-frames on
|
||||||
|
// demand (see autoLayout).
|
||||||
|
let framedOnce = false
|
||||||
|
const reframe = (force = false) => {
|
||||||
|
if (!flowStore || !nodes.value.length) return
|
||||||
|
if (framedOnce && !force) return
|
||||||
|
framedOnce = true
|
||||||
|
nextTick(() => { void frameCanvas() })
|
||||||
|
}
|
||||||
|
|
||||||
|
const onFlowInit = (store: FlowStore) => {
|
||||||
|
flowStore = store
|
||||||
|
reframe()
|
||||||
|
}
|
||||||
|
|
||||||
// Background dot-pattern color, resolved from the live theme var so the grid
|
// Background dot-pattern color, resolved from the live theme var so the grid
|
||||||
// recolors on theme switch (vue-flow Background renders pattern-color as an
|
// recolors on theme switch (vue-flow Background renders pattern-color as an
|
||||||
// SVG fill attribute, which can't resolve var() directly — read the computed
|
// SVG fill attribute, which can't resolve var() directly — read the computed
|
||||||
@ -667,7 +765,12 @@ const remoteNodes = computed(() =>
|
|||||||
nodes.value.filter((n) => n.type === 'remote'),
|
nodes.value.filter((n) => n.type === 'remote'),
|
||||||
)
|
)
|
||||||
|
|
||||||
const autoLayout = () => layoutColumns(true)
|
// Re-frame after rearranging, bounded by the same zoom floor so "自动排列" never
|
||||||
|
// shrinks the cards back into unreadable territory on a narrow screen.
|
||||||
|
const autoLayout = () => {
|
||||||
|
layoutColumns(true)
|
||||||
|
reframe(true)
|
||||||
|
}
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@ -718,6 +821,9 @@ const load = async () => {
|
|||||||
}
|
}
|
||||||
assignLayers()
|
assignLayers()
|
||||||
dirty.value = false
|
dirty.value = false
|
||||||
|
// The store exists by now but the node list has only just been populated, so
|
||||||
|
// this is the first moment a fit actually has something to frame.
|
||||||
|
reframe(true)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@ -1053,4 +1159,47 @@ onBeforeUnmount(() => {
|
|||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- narrow screens ----------
|
||||||
|
画布本质上需要宽屏拖拽,窄屏下只做到“可用”:
|
||||||
|
- toolbar 七个按钮在 375px 下会溢出,改成标题占行 + 按钮换行平铺
|
||||||
|
- 画布高度给个下限,否则在短屏上只剩一条缝
|
||||||
|
- handle 抬到 16px(手指命中 12px 圆点几乎不可能) */
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.canvas-editor { gap: 10px; }
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
padding: 11px 12px;
|
||||||
|
row-gap: 8px;
|
||||||
|
}
|
||||||
|
.toolbar-left,
|
||||||
|
.toolbar-right {
|
||||||
|
width: 100%;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.toolbar-title {
|
||||||
|
width: 100%;
|
||||||
|
margin-right: 0;
|
||||||
|
font-size: 15.5px;
|
||||||
|
}
|
||||||
|
.toolbar .btn {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
.save-hint { width: 100%; }
|
||||||
|
|
||||||
|
.flow-wrap { min-height: 62vh; }
|
||||||
|
|
||||||
|
/* 触控:连接点与边标签放大,手指才点得到 */
|
||||||
|
:deep(.vue-flow__handle) {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
:deep(.vue-flow__edge-label) {
|
||||||
|
padding: 3px 9px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -481,7 +481,11 @@ onBeforeUnmount(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.cluster-page { height: 100%; display: flex; flex-direction: column; overflow-y: auto; padding: 4px 0 40px; gap: 14px; }
|
/* Let .content (App.vue) remain the single scroll container — see the note in
|
||||||
|
* StatusView: a page-level scroller sized by leftover flex space is what froze
|
||||||
|
* the status page. `.log-list` keeps its own bounded scroll (that one is
|
||||||
|
* deliberate — a log pane should not push the whole page). */
|
||||||
|
.cluster-page { display: flex; flex-direction: column; padding: 4px 0 40px; gap: 14px; }
|
||||||
|
|
||||||
/* hero */
|
/* hero */
|
||||||
.hero { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding: 16px 20px; }
|
.hero { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding: 16px 20px; }
|
||||||
@ -560,4 +564,78 @@ onBeforeUnmount(() => {
|
|||||||
/* dialog form */
|
/* dialog form */
|
||||||
.form-row { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; label { width: 72px; font-size: 13px; color: $color-text-secondary; flex-shrink: 0; } }
|
.form-row { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; label { width: 72px; font-size: 13px; color: $color-text-secondary; flex-shrink: 0; } }
|
||||||
.form-hint { font-size: 12px; color: $color-text-muted; }
|
.form-hint { font-size: 12px; color: $color-text-muted; }
|
||||||
|
|
||||||
|
/* ---------- narrow screens ----------
|
||||||
|
窄屏下的主要问题:
|
||||||
|
- ring-node 的 min-width:168px 与环形箭头在 375px 下会横向溢出,且节点横排无意义
|
||||||
|
- nodeKey 是 32 位 hex,单行放不下会撑宽容器
|
||||||
|
- log-row 的 lg-detail max-width:60% + 不换行,窄屏几乎全被省略号吃掉
|
||||||
|
- toolbar / 对话框表单同 StatusView 的问题 */
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.cluster-page { gap: 10px; }
|
||||||
|
.section { padding: 14px 14px; }
|
||||||
|
|
||||||
|
.hero { padding: 14px 15px; gap: 12px; }
|
||||||
|
.page-title { font-size: 16.5px; }
|
||||||
|
|
||||||
|
/* nodeKey 允许折行,不再撑宽容器 */
|
||||||
|
.nk-value {
|
||||||
|
font-size: 11px;
|
||||||
|
word-break: break-all;
|
||||||
|
white-space: normal;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.nk-label { white-space: normal; }
|
||||||
|
|
||||||
|
.kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||||
|
|
||||||
|
/* 工具栏按钮平分整行 */
|
||||||
|
.toolbar { padding: 11px 13px; gap: 8px; }
|
||||||
|
.toolbar > .w4f-btn { flex: 1 1 auto; min-height: 40px; }
|
||||||
|
.act-hint { width: 100%; }
|
||||||
|
|
||||||
|
/* 环拓扑改竖向:节点卡占整宽,箭头旋转 90° 指下 */
|
||||||
|
.ring-line { flex-direction: column; align-items: stretch; gap: 6px; }
|
||||||
|
.ring-node { min-width: 0; width: 100%; padding: 11px 12px; }
|
||||||
|
.rn-id { font-size: 12.5px; word-break: break-all; }
|
||||||
|
.ring-arrow {
|
||||||
|
align-self: center;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 日志:详情另起一行完整显示,不再被省略号截掉 */
|
||||||
|
.log-row { row-gap: 3px; font-size: 11.5px; }
|
||||||
|
.lg-detail {
|
||||||
|
margin-left: 0;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.log-list { max-height: 260px; }
|
||||||
|
|
||||||
|
/* 任务/拓扑行:允许折行,归属另起一行 */
|
||||||
|
.task-row, .topo-row { flex-wrap: wrap; row-gap: 4px; font-size: 12.5px; }
|
||||||
|
.tp-owner { margin-left: 0; width: 100%; }
|
||||||
|
|
||||||
|
.title-actions { margin-left: 0; width: 100%; }
|
||||||
|
.title-actions > .w4f-btn { flex: 1 1 0; min-height: 36px; }
|
||||||
|
.section-title { flex-wrap: wrap; row-gap: 8px; }
|
||||||
|
|
||||||
|
/* 对话框表单:标签在上、输入在下 */
|
||||||
|
.form-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 5px;
|
||||||
|
label { width: auto; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 380px) {
|
||||||
|
.kpis { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
.page-title { font-size: 15.5px; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -167,11 +167,12 @@ onMounted(load)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
/* Let .content (App.vue) remain the single scroll container — see the note in
|
||||||
|
* StatusView: a page-level scroller sized by leftover flex space can collapse
|
||||||
|
* to zero height on narrow screens. */
|
||||||
.settings-page {
|
.settings-page {
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-top {
|
.page-top {
|
||||||
@ -321,4 +322,21 @@ onMounted(load)
|
|||||||
font-size: $font-size-sm;
|
font-size: $font-size-sm;
|
||||||
color: $color-text-muted;
|
color: $color-text-muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- narrow screens ----------
|
||||||
|
binary-row 把输入框与安装按钮横排,窄屏下路径输入框被挤到只剩几十像素;
|
||||||
|
setting-row 的 space-between 在窄屏也会把说明文字与开关挤在一起。 */
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.binary-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.binary-row .btn { min-height: 40px; justify-content: center; }
|
||||||
|
|
||||||
|
.setting-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -496,11 +496,22 @@ onBeforeUnmount(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
/* The pages inside .content must NOT be their own scrollers.
|
||||||
|
*
|
||||||
|
* App.vue already designates `.content` as the app's single scroll container
|
||||||
|
* (`flex:1; overflow-y:auto`). These pages were also `height:100%` +
|
||||||
|
* `overflow-y:auto`, producing a nested scroller whose height is the leftover
|
||||||
|
* space of a flex column. On a 375px phone the status page's fixed-height
|
||||||
|
* header (122px) plus the single-column KPI stack (565px) exceed the available
|
||||||
|
* 651px, so the inner `.status-body` was handed 0px while holding 1682px of
|
||||||
|
* content — the page looked completely frozen, since neither `.content` (which
|
||||||
|
* had nothing to scroll) nor the 0-height inner box could be swiped.
|
||||||
|
*
|
||||||
|
* Letting the page grow naturally and scroll in `.content` removes the whole
|
||||||
|
* class of bug: there is no leftover-space arithmetic left to get wrong. */
|
||||||
.status-page {
|
.status-page {
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -527,12 +538,9 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status-body {
|
.status-body {
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
overflow-y: auto;
|
|
||||||
padding-right: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.section { display: flex; flex-direction: column; }
|
.section { display: flex; flex-direction: column; }
|
||||||
@ -638,4 +646,96 @@ onBeforeUnmount(() => {
|
|||||||
.form-row { display: flex; align-items: center; gap: 10px; margin-bottom: 12px;
|
.form-row { display: flex; align-items: center; gap: 10px; margin-bottom: 12px;
|
||||||
label { width: 72px; font-size: 13px; color: $color-text-secondary; flex-shrink: 0; }
|
label { width: 72px; font-size: 13px; color: $color-text-secondary; flex-shrink: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- narrow screens ----------
|
||||||
|
窄屏下的主要问题:
|
||||||
|
- card-grid 的 minmax(320px) 在 375px 屏上会溢出(加上内边距超宽)
|
||||||
|
- topbar 的 space-between 把标题和按钮挤在一行
|
||||||
|
- fwd-head 一行塞下 7~8 个元素(类型/点/路由/协议/分组/归属/按钮)
|
||||||
|
- 分组标题的三个按钮 margin-left:auto 后被挤成细条
|
||||||
|
- 触控目标(small 按钮)低于 44px */
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.status-page { gap: 10px; }
|
||||||
|
|
||||||
|
/* 标题与工具栏换行,按钮占整行平分 */
|
||||||
|
.topbar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
.tb-right { width: 100%; }
|
||||||
|
.tb-right > .w4f-btn { flex: 1 1 0; min-height: 40px; }
|
||||||
|
|
||||||
|
/* KPI 固定两列(auto-fit 在窄屏会变单列,四张卡拉得太长) */
|
||||||
|
.kpis {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 卡片网格改单列:消除 320px 最小宽导致的横向溢出 */
|
||||||
|
.card-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分组标题:标题一行,操作按钮另起一行平分 */
|
||||||
|
.section-title {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 8px;
|
||||||
|
}
|
||||||
|
.grp-actions {
|
||||||
|
margin-left: 0;
|
||||||
|
width: 100%;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.grp-actions > .w4f-btn { flex: 1 1 0; min-height: 38px; font-size: 12px; }
|
||||||
|
|
||||||
|
/* 转发卡:路由占整行,其余属性换行,按钮另起一行 */
|
||||||
|
.fwd-card { padding: 11px 12px; }
|
||||||
|
.fwd-head { row-gap: 7px; }
|
||||||
|
.fwd-route {
|
||||||
|
order: -1;
|
||||||
|
width: 100%;
|
||||||
|
margin-right: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 2px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.fwd-owner { max-width: 100%; }
|
||||||
|
.fwd-actions {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.fwd-actions > .w4f-btn { flex: 1 1 0; min-height: 38px; }
|
||||||
|
|
||||||
|
/* 远程节点卡:名称占行,操作按钮平分 */
|
||||||
|
.node-card { padding: 14px 15px; }
|
||||||
|
.nc-name { width: 100%; margin-right: 0; }
|
||||||
|
.nc-actions { width: 100%; }
|
||||||
|
.nc-actions > .w4f-btn { flex: 1 1 0; min-height: 38px; }
|
||||||
|
.nc-cluster-note { max-width: 100%; }
|
||||||
|
.meta-pill { max-width: 100%; }
|
||||||
|
.np-err { max-width: 45%; }
|
||||||
|
|
||||||
|
/* 对话框表单:标签在上、输入在下(窄屏下 72px 标签挤死输入框) */
|
||||||
|
.form-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 5px;
|
||||||
|
label { width: auto; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 380px) {
|
||||||
|
/* Keep the KPI grid at TWO columns even on the narrowest phones.
|
||||||
|
* Dropping to one column made four full-width cards ~565px tall, which on its
|
||||||
|
* own exceeded the whole viewport and (with the page-level scroller of the
|
||||||
|
* day) collapsed the content area to zero height. Two columns keeps each card
|
||||||
|
* ~160px wide — enough for the 27px number + 11.5px caption, which is all a
|
||||||
|
* KPI card holds. */
|
||||||
|
.kpis { gap: 8px; }
|
||||||
|
.fwd-route { font-size: 12.5px; }
|
||||||
|
.page-title { font-size: 16px; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -362,4 +362,37 @@ const onDeleteKey = async (k: ApiKey) => {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- narrow screens ----------
|
||||||
|
两张 el-table 列实宽加起来 ~900px,窄屏必然需要横向滚动;
|
||||||
|
Element Plus 自己会处理表内滚动,但卡片内边距与标题行需要压缩,
|
||||||
|
否则“导出 CSV / 新建”两个按钮会把标题挤出容器。 */
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.users-view { gap: 14px; }
|
||||||
|
.card { padding: 13px 13px; border-radius: 14px; }
|
||||||
|
|
||||||
|
.card-h {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
.card-h .h-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
/* Element 按钮在窄屏平分整行,并抬到 40px 触控高度 */
|
||||||
|
.card-h .h-actions :deep(.el-button) {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 40px;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格横向滚动时给个视觉提示,并保证不把卡片撑宽 */
|
||||||
|
:deep(.el-table) {
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
:deep(.el-table__body-wrapper) {
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user