Files
webui4frpc/internal/httpapi/handlers_canvas.go
jianf eda9bb9597 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)
2026-08-19 21:09:24 +08:00

258 lines
7.6 KiB
Go

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