From b2de420e3db415767ab1800bea8c09b28e3e185c Mon Sep 17 00:00:00 2001 From: Michael Ben-Ami Date: Mon, 13 Jul 2026 16:56:33 -0400 Subject: [PATCH] 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 --- feature/conn25/conn25.go | 2 + feature/conn25/ippool.go | 35 ++++++ feature/conn25/ippool_test.go | 83 +++++++++++++ feature/conn25/localapi.go | 204 ++++++++++++++++++++++++++++++++ feature/conn25/localapi_test.go | 158 +++++++++++++++++++++++++ types/appctype/appconnector.go | 80 +++++++++++++ 6 files changed, 562 insertions(+) create mode 100644 feature/conn25/localapi.go create mode 100644 feature/conn25/localapi_test.go diff --git a/feature/conn25/conn25.go b/feature/conn25/conn25.go index ae0764433..9c02dd350 100644 --- a/feature/conn25/conn25.go +++ b/feature/conn25/conn25.go @@ -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) { diff --git a/feature/conn25/ippool.go b/feature/conn25/ippool.go index 72149cbee..af2b3f25b 100644 --- a/feature/conn25/ippool.go +++ b/feature/conn25/ippool.go @@ -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<= 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 +} diff --git a/feature/conn25/ippool_test.go b/feature/conn25/ippool_test.go index 1cc9845f5..b12b601cd 100644 --- a/feature/conn25/ippool_test.go +++ b/feature/conn25/ippool_test.go @@ -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) + } + }) +} diff --git a/feature/conn25/localapi.go b/feature/conn25/localapi.go new file mode 100644 index 000000000..45908a4f1 --- /dev/null +++ b/feature/conn25/localapi.go @@ -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)) +} diff --git a/feature/conn25/localapi_test.go b/feature/conn25/localapi_test.go new file mode 100644 index 000000000..b0c034875 --- /dev/null +++ b/feature/conn25/localapi_test.go @@ -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) + } + }) +} diff --git a/types/appctype/appconnector.go b/types/appctype/appconnector.go index e92f62939..6be5d51b5 100644 --- a/types/appctype/appconnector.go +++ b/types/appctype/appconnector.go @@ -7,9 +7,11 @@ package appctype import ( "net/netip" + "time" "go4.org/netipx" "tailscale.com/tailcfg" + "tailscale.com/util/dnsname" ) // ConfigID is an opaque identifier for a configuration. @@ -113,3 +115,81 @@ type Conn25PoolsAttr struct { V6MagicIPPool []netipx.IPRange `json:"v6MagicIPPool,omitempty"` V6TransitIPPool []netipx.IPRange `json:"v6TransitIPPool,omitempty"` } + +// Conn25ActiveState holds the active client and connector state. +type Conn25ActiveState struct { + Configured bool `json:"configured"` + Client Conn25ClientState `json:"client,omitzero"` + Connector Conn25ConnectorState `json:"connector,omitzero"` +} + +// Conn25ClientState holds the active client state. +type Conn25ClientState struct { + Apps []Conn25ClientAppState `json:"apps,omitempty"` + IPPoolStats Conn25ClientIPPoolStats `json:"ipPoolStats,omitzero"` +} + +// Conn25ClientIPPoolStats holds the in-use and total capacity counts for the +// client's magic and transit IP pools, split by address family. If a count +// exceeds [math.MaxInt64], that maximum is used instead. +type Conn25ClientIPPoolStats struct { + IPv4MagicIPsInUse int64 `json:"ipv4MagicIPsInUse"` + IPv4MagicIPsCapacity int64 `json:"ipv4MagicIPsCapacity"` + IPv6MagicIPsInUse int64 `json:"ipv6MagicIPsInUse"` + IPv6MagicIPsCapacity int64 `json:"ipv6MagicIPsCapacity"` + IPv4TransitIPsInUse int64 `json:"ipv4TransitIPsInUse"` + IPv4TransitIPsCapacity int64 `json:"ipv4TransitIPsCapacity"` + IPv6TransitIPsInUse int64 `json:"ipv6TransitIPsInUse"` + IPv6TransitIPsCapacity int64 `json:"ipv6TransitIPsCapacity"` +} + +// Conn25ClientAppState holds the active client state for a single app, +// grouped by domain. +type Conn25ClientAppState struct { + App string `json:"app,omitempty"` + Domains []Conn25ClientDomainState `json:"domains,omitempty"` +} + +// Conn25ClientDomainState holds the address mappings the client has allocated +// for a single domain. +type Conn25ClientDomainState struct { + Domain dnsname.FQDN `json:"domain"` + Addresses []Conn25ClientAddressState `json:"addresses,omitempty"` +} + +// Conn25ClientAddressState describes a single address mapping the client has +// allocated: the destination it resolves to, the magic and transit IPs handed +// out for it, its active flow count, and when the mapping expires. +type Conn25ClientAddressState struct { + ActiveFlowCount int `json:"activeFlowCount"` + DestinationIP string `json:"destinationIP,omitempty"` + MagicIP string `json:"magicIP,omitempty"` + TransitIP string `json:"transitIP,omitempty"` + ExpiresAt time.Time `json:"expiresAt,omitzero"` +} + +// Conn25ConnectorState holds the active connector state. +type Conn25ConnectorState struct { + Peers []Conn25ConnectorPeerState `json:"peers,omitempty"` +} + +// Conn25ConnectorPeerState holds the active connector state for a single peer +// (client) that has registered addresses with the connector, grouped by app. +type Conn25ConnectorPeerState struct { + ClientIP string `json:"clientIP,omitempty"` + Apps []Conn25ConnectorAppState `json:"apps,omitempty"` +} + +// Conn25ConnectorAppState holds the address mappings a peer has registered +// with the connector for a single app. +type Conn25ConnectorAppState struct { + App string `json:"app,omitempty"` + Addresses []Conn25ConnectorAddressState `json:"addresses,omitempty"` +} + +// Conn25ConnectorAddressState describes a single transit-to-destination IP +// mapping the connector routes on behalf of a peer. +type Conn25ConnectorAddressState struct { + DestinationIP string `json:"destinationIP,omitempty"` + TransitIP string `json:"transitIP,omitempty"` +}