mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-20 00:47:57 +00:00
feat: cluster reliability (leader failover, crash rejoin, key exchange) + auth/users + canvas/forwards enhancements + comprehensive README + API docs
- Cluster: forwardToNext offline detection (leader+non-leader), WatchLeader 1s heartbeat fallback, 409 for standalone nodes, Node.NodeKey key exchange via token ring, ClusterPeers persistence + auto-rejoin, Forward delegates to forwardToNext (bugfix) - Auth: Basic Auth (flag-creds fast path) + bcrypt users (admin/viewer) + Bearer API keys (read/write/admin scope) - Frontend: UsersView (accounts+API keys), ClusterView (ring/nodeKey/tasks/topology/log), StatusView (group management, per-proxy status), CanvasView (edge toggle/group), PortEdge (disabled/group labels) - API: handlers split (canvas/forwards/users/logs), canvas export/import, forwards group start/stop/assign/delete, cluster endpoints - Docs: comprehensive README rewrite (all flags/APIs/auth/cluster), docs/cluster-api.md (cluster management API reference) - Deploy: run-cluster.sh now 4-node ring + 1 isolated standalone, test-forward.sh updated for 4 nodes - Removed plan.md (design notes consolidated into README + API docs)
This commit is contained in:
129
internal/httpapi/auth.go
Normal file
129
internal/httpapi/auth.go
Normal file
@ -0,0 +1,129 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"webui4frpc/internal/store"
|
||||
)
|
||||
|
||||
// identity is the authenticated principal for a request, stashed in the
|
||||
// request context so handlers can read who/what level the caller is.
|
||||
//
|
||||
// Type is one of:
|
||||
// - "user": a row in the users table, authenticated via Basic + bcrypt.
|
||||
// - "service": the -user/-password flag creds (inter-node cluster traffic
|
||||
// and bootstrap), authenticated via a constant-time compare.
|
||||
// - "key": an API key, authenticated via Bearer + sha256 lookup.
|
||||
//
|
||||
// Level is the effective access tier: "read" | "write" | "admin". For users it
|
||||
// derives from the role (viewer -> read, admin -> admin); for keys it is the
|
||||
// key's scope; for the service identity it is always admin (the flags are the
|
||||
// built-in operator).
|
||||
type identity struct {
|
||||
Type string `json:"type"` // "user" | "service" | "key"
|
||||
Name string `json:"name"` // username / flag user / key label
|
||||
Level string `json:"level"` // "read" | "write" | "admin"
|
||||
UserID int64 `json:"userId"` // users.id for Type=="user"|"key"
|
||||
}
|
||||
|
||||
type ctxKey struct{}
|
||||
|
||||
// identityFrom returns the authenticated identity from the request context,
|
||||
// or a zero identity (Level=="") when unauthenticated.
|
||||
func identityFrom(r *http.Request) identity {
|
||||
if v, ok := r.Context().Value(ctxKey{}).(identity); ok {
|
||||
return v
|
||||
}
|
||||
return identity{}
|
||||
}
|
||||
|
||||
// levelRank orders the access tiers: read < write < admin. Unknown = 0.
|
||||
func levelRank(level string) int {
|
||||
switch level {
|
||||
case "read":
|
||||
return 1
|
||||
case "write":
|
||||
return 2
|
||||
case "admin":
|
||||
return 3
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// roleLevel maps a user role to an access level (viewer -> read, admin -> admin).
|
||||
func roleLevel(role string) string {
|
||||
if role == "admin" {
|
||||
return "admin"
|
||||
}
|
||||
return "read"
|
||||
}
|
||||
|
||||
// hasLevel reports whether the request's identity meets minLevel. Handlers
|
||||
// serving both read and mutating methods on one path use this to gate the
|
||||
// mutating branch (the route itself is registered at the read level so that
|
||||
// viewer GETs succeed, then PUT/POST/DELETE branches re-check here).
|
||||
func hasLevel(r *http.Request, minLevel string) bool {
|
||||
return levelRank(identityFrom(r).Level) >= levelRank(minLevel)
|
||||
}
|
||||
|
||||
// forbidden writes a 403 with a small JSON body.
|
||||
func forbidden(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte(`{"error":"forbidden: insufficient scope"}`))
|
||||
}
|
||||
|
||||
// auth wraps a handler with authentication AND authorization. It accepts either
|
||||
// HTTP Basic (users table bcrypt, with a flag-creds admin fast path that keeps
|
||||
// inter-node cluster traffic working) or a Bearer API key (sha256-looked-up).
|
||||
// Identities below minLevel get 403; missing/bad credentials get 401.
|
||||
//
|
||||
// The flag-creds fast path is checked BEFORE the users table: inter-node token
|
||||
// relay and the browser-cached Basic header hit it on every request, and a
|
||||
// bcrypt verify per hop would be wasteful; the flags are the built-in operator
|
||||
// and are synced into a system=1 admin row by SyncSystemUser anyway, so this
|
||||
// shortcut grants no privilege that the flags themselves do not already confer.
|
||||
func (h *Handler) auth(minLevel string) func(http.HandlerFunc) http.HandlerFunc {
|
||||
need := levelRank(minLevel)
|
||||
return func(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var id identity
|
||||
switch {
|
||||
case strings.HasPrefix(r.Header.Get("Authorization"), "Basic "):
|
||||
u, p, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(u), []byte(h.User)) == 1 &&
|
||||
subtle.ConstantTimeCompare([]byte(p), []byte(h.Password)) == 1 {
|
||||
id = identity{Type: "service", Name: h.User, Level: "admin"}
|
||||
} else if usr, ok := h.Store.VerifyUserPassword(u, p); ok {
|
||||
id = identity{Type: "user", Name: usr.Username, Level: roleLevel(usr.Role), UserID: usr.ID}
|
||||
_ = h.Store.TouchUserLogin(usr.ID)
|
||||
}
|
||||
case strings.HasPrefix(r.Header.Get("Authorization"), "Bearer "):
|
||||
key := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
if k, usr, ok := h.Store.LookupApiKey(store.HashApiKey(key)); ok {
|
||||
id = identity{Type: "key", Name: k.Label, Level: k.Scope, UserID: usr.ID}
|
||||
_ = h.Store.TouchApiKey(k.ID)
|
||||
}
|
||||
}
|
||||
if id.Level == "" {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="webui-frpc", Bearer realm="webui4frpc-apikey"`)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
|
||||
return
|
||||
}
|
||||
if levelRank(id.Level) < need {
|
||||
forbidden(w)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), ctxKey{}, id)
|
||||
next(w, r.WithContext(ctx))
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
77
internal/httpapi/dist/assets/index-C5WLVFP2.js
vendored
Normal file
77
internal/httpapi/dist/assets/index-C5WLVFP2.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
internal/httpapi/dist/assets/index-CL42Ur3_.css
vendored
Normal file
1
internal/httpapi/dist/assets/index-CL42Ur3_.css
vendored
Normal file
File diff suppressed because one or more lines are too long
76
internal/httpapi/dist/assets/index-CXBf2fLP.js
vendored
76
internal/httpapi/dist/assets/index-CXBf2fLP.js
vendored
File diff suppressed because one or more lines are too long
283
internal/httpapi/dist/favicon.svg
vendored
Normal file
283
internal/httpapi/dist/favicon.svg
vendored
Normal file
@ -0,0 +1,283 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500" width="500" height="500" shape-rendering="crispEdges">
|
||||
<defs>
|
||||
<!-- 主字母像素块(黑色) -->
|
||||
<rect id="p" width="12" height="12" fill="#000000" />
|
||||
<!-- frpc 小像素块(蓝色) -->
|
||||
<rect id="q" width="5" height="5" fill="#2563eb" />
|
||||
<!-- 装饰小方块(浅灰) -->
|
||||
<rect id="d" width="8" height="8" fill="#94a3b8" />
|
||||
<!-- 小十字星装饰 -->
|
||||
<rect id="s" width="6" height="6" fill="#94a3b8" />
|
||||
</defs>
|
||||
|
||||
<!-- 圆角白色背景(模拟 App 图标外框) -->
|
||||
<rect width="500" height="500" rx="40" fill="#ffffff" stroke="#e2e8f0" stroke-width="4" />
|
||||
|
||||
<!-- ========== 主字母整体居中(横向间距更合理) ========== -->
|
||||
<g transform="translate(70, 160)">
|
||||
<!-- ===== W(基于原始结构,高度约 150px) ===== -->
|
||||
<g transform="translate(0, 0)">
|
||||
<!-- 左竖线(占2列) -->
|
||||
<use href="#p" x="0" y="0" />
|
||||
<use href="#p" x="12" y="0" />
|
||||
<use href="#p" x="0" y="12" />
|
||||
<use href="#p" x="12" y="12" />
|
||||
<use href="#p" x="0" y="24" />
|
||||
<use href="#p" x="12" y="24" />
|
||||
<use href="#p" x="0" y="36" />
|
||||
<use href="#p" x="12" y="36" />
|
||||
<use href="#p" x="0" y="48" />
|
||||
<use href="#p" x="12" y="48" />
|
||||
<use href="#p" x="0" y="60" />
|
||||
<use href="#p" x="12" y="60" />
|
||||
<use href="#p" x="0" y="72" />
|
||||
<use href="#p" x="12" y="72" />
|
||||
<use href="#p" x="0" y="84" />
|
||||
<use href="#p" x="12" y="84" />
|
||||
<use href="#p" x="0" y="96" />
|
||||
<use href="#p" x="12" y="96" />
|
||||
<use href="#p" x="0" y="108" />
|
||||
<use href="#p" x="12" y="108" />
|
||||
<use href="#p" x="0" y="120" />
|
||||
<use href="#p" x="12" y="120" />
|
||||
<use href="#p" x="0" y="132" />
|
||||
<use href="#p" x="12" y="132" />
|
||||
|
||||
<!-- 右竖线(占2列) -->
|
||||
<use href="#p" x="84" y="0" />
|
||||
<use href="#p" x="96" y="0" />
|
||||
<use href="#p" x="84" y="12" />
|
||||
<use href="#p" x="96" y="12" />
|
||||
<use href="#p" x="84" y="24" />
|
||||
<use href="#p" x="96" y="24" />
|
||||
<use href="#p" x="84" y="36" />
|
||||
<use href="#p" x="96" y="36" />
|
||||
<use href="#p" x="84" y="48" />
|
||||
<use href="#p" x="96" y="48" />
|
||||
<use href="#p" x="84" y="60" />
|
||||
<use href="#p" x="96" y="60" />
|
||||
<use href="#p" x="84" y="72" />
|
||||
<use href="#p" x="96" y="72" />
|
||||
<use href="#p" x="84" y="84" />
|
||||
<use href="#p" x="96" y="84" />
|
||||
<use href="#p" x="84" y="96" />
|
||||
<use href="#p" x="96" y="96" />
|
||||
<use href="#p" x="84" y="108" />
|
||||
<use href="#p" x="96" y="108" />
|
||||
<use href="#p" x="84" y="120" />
|
||||
<use href="#p" x="96" y="120" />
|
||||
<use href="#p" x="84" y="132" />
|
||||
<use href="#p" x="96" y="132" />
|
||||
|
||||
<!-- 中间 V 形(从 y=60 开始) -->
|
||||
<use href="#p" x="24" y="60" />
|
||||
<use href="#p" x="48" y="60" />
|
||||
<use href="#p" x="72" y="60" />
|
||||
<use href="#p" x="24" y="72" />
|
||||
<use href="#p" x="48" y="72" />
|
||||
<use href="#p" x="72" y="72" />
|
||||
<use href="#p" x="24" y="84" />
|
||||
<use href="#p" x="48" y="84" />
|
||||
<use href="#p" x="72" y="84" />
|
||||
<use href="#p" x="24" y="96" />
|
||||
<use href="#p" x="48" y="96" />
|
||||
<use href="#p" x="72" y="96" />
|
||||
<!-- 底部汇合 -->
|
||||
<use href="#p" x="36" y="108" />
|
||||
<use href="#p" x="60" y="108" />
|
||||
<use href="#p" x="36" y="120" />
|
||||
<use href="#p" x="60" y="120" />
|
||||
<use href="#p" x="36" y="132" />
|
||||
<use href="#p" x="60" y="132" />
|
||||
<use href="#p" x="48" y="132" />
|
||||
</g>
|
||||
|
||||
<!-- ===== U(高度与 W 一致,内部空间增大) ===== -->
|
||||
<g transform="translate(124, 0)">
|
||||
<!-- 左竖线(占2列) -->
|
||||
<use href="#p" x="0" y="0" />
|
||||
<use href="#p" x="12" y="0" />
|
||||
<use href="#p" x="0" y="12" />
|
||||
<use href="#p" x="12" y="12" />
|
||||
<use href="#p" x="0" y="24" />
|
||||
<use href="#p" x="12" y="24" />
|
||||
<use href="#p" x="0" y="36" />
|
||||
<use href="#p" x="12" y="36" />
|
||||
<use href="#p" x="0" y="48" />
|
||||
<use href="#p" x="12" y="48" />
|
||||
<use href="#p" x="0" y="60" />
|
||||
<use href="#p" x="12" y="60" />
|
||||
<use href="#p" x="0" y="72" />
|
||||
<use href="#p" x="12" y="72" />
|
||||
<use href="#p" x="0" y="84" />
|
||||
<use href="#p" x="12" y="84" />
|
||||
<use href="#p" x="0" y="96" />
|
||||
<use href="#p" x="12" y="96" />
|
||||
<use href="#p" x="0" y="108" />
|
||||
<use href="#p" x="12" y="108" />
|
||||
<use href="#p" x="0" y="120" />
|
||||
<use href="#p" x="12" y="120" />
|
||||
<use href="#p" x="0" y="132" />
|
||||
<use href="#p" x="12" y="132" />
|
||||
|
||||
<!-- 右竖线(占2列) -->
|
||||
<use href="#p" x="84" y="0" />
|
||||
<use href="#p" x="96" y="0" />
|
||||
<use href="#p" x="84" y="12" />
|
||||
<use href="#p" x="96" y="12" />
|
||||
<use href="#p" x="84" y="24" />
|
||||
<use href="#p" x="96" y="24" />
|
||||
<use href="#p" x="84" y="36" />
|
||||
<use href="#p" x="96" y="36" />
|
||||
<use href="#p" x="84" y="48" />
|
||||
<use href="#p" x="96" y="48" />
|
||||
<use href="#p" x="84" y="60" />
|
||||
<use href="#p" x="96" y="60" />
|
||||
<use href="#p" x="84" y="72" />
|
||||
<use href="#p" x="96" y="72" />
|
||||
<use href="#p" x="84" y="84" />
|
||||
<use href="#p" x="96" y="84" />
|
||||
<use href="#p" x="84" y="96" />
|
||||
<use href="#p" x="96" y="96" />
|
||||
<use href="#p" x="84" y="108" />
|
||||
<use href="#p" x="96" y="108" />
|
||||
<use href="#p" x="84" y="120" />
|
||||
<use href="#p" x="96" y="120" />
|
||||
<use href="#p" x="84" y="132" />
|
||||
<use href="#p" x="96" y="132" />
|
||||
|
||||
<!-- 底部横线 -->
|
||||
<use href="#p" x="0" y="132" />
|
||||
<use href="#p" x="12" y="132" />
|
||||
<use href="#p" x="24" y="132" />
|
||||
<use href="#p" x="36" y="132" />
|
||||
<use href="#p" x="48" y="132" />
|
||||
<use href="#p" x="60" y="132" />
|
||||
<use href="#p" x="72" y="132" />
|
||||
<use href="#p" x="84" y="132" />
|
||||
<use href="#p" x="96" y="132" />
|
||||
|
||||
<!-- ===== U 内部:竖向排列的 frpc(蓝色,5×5 像素) ===== -->
|
||||
<g transform="translate(32, 18)">
|
||||
<!-- f -->
|
||||
<use href="#q" x="0" y="0" />
|
||||
<use href="#q" x="0" y="6" />
|
||||
<use href="#q" x="0" y="12" />
|
||||
<use href="#q" x="0" y="18" />
|
||||
<use href="#q" x="6" y="0" />
|
||||
<use href="#q" x="12" y="0" />
|
||||
<use href="#q" x="18" y="0" />
|
||||
<use href="#q" x="6" y="12" />
|
||||
<use href="#q" x="12" y="12" />
|
||||
|
||||
<!-- r -->
|
||||
<use href="#q" x="0" y="28" />
|
||||
<use href="#q" x="0" y="34" />
|
||||
<use href="#q" x="0" y="40" />
|
||||
<use href="#q" x="0" y="46" />
|
||||
<use href="#q" x="6" y="28" />
|
||||
<use href="#q" x="12" y="28" />
|
||||
<use href="#q" x="18" y="28" />
|
||||
<use href="#q" x="6" y="40" />
|
||||
<use href="#q" x="12" y="40" />
|
||||
|
||||
<!-- p -->
|
||||
<use href="#q" x="0" y="56" />
|
||||
<use href="#q" x="0" y="62" />
|
||||
<use href="#q" x="0" y="68" />
|
||||
<use href="#q" x="0" y="74" />
|
||||
<use href="#q" x="0" y="80" />
|
||||
<use href="#q" x="6" y="56" />
|
||||
<use href="#q" x="12" y="56" />
|
||||
<use href="#q" x="18" y="56" />
|
||||
<use href="#q" x="18" y="62" />
|
||||
<use href="#q" x="18" y="68" />
|
||||
<use href="#q" x="6" y="74" />
|
||||
<use href="#q" x="12" y="74" />
|
||||
<use href="#q" x="18" y="74" />
|
||||
|
||||
<!-- c -->
|
||||
<use href="#q" x="6" y="90" />
|
||||
<use href="#q" x="12" y="90" />
|
||||
<use href="#q" x="18" y="90" />
|
||||
<use href="#q" x="0" y="96" />
|
||||
<use href="#q" x="0" y="102" />
|
||||
<use href="#q" x="6" y="108" />
|
||||
<use href="#q" x="12" y="108" />
|
||||
<use href="#q" x="18" y="108" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- ===== I(中间竖线 2 列宽,高度一致) ===== -->
|
||||
<g transform="translate(248, 0)">
|
||||
<!-- 顶部横线(占6列) -->
|
||||
<use href="#p" x="0" y="0" />
|
||||
<use href="#p" x="12" y="0" />
|
||||
<use href="#p" x="24" y="0" />
|
||||
<use href="#p" x="36" y="0" />
|
||||
<use href="#p" x="48" y="0" />
|
||||
<use href="#p" x="60" y="0" />
|
||||
|
||||
<!-- 中间竖线(2 列宽) -->
|
||||
<use href="#p" x="24" y="12" />
|
||||
<use href="#p" x="36" y="12" />
|
||||
<use href="#p" x="24" y="24" />
|
||||
<use href="#p" x="36" y="24" />
|
||||
<use href="#p" x="24" y="36" />
|
||||
<use href="#p" x="36" y="36" />
|
||||
<use href="#p" x="24" y="48" />
|
||||
<use href="#p" x="36" y="48" />
|
||||
<use href="#p" x="24" y="60" />
|
||||
<use href="#p" x="36" y="60" />
|
||||
<use href="#p" x="24" y="72" />
|
||||
<use href="#p" x="36" y="72" />
|
||||
<use href="#p" x="24" y="84" />
|
||||
<use href="#p" x="36" y="84" />
|
||||
<use href="#p" x="24" y="96" />
|
||||
<use href="#p" x="36" y="96" />
|
||||
<use href="#p" x="24" y="108" />
|
||||
<use href="#p" x="36" y="108" />
|
||||
<use href="#p" x="24" y="120" />
|
||||
<use href="#p" x="36" y="120" />
|
||||
|
||||
<!-- 底部横线(占6列) -->
|
||||
<use href="#p" x="0" y="132" />
|
||||
<use href="#p" x="12" y="132" />
|
||||
<use href="#p" x="24" y="132" />
|
||||
<use href="#p" x="36" y="132" />
|
||||
<use href="#p" x="48" y="132" />
|
||||
<use href="#p" x="60" y="132" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- ========== 外围装饰元素(像素风,增加层次) ========== -->
|
||||
<!-- 左上角小十字星 -->
|
||||
<g transform="translate(40, 40)">
|
||||
<use href="#s" x="0" y="0" />
|
||||
<use href="#s" x="0" y="12" />
|
||||
<use href="#s" x="6" y="6" />
|
||||
<use href="#s" x="12" y="0" />
|
||||
<use href="#s" x="12" y="12" />
|
||||
</g>
|
||||
<!-- 右上角小方块 -->
|
||||
<use href="#d" x="440" y="40" />
|
||||
<!-- 左下角小方块 -->
|
||||
<use href="#d" x="40" y="440" />
|
||||
<!-- 右下角小十字星 -->
|
||||
<g transform="translate(436, 436)">
|
||||
<use href="#s" x="0" y="0" />
|
||||
<use href="#s" x="0" y="12" />
|
||||
<use href="#s" x="6" y="6" />
|
||||
<use href="#s" x="12" y="0" />
|
||||
<use href="#s" x="12" y="12" />
|
||||
</g>
|
||||
|
||||
<!-- 三个随机黑色装饰点(更大一点,12×12) -->
|
||||
<rect x="90" y="380" width="12" height="12" fill="#000000" />
|
||||
<rect x="420" y="140" width="12" height="12" fill="#000000" />
|
||||
<rect x="240" y="60" width="12" height="12" fill="#000000" />
|
||||
|
||||
<!-- 额外小点缀:蓝色小点,呼应 frpc 颜色 -->
|
||||
<rect x="180" y="400" width="6" height="6" fill="#2563eb" />
|
||||
<rect x="370" y="80" width="6" height="6" fill="#2563eb" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 10 KiB |
7
internal/httpapi/dist/index.html
vendored
7
internal/httpapi/dist/index.html
vendored
@ -3,9 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>webui-frpc</title>
|
||||
<script type="module" crossorigin src="/assets/index-CXBf2fLP.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B_Hvtf6C.css">
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<title>webui4frpc</title>
|
||||
<script type="module" crossorigin src="/assets/index-C5WLVFP2.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CL42Ur3_.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@ -29,6 +29,11 @@ func (h *Handler) handleCanvasSave(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
@ -41,6 +46,19 @@ func (h *Handler) saveCanvas(w http.ResponseWriter, r *http.Request) {
|
||||
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.
|
||||
@ -57,7 +75,7 @@ func (h *Handler) saveCanvas(w http.ResponseWriter, r *http.Request) {
|
||||
for _, l := range canvas.Locals {
|
||||
if err := s.UpsertLocal(l); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Delete locals not present. A removed localOnly forward is cancelled
|
||||
@ -101,7 +119,7 @@ func (h *Handler) saveCanvas(w http.ResponseWriter, r *http.Request) {
|
||||
for _, rem := range canvas.Remotes {
|
||||
if err := s.UpsertRemote(rem); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
return false
|
||||
}
|
||||
}
|
||||
if existing, err := s.ListRemotes(); err == nil {
|
||||
@ -119,32 +137,44 @@ func (h *Handler) saveCanvas(w http.ResponseWriter, r *http.Request) {
|
||||
// Replace links wholesale.
|
||||
if err := s.ReplaceLinks(canvas.Links); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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 := map[string]store.Local{}
|
||||
for _, l := range canvas.Locals {
|
||||
localByName[l.Name] = l
|
||||
}
|
||||
remoteByName := map[string]store.Remote{}
|
||||
for _, r := range canvas.Remotes {
|
||||
remoteByName[r.Name] = r
|
||||
}
|
||||
if h.Ring != nil {
|
||||
for _, l := range canvas.Locals {
|
||||
if l.LocalOnly {
|
||||
for _, ln := range canvas.Links {
|
||||
loc, ok := localByName[ln.Local]
|
||||
if !ok || loc.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)
|
||||
}
|
||||
rem, ok := remoteByName[ln.Remote]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if ln.Disabled {
|
||||
// Stopped on the forwards page: make sure it leaves the topology.
|
||||
if h.Ring.HasTask(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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -153,8 +183,7 @@ func (h *Handler) saveCanvas(w http.ResponseWriter, r *http.Request) {
|
||||
if h.SyncWorkers != nil {
|
||||
h.SyncWorkers()
|
||||
}
|
||||
|
||||
h.handleCanvasGet(w, r)
|
||||
return true
|
||||
}
|
||||
|
||||
// ---- Settings ----
|
||||
@ -280,6 +309,12 @@ func (h *Handler) handleProfile(w http.ResponseWriter, r *http.Request) {
|
||||
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.Start(name)
|
||||
|
||||
257
internal/httpapi/handlers_canvas.go
Normal file
257
internal/httpapi/handlers_canvas.go
Normal file
@ -0,0 +1,257 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"webui4frpc/internal/cluster"
|
||||
"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 {
|
||||
if fwd, _ := h.Store.LinksForLocal(name); len(fwd) > 0 {
|
||||
_ = h.Process.Stop(fwd[0].Remote)
|
||||
}
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
278
internal/httpapi/handlers_forwards.go
Normal file
278
internal/httpapi/handlers_forwards.go
Normal file
@ -0,0 +1,278 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"webui4frpc/internal/store"
|
||||
)
|
||||
|
||||
// forwardsReq is the {local,remote,remotePort} natural key identifying a single
|
||||
// forward. The triple is stable across canvas re-saves (unlike Link.ID, which
|
||||
// ReplaceLinks wholesale-replaces) and matches HasTask's keying.
|
||||
type forwardsReq struct {
|
||||
Local string `json:"local"`
|
||||
Remote string `json:"remote"`
|
||||
RemotePort int `json:"remotePort"`
|
||||
}
|
||||
|
||||
// groupReq selects a group for one-click start/stop on the forwards page.
|
||||
type groupReq struct {
|
||||
Group string `json:"group"`
|
||||
}
|
||||
|
||||
// findLinkByTriple returns the link matching the (local,remote,remotePort)
|
||||
// natural key, or ok=false.
|
||||
func findLinkByTriple(s *store.Store, local, remote string, port int) (store.Link, bool) {
|
||||
links, err := s.ListLinks()
|
||||
if err != nil {
|
||||
return store.Link{}, false
|
||||
}
|
||||
for _, l := range links {
|
||||
if l.Local == local && l.Remote == remote && l.RemotePort == port {
|
||||
return l, true
|
||||
}
|
||||
}
|
||||
return store.Link{}, false
|
||||
}
|
||||
|
||||
// startForward flips a forward to enabled and brings it up. A local-only
|
||||
// forward restarts/starts its local frpc worker so the re-enabled proxy is
|
||||
// rendered back in; a cluster (non-localOnly) forward is submitted to the ring
|
||||
// so the lowest-load member claims and spawns it (plan §令牌环协议). SubmitTask
|
||||
// is idempotent via HasTask. Returns store.ErrNotFound if the forward is gone.
|
||||
func (h *Handler) startForward(local, remote string, port int) error {
|
||||
ln, ok := findLinkByTriple(h.Store, local, remote, port)
|
||||
if !ok {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
loc, ok := h.Store.GetLocal(local)
|
||||
if !ok {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
rem, ok := h.Store.GetRemote(remote)
|
||||
if !ok {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
_ = h.Store.SetLinkDisabled(local, remote, port, false)
|
||||
// Reflect the post-start (enabled) state in the snapshot handed to the
|
||||
// ring: ln was fetched before SetLinkDisabled, so for a re-start of a
|
||||
// previously stopped forward it still carries disabled=true. Without this
|
||||
// the topology entry would embed a stale disabled flag (cosmetically
|
||||
// wrong, and confusing if anything reads topology's link snapshot).
|
||||
ln.Disabled = false
|
||||
if loc.LocalOnly {
|
||||
if h.Process != nil {
|
||||
// Re-render (proxy re-added) on a running worker, or start it.
|
||||
if _, has := h.Process.Status(remote); has {
|
||||
_ = h.Process.Restart(remote)
|
||||
} else {
|
||||
_ = h.Process.Start(remote)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if h.Ring != nil {
|
||||
h.Ring.SubmitTask(loc, rem, ln)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// stopForward flips a forward to disabled and tears it down. A local-only
|
||||
// forward restarts its worker so renderRemote omits the proxy (sibling forwards
|
||||
// on the same remote keep running — true per-forward stop); a cluster forward
|
||||
// is revoked so the owning node cancels its worker and drops it from the
|
||||
// topology (plan §任务撤销). RevokeTask is idempotent.
|
||||
func (h *Handler) stopForward(local, remote string, port int) error {
|
||||
ln, ok := findLinkByTriple(h.Store, local, remote, port)
|
||||
if !ok {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
loc, ok := h.Store.GetLocal(local)
|
||||
if !ok {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
rem, ok := h.Store.GetRemote(remote)
|
||||
if !ok {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
_ = h.Store.SetLinkDisabled(local, remote, port, true)
|
||||
if loc.LocalOnly {
|
||||
// Re-render without this proxy; only meaningful while a worker runs.
|
||||
if h.Process != nil {
|
||||
if _, has := h.Process.Status(remote); has {
|
||||
_ = h.Process.Restart(remote)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if h.Ring != nil {
|
||||
h.Ring.RevokeTask(loc, rem, ln)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleForwardsStart toggles one forward on.
|
||||
func (h *Handler) handleForwardsStart(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
var req forwardsReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Local == "" || req.Remote == "" || req.RemotePort <= 0 {
|
||||
http.Error(w, "local/remote/remotePort required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.startForward(req.Local, req.Remote, req.RemotePort); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// handleForwardsStop toggles one forward off.
|
||||
func (h *Handler) handleForwardsStop(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
var req forwardsReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Local == "" || req.Remote == "" || req.RemotePort <= 0 {
|
||||
http.Error(w, "local/remote/remotePort required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.stopForward(req.Local, req.Remote, req.RemotePort); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// handleForwardsGroupStart starts every forward in a group with one click.
|
||||
func (h *Handler) handleForwardsGroupStart(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
var req groupReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
links, err := h.Store.ListLinks()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
for _, ln := range links {
|
||||
if ln.Group != req.Group {
|
||||
continue
|
||||
}
|
||||
_ = h.startForward(ln.Local, ln.Remote, ln.RemotePort)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// handleForwardsGroupStop stops every forward in a group with one click.
|
||||
func (h *Handler) handleForwardsGroupStop(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
var req groupReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
links, err := h.Store.ListLinks()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
for _, ln := range links {
|
||||
if ln.Group != req.Group {
|
||||
continue
|
||||
}
|
||||
_ = h.stopForward(ln.Local, ln.Remote, ln.RemotePort)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// assignReq carries the group label to assign to a single forward (status
|
||||
// page group chip edit). Empty Group clears the assignment (移出分组).
|
||||
type assignReq struct {
|
||||
Local string `json:"local"`
|
||||
Remote string `json:"remote"`
|
||||
RemotePort int `json:"remotePort"`
|
||||
Group string `json:"group"`
|
||||
}
|
||||
|
||||
// handleForwardsAssign changes the group label of a single forward. The
|
||||
// status page group chip is the quick entry; the canvas port editor also
|
||||
// carries a group field (both persist via the same store column). The
|
||||
// change is purely a DB update — no worker/ring action is needed (group is
|
||||
// a management label, not a runtime knob).
|
||||
func (h *Handler) handleForwardsAssign(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
var req assignReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Local == "" || req.Remote == "" || req.RemotePort <= 0 {
|
||||
http.Error(w, "local/remote/remotePort required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, ok := findLinkByTriple(h.Store, req.Local, req.Remote, req.RemotePort); !ok {
|
||||
http.Error(w, store.ErrNotFound.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := h.Store.SetLinkGroup(req.Local, req.Remote, req.RemotePort, req.Group); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// handleForwardsGroupDelete dissolves a group: every forward in the named
|
||||
// group is moved to 未分组 (grp=""). The group itself is not a stored entity
|
||||
// — it exists only as a label on links — so clearing all members is the
|
||||
// complete "delete". No worker/ring action (group is a management label).
|
||||
func (h *Handler) handleForwardsGroupDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
var req groupReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Group == "" {
|
||||
http.Error(w, "group required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
links, err := h.Store.ListLinks()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
for _, ln := range links {
|
||||
if ln.Group != req.Group {
|
||||
continue
|
||||
}
|
||||
_ = h.Store.SetLinkGroup(ln.Local, ln.Remote, ln.RemotePort, "")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
146
internal/httpapi/handlers_logs.go
Normal file
146
internal/httpapi/handlers_logs.go
Normal file
@ -0,0 +1,146 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// workerLog is one frpc worker's log tail on a node.
|
||||
type workerLog struct {
|
||||
Name string `json:"name"`
|
||||
Remote string `json:"remote"` // worker key = remote name
|
||||
State string `json:"state"`
|
||||
Lines string `json:"lines"`
|
||||
}
|
||||
|
||||
// nodeLogsResp is the body of GET /node/logs: all frpc workers handling
|
||||
// forwards on THIS node (cluster-owned workers that run here + local-only ones).
|
||||
type nodeLogsResp struct {
|
||||
Node string `json:"node"`
|
||||
Workers []workerLog `json:"workers"`
|
||||
}
|
||||
|
||||
// localWorkerLogs gathers this node's frpc worker logs. Shared by /node/logs
|
||||
// and the /cluster/logs/export self entry so the requesting node doesn't HTTP
|
||||
// back to itself.
|
||||
func (h *Handler) localWorkerLogs() nodeLogsResp {
|
||||
out := nodeLogsResp{Node: h.SelfAddr, Workers: []workerLog{}}
|
||||
if h.Process == nil {
|
||||
return out
|
||||
}
|
||||
for _, name := range h.Process.ListWorkers() {
|
||||
st, _ := h.Process.Status(name)
|
||||
lines, _ := tailFile(h.Process.LogPath(name), 64*1024)
|
||||
out.Workers = append(out.Workers, workerLog{
|
||||
Name: name, Remote: name, State: st.State, Lines: lines,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handleNodeLogs returns the frpc worker logs of THIS node only. Read-level:
|
||||
// auditors can pull it directly on any node, and the cluster export fans it out.
|
||||
func (h *Handler) handleNodeLogs(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, h.localWorkerLogs())
|
||||
}
|
||||
|
||||
// clusterWorkerLogNode is one node's entry in the export bundle.
|
||||
type clusterWorkerLogNode struct {
|
||||
ID string `json:"id"`
|
||||
Addr string `json:"addr"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Logs *nodeLogsResp `json:"logs,omitempty"`
|
||||
}
|
||||
|
||||
// handleClusterLogsExport fans out GET /node/logs to every alive ring node
|
||||
// (using the flag Basic creds, which resolve to admin on each peer via the
|
||||
// flag fast path) and aggregates the results into a downloadable bundle.
|
||||
// Unreachable nodes are marked error but don't abort the export. The
|
||||
// requester's own logs are gathered locally without an HTTP round-trip.
|
||||
func (h *Handler) handleClusterLogsExport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
if h.Ring == nil {
|
||||
http.Error(w, "ring engine not enabled", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
snap := h.Ring.Snapshot()
|
||||
selfID := snap.SelfID
|
||||
type target struct{ id, addr string }
|
||||
var targets []target
|
||||
for _, n := range snap.Nodes {
|
||||
if !n.Alive || n.Addr == "" {
|
||||
continue
|
||||
}
|
||||
targets = append(targets, target{n.ID, n.Addr})
|
||||
}
|
||||
results := make([]clusterWorkerLogNode, len(targets))
|
||||
var wg sync.WaitGroup
|
||||
cli := &http.Client{Timeout: 8 * time.Second}
|
||||
for i, t := range targets {
|
||||
wg.Add(1)
|
||||
go func(i int, t target) {
|
||||
defer wg.Done()
|
||||
entry := clusterWorkerLogNode{ID: t.id, Addr: t.addr}
|
||||
// Self: gather locally, no HTTP round-trip.
|
||||
if t.id == selfID {
|
||||
logs := h.localWorkerLogs()
|
||||
entry.OK = true
|
||||
entry.Logs = &logs
|
||||
results[i] = entry
|
||||
return
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, "http://"+t.addr+apiPrefix+"/node/logs", nil)
|
||||
if err != nil {
|
||||
entry.Error = err.Error()
|
||||
results[i] = entry
|
||||
return
|
||||
}
|
||||
req.SetBasicAuth(h.User, h.Password)
|
||||
resp, err := cli.Do(req)
|
||||
if err != nil {
|
||||
entry.Error = "unreachable: " + err.Error()
|
||||
results[i] = entry
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
entry.Error = "HTTP " + resp.Status
|
||||
results[i] = entry
|
||||
return
|
||||
}
|
||||
var logs nodeLogsResp
|
||||
if err := json.Unmarshal(body, &logs); err != nil {
|
||||
entry.Error = "parse: " + err.Error()
|
||||
results[i] = entry
|
||||
return
|
||||
}
|
||||
entry.OK = true
|
||||
entry.Logs = &logs
|
||||
results[i] = entry
|
||||
}(i, t)
|
||||
}
|
||||
wg.Wait()
|
||||
bundle := map[string]any{
|
||||
"_type": "webui4frpc-worker-logs",
|
||||
"version": 1,
|
||||
"exportedAt": time.Now().Unix(),
|
||||
"requester": selfID,
|
||||
"nodes": results,
|
||||
}
|
||||
body, _ := json.MarshalIndent(bundle, "", " ")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="worker-logs.json"`)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
206
internal/httpapi/handlers_users.go
Normal file
206
internal/httpapi/handlers_users.go
Normal file
@ -0,0 +1,206 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"webui4frpc/internal/store"
|
||||
)
|
||||
|
||||
// handleMe returns the authenticated principal. The frontend uses this to gate
|
||||
// UI (hide Users nav + disable write controls for viewer/audit users).
|
||||
func (h *Handler) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
methodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
id := identityFrom(r)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"type": id.Type,
|
||||
"name": id.Name,
|
||||
"level": id.Level,
|
||||
"userId": id.UserID,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Users ----
|
||||
|
||||
// handleUsers is the collection endpoint: GET lists users, POST creates one.
|
||||
// Admin only. password_hash is never serialized (User.PasswordHash has json:"-").
|
||||
func (h *Handler) handleUsers(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
users, err := h.Store.ListUsers()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"users": users})
|
||||
case http.MethodPost:
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
u, err := h.Store.CreateUser(req.Username, req.Password, req.Role)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, u)
|
||||
default:
|
||||
methodNotAllowed(w)
|
||||
}
|
||||
}
|
||||
|
||||
// handleUserByName operates on /users/{name}: PUT updates role/enabled/password,
|
||||
// DELETE removes the user. System (flag-synced) users are read-only; the last
|
||||
// enabled admin cannot be deleted or disabled.
|
||||
func (h *Handler) handleUserByName(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimPrefix(r.URL.Path, apiPrefix+"/users/")
|
||||
if name == "" {
|
||||
http.Error(w, "username required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
u, ok := h.Store.GetUser(name)
|
||||
if !ok {
|
||||
http.Error(w, "user not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodPut:
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Role defaults to the current value when omitted.
|
||||
role := req.Role
|
||||
if role == "" {
|
||||
role = u.Role
|
||||
}
|
||||
enabled := u.Enabled
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
// Guard: never disable/demote the last admin.
|
||||
if u.Role == "admin" && (role != "admin" || !enabled) {
|
||||
n, _ := h.Store.CountAdmins()
|
||||
if n <= 1 {
|
||||
http.Error(w, "cannot demote or disable the last admin", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := h.Store.UpdateUser(u.ID, role, enabled, req.Password); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
updated, _ := h.Store.GetUserByID(u.ID)
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
case http.MethodDelete:
|
||||
if u.System {
|
||||
http.Error(w, "system user is managed by -user/-password flags", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if u.Role == "admin" {
|
||||
n, _ := h.Store.CountAdmins()
|
||||
if n <= 1 {
|
||||
http.Error(w, "cannot delete the last admin", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := h.Store.DeleteUser(u.ID); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
methodNotAllowed(w)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- API keys ----
|
||||
|
||||
// handleApiKeys is the collection endpoint: GET lists keys (no hashes), POST
|
||||
// creates a key and returns the plaintext exactly once.
|
||||
func (h *Handler) handleApiKeys(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
keys, err := h.Store.ListApiKeys()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"apiKeys": keys})
|
||||
case http.MethodPost:
|
||||
var req struct {
|
||||
UserID int64 `json:"userId"`
|
||||
Label string `json:"label"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
k, plaintext, err := h.Store.CreateApiKey(req.UserID, req.Label, req.Scope)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"id": k.ID,
|
||||
"userId": k.UserID,
|
||||
"label": k.Label,
|
||||
"scope": k.Scope,
|
||||
"prefix": k.Prefix,
|
||||
"createdAt": k.CreatedAt,
|
||||
"key": plaintext, // shown exactly once
|
||||
})
|
||||
default:
|
||||
methodNotAllowed(w)
|
||||
}
|
||||
}
|
||||
|
||||
// handleApiKeyByID operates on /apikeys/{id}: DELETE revokes the key.
|
||||
func (h *Handler) handleApiKeyByID(w http.ResponseWriter, r *http.Request) {
|
||||
raw := strings.TrimPrefix(r.URL.Path, apiPrefix+"/apikeys/")
|
||||
id, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
http.Error(w, "invalid key id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodDelete:
|
||||
if err := h.Store.DeleteApiKey(id); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
methodNotAllowed(w)
|
||||
}
|
||||
}
|
||||
|
||||
// writeStoreErr maps store sentinel errors to HTTP statuses.
|
||||
func writeStoreErr(w http.ResponseWriter, err error) {
|
||||
switch err {
|
||||
case store.ErrNotFound:
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
case store.ErrAlreadyExists:
|
||||
http.Error(w, "already exists", http.StatusConflict)
|
||||
case store.ErrInvalid:
|
||||
http.Error(w, "invalid argument", http.StatusBadRequest)
|
||||
default:
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@ -57,68 +57,88 @@ const (
|
||||
func NewServeMux(h *Handler) (http.Handler, error) {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
auth := func(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
u, p, ok := r.BasicAuth()
|
||||
if !ok || u != h.User || p != h.Password {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="webui-frpc"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
mux.HandleFunc(healthzPath, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
// API routes (basic auth).
|
||||
mux.HandleFunc(apiPrefix+"/status", auth(h.handleStatus))
|
||||
mux.HandleFunc(apiPrefix+"/canvas", auth(h.handleCanvasSave))
|
||||
mux.HandleFunc(apiPrefix+"/settings", auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
// read tier (viewer + read-scope key + everyone above): pure-GET reads.
|
||||
// Routes that also accept a mutating method re-check the write level inside
|
||||
// the handler so a viewer GET still works while viewer PUT/POST is 403.
|
||||
mux.HandleFunc(apiPrefix+"/status", h.auth("read")(h.handleStatus))
|
||||
mux.HandleFunc(apiPrefix+"/canvas", h.auth("read")(h.handleCanvasSave)) // GET read; PUT write-checked inside
|
||||
mux.HandleFunc(apiPrefix+"/canvas/export", h.auth("read")(h.handleCanvasExport))
|
||||
mux.HandleFunc(apiPrefix+"/canvas/import", h.auth("write")(h.handleCanvasImport))
|
||||
mux.HandleFunc(apiPrefix+"/locals", h.auth("write")(h.handleLocals))
|
||||
mux.HandleFunc(apiPrefix+"/locals/", h.auth("write")(h.handleLocalByName))
|
||||
mux.HandleFunc(apiPrefix+"/links", h.auth("write")(h.handleLinks))
|
||||
mux.HandleFunc(apiPrefix+"/links/", h.auth("write")(h.handleLinkByID))
|
||||
// Per-forward start/stop + one-click group start/stop. Decoupled from the
|
||||
// remote-node worker controls so the forwards page owns forward lifecycle
|
||||
// (local-only → local worker restart; cluster → ring submit/revoke).
|
||||
mux.HandleFunc(apiPrefix+"/forwards/start", h.auth("write")(h.handleForwardsStart))
|
||||
mux.HandleFunc(apiPrefix+"/forwards/stop", h.auth("write")(h.handleForwardsStop))
|
||||
mux.HandleFunc(apiPrefix+"/forwards/group/start", h.auth("write")(h.handleForwardsGroupStart))
|
||||
mux.HandleFunc(apiPrefix+"/forwards/group/stop", h.auth("write")(h.handleForwardsGroupStop))
|
||||
mux.HandleFunc(apiPrefix+"/forwards/assign", h.auth("write")(h.handleForwardsAssign))
|
||||
mux.HandleFunc(apiPrefix+"/forwards/group/delete", h.auth("write")(h.handleForwardsGroupDelete))
|
||||
mux.HandleFunc(apiPrefix+"/settings", h.auth("read")(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPut && !hasLevel(r, "write") {
|
||||
forbidden(w)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPut {
|
||||
h.handleSettingsPut(w, r)
|
||||
return
|
||||
}
|
||||
h.handleSettingsGet(w, r)
|
||||
}))
|
||||
mux.HandleFunc(apiPrefix+"/binary/status", auth(h.handleBinaryStatus))
|
||||
mux.HandleFunc(apiPrefix+"/binary/install", auth(h.handleBinaryInstall))
|
||||
mux.HandleFunc(apiPrefix+"/binary/status", h.auth("read")(h.handleBinaryStatus))
|
||||
mux.HandleFunc(apiPrefix+"/binary/install", h.auth("write")(h.handleBinaryInstall))
|
||||
|
||||
// Profile lifecycle routes.
|
||||
mux.HandleFunc(apiPrefix+"/profiles/", auth(h.handleProfile))
|
||||
mux.HandleFunc(apiPrefix+"/remotes", auth(h.handleRemoteUpsert))
|
||||
mux.HandleFunc(apiPrefix+"/remotes/", auth(h.handleRemoteDelete))
|
||||
// Profile lifecycle: status/config/logs = read; start/stop/restart = write
|
||||
// (write-checked inside handleProfile).
|
||||
mux.HandleFunc(apiPrefix+"/profiles/", h.auth("read")(h.handleProfile))
|
||||
|
||||
// M6: cluster nodes + per-node cached versions (UI + discovery).
|
||||
mux.HandleFunc(apiPrefix+"/cluster/nodes", auth(h.handleClusterNodes))
|
||||
mux.HandleFunc(apiPrefix+"/cluster/cache", auth(h.handleClusterCache))
|
||||
mux.HandleFunc(apiPrefix+"/cluster/token", auth(h.handleClusterToken))
|
||||
mux.HandleFunc(apiPrefix+"/cluster/ring", auth(h.handleClusterRing))
|
||||
mux.HandleFunc(apiPrefix+"/cluster/join", auth(h.handleClusterJoin))
|
||||
mux.HandleFunc(apiPrefix+"/cluster/task", auth(h.handleClusterTask))
|
||||
mux.HandleFunc(apiPrefix+"/cluster/node-remove", auth(h.handleNodeRemove))
|
||||
mux.HandleFunc(apiPrefix+"/cluster/create", auth(h.handleClusterCreate))
|
||||
mux.HandleFunc(apiPrefix+"/cluster/join-ring", auth(h.handleClusterJoinRing))
|
||||
// write tier (write-scope key / admin): single-resource mutation.
|
||||
mux.HandleFunc(apiPrefix+"/remotes", h.auth("write")(h.handleRemoteUpsert))
|
||||
mux.HandleFunc(apiPrefix+"/remotes/", h.auth("write")(h.handleRemoteDelete))
|
||||
|
||||
// M6 cluster: reads + control plane. Inter-node token relay uses Basic flag
|
||||
// creds which resolve to admin via the flag fast path, so write level keeps
|
||||
// the ring working while blocking read-scope keys from injecting tasks.
|
||||
mux.HandleFunc(apiPrefix+"/cluster/nodes", h.auth("read")(h.handleClusterNodes))
|
||||
mux.HandleFunc(apiPrefix+"/cluster/cache", h.auth("read")(h.handleClusterCache)) // GET read; POST write-checked inside
|
||||
mux.HandleFunc(apiPrefix+"/cluster/ring", h.auth("read")(h.handleClusterRing))
|
||||
mux.HandleFunc(apiPrefix+"/node/logs", h.auth("read")(h.handleNodeLogs))
|
||||
mux.HandleFunc(apiPrefix+"/cluster/logs/export", h.auth("read")(h.handleClusterLogsExport))
|
||||
mux.HandleFunc(apiPrefix+"/cluster/token", h.auth("write")(h.handleClusterToken))
|
||||
mux.HandleFunc(apiPrefix+"/cluster/join", h.auth("write")(h.handleClusterJoin))
|
||||
mux.HandleFunc(apiPrefix+"/cluster/task", h.auth("write")(h.handleClusterTask))
|
||||
mux.HandleFunc(apiPrefix+"/cluster/node-remove", h.auth("write")(h.handleNodeRemove))
|
||||
mux.HandleFunc(apiPrefix+"/cluster/create", h.auth("write")(h.handleClusterCreate))
|
||||
mux.HandleFunc(apiPrefix+"/cluster/join-ring", h.auth("write")(h.handleClusterJoinRing))
|
||||
|
||||
// Account & API key management (admin only).
|
||||
mux.HandleFunc(apiPrefix+"/me", h.auth("read")(h.handleMe))
|
||||
mux.HandleFunc(apiPrefix+"/users", h.auth("admin")(h.handleUsers))
|
||||
mux.HandleFunc(apiPrefix+"/users/", h.auth("admin")(h.handleUserByName))
|
||||
mux.HandleFunc(apiPrefix+"/apikeys", h.auth("admin")(h.handleApiKeys))
|
||||
mux.HandleFunc(apiPrefix+"/apikeys/", h.auth("admin")(h.handleApiKeyByID))
|
||||
|
||||
// M6: peer-to-peer binary exchange endpoint (Basic Auth, same creds).
|
||||
// Not under /api so peers hit it directly; auth still applied.
|
||||
mux.HandleFunc("/frpc/", auth(h.handleFrpcBinary))
|
||||
// Not under /api so peers hit it directly; auth still applied. Peers
|
||||
// resolve to admin via the flag fast path.
|
||||
mux.HandleFunc("/frpc/", h.auth("read")(h.handleFrpcBinary))
|
||||
|
||||
// Static assets (also basic auth) under /.
|
||||
// The SPA shell is served behind the SAME Basic Auth as the API (plan:
|
||||
// "healthz 免认证,其余 Basic Auth"). Without this, the browser loads the
|
||||
// page without a 401 challenge, never caches credentials, and every
|
||||
// same-origin /api/* fetch (credentials:"same-origin") gets 401 — so the
|
||||
// SPA renders but all data pages read as empty ("集群未启动"). Wrapping /
|
||||
// in auth makes the browser prompt once, cache the Basic header for the
|
||||
// origin, and send it on every subsequent asset + API request.
|
||||
mux.HandleFunc("/", auth(h.handleStatic))
|
||||
// Static assets behind the same auth as the API: the browser caches the
|
||||
// Basic header once and sends it on every asset + /api/* request, so the
|
||||
// SPA loads for any valid identity (viewer included).
|
||||
mux.HandleFunc("/", h.auth("read")(h.handleStatic))
|
||||
|
||||
return mux, nil
|
||||
}
|
||||
|
||||
|
||||
// handleStatic serves the embedded web build. Paths map to dist files; / and
|
||||
// unknown paths serve index.html for SPA routing. The SPA uses hash-based
|
||||
// routing, so returning index.html for "/" is enough — a redirect here would
|
||||
@ -207,6 +227,28 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
GroupCount int `json:"groupCount,omitempty"`
|
||||
}
|
||||
|
||||
// Build per-forward lookup tables: a localByName index for the forwards
|
||||
// array, and an ownerOf map from ring topology (keyed by the
|
||||
// local/remote/remotePort triple) so each forward card can show its owning
|
||||
// node and active state. plan §任务撤销 / §令牌环协议.
|
||||
localByName := make(map[string]store.Local, len(locals))
|
||||
for _, l := range locals {
|
||||
localByName[l.Name] = l
|
||||
}
|
||||
type topoKey struct{ local, remote string; port int }
|
||||
ownerOf := map[topoKey]string{}
|
||||
selfID := h.SelfAddr
|
||||
if h.Ring != nil {
|
||||
snap := h.Ring.Snapshot()
|
||||
selfID = snap.SelfID
|
||||
for _, e := range snap.Topology {
|
||||
k := topoKey{e.Local.Name, e.Remote.Name, e.Link.RemotePort}
|
||||
if _, ok := ownerOf[k]; !ok {
|
||||
ownerOf[k] = e.OwnerID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
profiles := make([]profileStatus, 0, len(remotes))
|
||||
binary := ""
|
||||
if h.BinaryPath != nil {
|
||||
@ -236,7 +278,8 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Local status: each local plus its forwarding targets and whether each
|
||||
// target's worker is healthy (running).
|
||||
// target's worker is healthy (running). The per-forward owner/active detail
|
||||
// lives in the forwards array below; this is the local-services overview.
|
||||
type localTargetStatus struct {
|
||||
Remote string `json:"remote"`
|
||||
RemotePort int `json:"remotePort"`
|
||||
@ -265,6 +308,51 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
localStatuses = append(localStatuses, localStatus{Local: l, Targets: ts})
|
||||
}
|
||||
|
||||
// Forwards: one entry per link — the forward-centric view the UI renders as
|
||||
// cards. kind distinguishes 本地转发 (localOnly) from 远程转发 (cluster-
|
||||
// distributed); ownerId/active are resolved from ring topology (remote) or
|
||||
// the local worker (localOnly); group drives one-click group start/stop.
|
||||
type forwardStatus struct {
|
||||
Local string `json:"local"`
|
||||
Remote string `json:"remote"`
|
||||
RemotePort int `json:"remotePort"`
|
||||
LocalOnly bool `json:"localOnly"`
|
||||
Kind string `json:"kind"` // "local" | "remote"
|
||||
OwnerID string `json:"ownerId,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Group string `json:"group,omitempty"`
|
||||
LocalIP string `json:"localIp,omitempty"`
|
||||
LocalPort int `json:"localPort,omitempty"`
|
||||
LocalProto string `json:"localProto,omitempty"`
|
||||
}
|
||||
links, _ := h.Store.ListLinks()
|
||||
forwards := make([]forwardStatus, 0, len(links))
|
||||
for _, ln := range links {
|
||||
loc, ok := localByName[ln.Local]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fs := forwardStatus{
|
||||
Local: ln.Local, Remote: ln.Remote, RemotePort: ln.RemotePort,
|
||||
LocalOnly: loc.LocalOnly, Disabled: ln.Disabled, Group: ln.Group,
|
||||
LocalIP: loc.IP, LocalPort: loc.Port, LocalProto: loc.Protocol,
|
||||
}
|
||||
if loc.LocalOnly {
|
||||
fs.Kind = "local"
|
||||
fs.OwnerID = selfID
|
||||
if st, has := h.Process.Status(ln.Remote); has {
|
||||
fs.Active = !ln.Disabled && st.State == "running"
|
||||
}
|
||||
} else {
|
||||
fs.Kind = "remote"
|
||||
owner, inTopo := ownerOf[topoKey{ln.Local, ln.Remote, ln.RemotePort}]
|
||||
fs.OwnerID = owner
|
||||
fs.Active = !ln.Disabled && inTopo
|
||||
}
|
||||
forwards = append(forwards, fs)
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"version": "0.1.0",
|
||||
"workDir": h.WorkDir,
|
||||
@ -274,6 +362,8 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
"binaryPath": binary,
|
||||
"profiles": profiles,
|
||||
"localStatus": localStatuses,
|
||||
"forwards": forwards,
|
||||
"selfId": selfID,
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
@ -349,6 +439,11 @@ func (h *Handler) handleClusterCache(w http.ResponseWriter, r *http.Request) {
|
||||
case http.MethodGet:
|
||||
writeJSON(w, http.StatusOK, map[string]any{"cache": h.Cluster.CacheInfo()})
|
||||
case http.MethodPost:
|
||||
// Route registered at read so GET works; cache pruning needs write.
|
||||
if !hasLevel(r, "write") {
|
||||
forbidden(w)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Keep int `json:"keep"`
|
||||
}
|
||||
@ -379,13 +474,27 @@ func (h *Handler) handleClusterToken(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
// Heartbeat ping: empty body (no token) is a liveness check from the
|
||||
// leader's predecessor — answer 200 without processing.
|
||||
// leader's predecessor (the FALLBACK leader-death path). A node that
|
||||
// restarted (CreateCluster → 1-node standalone) or detached
|
||||
// (detachAsStandalone → 1-node standalone) is NOT the multi-node ring
|
||||
// leader the predecessor thinks it is. Returning 409 makes the
|
||||
// predecessor's WatchLeader heartbeat fail → MarkOffline → becomeLeader
|
||||
// → StartRing, healing the ring. Per design: "心跳拒绝应当发生在leader
|
||||
// 退出节点时,让leader上邻居意识到当前环已经没有节点了".
|
||||
if r.Body == nil {
|
||||
if s := h.Ring.State(); len(s.Nodes) <= 1 {
|
||||
http.Error(w, "standalone node", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if len(bytes.TrimSpace(body)) == 0 {
|
||||
if s := h.Ring.State(); len(s.Nodes) <= 1 {
|
||||
http.Error(w, "standalone node", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
@ -448,6 +557,14 @@ func (h *Handler) handleClusterJoin(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "parse join: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Verify the newcomer presents this node's admission key. Without this
|
||||
// check, any client that knows the shared webui password could join and
|
||||
// receive the full cluster picture — the key adds a per-node admission
|
||||
// layer the operator must copy from the sponsor's cluster page.
|
||||
if ji.JoinKey == "" || ji.JoinKey != h.Ring.NodeKey() {
|
||||
http.Error(w, "invalid join key", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
wasSingle := len(h.Ring.State().Nodes) <= 1
|
||||
state := h.Ring.JoinNode(ji)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"state": state})
|
||||
@ -547,7 +664,8 @@ func (h *Handler) handleClusterJoinRing(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Addr string `json:"addr"`
|
||||
Addr string `json:"addr"`
|
||||
JoinKey string `json:"joinKey"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "parse: "+err.Error(), http.StatusBadRequest)
|
||||
@ -557,13 +675,17 @@ func (h *Handler) handleClusterJoinRing(w http.ResponseWriter, r *http.Request)
|
||||
http.Error(w, "addr required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.JoinKey == "" {
|
||||
http.Error(w, "joinKey required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if h.Ring.IsMember() {
|
||||
http.Error(w, "already a multi-node cluster member; leave first", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
jc, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := h.Ring.JoinRingAddr(jc, req.Addr); err != nil {
|
||||
if err := h.Ring.JoinRingAddr(jc, req.Addr, req.JoinKey); err != nil {
|
||||
http.Error(w, "join failed: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
@ -211,7 +211,7 @@ func TestSaveCanvasPublishesRevokeTask(t *testing.T) {
|
||||
})
|
||||
ring := cluster.NewEngine("n1", "n1:7500", "u", "p", "0.1.0", nil,
|
||||
&cluster.AppHandler{}, func(ctx context.Context, next string, tk *cluster.Token) error { return nil },
|
||||
"n1:7500", true)
|
||||
"n1:7500", true, "")
|
||||
h := &Handler{Store: st, Process: pm, WorkDir: dir, User: "admin", Password: "pw", Ring: ring}
|
||||
mux, _ := NewServeMux(h)
|
||||
ts := httptest.NewServer(mux)
|
||||
|
||||
Reference in New Issue
Block a user