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 {