mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-20 08:57:55 +00:00
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:
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
76
internal/httpapi/dist/assets/index-C04K5jB8.js
vendored
76
internal/httpapi/dist/assets/index-C04K5jB8.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
76
internal/httpapi/dist/assets/index-C9C9_j3w.js
vendored
Normal file
76
internal/httpapi/dist/assets/index-C9C9_j3w.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
internal/httpapi/dist/index.html
vendored
4
internal/httpapi/dist/index.html
vendored
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user