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)
}
}
}