feat: cluster reliability (leader failover, crash rejoin, key exchange) + auth/users + canvas/forwards enhancements + comprehensive README + API docs

- Cluster: forwardToNext offline detection (leader+non-leader), WatchLeader 1s heartbeat fallback, 409 for standalone nodes, Node.NodeKey key exchange via token ring, ClusterPeers persistence + auto-rejoin, Forward delegates to forwardToNext (bugfix)
- Auth: Basic Auth (flag-creds fast path) + bcrypt users (admin/viewer) + Bearer API keys (read/write/admin scope)
- Frontend: UsersView (accounts+API keys), ClusterView (ring/nodeKey/tasks/topology/log), StatusView (group management, per-proxy status), CanvasView (edge toggle/group), PortEdge (disabled/group labels)
- API: handlers split (canvas/forwards/users/logs), canvas export/import, forwards group start/stop/assign/delete, cluster endpoints
- Docs: comprehensive README rewrite (all flags/APIs/auth/cluster), docs/cluster-api.md (cluster management API reference)
- Deploy: run-cluster.sh now 4-node ring + 1 isolated standalone, test-forward.sh updated for 4 nodes
- Removed plan.md (design notes consolidated into README + API docs)
This commit is contained in:
2026-08-19 21:09:24 +08:00
parent b518a13446
commit eda9bb9597
55 changed files with 5462 additions and 793 deletions

View File

@ -29,6 +29,11 @@ func (h *Handler) handleCanvasSave(w http.ResponseWriter, r *http.Request) {
case http.MethodGet:
h.handleCanvasGet(w, r)
case http.MethodPut:
// Route is registered at read so viewer GETs work; PUT needs write.
if !hasLevel(r, "write") {
forbidden(w)
return
}
h.saveCanvas(w, r)
default:
methodNotAllowed(w)
@ -41,6 +46,19 @@ func (h *Handler) saveCanvas(w http.ResponseWriter, r *http.Request) {
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
return
}
if !h.applyCanvas(w, r, &canvas) {
return
}
h.handleCanvasGet(w, r)
}
// applyCanvas performs the full-replace semantics shared by PUT /canvas and
// POST /canvas/import: loopback rewrite, upsert locals + delete-missing (with
// localOnly stop / cluster revoke), upsert remotes + delete-missing, wholesale
// link replace, cluster task submit for non-localOnly forwards, and a
// SyncWorkers pass. It writes errors to w and returns false on failure so the
// caller knows not to write a success response.
func (h *Handler) applyCanvas(w http.ResponseWriter, r *http.Request, canvas *canvasData) bool {
s := h.Store
// Rewrite loopback backend addresses for cluster-distributed forwards.
@ -57,7 +75,7 @@ func (h *Handler) saveCanvas(w http.ResponseWriter, r *http.Request) {
for _, l := range canvas.Locals {
if err := s.UpsertLocal(l); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
return false
}
}
// Delete locals not present. A removed localOnly forward is cancelled
@ -101,7 +119,7 @@ func (h *Handler) saveCanvas(w http.ResponseWriter, r *http.Request) {
for _, rem := range canvas.Remotes {
if err := s.UpsertRemote(rem); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
return false
}
}
if existing, err := s.ListRemotes(); err == nil {
@ -119,32 +137,44 @@ func (h *Handler) saveCanvas(w http.ResponseWriter, r *http.Request) {
// Replace links wholesale.
if err := s.ReplaceLinks(canvas.Links); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
return false
}
// 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.
// Cluster distribution: reconcile non-localOnly forwards against the ring
// topology, respecting each link's Disabled flag. A non-disabled forward
// not yet in topology is submitted (lowest-load member claims it); a
// disabled forward present in topology is revoked. plan §画布差异判断,
// extended so a per-forward stop made on the forwards page (disabled=true)
// is not re-activated by a later canvas save. Local-only forwards are NOT
// submitted — they stay on this node.
localByName := map[string]store.Local{}
for _, l := range canvas.Locals {
localByName[l.Name] = l
}
remoteByName := map[string]store.Remote{}
for _, r := range canvas.Remotes {
remoteByName[r.Name] = r
}
if h.Ring != nil {
for _, l := range canvas.Locals {
if l.LocalOnly {
for _, ln := range canvas.Links {
loc, ok := localByName[ln.Local]
if !ok || loc.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)
}
rem, ok := remoteByName[ln.Remote]
if !ok {
continue
}
if ln.Disabled {
// Stopped on the forwards page: make sure it leaves the topology.
if h.Ring.HasTask(ln.Local, ln.Remote, ln.RemotePort) {
h.Ring.RevokeTask(loc, rem, ln)
}
continue
}
// SubmitTask is idempotent (HasTask guard), so re-saving an active
// canvas is a no-op for forwards already in the topology.
h.Ring.SubmitTask(loc, rem, ln)
}
}
@ -153,8 +183,7 @@ func (h *Handler) saveCanvas(w http.ResponseWriter, r *http.Request) {
if h.SyncWorkers != nil {
h.SyncWorkers()
}
h.handleCanvasGet(w, r)
return true
}
// ---- Settings ----
@ -280,6 +309,12 @@ func (h *Handler) handleProfile(w http.ResponseWriter, r *http.Request) {
case "start", "stop", "restart":
switch r.Method {
case http.MethodPost:
// Route registered at read so status/config/logs GETs work; worker
// lifecycle mutations need write.
if !hasLevel(r, "write") {
forbidden(w)
return
}
var err error
if action == "start" {
err = h.Process.Start(name)