mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-20 08:57:55 +00:00
- Cluster: forwardToNext offline detection (leader+non-leader), WatchLeader 1s heartbeat fallback, 409 for standalone nodes, Node.NodeKey key exchange via token ring, ClusterPeers persistence + auto-rejoin, Forward delegates to forwardToNext (bugfix) - Auth: Basic Auth (flag-creds fast path) + bcrypt users (admin/viewer) + Bearer API keys (read/write/admin scope) - Frontend: UsersView (accounts+API keys), ClusterView (ring/nodeKey/tasks/topology/log), StatusView (group management, per-proxy status), CanvasView (edge toggle/group), PortEdge (disabled/group labels) - API: handlers split (canvas/forwards/users/logs), canvas export/import, forwards group start/stop/assign/delete, cluster endpoints - Docs: comprehensive README rewrite (all flags/APIs/auth/cluster), docs/cluster-api.md (cluster management API reference) - Deploy: run-cluster.sh now 4-node ring + 1 isolated standalone, test-forward.sh updated for 4 nodes - Removed plan.md (design notes consolidated into README + API docs)
252 lines
8.0 KiB
Go
252 lines
8.0 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"webui4frpc/internal/cluster"
|
|
"webui4frpc/internal/process"
|
|
"webui4frpc/internal/store"
|
|
)
|
|
|
|
func newTestHandler(t *testing.T) (*Handler, *httptest.Server) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
st, err := store.New(filepath.Join(dir, "test.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
pm := process.NewManager(process.Options{
|
|
ConfigsDir: filepath.Join(dir, "configs"),
|
|
LogsDir: filepath.Join(dir, "logs"),
|
|
BinaryPath: func() string { return "" },
|
|
Render: func(string) ([]byte, error) { return []byte(`{}`), nil },
|
|
AutoRestart: func(string) bool { return false },
|
|
RestartInterval: func() int { return 5 },
|
|
})
|
|
|
|
h := &Handler{Store: st, Process: pm, WorkDir: dir, User: "admin", Password: "pw"}
|
|
mux, err := NewServeMux(h)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ts := httptest.NewServer(mux)
|
|
t.Cleanup(ts.Close)
|
|
t.Cleanup(func() { _ = st.Close() })
|
|
return h, ts
|
|
}
|
|
|
|
func TestAuthRequired(t *testing.T) {
|
|
_, ts := newTestHandler(t)
|
|
resp, err := http.Get(ts.URL + "/api/manager/status")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d, want 401", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestCanvasRoundTrip(t *testing.T) {
|
|
_, ts := newTestHandler(t)
|
|
client := ts.Client()
|
|
|
|
body := `{
|
|
"locals": [{"name":"web","ip":"127.0.0.1","port":8080,"protocol":"tcp"}],
|
|
"remotes": [{"name":"srv-a","ip":"1.2.3.4","port":7000,"enabled":true}],
|
|
"links": [{"local":"web","remote":"srv-a","remotePort":8080}]
|
|
}`
|
|
req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/manager/canvas", bytes.NewBufferString(body))
|
|
req.SetBasicAuth("admin", "pw")
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("save canvas status = %d", resp.StatusCode)
|
|
}
|
|
|
|
var got canvasData
|
|
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got.Locals) != 1 || got.Locals[0].Name != "web" {
|
|
t.Fatalf("locals = %+v", got.Locals)
|
|
}
|
|
if len(got.Links) != 1 || got.Links[0].RemotePort != 8080 {
|
|
t.Fatalf("links = %+v", got.Links)
|
|
}
|
|
|
|
// GET again to confirm persistence via store.
|
|
req2, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/manager/canvas", nil)
|
|
req2.SetBasicAuth("admin", "pw")
|
|
resp2, err2 := client.Do(req2)
|
|
if err2 != nil {
|
|
t.Fatal(err2)
|
|
}
|
|
defer resp2.Body.Close()
|
|
var got2 canvasData
|
|
_ = json.NewDecoder(resp2.Body).Decode(&got2)
|
|
if len(got2.Remotes) != 1 || got2.Remotes[0].Name != "srv-a" {
|
|
t.Fatalf("remotes after reload = %+v", got2.Remotes)
|
|
}
|
|
}
|
|
|
|
func TestSettingsPutRoundTrip(t *testing.T) {
|
|
_, ts := newTestHandler(t)
|
|
client := ts.Client()
|
|
|
|
// PUT updated settings
|
|
body := `{"autoStartProfiles":false,"restartOnExit":false,"restartIntervalSeconds":12}`
|
|
req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/manager/settings", bytes.NewBufferString(body))
|
|
req.SetBasicAuth("admin", "pw")
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("PUT settings status = %d, want 200", resp.StatusCode)
|
|
}
|
|
|
|
// Confirm response body reflects saved values
|
|
var saved store.Settings
|
|
if err := json.NewDecoder(resp.Body).Decode(&saved); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if saved.AutoStartProfiles || saved.RestartOnExit || saved.RestartIntervalSeconds != 12 {
|
|
t.Fatalf("saved settings = %+v", saved)
|
|
}
|
|
|
|
// GET again to confirm persistence
|
|
req2, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/manager/settings", nil)
|
|
req2.SetBasicAuth("admin", "pw")
|
|
resp2, err2 := client.Do(req2)
|
|
if err2 != nil {
|
|
t.Fatal(err2)
|
|
}
|
|
defer resp2.Body.Close()
|
|
var got store.Settings
|
|
_ = json.NewDecoder(resp2.Body).Decode(&got)
|
|
if got.AutoStartProfiles || got.RestartOnExit || got.RestartIntervalSeconds != 12 {
|
|
t.Fatalf("settings after reload = %+v", got)
|
|
}
|
|
}
|
|
|
|
// TestFetchProxyStatesFromAdminAPI verifies the M3 status path: we mock the
|
|
// frpc admin API (/api/status with Basic Auth) and confirm fetchProxyStates
|
|
// parses per-proxy rows and connStateAdmin derives the right summary.
|
|
func TestFetchProxyStatesFromAdminAPI(t *testing.T) {
|
|
// Mock frpc admin server.
|
|
admin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
u, p, ok := r.BasicAuth()
|
|
if !ok || u != "admin" || p != "pw" {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if r.URL.Path != "/api/status" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string][]proxyState{
|
|
"tcp": {
|
|
{Name: "web1", Type: "tcp", Status: "running", LocalAddr: "127.0.0.1:8080", RemoteAddr: "1.2.3.4:8080"},
|
|
{Name: "web2", Type: "tcp", Status: "check failed", Err: "health check failed"},
|
|
},
|
|
})
|
|
}))
|
|
defer admin.Close()
|
|
u := strings.TrimPrefix(admin.URL, "http://")
|
|
host, portStr, _ := strings.Cut(u, ":")
|
|
port, _ := strconv.Atoi(portStr)
|
|
|
|
states, err := fetchProxyStates(host, port, "admin", "pw")
|
|
if err != nil {
|
|
t.Fatalf("fetchProxyStates: %v", err)
|
|
}
|
|
if len(states) != 2 {
|
|
t.Fatalf("states = %+v", states)
|
|
}
|
|
if states[0].Status != "running" || states[1].Status != "check failed" {
|
|
t.Fatalf("unexpected states = %+v", states)
|
|
}
|
|
|
|
// connStateAdmin: one running -> connected.
|
|
if got := connStateAdmin(states); got != "connected" {
|
|
t.Fatalf("connStateAdmin = %q, want connected", got)
|
|
}
|
|
// All failed -> failed.
|
|
if got := connStateAdmin([]proxyState{{Name: "a", Status: "check failed"}, {Name: "b", Status: "start error"}}); got != "failed" {
|
|
t.Fatalf("connStateAdmin(all failed) = %q, want failed", got)
|
|
}
|
|
// wait start -> connecting.
|
|
if got := connStateAdmin([]proxyState{{Name: "a", Status: "wait start"}}); got != "connecting" {
|
|
t.Fatalf("connStateAdmin(wait start) = %q, want connecting", got)
|
|
}
|
|
}
|
|
|
|
// TestSaveCanvasPublishesRevokeTask: deleting a non-localOnly forward from the
|
|
// canvas publishes a REVOKE task to the ring (owner cancels it cluster-wide).
|
|
func TestSaveCanvasPublishesRevokeTask(t *testing.T) {
|
|
dir := t.TempDir()
|
|
st, err := store.New(filepath.Join(dir, "test.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
pm := process.NewManager(process.Options{
|
|
ConfigsDir: filepath.Join(dir, "configs"), LogsDir: filepath.Join(dir, "logs"),
|
|
BinaryPath: func() string { return "" }, Render: func(string) ([]byte, error) { return []byte(`{}`), nil },
|
|
AutoRestart: func(string) bool { return false }, RestartInterval: func() int { return 5 },
|
|
})
|
|
ring := cluster.NewEngine("n1", "n1:7500", "u", "p", "0.1.0", nil,
|
|
&cluster.AppHandler{}, func(ctx context.Context, next string, tk *cluster.Token) error { return nil },
|
|
"n1:7500", true, "")
|
|
h := &Handler{Store: st, Process: pm, WorkDir: dir, User: "admin", Password: "pw", Ring: ring}
|
|
mux, _ := NewServeMux(h)
|
|
ts := httptest.NewServer(mux)
|
|
defer ts.Close()
|
|
|
|
// establish a cluster forward first via save (non-localOnly)
|
|
body := `{"locals":[{"name":"web","ip":"127.0.0.1","port":8080,"protocol":"tcp"}],
|
|
"remotes":[{"name":"frps1","ip":"10.0.0.1","port":7000,"enabled":true}],
|
|
"links":[{"local":"web","remote":"frps1","remotePort":18081}]}`
|
|
req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/manager/canvas", strings.NewReader(body))
|
|
req.SetBasicAuth("admin", "pw")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
// now save canvas WITHOUT the forward -> should publish revoke
|
|
body2 := `{"locals":[],"remotes":[],"links":[]}`
|
|
req2, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/manager/canvas", strings.NewReader(body2))
|
|
req2.SetBasicAuth("admin", "pw")
|
|
resp2, err := http.DefaultClient.Do(req2)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp2.Body.Close()
|
|
// Ring should now have a pending REVOKE task for web->frps1
|
|
var sawRevoke bool
|
|
for _, tk := range ring.State().PendingList() {
|
|
if tk.Revoke && tk.Local.Name == "web" {
|
|
sawRevoke = true
|
|
}
|
|
}
|
|
if !sawRevoke {
|
|
t.Fatalf("expected revoke task for web, pending=%+v", ring.State().PendingList())
|
|
}
|
|
}
|