diff --git a/panel-go/clone_equivalence_test.go b/panel-go/clone_equivalence_test.go new file mode 100644 index 0000000..11839df --- /dev/null +++ b/panel-go/clone_equivalence_test.go @@ -0,0 +1,92 @@ +package main + +// Item 22: the reflective Clone must be a perfect substitute for the +// JSON round-trip clone — byte-identical serialization on the real +// seed database, and fully independent of the original afterwards. + +import ( + "bytes" + "encoding/json" + "os" + "testing" +) + +func TestCloneEquivalenceOnSeed(t *testing.T) { + raw, err := os.ReadFile("difftest/seed.json") + if err != nil { + t.Fatal(err) + } + var d Data + if err := unmarshalJSONUseNumber(raw, &d); err != nil { + t.Fatal(err) + } + fast := d.Clone() + slow := d.cloneViaJSON() + fastJSON, err := marshalJSON(fast) + if err != nil { + t.Fatal(err) + } + slowJSON, err := marshalJSON(slow) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(fastJSON, slowJSON) { + t.Fatalf("clone mismatch:\nfast: %.400s\nslow: %.400s", fastJSON, slowJSON) + } + origJSON, err := marshalJSON(&d) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(fastJSON, origJSON) { + t.Fatal("clone must serialize identically to the source data") + } + // Deep independence: mutating the clone at several depths must not + // leak into the source. + if len(fast.Customers) > 0 { + fast.Customers[0].Name = "MUTATED" + if fast.Customers[0].Credentials != nil { + fast.Customers[0].Credentials.RelayPassword = "MUTATED" + } + } + if len(fast.Nodes) > 0 { + fast.Nodes[0].Name = "MUTATED" + } + for key := range fast.KeyStore { + fast.KeyStore[key].PrivateKey = "MUTATED" + break + } + afterJSON, err := marshalJSON(&d) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(afterJSON, origJSON) { + t.Fatal("clone mutation leaked into the source data") + } +} + +func TestCloneEquivalenceSynthetic(t *testing.T) { + d := perfSeedData() + // Add the shapes the perf seed lacks: activity detail with nested + // generic values, keyStore entries, unknown (Extra) fields. + d.Activity = []*Activity{{ID: "act_1", At: NowISO(), Actor: "admin", Action: "x", Target: "y", + Detail: map[string]any{"n": json.Number("42"), "nested": map[string]any{"list": []any{"a", json.Number("1.5"), true, nil}}}}} + d.KeyStore["reality-nod_0"] = &KeyEntry{PrivateKey: "priv", PublicKey: "pub", UpdatedAt: NowISO()} + fastJSON, err := marshalJSON(d.Clone()) + if err != nil { + t.Fatal(err) + } + slowJSON, err := marshalJSON(d.cloneViaJSON()) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(fastJSON, slowJSON) { + t.Fatalf("synthetic clone mismatch:\nfast: %.300s\nslow: %.300s", fastJSON, slowJSON) + } + if d.Clone() == nil || EmptyData().Clone() == nil { + t.Fatal("clone of empty data must be non-nil") + } + var nilData *Data + if nilData.Clone() != nil { + t.Fatal("clone of nil must be nil") + } +} diff --git a/panel-go/handlers.go b/panel-go/handlers.go index e7765a5..a32c82e 100644 --- a/panel-go/handlers.go +++ b/panel-go/handlers.go @@ -848,19 +848,29 @@ type adminAuth struct { User *User } -// CurrentAdmin is currentAdmin. +// CurrentAdmin is currentAdmin. Since round-3 item 22 it resolves the +// session's user under a read-only View instead of cloning the whole +// database: admin auth runs on EVERY request, and the user table is +// tiny. The returned User is a copy the caller may read freely. func (a *App) CurrentAdmin(r *http.Request) *adminAuth { session := a.getSessions().Get(r) if session == nil { return nil } - data := a.store.Snapshot() - for _, user := range data.Users { - if user.ID == session.UserID && user.Status == "active" { - return &adminAuth{Session: session, User: user} + var found *User + a.store.View(func(d *Data) { + for _, user := range d.Users { + if user.ID == session.UserID && user.Status == "active" { + copied := *user + found = &copied + return + } } + }) + if found == nil { + return nil } - return nil + return &adminAuth{Session: session, User: found} } // RequireAdmin is requireAdmin: sends 401/403 itself and returns nil. diff --git a/panel-go/perf_bench_test.go b/panel-go/perf_bench_test.go new file mode 100644 index 0000000..d7eae85 --- /dev/null +++ b/panel-go/perf_bench_test.go @@ -0,0 +1,107 @@ +package main + +// Benchmarks for round-3 item 22 (per-request full-clone cost). Not run +// by `go test` without -bench. The seed is synthetic data at production +// scale (~11 servers / 26 customers / 25 nodes / ~100 bindings). + +import ( + "encoding/json" + "fmt" + "net/http/httptest" + "path/filepath" + "strings" + "testing" +) + +func perfSeedData() *Data { + d := EmptyData() + d.Users = []*User{{ID: "usr_1", Username: "admin", PasswordHash: "x", Role: "owner", Status: "active", CreatedAt: NowISO()}} + for i := 0; i < 11; i++ { + d.Servers = append(d.Servers, &Server{ + ID: fmt.Sprintf("srv_%d", i), Name: fmt.Sprintf("S%d", i), PublicAddress: "203.0.113.1", + Status: "online", LastSeenAt: StrPtr(NowISO()), PortRangeStart: 20000, PortRangeEnd: 50000, + Labels: []string{}, CreatedAt: NowISO(), UpdatedAt: NowISO(), + }) + } + for i := 0; i < 26; i++ { + d.Customers = append(d.Customers, &Customer{ + ID: fmt.Sprintf("cus_%d", i), Name: fmt.Sprintf("C%d", i), Status: "active", + SubscriptionToken: fmt.Sprintf("tok-%032d", i), + Credentials: &Credentials{ClientID: NewUUID(), RelayPassword: "FRYXGBkaGxwdHh8gISIjJA==", ShortID: "abcd0001"}, + Tags: []string{}, CreatedAt: NowISO(), UpdatedAt: NowISO(), + }) + } + protocols := []string{"vless-reality-vision", "shadowsocks-2022-aes128", "socks5-auth", "vless-ws", "hysteria2"} + for i := 0; i < 25; i++ { + node := &Node{ + ID: fmt.Sprintf("nod_%d", i), Name: fmt.Sprintf("N%d", i), ServerID: fmt.Sprintf("srv_%d", i%11), + Protocol: protocols[i%len(protocols)], Topology: "direct", NetworkMode: "ipv4", Port: 23000 + i, + Status: "active", ResourceID: fmt.Sprintf("res_%d", i), Artifacts: map[string]json.RawMessage{}, + } + if node.Protocol == "socks5-auth" { + node.SocksUser, node.SocksPassword = "ngs_fixed", "fixedpass" + } + if IsRealityProtocol(node.Protocol) { + node.Reality = &RealityConfig{KeyID: fmt.Sprintf("reality-nod_%d", i), ServerName: "www.tesla.com", DestPort: 443} + } + d.Nodes = append(d.Nodes, node) + for j := 0; j < 4; j++ { + d.NodeClients = append(d.NodeClients, &NodeClient{ + ID: fmt.Sprintf("nc_%d_%d", i, j), NodeID: node.ID, + CustomerID: fmt.Sprintf("cus_%d", (i+j)%26), Status: "active", + CreatedAt: NowISO(), UpdatedAt: NowISO(), + }) + } + } + return d +} + +func BenchmarkCloneJSONRoundTrip(b *testing.B) { + d := perfSeedData() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = d.cloneViaJSON() + } +} + +func BenchmarkCloneFast(b *testing.B) { + d := perfSeedData() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = d.Clone() + } +} + +func BenchmarkAdminGET(b *testing.B) { + cookieSecure = false + store := NewStore(filepath.Join(b.TempDir(), "nexusgate.json")) + if err := store.Init(); err != nil { + b.Fatal(err) + } + seed := perfSeedData() + seed.Users[0].PasswordHash = HashSecret("password12345") + if _, err := store.Transaction(func(d *Data) (any, error) { *d = *seed; return nil, nil }); err != nil { + b.Fatal(err) + } + app := NewApp(store, NewSessionManager(12), NewOrchestrator(store)) + login := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"username":"admin","password":"password12345"}`)) + rec := httptest.NewRecorder() + app.ServeHTTP(rec, login) + if rec.Code != 200 { + b.Fatalf("login %d", rec.Code) + } + cookie := strings.Split(rec.Header().Get("Set-Cookie"), ";")[0] + for _, path := range []string{"/api/overview", "/api/servers", "/api/customers", "/api/nodes", "/api/jobs"} { + b.Run(strings.TrimPrefix(path, "/api/"), func(b *testing.B) { + for i := 0; i < b.N; i++ { + req := httptest.NewRequest("GET", path, nil) + req.Header.Set("Cookie", cookie) + rec := httptest.NewRecorder() + app.ServeHTTP(rec, req) + if rec.Code != 200 { + b.Fatalf("%s -> %d", path, rec.Code) + } + } + }) + } +} diff --git a/panel-go/subscriptions.go b/panel-go/subscriptions.go index c7cee17..fd07714 100644 --- a/panel-go/subscriptions.go +++ b/panel-go/subscriptions.go @@ -118,9 +118,15 @@ func NodeClientURI(d *Data, nc *NodeClient) string { socksUser := NodeEmail(nc.CustomerID) relayPassword := creds.RelayPassword if node.Protocol == "socks5-auth" { - EnsureSocksCredentials(node) - socksUser = node.SocksUser - relayPassword = node.SocksPassword + socksUser, relayPassword = node.SocksUser, node.SocksPassword + if socksUser == "" || relayPassword == "" { + // Legacy record the load migration has not reached (only + // possible on an in-memory fixture): mint into a throwaway + // copy — a read path must never mutate the node record. + probe := *node + EnsureSocksCredentials(&probe) + socksUser, relayPassword = probe.SocksUser, probe.SocksPassword + } } uri, ok := BuildClientURI(BuildClientURIParams{ Protocol: node.Protocol, diff --git a/panel-go/types.go b/panel-go/types.go index e1ff430..6d618c5 100644 --- a/panel-go/types.go +++ b/panel-go/types.go @@ -254,8 +254,85 @@ func (d Data) MarshalJSON() ([]byte, error) { return mergeMarshal(plain(d), d.Extra) } -// Clone is structuredClone(this.data). +// Clone is structuredClone(this.data). Since round-3 item 22 it is a +// reflective deep copy: the old JSON marshal/unmarshal round trip cost +// a full serialize + parse of the whole database (with a per-record +// map dance for unknown fields) on EVERY store read — twice per admin +// request — and dominated panel latency on the router's CPU. The data +// graph is plain exported structs/slices/maps/strings/numbers, which +// reflection copies exactly; TestCloneEquivalence proves byte-identical +// output against cloneViaJSON on the real seed database. func (d *Data) Clone() *Data { + if d == nil { + return nil + } + return cloneReflectValue(reflect.ValueOf(d)).Interface().(*Data) +} + +// cloneReflectValue deep-copies v. Structs are shallow-copied first (so +// any unexported field keeps its value) and every exported field is +// then replaced by its deep copy; []byte (incl. json.RawMessage) is +// copied verbatim; scalars pass through. +func cloneReflectValue(v reflect.Value) reflect.Value { + if !v.IsValid() { + return v + } + switch v.Kind() { + case reflect.Pointer: + if v.IsNil() { + return v + } + out := reflect.New(v.Type().Elem()) + out.Elem().Set(cloneReflectValue(v.Elem())) + return out + case reflect.Interface: + if v.IsNil() { + return v + } + return reflect.ValueOf(cloneReflectValue(v.Elem()).Interface()) + case reflect.Slice: + if v.IsNil() { + return v + } + if v.Type().Elem().Kind() == reflect.Uint8 { + out := reflect.MakeSlice(v.Type(), v.Len(), v.Len()) + reflect.Copy(out, v) + return out + } + out := reflect.MakeSlice(v.Type(), v.Len(), v.Len()) + for i := 0; i < v.Len(); i++ { + out.Index(i).Set(cloneReflectValue(v.Index(i))) + } + return out + case reflect.Map: + if v.IsNil() { + return v + } + out := reflect.MakeMapWithSize(v.Type(), v.Len()) + iter := v.MapRange() + for iter.Next() { + out.SetMapIndex(cloneReflectValue(iter.Key()), cloneReflectValue(iter.Value())) + } + return out + case reflect.Struct: + out := reflect.New(v.Type()).Elem() + out.Set(v) + for i := 0; i < v.NumField(); i++ { + if v.Type().Field(i).PkgPath != "" { + continue // unexported: kept from the shallow copy + } + out.Field(i).Set(cloneReflectValue(v.Field(i))) + } + return out + default: + return v + } +} + +// cloneViaJSON is the original Clone implementation: a full JSON +// marshal/unmarshal round trip. Kept for the equivalence test and the +// item-22 benchmarks that quantify what the reflective Clone replaced. +func (d *Data) cloneViaJSON() *Data { raw, err := marshalJSON(d) if err != nil { panic(err)