mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-20 00:47:57 +00:00
核心改动(ring 协议不变,Task 本来就是 {Local,Remote,Link} 三元组):
- process.WorkerKey: worker 键改为 'local~remote~port' 三元组
- renderForward: 每条 forward 渲染只含 1 个 proxy 的独立 frpc 配置
(替代 renderRemote 把该 remote 全部 forwards 塞进一个进程)
- ClaimFn/RevokeFn: 认领/撤销只操作这一条 forward 自己的进程
- SyncWorkers/auto-start: 只拉起 localOnly 转发的独立进程,
集群转发由 ring 认领节点拉起 (消除三台争抢 proxy already exists)
- RemoteStatus/StartRemote/StopRemote/RestartRemote: remote 级聚合辅助,
保持 /profiles API 形状不变, 前端零改动
- handleStatus/logs: 按 worker key 解析真实 remote 名
效果: 三台节点的 frpc 各自只注册自己拥有的 proxy, 单条转发故障不再波及兄弟
262 lines
7.8 KiB
Go
262 lines
7.8 KiB
Go
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)
|
|
}
|