Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,32 @@
## 0.5.0

A feature and performance release. Inbound connections and HTTP requests now
carry the calling node's Tailscale identity, resolved at accept time, and the
LocalAPI loopback that backs `whois()` is dramatically faster.

**Features:**

- Inbound TCP/TLS connections accepted from a listener now expose the calling
node's identity as `TailscaleConnection.identity` (`TailscaleNodeIdentity?`) —
host name, stable node ID, tags, and tailnet IPs. It is `null` for outbound
`tcp.dial` (you chose the target) and when the caller can't be resolved;
resolution is best-effort and never blocks or fails an accept.
- Inbound `Http.bind` requests expose the same via `TailscaleHttpRequest.identity`
— the in-process counterpart to the `Tailscale-User-Login` headers that
`serve.forward` adds. `null` for public Funnel callers, which originate
outside the tailnet.

**Performance:**

- `whois()` (and every other LocalAPI call) no longer forks `lsof` per request.
The loopback auth path was hunting a macOS GUI credential file that an
embedded tsnet process can never have; skipping it cuts a `whois()` round-trip
from ~40 ms to ~0.3 ms.
- Attaching identity to an inbound connection is a ~80 ns read from an in-memory
index mirrored from the netmap, not a per-accept LocalAPI round-trip. The
index falls back to a live lookup while cold and is dropped on teardown, so
identity is never stale.

## 0.4.0

