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>
This commit is contained in:
Michael Ben-Ami
2026-07-16 16:35:38 -04:00
committed by mzbenami
parent 71e5a98404
commit b2de420e3d
6 changed files with 562 additions and 0 deletions
+2
View File
@@ -31,6 +31,7 @@ import (
"tailscale.com/ipn"
"tailscale.com/ipn/ipnext"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/localapi"
"tailscale.com/net/packet"
"tailscale.com/net/tsaddr"
"tailscale.com/net/tstun"
@@ -84,6 +85,7 @@ func init() {
})
ipnlocal.RegisterPeerAPIHandler("/v0/connector/transit-ip", handleConnectorTransitIP)
ipnlocal.HookReplyToDNSQueries.Add(handleHookReplyToDNSQueries)
localapi.Register("conn25-state", serveStateGet)
}
func handleConnectorTransitIP(h ipnlocal.PeerAPIHandler, w http.ResponseWriter, r *http.Request) {
+35
View File
@@ -5,6 +5,7 @@ package conn25
import (
"errors"
"math"
"net/netip"
"go4.org/netipx"
@@ -140,3 +141,37 @@ func (ipp *ippool) reconfig(ipSet *netipx.IPSet) *ippool {
}
return newPool
}
// inUseCount returns the number of addresses currently handed out from the
// pool. It is safe to call on a nil or uninitialized pool, returning 0.
func (ipp *ippool) inUseCount() int64 {
if ipp == nil || ipp.inUse == nil {
return 0
}
return int64(ipp.inUse.Len())
}
// capacity returns the total number of addresses defined by the pool's ipSet.
// It is safe to call on a nil or uninitialized pool, returning 0. The count is
// clamped to [math.MaxInt64] because an IP pool (particularly IPv6) can define
// far more addresses than fit in an int64.
func (ipp *ippool) capacity() int64 {
if ipp == nil || ipp.ipSet == nil {
return 0
}
var count int64
for _, pfx := range ipp.ipSet.Prefixes() {
bits := pfx.Addr().BitLen() - pfx.Bits()
// 1<<bits can exceed an int64 (e.g. a /64 IPv6 pool), so clamp.
if bits >= 63 {
return math.MaxInt64
}
addend := int64(1) << bits
if count > math.MaxInt64-addend {
// Summing multiple large prefixes would overflow.
return math.MaxInt64
}
count += addend
}
return count
}
+83
View File
@@ -5,6 +5,7 @@ package conn25
import (
"errors"
"math"
"net/netip"
"testing"
@@ -197,3 +198,85 @@ func TestIPPoolReconfig(t *testing.T) {
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)
}
})
}
+204
View File
@@ -0,0 +1,204 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package conn25
import (
"cmp"
"encoding/json"
"maps"
"net/http"
"net/netip"
"slices"
"strings"
"tailscale.com/envknob"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/localapi"
"tailscale.com/types/appctype"
"tailscale.com/util/dnsname"
"tailscale.com/util/httpm"
"tailscale.com/util/mak"
"tailscale.com/util/testenv"
)
// serveStateGet serves the localapi endpoint /conn25-state.
// See also [*Conn25.GetActiveState].
func serveStateGet(h *localapi.Handler, w http.ResponseWriter, r *http.Request) {
// TODO(tailscale/corp#39033): Remove for alpha release.
if !envknob.UseWIPCode() && !testenv.InTest() {
w.WriteHeader(http.StatusNotImplemented)
return
}
if !h.PermitRead {
http.Error(w, "conn25-state access denied", http.StatusForbidden)
return
}
if r.Method != httpm.GET {
http.Error(w, "GET required", http.StatusMethodNotAllowed)
return
}
ext, ok := ipnlocal.GetExt[*extension](h.LocalBackend())
if !ok {
http.Error(w, "miswired", http.StatusInternalServerError)
return
}
state := ext.conn25.GetActiveState()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(state); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
// GetActiveState returns active state for the client and the connector,
// including IP pool usage, address mappings, domains, and active flow counts.
func (c *Conn25) GetActiveState() appctype.Conn25ActiveState {
if !c.isConfigured() {
return appctype.Conn25ActiveState{}
}
return appctype.Conn25ActiveState{
Configured: true,
Client: c.client.getActiveState(),
Connector: c.connector.getActiveState(),
}
}
// getActiveState gets active state from the client.
// See [appctype.Conn25ClientState] for structure.
func (c *client) getActiveState() appctype.Conn25ClientState {
c.mu.Lock()
defer c.mu.Unlock()
var appToDomains map[string]map[dnsname.FQDN][]appctype.Conn25ClientAddressState
// Pre-sort addresses by destination IP. They'll later be primary sorted
// by active flow count.
domainDstKeys := slices.Collect(maps.Keys(c.assignments.byDomainDst))
slices.SortFunc(domainDstKeys, func(a, b domainDst) int {
return a.dst.Compare(b.dst)
})
for _, domainDstKey := range domainDstKeys {
assignment := c.assignments.byDomainDst[domainDstKey]
domainMap := appToDomains[assignment.app]
addresses := domainMap[assignment.domain]
addresses = append(addresses, appctype.Conn25ClientAddressState{
ActiveFlowCount: assignment.activeFlowCount,
DestinationIP: assignment.dst.String(),
MagicIP: assignment.magic.String(),
TransitIP: assignment.transit.String(),
ExpiresAt: assignment.expiresAt,
})
mak.Set(&domainMap, assignment.domain, addresses)
mak.Set(&appToDomains, assignment.app, domainMap)
}
var apps []appctype.Conn25ClientAppState
for appName, domainMap := range appToDomains {
var app appctype.Conn25ClientAppState
app.App = appName
for domain, addresses := range domainMap {
var domainState appctype.Conn25ClientDomainState
domainState.Domain = domain
// Sort address mappings by descending active flow count.
slices.SortStableFunc(addresses, func(a, b appctype.Conn25ClientAddressState) int {
if a.ActiveFlowCount > b.ActiveFlowCount {
return -1
}
if a.ActiveFlowCount < b.ActiveFlowCount {
return 1
}
return 0
})
domainState.Addresses = addresses
app.Domains = append(app.Domains, domainState)
}
// Sort domains by hierarchy, e.g. "example.com", "sub.example.com".
slices.SortFunc(app.Domains, compareFQDNHierarchical)
apps = append(apps, app)
}
// Sort apps lexicographically.
slices.SortFunc(apps, func(a, b appctype.Conn25ClientAppState) int {
return strings.Compare(a.App, b.App)
})
return appctype.Conn25ClientState{
Apps: apps,
IPPoolStats: appctype.Conn25ClientIPPoolStats{
IPv4MagicIPsInUse: c.v4MagicIPPool.inUseCount(),
IPv4MagicIPsCapacity: c.v4MagicIPPool.capacity(),
IPv6MagicIPsInUse: c.v6MagicIPPool.inUseCount(),
IPv6MagicIPsCapacity: c.v6MagicIPPool.capacity(),
IPv4TransitIPsInUse: c.v4TransitIPPool.inUseCount(),
IPv4TransitIPsCapacity: c.v4TransitIPPool.capacity(),
IPv6TransitIPsInUse: c.v6TransitIPPool.inUseCount(),
IPv6TransitIPsCapacity: c.v6TransitIPPool.capacity(),
},
}
}
// getActiveState gets active state from the connector.
// See [appctype.Conn25ConnectorState] for structure.
func (c *connector) getActiveState() appctype.Conn25ConnectorState {
c.mu.Lock()
defer c.mu.Unlock()
var peers []appctype.Conn25ConnectorPeerState
// Sort peers by client IP.
for _, clientIP := range slices.SortedFunc(maps.Keys(c.transitIPs), netip.Addr.Compare) {
transitToAddr := c.transitIPs[clientIP]
var appToAddrs map[string][]appctype.Conn25ConnectorAddressState
// Sort address mappings by transit IP.
for _, transitIP := range slices.SortedFunc(maps.Keys(transitToAddr), netip.Addr.Compare) {
addr := transitToAddr[transitIP]
apiAddr := appctype.Conn25ConnectorAddressState{
DestinationIP: addr.addr.String(),
TransitIP: transitIP.String(),
}
mak.Set(&appToAddrs, addr.app, append(appToAddrs[addr.app], apiAddr))
}
var apps []appctype.Conn25ConnectorAppState
for appName, addrs := range appToAddrs {
apps = append(apps, appctype.Conn25ConnectorAppState{
App: appName,
Addresses: addrs,
})
}
// Sort apps lexicographically.
slices.SortFunc(apps, func(a, b appctype.Conn25ConnectorAppState) int {
return strings.Compare(a.App, b.App)
})
peers = append(peers, appctype.Conn25ConnectorPeerState{
ClientIP: clientIP.String(),
Apps: apps,
})
}
return appctype.Conn25ConnectorState{Peers: peers}
}
// compareFQDNHierarchical sorts [appctype.Conn25ClientDomainState] hierarchically
// such that parents precede their children, e.g. "example.com" precedes
// "sub.example.com". If two domains aren't related, they are sorted lexicographically
// from the root and moving forward, e.g. "example.com" precedes "abc.org".
func compareFQDNHierarchical(ad, bd appctype.Conn25ClientDomainState) int {
a, b := ad.Domain, bd.Domain
al := strings.Split(a.WithoutTrailingDot(), ".")
bl := strings.Split(b.WithoutTrailingDot(), ".")
for i := 1; i <= len(al) && i <= len(bl); i++ {
if c := strings.Compare(al[len(al)-i], bl[len(bl)-i]); c != 0 {
return c
}
}
return cmp.Compare(len(al), len(bl))
}
+158
View File
@@ -0,0 +1,158 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package conn25
import (
"net/netip"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"tailscale.com/types/appctype"
"tailscale.com/types/logger"
"tailscale.com/util/dnsname"
"tailscale.com/util/must"
)
func TestGetActiveState(t *testing.T) {
mustFQDN := func(s string) dnsname.FQDN { return must.Get(dnsname.ToFQDN(s)) }
// Fixed, arbitrary expiry so the output is comparable.
expires := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC)
t.Run("unconfigured", func(t *testing.T) {
c := newConn25(logger.Discard)
if diff := cmp.Diff(appctype.Conn25ActiveState{}, c.GetActiveState()); diff != "" {
t.Fatalf("unconfigured Conn25ActiveState mismatch (-want +got):\n%s", diff)
}
})
t.Run("configured", func(t *testing.T) {
c := newConn25(logger.Discard)
c.config.Store(&config{isConfigured: true})
client := c.client
client.assignments.byDomainDst = map[domainDst]*addrs{}
addClientAssignment := func(app, domain, dst, magic, transit string, flows int) {
as := &addrs{
app: app,
domain: mustFQDN(domain),
dst: netip.MustParseAddr(dst),
magic: netip.MustParseAddr(magic),
transit: netip.MustParseAddr(transit),
activeFlowCount: flows,
expiresAt: expires,
}
client.assignments.byDomainDst[domainDst{domain: as.domain, dst: as.dst}] = as
}
// Two addresses under app1/example.com: should sort by active flow
// count descending (5 before 2).
addClientAssignment("app1", "example.com", "10.0.0.1", "100.64.0.1", "169.254.0.1", 2)
addClientAssignment("app1", "example.com", "10.0.0.2", "100.64.0.2", "169.254.0.2", 5)
// example.com (2 labels) sorts before sub.example.com.
addClientAssignment("app1", "sub.example.com", "10.0.0.3", "100.64.0.3", "169.254.0.3", 1)
// A second app: apps sort by name, so app1 before zebra.
addClientAssignment("zebra", "z.example.org", "10.0.0.4", "100.64.0.4", "169.254.0.4", 1)
// IP addresses should be sorted numerically, not lexigraphically.
c.connector.transitIPs = map[netip.Addr]map[netip.Addr]appAddr{
netip.MustParseAddr("100.64.0.1"): {
netip.MustParseAddr("169.254.0.100"): {app: "app1", addr: netip.MustParseAddr("10.0.0.100")},
netip.MustParseAddr("169.254.0.11"): {app: "app1", addr: netip.MustParseAddr("10.0.0.11")},
},
netip.MustParseAddr("11.0.0.1"): {
netip.MustParseAddr("169.254.0.5"): {app: "zapp", addr: netip.MustParseAddr("10.0.0.5")},
netip.MustParseAddr("169.254.0.6"): {app: "app1", addr: netip.MustParseAddr("10.0.0.6")},
},
}
// Configure the four IP pools with distinct capacities and hand out a
// distinct number of addresses from each.
mustPool := func(prefix string, handOut int) *ippool {
p := newIPPool(mustIPSetFromPrefix(prefix))
for range handOut {
if _, err := p.next(); err != nil {
t.Fatal(err)
}
}
return p
}
client.v4MagicIPPool = mustPool("100.64.0.0/30", 1) // capacity 4
client.v6MagicIPPool = mustPool("fd7a:1::/125", 2) // capacity 8
client.v4TransitIPPool = mustPool("169.254.0.0/28", 3) // capacity 16
client.v6TransitIPPool = mustPool("fd7a:2::/123", 4) // capacity 32
want := appctype.Conn25ActiveState{
Configured: true,
Client: appctype.Conn25ClientState{
Apps: []appctype.Conn25ClientAppState{
{
App: "app1",
Domains: []appctype.Conn25ClientDomainState{
{
Domain: mustFQDN("example.com"),
Addresses: []appctype.Conn25ClientAddressState{
{ActiveFlowCount: 5, DestinationIP: "10.0.0.2", MagicIP: "100.64.0.2", TransitIP: "169.254.0.2", ExpiresAt: expires},
{ActiveFlowCount: 2, DestinationIP: "10.0.0.1", MagicIP: "100.64.0.1", TransitIP: "169.254.0.1", ExpiresAt: expires},
},
},
{
Domain: mustFQDN("sub.example.com"),
Addresses: []appctype.Conn25ClientAddressState{
{ActiveFlowCount: 1, DestinationIP: "10.0.0.3", MagicIP: "100.64.0.3", TransitIP: "169.254.0.3", ExpiresAt: expires},
},
},
},
},
{
App: "zebra",
Domains: []appctype.Conn25ClientDomainState{
{
Domain: mustFQDN("z.example.org"),
Addresses: []appctype.Conn25ClientAddressState{
{ActiveFlowCount: 1, DestinationIP: "10.0.0.4", MagicIP: "100.64.0.4", TransitIP: "169.254.0.4", ExpiresAt: expires},
},
},
},
},
},
IPPoolStats: appctype.Conn25ClientIPPoolStats{
IPv4MagicIPsInUse: 1,
IPv4MagicIPsCapacity: 4,
IPv6MagicIPsInUse: 2,
IPv6MagicIPsCapacity: 8,
IPv4TransitIPsInUse: 3,
IPv4TransitIPsCapacity: 16,
IPv6TransitIPsInUse: 4,
IPv6TransitIPsCapacity: 32,
},
},
Connector: appctype.Conn25ConnectorState{
Peers: []appctype.Conn25ConnectorPeerState{
{
ClientIP: "11.0.0.1",
Apps: []appctype.Conn25ConnectorAppState{
{App: "app1", Addresses: []appctype.Conn25ConnectorAddressState{{DestinationIP: "10.0.0.6", TransitIP: "169.254.0.6"}}},
{App: "zapp", Addresses: []appctype.Conn25ConnectorAddressState{{DestinationIP: "10.0.0.5", TransitIP: "169.254.0.5"}}},
},
},
{
ClientIP: "100.64.0.1",
Apps: []appctype.Conn25ConnectorAppState{
{App: "app1", Addresses: []appctype.Conn25ConnectorAddressState{
{DestinationIP: "10.0.0.11", TransitIP: "169.254.0.11"},
{DestinationIP: "10.0.0.100", TransitIP: "169.254.0.100"},
}},
},
},
},
},
}
got := c.GetActiveState()
if diff := cmp.Diff(want, got, cmpopts.EquateApproxTime(0)); diff != "" {
t.Fatalf("Conn25ActiveState mismatch (-want +got):\n%s", diff)
}
})
}