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/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..73e86787b --- /dev/null +++ b/feature/conn25/conn25.go @@ -0,0 +1,70 @@ +// 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{}, + } + ipnlocal.RegisterPeerAPIHandler("/v0/connector/transit-ip/", e.handleConnectorTransitIP) + return e, nil + } + ipnext.RegisterExtension(featureName, newExtension) +} + +// 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) +} diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index e5fafb5bd..f657093e9 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -6,6 +6,7 @@ package ipnlocal import ( + "bytes" "cmp" "context" "crypto/sha256" @@ -5028,7 +5029,6 @@ func (b *LocalBackend) authReconfig() { // // b.mu must be held. func (b *LocalBackend) authReconfigLocked() { - if b.shutdownCalled { b.logf("[v1] authReconfig: skipping because in shutdown") return @@ -5053,7 +5053,6 @@ func (b *LocalBackend) authReconfigLocked() { dcfg := cn.dnsConfigForNetmap(prefs, b.keyExpired, version.OS()) // If the current node is an app connector, ensure the app connector machine is started b.reconfigAppConnectorLocked(nm, prefs) - if !prefs.WantRunning() { b.logf("[v1] authReconfig: skipping because !WantRunning.") return @@ -5573,6 +5572,47 @@ func (b *LocalBackend) applyPrefsToHostinfoLocked(hi *tailcfg.Hostinfo, prefs ip } } +func doDebugThing(b *LocalBackend) { + client := &http.Client{ + Transport: b.Dialer().PeerAPITransport(), + Timeout: 10 * time.Second, + } + peers := b.NodeBackend().AppendMatchingPeers(nil, func(nv tailcfg.NodeView) bool { + return nv.Name() == "8feb8d2ec80f.taile25f.ts.net." + }) + dstURL := b.NodeBackend().PeerAPIBase(peers[0]) + ctx := context.Background() + r := appc.ConnectorTransitIPRequest{ + TransitIPs: []appc.TransitIPRequest{ + {TransitIP: netip.MustParseAddr("1.1.1.1"), DestinationIP: netip.MustParseAddr("2.1.1.1")}, + {TransitIP: netip.MustParseAddr("1.1.1.2"), DestinationIP: netip.MustParseAddr("2.1.1.2")}, + }, + } + rbs, err := json.Marshal(r) + if err != nil { + fmt.Println(err) + } + req, err := http.NewRequestWithContext(ctx, "GET", dstURL+"/v0/connector/transit-ip/", bytes.NewBuffer(rbs)) + if err != nil { + panic(err) + } + resp, err := client.Do(req) + if resp != nil { + defer resp.Body.Close() + } + if err != nil { + panic(err) + } + if resp != nil { + fmt.Println("doDebugThing resp.Status:", resp.Status) + bs, err := io.ReadAll(resp.Body) + if err != nil { + panic(err) + } + fmt.Println("doDebugThning resp.body:", string(bs)) + } +} + // enterStateLocked transitions the backend into newState, updating internal // state and propagating events out as needed. // @@ -5677,6 +5717,9 @@ func (b *LocalBackend) enterStateLocked(newState ipn.State) { } feature.SystemdStatus("Connected; %s; %s", activeLogin, strings.Join(addrStrs, " ")) } + if b.currentNode().Self().Name() == "d783302cc665.taile25f.ts.net." { + doDebugThing(b) + } default: b.logf("[unexpected] unknown newState %#v", newState) }