From 8946471a450d8cbbdf0653b09f1ba0006f031da3 Mon Sep 17 00:00:00 2001 From: Fran Bull Date: Mon, 8 Dec 2025 10:47:32 -0800 Subject: [PATCH] appc,feature: add the start of new conn25 app connector When peers request an IP address mapping to be stored, the connector stores it in memory. Fixes tailscale/corp#34251 --- appc/conn25.go | 117 ++++++++++++++++++ appc/conn25_test.go | 176 +++++++++++++++++++++++++++ cmd/tailscaled/depaware-min.txt | 5 +- cmd/tailscaled/depaware-minbox.txt | 5 +- cmd/tailscaled/depaware.txt | 3 +- feature/condregister/maybe_conn25.go | 8 ++ feature/conn25/conn25.go | 79 ++++++++++++ 7 files changed, 388 insertions(+), 5 deletions(-) create mode 100644 appc/conn25.go create mode 100644 appc/conn25_test.go create mode 100644 feature/condregister/maybe_conn25.go create mode 100644 feature/conn25/conn25.go diff --git a/appc/conn25.go b/appc/conn25.go new file mode 100644 index 000000000..563fc0703 --- /dev/null +++ b/appc/conn25.go @@ -0,0 +1,117 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package appc + +import ( + "net/netip" + "sync" + + "tailscale.com/tailcfg" +) + +// Conn25 holds the developing state for the as yet nascent next generation app connector. +// There is currently (2025-12-08) no actual app connecting functionality. +type Conn25 struct { + mu sync.Mutex + transitIPMap map[tailcfg.NodeID]map[netip.Addr]netip.Addr +} + +const dupeTransitIPMessage = "Duplicate transit address in ConnectorTransitIPRequest" + +// HandleConnectorTransitIPRequest creates a ConnectorTransitIPResponse in response to a ConnectorTransitIPRequest. +// It updates the connectors mapping of TransitIP->DestinationIP per peer (tailcfg.NodeID). +// If a peer has stored this mapping in the connector Conn25 will route traffic to TransitIPs to DestinationIPs for that peer. +func (c *Conn25) HandleConnectorTransitIPRequest(nid tailcfg.NodeID, ctipr ConnectorTransitIPRequest) ConnectorTransitIPResponse { + resp := ConnectorTransitIPResponse{} + seen := map[netip.Addr]bool{} + for _, each := range ctipr.TransitIPs { + if seen[each.TransitIP] { + resp.TransitIPs = append(resp.TransitIPs, TransitIPResponse{ + Code: OtherFailure, + Message: dupeTransitIPMessage, + }) + continue + } + tipresp := c.handleTransitIPRequest(nid, each) + seen[each.TransitIP] = true + resp.TransitIPs = append(resp.TransitIPs, tipresp) + } + return resp +} + +func (c *Conn25) handleTransitIPRequest(nid tailcfg.NodeID, tipr TransitIPRequest) TransitIPResponse { + c.mu.Lock() + defer c.mu.Unlock() + if c.transitIPMap == nil { + c.transitIPMap = make(map[tailcfg.NodeID]map[netip.Addr]netip.Addr) + } + peerMap, ok := c.transitIPMap[nid] + if !ok { + peerMap = make(map[netip.Addr]netip.Addr) + c.transitIPMap[nid] = peerMap + } + peerMap[tipr.TransitIP] = tipr.DestinationIP + return TransitIPResponse{} +} + +func (c *Conn25) getDstAddr(nid tailcfg.NodeID, tip netip.Addr) netip.Addr { + c.mu.Lock() + defer c.mu.Unlock() + if c.transitIPMap == nil { + return netip.Addr{} + } + peerMap, ok := c.transitIPMap[nid] + if !ok { + return netip.Addr{} + } + return peerMap[tip] +} + +// TransitIPRequest details a single TransitIP allocation request from a client to a +// connector. +type TransitIPRequest struct { + // TransitIP is the intermediate destination IP that will be received at this + // connector and will be replaced by DestinationIP when performing DNAT. + TransitIP netip.Addr `json:"transitIP,omitzero"` + + // DestinationIP is the final destination IP that connections to the TransitIP + // should be mapped to when performing DNAT. + DestinationIP netip.Addr `json:"destinationIP,omitzero"` +} + +// ConnectorTransitIPRequest is the request body for a PeerAPI request to +// /connector/transit-ip and can include zero or more TransitIP allocation requests. +type ConnectorTransitIPRequest struct { + // TransitIPs is the list of requested mappings. + TransitIPs []TransitIPRequest `json:"transitIPs,omitempty"` +} + +// TransitIPResponseCode appears in TransitIPResponse and signifies success or failure status. +type TransitIPResponseCode int + +const ( + // OK indicates that the mapping was created as requested. + OK TransitIPResponseCode = 0 + + // OtherFailure indicates that the mapping failed for a reason that does not have + // another relevant [TransitIPResponsecode]. + OtherFailure TransitIPResponseCode = 1 +) + +// TransitIPResponse is the response to a TransitIPRequest +type TransitIPResponse struct { + // Code is an error code indicating success or failure of the [TransitIPRequest]. + Code TransitIPResponseCode `json:"code,omitzero"` + // Message is an error message explaining what happened, suitable for logging but + // not necessarily suitable for displaying in a UI to non-technical users. It + // should be empty when [Code] is [OK]. + Message string `json:"message,omitzero"` +} + +// ConnectorTransitIPResponse is the response to a ConnectorTransitIPRequest +type ConnectorTransitIPResponse struct { + // TransitIPs is the list of outcomes for each requested mapping. Elements + // correspond to the order of [ConnectorTransitIPRequest.TransitIPs]. + TransitIPs []TransitIPResponse `json:"transitIPs,omitempty"` +} diff --git a/appc/conn25_test.go b/appc/conn25_test.go new file mode 100644 index 000000000..1cda983fd --- /dev/null +++ b/appc/conn25_test.go @@ -0,0 +1,176 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package appc + +import ( + "net/netip" + "testing" + + "tailscale.com/tailcfg" +) + +// TestHandleConnectorTransitIPRequestZeroLength tests that if sent a +// ConnectorTransitIPRequest with 0 TransitIPRequests, we respond with a +// ConnectorTransitIPResponse with 0 TransitIPResponses. +func TestHandleConnectorTransitIPRequestZeroLength(t *testing.T) { + c := &Conn25{} + req := ConnectorTransitIPRequest{} + nid := tailcfg.NodeID(1) + + resp := c.HandleConnectorTransitIPRequest(nid, req) + if len(resp.TransitIPs) != 0 { + t.Fatalf("n TransitIPs in response: %d, want 0", len(resp.TransitIPs)) + } +} + +// TestHandleConnectorTransitIPRequestStoresAddr tests that if sent a +// request with a transit addr and a destination addr we store that mapping +// and can retrieve it. If sent another req with a different dst for that transit addr +// we store that instead. +func TestHandleConnectorTransitIPRequestStoresAddr(t *testing.T) { + c := &Conn25{} + nid := tailcfg.NodeID(1) + tip := netip.MustParseAddr("0.0.0.1") + dip := netip.MustParseAddr("1.2.3.4") + dip2 := netip.MustParseAddr("1.2.3.5") + mr := func(t, d netip.Addr) ConnectorTransitIPRequest { + return ConnectorTransitIPRequest{ + TransitIPs: []TransitIPRequest{ + {TransitIP: t, DestinationIP: d}, + }, + } + } + + resp := c.HandleConnectorTransitIPRequest(nid, mr(tip, dip)) + if len(resp.TransitIPs) != 1 { + t.Fatalf("n TransitIPs in response: %d, want 1", len(resp.TransitIPs)) + } + got := resp.TransitIPs[0].Code + if got != TransitIPResponseCode(0) { + t.Fatalf("TransitIP Code: %d, want 0", got) + } + gotAddr := c.getDstAddr(nid, tip) + if gotAddr != dip { + t.Fatalf("Connector stored destination for tip: %v, want %v", gotAddr, dip) + } + + // mapping can be overwritten + resp2 := c.HandleConnectorTransitIPRequest(nid, mr(tip, dip2)) + if len(resp2.TransitIPs) != 1 { + t.Fatalf("n TransitIPs in response: %d, want 1", len(resp2.TransitIPs)) + } + got2 := resp.TransitIPs[0].Code + if got2 != TransitIPResponseCode(0) { + t.Fatalf("TransitIP Code: %d, want 0", got2) + } + gotAddr2 := c.getDstAddr(nid, tip) + if gotAddr2 != dip2 { + t.Fatalf("Connector stored destination for tip: %v, want %v", gotAddr, dip2) + } +} + +// TestHandleConnectorTransitIPRequestMultipleTIP tests that we can +// get a req with multiple mappings and we store them all. Including +// multiple transit addrs for the same destination. +func TestHandleConnectorTransitIPRequestMultipleTIP(t *testing.T) { + c := &Conn25{} + nid := tailcfg.NodeID(1) + tip := netip.MustParseAddr("0.0.0.1") + tip2 := netip.MustParseAddr("0.0.0.2") + tip3 := netip.MustParseAddr("0.0.0.3") + dip := netip.MustParseAddr("1.2.3.4") + dip2 := netip.MustParseAddr("1.2.3.5") + req := ConnectorTransitIPRequest{ + TransitIPs: []TransitIPRequest{ + {TransitIP: tip, DestinationIP: dip}, + {TransitIP: tip2, DestinationIP: dip2}, + // can store same dst addr for multiple transit addrs + {TransitIP: tip3, DestinationIP: dip}, + }, + } + resp := c.HandleConnectorTransitIPRequest(nid, req) + if len(resp.TransitIPs) != 3 { + t.Fatalf("n TransitIPs in response: %d, want 3", len(resp.TransitIPs)) + } + + for i := 0; i < 3; i++ { + got := resp.TransitIPs[i].Code + if got != TransitIPResponseCode(0) { + t.Fatalf("i=%d TransitIP Code: %d, want 0", i, got) + } + } + gotAddr1 := c.getDstAddr(nid, tip) + if gotAddr1 != dip { + t.Fatalf("Connector stored destination for tip(%v): %v, want %v", tip, gotAddr1, dip) + } + gotAddr2 := c.getDstAddr(nid, tip2) + if gotAddr2 != dip2 { + t.Fatalf("Connector stored destination for tip(%v): %v, want %v", tip2, gotAddr2, dip2) + } + gotAddr3 := c.getDstAddr(nid, tip3) + if gotAddr3 != dip { + t.Fatalf("Connector stored destination for tip(%v): %v, want %v", tip3, gotAddr3, dip) + } +} + +// TestHandleConnectorTransitIPRequestSameTIP tests that if we get +// a req that has more than one TransitIPRequest for the same transit addr +// only the first is stored, and the subsequent ones get an error code and +// message in the response. +func TestHandleConnectorTransitIPRequestSameTIP(t *testing.T) { + c := &Conn25{} + nid := tailcfg.NodeID(1) + tip := netip.MustParseAddr("0.0.0.1") + tip2 := netip.MustParseAddr("0.0.0.2") + dip := netip.MustParseAddr("1.2.3.4") + dip2 := netip.MustParseAddr("1.2.3.5") + dip3 := netip.MustParseAddr("1.2.3.6") + req := ConnectorTransitIPRequest{ + TransitIPs: []TransitIPRequest{ + {TransitIP: tip, DestinationIP: dip}, + // cannot have dupe TransitIPs in one ConnectorTransitIPRequest + {TransitIP: tip, DestinationIP: dip2}, + {TransitIP: tip2, DestinationIP: dip3}, + }, + } + + resp := c.HandleConnectorTransitIPRequest(nid, req) + if len(resp.TransitIPs) != 3 { + t.Fatalf("n TransitIPs in response: %d, want 3", len(resp.TransitIPs)) + } + + got := resp.TransitIPs[0].Code + if got != TransitIPResponseCode(0) { + t.Fatalf("i=0 TransitIP Code: %d, want 0", got) + } + msg := resp.TransitIPs[0].Message + if msg != "" { + t.Fatalf("i=0 TransitIP Message: \"%s\", want \"%s\"", msg, "") + } + got1 := resp.TransitIPs[1].Code + if got1 != TransitIPResponseCode(1) { + t.Fatalf("i=1 TransitIP Code: %d, want 1", got1) + } + msg1 := resp.TransitIPs[1].Message + if msg1 != dupeTransitIPMessage { + t.Fatalf("i=1 TransitIP Message: \"%s\", want \"%s\"", msg1, dupeTransitIPMessage) + } + got2 := resp.TransitIPs[2].Code + if got2 != TransitIPResponseCode(0) { + t.Fatalf("i=2 TransitIP Code: %d, want 0", got2) + } + msg2 := resp.TransitIPs[2].Message + if msg2 != "" { + t.Fatalf("i=2 TransitIP Message: \"%s\", want \"%s\"", msg, "") + } + + gotAddr1 := c.getDstAddr(nid, tip) + if gotAddr1 != dip { + t.Fatalf("Connector stored destination for tip(%v): %v, want %v", tip, gotAddr1, dip) + } + gotAddr2 := c.getDstAddr(nid, tip2) + if gotAddr2 != dip3 { + t.Fatalf("Connector stored destination for tip(%v): %v, want %v", tip2, gotAddr2, dip3) + } +} diff --git a/cmd/tailscaled/depaware-min.txt b/cmd/tailscaled/depaware-min.txt index 69e6559a0..942c96228 100644 --- a/cmd/tailscaled/depaware-min.txt +++ b/cmd/tailscaled/depaware-min.txt @@ -35,7 +35,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de 💣 go4.org/mem from tailscale.com/control/controlbase+ go4.org/netipx from tailscale.com/ipn/ipnlocal+ tailscale.com from tailscale.com/version - tailscale.com/appc from tailscale.com/ipn/ipnlocal + tailscale.com/appc from tailscale.com/ipn/ipnlocal+ tailscale.com/atomicfile from tailscale.com/ipn+ tailscale.com/client/tailscale/apitype from tailscale.com/ipn/ipnauth+ tailscale.com/cmd/tailscaled/childproc from tailscale.com/cmd/tailscaled @@ -58,13 +58,14 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de tailscale.com/feature/condregister from tailscale.com/cmd/tailscaled tailscale.com/feature/condregister/portmapper from tailscale.com/feature/condregister tailscale.com/feature/condregister/useproxy from tailscale.com/feature/condregister + tailscale.com/feature/conn25 from tailscale.com/feature/condregister tailscale.com/health from tailscale.com/control/controlclient+ tailscale.com/health/healthmsg from tailscale.com/ipn/ipnlocal+ tailscale.com/hostinfo from tailscale.com/cmd/tailscaled+ tailscale.com/ipn from tailscale.com/cmd/tailscaled+ tailscale.com/ipn/conffile from tailscale.com/cmd/tailscaled+ tailscale.com/ipn/ipnauth from tailscale.com/ipn/ipnext+ - tailscale.com/ipn/ipnext from tailscale.com/ipn/ipnlocal + tailscale.com/ipn/ipnext from tailscale.com/ipn/ipnlocal+ tailscale.com/ipn/ipnlocal from tailscale.com/cmd/tailscaled+ tailscale.com/ipn/ipnserver from tailscale.com/cmd/tailscaled tailscale.com/ipn/ipnstate from tailscale.com/control/controlclient+ diff --git a/cmd/tailscaled/depaware-minbox.txt b/cmd/tailscaled/depaware-minbox.txt index 55a21c426..acc424103 100644 --- a/cmd/tailscaled/depaware-minbox.txt +++ b/cmd/tailscaled/depaware-minbox.txt @@ -48,7 +48,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de 💣 go4.org/mem from tailscale.com/control/controlbase+ go4.org/netipx from tailscale.com/ipn/ipnlocal+ tailscale.com from tailscale.com/version - tailscale.com/appc from tailscale.com/ipn/ipnlocal + tailscale.com/appc from tailscale.com/ipn/ipnlocal+ tailscale.com/atomicfile from tailscale.com/ipn+ tailscale.com/client/local from tailscale.com/client/tailscale+ tailscale.com/client/tailscale from tailscale.com/internal/client/tailscale @@ -80,6 +80,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de tailscale.com/feature/condregister/oauthkey from tailscale.com/cmd/tailscale/cli tailscale.com/feature/condregister/portmapper from tailscale.com/feature/condregister+ tailscale.com/feature/condregister/useproxy from tailscale.com/cmd/tailscale/cli+ + tailscale.com/feature/conn25 from tailscale.com/feature/condregister tailscale.com/health from tailscale.com/control/controlclient+ tailscale.com/health/healthmsg from tailscale.com/ipn/ipnlocal+ tailscale.com/hostinfo from tailscale.com/cmd/tailscaled+ @@ -87,7 +88,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de tailscale.com/ipn from tailscale.com/cmd/tailscaled+ tailscale.com/ipn/conffile from tailscale.com/cmd/tailscaled+ tailscale.com/ipn/ipnauth from tailscale.com/ipn/ipnext+ - tailscale.com/ipn/ipnext from tailscale.com/ipn/ipnlocal + tailscale.com/ipn/ipnext from tailscale.com/ipn/ipnlocal+ tailscale.com/ipn/ipnlocal from tailscale.com/cmd/tailscaled+ tailscale.com/ipn/ipnserver from tailscale.com/cmd/tailscaled tailscale.com/ipn/ipnstate from tailscale.com/control/controlclient+ diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index 79f92deb9..5a5f0a1b3 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -243,7 +243,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de gvisor.dev/gvisor/pkg/tcpip/transport/udp from gvisor.dev/gvisor/pkg/tcpip/adapters/gonet+ gvisor.dev/gvisor/pkg/waiter from gvisor.dev/gvisor/pkg/context+ tailscale.com from tailscale.com/version - tailscale.com/appc from tailscale.com/ipn/ipnlocal + tailscale.com/appc from tailscale.com/ipn/ipnlocal+ 💣 tailscale.com/atomicfile from tailscale.com/ipn+ LD tailscale.com/chirp from tailscale.com/cmd/tailscaled tailscale.com/client/local from tailscale.com/client/web+ @@ -285,6 +285,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de tailscale.com/feature/condregister from tailscale.com/cmd/tailscaled tailscale.com/feature/condregister/portmapper from tailscale.com/feature/condregister tailscale.com/feature/condregister/useproxy from tailscale.com/feature/condregister + tailscale.com/feature/conn25 from tailscale.com/feature/condregister tailscale.com/feature/debugportmapper from tailscale.com/feature/condregister tailscale.com/feature/doctor from tailscale.com/feature/condregister tailscale.com/feature/drive from tailscale.com/feature/condregister diff --git a/feature/condregister/maybe_conn25.go b/feature/condregister/maybe_conn25.go new file mode 100644 index 000000000..fb885bfe3 --- /dev/null +++ b/feature/condregister/maybe_conn25.go @@ -0,0 +1,8 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !ts_omit_conn25 + +package condregister + +import _ "tailscale.com/feature/conn25" diff --git a/feature/conn25/conn25.go b/feature/conn25/conn25.go new file mode 100644 index 000000000..7e195b848 --- /dev/null +++ b/feature/conn25/conn25.go @@ -0,0 +1,79 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// Package conn25 registers the conn25 feature and implements its associated ipnext.Extension. +package conn25 + +import ( + "encoding/json" + "net/http" + + "tailscale.com/appc" + "tailscale.com/feature" + "tailscale.com/ipn/ipnext" + "tailscale.com/ipn/ipnlocal" + "tailscale.com/types/logger" +) + +// featureName is the name of the feature implemented by this package. +// It is also the [extension] name and the log prefix. +const featureName = "conn25" + +func init() { + feature.Register(featureName) + newExtension := func(logf logger.Logf, sb ipnext.SafeBackend) (ipnext.Extension, error) { + e := &extension{ + conn: &appc.Conn25{}, + } + return e, nil + } + ipnext.RegisterExtension(featureName, newExtension) + ipnlocal.RegisterPeerAPIHandler("/v0/connector/transit-ip/", handleConnectorTransitIP) +} + +func handleConnectorTransitIP(h ipnlocal.PeerAPIHandler, w http.ResponseWriter, r *http.Request) { + e, ok := ipnlocal.GetExt[*extension](h.LocalBackend()) + if !ok { + http.Error(w, "miswired", http.StatusInternalServerError) + return + } + e.handleConnectorTransitIP(h, w, r) +} + +// extension is an [ipnext.Extension] managing the relay server on platforms +// that import this package. +type extension struct { + conn *appc.Conn25 +} + +// Name implements [ipnext.Extension]. +func (e *extension) Name() string { + return featureName +} + +// Init implements [ipnext.Extension]. +func (e *extension) Init(host ipnext.Host) error { + return nil +} + +// Shutdown implements [ipnlocal.Extension]. +func (e *extension) Shutdown() error { + return nil +} + +func (e *extension) handleConnectorTransitIP(h ipnlocal.PeerAPIHandler, w http.ResponseWriter, r *http.Request) { + var req appc.ConnectorTransitIPRequest + defer r.Body.Close() + err := json.NewDecoder(r.Body).Decode(&req) + if err != nil { + http.Error(w, "Error decoding JSON", http.StatusBadRequest) + return + } + resp := e.conn.HandleConnectorTransitIPRequest(h.Peer().ID(), req) + bs, err := json.Marshal(resp) + if err != nil { + http.Error(w, "Error encoding JSON", http.StatusInternalServerError) + return + } + w.Write(bs) +}