Skip to content

Commit 8a7b54e

Browse files
tinniaru3005github-actions[bot]gmelodie
authored
fix(service-disco): extend IP-similarity scoring to IPv6 (#2805)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Gabriel Cruz <8129788+gmelodie@users.noreply.github.com>
1 parent dabc399 commit 8a7b54e

6 files changed

Lines changed: 245 additions & 90 deletions

File tree

libp2p/protocols/service_discovery/registrar.nim

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,34 +21,35 @@ logScope:
2121
proc updateRegistrarMetrics(registrar: Registrar) {.raises: [].} =
2222
cd_registrar_cache_ads.set(registrar.cacheTimestamps.len.float64)
2323
cd_registrar_cache_services.set(registrar.cache.len.float64)
24-
cd_iptree_unique_ips.set(registrar.ipTree.root.counter.float64)
24+
cd_iptree_total_ips.set(
25+
registrar.ipTree.root.counter.float64 + registrar.ipTree.root6.counter.float64
26+
)
2527

26-
proc filterIPv4(addrsInfos: seq[AddressInfo]): seq[IpAddress] {.raises: [].} =
28+
proc getIPs(addrsInfos: seq[AddressInfo]): seq[IpAddress] {.raises: [].} =
2729
var ips: seq[IpAddress]
2830
for addrInfo in addrsInfos:
2931
let multiAddr = addrInfo.address
3032
multiAddr.getIp().withValue(ip):
31-
if ip.family == IpAddressFamily.IPv4:
32-
ips.add(ip)
33+
ips.add(ip)
3334
return ips
3435

3536
proc adScore*(ipTree: IpTree, ad: Advertisement): float64 {.raises: [].} =
3637
## Return the max score for this advertisement
3738

3839
var maxScore = 0.0
39-
for ip in ad.data.addresses.filterIPv4():
40+
for ip in ad.data.addresses.getIPs():
4041
let score = ipTree.ipScore(ip)
4142
if score > maxScore:
4243
maxScore = score
4344

4445
return maxScore
4546

4647
proc insertAd*(ipTree: IpTree, ad: Advertisement) {.raises: [].} =
47-
for ip in ad.data.addresses.filterIPv4():
48+
for ip in ad.data.addresses.getIPs():
4849
ipTree.insertIp(ip)
4950

5051
proc removeAd*(ipTree: IpTree, ad: Advertisement) {.raises: [].} =
51-
for ip in ad.data.addresses.filterIPv4():
52+
for ip in ad.data.addresses.getIPs():
5253
ipTree.removeIp(ip)
5354

5455
proc isExpired(now, ts: Moment, expiry: Duration): bool =

libp2p/protocols/service_discovery/service_discovery_metrics.nim

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ declarePublicCounter cd_service_table_insertions,
4242
"peer insertions into service routing tables"
4343

4444
# IpTree metrics
45-
declarePublicGauge cd_iptree_unique_ips, "unique IPs tracked in the IP tree"
45+
declarePublicGauge cd_iptree_total_ips,
46+
"total IP entries currently tracked in the IP tree (counts repeat inserts of the same address, not distinct addresses)"
4647

4748
# Advertiser metrics
4849
declarePublicGauge cd_advertiser_pending_actions,

libp2p/utils/iptree.nim

Lines changed: 86 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -9,100 +9,131 @@ type
99
left*, right*: IpTreeNode
1010

1111
IpTree* = ref object
12-
root*: IpTreeNode
12+
root*: IpTreeNode ## IPv4 tree root: a 32-level binary tree.
13+
root6*: IpTreeNode ## IPv6 tree root: a 128-level binary tree.
1314

1415
proc new*(T: typedesc[IpTree]): T =
15-
T(root: IpTreeNode(counter: 0))
16+
T(root: IpTreeNode(counter: 0), root6: IpTreeNode(counter: 0))
17+
18+
proc ipBytes(ip: IpAddress): seq[uint8] {.raises: [].} =
19+
case ip.family
20+
of IpAddressFamily.IPv4:
21+
@(ip.address_v4)
22+
of IpAddressFamily.IPv6:
23+
@(ip.address_v6)
24+
25+
proc treeRoot(ipTree: IpTree, ip: IpAddress): IpTreeNode =
26+
case ip.family
27+
of IpAddressFamily.IPv4: ipTree.root
28+
of IpAddressFamily.IPv6: ipTree.root6
29+
30+
proc step(v: IpTreeNode, goRight: bool, create: bool): IpTreeNode =
31+
## Returns the child of `v` in the direction given by `goRight`. With
32+
## `create`, a missing child is allocated and linked in, so the result is
33+
## never nil; otherwise a missing child yields nil.
34+
var nxt = if goRight: v.right else: v.left
35+
if nxt.isNil and create:
36+
nxt = IpTreeNode(counter: 0)
37+
if goRight:
38+
v.right = nxt
39+
else:
40+
v.left = nxt
41+
nxt
1642

1743
proc insertIp*(ipTree: IpTree, ip: IpAddress) {.raises: [].} =
18-
doAssert ip.family == IpAddressFamily.IPv4
19-
20-
var v = ipTree.root
44+
## Inserts an IP address into the IP tree, following its binary
45+
## representation and incrementing counters at each visited node.
46+
## IPv4 addresses walk the 32-level tree,
47+
## IPv6 addresses walk the 128-level tree.
48+
var v = ipTree.treeRoot(ip)
2149
v.counter += 1
2250

23-
let bytes = ip.address_v4
51+
let bytes = ip.ipBytes()
2452

25-
for i in 0 ..< 4:
26-
let b = bytes[i]
53+
for b in bytes:
2754
for bit in countdown(7, 0):
2855
let goRight = (b and (1'u8 shl bit)) != 0
29-
30-
if goRight:
31-
if v.right.isNil:
32-
v.right = IpTreeNode(counter: 0)
33-
v = v.right
34-
else:
35-
if v.left.isNil:
36-
v.left = IpTreeNode(counter: 0)
37-
v = v.left
38-
56+
v = step(v, goRight, create = true)
3957
v.counter += 1
4058

4159
proc removeIp*(ipTree: IpTree, ip: IpAddress) {.raises: [].} =
42-
## Removes an IPv4 address from the IP tree by decrementing counters along
43-
## the 32-bit path. Counters never go below zero. Only IPv4 is supported.
44-
doAssert ip.family == IpAddressFamily.IPv4
45-
46-
if ipTree.root.counter == 0:
60+
## Removes an IP address from the IP tree by decrementing counters along
61+
## its binary-representation path, from the root through the leaf.
62+
## Counters never go below zero. Any trailing run of nodes left at counter
63+
## 0 with no children is unlinked, so a fully-removed address doesn't leave
64+
## dead nodes behind.
65+
let root = ipTree.treeRoot(ip)
66+
if root.counter == 0:
4767
return
4868

49-
var v = ipTree.root
50-
let bytes = ip.address_v4
69+
let bytes = ip.ipBytes()
5170

52-
var path: array[32, IpTreeNode]
53-
var pathLen = 0
71+
# path[0] is the root; path[i] for i > 0 is the node reached after the
72+
# i-th bit. Sized for the deepest case (IPv6: root + 128 levels).
73+
var path: array[129, IpTreeNode]
74+
path[0] = root
75+
var pathLen = 1
5476

55-
for i in 0 ..< 4:
56-
let b = bytes[i]
77+
var v = root
78+
for b in bytes:
5779
for bit in countdown(7, 0):
80+
let goRight = (b and (1'u8 shl bit)) != 0
81+
v = step(v, goRight, create = false)
5882
if v.isNil or v.counter == 0:
5983
return
6084

6185
path[pathLen] = v
6286
inc pathLen
6387

64-
let goLeft = (b and (1'u8 shl bit)) == 0
65-
let nxt = if goLeft: v.left else: v.right
66-
if nxt.isNil:
67-
return
68-
v = nxt
69-
88+
# Every node visited above has counter > 0 (checked as it was added to
89+
# path), so this can't underflow.
7090
for j in 0 ..< pathLen:
71-
let n = path[j]
72-
if n.counter > 0:
73-
dec n.counter
91+
dec path[j].counter
92+
93+
# Prune the trailing run of now-empty leaves: walk from the leaf back
94+
# toward the root, unlinking any node left with counter == 0 and no
95+
# children, stopping at the first node that still has either.
96+
for i in countdown(pathLen - 1, 1):
97+
let n = path[i]
98+
if n.counter > 0 or not n.left.isNil or not n.right.isNil:
99+
break
100+
let parent = path[i - 1]
101+
if parent.left == n:
102+
parent.left = nil
103+
else:
104+
parent.right = nil
74105

75106
proc ipScore*(ipTree: IpTree, ip: IpAddress): float64 {.raises: [].} =
76-
## Returns an IP similarity score in [0.0, 1.0] for the given IPv4 address.
77-
## Asserts that `ip` is an IPv4 address.
107+
## Returns an IP similarity score in [0.0, 1.0] for the given IP address.
108+
## Supports both IPv4 (32-level tree) and IPv6 (128-level tree).
78109
##
79-
## The score counts how many of the 32 prefix nodes along the IP's path have
110+
## The score counts how many of the prefix nodes along the IP's path have
80111
## a counter exceeding the expected threshold (root.counter / 2^(depth+1)),
81112
## where depth+1 is the tree level of the child node being evaluated.
82113
## A high score means many existing IPs share the same subnet — a signal of
83114
## Sybil-style clustering.
84-
doAssert ip.family == IpAddressFamily.IPv4
85-
86-
if ipTree.root.counter == 0:
115+
let root = ipTree.treeRoot(ip)
116+
if root.counter == 0:
87117
return 0.0
88118

89-
var v = ipTree.root
119+
var v = root
90120
var score = 0
91-
let total = float64(ipTree.root.counter)
92-
let bytes = ip.address_v4
121+
let total = float64(root.counter)
122+
let bytes = ip.ipBytes()
123+
let nBits = bytes.len * 8
93124

94-
for i in 0 ..< 4:
95-
let b = bytes[i]
125+
var threshold = total * 0.5
126+
for b in bytes:
96127
for bit in countdown(7, 0):
97-
let depth = i * 8 + (7 - bit) # 0 .. 31; child node sits at tree level depth+1
98-
let threshold = total / float64(1'u64 shl (depth + 1))
99-
100-
v = if (b and (1'u8 shl bit)) == 0: v.left else: v.right
128+
let goRight = (b and (1'u8 shl bit)) != 0
129+
v = step(v, goRight, create = false)
101130

102131
if v.isNil:
103-
return (float64(score) / 32.0)
132+
return (float64(score) / float64(nBits))
104133

105134
if float64(v.counter) > threshold:
106135
score += 1
107136

108-
(float64(score) / 32.0)
137+
threshold *= 0.5
138+
139+
(float64(score) / float64(nBits))

tests/libp2p/service_discovery/component/test_advertise_discover.nim

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -391,22 +391,24 @@ suite "Service Discovery Component - Advertise Discover":
391391
checkUntilTimeout:
392392
lateRegistrar.countAdsInCache(serviceId) == 1
393393

394-
asyncTest "an IPv6 node registers three services one after another":
395-
# TODO: vacp2p/nim-libp2p#2756
396-
# A node listening only on IPv6 advertises only IPv6 addresses, which never
397-
# enter the IPv4-only IP tree, so its advertisements draw no IP-similarity
398-
# waiting time: each is Confirmed and cached, even under default settings.
394+
asyncTest "an IPv6-only node is subject to IP-similarity throttling like an IPv4 node":
399395
let registrarNode = setupServiceDiscoveryNode()
400396
let advertiserNode = setupServiceDiscoveryNode(addresses = @[TcpAutoAddressIP6()])
401397
startAndDeferStop(@[registrarNode, advertiserNode])
402398
await connect(registrarNode, advertiserNode)
403399

404-
for i in 1 .. 3:
405-
let service = makeServiceInfo("ipv6-service-" & $i)
406-
let serviceId = service.id.hashServiceId()
407-
advertiserNode.addProvidedService(service)
408-
checkUntilTimeout:
409-
registrarNode.countAdsInCache(serviceId) == 1
400+
let firstService = makeServiceInfo("ipv6-service-1")
401+
let firstServiceId = firstService.id.hashServiceId()
402+
advertiserNode.addProvidedService(firstService)
403+
checkUntilTimeout:
404+
registrarNode.countAdsInCache(firstServiceId) == 1
405+
406+
let secondService = makeServiceInfo("ipv6-service-2")
407+
let secondServiceId = secondService.id.hashServiceId()
408+
advertiserNode.addProvidedService(secondService)
409+
checkUntilTimeout:
410+
registrarNode.registrar.boundService.hasKey(secondServiceId)
411+
check registrarNode.countAdsInCache(secondServiceId) == 0
410412

411413
asyncTest "a dual-stack node follows the normal path and is told to Wait":
412414
let registrarNode = setupServiceDiscoveryNode()

tests/libp2p/service_discovery/test_registrar.nim

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -637,7 +637,7 @@ suite "Service Discovery Registrar - Edge Cases":
637637

638638
check w >= ZeroDuration
639639

640-
test "waitingTime with IPv6 addresses only (ignored in IP tree)":
640+
test "waitingTime with IPv6 addresses only, tree empty":
641641
let registrar = Registrar.new()
642642
let discoConfig = ServiceDiscoveryConfig.new()
643643
let serviceId = makeServiceId()
@@ -649,6 +649,26 @@ suite "Service Discovery Registrar - Edge Cases":
649649

650650
check w >= ZeroDuration
651651

652+
test "waitingTime with IPv6 addresses contributes IP similarity":
653+
let registrar = Registrar.new()
654+
let discoConfig = ServiceDiscoveryConfig.new()
655+
let serviceId = makeServiceId()
656+
657+
registrar.ipTree.insertIp(
658+
IpAddress(
659+
family: IpAddressFamily.IPv6,
660+
address_v6: [0'u8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
661+
)
662+
)
663+
664+
let ipv6Addr = MultiAddress.init("/ip6/::1/tcp/9000").get()
665+
let ad = makeAdvertisement(addrs = @[ipv6Addr])
666+
let now = Moment.now()
667+
668+
let w = registrar.waitingTime(discoConfig, ad, 1000, serviceId, now)
669+
670+
check w > ZeroDuration
671+
652672
test "waitingTime with mixed IPv4 and IPv6 addresses":
653673
let registrar = Registrar.new()
654674
let discoConfig = ServiceDiscoveryConfig.new()

0 commit comments

Comments
 (0)