feat: per-forward worker 模型 — 每条转发独立 frpc 配置+独立进程

核心改动(ring 协议不变,Task 本来就是 {Local,Remote,Link} 三元组):
- process.WorkerKey: worker 键改为 'local~remote~port' 三元组
- renderForward: 每条 forward 渲染只含 1 个 proxy 的独立 frpc 配置
  (替代 renderRemote 把该 remote 全部 forwards 塞进一个进程)
- ClaimFn/RevokeFn: 认领/撤销只操作这一条 forward 自己的进程
- SyncWorkers/auto-start: 只拉起 localOnly 转发的独立进程,
  集群转发由 ring 认领节点拉起 (消除三台争抢 proxy already exists)
- RemoteStatus/StartRemote/StopRemote/RestartRemote: remote 级聚合辅助,
  保持 /profiles API 形状不变, 前端零改动
- handleStatus/logs: 按 worker key 解析真实 remote 名

效果: 三台节点的 frpc 各自只注册自己拥有的 proxy, 单条转发故障不再波及兄弟
This commit is contained in:
JianFeeeee
2026-08-24 01:42:58 +08:00
parent 2292ee7f3a
commit e3a978888a
9 changed files with 340 additions and 101 deletions

View File

@ -2,11 +2,13 @@ package httpapi
import (
"encoding/json"
"log"
"net/http"
"os"
"strings"
"webui4frpc/internal/cluster"
"webui4frpc/internal/process"
"webui4frpc/internal/store"
)
@ -127,8 +129,11 @@ func (h *Handler) applyCanvas(w http.ResponseWriter, r *http.Request, canvas *ca
if !keep[old.Name] {
if old.LocalOnly {
if h.Process != nil {
// Per-forward model: stop every localOnly worker of this local.
if fwd, _ := s.LinksForLocal(old.Name); len(fwd) > 0 {
_ = h.Process.Stop(fwd[0].Remote)
for _, f := range fwd {
_ = h.Process.Stop(process.WorkerKey(old.Name, f.Remote, f.RemotePort))
}
}
}
} else if h.Ring != nil {
@ -324,9 +329,13 @@ func (h *Handler) handleRemoteUpsert(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Start the worker if enabled.
// Start the per-forward workers for this remote if enabled.
if rem.Enabled {
_ = h.Process.Start(rem.Name)
// In the per-forward model, merely upserting a remote does not start
// any workers — the actual links control which forwards run. The
// caller is expected to save the canvas (PUT /canvas) or start
// individual forwards via the forwards page.
log.Printf("remote %s upserted (enabled=%v); use status page forwards to start", rem.Name, rem.Enabled)
}
writeJSON(w, http.StatusOK, rem)
}
@ -341,7 +350,7 @@ func (h *Handler) handleRemoteDelete(w http.ResponseWriter, r *http.Request) {
http.Error(w, "remote name required", http.StatusBadRequest)
return
}
_ = h.Process.Stop(name)
_ = h.Process.StopRemote(name)
if err := h.Store.DeleteRemote(name); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@ -367,8 +376,10 @@ func (h *Handler) handleProfile(w http.ResponseWriter, r *http.Request) {
switch action {
case "":
// GET profile status
st, has := h.Process.Status(name)
// GET profile status — aggregate per-forward workers into a single
// remote-centric view. "hasProcess" is true when ANY forward worker
// for this remote is running.
st, has := h.Process.RemoteStatus(name)
forwards, _ := h.Store.LinksForRemote(name)
writeJSON(w, http.StatusOK, map[string]any{
"name": name, "process": st, "hasProcess": has, "forwards": forwards,
@ -384,11 +395,11 @@ func (h *Handler) handleProfile(w http.ResponseWriter, r *http.Request) {
}
var err error
if action == "start" {
err = h.Process.Start(name)
err = h.Process.StartRemote(name)
} else if action == "stop" {
err = h.Process.Stop(name)
err = h.Process.StopRemote(name)
} else {
err = h.Process.Restart(name)
err = h.Process.RestartRemote(name)
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@ -399,7 +410,15 @@ func (h *Handler) handleProfile(w http.ResponseWriter, r *http.Request) {
methodNotAllowed(w)
}
case "config":
data, err := os.ReadFile(h.Process.ConfigPath(name))
// Per-forward model: no single config file for a remote. Return the
// first forward's config as a hint; the status page already shows
// per-forward configs.
workers := h.Process.RemoteWorkers(name)
if len(workers) == 0 {
http.NotFound(w, r)
return
}
data, err := os.ReadFile(h.Process.ConfigPath(workers[0]))
if err != nil {
http.NotFound(w, r)
return
@ -407,7 +426,13 @@ func (h *Handler) handleProfile(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(data)
case "logs":
path := h.Process.LogPath(name)
// Per-forward model: return the first per-forward log for this remote.
workers := h.Process.RemoteWorkers(name)
if len(workers) == 0 {
http.Error(w, "no workers for this remote", http.StatusNotFound)
return
}
path := h.Process.LogPath(workers[0])
data, err := tailFile(path, 64*1024)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)