WIP: rebase fork onto upstream/main (v1.103.0) #15

Closed
codinget wants to merge 670 commits from webnet into save/webnet-2026-07-29
6 changed files with 562 additions and 0 deletions
Showing only changes of commit b2de420e3d - Show all commits
+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)
}
})
}
+80
View File
@@ -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"`
}