mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-10-02 15:14:01 +00:00
承接用户指出的遗留:links 表是每节点本地副本,靠「store→环上行 + 环→store
下行」双向 sync 收敛,但两边都可能被覆盖。其中「本地 store 权威」这条规则有
硬伤,本次改掉。
## 先复现,再动手
写探针验证「入环 token 会不会冲掉本地未传播的决定」,结果比预期严重:
after adopting a stale token: known=true disabled=false
LOCAL DISABLE WAS WIPED by an incoming token
OnToken/AdoptState 是 `e.state = tk.State` **整体替换**。两次 token 之间做出的
停用决定,只要下一轮到达的 token 是「决定之前」捕获的,就会被整个洗掉 ——
决定永远传不出去,转发照旧运行。Group 之所以看起来没这个问题,是因为它每次
adoption 都被 `SetTopologySync` 从 store 重新推上去,而 disabled 没有对应的
「尚未传播」保护。
## 改法:环权威 + 本地决定带「未确认」标记
1. **环权威**:adoption 时把环上的 disabled 写穿本地 store(write-through,
不是监听器),任何 peer 的决定都在一个 token 周期内落地。这终结了旧规则
「各人信自己那份」造成的永久分歧。顺序上只有单向要求:引擎先重新断言本地
未确认决定,再跑 host sync,所以读环绝不会覆盖用户刚做的操作。
2. **未确认决定受保护**(RingEngine.localDisabled):`UpdateTopologyDisabled`
记下决定,adoption 后由 `reconcileLocalDisabled` 重新断言到刚采纳的 state 上,
于是它会随下一轮 token 传出去。环报回同值时删除该键(全cluster已一致);
转发从 topology 消失时一并清扫,map 不会无限增长。**false 同样受保护** ——
重新启用也需要传播,丢掉它会把转发永久留在停用态。
3. `TopologyDisabled()` 对未确认决定短路返回本地值,避免状态页与用户刚做的
操作相反。
## 测试(又抓到一个假绿)
新增 4 条:停用/启用跨 adoption 存活、确认后停止断言(让位给 peer 的后续决定)、
追踪表不累积。
★ `TestLocalDisableSurvivesAdoption` **第一版是假绿**:它断言
`TopologyDisabled()`,而该访问器会短路到本地决定,于是「即便即将转发的 state
仍是 enabled」它也报 true。改成断言**下游节点会看到什么**(用一个 peer 引擎
AdoptState 本引擎的 state)后,去掉重新断言如期变红:
the forwarded token carries disabled=false, want true
双向验证通过,这个教训要记:测「声明」而不是测「实际传播的状态」,等于没测。
go build / go vet / go test ./... 全绿,gofmt 干净。
918 lines
33 KiB
Go
918 lines
33 KiB
Go
// webui-frpc is a standalone web controller for frpc: configure local forward
|
|
// items and remote servers on a visual canvas, and it spawns one frpc worker
|
|
// per remote server. No frp source code is bundled; frpc binaries are
|
|
// downloaded on demand or pointed at manually.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"webui4frpc/internal/cluster"
|
|
"webui4frpc/internal/httpapi"
|
|
"webui4frpc/internal/install"
|
|
"webui4frpc/internal/process"
|
|
"webui4frpc/internal/render"
|
|
"webui4frpc/internal/store"
|
|
"webui4frpc/internal/version"
|
|
)
|
|
|
|
func main() {
|
|
addr := flag.String("addr", "127.0.0.1:7500", "listen address")
|
|
user := flag.String("user", "admin", "basic auth user")
|
|
pass := flag.String("password", "admin", "basic auth password")
|
|
workdir := flag.String("workdir", "./webui-frpc", "data directory (db, configs, logs, bin)")
|
|
bin := flag.String("bin", "", "initial frpc binary path (optional; defaults to manual/auto-install)")
|
|
frpc := flag.String("frpc", "", "default worker frpc binary path (fallback when settings has none)")
|
|
var peers multiFlag
|
|
flag.Var(&peers, "peer", "cluster peer host:port (repeatable; same creds)")
|
|
joinKey := flag.String("join-key", os.Getenv("W4F_JOIN_KEY"), "cluster join key = the sponsor node's nodeKey (required with -peer to join an existing cluster)")
|
|
flag.Parse()
|
|
|
|
wd, err := filepath.Abs(*workdir)
|
|
if err != nil {
|
|
log.Fatalf("resolve workdir: %v", err)
|
|
}
|
|
for _, d := range []string{wd, filepath.Join(wd, "configs"), filepath.Join(wd, "logs"), filepath.Join(wd, "bin")} {
|
|
if err := os.MkdirAll(d, 0o755); err != nil {
|
|
log.Fatalf("create dir %s: %v", d, err)
|
|
}
|
|
}
|
|
|
|
st, err := store.New(filepath.Join(wd, "manager.db"))
|
|
if err != nil {
|
|
log.Fatalf("open store: %v", err)
|
|
}
|
|
defer st.Close()
|
|
|
|
// Sync the flag credentials into the users table as a system=1 admin account
|
|
// on every startup, so -user/-password changes propagate without manual
|
|
// account setup. The auth middleware's Basic branch still falls back to the
|
|
// raw flag creds, so inter-node cluster traffic (which uses SetBasicAuth
|
|
// with these flags) never breaks even before this row exists.
|
|
if err := st.SyncSystemUser(*user, *pass); err != nil {
|
|
log.Fatalf("sync system user: %v", err)
|
|
}
|
|
|
|
// Seed settings with the initial binary path if provided.
|
|
if *bin != "" {
|
|
cfg, _ := st.Settings()
|
|
cfg.BinaryPath = *bin
|
|
_ = st.UpdateSettings(cfg)
|
|
}
|
|
|
|
// Determine the fallback binary: explicit -bin, else this executable (if it
|
|
// looks like a usable frpc) — but we never want to accidentally treat our
|
|
// own webui binary as frpc. So fallback is empty unless -bin is set; the UI
|
|
// then asks the user to install or provide frpc.
|
|
fallbackBin := ""
|
|
if *frpc != "" {
|
|
fallbackBin = *frpc
|
|
} else if *bin != "" {
|
|
fallbackBin = *bin
|
|
}
|
|
|
|
pm := process.NewManager(process.Options{
|
|
ConfigsDir: filepath.Join(wd, "configs"),
|
|
LogsDir: filepath.Join(wd, "logs"),
|
|
BinaryPath: func() string {
|
|
if s, _ := st.Settings(); s.BinaryPath != "" {
|
|
return s.BinaryPath
|
|
}
|
|
return fallbackBin
|
|
},
|
|
Render: renderForward(st),
|
|
AutoRestart: func(name string) bool {
|
|
s, _ := st.Settings()
|
|
if !s.RestartOnExit {
|
|
return false
|
|
}
|
|
rem, ok := st.GetRemote(name)
|
|
return ok && rem.Enabled
|
|
},
|
|
RestartInterval: func() int {
|
|
s, _ := st.Settings()
|
|
if s.RestartIntervalSeconds > 0 {
|
|
return s.RestartIntervalSeconds
|
|
}
|
|
return 5
|
|
},
|
|
})
|
|
|
|
reg := cluster.NewRegistry(filepath.Join(wd, "bin"))
|
|
// M6: register cluster peers from -peer flags (same creds as this node).
|
|
for _, p := range peers {
|
|
reg.RegisterPeer(p, p, *user, *pass)
|
|
}
|
|
|
|
// M6 token ring: this node is the initial leader of the cluster ring
|
|
// (creation node). Engine drives two-phase cycles; transport POSTs the
|
|
// token to the successor over HTTP.
|
|
selfID := reachableAddr(*addr)
|
|
ringTransport := &cluster.TokenTransport{User: *user, Pass: *pass}
|
|
// nodeKey is this node's cluster admission key. It is NOT generated at
|
|
// startup — a fresh node has no key until it creates or joins a cluster.
|
|
// A creation node (no -peer) generates its key on CreateCluster; a joiner
|
|
// (has -peer) generates its key on AdoptState after a successful join.
|
|
// If the store has a persisted key (from a previous cluster membership),
|
|
// restore it so a restart-in-cluster node keeps its identity.
|
|
nodeKey := ""
|
|
if s, _ := st.Settings(); s.NodeKey != "" {
|
|
nodeKey = s.NodeKey
|
|
}
|
|
var ring *cluster.Engine
|
|
ring = cluster.NewEngine(
|
|
selfID, selfID, *user, *pass, version.Version, nil,
|
|
&cluster.AppHandler{
|
|
LoadFn: func() (float64, float64) {
|
|
// NetPct: real NIC saturation from /proc/net/dev deltas vs
|
|
// interface link speed (Linux); 0 fallback elsewhere. A NIC
|
|
// already near capacity should stop attracting new forwards —
|
|
// the forward itself is a network hop.
|
|
return cluster.SampleMemLoad(), cluster.SampleNetLoad()
|
|
},
|
|
// Claim persists the forward locally (intermediate config -> store),
|
|
// then syncs workers so the frpc worker for that remote starts.
|
|
ClaimFn: func(ctx context.Context, tk *cluster.Task) error {
|
|
if err := st.UpsertLocal(tk.Local); err != nil {
|
|
return err
|
|
}
|
|
if err := st.UpsertRemote(tk.Remote); err != nil {
|
|
return err
|
|
}
|
|
links, err := st.ListLinks()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Upsert the task's link (dedup by local+remote+remotePort).
|
|
// The submitter persists the canvas — including this link — via
|
|
// saveCanvas, and when this same node is also the claimer (lowest
|
|
// load) blindly re-appending would duplicate the proxy in the
|
|
// rendered frpc config (two proxies, same remotePort) and the
|
|
// worker would fail to register. Skip only if already present.
|
|
dup := false
|
|
for _, l := range links {
|
|
if l.Local == tk.Link.Local && l.Remote == tk.Link.Remote && l.RemotePort == tk.Link.RemotePort {
|
|
dup = true
|
|
break
|
|
}
|
|
}
|
|
if !dup {
|
|
links = append(links, tk.Link)
|
|
}
|
|
if err := st.ReplaceLinks(links); err != nil {
|
|
return err
|
|
}
|
|
// A re-claim after a forward-centric stop leaves the link flagged
|
|
// disabled (by RevokeFn). Do NOT clear that flag here: the ring
|
|
// re-claims automatically (startup reconcile re-spawns any owned
|
|
// 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.
|
|
// Each forward has its own frpc process (keyed by the forward
|
|
// triple); restarting only this key leaves sibling forwards'
|
|
// 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 {
|
|
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] claimed task %s: %s→%s:%d", selfID, tk.ID, tk.Local.Name, tk.Remote.Name, tk.Link.RemotePort)
|
|
return nil
|
|
},
|
|
// Revoke cancels the forward NON-destructively: stop THIS forward's
|
|
// own worker process (per-forward model — no siblings share it) and
|
|
// flag the exact link disabled so a later forward-centric start can
|
|
// re-enable it. The old delete-local/remote/drop-all-matching-links
|
|
// form served the topology-derived-canvas model but broke per-forward
|
|
// stop/start: it wiped sibling forwards sharing the local or remote.
|
|
RevokeFn: func(ctx context.Context, tk *cluster.Task) error {
|
|
_ = 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)
|
|
if _, running := pm.Status(key); running {
|
|
_ = 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)
|
|
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 {
|
|
if ring == nil {
|
|
return nodeID
|
|
}
|
|
for _, n := range ring.State().Nodes {
|
|
if n.ID == nodeID {
|
|
return n.Addr
|
|
}
|
|
}
|
|
return nodeID
|
|
}),
|
|
selfID, len(peers) == 0, nodeKey, // creation node (no -peer) is the initial leader
|
|
)
|
|
// Install the persistence callbacks so ensureNodeKey (called from
|
|
// CreateCluster / AdoptState) and detachAsStandalone can persist/clear
|
|
// the key in the settings store without the engine depending on store.
|
|
ring.SetKeyPersist(func(key string) error { return st.SetNodeKey(key) })
|
|
// peerPersist saves the cached peer list (addr+key) every token cycle so
|
|
// a crashed node can auto-rejoin via any cached peer on restart. Cleared
|
|
// on explicit detach (detachAsStandalone) so a node that intentionally
|
|
// left does NOT auto-rejoin.
|
|
ring.SetPeerPersist(func(peersJSON string) error { return st.SetClusterPeers(peersJSON) })
|
|
|
|
// Re-apply local store state onto the ring topology after each state
|
|
// 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
|
|
// 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() {
|
|
links, err := st.ListLinks()
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, ln := range links {
|
|
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{
|
|
Store: st,
|
|
Process: pm,
|
|
WorkDir: wd,
|
|
BinDir: filepath.Join(wd, "bin"),
|
|
Sessions: httpapi.NewSessionStore(),
|
|
Cluster: reg,
|
|
Ring: ring,
|
|
// SelfAddr must be reachable by peers. 0.0.0.0/empty on a real host
|
|
// would break peer reconnect, so fall back to the hostname (container
|
|
// name inside docker is a routable addr on the compose network).
|
|
SelfAddr: reachableAddr(*addr),
|
|
User: *user,
|
|
Password: *pass,
|
|
BinaryPath: func() string {
|
|
s, _ := st.Settings()
|
|
if s.BinaryPath != "" {
|
|
return s.BinaryPath
|
|
}
|
|
return fallbackBin
|
|
},
|
|
InstallBinary: func(version string) (string, string, error) {
|
|
// M6: prefer cluster peer exchange (local cache -> peers), fall
|
|
// back to the official GitHub URL only when no peer has it.
|
|
ictx, icancel := context.WithTimeout(context.Background(), 90*time.Second)
|
|
defer icancel()
|
|
if p, err := reg.EnsureVersion(ictx, version); err == nil {
|
|
s, _ := st.Settings()
|
|
s.BinaryPath = p
|
|
if err := st.UpdateSettings(s); err != nil {
|
|
return p, version, err
|
|
}
|
|
return p, version, nil
|
|
}
|
|
path, ver, err := install.Install(context.Background(), filepath.Join(wd, "bin"), version)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
s, _ := st.Settings()
|
|
s.BinaryPath = path
|
|
if err := st.UpdateSettings(s); err != nil {
|
|
return path, ver, err
|
|
}
|
|
return path, ver, nil
|
|
},
|
|
SyncWorkers: func() {
|
|
// PER-FORWARD worker model: every enabled, non-disabled link on THIS
|
|
// node gets its own frpc process keyed by the forward triple.
|
|
// - localOnly forwards: always owned here (loopback stays local).
|
|
// - cluster forwards: only started by the ring claim (ClaimFn) —
|
|
// SyncWorkers deliberately skips them so a node that merely holds
|
|
// a canvas copy does not spawn workers for forwards it doesn't own
|
|
// (the old "every node registers every proxy" bug).
|
|
links, err := st.ListLinks()
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, ln := range links {
|
|
loc, ok := st.GetLocal(ln.Local)
|
|
if !ok || !loc.LocalOnly {
|
|
continue // cluster-owned or unknown local
|
|
}
|
|
rem, ok := st.GetRemote(ln.Remote)
|
|
if !ok || !rem.Enabled {
|
|
continue
|
|
}
|
|
key := process.WorkerKey(ln.Local, ln.Remote, ln.RemotePort)
|
|
if ln.Disabled {
|
|
_ = pm.Stop(key) // per-forward stop kills exactly its process
|
|
continue
|
|
}
|
|
if _, has := pm.Status(key); has {
|
|
_ = pm.Restart(key)
|
|
} else {
|
|
_ = pm.Start(key)
|
|
}
|
|
}
|
|
},
|
|
}
|
|
|
|
// Auto-start localOnly forwards on boot. Cluster forwards are NOT started
|
|
// here — they are re-submitted by the leader's startup auto-submit below
|
|
// and claimed (spawning their own worker) wherever the ring places them.
|
|
s, _ := st.Settings()
|
|
if s.AutoStartProfiles {
|
|
links, _ := st.ListLinks()
|
|
remotesByName := map[string]store.Remote{}
|
|
if remotes, err := st.ListRemotes(); err == nil {
|
|
for _, r := range remotes {
|
|
remotesByName[r.Name] = r
|
|
}
|
|
}
|
|
localsByName := map[string]store.Local{}
|
|
if locals, err := st.ListLocals(); err == nil {
|
|
for _, l := range locals {
|
|
localsByName[l.Name] = l
|
|
}
|
|
}
|
|
for _, ln := range links {
|
|
if ln.Disabled {
|
|
continue
|
|
}
|
|
loc, ok := localsByName[ln.Local]
|
|
if !ok || !loc.LocalOnly {
|
|
continue
|
|
}
|
|
rem, ok := remotesByName[ln.Remote]
|
|
if !ok || !rem.Enabled {
|
|
continue
|
|
}
|
|
_ = pm.Start(process.WorkerKey(ln.Local, ln.Remote, ln.RemotePort))
|
|
}
|
|
}
|
|
|
|
handler, err := httpapi.NewServeMux(h)
|
|
if err != nil {
|
|
log.Fatalf("build handler: %v", err)
|
|
}
|
|
srv := &http.Server{Addr: *addr, Handler: handler}
|
|
|
|
go func() {
|
|
log.Printf("webui-frpc listening on %s (workdir %s)", *addr, wd)
|
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
log.Fatalf("server error: %v", err)
|
|
}
|
|
}()
|
|
|
|
// M6: cluster heartbeat + new-node bootstrap auto-propagation. The
|
|
// bootstrap loop retries every 10s for the first minutes so binaries are
|
|
// pulled as soon as neighbor info appears (a fresh node has none at boot).
|
|
clusterCtx, clusterCancel := context.WithCancel(context.Background())
|
|
defer clusterCancel()
|
|
go reg.Discover(clusterCtx, 30*time.Second)
|
|
// M6 token ring startup. Cached peers (persisted on every token cycle)
|
|
// take priority over the -peer flag — -peer is only for FIRST-TIME
|
|
// bootstrap; on crash/restart the node always rejoin via cached peers.
|
|
//
|
|
// 1. Persisted ClusterPeers → crash/restart recovery: try to rejoin
|
|
// via any cached peer (addr+key from the last token cycle before
|
|
// the crash). First node up finds all peers down → falls back to
|
|
// CreateCluster; others rejoin it via cached peers.
|
|
// 2. No cached peers + -peer flag → first-time bootstrap: join the
|
|
// specified peer with the given join key. Retry every 10s for 5 min.
|
|
// 3. No cached peers, no -peer → first-time creation node: seed a
|
|
// fresh standalone cluster (CreateCluster).
|
|
//
|
|
// Explicit detach (detachAsStandalone) clears ClusterPeers, so a node
|
|
// that intentionally left falls into case 2 or 3 and does NOT auto-rejoin.
|
|
persistedPeers := ""
|
|
if s, _ := st.Settings(); s.ClusterPeers != "" {
|
|
persistedPeers = s.ClusterPeers
|
|
}
|
|
if persistedPeers != "" {
|
|
go retryRejoinCached(clusterCtx, ring, persistedPeers)
|
|
} else if len(peers) > 0 {
|
|
go retryJoinCluster(clusterCtx, ring, peers[0], *joinKey)
|
|
} else {
|
|
if err := ring.CreateCluster(); err != nil {
|
|
log.Printf("ring[%s] create cluster at startup: %v", selfID, err)
|
|
}
|
|
go ring.StartRing(clusterCtx)
|
|
}
|
|
go ring.WatchTokenLoss(clusterCtx)
|
|
go ring.WatchLeader(clusterCtx)
|
|
|
|
// Startup reconcile: every node (leader or not) checks its own topology
|
|
// entries and re-claims any whose per-forward frpc worker process is
|
|
// absent — the topology was recovered via AdoptState but the worker
|
|
// processes died with the restart. This is safer than the leader clearing
|
|
// and re-submitting all topology, which races with leader changes.
|
|
// LocalOnly forwards are never in the topology and are handled by the
|
|
// auto-start loop above.
|
|
go func() {
|
|
for {
|
|
if ring.IsLeader() {
|
|
break
|
|
}
|
|
select {
|
|
case <-time.After(2 * time.Second):
|
|
case <-clusterCtx.Done():
|
|
return
|
|
}
|
|
}
|
|
// Let the ring stabilize after leader election / rejoin.
|
|
time.Sleep(3 * time.Second)
|
|
snap := ring.Snapshot()
|
|
reclaimed := 0
|
|
for _, t := range snap.Topology {
|
|
if t.OwnerID != selfID {
|
|
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)
|
|
if _, has := pm.Status(key); has {
|
|
continue // worker already running
|
|
}
|
|
ctx, cancel := context.WithTimeout(clusterCtx, 15*time.Second)
|
|
if err := ring.Handler.Claim(ctx, &cluster.Task{
|
|
ID: fmt.Sprintf("reclaim-%s-%s-%d", t.Local.Name, t.Remote.Name, t.Link.RemotePort),
|
|
Local: t.Local, Remote: t.Remote, Link: t.Link,
|
|
}); err != nil {
|
|
log.Printf("ring[%s] reclaim %s→%s:%d: %v", ring.ID, t.Local.Name, t.Remote.Name, t.Link.RemotePort, err)
|
|
} else {
|
|
reclaimed++
|
|
}
|
|
cancel()
|
|
}
|
|
// Leader also re-submits any non-disabled, non-localOnly forwards
|
|
// that are NOT yet in the topology (new entries the canvas has but
|
|
// the ring hasn't processed yet).
|
|
links, err := st.ListLinks()
|
|
if err != nil {
|
|
log.Printf("ring[%s] reconcile: %v", ring.ID, err)
|
|
return
|
|
}
|
|
type ownerKey struct {
|
|
local, remote string
|
|
port int
|
|
}
|
|
ownedBy := map[ownerKey]string{}
|
|
for _, t := range snap.Topology {
|
|
k := ownerKey{t.Local.Name, t.Remote.Name, t.Link.RemotePort}
|
|
if _, ok := ownedBy[k]; !ok {
|
|
ownedBy[k] = t.OwnerID
|
|
}
|
|
}
|
|
submitted := 0
|
|
for _, ln := range links {
|
|
if ln.Disabled {
|
|
continue
|
|
}
|
|
loc, ok := st.GetLocal(ln.Local)
|
|
if !ok || loc.LocalOnly {
|
|
continue
|
|
}
|
|
rem, ok := st.GetRemote(ln.Remote)
|
|
if !ok {
|
|
continue
|
|
}
|
|
k := ownerKey{ln.Local, ln.Remote, ln.RemotePort}
|
|
if _, inTopo := ownedBy[k]; inTopo {
|
|
continue // already reclaimed above
|
|
}
|
|
ring.SubmitTask(loc, rem, ln)
|
|
submitted++
|
|
}
|
|
log.Printf("ring[%s] startup reconcile: reclaimed=%d submitted=%d", ring.ID, reclaimed, submitted)
|
|
}()
|
|
|
|
go func() {
|
|
ticker := time.NewTicker(10 * time.Second)
|
|
defer ticker.Stop()
|
|
bootTimeout := time.NewTimer(90 * time.Second)
|
|
defer bootTimeout.Stop()
|
|
for {
|
|
bootCtx, bootCancel := context.WithTimeout(clusterCtx, 20*time.Second)
|
|
err := reg.SyncFromPeers(bootCtx)
|
|
bootCancel()
|
|
log.Printf("cluster bootstrap sync iteration (peers=%d caches=%d err=%v)", len(reg.PeerList()), len(reg.CachedVersions()), err)
|
|
if err != nil {
|
|
log.Printf("cluster bootstrap sync: %v", err)
|
|
}
|
|
select {
|
|
case <-ticker.C:
|
|
case <-bootTimeout.C:
|
|
return
|
|
case <-clusterCtx.Done():
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
sig := make(chan os.Signal, 1)
|
|
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
|
<-sig
|
|
log.Println("shutting down...")
|
|
clusterCancel()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
pm.StopAll()
|
|
_ = srv.Shutdown(ctx)
|
|
}
|
|
|
|
// retryJoinCluster joins the ring at targetAddr using joinKey, retrying every
|
|
// 10s for up to 5 min in case the peer isn't up yet. On success AdoptState is
|
|
// called by JoinRing (which persists peers). On exhaustion, falls back to
|
|
// CreateCluster so the node is at least functional as standalone.
|
|
func retryJoinCluster(ctx context.Context, ring *cluster.Engine, targetAddr, joinKey string) {
|
|
selfNode := ring.State().Nodes[0]
|
|
ji := cluster.JoinInfo{ID: selfNode.ID, Addr: selfNode.Addr, Version: version.Version, JoinKey: joinKey}
|
|
deadline := time.NewTimer(5 * time.Minute)
|
|
defer deadline.Stop()
|
|
ticker := time.NewTicker(10 * time.Second)
|
|
defer ticker.Stop()
|
|
// First attempt immediately, then on each tick.
|
|
for {
|
|
// If another node already joined us (we're now multi-node), stop
|
|
// trying to join — we're already in a ring.
|
|
if ring.IsMember() {
|
|
log.Printf("ring[%s] already a cluster member, skipping join", selfNode.ID)
|
|
return
|
|
}
|
|
jc, cancel := context.WithTimeout(ctx, 8*time.Second)
|
|
err := ring.JoinRing(jc, targetAddr, ji)
|
|
cancel()
|
|
if err == nil {
|
|
log.Printf("ring[%s] joined cluster via %s", selfNode.ID, targetAddr)
|
|
return
|
|
}
|
|
log.Printf("ring[%s] join %s failed: %v; retrying", selfNode.ID, targetAddr, err)
|
|
select {
|
|
case <-ticker.C:
|
|
case <-deadline.C:
|
|
log.Printf("ring[%s] join retry exhausted, falling back to standalone", selfNode.ID)
|
|
_ = ring.CreateCluster()
|
|
go ring.StartRing(ctx)
|
|
return
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// retryRejoinCached is the crash/restart recovery path: parse the persisted
|
|
// peer list (JSON of [{addr,key},...]) and try to rejoin via each peer in
|
|
// order. The first peer that accepts wins. Retry the whole list every 10s
|
|
// for up to 5 min. On exhaustion, fall back to CreateCluster (standalone).
|
|
func retryRejoinCached(ctx context.Context, ring *cluster.Engine, peersJSON string) {
|
|
type peerEntry struct {
|
|
Addr string `json:"addr"`
|
|
Key string `json:"key"`
|
|
}
|
|
var peers []peerEntry
|
|
if err := json.Unmarshal([]byte(peersJSON), &peers); err != nil || len(peers) == 0 {
|
|
log.Printf("ring[%s] cached peers parse error, falling back to standalone", ring.ID)
|
|
_ = ring.CreateCluster()
|
|
go ring.StartRing(ctx)
|
|
return
|
|
}
|
|
selfNode := ring.State().Nodes[0]
|
|
deadline := time.NewTimer(5 * time.Minute)
|
|
defer deadline.Stop()
|
|
for {
|
|
// If another node already joined us, stop trying to rejoin.
|
|
if ring.IsMember() {
|
|
log.Printf("ring[%s] already a cluster member, skipping rejoin", ring.ID)
|
|
return
|
|
}
|
|
for _, p := range peers {
|
|
ji := cluster.JoinInfo{ID: selfNode.ID, Addr: selfNode.Addr, Version: version.Version, JoinKey: p.Key}
|
|
jc, cancel := context.WithTimeout(ctx, 8*time.Second)
|
|
err := ring.JoinRing(jc, p.Addr, ji)
|
|
cancel()
|
|
if err == nil {
|
|
log.Printf("ring[%s] rejoined cluster via cached peer %s", ring.ID, p.Addr)
|
|
return
|
|
}
|
|
log.Printf("ring[%s] rejoin %s failed: %v", ring.ID, p.Addr, err)
|
|
}
|
|
select {
|
|
case <-time.After(10 * time.Second):
|
|
case <-deadline.C:
|
|
log.Printf("ring[%s] rejoin retry exhausted, falling back to standalone", ring.ID)
|
|
_ = ring.CreateCluster()
|
|
go ring.StartRing(ctx)
|
|
return
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// reachableAddr returns an address peers can dial back: an explicit
|
|
// W4F_HOST (compose DNS name) wins; otherwise a non-wildcard listen addr;
|
|
// a wildcard falls back to the primary non-loopback IPv4 detected from the
|
|
// interfaces (routable on plain LAN hosts); hostname is the last resort.
|
|
func reachableAddr(addr string) string {
|
|
if h := os.Getenv("W4F_HOST"); h != "" {
|
|
if _, port, err := net.SplitHostPort(addr); err == nil {
|
|
return net.JoinHostPort(h, port)
|
|
}
|
|
return h
|
|
}
|
|
host, port, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
return addr
|
|
}
|
|
if host == "" || host == "0.0.0.0" || host == "::" || host == "[::]" {
|
|
if ip := primaryLANIPv4(); ip != "" {
|
|
return net.JoinHostPort(ip, port)
|
|
}
|
|
if h, herr := os.Hostname(); herr == nil && h != "" {
|
|
return net.JoinHostPort(h, port)
|
|
}
|
|
}
|
|
return addr
|
|
}
|
|
|
|
// primaryLANIPv4 returns the first global-scope non-loopback IPv4 address of
|
|
// any up interface, preferring RFC1918 ranges. Empty string when none found.
|
|
func primaryLANIPv4() string {
|
|
addrs, err := net.InterfaceAddrs()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
var fallback string
|
|
for _, a := range addrs {
|
|
ipnet, ok := a.(*net.IPNet)
|
|
if !ok || ipnet.IP.To4() == nil || ipnet.IP.IsLoopback() || !ipnet.IP.IsGlobalUnicast() {
|
|
continue
|
|
}
|
|
ip := ipnet.IP.To4()
|
|
if fallback == "" {
|
|
fallback = ip.String()
|
|
}
|
|
// prefer private ranges over link-local / unusual globals
|
|
if ip[0] == 10 || (ip[0] == 172 && ip[1]&0xf0 == 16) || (ip[0] == 192 && ip[1] == 168) {
|
|
return ip.String()
|
|
}
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// splitCSV splits a comma-separated list into a slice (empty -> nil).
|
|
func splitCSV(s string) []string {
|
|
if strings.TrimSpace(s) == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(s, ",")
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
if p = strings.TrimSpace(p); p != "" {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// renderForward builds the process.Render closure for the PER-FORWARD worker
|
|
// model: each (local, remote, remotePort) link gets its OWN frpc config
|
|
// containing exactly ONE proxy. The worker key is the forward triple
|
|
// (process.WorkerKey), so every node only ever registers its own proxies on
|
|
// frps — this eliminates the multi-node "proxy already exists" fight of the
|
|
// old per-remote model, where every node rendered ALL forwards of a remote.
|
|
// A disabled link renders an empty proxy list: frpc exits cleanly with no
|
|
// proxies (the supervisor sees a clean exit and stops restarting it), which
|
|
// is exactly the per-forward stop semantics.
|
|
func renderForward(st *store.Store) func(string) ([]byte, error) {
|
|
return func(key string) ([]byte, error) {
|
|
localName, remoteName, port, ok := process.ParseWorkerKey(key)
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid worker key %q", key)
|
|
}
|
|
rem, ok := st.GetRemote(remoteName)
|
|
if !ok {
|
|
return nil, store.ErrNotFound
|
|
}
|
|
loc, ok := st.GetLocal(localName)
|
|
if !ok {
|
|
return nil, store.ErrNotFound
|
|
}
|
|
disabled := false
|
|
for _, ln := range mustListLinks(st) {
|
|
if ln.Local == localName && ln.Remote == remoteName && ln.RemotePort == port {
|
|
disabled = ln.Disabled
|
|
break
|
|
}
|
|
}
|
|
proxies := []render.Proxy(nil)
|
|
if !disabled {
|
|
proxies = append(proxies, render.Proxy{
|
|
Name: loc.Name,
|
|
Type: loc.Protocol,
|
|
LocalIP: loc.IP,
|
|
LocalPort: loc.Port,
|
|
RemotePort: port,
|
|
UseEncryption: loc.UseEncryption,
|
|
UseCompression: loc.UseCompression,
|
|
BandwidthLimit: loc.BandwidthLimit,
|
|
PoolCount: loc.PoolCount,
|
|
Metadatas: loc.Metadatas,
|
|
Annotations: loc.Annotations,
|
|
CustomDomains: splitCSV(loc.CustomDomains),
|
|
SubDomain: loc.SubDomain,
|
|
Locations: loc.Locations,
|
|
HostHeaderRewrite: loc.HostHeaderRewrite,
|
|
HTTPHeaders: loc.HTTPHeaders,
|
|
BasicAuthUser: loc.BasicAuthUser,
|
|
BasicAuthPassword: loc.BasicAuthPassword,
|
|
|
|
// M3: load balancing group + health check.
|
|
LBGroup: loc.LBGroup,
|
|
LBGroupKey: loc.LBGroupKey,
|
|
HealthCheckType: loc.HealthCheckType,
|
|
HealthCheckPath: loc.HealthCheckPath,
|
|
HealthCheckTimeout: loc.HealthCheckTimeout,
|
|
HealthCheckMaxFailed: loc.HealthCheckMaxFailed,
|
|
HealthCheckInterval: loc.HealthCheckInterval,
|
|
})
|
|
}
|
|
return render.Render(rem, proxies)
|
|
}
|
|
}
|
|
|
|
// mustListLinks is ListLinks with errors swallowed (best-effort reads).
|
|
func mustListLinks(st *store.Store) []store.Link {
|
|
links, _ := st.ListLinks()
|
|
return links
|
|
}
|
|
|
|
// multiFlag collects repeated string flags (-peer a -peer b ...).
|
|
type multiFlag []string
|
|
|
|
func (m *multiFlag) String() string { return strings.Join(*m, ",") }
|
|
func (m *multiFlag) Set(v string) error {
|
|
*m = append(*m, v)
|
|
return nil
|
|
}
|