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"` +}