feat(M6): localOnly forward option (loopback→LAN rewrite for cluster, local direct), canvas diff-based create/revoke commands, revoke task semantics, topology derived canvas on sync nodes, LAN addr helper

This commit is contained in:
2026-08-18 09:44:38 +08:00
parent bf602016b4
commit 40980944e5
12 changed files with 364 additions and 13 deletions

View File

@ -0,0 +1,50 @@
// LocalAddr helpers: resolve this node's routable LAN address used when a
// forward targets a loopback (127.0.0.1/0.0.0.0/localhost) and is NOT marked
// local-only — the task is distributed to the cluster, so the backend address
// must be reachable from whichever node claims it.
package cluster
import (
"net"
)
// LanAddr returns this host's first non-loopback IPv4 address ("" if none can
// be determined, e.g. offline/no interface).
func LanAddr() string {
addrs, err := net.InterfaceAddrs()
if err != nil {
return ""
}
for _, a := range addrs {
if ipn, ok := a.(*net.IPNet); ok {
ip := ipn.IP
if ip.IsLoopback() || ip.To4() == nil {
continue
}
return ip.String()
}
}
return ""
}
// IsLoopbackIP reports whether addr is a loopback/unspecified/localhost value
// that must be rewritten to a routable address before cluster distribution.
func IsLoopbackIP(addr string) bool {
switch addr {
case "", "127.0.0.1", "0.0.0.0", "::1", "::", "localhost":
return true
}
return false
}
// RewriteForCluster returns addr if it is routable, or the LAN address when it
// is loopback. Returns original when no LAN address is known (keeps ip as-is).
func RewriteForCluster(addr string) string {
if !IsLoopbackIP(addr) {
return addr
}
if lan := LanAddr(); lan != "" {
return lan
}
return addr
}

View File

