Browse Source

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
Signed-off-by: Fran Bull <[email protected]>
Fran Bull 2 months ago
parent
commit
076d5c7214

+ 110 - 0
appc/conn25.go

@@ -0,0 +1,110 @@
+// 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
+	transitIPs 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.transitIPs == nil {
+		c.transitIPs = make(map[tailcfg.NodeID]map[netip.Addr]netip.Addr)
+	}
+	peerMap, ok := c.transitIPs[nid]
+	if !ok {
+		peerMap = make(map[netip.Addr]netip.Addr)
+		c.transitIPs[nid] = peerMap
+	}
+	peerMap[tipr.TransitIP] = tipr.DestinationIP
+	return TransitIPResponse{}
+}
+
+func (c *Conn25) transitIPTarget(nid tailcfg.NodeID, tip netip.Addr) netip.Addr {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	return c.transitIPs[nid][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"`
+}

+ 188 - 0
appc/conn25_test.go

@@ -0,0 +1,188 @@
+// 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.transitIPTarget(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.transitIPTarget(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.transitIPTarget(nid, tip)
+	if gotAddr1 != dip {
+		t.Fatalf("Connector stored destination for tip(%v): %v, want %v", tip, gotAddr1, dip)
+	}
+	gotAddr2 := c.transitIPTarget(nid, tip2)
+	if gotAddr2 != dip2 {
+		t.Fatalf("Connector stored destination for tip(%v): %v, want %v", tip2, gotAddr2, dip2)
+	}
+	gotAddr3 := c.transitIPTarget(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.transitIPTarget(nid, tip)
+	if gotAddr1 != dip {
+		t.Fatalf("Connector stored destination for tip(%v): %v, want %v", tip, gotAddr1, dip)
+	}
+	gotAddr2 := c.transitIPTarget(nid, tip2)
+	if gotAddr2 != dip3 {
+		t.Fatalf("Connector stored destination for tip(%v): %v, want %v", tip2, gotAddr2, dip3)
+	}
+}
+
+// TestGetDstIPUnknownTIP tests that unknown transit addresses can be looked up without problem.
+func TestTransitIPTargetUnknownTIP(t *testing.T) {
+	c := &Conn25{}
+	nid := tailcfg.NodeID(1)
+	tip := netip.MustParseAddr("0.0.0.1")
+	got := c.transitIPTarget(nid, tip)
+	want := netip.Addr{}
+	if got != want {
+		t.Fatalf("Unknown transit addr, want: %v, got %v", want, got)
+	}
+}

+ 3 - 2
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+

+ 3 - 2
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+

+ 2 - 1
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

+ 8 - 0
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"

+ 84 - 0
feature/conn25/conn25.go

@@ -0,0 +1,84 @@
+// 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 connector 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) {
+	const maxBodyBytes = 1024 * 1024
+	defer r.Body.Close()
+	if r.Method != "POST" {
+		http.Error(w, "Method should be POST", http.StatusMethodNotAllowed)
+		return
+	}
+	var req appc.ConnectorTransitIPRequest
+	err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBodyBytes+1)).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)
+}