Files
tailscale/feature/conn25/api.go
T
Michael Ben-Amiandmzbenami 2820b5e99d feature/conn25: rename localapi.go to api.go
And rename serveStateGet to serveLocalAPIStateGet to prepare for adding
a c2n handler that is backed by the same methods as the LocalAPI
handler.

Updates tailscale/corp#40125

Signed-off-by: Michael Ben-Ami <mzb@tailscale.com>
2026-07-22 13:04:41 -04:00

205 lines
6.4 KiB
Go

// 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"
)
// serveLocalAPIStateGet serves the localapi endpoint /conn25-state.
// See also [*Conn25.GetActiveState].
func serveLocalAPIStateGet(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))
}