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

@ -287,6 +287,88 @@ func (m *Manager) ListWorkers() []string {
return out
}
// RemoteWorkers returns the worker keys of all per-forward workers whose key
// parses to the given remote name. In the per-forward model every link has
// its own process keyed "local~remote~port"; this aggregates them so remote-
// centric callers (status page, profile endpoints) can report an overall
// view without knowing the key format.
func (m *Manager) RemoteWorkers(remote string) []string {
var out []string
for _, key := range m.ListWorkers() {
if _, r, _, ok := ParseWorkerKey(key); ok && r == remote {
out = append(out, key)
}
}
return out
}
// RemoteStatus summarizes a remote's per-forward workers into one Status:
// running when ANY forward worker runs; crashed/starting/restarting take
// priority in that order; stopped (has=false) when no worker exists.
// This keeps the legacy per-remote status shape working on top of the
// per-forward worker model.
func (m *Manager) RemoteStatus(remote string) (Status, bool) {
keys := m.RemoteWorkers(remote)
if len(keys) == 0 {
return Status{State: "stopped"}, false
}
summary := Status{State: "stopped"}
worst := -1 // rank: running=0 < others=1 < crashed=2
for _, k := range keys {
st, _ := m.Status(k)
rank := 1
switch st.State {
case "running":
rank = 0
case "crashed":
rank = 2
}
if rank > worst || (rank == worst && summary.State == "stopped") {
if rank != worst || st.State != "stopped" {
summary = st
worst = rank
}
}
}
return summary, true
}
// StartRemote starts every per-forward worker of one remote. Missing workers
// are skipped; the first error wins.
func (m *Manager) StartRemote(remote string) error {
var firstErr error
for _, key := range m.RemoteWorkers(remote) {
if err := m.Start(key); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
// RestartRemote restarts every per-forward worker belonging to one remote
// (used where the old model had a single per-remote worker). Missing workers
// are skipped; the first error wins.
func (m *Manager) RestartRemote(remote string) error {
var firstErr error
for _, key := range m.RemoteWorkers(remote) {
if err := m.Restart(key); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
// StopRemote stops every per-forward worker of one remote.
func (m *Manager) StopRemote(remote string) error {
var firstErr error
for _, key := range m.RemoteWorkers(remote) {
if err := m.Stop(key); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
// StopAll stops all workers (used on manager shutdown).
func (m *Manager) StopAll() {
m.mu.Lock()

View File

@ -0,0 +1,39 @@
package process
import (
"fmt"
"strconv"
"strings"
)
// WorkerKey is the unique identity of ONE forward's worker process:
//
// "<local>~<remote>~<port>"
//
// The per-forward worker model gives every link its own frpc process, config
// file and log file. This fixes the multi-node proxy-name fight of the old
// per-remote model (every node rendered the SAME proxy list and raced to
// register them on frps -> "proxy already exists"), and isolates faults: one
// crashing forward can no longer take its siblings down.
//
// '~' is a legal filename character on every supported OS (including Windows,
// unlike ':') and is rare in user-chosen node names. ParseWorkerKey rejects
// malformed keys so callers fail loudly instead of silently mis-rendering.
func WorkerKey(local, remote string, port int) string {
return fmt.Sprintf("%s~%s~%d", local, remote, port)
}
// ParseWorkerKey splits a worker key back into its (local, remote, port)
// forward triple. ok=false for anything that is not a well-formed key
// (e.g. legacy per-remote worker names).
func ParseWorkerKey(key string) (local, remote string, port int, ok bool) {
parts := strings.Split(key, "~")
if len(parts) != 3 {
return "", "", 0, false
}
p, err := strconv.Atoi(parts[2])
if err != nil || parts[0] == "" || parts[1] == "" || p <= 0 {
return "", "", 0, false
}
return parts[0], parts[1], p, true
}

View File

@ -0,0 +1,37 @@
package process
import "testing"
func TestWorkerKeyRoundTrip(t *testing.T) {
cases := []struct {
local, remote string
port int
}{
{"web", "frps1", 18080},
{"db", "aliyun-frps", 5432},
{"svc-1", "node.a.example", 80},
}
for _, c := range cases {
key := WorkerKey(c.local, c.remote, c.port)
l, r, p, ok := ParseWorkerKey(key)
if !ok || l != c.local || r != c.remote || p != c.port {
t.Fatalf("roundtrip %q -> (%q,%q,%d,%v)", key, l, r, p, ok)
}
}
}
func TestParseWorkerKeyRejectsBad(t *testing.T) {
bad := []string{
"", "srv-a", // legacy per-remote names / empty
"a~b", // missing port
"a~b~x", // non-numeric port
"a~b~0", // port out of range
"~b~1", // empty local
"a~~1", // empty remote
}
for _, key := range bad {
if _, _, _, ok := ParseWorkerKey(key); ok {
t.Fatalf("ParseWorkerKey(%q) accepted, want reject", key)
}
}
}