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>
283 lines
8.8 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|