Files
tailscale/feature/conn25/ippool_test.go
T
Michael Ben-Amiandmzbenami b2de420e3d feature/conn25,types/appctype: serve active Conn25 state over localapi
At /v0/conn25-state.

State includes whether the node is configured for Connectors 2025, as
well as client-specific and connector-specific state, if the node is
acting in those contexts.

Client-specific state includes the reserved Magic IPs and Transit IPs on
the client that have not been returned to their IP pools, and their
associated apps, domains, real destination IPs, and active flow counts.

We also report IP pool utilization: the number of magic and transit IPs
in use versus each pool's capacity, split by IP family.

Connector-specific state includes a peer list of clients that have
registered Transit IPs with the connector, and the apps are real
destination IPs the Transit IPs map to.

Updates tailscale/corp#40125

Signed-off-by: Michael Ben-Ami <mzb@tailscale.com>
2026-07-16 16:35:38 -04:00

283 lines
8.8 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package conn25
import (
"errors"
"math"
"net/netip"
"testing"
"go4.org/netipx"
"tailscale.com/util/must"
)
func TestNext(t *testing.T) {
a := ipSetIterator{}
_, err := a.next()
if !errors.Is(err, errPoolExhausted) {
t.Fatalf("expected errPoolExhausted, got %v", err)
}
var isb netipx.IPSetBuilder
ipset := must.Get(isb.IPSet())
b := newIPPool(ipset)
_, err = b.next()
if !errors.Is(err, errPoolExhausted) {
t.Fatalf("expected errPoolExhausted, got %v", err)
}
isb.AddRange(netipx.IPRangeFrom(netip.MustParseAddr("192.168.0.0"), netip.MustParseAddr("192.168.0.2")))
isb.AddRange(netipx.IPRangeFrom(netip.MustParseAddr("200.0.0.0"), netip.MustParseAddr("200.0.0.0")))
isb.AddRange(netipx.IPRangeFrom(netip.MustParseAddr("201.0.0.0"), netip.MustParseAddr("201.0.0.1")))
ipset = must.Get(isb.IPSet())
c := newIPPool(ipset)
expected := []string{
"192.168.0.0",
"192.168.0.1",
"192.168.0.2",
"200.0.0.0",
"201.0.0.0",
"201.0.0.1",
}
for i, want := range expected {
addr, err := c.next()
if err != nil {
t.Fatal(err)
}
if addr != netip.MustParseAddr(want) {
t.Fatalf("next call %d want: %s, got: %v", i, want, addr)
}
}
_, err = c.next()
if !errors.Is(err, errPoolExhausted) {
t.Fatalf("expected errPoolExhausted, got %v", err)
}
_, err = c.next()
if !errors.Is(err, errPoolExhausted) {
t.Fatalf("expected errPoolExhausted, got %v", err)
}
}
// TestReturnAddr tests that if a pool is exhausted, an address can be returned to the
// pool, and then that address will be handed out again.
func TestReturnAddr(t *testing.T) {
addrString := "192.168.0.0"
// There's an IPPool with one address in it.
var isb netipx.IPSetBuilder
isb.AddRange(netipx.IPRangeFrom(netip.MustParseAddr(addrString), netip.MustParseAddr(addrString)))
ipset := must.Get(isb.IPSet())
ipp := newIPPool(ipset)
// The first time we call next we get the address.
addr, err := ipp.next()
if err != nil {
t.Fatalf("expected nil error, got: %v", err)
}
if addr != netip.MustParseAddr(addrString) {
t.Fatalf("want %v, got %v", addrString, addr)
}
// The second time we call next we get errPoolExhausted
_, err = ipp.next()
if !errors.Is(err, errPoolExhausted) {
t.Fatalf("expected errPoolExhausted, got %v", err)
}
// Return the addr to the pool
err = ipp.returnAddr(netip.MustParseAddr(addrString))
if err != nil {
t.Fatal(err)
}
// It's not possible to return addresses that are already in the pool.
err = ipp.returnAddr(netip.MustParseAddr(addrString))
if !errors.Is(err, errAddrExists) {
t.Fatalf("want errAddrExists, got: %v", err)
}
// When we call next we get the returned addr
addrAfterReturn, err := ipp.next()
if err != nil {
t.Fatalf("expected nil error, got: %v", err)
}
if addrAfterReturn != netip.MustParseAddr(addrString) {
t.Fatalf("want %v, got %v", addrString, addrAfterReturn)
}
// You can't return addresses that aren't from the pool.
err = ipp.returnAddr(netip.MustParseAddr("100.100.100.0"))
if !errors.Is(err, errNotOurAddress) {
t.Fatalf("want errNotOurAddress, got: %v", err)
}
}
func expectAddrNext(t *testing.T, ipp *ippool, addrString string) {
t.Helper()
got, err := ipp.next()
if err != nil {
t.Fatalf("expected nil error, got: %v", err)
}
want := netip.MustParseAddr(addrString)
if want != got {
t.Fatalf("want %v; got %v", want, got)
}
}
func expectErrPoolExhaustedNext(t *testing.T, ipp *ippool) {
t.Helper()
_, err := ipp.next()
if !errors.Is(err, errPoolExhausted) {
t.Fatalf("expected errPoolExhausted; got %v", err)
}
}
// TestGettingReturnedAddresses tests that when addresses are returned to the IP Pool
// they are then handed out in the order they were returned.
func TestGettingReturnedAddresses(t *testing.T) {
var isb netipx.IPSetBuilder
isb.AddRange(netipx.IPRangeFrom(netip.MustParseAddr("192.168.0.0"), netip.MustParseAddr("192.168.0.4")))
ipset := must.Get(isb.IPSet())
ipp := newIPPool(ipset)
expectAddrNext(t, ipp, "192.168.0.0")
expectAddrNext(t, ipp, "192.168.0.1")
expectAddrNext(t, ipp, "192.168.0.2")
expectAddrNext(t, ipp, "192.168.0.3")
expectAddrNext(t, ipp, "192.168.0.4")
expectErrPoolExhaustedNext(t, ipp)
ipp.returnAddr(netip.MustParseAddr("192.168.0.2"))
ipp.returnAddr(netip.MustParseAddr("192.168.0.4"))
expectAddrNext(t, ipp, "192.168.0.2")
expectAddrNext(t, ipp, "192.168.0.4")
expectErrPoolExhaustedNext(t, ipp)
}
func TestIPPoolReconfig(t *testing.T) {
var isb netipx.IPSetBuilder
isb.AddRange(netipx.IPRangeFrom(netip.MustParseAddr("192.168.0.0"), netip.MustParseAddr("192.168.0.4")))
ipsetOne := must.Get(isb.IPSet())
ipsetOneClone := must.Get(isb.IPSet())
isb = netipx.IPSetBuilder{}
isb.AddRange(netipx.IPRangeFrom(netip.MustParseAddr("192.168.0.7"), netip.MustParseAddr("192.168.0.10")))
ipsetTwo := must.Get(isb.IPSet())
var ipp *ippool
ipp = ipp.reconfig(ipsetOne)
if ipp.ipSet != ipsetOne {
t.Fatalf("want %v, got %v", ipsetOne, ipp.ipSet)
}
expectAddrNext(t, ipp, "192.168.0.0")
// check that we don't lose iterator state when we reconfig with the same ranges
expectAddrNext(t, ipp, "192.168.0.1")
ipp.returnAddr(netip.MustParseAddr("192.168.0.1"))
ipp = ipp.reconfig(ipsetOneClone)
expectAddrNext(t, ipp, "192.168.0.2")
// when we reconfig with different ranges, we only hand out addresses from the new ranges
ipp = ipp.reconfig(ipsetTwo)
if ipp.ipSet != ipsetTwo {
t.Fatalf("want %v, got %v", ipsetTwo, ipp.ipSet)
}
expectAddrNext(t, ipp, "192.168.0.7")
expectAddrNext(t, ipp, "192.168.0.8")
expectAddrNext(t, ipp, "192.168.0.9")
expectAddrNext(t, ipp, "192.168.0.10")
expectErrPoolExhaustedNext(t, ipp)
// but we have not lost track of the fact that the old addresses are in use
if !ipp.inUse.Contains(netip.MustParseAddr("192.168.0.0")) {
t.Fatalf("expected inUse to still have the address")
}
// old addresses can be returned
ipp.returnAddr(netip.MustParseAddr("192.168.0.0"))
// but they are not handed out again
expectErrPoolExhaustedNext(t, ipp)
if ipp.inUse.Contains(netip.MustParseAddr("192.168.0.0")) {
t.Fatalf("expected inUse to no longer have the address")
}
// returning addresses from the new ranges works as normal
ipp.returnAddr(netip.MustParseAddr("192.168.0.9"))
expectAddrNext(t, ipp, "192.168.0.9")
}
func TestIPPoolCapacity(t *testing.T) {
tests := []struct {
name string
prefix string
want int64
}{
{"ipv4-slash-30", "100.64.0.0/30", 4},
{"ipv4-slash-24", "100.64.0.0/24", 256},
{"ipv4-single", "100.64.0.1/32", 1},
{"ipv6-slash-120", "fd7a::/120", 256},
// 2^62 is the largest power of two below math.MaxInt64; not clamped.
{"ipv6-slash-66-not-clamped", "fd7a::/66", 1 << 62},
// 2^63 overflows int64, so it clamps.
{"ipv6-slash-65-clamps", "fd7a::/65", math.MaxInt64},
{"ipv6-slash-64-clamps", "fd7a::/64", math.MaxInt64},
{"ipv6-default-clamps", "::/0", math.MaxInt64},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ipp := newIPPool(mustIPSetFromPrefix(tt.prefix))
if got := ipp.capacity(); got != tt.want {
t.Errorf("capacity(%s) = %d, want %d", tt.prefix, got, tt.want)
}
})
}
t.Run("multi-prefix-sum-overflows-clamps", func(t *testing.T) {
b := &netipx.IPSetBuilder{}
b.AddPrefix(netip.MustParsePrefix("fd7a::/66")) // 2^62 +
b.AddPrefix(netip.MustParsePrefix("fd7a:0:0:0:8000::/66")) // 2^62 = 2^63 (1 more than MaxInt64)
set, err := b.IPSet()
if err != nil {
t.Fatal(err)
}
if got := newIPPool(set).capacity(); got != math.MaxInt64 {
t.Errorf("capacity() = %d, want %d", got, int64(math.MaxInt64))
}
})
t.Run("nil-and-uninitialized", func(t *testing.T) {
var nilPool *ippool
if got := nilPool.capacity(); got != 0 {
t.Errorf("nil pool capacity() = %d, want 0", got)
}
// newIPPool(nil) returns a non-nil pool with a nil ipSet.
if got := newIPPool(nil).capacity(); got != 0 {
t.Errorf("uninitialized pool capacity() = %d, want 0", got)
}
})
}
func TestIPPoolInUseCount(t *testing.T) {
t.Run("counts-handed-out", func(t *testing.T) {
ipp := newIPPool(mustIPSetFromPrefix("100.64.0.0/29")) // 8 addresses
if got := ipp.inUseCount(); got != 0 {
t.Fatalf("fresh pool inUseCount() = %d, want 0", got)
}
a1 := must.Get(ipp.next())
must.Get(ipp.next())
if got := ipp.inUseCount(); got != 2 {
t.Fatalf("after 2 next() inUseCount() = %d, want 2", got)
}
if err := ipp.returnAddr(a1); err != nil {
t.Fatal(err)
}
if got := ipp.inUseCount(); got != 1 {
t.Fatalf("after returnAddr inUseCount() = %d, want 1", got)
}
})
t.Run("nil-and-uninitialized", func(t *testing.T) {
var nilPool *ippool
if got := nilPool.inUseCount(); got != 0 {
t.Errorf("nil pool inUseCount() = %d, want 0", got)
}
// newIPPool(nil) returns a non-nil pool with a nil inUse set.
if got := newIPPool(nil).inUseCount(); got != 0 {
t.Errorf("uninitialized pool inUseCount() = %d, want 0", got)
}
})
}