mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-20 00:47:57 +00:00
feat: M6 stage B - cluster heartbeat, new-node bootstrap auto-pull, peer-first install (e2e verified)
This commit is contained in:
@ -11,6 +11,7 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@ -311,3 +312,94 @@ func extractFrpcBytes(tarGz []byte) ([]byte, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Discover periodically polls registered peers for their cached versions and
|
||||
// refreshes the node registry (M6 stage B heartbeats). Run in a goroutine.
|
||||
func (r *Registry) Discover(ctx context.Context, interval time.Duration) {
|
||||
tick := time.NewTicker(interval)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
peers := r.PeerList()
|
||||
for _, p := range peers {
|
||||
ni, err := r.queryNode(ctx, p)
|
||||
if err != nil {
|
||||
continue // peer unreachable; keep last known info
|
||||
}
|
||||
r.UpdateNodeInfo(ni)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// queryNode asks one peer for its node info (cached frpc versions).
|
||||
func (r *Registry) queryNode(ctx context.Context, p *PeerTarget) (*NodeInfo, error) {
|
||||
url := fmt.Sprintf("http://%s/api/manager/cluster/nodes", p.Addr)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.User != "" || p.Pass != "" {
|
||||
req.SetBasicAuth(p.User, p.Pass)
|
||||
}
|
||||
cli := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := cli.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return nil, fmt.Errorf("node query HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var payload struct {
|
||||
Nodes []struct {
|
||||
Addr string `json:"addr"`
|
||||
Cache []string `json:"cache"`
|
||||
Version string `json:"version"`
|
||||
} `json:"nodes"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(payload.Nodes) == 0 {
|
||||
return nil, fmt.Errorf("peer returned no nodes")
|
||||
}
|
||||
self := payload.Nodes[0]
|
||||
return &NodeInfo{ID: self.Addr, Addr: self.Addr, Version: self.Version, Cache: self.Cache}, nil
|
||||
}
|
||||
|
||||
// SyncFromPeers ensures the locally cached frpc set covers the latest version
|
||||
// each known peer offers (new-member auto propagation). It leaves version
|
||||
// moves to EnsureVersion on demand; here we just warm the cache.
|
||||
func (r *Registry) SyncFromPeers(ctx context.Context) error {
|
||||
// First refresh node info from registered peers so the cache set reflects
|
||||
// what neighbors actually offer (a fresh join may have no info yet).
|
||||
for _, p := range r.PeerList() {
|
||||
if ni, err := r.queryNode(ctx, p); err == nil {
|
||||
r.UpdateNodeInfo(ni)
|
||||
}
|
||||
}
|
||||
for _, n := range r.NodeList() {
|
||||
if n.Version == "" {
|
||||
continue
|
||||
}
|
||||
if r.HasVersion(n.Version) {
|
||||
continue
|
||||
}
|
||||
// EnsureVersion internally iterates registered peers (and falls back
|
||||
// to the external URL), so no peer selection is needed here.
|
||||
if _, err := r.EnsureVersion(ctx, n.Version); err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SelfInfo returns this node's own info for reporting.
|
||||
func (r *Registry) SelfInfo() NodeInfo {
|
||||
return NodeInfo{Addr: "self", Version: "", Cache: r.CachedVersions()}
|
||||
}
|
||||
|
||||
122
internal/cluster/registry_test.go
Normal file
122
internal/cluster/registry_test.go
Normal file
@ -0,0 +1,122 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writeFakeFrpc writes an executable file pretending to be frpc (any bytes
|
||||
// work for tests that don't run --version).
|
||||
func writeFakeFrpc(t *testing.T, dir, version string) string {
|
||||
t.Helper()
|
||||
binDir := filepath.Join(dir, "frpc-"+version)
|
||||
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := filepath.Join(binDir, "frpc")
|
||||
if err := os.WriteFile(bin, []byte("#!/bin/sh\necho frpc "+version+"\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return bin
|
||||
}
|
||||
|
||||
func TestCachedVersionsAndProvide(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeFakeFrpc(t, dir, "0.71.0")
|
||||
reg := NewRegistry(dir)
|
||||
vs := reg.CachedVersions()
|
||||
if len(vs) != 1 || vs[0] != "0.71.0" {
|
||||
t.Fatalf("cached versions = %+v", vs)
|
||||
}
|
||||
if !reg.HasVersion("0.71.0") || reg.HasVersion("0.99.0") {
|
||||
t.Fatalf("HasVersion wrong")
|
||||
}
|
||||
p, sum, err := reg.Provide("0.71.0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p == "" || len(sum) != 64 {
|
||||
t.Fatalf("provide p=%s sum=%s", p, sum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureVersionPrefersLocalAndPeer(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg := NewRegistry(dir)
|
||||
|
||||
peerBin := []byte("#!/bin/sh\necho fake-frpc\n")
|
||||
sum := sha256.Sum256(peerBin)
|
||||
peerSum := hex.EncodeToString(sum[:])
|
||||
|
||||
peerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/frpc/0.71.0" {
|
||||
w.Header().Set("X-Frpc-SHA256", peerSum)
|
||||
_, _ = w.Write(peerBin)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer peerSrv.Close()
|
||||
host := peerSrv.Listener.Addr().String()
|
||||
|
||||
reg.RegisterPeer("peer1", host, "u", "p")
|
||||
if reg.HasVersion("0.71.0") {
|
||||
t.Fatal("should not be cached yet")
|
||||
}
|
||||
binPath, err := reg.EnsureVersion(context.Background(), "0.71.0")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureVersion: %v", err)
|
||||
}
|
||||
if !reg.HasVersion("0.71.0") {
|
||||
t.Fatal("EnsureVersion should have stored binary")
|
||||
}
|
||||
got, err := reg.EnsureVersion(context.Background(), "0.71.0")
|
||||
if err != nil || got != binPath {
|
||||
t.Fatalf("local hit = %q err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchFromPeerChecksumMismatch(t *testing.T) {
|
||||
peerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("corrupted"))
|
||||
}))
|
||||
defer peerSrv.Close()
|
||||
_, err := fetchFromPeer(context.Background(), &PeerTarget{Addr: peerSrv.Listener.Addr().String()}, "1.2.3", "0000000000000000000000000000000000000000000000000000000000000000")
|
||||
if err == nil {
|
||||
t.Fatal("expected checksum mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterAndDiscover(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeFakeFrpc(t, dir, "0.70.0")
|
||||
reg := NewRegistry(dir)
|
||||
|
||||
peerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/manager/cluster/nodes" {
|
||||
_, _ = w.Write([]byte(`{"nodes":[{"addr":"10.0.0.9:7500","cache":["0.71.0"],"version":"0.71.0"}]}`))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer peerSrv.Close()
|
||||
|
||||
reg.RegisterPeer("peerX", peerSrv.Listener.Addr().String(), "u", "p")
|
||||
ni, err := reg.queryNode(context.Background(), reg.PeerList()[0])
|
||||
if err != nil {
|
||||
t.Fatalf("queryNode: %v", err)
|
||||
}
|
||||
if ni.Version != "0.71.0" || len(ni.Cache) != 1 {
|
||||
t.Fatalf("node info = %+v", ni)
|
||||
}
|
||||
reg.UpdateNodeInfo(ni)
|
||||
if got := reg.NodeList(); len(got) != 1 || got[0].Addr != "10.0.0.9:7500" {
|
||||
t.Fatalf("node list = %+v", got)
|
||||
}
|
||||
}
|
||||
@ -9,6 +9,7 @@ import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -38,6 +39,9 @@ type Handler struct {
|
||||
SyncWorkers func()
|
||||
// Cluster is the peer-to-peer binary registry (M6). Nil disables M6 routes.
|
||||
Cluster *cluster.Registry
|
||||
// SelfAddr is this node's reachable listen address (from -addr), used for
|
||||
// cluster discovery so peers can reach back for binary exchange.
|
||||
SelfAddr string
|
||||
}
|
||||
|
||||
const (
|
||||
@ -266,21 +270,21 @@ func (h *Handler) handleClusterNodes(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "cluster registry not enabled", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
nodes := h.Cluster.NodeList()
|
||||
// Self node goes first so a querier can identify us by Nodes[0].
|
||||
type nodeResp struct {
|
||||
Addr string `json:"addr"`
|
||||
Cache []string `json:"cache,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
out := make([]nodeResp, 0, len(nodes))
|
||||
for _, n := range nodes {
|
||||
out := make([]nodeResp, 0, 1+len(h.Cluster.NodeList()))
|
||||
ver := ""
|
||||
if h.BinaryPath != nil {
|
||||
ver = frpcVersionOf(h.BinaryPath())
|
||||
}
|
||||
out = append(out, nodeResp{Addr: h.SelfAddr, Cache: h.Cluster.CachedVersions(), Version: ver})
|
||||
for _, n := range h.Cluster.NodeList() {
|
||||
out = append(out, nodeResp{Addr: n.Addr, Version: n.Version, Cache: n.Cache})
|
||||
}
|
||||
out = append(out, nodeResp{
|
||||
Addr: "self",
|
||||
Cache: h.Cluster.CachedVersions(),
|
||||
Version: currentVersion(),
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"nodes": out})
|
||||
}
|
||||
|
||||
@ -312,9 +316,15 @@ func (h *Handler) handleFrpcBinary(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.Copy(w, f)
|
||||
}
|
||||
|
||||
// currentVersion returns the app version for cluster node reporting.
|
||||
func currentVersion() string {
|
||||
return "0.1.0"
|
||||
// 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 {
|
||||
dir := filepath.Dir(binPath)
|
||||
base := filepath.Base(dir)
|
||||
if strings.HasPrefix(base, "frpc-") {
|
||||
return strings.TrimPrefix(base, "frpc-")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// connStateOf derives the frps connection state from the local worker process
|
||||
|
||||
Reference in New Issue
Block a user