@ -49,6 +49,10 @@ type Task struct {
Remote store.Remote `json:"remote"`
Link store.Link `json:"link"`
Created int64 `json:"created"`
// Revoke marks a REVOCATION task: instead of creating the forward, the
// owning node cancels it (stop worker, drop from topology). Reuses the
// same publish channel as creation (round-1 inject, round-2 apply).
Revoke bool `json:"revoke,omitempty"`
}
// TopoEntry is an ESTABLISHED forward in the cluster topology: who (OwnerID)
@ -201,6 +205,25 @@ func (s *State) NextTaskID() string {
return fmt.Sprintf("t%d", s.Seq)
}
// AddRevoke publishes a REVOCATION task (same channel as creation): when a
// node wants to cancel an established forward, it injects a Revoke task;
// the owning node cancels the worker and drops it from topology.
func (s *State) AddRevoke(local store.Local, remote store.Remote, link store.Link) *Task {
t := &Task{
ID: s.NextTaskID(),
Local: local,
Remote: remote,
Link: link,
Created: time.Now().Unix(),
Revoke: true,
}
if s.PendingTasks == nil {
s.PendingTasks = map[string]*Task{}
}
s.PendingTasks[t.ID] = t
return t
}
// AddPending attaches a NEW forward request to the token (round-1 inject).
func (s *State) AddPending(local store.Local, remote store.Remote, link store.Link) *Task {
t := &Task{
@ -257,6 +280,18 @@ func (s *State) AddTopology(t *Task, ownerID string) *TopoEntry {
return e
}
// RemoveTopology drops the active forward for the given local/remote/port
// (returns true if removed — the owning node revokes it).
func (s *State) RemoveTopology(local, remote string, port int) bool {
for id, e := range s.Topology {
if e.Local.Name == local && e.Remote.Name == remote && e.Link.RemotePort == port {
delete(s.Topology, id)
return true
}
}
return false
}
// TopologyList returns active forwards, stable by task id.
func (s *State) TopologyList() []*TopoEntry {
out := make([]*TopoEntry, 0, len(s.Topology))

View File

@ -20,6 +20,7 @@ const (
// (create the frpc worker / forward). Injected to avoid import cycle.
type Handler interface {
Claim(ctx context.Context, tk *Task) error
Revoke(ctx context.Context, tk *Task) error
RuntimeLoad() Load
}
@ -145,6 +146,23 @@ func (e *Engine) phase2(ctx context.Context, tk *Token) error {
if claimed == nil {
break
}
// Revocation task: the owning node cancels the forward (stop worker,
// drop from topology, log forward.remove). Idempotent if missing.
if claimed.Revoke {
if e.state.RemoveTopology(claimed.Local.Name, claimed.Remote.Name, claimed.Link.RemotePort) {
if e.Handler != nil {
if err := e.Handler.Revoke(ctx, claimed); err != nil {
log.Printf("ring[%s] revoke %s: %v", e.ID, claimed.ID, err)
}
}
if e.Log != nil {
_, _ = e.Log.Append(e.ID, LogForwardRemove, map[string]any{
"taskId": claimed.ID, "local": claimed.Local.Name, "remote": claimed.Remote.Name,
})
}
}
continue
}
if e.Handler != nil {
if err := e.Handler.Claim(ctx, claimed); err != nil {
e.state.PendingTasks[claimed.ID] = claimed
@ -366,10 +384,35 @@ func (e *Engine) AdoptState(s State) {
// SubmitTask adds a new forward request to pending; it rides the next token
// round and is claimed by the lowest-load member.
// RevokeTask publishes a revocation for an established forward through the
// 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 {
return e.state.AddRevoke(local, remote, link)
}
func (e *Engine) SubmitTask(local store.Local, remote store.Remote, link store.Link) *Task {
if e.HasTask(local.Name, remote.Name, link.RemotePort) {
return nil
}
return e.state.AddPending(local, remote, link)
}
// HasTask reports whether a forward with the same local/remote/remotePort is
// already pending or active in the topology (idempotency guard for resaves).
func (e *Engine) HasTask(local, remote string, port int) bool {
for _, t := range e.state.PendingList() {
if t.Local.Name == local && t.Remote.Name == remote && t.Link.RemotePort == port {
return true
}
}
for _, t := range e.state.TopologyList() {
if t.Local.Name == local && t.Remote.Name == remote && t.Link.RemotePort == port {
return true
}
}
return false
}
// IsLeader reports whether this node is the current ring leader.
func (e *Engine) IsLeader() bool { return e.state.LeaderID == e.ID }

View File

@ -6,8 +6,16 @@ import (
)
type fakeHandler struct {
load Load
claim func(ctx context.Context, tk *Task) error
load Load
claim func(ctx context.Context, tk *Task) error
revoke func(ctx context.Context, tk *Task) error
}
func (h *fakeHandler) Revoke(ctx context.Context, tk *Task) error {
if h.revoke != nil {
return h.revoke(ctx, tk)
}
return nil
}
func (h *fakeHandler) RuntimeLoad() Load { return h.load }

View File

@ -15,6 +15,8 @@ type AppHandler struct {
LoadFn func() (memPct, netPct float64)
// ClaimFn creates the forward on this node (persist + spawn worker).
ClaimFn func(ctx context.Context, tk *Task) error
// RevokeFn cancels the forward on this node (stop worker + drop from store).
RevokeFn func(ctx context.Context, tk *Task) error
}
// RuntimeLoad implements Handler.
@ -34,6 +36,14 @@ func (a *AppHandler) Claim(ctx context.Context, tk *Task) error {
return a.ClaimFn(ctx, tk)
}
// Revoke implements Handler.
func (a *AppHandler) Revoke(ctx context.Context, tk *Task) error {
if a.RevokeFn == nil {
return nil
}
return a.RevokeFn(ctx, tk)
}
// SampleMemLoad returns a cheap memory-usage percentage (0..100).
func SampleMemLoad() float64 {
var m runtime.MemStats

View File

@ -0,0 +1,64 @@
package cluster
import (
"context"
"testing"
"webui4frpc/internal/store"
)
// TestRevokeTaskRemovesTopology: a revoke task published to the ring removes
// the forward from topology, calls Handler.Revoke, and logs forward.remove.
func TestRevokeTaskRemovesTopology(t *testing.T) {
revoked := false
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 = true; return nil }},
func(ctx context.Context, next string, tk *Token) error { return nil },
"n1:7500", true)
// establish a forward
eng.state.AddPending(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
// lowest-load is n1 (only node): it claims + builds topology
if err := eng.phase2(context.Background(), &Token{Cycle: 1, Phase: PhaseSync, State: eng.state}); err != nil {
t.Fatal(err)
}
if len(eng.state.TopologyList()) != 1 {
t.Fatalf("topology after claim = %+v", eng.state.TopologyList())
}
// publish a revoke task pointing at the same forward
eng.state.AddRevoke(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081})
if err := eng.phase2(context.Background(), &Token{Cycle: 2, Phase: PhaseSync, State: eng.state}); err != nil {
t.Fatal(err)
}
if len(eng.state.TopologyList()) != 0 {
t.Fatalf("topology after revoke = %+v", eng.state.TopologyList())
}
if !revoked {
t.Fatal("Handler.Revoke was not called")
}
// log should contain forward.remove
var sawRemove bool
for _, e := range eng.Log.Snapshot() {
if e.Kind == LogForwardRemove {
sawRemove = true
}
}
if !sawRemove {
t.Fatalf("log missing forward.remove: %+v", eng.Log.Snapshot())
}
}
// TestRevokeIdempotent: revoking an already-missing forward does not error.
func TestRevokeIdempotent(t *testing.T) {
eng := newTestEngine("n1", true)
eng.state.AddRevoke(store.Local{Name: "ghost"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 1})
if err := eng.phase2(context.Background(), &Token{Cycle: 1, Phase: PhaseSync, State: eng.state}); err != nil {
t.Fatalf("revoke missing: %v", err)
}
// no topology entry, no panic
if len(eng.state.TopologyList()) != 0 {
t.Fatal("should be empty")
}
}

View File

@ -6,6 +6,7 @@ import (
"os"
"strings"
"webui4frpc/internal/cluster"
"webui4frpc/internal/store"
)
@ -42,6 +43,16 @@ func (h *Handler) saveCanvas(w http.ResponseWriter, r *http.Request) {
}
s := h.Store
// Rewrite loopback backend addresses for cluster-distributed forwards.
// Non-localOnly locals targeting 127.0.0.1/0.0.0.0/localhost must be
// reachable from whichever node claims them, so swap in this node LAN addr.
// Local-only forwards keep the loopback as-is (they never leave this node).
for i := range canvas.Locals {
if !canvas.Locals[i].LocalOnly && cluster.IsLoopbackIP(canvas.Locals[i].IP) {
canvas.Locals[i].IP = cluster.RewriteForCluster(canvas.Locals[i].IP)
}
}
// Upsert locals.
for _, l := range canvas.Locals {
if err := s.UpsertLocal(l); err != nil {
@ -49,7 +60,9 @@ func (h *Handler) saveCanvas(w http.ResponseWriter, r *http.Request) {
return
}
}
// Delete locals not present.
// Delete locals not present. A removed localOnly forward is cancelled
// locally (stop worker). A removed cluster forward publishes a REVOKE
// task through the token so its owning node cancels it everywhere.
if existing, err := s.ListLocals(); err == nil {
keep := map[string]bool{}
for _, l := range canvas.Locals {
@ -57,6 +70,19 @@ func (h *Handler) saveCanvas(w http.ResponseWriter, r *http.Request) {
}
for _, old := range existing {
if !keep[old.Name] {
if old.LocalOnly {
if h.Process != nil {
if fwd, _ := s.LinksForLocal(old.Name); len(fwd) > 0 {
_ = h.Process.Stop(fwd[0].Remote)
}
}
} else if h.Ring != nil {
if fwd, _ := s.LinksForLocal(old.Name); len(fwd) > 0 {
if rem, ok := s.GetRemote(fwd[0].Remote); ok {
h.Ring.RevokeTask(old, rem, store.Link{Local: old.Name, Remote: rem.Name, RemotePort: fwd[0].RemotePort})
}
}
}
_ = s.DeleteLocal(old.Name)
}
}
@ -87,7 +113,34 @@ func (h *Handler) saveCanvas(w http.ResponseWriter, r *http.Request) {
return
}
// Restart affected running workers so changes take effect immediately.
// Cluster distribution: non-localOnly forwards are submitted as token-ring
// tasks (claimed by the lowest-load member). Local-only forwards are NOT
// submitted — they stay on this node and are only visible here.
if h.Ring != nil {
for _, l := range canvas.Locals {
if l.LocalOnly {
continue
}
for _, ln := range canvas.Links {
if ln.Local != l.Name {
continue
}
var rem store.Remote
for _, rr := range canvas.Remotes {
if rr.Name == ln.Remote {
rem = rr
break
}
}
if rem.Name != "" {
h.Ring.SubmitTask(l, rem, ln)
}
}
}
}
// Restart affected running workers so changes take effect immediately
// (local-only forwards get their worker started right here).
if h.SyncWorkers != nil {
h.SyncWorkers()
}

View File

@ -2,6 +2,7 @@ package httpapi
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@ -10,6 +11,7 @@ import (
"strings"
"testing"
"webui4frpc/internal/cluster"
"webui4frpc/internal/process"
"webui4frpc/internal/store"
)
@ -192,3 +194,58 @@ func TestFetchProxyStatesFromAdminAPI(t *testing.T) {
t.Fatalf("connStateAdmin(wait start) = %q, want connecting", got)
}
}
// TestSaveCanvasPublishesRevokeTask: deleting a non-localOnly forward from the
// canvas publishes a REVOKE task to the ring (owner cancels it cluster-wide).
func TestSaveCanvasPublishesRevokeTask(t *testing.T) {
dir := t.TempDir()
st, err := store.New(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
defer 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, _ := NewServeMux(h)
ts := httptest.NewServer(mux)
defer ts.Close()
// establish a cluster forward first via save (non-localOnly)
body := `{"locals":[{"name":"web","ip":"127.0.0.1","port":8080,"protocol":"tcp"}],
"remotes":[{"name":"frps1","ip":"10.0.0.1","port":7000,"enabled":true}],
"links":[{"local":"web","remote":"frps1","remotePort":18081}]}`
req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/manager/canvas", strings.NewReader(body))
req.SetBasicAuth("admin", "pw")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
// now save canvas WITHOUT the forward -> should publish revoke
body2 := `{"locals":[],"remotes":[],"links":[]}`
req2, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/manager/canvas", strings.NewReader(body2))
req2.SetBasicAuth("admin", "pw")
resp2, err := http.DefaultClient.Do(req2)
if err != nil {
t.Fatal(err)
}
resp2.Body.Close()
// Ring should now have a pending REVOKE task for web->frps1
var sawRevoke bool
for _, tk := range ring.State().PendingList() {
if tk.Revoke && tk.Local.Name == "web" {
sawRevoke = true
}
}
if !sawRevoke {
t.Fatalf("expected revoke task for web, pending=%+v", ring.State().PendingList())
}
}

View File

@ -41,6 +41,10 @@ type Local struct {
// The frps load balances the group's proxies (many-to-one backend set).
LBGroup string `json:"lbGroup,omitempty"`
LBGroupKey string `json:"lbGroupKey,omitempty"`
// LocalOnly: when true the forward is created directly on this node
// (loopback stays 127.0.0.1), it does not enter the cluster token/topology,
// and is only visible in this node webui.
LocalOnly bool `json:"localOnly,omitempty"`
// Health check (frpc healthCheck): tcp probes LocalIP:LocalPort or an
// http GET to LocalIP:LocalPort + HealthCheckPath. When a group member
@ -114,6 +118,7 @@ CREATE TABLE IF NOT EXISTS locals (
ip TEXT NOT NULL,
port INTEGER NOT NULL,
protocol TEXT NOT NULL DEFAULT 'tcp',
local_only INTEGER NOT NULL DEFAULT 0,
use_encryption INTEGER NOT NULL DEFAULT 0,
use_compression INTEGER NOT NULL DEFAULT 0,
bandwidth_limit TEXT NOT NULL DEFAULT '',
@ -222,6 +227,7 @@ func (s *Store) migrate() error {
"health_check_timeout INTEGER NOT NULL DEFAULT 0",
"health_check_max_failed INTEGER NOT NULL DEFAULT 0",
"health_check_interval INTEGER NOT NULL DEFAULT 0",
"local_only INTEGER NOT NULL DEFAULT 0",
},
"remotes": {
"transport_protocol TEXT NOT NULL DEFAULT ''",
@ -270,7 +276,7 @@ func (s *Store) Close() error { return s.db.Close() }
// ---- Locals ----
func (s *Store) ListLocals() ([]Local, error) {
rows, err := s.db.Query("SELECT name, ip, port, protocol, use_encryption, use_compression, bandwidth_limit, pool_count, metadatas, annotations, custom_domains, subdomain, locations, host_header_rewrite, http_headers, basic_auth_user, basic_auth_password, lb_group, lb_group_key, health_check_type, health_check_path, health_check_timeout, health_check_max_failed, health_check_interval FROM locals ORDER BY name")
rows, err := s.db.Query("SELECT name, ip, port, protocol, local_only, use_encryption, use_compression, bandwidth_limit, pool_count, metadatas, annotations, custom_domains, subdomain, locations, host_header_rewrite, http_headers, basic_auth_user, basic_auth_password, lb_group, lb_group_key, health_check_type, health_check_path, health_check_timeout, health_check_max_failed, health_check_interval FROM locals ORDER BY name")
if err != nil {
return nil, err
}
@ -278,13 +284,14 @@ func (s *Store) ListLocals() ([]Local, error) {
var out []Local
for rows.Next() {
var l Local
var enc, comp int
var enc, comp, localOnly int
var metaRaw, annoRaw, hdrRaw, locRaw string
if err := rows.Scan(&l.Name, &l.IP, &l.Port, &l.Protocol, &enc, &comp, &l.BandwidthLimit, &l.PoolCount, &metaRaw, &annoRaw, &l.CustomDomains, &l.SubDomain, &locRaw, &l.HostHeaderRewrite, &hdrRaw, &l.BasicAuthUser, &l.BasicAuthPassword, &l.LBGroup, &l.LBGroupKey, &l.HealthCheckType, &l.HealthCheckPath, &l.HealthCheckTimeout, &l.HealthCheckMaxFailed, &l.HealthCheckInterval); err != nil {
if err := rows.Scan(&l.Name, &l.IP, &l.Port, &l.Protocol, &localOnly, &enc, &comp, &l.BandwidthLimit, &l.PoolCount, &metaRaw, &annoRaw, &l.CustomDomains, &l.SubDomain, &locRaw, &l.HostHeaderRewrite, &hdrRaw, &l.BasicAuthUser, &l.BasicAuthPassword, &l.LBGroup, &l.LBGroupKey, &l.HealthCheckType, &l.HealthCheckPath, &l.HealthCheckTimeout, &l.HealthCheckMaxFailed, &l.HealthCheckInterval); err != nil {
return nil, err
}
l.UseEncryption = enc != 0
l.UseCompression = comp != 0
l.LocalOnly = localOnly != 0
l.Metadatas = decodeMap(metaRaw)
l.Annotations = decodeMap(annoRaw)
l.Locations = decodeSlice(locRaw)
@ -296,14 +303,15 @@ func (s *Store) ListLocals() ([]Local, error) {
func (s *Store) GetLocal(name string) (Local, bool) {
var l Local
var enc, comp int
var enc, comp, localOnly int
var metaRaw, annoRaw, hdrRaw, locRaw string
row := s.db.QueryRow("SELECT name, ip, port, protocol, use_encryption, use_compression, bandwidth_limit, pool_count, metadatas, annotations, custom_domains, subdomain, locations, host_header_rewrite, http_headers, basic_auth_user, basic_auth_password, lb_group, lb_group_key, health_check_type, health_check_path, health_check_timeout, health_check_max_failed, health_check_interval FROM locals WHERE name = ?", name)
if err := row.Scan(&l.Name, &l.IP, &l.Port, &l.Protocol, &enc, &comp, &l.BandwidthLimit, &l.PoolCount, &metaRaw, &annoRaw, &l.CustomDomains, &l.SubDomain, &locRaw, &l.HostHeaderRewrite, &hdrRaw, &l.BasicAuthUser, &l.BasicAuthPassword, &l.LBGroup, &l.LBGroupKey, &l.HealthCheckType, &l.HealthCheckPath, &l.HealthCheckTimeout, &l.HealthCheckMaxFailed, &l.HealthCheckInterval); err != nil {
row := s.db.QueryRow("SELECT name, ip, port, protocol, local_only, use_encryption, use_compression, bandwidth_limit, pool_count, metadatas, annotations, custom_domains, subdomain, locations, host_header_rewrite, http_headers, basic_auth_user, basic_auth_password, lb_group, lb_group_key, health_check_type, health_check_path, health_check_timeout, health_check_max_failed, health_check_interval FROM locals WHERE name = ?", name)
if err := row.Scan(&l.Name, &l.IP, &l.Port, &l.Protocol, &localOnly, &enc, &comp, &l.BandwidthLimit, &l.PoolCount, &metaRaw, &annoRaw, &l.CustomDomains, &l.SubDomain, &locRaw, &l.HostHeaderRewrite, &hdrRaw, &l.BasicAuthUser, &l.BasicAuthPassword, &l.LBGroup, &l.LBGroupKey, &l.HealthCheckType, &l.HealthCheckPath, &l.HealthCheckTimeout, &l.HealthCheckMaxFailed, &l.HealthCheckInterval); err != nil {
return Local{}, false
}
l.UseEncryption = enc != 0
l.UseCompression = comp != 0
l.LocalOnly = localOnly != 0
l.Metadatas = decodeMap(metaRaw)
l.Annotations = decodeMap(annoRaw)
l.Locations = decodeSlice(locRaw)
@ -313,9 +321,9 @@ func (s *Store) GetLocal(name string) (Local, bool) {
func (s *Store) UpsertLocal(l Local) error {
_, err := s.db.Exec(
"INSERT INTO locals(name, ip, port, protocol, use_encryption, use_compression, bandwidth_limit, pool_count, metadatas, annotations, custom_domains, subdomain, locations, host_header_rewrite, http_headers, basic_auth_user, basic_auth_password, lb_group, lb_group_key, health_check_type, health_check_path, health_check_timeout, health_check_max_failed, health_check_interval) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "+
"ON CONFLICT(name) DO UPDATE SET ip=excluded.ip, port=excluded.port, protocol=excluded.protocol, use_encryption=excluded.use_encryption, use_compression=excluded.use_compression, bandwidth_limit=excluded.bandwidth_limit, pool_count=excluded.pool_count, metadatas=excluded.metadatas, annotations=excluded.annotations, custom_domains=excluded.custom_domains, subdomain=excluded.subdomain, locations=excluded.locations, host_header_rewrite=excluded.host_header_rewrite, http_headers=excluded.http_headers, basic_auth_user=excluded.basic_auth_user, basic_auth_password=excluded.basic_auth_password, lb_group=excluded.lb_group, lb_group_key=excluded.lb_group_key, health_check_type=excluded.health_check_type, health_check_path=excluded.health_check_path, health_check_timeout=excluded.health_check_timeout, health_check_max_failed=excluded.health_check_max_failed, health_check_interval=excluded.health_check_interval",
l.Name, l.IP, l.Port, l.Protocol, boolToInt(l.UseEncryption), boolToInt(l.UseCompression), l.BandwidthLimit, l.PoolCount, encodeMap(l.Metadatas), encodeMap(l.Annotations), l.CustomDomains, l.SubDomain, encodeSlice(l.Locations), l.HostHeaderRewrite, encodeMap(l.HTTPHeaders), l.BasicAuthUser, l.BasicAuthPassword, l.LBGroup, l.LBGroupKey, l.HealthCheckType, l.HealthCheckPath, l.HealthCheckTimeout, l.HealthCheckMaxFailed, l.HealthCheckInterval,
"INSERT INTO locals(name, ip, port, protocol, local_only, use_encryption, use_compression, bandwidth_limit, pool_count, metadatas, annotations, custom_domains, subdomain, locations, host_header_rewrite, http_headers, basic_auth_user, basic_auth_password, lb_group, lb_group_key, health_check_type, health_check_path, health_check_timeout, health_check_max_failed, health_check_interval) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "+
"ON CONFLICT(name) DO UPDATE SET ip=excluded.ip, port=excluded.port, protocol=excluded.protocol, local_only=excluded.local_only, use_encryption=excluded.use_encryption, use_compression=excluded.use_compression, bandwidth_limit=excluded.bandwidth_limit, pool_count=excluded.pool_count, metadatas=excluded.metadatas, annotations=excluded.annotations, custom_domains=excluded.custom_domains, subdomain=excluded.subdomain, locations=excluded.locations, host_header_rewrite=excluded.host_header_rewrite, http_headers=excluded.http_headers, basic_auth_user=excluded.basic_auth_user, basic_auth_password=excluded.basic_auth_password, lb_group=excluded.lb_group, lb_group_key=excluded.lb_group_key, health_check_type=excluded.health_check_type, health_check_path=excluded.health_check_path, health_check_timeout=excluded.health_check_timeout, health_check_max_failed=excluded.health_check_max_failed, health_check_interval=excluded.health_check_interval",
l.Name, l.IP, l.Port, l.Protocol, boolToInt(l.LocalOnly), boolToInt(l.UseEncryption), boolToInt(l.UseCompression), l.BandwidthLimit, l.PoolCount, encodeMap(l.Metadatas), encodeMap(l.Annotations), l.CustomDomains, l.SubDomain, encodeSlice(l.Locations), l.HostHeaderRewrite, encodeMap(l.HTTPHeaders), l.BasicAuthUser, l.BasicAuthPassword, l.LBGroup, l.LBGroupKey, l.HealthCheckType, l.HealthCheckPath, l.HealthCheckTimeout, l.HealthCheckMaxFailed, l.HealthCheckInterval,
)
return err
}

16
plan.md
View File

@ -118,6 +118,22 @@
- 集群状态(拓扑/任务/leader可**由日志重放得到**:新节点加入时,通过令牌/邻居拉取完整日志并重放,即可获得与其它节点一致的完整视图(满足"每个节点都掌握完整集群信息")。
- 该机制与"令牌承载中间配置文件/转发拓扑"正交共存:令牌同时携带〔拓扑快照〕与〔日志增量〕,快照用于即时校验,日志用于一致性追补。
#### 任务撤销(复用任务发布通道,撤销语义)
- 撤销一个转发 = **发布一个"撤销任务"到令牌**与任务发布走同一基础通道round-1 注入、round-2 执行),只是 Type 为撤销、载荷为〔任务 ID + 该转发中间配置〕。
- 令牌环行时,**持有该转发的节点**(其 owner 记录在拓扑中)收到撤销任务后:取消本地 frpc worker → 从活跃拓扑移除该转发 → 记 `forward.remove` 增量日志,全网随之下一次轮次收敛一致。
- 撤销任务幂等:若转发已不在拓扑(已被撤销/离线重挂),收到撤销仅记日志、不报错。
- localOnly 的本地转发撤销不发布到令牌,直接本机取消(因本机即 owner仅本节点可见
#### 画布差异判断diff与命令追加
- **在哪个节点修改 webui 画布,就由该节点进行差异判断**:将该节点画布的「期望状态」与当前集群拓扑(该节点持有的完整视图)比对。
- 差异结果转为**一批转发/撤销命令,追加到令牌任务**
- 画布有、拓扑无 → 追加**新增转发命令**(任务+中间配置)→ 后续由负载最低者摘取创建。
- 画布无、拓扑有 → 追加**撤销转发命令**(撤任务+转发的中间配置)→ 持有者收到后取消 worker 并移出拓扑。
- 画布即唯一编辑入口:编辑节点只发命令,不直接改其它节点;拓扑随令牌轮次收敛全网一致。
- **其它节点根据集群拓扑生成本地画布存储**非编辑节点收到令牌同步的完整拓扑后把拓扑中的转发locals/remotes/links重建为本地画布视图叠加本节点的 localOnly 项。故所有节点的画布展示一致(都源自拓扑),编辑只在入口节点生效。
#### 实现里程碑(待按上述设计重建)
- [x] 数据面Ring State节点表/转发链/leader/轮次延迟/负载/待办任务)+ 日志(追加式 + 水位)

View File

@ -27,6 +27,10 @@
<option value="https">https</option>
</select>
</label>
<label class="adv-check">
<input v-model="localOnlyField" type="checkbox" /> 仅本机转发
<span class="hint">(勾选=直接在本机拉起不进集群不勾选=127.0.0.1 替换为本机实际地址交给集群)</span>
</label>
</div>
<div class="adv">
@ -106,6 +110,7 @@ const nameField = field('name')
const ipField = field('ip')
const portField = field('port')
const protocolField = field('protocol')
const localOnlyField = field('localOnly')
const useEncryptionField = field('useEncryption')
const useCompressionField = field('useCompression')
const bandwidthLimitField = field('bandwidthLimit')

View File

@ -13,6 +13,8 @@ export interface Local {
ip: string;
port: number;
protocol: string; // tcp | udp | http | https
// localOnly: forward stays on this node (loopback kept), no cluster.
localOnly?: boolean;
// M1 advanced transport knobs (optional; empty/zero = frpc defaults)
useEncryption?: boolean;