From 8c132863b0b301eb103886f5c89e280cc6dc5adc Mon Sep 17 00:00:00 2001 From: Fran Bull Date: Tue, 25 Nov 2025 14:23:51 -0800 Subject: [PATCH] wip --- appc/conn25.go | 116 +++++++++++++++++++++++++++++++++++++++++ appc/conn25_test.go | 53 +++++++++++++++++++ ipn/ipnlocal/conn25.go | 39 ++++++++++++++ ipn/ipnlocal/local.go | 1 + 4 files changed, 209 insertions(+) create mode 100644 appc/conn25.go create mode 100644 appc/conn25_test.go create mode 100644 ipn/ipnlocal/conn25.go diff --git a/appc/conn25.go b/appc/conn25.go new file mode 100644 index 000000000..b0105c41c --- /dev/null +++ b/appc/conn25.go @@ -0,0 +1,116 @@ +package appc + +import ( + "net/netip" + "sync" + + "tailscale.com/tailcfg" +) + +type Conn25 struct { + mu sync.Mutex + transitIPMap map[tailcfg.NodeID]map[netip.Addr]netip.Addr +} + +func (c *Conn25) HandleConnectorTransitIPRequest(nid tailcfg.NodeID, ctipr ConnectorTransitIPRequest) ConnectorTransitIPResponse { + resp := ConnectorTransitIPResponse{} + for _, each := range ctipr.TransitIPs { + tipresp := c.handleTransitIPRequest(nid, each) + 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{} +} + +// TransitIPRequest details a single TransitIP allocation request from a client to a +// connector. +type TransitIPRequest struct { + // Mapping details + + // TransitIP is the intermediate destination IP that will be received at this + // connector and will be replaced by DestinationIP when performing DNAT. The + // TransitIP is specific to the peer making this request and must be within the + // tailnet's TransitIP range. + // If the connector already has a mapping for this TransitIP from this client, it + // will be replaced with the new mapping specified here. It is an error to request + // the same TransitIP more than once in the same [ConnectorTransitIPRequest]. + TransitIP netip.Addr `json:"transitIP,omitzero"` + // DestinationIP is the final destination IP that connections to the TransitIP + // should be mapped to when performing DNAT. + // If the connector already has a mapping for this DestinationIP in the context of + // this client and App, then the connector may immediately expire the old mapping. + DestinationIP netip.Addr `json:"destinationIP,omitzero"` + // AppName is the name of the connector application from the tailnet + // configuration, as listed in [appctype.AppConnectorAttr.Name]. + App string `json:"app,omitzero"` + + // Proof of destination IP (optional) + + // FQDNs is an optional list of FQDNs that have previously resolved to the + // requested destination IP. If the connector's destination IP cache does not + // currently indicate that the destination IP applies to the app, then the + // connector may attempt DNS resolution to confirm the destination IP instead of + // rejecting the request. + FQDNs []string `json:"fqdns,omitempty"` +} + +// 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 { + // Clear is set when the client wishes to flush the connector's TransitIP + // configuration for this client. The connector may ignore Clear, for it is simply + // a hint to accelerate cache expiry. Clients are expected to set this on the + // first request to a particular connector after client start in order to clear + // lingering mappings from a prior instance. + Clear bool `json:"clear,omitzero"` + // TransitIPs is the list of requested mappings. + TransitIPs []TransitIPRequest `json:"transitIPs,omitempty"` +} +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 + // MissingProof indicates that the mapping failed because the connector has not + // seen sufficient proof (via local cache or the Proof section of + // [TransitIPRequest]) that the requested destination IP applies to the specified + // App. The request can be retried after supplying additional proof. + MissingProof TransitIPResponseCode = 2 +) + +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"` +} +type ConnectorTransitIPResponse struct { + // Clear is set when the connector wishes to flush the client's TransitIP + // configuration for this connector. The client may ignore Clear, for it is simply + // a hint to accelerate cache expiry. Connectors are expected to set this on the + // first response to a particular client after connector start in order to clear + // lingering mappings from a prior instance. + Clear bool `json:"clear,omitzero"` + // 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..8449dc6cb --- /dev/null +++ b/appc/conn25_test.go @@ -0,0 +1,53 @@ +package appc + +import ( + "net/netip" + "testing" + + "tailscale.com/tailcfg" +) + +func TestHandleConnectorTransitIPRequest(t *testing.T) { + c := &Conn25{} + // specific to the peer making this request and + // must be within the tailnet's TransitIP range. + // If the connector already has a mapping for this TransitIP from this client, it will be replaced with the new mapping specified here. + // It is an error to request the same TransitIP more than once in the same [ConnectorTransitIPRequest]. + + req := ConnectorTransitIPRequest{} + nid := tailcfg.NodeID(1) + resp := c.HandleConnectorTransitIPRequest(nid, req) + if len(resp.TransitIPs) != 0 { + t.Fatal("shoulda been 0") + } + + tip := netip.MustParseAddr("0.0.0.1") + dip := netip.MustParseAddr("1.2.3.4") + req = ConnectorTransitIPRequest{ + TransitIPs: []TransitIPRequest{ + {TransitIP: tip, DestinationIP: dip}, + }, + } + resp = c.HandleConnectorTransitIPRequest(tailcfg.NodeID(1), req) + if len(resp.TransitIPs) != 1 { + t.Fatal("shoulda been 1") + } + if resp.TransitIPs[0].Code != TransitIPResponseCode(0) { + t.Fatal("shoulda been 0") + } + func() { + c.mu.Lock() + defer c.mu.Unlock() + state, ok := c.transitIPMap[nid] + if !ok { + t.Fatal("shoulda found it") + } + stored, ok := state[tip] + if !ok { + t.Fatal("shoulda found it") + } + if stored != dip { + t.Fatal("shoulda been dip") + } + }() +} diff --git a/ipn/ipnlocal/conn25.go b/ipn/ipnlocal/conn25.go new file mode 100644 index 000000000..7b44a7405 --- /dev/null +++ b/ipn/ipnlocal/conn25.go @@ -0,0 +1,39 @@ +package ipnlocal + +import ( + "encoding/json" + "net/http" + + "tailscale.com/appc" +) + +// ok, so ipnlocal imports appc +// so we can't put the peeraip registration and handling that conn25 wants in appc, because we get an import loop +// possible solutions: +// 1. ipnlocal stops importing appc +// (how would this work?) +// 2. conn25 doesn't go in appc +// (this might really be a part of 1. not sure, Adrian said put it in there so not approaching this as the first thing, how does ipnlocal _have_ an appc without importing appc +// anyway (obvz it can't but how does the whole thing work then?)) +// 3. put the peerapi registration and handling for conn25 in ipnlocal <- that's what we're doing here + +func init() { + RegisterPeerAPIHandler("/v0/connector/transit-ip/", handleConnectorTransitIP) +} + +func handleConnectorTransitIP(h 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 := h.LocalBackend().Conn25.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) +} diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 0ff299399..d6c2e2469 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -270,6 +270,7 @@ type LocalBackend struct { ccGen clientGen // function for producing controlclient; lazily populated sshServer SSHServer // or nil, initialized lazily. appConnector *appc.AppConnector // or nil, initialized when configured. + Conn25 *appc.Conn25 // or nil, initialized when configured. // notifyCancel cancels notifications to the current SetNotifyCallback. notifyCancel context.CancelFunc cc controlclient.Client // TODO(nickkhyl): move to nodeBackend