package httpapi import ( "encoding/json" "io" "net/http" "strconv" "strings" "time" "webui4frpc/internal/cluster" "webui4frpc/internal/process" "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 { // Per-forward model: stop every localOnly worker of this local. if fwd, _ := h.Store.LinksForLocal(name); len(fwd) > 0 { for _, f := range fwd { _ = h.Process.Stop(process.WorkerKey(name, f.Remote, f.RemotePort)) } } } } 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) }