mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-20 17:07:57 +00:00
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:
257
internal/httpapi/handlers_canvas.go
Normal file
257
internal/httpapi/handlers_canvas.go
Normal file
@ -0,0 +1,257 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"webui4frpc/internal/cluster"
|
||||
"webui4frpc/internal/store"
|
||||
)
|
||||
|
||||
// ---- Locals: single-resource CRUD ----
|
||||
|
||||
// handleLocals operates on /api/manager/locals: PUT upserts a single local.
|
||||
// Mirrors applyCanvas's per-local logic (loopback rewrite + ring task submit
|
||||
// for the local's links when it is a cluster forward, or a SyncWorkers pass
|
||||
// for a local-only forward).
|
||||
func (h *Handler) handleLocals(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
var l store.Local
|
||||
if err := json.NewDecoder(r.Body).Decode(&l); err != nil {
|
||||
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if l.Name == "" {
|
||||
http.Error(w, "local name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Loopback rewrite, same as applyCanvas: a non-localOnly forward must be
|
||||
// reachable from whichever cluster node claims it.
|
||||
if !l.LocalOnly && cluster.IsLoopbackIP(l.IP) {
|
||||
l.IP = cluster.RewriteForCluster(l.IP)
|
||||
}
|
||||
if err := h.Store.UpsertLocal(l); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Submit ring tasks for this local's existing links when it is a cluster
|
||||
// forward; local-only forwards just refresh their local worker.
|
||||
if !l.LocalOnly && h.Ring != nil {
|
||||
fwd, _ := h.Store.LinksForLocal(l.Name)
|
||||
for _, ln := range fwd {
|
||||
if rem, ok := h.Store.GetRemote(ln.Remote); ok {
|
||||
h.Ring.SubmitTask(l, rem, store.Link{
|
||||
Local: l.Name, Remote: rem.Name, RemotePort: ln.RemotePort,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if h.SyncWorkers != nil {
|
||||
h.SyncWorkers()
|
||||
}
|
||||
writeJSON(w, http.StatusOK, l)
|
||||
}
|
||||
|
||||
// handleLocalByName operates on /api/manager/locals/{name}: DELETE removes a
|
||||
// single local, mirroring applyCanvas's removed-local branch.
|
||||
func (h *Handler) handleLocalByName(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimPrefix(r.URL.Path, apiPrefix+"/locals/")
|
||||
if name == "" {
|
||||
http.Error(w, "local name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
l, ok := h.Store.GetLocal(name)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodDelete:
|
||||
if l.LocalOnly {
|
||||
if h.Process != nil {
|
||||
if fwd, _ := h.Store.LinksForLocal(name); len(fwd) > 0 {
|
||||
_ = h.Process.Stop(fwd[0].Remote)
|
||||
}
|
||||
}
|
||||
} else if h.Ring != nil {
|
||||
fwd, _ := h.Store.LinksForLocal(name)
|
||||
for _, ln := range fwd {
|
||||
if rem, ok := h.Store.GetRemote(ln.Remote); ok {
|
||||
h.Ring.RevokeTask(l, rem, store.Link{
|
||||
Local: name, Remote: rem.Name, RemotePort: ln.RemotePort,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = h.Store.DeleteLocal(name)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
methodNotAllowed(w)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Links: single-resource CRUD ----
|
||||
|
||||
// handleLinks is the collection endpoint: POST adds a single link.
|
||||
// For a cluster (non-localOnly) forward it submits a ring task so the owning
|
||||
// node spawns the worker; for a local-only forward it just SyncWorkers so the
|
||||
// local frpc picks up the new proxy.
|
||||
func (h *Handler) handleLinks(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
var ln store.Link
|
||||
if err := json.NewDecoder(r.Body).Decode(&ln); err != nil {
|
||||
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if ln.Local == "" || ln.Remote == "" || ln.RemotePort <= 0 {
|
||||
http.Error(w, "local/remote/remotePort required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, ok := h.Store.GetLocal(ln.Local); !ok {
|
||||
http.Error(w, "local not found", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, ok := h.Store.GetRemote(ln.Remote); !ok {
|
||||
http.Error(w, "remote not found", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Idempotent: an identical link already present is returned as-is.
|
||||
for _, existing := range mustListLinks(h.Store) {
|
||||
if existing.Local == ln.Local && existing.Remote == ln.Remote && existing.RemotePort == ln.RemotePort {
|
||||
writeJSON(w, http.StatusOK, existing)
|
||||
return
|
||||
}
|
||||
}
|
||||
created, err := h.Store.AddLink(ln)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if loc, ok := h.Store.GetLocal(ln.Local); ok {
|
||||
if !loc.LocalOnly && h.Ring != nil {
|
||||
if rem, ok := h.Store.GetRemote(ln.Remote); ok {
|
||||
h.Ring.SubmitTask(loc, rem, created)
|
||||
}
|
||||
}
|
||||
}
|
||||
if h.SyncWorkers != nil {
|
||||
h.SyncWorkers()
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, created)
|
||||
}
|
||||
|
||||
// handleLinkByID operates on /api/manager/links/{id}: DELETE removes a single
|
||||
// link, revoking the cluster forward on its owning node when applicable.
|
||||
func (h *Handler) handleLinkByID(w http.ResponseWriter, r *http.Request) {
|
||||
raw := strings.TrimPrefix(r.URL.Path, apiPrefix+"/links/")
|
||||
id, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
http.Error(w, "invalid link id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ln, ok := h.Store.GetLink(id)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodDelete:
|
||||
if loc, ok := h.Store.GetLocal(ln.Local); ok {
|
||||
if !loc.LocalOnly && h.Ring != nil {
|
||||
if rem, ok := h.Store.GetRemote(ln.Remote); ok {
|
||||
h.Ring.RevokeTask(loc, rem, ln)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = h.Store.DeleteLink(id)
|
||||
if h.SyncWorkers != nil {
|
||||
h.SyncWorkers()
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
methodNotAllowed(w)
|
||||
}
|
||||
}
|
||||
|
||||
func mustListLinks(s *store.Store) []store.Link {
|
||||
links, _ := s.ListLinks()
|
||||
return links
|
||||
}
|
||||
|
||||
// ---- Canvas export / import ----
|
||||
|
||||
// canvasExportEnvelope wraps a canvas with provenance metadata for backup files.
|
||||
type canvasExportEnvelope struct {
|
||||
Type string `json:"_type"`
|
||||
Version int `json:"version"`
|
||||
ExportedAt int64 `json:"exportedAt"`
|
||||
Exporter string `json:"exporter"`
|
||||
Canvas canvasData `json:"canvas"`
|
||||
}
|
||||
|
||||
// handleCanvasExport returns the full canvas wrapped in a metadata envelope and
|
||||
// as a downloadable attachment. Read-level (auditors can export).
|
||||
func (h *Handler) handleCanvasExport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
locals, _ := h.Store.ListLocals()
|
||||
remotes, _ := h.Store.ListRemotes()
|
||||
links, _ := h.Store.ListLinks()
|
||||
env := canvasExportEnvelope{
|
||||
Type: "webui4frpc-canvas",
|
||||
Version: 1,
|
||||
ExportedAt: time.Now().Unix(),
|
||||
Exporter: h.SelfAddr,
|
||||
Canvas: canvasData{Locals: locals, Remotes: remotes, Links: links},
|
||||
}
|
||||
body, err := json.MarshalIndent(env, "", " ")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="webui4frpc-canvas.json"`)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
// handleCanvasImport restores a previously exported canvas. Accepts either the
|
||||
// full envelope ({"_type":"webui4frpc-canvas","canvas":{...}}) or a bare canvas
|
||||
// ({locals,remotes,links}); both are applied via applyCanvas (full replace).
|
||||
func (h *Handler) handleCanvasImport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
raw, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "read body: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Try the envelope first; fall back to a bare canvas ({locals,remotes,
|
||||
// links}). Probing for the envelope keeps both shapes working so a caller
|
||||
// can replay either a /canvas/export bundle or a raw PUT /canvas body.
|
||||
var env canvasExportEnvelope
|
||||
var canvas canvasData
|
||||
if jerr := json.Unmarshal(raw, &env); jerr == nil && env.Type == "webui4frpc-canvas" {
|
||||
canvas = env.Canvas
|
||||
} else if jerr := json.Unmarshal(raw, &canvas); jerr != nil {
|
||||
http.Error(w, "parse json: "+jerr.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !h.applyCanvas(w, r, &canvas) {
|
||||
return
|
||||
}
|
||||
h.handleCanvasGet(w, r)
|
||||
}
|
||||
Reference in New Issue
Block a user