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 <fran@tailscale.com>main
parent
dd1bb8ee42
commit
076d5c7214
@ -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"` |
||||
} |
||||
@ -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) |
||||
} |
||||
} |
||||
@ -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" |
||||
@ -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) |
||||
} |
||||
Loading…
Reference in new issue