mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-10-02 15:14:01 +00:00
承接用户提问「设计上停用不是本来就会跨节点传输吗」——核实结论:结构上确实 如此(TopoEntry.Link 是完整 store.Link,整个 State 随 token 每轮广播),但 实际路径断了。断点正是「撤销会删掉 topology 条目」:条目是 flag 的载体, 删了就无处传播,于是停用只能靠一次性 revoke 任务投递给 owner,**owner 当时 不在线就收不到**(实测 .60 记 disabled=1 / .106 记 0,就是这么来的)。 ## 改为标记而非移除 撤销不再 RemoveTopology,而是 UpdateTopologyDisabled(true),条目保留、 Link.Disabled=true、Active=false。Active 正是为此存在:OfflineReassign() 只处理 Active 条目,所以停用的转发在 owner 掉线时不会被重新排队。 - 新增 UpdateTopologyDisabled / TopologyDisabled(照 UpdateTopologyGroup 的桥) - 新增 store.ReconcileLinkDisabled 作接收端:adoption 时把环上的 flag 落进 本地 store;本节点没有该转发时补一条 disabled 占位行(否则日后在本节点被 claim 会复活),enable 则不建行 - SetTopologySync 由单向(store→环)扩为双向:群组仍上行,disabled 下行 - AddTopology 的 Active 跟随 Link.Disabled(原本硬编码 true,认领一个停用 转发就会复活它) - 审计日志细分 forward.stop / forward.start,与 forward.remove 区分 ## 语义变更带出的两个新问题(都已修) 1. **「启动」这条路断了**。条目保留 ⇒ SubmitTask 被去重挡下,而认领路径的 duplicate-claim 防御又会丢弃「已有 owner」的任务 ⇒ 重启任务发不出去,owner 永远收不到,转发**能停不能起**。 修:新增 Task.Restart 这一独立任务类型 + SubmitRestart + Handler.RestartFn, 显式绕过 duplicate-claim 防御并原地复活(不重复建条目、不重跑 claim 簿记)。 SubmitTask 的守卫同时从 HasTask 收窄为新的 HasActiveTask(跳过 disabled 条目 与撤销任务);saveCanvas 的判断相应改用 HasActiveTask,避免每次保存都对 已标记的转发重复发撤销。 2. 原本两处 RemoveTopologyEntry 调用(ClaimFn/RevokeFn 的 disabled 分支)在 新语义下会把本该保留的条目删掉,改为 UpdateTopologyDisabled。 ## 测试(每个都做了「回退修复行→必须变红→还原变绿」双向验证) - TestStoppedTopologyEntrySurvivesAdoption —— 离线成员也能学到停用, 一次性 revoke 任务永远做不到这一点 - TestStoppedForwardNotRequeuedOnNodeDeparture / TestAddTopologyRespectsDisabledFlag —— 标记而非删除为何安全 - TestSubmitTaskNotBlockedByStoppedEntry / TestSubmitTaskStillDedupesActiveForward - TestRestartTaskBypassesDuplicateClaimGuard / TestRestartFlagSurvivesTokenSerialization - TestStopThenStartPublishesRestartTask(HTTP 端到端,断言**任务真的发出**) - TestReconcileLinkDisabled*(store 侧三条) ★ 两次踩到**假绿**:第一版只断言 store 层(newTestHandler 的 Ring 为 nil, 坏掉的路根本没执行);第二版在 re-enable **之后**才调 SubmitTask,此时新旧 谓词结果相同,测不出差异。都是靠「回退修复行看是否变红」抓出来的 —— 这个 双向验证已经是本项目的固定动作。 go build / go vet / go test ./... 全绿,gofmt 干净。
481 lines
14 KiB
Go
481 lines
14 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
|
|
"webui4frpc/internal/cluster"
|
|
"webui4frpc/internal/process"
|
|
"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()
|
|
|
|
// Merge ring topology entries not in the local store so every node's
|
|
// canvas shows the FULL cluster picture. This is a read-time merge —
|
|
// nothing is written back to SQLite (avoids the per-forward stop/start
|
|
// issue that broke the old topology-derived-canvas model).
|
|
if h.Ring != nil {
|
|
snap := h.Ring.Snapshot()
|
|
localNames := make(map[string]bool, len(locals))
|
|
for _, l := range locals {
|
|
localNames[l.Name] = true
|
|
}
|
|
remoteNames := make(map[string]bool, len(remotes))
|
|
for _, r := range remotes {
|
|
remoteNames[r.Name] = true
|
|
}
|
|
type linkKey struct {
|
|
local, remote string
|
|
port int
|
|
}
|
|
linkSeen := make(map[linkKey]bool, len(links))
|
|
for _, l := range links {
|
|
linkSeen[linkKey{l.Local, l.Remote, l.RemotePort}] = true
|
|
}
|
|
for _, t := range snap.Topology {
|
|
if !localNames[t.Local.Name] {
|
|
locals = append(locals, t.Local)
|
|
localNames[t.Local.Name] = true
|
|
}
|
|
if !remoteNames[t.Remote.Name] {
|
|
remotes = append(remotes, t.Remote)
|
|
remoteNames[t.Remote.Name] = true
|
|
}
|
|
k := linkKey{t.Link.Local, t.Link.Remote, t.Link.RemotePort}
|
|
if !linkSeen[k] {
|
|
links = append(links, t.Link)
|
|
linkSeen[k] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
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:
|
|
// Route is registered at read so viewer GETs work; PUT needs write.
|
|
if !hasLevel(r, "write") {
|
|
forbidden(w)
|
|
return
|
|
}
|
|
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
|
|
}
|
|
if !h.applyCanvas(w, r, &canvas) {
|
|
return
|
|
}
|
|
h.handleCanvasGet(w, r)
|
|
}
|
|
|
|
// applyCanvas performs the full-replace semantics shared by PUT /canvas and
|
|
// POST /canvas/import: loopback rewrite, upsert locals + delete-missing (with
|
|
// localOnly stop / cluster revoke), upsert remotes + delete-missing, wholesale
|
|
// link replace, cluster task submit for non-localOnly forwards, and a
|
|
// SyncWorkers pass. It writes errors to w and returns false on failure so the
|
|
// caller knows not to write a success response.
|
|
func (h *Handler) applyCanvas(w http.ResponseWriter, r *http.Request, canvas *canvasData) bool {
|
|
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 false
|
|
}
|
|
}
|
|
// 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 {
|
|
// Per-forward model: stop every localOnly worker of this local.
|
|
if fwd, _ := s.LinksForLocal(old.Name); len(fwd) > 0 {
|
|
for _, f := range fwd {
|
|
_ = h.Process.Stop(process.WorkerKey(old.Name, f.Remote, f.RemotePort))
|
|
}
|
|
}
|
|
}
|
|
} 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 false
|
|
}
|
|
}
|
|
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 false
|
|
}
|
|
|
|
// Cluster distribution: reconcile non-localOnly forwards against the ring
|
|
// topology, respecting each link's Disabled flag. A non-disabled forward
|
|
// not yet in topology is submitted (lowest-load member claims it); a
|
|
// disabled forward present in topology is revoked. plan §画布差异判断,
|
|
// extended so a per-forward stop made on the forwards page (disabled=true)
|
|
// is not re-activated by a later canvas save. Local-only forwards are NOT
|
|
// submitted — they stay on this node.
|
|
localByName := make(map[string]store.Local, len(canvas.Locals))
|
|
for _, l := range canvas.Locals {
|
|
localByName[l.Name] = l
|
|
}
|
|
remoteByName := make(map[string]store.Remote, len(canvas.Remotes))
|
|
for _, r := range canvas.Remotes {
|
|
remoteByName[r.Name] = r
|
|
}
|
|
|
|
// Build a set of (local, remote, remotePort) triples from the incoming
|
|
// canvas links so we can revoke any stale topology entries no longer
|
|
// present in the canvas (e.g. links that were deleted from the UI).
|
|
type triple struct {
|
|
local, remote string
|
|
port int
|
|
}
|
|
canvasTriples := make(map[triple]bool, len(canvas.Links))
|
|
for _, ln := range canvas.Links {
|
|
canvasTriples[triple{ln.Local, ln.Remote, ln.RemotePort}] = true
|
|
}
|
|
|
|
if h.Ring != nil {
|
|
// Revoke topology entries that the canvas no longer references.
|
|
// Only revoke entries whose local name is known to this node (we only
|
|
// own tasks for locals we created). The topology is a ring-wide view
|
|
// and includes forwards owned by other nodes.
|
|
snap := h.Ring.Snapshot()
|
|
for _, t := range snap.Topology {
|
|
if _, ok := localByName[t.Local.Name]; !ok {
|
|
continue // local not in this node's canvas — skip
|
|
}
|
|
if t.Local.LocalOnly {
|
|
continue
|
|
}
|
|
if canvasTriples[triple{t.Local.Name, t.Remote.Name, t.Link.RemotePort}] {
|
|
continue // still in canvas — keep
|
|
}
|
|
// This forward is in the ring topology but NOT in the new canvas.
|
|
// Submit a REVOKE so the owning node cleans it up.
|
|
h.Ring.RevokeTask(t.Local, t.Remote, t.Link)
|
|
}
|
|
|
|
for _, ln := range canvas.Links {
|
|
loc, ok := localByName[ln.Local]
|
|
if !ok || loc.LocalOnly {
|
|
continue
|
|
}
|
|
rem, ok := remoteByName[ln.Remote]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if ln.Disabled {
|
|
// Stopped on the forwards page: make sure it leaves the topology.
|
|
// Guard on an ACTIVE task — an entry that is already marked
|
|
// disabled has nothing left to revoke, and re-issuing a revocation
|
|
// for it on every canvas save would be pure noise. (HasTask, which
|
|
// also matches disabled entries, is the right predicate for the
|
|
// "already handled" question this branch is not asking.)
|
|
if h.Ring.HasActiveTask(ln.Local, ln.Remote, ln.RemotePort) {
|
|
h.Ring.RevokeTask(loc, rem, ln)
|
|
}
|
|
continue
|
|
}
|
|
// SubmitTask is idempotent (HasTask guard), so re-saving an active
|
|
// canvas is a no-op for forwards already in the topology.
|
|
h.Ring.SubmitTask(loc, 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()
|
|
}
|
|
return true
|
|
}
|
|
|
|
// ---- 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 per-forward workers for this remote if enabled.
|
|
if rem.Enabled {
|
|
// In the per-forward model, merely upserting a remote does not start
|
|
// any workers — the actual links control which forwards run. The
|
|
// caller is expected to save the canvas (PUT /canvas) or start
|
|
// individual forwards via the forwards page.
|
|
log.Printf("remote %s upserted (enabled=%v); use status page forwards to start", rem.Name, rem.Enabled)
|
|
}
|
|
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.StopRemote(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 — aggregate per-forward workers into a single
|
|
// remote-centric view. "hasProcess" is true when ANY forward worker
|
|
// for this remote is running.
|
|
st, has := h.Process.RemoteStatus(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:
|
|
// Route registered at read so status/config/logs GETs work; worker
|
|
// lifecycle mutations need write.
|
|
if !hasLevel(r, "write") {
|
|
forbidden(w)
|
|
return
|
|
}
|
|
var err error
|
|
if action == "start" {
|
|
err = h.Process.StartRemote(name)
|
|
} else if action == "stop" {
|
|
err = h.Process.StopRemote(name)
|
|
} else {
|
|
err = h.Process.RestartRemote(name)
|
|
}
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
default:
|
|
methodNotAllowed(w)
|
|
}
|
|
case "config":
|
|
// Per-forward model: no single config file for a remote. Return the
|
|
// first forward's config as a hint; the status page already shows
|
|
// per-forward configs.
|
|
workers := h.Process.RemoteWorkers(name)
|
|
if len(workers) == 0 {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
data, err := os.ReadFile(h.Process.ConfigPath(workers[0]))
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write(data)
|
|
case "logs":
|
|
// Per-forward model: return the first per-forward log for this remote.
|
|
workers := h.Process.RemoteWorkers(name)
|
|
if len(workers) == 0 {
|
|
http.Error(w, "no workers for this remote", http.StatusNotFound)
|
|
return
|
|
}
|
|
path := h.Process.LogPath(workers[0])
|
|
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
|
|
}
|