Files
webui4frpc/internal/httpapi/handlers.go
jianf c1936887a2 webui4frpc: 独立可用的可视化 frpc 控制器 (M0)
- 零 frp 源码依赖,单二进制 (Go + Vue3 + VueFlow + Element Plus)
- 画布多对多连线,渲染 tcp/udp/http/https frpc 配置
- worker 进程管理:自愈、日志轮转、崩溃退避重启
- frpc 一键安装 (GitHub Releases) + 手动指定路径
- 三页 UI:状态(默认)/连接配置/设置
- 状态页实时节点/转发状态,节点可增删改启停
- 画布冲突检查:端口/域名冲突标红 + 弹窗拦截保存
- backend 单测覆盖 store/render/process/httpapi/install
- plan.md + FRPC_FEATURES_AUDIT.md 文档
2026-08-17 11:00:26 +08:00

284 lines
6.8 KiB
Go

package httpapi
import (
"encoding/json"
"net/http"
"os"
"strings"
"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
// 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.
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] {
_ = 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
}
// Restart affected running workers so changes take effect immediately.
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
}