Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions backend/wireguard/user_partial_sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
122 changes: 122 additions & 0 deletions backend/wireguard/user_sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Loading