feat: M3 cluster page (LB groups + health check status) e2e-verified failover; M6 cache API + version path hardening + frontend cluster methods

This commit is contained in:
2026-08-17 22:44:04 +08:00
parent a842198447
commit 5bd70b90c0
11 changed files with 470 additions and 128 deletions

View File

@ -233,8 +233,27 @@ func fetchFromURL(ctx context.Context, version string) ([]byte, error) {
// EnsureVersion makes sure a given frpc version is cached locally. It follows
// the distribution priority: local cache -> cluster peers -> external URL.
// pathSafeVersion normalizes a requested version and rejects anything that
// could escape the cache dir (e.g. "../../x").
func pathSafeVersion(version string) (string, bool) {
v := strings.TrimPrefix(version, "v")
if v == "" || strings.ContainsAny(v, "/\\") || strings.Contains(v, "..") {
return "", false
}
for _, c := range v {
if (c < '0' || c > '9') && (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && c != '.' && c != '-' && c != '_' {
return "", false
}
}
return v, true
}
func (r *Registry) EnsureVersion(ctx context.Context, version string) (string, error) {
version = strings.TrimPrefix(version, "v")
safe, ok := pathSafeVersion(version)
if !ok {
return "", fmt.Errorf("invalid version %q", version)
}
version = safe
if r.HasVersion(version) {
return r.BinaryPath(version), nil
}
@ -403,3 +422,47 @@ func (r *Registry) SyncFromPeers(ctx context.Context) error {
func (r *Registry) SelfInfo() NodeInfo {
return NodeInfo{Addr: "self", Version: "", Cache: r.CachedVersions()}
}
// CacheEntry describes one locally cached frpc version (for UI + prune).
type CacheEntry struct {
Version string `json:"version"`
Path string `json:"path"`
Size int64 `json:"size"`
ModTime int64 `json:"modTime"`
}
// CacheInfo lists all locally cached frpc versions with metadata.
func (r *Registry) CacheInfo() []CacheEntry {
entries, err := os.ReadDir(r.BinDir)
if err != nil {
return nil
}
var out []CacheEntry
for _, e := range entries {
if !e.IsDir() || !strings.HasPrefix(e.Name(), "frpc-") {
continue
}
ver := strings.TrimPrefix(e.Name(), "frpc-")
bin := filepath.Join(r.BinDir, e.Name(), "frpc")
info, err := os.Stat(bin)
if err != nil {
continue
}
out = append(out, CacheEntry{Version: ver, Path: bin, Size: info.Size(), ModTime: info.ModTime().Unix()})
}
return out
}
// PruneCache removes all cached versions except the newest keep (LRU-ish).
func (r *Registry) PruneCache(keep int) []string {
entries := r.CacheInfo()
sort.Slice(entries, func(i, j int) bool { return entries[i].ModTime > entries[j].ModTime })
var removed []string
for i := keep; i < len(entries); i++ {
dir := filepath.Join(r.BinDir, "frpc-"+entries[i].Version)
if err := os.RemoveAll(dir); err == nil {
removed = append(removed, entries[i].Version)
}
}
return removed
}

View File

@ -120,3 +120,35 @@ func TestRegisterAndDiscover(t *testing.T) {
t.Fatalf("node list = %+v", got)
}
}
func TestCacheInfoAndPrune(t *testing.T) {
dir := t.TempDir()
writeFakeFrpc(t, dir, "0.70.0")
writeFakeFrpc(t, dir, "0.71.0")
reg := NewRegistry(dir)
infos := reg.CacheInfo()
if len(infos) != 2 {
t.Fatalf("cache info = %+v", infos)
}
// prune keep=1 -> removes oldest by modtime (0.70.0 likely older)
removed := reg.PruneCache(1)
_ = removed
if len(reg.CacheInfo()) != 1 {
t.Fatalf("after prune keep=1: %+v", reg.CacheInfo())
}
}
func TestPathSafeVersion(t *testing.T) {
good := []string{"0.71.0", "v0.71.0", "1.2.3-beta", "0.71.0-rc1_amd64"}
for _, v := range good {
if _, ok := pathSafeVersion(v); !ok {
t.Fatalf("expected safe: %q", v)
}
}
bad := []string{"", "../evil", "a/b", "..", `x\y`, "0.7 1.0"}
for _, v := range bad {
if _, ok := pathSafeVersion(v); ok {
t.Fatalf("expected unsafe: %q", v)
}
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -4,8 +4,8 @@
<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-C04K5jB8.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B6NLA8yp.css">
<script type="module" crossorigin src="/assets/index-C9C9_j3w.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C5T3LFGh.css">
</head>
<body>
<div id="app"></div>

View File

@ -89,6 +89,7 @@ func NewServeMux(h *Handler) (http.Handler, error) {
// 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))
// M6: peer-to-peer binary exchange endpoint (Basic Auth, same creds).
// Not under /api so peers hit it directly; auth still applied.
@ -320,6 +321,34 @@ func (h *Handler) handleFrpcBinary(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(w, f)
}
// handleClusterCache lists cached frpc versions (GET) or prunes (POST {keep:N}).
func (h *Handler) handleClusterCache(w http.ResponseWriter, r *http.Request) {
if h.Cluster == nil {
http.Error(w, "cluster registry not enabled", http.StatusNotFound)
return
}
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, map[string]any{"cache": h.Cluster.CacheInfo()})
case http.MethodPost:
var req struct {
Keep int `json:"keep"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "parse json: "+err.Error(), http.StatusBadRequest)
return
}
if req.Keep < 0 || req.Keep > 50 {
http.Error(w, "keep in [0,50]", http.StatusBadRequest)
return
}
removed := h.Cluster.PruneCache(req.Keep)
writeJSON(w, http.StatusOK, map[string]any{"removed": removed, "cache": h.Cluster.CacheInfo()})
default:
methodNotAllowed(w)
}
}
// frpcVersionOf extracts the frpc version from a binary path like
// .../bin/frpc-0.71.0/frpc. Empty when not a versioned cache path.
func frpcVersionOf(binPath string) string {

View File

@ -59,7 +59,7 @@
- [x] 渲染器proxy 输出 loadBalancer + healthCheck 段worker 配置输出 webServer 段frpc >= 0.52 支持)
- [x] 状态页:按 admin API GET /api/status 拉取真实 per-proxy 状态running / check failed / wait start / start error / new / closed未启用 admin 时回退日志推断
- [x] 前端LocalNode/RemoteNode 表单 + StatusView per-proxy 状态与 LB 组徽标vue-tsc 通过)
- [ ] CLUSTER 页展示负载均衡组group+ 健康检查状态(待 M3 后续迭代
- [x] CLUSTER 页展示负载均衡组group+ 健康检查状态(e2e 验证backend 停止→check failed 剔除,恢复→自动收回
### M4 更多代理类型

View File

@ -1,84 +1,94 @@
// HTTP client and API functions for webui4frpc.
import type {
BinaryStatus,
CacheResp,
CanvasData,
ClusterNodesResp,
InstallResult,
Remote,
Settings,
StatusResp,
} from './types'
} from "./types";
class HTTPError extends Error {
status: number
status: number;
constructor(status: number, message: string) {
super(message)
this.status = status
super(message);
this.status = status;
}
}
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(url, {
credentials: 'same-origin',
credentials: "same-origin",
...options,
})
});
if (!response.ok) {
throw new HTTPError(response.status, `HTTP ${response.status}`)
throw new HTTPError(response.status, `HTTP ${response.status}`);
}
const ct = response.headers.get('content-type') || ''
if (ct.includes('application/json')) {
return response.json() as Promise<T>
const ct = response.headers.get("content-type") || "";
if (ct.includes("application/json")) {
return response.json() as Promise<T>;
}
return response.text() as unknown as Promise<T>
return response.text() as unknown as Promise<T>;
}
const json = (body: unknown): RequestInit => ({
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
});
export const api = {
status: () => request<StatusResp>('/api/manager/status'),
status: () => request<StatusResp>("/api/manager/status"),
canvas: () => request<CanvasData>('/api/manager/canvas'),
canvas: () => request<CanvasData>("/api/manager/canvas"),
saveCanvas: (data: CanvasData) =>
request<CanvasData>('/api/manager/canvas', json(data)),
request<CanvasData>("/api/manager/canvas", json(data)),
settings: () => request<Settings>('/api/manager/settings'),
settings: () => request<Settings>("/api/manager/settings"),
saveSettings: (s: Settings) =>
request<Settings>('/api/manager/settings', json(s)),
request<Settings>("/api/manager/settings", json(s)),
binaryStatus: () => request<BinaryStatus>('/api/manager/binary/status'),
binaryStatus: () => request<BinaryStatus>("/api/manager/binary/status"),
installBinary: (version?: string) =>
request<InstallResult>('/api/manager/binary/install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
request<InstallResult>("/api/manager/binary/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: version ? JSON.stringify({ version }) : undefined,
}),
saveRemote: (remote: Remote) =>
request<Remote>('/api/manager/remotes', json(remote)),
request<Remote>("/api/manager/remotes", json(remote)),
deleteRemote: (name: string) =>
request<void>(`/api/manager/remotes/${encodeURIComponent(name)}`, {
method: 'DELETE',
method: "DELETE",
}),
profileStart: (name: string) =>
request<void>(`/api/manager/profiles/${encodeURIComponent(name)}/start`, {
method: 'POST',
method: "POST",
}),
profileStop: (name: string) =>
request<void>(`/api/manager/profiles/${encodeURIComponent(name)}/stop`, {
method: 'POST',
method: "POST",
}),
profileRestart: (name: string) =>
request<void>(
`/api/manager/profiles/${encodeURIComponent(name)}/restart`,
{ method: 'POST' },
),
request<void>(`/api/manager/profiles/${encodeURIComponent(name)}/restart`, {
method: "POST",
}),
profileConfig: (name: string) =>
request<string>(`/api/manager/profiles/${encodeURIComponent(name)}/config`),
profileLogs: (name: string) =>
request<string>(`/api/manager/profiles/${encodeURIComponent(name)}/logs`),
}
// M6: cluster nodes + binary cache management.
clusterNodes: () => request<ClusterNodesResp>("/api/manager/cluster/nodes"),
clusterCache: () => request<CacheResp>("/api/manager/cluster/cache"),
pruneCache: (keep: number) =>
request<CacheResp>("/api/manager/cluster/cache", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ keep }),
}),
};

View File

@ -150,3 +150,26 @@ export interface InstallResult {
path: string;
version: string;
}
// M6: cluster node registry + binary cache.
export interface ClusterNode {
addr: string;
cache?: string[];
version?: string;
}
export interface CacheEntry {
version: string;
path: string;
size: number;
modTime: number;
}
export interface ClusterNodesResp {
nodes: ClusterNode[];
}
export interface CacheResp {
cache: CacheEntry[];
removed?: string[];
}

View File

@ -2,18 +2,147 @@
<div class="cluster-page">
<div class="cluster-top">
<h2 class="page-title">集群</h2>
<span class="page-sub">
负载均衡组group与健康检查health check将在这里展示 M3 功能占位
<span class="page-sub">负载均衡组 + 健康检查状态M3</span>
<button class="refresh-btn" @click="load">刷新</button>
</div>
<div class="cluster-body">
<!-- 远程节点 × LB 组概览 -->
<section class="section" v-if="profileList.length">
<h3 class="section-title">
远程节点 × 负载均衡组
<span class="hint">group 中同一 local 多成员自动负载均衡</span>
</h3>
<div class="node-grid">
<div v-for="p in profileList" :key="p.name" class="node-card">
<div class="nc-head">
<span class="nc-name">{{ p.name }}</span>
<span class="nc-badge" :class="connClass(p.connState)">
{{ connLabel(p.connState) }}
</span>
<span v-if="p.groupCount" class="nc-lb">LB ×{{ p.groupCount }}</span>
</div>
<div class="nc-meta">
<span v-if="p.adminEnabled" class="tag">admin API 状态</span>
<span class="fwd-count">{{ p.forwards.length }} 转发</span>
</div>
<!-- per-proxy 健康状态 -->
<div v-if="p.proxyStates?.length" class="proxy-list">
<div
v-for="ps in p.proxyStates"
:key="ps.name"
class="proxy-row"
:class="psClass(ps.status)"
>
<span class="pr-name">{{ ps.name }}</span>
<span class="pr-type">{{ ps.type }}</span>
<span class="pr-status">{{ psLabel(ps.status) }}</span>
<span v-if="ps.remote_addr" class="pr-addr">{{ ps.remote_addr }}</span>
<span v-if="ps.err" class="pr-err" :title="ps.err">{{ ps.err }}</span>
</div>
</div>
<div v-else class="proxy-empty">
{{ p.adminEnabled ? 'admin API 未返回状态frpc 未连上 frps' : '未启用 admin API回退日志推断' }}
</div>
</div>
</div>
</section>
<!-- 本地服务 LB 组成员 -->
<section class="section" v-if="localStatusList.length">
<h3 class="section-title">本地服务 转发成员</h3>
<div class="ls-list">
<div v-for="ls in localStatusList" :key="ls.local.name" class="ls-card">
<div class="ls-head">
<span class="ls-name">{{ ls.local.name }}</span>
<span class="ls-addr">{{ ls.local.ip }}:{{ ls.local.port }}</span>
<span class="ls-proto">{{ ls.local.protocol }}</span>
<span v-if="ls.local.lbGroup" class="ls-lb">
group: {{ ls.local.lbGroup }}
</span>
</div>
<div class="cluster-body">
<el-empty description="集群功能开发中M3负载均衡与健康检查" />
<div class="ls-targets">
<div v-for="t in ls.targets" :key="t.remote + ':' + t.remotePort" class="ls-tgt">
{{ t.remote }}:{{ t.remotePort }}
<span class="ls-state" :class="{ ok: t.workerState === 'running' }">
{{ t.workerState }}
</span>
</div>
<div v-if="!ls.targets.length" class="ls-none">未转发</div>
</div>
</div>
</div>
</section>
<el-empty v-if="!profileList.length && !localStatusList.length" description="暂无集群配置(先在状态/连接配置页添加 remote 与 local" />
</div>
</div>
</template>
<script setup lang="ts">
// M3 集群页骨架group / groupKey 多后端负载均衡 + 健康检查tcp/http将在此呈现。
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { api } from '../api'
import type { StatusResp } from '../types'
const status = ref<StatusResp | null>(null)
let timer: number | null = null
const profileList = computed(() => status.value?.profiles ?? [])
const localStatusList = computed(() => status.value?.localStatus ?? [])
const load = async () => {
try {
status.value = await api.status()
} catch {
// keep last on transient errors
}
}
const connLabel = (s?: string) => {
switch (s) {
case 'connected': return '已连接'
case 'connecting': return '连接中'
case 'failed': return '连接失败'
case 'disabled': return '已停用'
default: return '未启动'
}
}
const connClass = (s?: string) => {
switch (s) {
case 'connected': return 'ok'
case 'connecting': return 'pending'
case 'failed': return 'err'
default: return ''
}
}
const psLabel = (s?: string) => {
switch (s) {
case 'running': return '运行中'
case 'wait start': return '等待启动'
case 'start error': return '启动错误'
case 'check failed': return '健康检查失败'
case 'closed': return '已关闭'
default: return '新建'
}
}
const psClass = (s?: string) => {
switch (s) {
case 'running': return 'ok'
case 'check failed':
case 'start error': return 'err'
case 'wait start': return 'pending'
default: return ''
}
}
onMounted(() => {
load()
timer = window.setInterval(load, 8000)
})
onBeforeUnmount(() => {
if (timer !== null) { clearInterval(timer); timer = null }
})
</script>
<style scoped lang="scss">
@ -30,16 +159,72 @@
gap: 12px;
margin-bottom: 16px;
}
.page-title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.page-sub {
color: $color-text-muted;
.page-title { margin: 0; font-size: 18px; font-weight: 600; }
.page-sub { color: $color-text-muted; font-size: 13px; }
.refresh-btn {
margin-left: auto;
border: 1px solid $color-border;
border-radius: 6px;
padding: 3px 12px;
background: #fff;
cursor: pointer;
font-size: 13px;
&:hover { background: $color-bg-hover; }
}
.cluster-body {
flex: 1;
.section { margin-bottom: 20px; }
.section-title {
margin: 0 0 10px;
font-size: 15px;
font-weight: 600;
.hint { font-size: 12px; font-weight: 400; color: $color-text-muted; margin-left: 8px; }
}
.node-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 12px;
}
.node-card {
border: 1px solid $color-border-light;
border-radius: 10px;
padding: 12px 14px;
background: #fff;
}
.nc-head { display: flex; align-items: center; gap: 8px; }
.nc-name { font-weight: 600; }
.nc-badge { font-size: 12px; padding: 1px 8px; border-radius: 8px; }
.nc-badge.ok { background: #f0f9eb; color: $color-success; }
.nc-badge.pending { background: #ecf5ff; color: #409eff; }
.nc-badge.err { background: #fef0f0; color: $color-danger; }
.nc-badge:not(.ok):not(.pending):not(.err) { background: #f2f3f5; color: $color-text-muted; }
.nc-lb { margin-left: auto; font-size: 11px; background: rgba(64,158,255,.12); color: #409eff; border-radius: 10px; padding: 1px 8px; }
.nc-meta { margin: 6px 0 8px; display: flex; gap: 8px; align-items: center; }
.tag { font-size: 11px; background: #ecf5ff; color: #409eff; border-radius: 6px; padding: 1px 6px; }
.fwd-count { font-size: 12px; color: $color-text-muted; }
.proxy-list { display: flex; flex-direction: column; gap: 4px; }
.proxy-row {
display: flex; align-items: center; gap: 8px;
font-size: 12px; border: 1px solid $color-border-lighter;
border-radius: 8px; padding: 3px 8px;
}
.proxy-row.ok { border-left: 3px solid $color-success; }
.proxy-row.pending { border-left: 3px solid #409eff; }
.proxy-row.err { border-left: 3px solid $color-danger; }
.pr-name { font-weight: 600; }
.pr-type { font-size: 11px; color: $color-text-muted; }
.pr-status { color: $color-text-secondary; }
.pr-addr { margin-left: auto; font-size: 11px; color: $color-text-muted; }
.pr-err { margin-left: auto; font-size: 11px; color: $color-danger; max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.proxy-empty { font-size: 12px; color: $color-text-muted; }
.ls-list { display: flex; flex-direction: column; gap: 8px; }
.ls-card { border: 1px solid $color-border-light; border-radius: 10px; padding: 10px 14px; background: #fafafa; }
.ls-head { display: flex; align-items: center; gap: 8px; }
.ls-name { font-weight: 600; }
.ls-addr { font-size: 12px; color: $color-text-secondary; }
.ls-proto { font-size: 11px; background: #ecf5ff; color: #409eff; border-radius: 6px; padding: 1px 6px; }
.ls-lb { font-size: 11px; background: rgba(64,158,255,.12); color: #409eff; border-radius: 6px; padding: 1px 6px; }
.ls-targets { margin-top: 6px; display: flex; flex-direction: column; gap: 4px; }
.ls-tgt { font-size: 13px; display: flex; align-items: center; gap: 6px; }
.ls-state { margin-left: auto; font-size: 12px; color: $color-danger; }
.ls-state.ok { color: $color-success; }
.ls-none { color: $color-text-light; font-size: 12px; }
</style>