diff --git a/backend/wireguard/user_partial_sync.go b/backend/wireguard/user_partial_sync.go index 656c406a..db41182a 100644 --- a/backend/wireguard/user_partial_sync.go +++ b/backend/wireguard/user_partial_sync.go @@ -21,9 +21,14 @@ func (wg *WireGuard) buildExistingPeersSubsetForTouched(touchedEmails map[string func (wg *WireGuard) syncUsersPartialReconcile(users []*common.User) error { normalizedUsers := normalizeUsers(users) - touchedEmails := make(map[string]struct{}, len(normalizedUsers)) - for _, user := range normalizedUsers { - touchedEmails[user.GetEmail()] = struct{}{} + // Every user in the request is touched, including ones normalizeUsers drops (empty + // peer_ips or no WireGuard credentials): the panel sends those when a user loses + // WireGuard access, and their existing peer must be removed. + touchedEmails := make(map[string]struct{}, len(users)) + for _, user := range users { + if email := user.GetEmail(); email != "" { + touchedEmails[email] = struct{}{} + } } existingSubset := wg.buildExistingPeersSubsetForTouched(touchedEmails) diff --git a/backend/wireguard/user_sync_test.go b/backend/wireguard/user_sync_test.go index 69b29507..4c8d9133 100644 --- a/backend/wireguard/user_sync_test.go +++ b/backend/wireguard/user_sync_test.go @@ -900,3 +900,125 @@ func mustPeerInfo(email, pubStr string, ips []string) *PeerInfo { AllowedIPs: parsedIPs, } } + +// The panel releases a user's WireGuard IPs (e.g. the user left every WG group) and then +// pushes the user with empty peer_ips or without WireGuard credentials. The stored peer must +// be removed even though the user no longer qualifies as a desired peer. +func TestUpdateUsersRemovesPeerWhenUserLosesWireguardAccess(t *testing.T) { + cases := map[string]*common.Proxy{ + "empty peer_ips": {Wireguard: &common.Wireguard{PeerIps: []string{}}}, + "no wireguard": {}, + } + for name, proxies := range cases { + t.Run(name, func(t *testing.T) { + cfg, err := NewConfig(`{ + "interface_name":"wg-test", + "listen_port":51820, + "address":["10.72.0.1/24"] + }`) + if err != nil { + t.Fatalf("failed to create config: %v", err) + } + + _, key, err := GenerateKeyPair() + if err != nil { + t.Fatalf("failed to generate key: %v", err) + } + if proxies.Wireguard != nil { + proxies.Wireguard.PublicKey = key + } + + ps := NewPeerStore() + ps.ReplaceAll([]*PeerInfo{mustPeerInfo("gone@example.com", key, []string{"10.72.0.2/32"})}) + + var applied []wgtypes.PeerConfig + wg := &WireGuard{ + config: cfg, + peerStore: ps, + statsTracker: stats.New(), + manager: &Manager{ + iFaceName: "wg-test", + client: &fakeWGClient{ + configureDeviceFn: func(interfaceName string, cfg wgtypes.Config) error { + applied = append(applied, cfg.Peers...) + return nil + }, + }, + }, + state: lifecycleRunning, + } + + wg.statsTracker.UpdateStatsBatch([]stats.Sample{{PublicKey: key, Email: "gone@example.com", Rx: 10, Tx: 5}}) + + users := []*common.User{{Email: "gone@example.com", Inbounds: []string{}, Proxies: proxies}} + if err := wg.UpdateUsers(context.Background(), users); err != nil { + t.Fatalf("UpdateUsers failed: %v", err) + } + + if len(applied) != 1 || !applied[0].Remove || applied[0].PublicKey.String() != key { + t.Fatalf("expected exactly one Remove for the stale peer, got %+v", applied) + } + if ps.GetByEmail("gone@example.com") != nil { + t.Fatal("expected the stale peer to be dropped from the peer store") + } + if entry := wg.statsTracker.GetStatsEntries([]string{key})[key]; entry == nil || !entry.IsDeleted { + t.Fatalf("expected the stale peer's stats entry to be marked deleted, got %+v", entry) + } + }) + } +} + +// A key moves from a user who lost WireGuard access (sent with empty peer_ips) to another user in the +// same batch. The former owner is touched, so the handover is allowed instead of failing the batch. +func TestUpdateUsersAllowsKeyHandoverFromUserWhoLostAccess(t *testing.T) { + cfg, err := NewConfig(`{ + "interface_name":"wg-test", + "listen_port":51820, + "address":["10.73.0.1/24"] + }`) + if err != nil { + t.Fatalf("failed to create config: %v", err) + } + + _, key, err := GenerateKeyPair() + if err != nil { + t.Fatalf("failed to generate key: %v", err) + } + + ps := NewPeerStore() + ps.ReplaceAll([]*PeerInfo{mustPeerInfo("old@example.com", key, []string{"10.73.0.2/32"})}) + + wg := &WireGuard{ + config: cfg, + peerStore: ps, + statsTracker: stats.New(), + manager: &Manager{ + iFaceName: "wg-test", + client: &fakeWGClient{ + configureDeviceFn: func(interfaceName string, cfg wgtypes.Config) error { return nil }, + }, + }, + state: lifecycleRunning, + } + + users := []*common.User{ + {Email: "old@example.com", Proxies: &common.Proxy{Wireguard: &common.Wireguard{PublicKey: key}}}, + { + Email: "new@example.com", + Inbounds: []string{"wg-test"}, + Proxies: &common.Proxy{ + Wireguard: &common.Wireguard{PublicKey: key, PeerIps: []string{"10.73.0.3/32"}}, + }, + }, + } + if err := wg.UpdateUsers(context.Background(), users); err != nil { + t.Fatalf("UpdateUsers failed: %v", err) + } + + if peer := ps.GetByKey(key); peer == nil || peer.Email != "new@example.com" { + t.Fatalf("expected the key to belong to new@example.com, got %+v", peer) + } + if ps.GetByEmail("old@example.com") != nil { + t.Fatal("expected the former owner to have no peer") + } +}