mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-20 00:47:57 +00:00
- 主题: sakura×frost 玻璃拟态 (theme.css) + SCSS 变量重映射 - 侧栏: 玻璃侧栏 246px + 渐变品牌区 + 面包屑导航 - 状态页: 玻璃 KPI 卡 + 远程节点/本地服务卡片网格 - 集群页: 英雄玻璃卡 + 横向环拓扑链 + 待办命令/活跃拓扑/日志区 - 令牌环: 新增 lastSync 上次同步时间替代周期计数 - 模拟 frps: frps2/frps3 容器 + test-forward.sh 全链路验证脚本 - 修复: BinaryPath 空导致 worker 不启动, 撤销仅撤第一个 link, 任务复活风暴 (published 追踪)
343 lines
8.9 KiB
Go
343 lines
8.9 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
|
|
"webui4frpc/internal/cluster"
|
|
"webui4frpc/internal/store"
|
|
)
|
|
|
|
// canvasData is the full drawing canvas exchanged with the frontend.
|
|
type canvasData struct {
|
|
Locals []store.Local `json:"locals"`
|
|
Remotes []store.Remote `json:"remotes"`
|
|
Links []store.Link `json:"links"`
|
|
}
|
|
|
|
func (h *Handler) handleCanvasGet(w http.ResponseWriter, _ *http.Request) {
|
|
locals, _ := h.Store.ListLocals()
|
|
remotes, _ := h.Store.ListRemotes()
|
|
links, _ := h.Store.ListLinks()
|
|
writeJSON(w, http.StatusOK, canvasData{Locals: locals, Remotes: remotes, Links: links})
|
|
}
|
|
|
|
func (h *Handler) handleCanvasSave(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
h.handleCanvasGet(w, r)
|
|
case http.MethodPut:
|
|
h.saveCanvas(w, r)
|
|
default:
|
|
methodNotAllowed(w)
|
|
}
|
|
}
|
|
|
|
func (h *Handler) saveCanvas(w http.ResponseWriter, r *http.Request) {
|
|
var canvas canvasData
|
|
if err := json.NewDecoder(r.Body).Decode(&canvas); err != nil {
|
|
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
s := h.Store
|
|
|
|
// Rewrite loopback backend addresses for cluster-distributed forwards.
|
|
// Non-localOnly locals targeting 127.0.0.1/0.0.0.0/localhost must be
|
|
// reachable from whichever node claims them, so swap in this node LAN addr.
|
|
// Local-only forwards keep the loopback as-is (they never leave this node).
|
|
for i := range canvas.Locals {
|
|
if !canvas.Locals[i].LocalOnly && cluster.IsLoopbackIP(canvas.Locals[i].IP) {
|
|
canvas.Locals[i].IP = cluster.RewriteForCluster(canvas.Locals[i].IP)
|
|
}
|
|
}
|
|
|
|
// Upsert locals.
|
|
for _, l := range canvas.Locals {
|
|
if err := s.UpsertLocal(l); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
// Delete locals not present. A removed localOnly forward is cancelled
|
|
// locally (stop worker). A removed cluster forward publishes a REVOKE
|
|
// task through the token so its owning node cancels it everywhere.
|
|
if existing, err := s.ListLocals(); err == nil {
|
|
keep := map[string]bool{}
|
|
for _, l := range canvas.Locals {
|
|
keep[l.Name] = true
|
|
}
|
|
for _, old := range existing {
|
|
if !keep[old.Name] {
|
|
if old.LocalOnly {
|
|
if h.Process != nil {
|
|
if fwd, _ := s.LinksForLocal(old.Name); len(fwd) > 0 {
|
|
_ = h.Process.Stop(fwd[0].Remote)
|
|
}
|
|
}
|
|
} else if h.Ring != nil {
|
|
// Publish a REVOKE task for EVERY link of the removed local —
|
|
// a local may fan out to several remotes, and revoking only the
|
|
// first (fwd[0]) left the rest as orphan workers running on
|
|
// their owning cluster nodes.
|
|
fwd, _ := s.LinksForLocal(old.Name)
|
|
for _, ln := range fwd {
|
|
rem, ok := s.GetRemote(ln.Remote)
|
|
if !ok {
|
|
continue
|
|
}
|
|
h.Ring.RevokeTask(old, rem, store.Link{
|
|
Local: old.Name, Remote: rem.Name, RemotePort: ln.RemotePort,
|
|
})
|
|
}
|
|
}
|
|
_ = s.DeleteLocal(old.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Upsert remotes.
|
|
for _, rem := range canvas.Remotes {
|
|
if err := s.UpsertRemote(rem); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
if existing, err := s.ListRemotes(); err == nil {
|
|
keep := map[string]bool{}
|
|
for _, rem := range canvas.Remotes {
|
|
keep[rem.Name] = true
|
|
}
|
|
for _, old := range existing {
|
|
if !keep[old.Name] {
|
|
_ = s.DeleteRemote(old.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Replace links wholesale.
|
|
if err := s.ReplaceLinks(canvas.Links); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// 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.
|
|
if h.Ring != nil {
|
|
for _, l := range canvas.Locals {
|
|
if l.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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Restart affected running workers so changes take effect immediately
|
|
// (local-only forwards get their worker started right here).
|
|
if h.SyncWorkers != nil {
|
|
h.SyncWorkers()
|
|
}
|
|
|
|
h.handleCanvasGet(w, r)
|
|
}
|
|
|
|
// ---- Settings ----
|
|
|
|
func (h *Handler) handleSettingsGet(w http.ResponseWriter, r *http.Request) {
|
|
settings, _ := h.Store.Settings()
|
|
writeJSON(w, http.StatusOK, settings)
|
|
}
|
|
|
|
func (h *Handler) handleSettingsPut(w http.ResponseWriter, r *http.Request) {
|
|
var st store.Settings
|
|
if err := json.NewDecoder(r.Body).Decode(&st); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := h.Store.UpdateSettings(st); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
h.handleSettingsGet(w, r)
|
|
}
|
|
|
|
// ---- Binary ----
|
|
|
|
func (h *Handler) handleBinaryStatus(w http.ResponseWriter, _ *http.Request) {
|
|
resp := map[string]any{"binaryPath": ""}
|
|
if h.BinaryPath != nil {
|
|
resp["binaryPath"] = h.BinaryPath()
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *Handler) handleBinaryInstall(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
var req struct {
|
|
Version string `json:"version"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
if h.InstallBinary == nil {
|
|
http.Error(w, "install not configured", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
path, version, err := h.InstallBinary(req.Version)
|
|
if err != nil {
|
|
http.Error(w, "install failed: "+err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"path": path, "version": version})
|
|
}
|
|
|
|
// ---- Single remote upsert/delete (used by the status page) ----
|
|
|
|
func (h *Handler) handleRemoteUpsert(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPut {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
var rem store.Remote
|
|
if err := json.NewDecoder(r.Body).Decode(&rem); err != nil {
|
|
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if rem.Name == "" || rem.IP == "" || rem.Port <= 0 || rem.Port > 65535 {
|
|
http.Error(w, "name/ip/port required, port in [1,65535]", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := h.Store.UpsertRemote(rem); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
// Start the worker if enabled.
|
|
if rem.Enabled {
|
|
_ = h.Process.Start(rem.Name)
|
|
}
|
|
writeJSON(w, http.StatusOK, rem)
|
|
}
|
|
|
|
func (h *Handler) handleRemoteDelete(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodDelete {
|
|
methodNotAllowed(w)
|
|
return
|
|
}
|
|
name := strings.TrimPrefix(r.URL.Path, apiPrefix+"/remotes/")
|
|
if name == "" {
|
|
http.Error(w, "remote name required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
_ = h.Process.Stop(name)
|
|
if err := h.Store.DeleteRemote(name); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
// ---- Profile lifecycle ----
|
|
|
|
func (h *Handler) handleProfile(w http.ResponseWriter, r *http.Request) {
|
|
// Path: /api/manager/profiles/{name}/{action?}
|
|
rel := strings.TrimPrefix(r.URL.Path, apiPrefix+"/profiles/")
|
|
parts := strings.Split(rel, "/")
|
|
if len(parts) == 0 || parts[0] == "" {
|
|
http.Error(w, "profile name required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
name := parts[0]
|
|
action := ""
|
|
if len(parts) > 1 {
|
|
action = parts[1]
|
|
}
|
|
|
|
switch action {
|
|
case "":
|
|
// GET profile status
|
|
st, has := h.Process.Status(name)
|
|
forwards, _ := h.Store.LinksForRemote(name)
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"name": name, "process": st, "hasProcess": has, "forwards": forwards,
|
|
})
|
|
case "start", "stop", "restart":
|
|
switch r.Method {
|
|
case http.MethodPost:
|
|
var err error
|
|
if action == "start" {
|
|
err = h.Process.Start(name)
|
|
} else if action == "stop" {
|
|
err = h.Process.Stop(name)
|
|
} else {
|
|
err = h.Process.Restart(name)
|
|
}
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
default:
|
|
methodNotAllowed(w)
|
|
}
|
|
case "config":
|
|
data, err := os.ReadFile(h.Process.ConfigPath(name))
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write(data)
|
|
case "logs":
|
|
path := h.Process.LogPath(name)
|
|
data, err := tailFile(path, 64*1024)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(data))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}
|
|
|
|
// ---- helpers ----
|
|
|
|
// tailFile returns the last maxBytes bytes of a file.
|
|
func tailFile(path string, maxBytes int64) (string, error) {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return "", nil
|
|
}
|
|
return "", err
|
|
}
|
|
size := min(info.Size(), maxBytes)
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer f.Close()
|
|
buf := make([]byte, size)
|
|
if _, err := f.ReadAt(buf, info.Size()-size); err != nil {
|
|
return "", err
|
|
}
|
|
return string(buf), nil
|
|
}
|