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

@ -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())
}
}