A security and reliability release. It hardens the embedded-tsnet data plane,
Expand Down
16 changes: 12 additions & 4 deletions go/cmd/dylib/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ func DuneHttpAccept(bindingID C.longlong) *C.char {
if closed {
return C.CString(`{"closed":true}`)
}
result, _ := json.Marshal(map[string]any{
payload := map[string]any{
"bindingId": req.BindingID,
"requestBodyFd": req.RequestBodyFD,
"responseBodyFd": req.ResponseBodyFD,
Expand All @@ -108,7 +108,11 @@ func DuneHttpAccept(bindingID C.longlong) *C.char {
"remotePort": req.RemotePort,
"localAddress": req.LocalAddress,
"localPort": req.LocalPort,
})
}
if req.Identity != nil {
payload["identity"] = req.Identity
}
result, _ := json.Marshal(payload)
return C.CString(string(result))
}

Expand Down Expand Up @@ -184,13 +188,17 @@ func DuneTcpAcceptFd(listenerID C.longlong) *C.char {
if closed {
return C.CString(`{"closed": true}`)
}
result, _ := json.Marshal(map[string]any{
payload := map[string]any{
"fd": conn.FD,
"localAddress": conn.LocalAddress,
"localPort": conn.LocalPort,
"remoteAddress": conn.RemoteAddress,
"remotePort": conn.RemotePort,
})
}
if conn.Identity != nil {
payload["identity"] = conn.Identity
}
result, _ := json.Marshal(payload)
return C.CString(string(result))
}

Expand Down
10 changes: 10 additions & 0 deletions go/http_fd_posix.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ type HttpIncomingRequest struct {
RemotePort int
LocalAddress string
LocalPort int
// Identity is the resolved identity of the calling node, attached at
// accept time the same way inbound TCP/TLS connections carry it. Nil for
// public Funnel callers (no tailnet node) and when the accept-time lookup
// found nothing or failed.
Identity *nodeIdentity
}

type httpResponseHead struct {
Expand Down Expand Up @@ -398,6 +403,11 @@ func serveHTTPFdRequest(state *httpBindingState, w http.ResponseWriter, r *http.
RemotePort: remotePort,
LocalAddress: localAddress,
LocalPort: localPort,
// Best-effort, like the TCP accept path: a nil result still delivers
// the request (IP-only). A map read against the netmap identity cache
// once warm; only the brief cold-cache window falls back to a live
// WhoIs (bounded by identityLookupTimeout).
Identity: lookupNodeIdentity(remoteAddress),
}

select {
Expand Down
123 changes: 123 additions & 0 deletions go/identity_bench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package tailscale

import (
"context"
"net/netip"
"os"
"testing"
"time"
)

// Benchmarks for the per-accept identity cost.
//
// The fd_transport benchmark in benchmark/ measures the socketpair data plane
// directly and never crosses the tsnet accept path, so it cannot see identity
// resolution. These benchmarks cover that gap: they isolate the one thing
// accept-time identity adds — a WhoIs over the in-process LocalAPI loopback.

// BenchmarkLookupNodeIdentityLoopback measures the cold-cache fallback: one
// LocalAPI WhoIs over the in-process loopback against a live netmap. This is
// the "before" — the per-accept cost when the cache is not warm. Gated on a
// Headscale environment (same gating as the Dart e2e) because a real control
// plane is required to produce a netmap.
//
// HEADSCALE_URL=http://localhost:8080 HEADSCALE_AUTH_KEY=<key> \
// go test -run '^$' -bench BenchmarkLookupNodeIdentityLoopback -benchtime=300x .
func BenchmarkLookupNodeIdentityLoopback(b *testing.B) {
ip := startTestNode(b)
identityCache.invalidate() // force the loopback fallback path
if id := lookupNodeIdentity(ip); id != nil {
b.Logf("resolved self via loopback: nodeId=%s host=%s", id.NodeID, id.HostName)
} else {
b.Logf("self IP %s did not resolve; still measuring loopback round-trip", ip)
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = lookupNodeIdentity(ip)
}
}

// BenchmarkLookupNodeIdentityCached measures the warm path: the same
// lookupNodeIdentity call once the state watcher has mirrored the netmap into
// the identity cache. This is the "after" — what accept-time identity costs
// with the cache in place. Same Headscale gating.
func BenchmarkLookupNodeIdentityCached(b *testing.B) {
ip := startTestNode(b)
StartWatch()
b.Cleanup(StopWatch)

addr := netip.MustParseAddr(ip)
deadline := time.Now().Add(30 * time.Second)
for {
if id, ok := identityCache.lookup(addr); ok && id != nil {
b.Logf("cache warm: self nodeId=%s", id.NodeID)
break
}
if time.Now().After(deadline) {
b.Fatal("identity cache did not warm within 30s")
}
time.Sleep(100 * time.Millisecond)
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = lookupNodeIdentity(ip)
}
}

// BenchmarkIdentityCacheFloor is the lower bound a cache would approach: a pure
// in-memory address->identity read with no loopback. The gap between this and
// BenchmarkLookupNodeIdentityLoopback is what a netmap/result cache could save
// per accept — the number that decides whether a cache is worth its complexity.
// Runs without a tailnet.
func BenchmarkIdentityCacheFloor(b *testing.B) {
addr := netip.MustParseAddr("100.64.0.2")
cache := map[netip.Addr]*nodeIdentity{
addr: {
NodeID: "nABC123",
HostName: "peer-1",
Tags: []string{"tag:server"},
TailscaleIPs: []string{"100.64.0.2"},
},
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if cache[addr] == nil {
b.Fatal("unexpected cache miss")
}
}
}

// startTestNode brings up an ephemeral node against Headscale and returns its
// self tailnet IP once the netmap is ready. Skips when the environment is
// absent so `go test ./...` stays hermetic.
func startTestNode(tb testing.TB) string {
tb.Helper()
url := os.Getenv("HEADSCALE_URL")
key := os.Getenv("HEADSCALE_AUTH_KEY")
if url == "" || key == "" {
tb.Skip("set HEADSCALE_URL and HEADSCALE_AUTH_KEY to run the live identity benchmark")
}
if err := Start("dune-bench", key, url, tb.TempDir(), true); err != nil {
tb.Fatalf("Start: %v", err)
}
tb.Cleanup(Stop)

lc, err := lcOr("identityBench")
if err != nil {
tb.Fatalf("LocalClient: %v", err)
}
deadline := time.Now().Add(60 * time.Second)
for {
st, err := lc.Status(context.Background())
if err == nil && st != nil && st.Self != nil && len(st.Self.TailscaleIPs) > 0 {
return st.Self.TailscaleIPs[0].String()
}
if time.Now().After(deadline) {
tb.Fatal("node did not reach Running with a self IP within 60s")
}
time.Sleep(500 * time.Millisecond)
}
}
110 changes: 110 additions & 0 deletions go/identity_cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package tailscale

import (
"net/netip"
"sync"

"tailscale.com/tailcfg"
"tailscale.com/types/netmap"
)

// identityIndex is an in-memory address -> identity map mirrored from the
// netmap. It turns accept-time identity resolution into a lock + map read
// instead of a multi-millisecond LocalAPI WhoIs round-trip. It is rebuilt
// wholesale from each netmap the state watcher delivers and read on the accept
// hot path.
//
// It deliberately mirrors only the node's own addresses (the same source
// tailscaled indexes for WhoIs-by-address), which is exactly what an accepted
// connection presents: the RemoteAddr of an inbound tailnet connection is the
// peer's own tailnet IP, never a subnet-routed or 4via6 source. So by address
// type the index covers everything an accept can present; the cases WhoIs
// resolves that this skips can't appear as an accept RemoteAddr. The one
// transient gap is temporal, not structural: a peer admitted just before its
// netmap reaches the watcher — see lookup.
type identityIndex struct {
mu sync.RWMutex
populated bool
byAddr map[netip.Addr]*nodeIdentity
}

// identityCache is the process-wide accept-path identity index. There is one
// embedded engine per process, so one cache suffices.
var identityCache identityIndex

// replace swaps in a freshly built index and marks the cache warm. Called from
// the state watcher on every netmap tick.
func (c *identityIndex) replace(byAddr map[netip.Addr]*nodeIdentity) {
c.mu.Lock()
c.byAddr = byAddr
c.populated = true
c.mu.Unlock()
}

// invalidate marks the cache cold so subsequent lookups fall back to the
// authoritative LocalAPI WhoIs. Called when the watcher stops (node down /
// watch torn down), because a frozen index can drift from the live netmap.
func (c *identityIndex) invalidate() {
c.mu.Lock()
c.byAddr = nil
c.populated = false
c.mu.Unlock()
}

// lookup returns (identity, true) when the cache is warm. The boolean is false
// only when the cache is cold (never populated or invalidated), signaling the
// caller to fall back to a live lookup. A warm cache that lacks the address
// returns (nil, true) and is treated as authoritative — no live fallback —
// which keeps the accept path O(1) and non-DoS-able. The window this trades
// away: a peer admitted just before its netmap reaches the watcher resolves to
// nil until the next tick (self-healing); callers needing a hard guarantee
// use whois().
func (c *identityIndex) lookup(addr netip.Addr) (*nodeIdentity, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
if !c.populated {
return nil, false
}
return c.byAddr[addr], true
}

// buildIdentityIndex flattens a netmap into an address -> identity map: self
// and every peer, indexed by each of their tailnet addresses, with login names
// resolved from the netmap's UserProfiles. Pure and allocation-bounded so it
// can be unit-tested without a live backend. Each address maps to one shared
// *nodeIdentity, so a multi-homed node costs one identity regardless of how
// many addresses it advertises.
func buildIdentityIndex(nm *netmap.NetworkMap) map[netip.Addr]*nodeIdentity {
if nm == nil {
return map[netip.Addr]*nodeIdentity{}
}
out := make(map[netip.Addr]*nodeIdentity, len(nm.Peers)+1)
index := func(n tailcfg.NodeView) {
if !n.Valid() {
return
}
id := nodeIdentityFromView(n, loginNameForUser(nm, n.User()))
if id == nil {
return
}
addrs := n.Addresses()
for i := range addrs.Len() {
out[addrs.At(i).Addr()] = id
}
}
index(nm.SelfNode)
for _, peer := range nm.Peers {
index(peer)
}
return out
}

// loginNameForUser resolves a node's owning user to a login name via the
// netmap's UserProfiles. Returns "" for tagged nodes (no user) or when the
// profile is absent, matching the WhoIs path's empty-login behavior.
func loginNameForUser(nm *netmap.NetworkMap, uid tailcfg.UserID) string {
if up, ok := nm.UserProfiles[uid]; ok && up.Valid() {
return up.LoginName()
}
return ""
}
Loading
Loading