package agentd import ( "context" "encoding/json" "os" "path/filepath" "strings" "sync" "testing" "time" agentv1 "github.com/wangjia/pangolin/server/internal/pb/agentv1" ) // fakeRestarter counts Restart calls. type fakeRestarter struct { mu sync.Mutex n int // Restart 次数(冷启动) reloads int // Reload 次数(热重载) } func (f *fakeRestarter) Restart(context.Context) error { f.mu.Lock() f.n++ f.mu.Unlock() return nil } func (f *fakeRestarter) Reload(context.Context) error { f.mu.Lock() f.reloads++ f.mu.Unlock() return nil } func (f *fakeRestarter) count() int { f.mu.Lock() defer f.mu.Unlock() return f.n } func (f *fakeRestarter) reloadCount() int { f.mu.Lock() defer f.mu.Unlock() return f.reloads } func testConfig(t *testing.T) Config { t.Helper() dir := t.TempDir() return Config{ ControlPlaneAddr: "bufnet", StateDir: dir, SingboxConfigPath: filepath.Join(dir, "singbox", "config.json"), DeriveKey: "test-derive-key", DebounceWindow: 20 * time.Millisecond, Insecure: true, }.withDefaults() } func sampleSnapshot(creds ...*agentv1.Credential) *agentv1.ConfigSnapshot { return &agentv1.ConfigSnapshot{ ConfigVersion: 7, Credentials: creds, Reality: &agentv1.RealityInbound{ ListenPort: 11443, PrivateKey: "pk", ShortID: "deadbeef", ServerName: "www.apple.com", HandshakeServer: "www.apple.com", HandshakePort: 443, }, Hy2: &agentv1.Hy2Inbound{ListenPort: 443, Masquerade: "https://www.bing.com", CertPath: "/c", KeyPath: "/k"}, LastCommandID: 3, } } func TestRenderContainsCredentials(t *testing.T) { sb := NewSingBox(testConfig(t), nil) sb.ApplyConfig(sampleSnapshot( &agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}, &agentv1.Credential{DpUUID: "bbbb", Protocol: agentv1.ProtocolReality}, &agentv1.Credential{DpUUID: "cccc", Protocol: agentv1.ProtocolHy2}, ), true) data, err := sb.RenderConfig() if err != nil { t.Fatal(err) } s := string(data) // REALITY inbound: aaaa + bbbb as VLESS uuids, with flow; cccc must NOT be there. for _, want := range []string{`"uuid": "aaaa"`, `"uuid": "bbbb"`, `"flow": "xtls-rprx-vision"`} { if !strings.Contains(s, want) { t.Errorf("rendered config missing %q\n%s", want, s) } } if strings.Contains(s, `"uuid": "cccc"`) { t.Errorf("hy2-only credential cccc leaked into REALITY users") } // Hy2 inbound: derived passwords for aaaa + cccc, never the raw dp_uuid as pw. var cfg map[string]any if err := json.Unmarshal(data, &cfg); err != nil { t.Fatalf("rendered config is not valid JSON: %v", err) } if !strings.Contains(s, DeriveHy2Password("aaaa", "test-derive-key")) { t.Error("hy2 password for aaaa not found") } if !strings.Contains(s, DeriveHy2Password("cccc", "test-derive-key")) { t.Error("hy2 password for cccc not found") } } func TestRevokeRemovesFromRender(t *testing.T) { sb := NewSingBox(testConfig(t), nil) sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "aaaa", Protocol: agentv1.ProtocolBoth}), true) sb.Revoke("aaaa") data, _ := sb.RenderConfig() if strings.Contains(string(data), "aaaa") { t.Errorf("revoked credential still present:\n%s", data) } if sb.Has("aaaa") { t.Error("Has reports revoked credential as present") } } func TestRotateGraceBothPresentThenOldExpires(t *testing.T) { cfg := testConfig(t) sb := NewSingBox(cfg, nil) now := time.Unix(1000, 0) sb.clock = func() time.Time { return now } sb.ApplyConfig(sampleSnapshot(&agentv1.Credential{DpUUID: "old", Protocol: agentv1.ProtocolBoth}), true) grace := int64(1005) sb.Rotate("old", &Cred{DpUUID: "new", Protocol: agentv1.ProtocolBoth}, grace) // During grace both coexist. if !sb.Has("old") || !sb.Has("new") { t.Fatalf("expected both old+new during grace; old=%v new=%v", sb.Has("old"), sb.Has("new")) } // After grace, sweep removes old, keeps new. now = time.Unix(1006, 0) removed := sb.sweepExpired() if len(removed) != 1 || removed[0] != "old" { t.Fatalf("expected old removed by sweep, got %v", removed) } if sb.Has("old") || !sb.Has("new") { t.Fatalf("after grace: old should be gone, new present; old=%v new=%v", sb.Has("old"), sb.Has("new")) } } func TestStateRecovery(t *testing.T) { cfg := testConfig(t) now := time.Unix(2000, 0) // First manager: add creds (one already expired), persist via mutation. sb1 := NewSingBox(cfg, nil) sb1.clock = func() time.Time { return now } sb1.Upsert(&Cred{DpUUID: "live", Protocol: agentv1.ProtocolBoth, ExpiresAt: 3000}) sb1.Upsert(&Cred{DpUUID: "dead", Protocol: agentv1.ProtocolBoth, ExpiresAt: 1000}) // already expired // state.json must contain ONLY dp_uuid + expires_at — no identity fields. raw, err := os.ReadFile(cfg.StatePath()) if err != nil { t.Fatal(err) } assertNoIdentityFields(t, raw) // Second manager (simulated kill -9 + restart) restores from disk. sb2 := NewSingBox(cfg, nil) sb2.clock = func() time.Time { return now } if err := sb2.LoadState(); err != nil { t.Fatal(err) } if !sb2.Has("live") { t.Error("live credential not recovered from state.json") } if sb2.Has("dead") { t.Error("expired credential should be dropped on load") } // Recovered creds default to BOTH + default flow until Register reconciles. for _, c := range sb2.Snapshot() { if c.DpUUID == "live" && (c.Protocol != agentv1.ProtocolBoth || c.Flow != DefaultFlow) { t.Errorf("recovered cred has unexpected defaults: %+v", c) } } } func TestTTLSweepReportsExpiry(t *testing.T) { cfg := testConfig(t) sb := NewSingBox(cfg, nil) now := time.Unix(5000, 0) sb.clock = func() time.Time { return now } var mu sync.Mutex var reported []string sb.OnExpire = func(dp string) { mu.Lock(); reported = append(reported, dp); mu.Unlock() } sb.Upsert(&Cred{DpUUID: "x", Protocol: agentv1.ProtocolBoth, ExpiresAt: 4999}) sb.Upsert(&Cred{DpUUID: "y", Protocol: agentv1.ProtocolBoth, ExpiresAt: 9999}) removed := sb.sweepExpired() if len(removed) != 1 || removed[0] != "x" { t.Fatalf("sweep removed = %v, want [x]", removed) } mu.Lock() defer mu.Unlock() if len(reported) != 1 || reported[0] != "x" { t.Fatalf("OnExpire reported = %v, want [x]", reported) } } func TestDebounceCoalescesRestarts(t *testing.T) { cfg := testConfig(t) cfg.DebounceWindow = 40 * time.Millisecond fr := &fakeRestarter{} sb := NewSingBox(cfg, fr) sb.ApplyConfig(sampleSnapshot(), true) // set inbounds so render succeeds ctx, cancel := context.WithCancel(context.Background()) defer cancel() go sb.Run(ctx) // Burst of changes within one debounce window. for i := 0; i < 5; i++ { sb.Upsert(&Cred{DpUUID: string(rune('a' + i)), Protocol: agentv1.ProtocolBoth}) } time.Sleep(150 * time.Millisecond) if n := fr.count(); n != 1 { t.Fatalf("expected 1 coalesced restart, got %d", n) } } // assertNoIdentityFields fails if the blob contains any user-identity token. func assertNoIdentityFields(t *testing.T, blob []byte) { t.Helper() lower := strings.ToLower(string(blob)) for _, banned := range []string{"email", "user_id", "userid", "phone", "device_id", "deviceid", "account", "destination", "dns"} { if strings.Contains(lower, banned) { t.Errorf("forbidden identity field %q found in persisted/rendered data:\n%s", banned, blob) } } } // 首次渲染走冷启动(Restart),之后的配置变更走 SIGHUP 热重载(Reload)。 func TestWriteAndRestartFirstColdThenReload(t *testing.T) { fr := &fakeRestarter{} sb := NewSingBox(testConfig(t), fr) sb.ApplyConfig(sampleSnapshot(), true) // 设置 inbounds 使渲染成功 ctx := context.Background() if err := sb.writeAndRestart(ctx); err != nil { t.Fatalf("first writeAndRestart: %v", err) } if r, rl := fr.count(), fr.reloadCount(); r != 1 || rl != 0 { t.Fatalf("首次后 restarts=%d reloads=%d, 期望 1/0", r, rl) } sb.Upsert(&Cred{DpUUID: "z", Protocol: agentv1.ProtocolBoth}) if err := sb.writeAndRestart(ctx); err != nil { t.Fatalf("second writeAndRestart: %v", err) } if r, rl := fr.count(), fr.reloadCount(); r != 1 || rl != 1 { t.Fatalf("变更后 restarts=%d reloads=%d, 期望 1/1(不再 Restart,走 Reload)", r, rl) } }