diff options
| author | Michael Ben-Ami <mzb@tailscale.com> | 2025-12-11 15:31:15 -0500 |
|---|---|---|
| committer | Michael Ben-Ami <mzb@tailscale.com> | 2026-02-04 15:53:58 -0500 |
| commit | c0422f977cdd7c292c37a7939b54ff4d226010a5 (patch) | |
| tree | c5f9f1b68fb05c98469fa09b1b1d8ee88457b201 | |
| parent | 40cd54daf73a154c3f8b60c020d70b11c1b5aa85 (diff) | |
| download | tailscale-mzb/dnat-exp.tar.xz tailscale-mzb/dnat-exp.zip | |
[DRAFT] appc,wgengine: sketch how connectors 2025 hooks into themzb/dnat-exp
datapath
This commit outlines basic NAT datapath actions for next-gen
app connectors, and FlowTable structure for caching those actions.
It also demonstrates datapath integration via tstun wrapper hooks,
and presents examples of the methods to be implemented at the
state-management layer (Conn25). It probably should not be merged as
is and this commit message should be re-written. There should be
more detail in the PR description.
| -rw-r--r-- | appc/conn25.go | 54 | ||||
| -rw-r--r-- | appc/conn25_datapath.go | 238 | ||||
| -rw-r--r-- | appc/conn25_flowtable.go | 117 | ||||
| -rw-r--r-- | cmd/tailscaled/tailscaled.go | 18 | ||||
| -rw-r--r-- | ipn/ipnlocal/local.go | 14 | ||||
| -rw-r--r-- | net/packet/capture.go | 3 | ||||
| -rw-r--r-- | net/packet/packet.go | 3 | ||||
| -rw-r--r-- | net/tstun/wrap.go | 20 | ||||
| -rw-r--r-- | types/appctype/appconnector.go | 4 | ||||
| -rw-r--r-- | types/appctype/conn25.go | 7 | ||||
| -rw-r--r-- | wgengine/filter/filter.go | 38 | ||||
| -rw-r--r-- | wgengine/userspace.go | 53 | ||||
| -rw-r--r-- | wgengine/wgcfg/nmcfg/nmcfg.go | 8 |
13 files changed, 544 insertions, 33 deletions
diff --git a/appc/conn25.go b/appc/conn25.go index 08ca651fd..0d8d5af02 100644 --- a/appc/conn25.go +++ b/appc/conn25.go @@ -5,7 +5,9 @@ package appc import ( "cmp" + "errors" "net/netip" + "os" "slices" "sync" @@ -15,11 +17,63 @@ import ( "tailscale.com/util/set" ) +// // mzbs fake functions //// +const testingTag = "tag:conn25-test" + +func (c *Conn25) ClientTransitIPForMagicIP(magic netip.Addr) (netip.Addr, error) { + if !magic.Is4() { + return netip.Addr{}, errors.New("bootleg transit ip for magic ip only deals with ip4 for now") + } + mb := magic.As4() + mb[0], mb[1] = 169, 254 + + return netip.AddrFrom4(mb), nil +} + +func (c *Conn25) ConnectorRealIPForTransitIPConnection(clientSrc, transitIP netip.Addr) (netip.Addr, error) { + // The transitIP may have overlap on this connector, right? + // In order to disambiguate we also need to know what client this came from. + // And all we have in the packet is the client src IP address. + return netip.MustParseAddr("104.16.184.241"), nil // icanhazip.com +} + +func (c *Conn25) SelfIsConnector() bool { + // We need this so that if this is a connector, the datapath can quickly look in the + // connector flow tracking table to fast path trafffic. + v, _ := os.LookupEnv("MZB_SELF_IS_CONNECTOR") + return v == "true" +} + +func (c *Conn25) AllowedLinkLocalDestination(addr netip.Addr) bool { + return transitIPs.Contains(addr) +} + +func (c *Conn25) AllTransitIPsForPeer(peer tailcfg.NodeView) []netip.Prefix { + // This method is expected to be called close to the wireguard config to configure + // WG Allowed IPs that aren't in the netmap Allowed IPs. + // For PoC purposes, anything with the testing tag will get the entire + // transit IP block, so the PoC should only use one connector at a time. + if peer.Tags().ContainsFunc(func(tag string) bool { return tag == testingTag }) { + return []netip.Prefix{transitIPs} + } + return nil + // TODO: Once Conn25 is more filled out, this function should search through state + // to determine which peers are active and what their transit IPs are, and append to the list. +} + +//// end mzbs fake functions //// + // 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 + + config config +} + +type config struct { + apps []appctype.Conn25Attr } const dupeTransitIPMessage = "Duplicate transit address in ConnectorTransitIPRequest" diff --git a/appc/conn25_datapath.go b/appc/conn25_datapath.go new file mode 100644 index 000000000..76dde6813 --- /dev/null +++ b/appc/conn25_datapath.go @@ -0,0 +1,238 @@ +package appc + +import ( + "log" + "net/netip" + + "tailscale.com/net/flowtrack" + "tailscale.com/net/packet" + "tailscale.com/net/packet/checksum" + "tailscale.com/wgengine" + "tailscale.com/wgengine/filter" +) + +////////// TESTING VARIABLES /////////////// + +var ( + magicIPs = netip.MustParsePrefix("172.16.25.0/24") + transitIPs = netip.MustParsePrefix("169.254.25.0/24") +) + +///////// END TESTING VARIABLES //////////// + +// datapathHandler is the main implementation of DatapathHandler. +type datapathHandler struct { + conn25 *Conn25 + clientFlowTable *FlowTable + connectorFlowTable *FlowTable +} + +func NewDatpathHooks() wgengine.AppConnectorPacketHooks { + return &datapathHandler{ + conn25: &Conn25{}, + clientFlowTable: NewFlowTable(0), + connectorFlowTable: NewFlowTable(0), + } +} + +func (dh *datapathHandler) HandlePacketsFromTunDevice(p *packet.Parsed) filter.Response { + log.Printf("Handling packet from tun device: %s", p.String()) + // Connector-bound traffic. + if dh.dstIPIsMagicIP(p) { + if err := dh.processClientToConnector(p); err != nil { + // TODO: log error? return error? + // Packets with a destination Magic IP, that we don't know + // what to do with, should be dropped. + // Perhaps we implement an ICMP error here, while dropping from + // the original datapath. + return filter.Drop + } + return filter.Accept + } + + // Return traffic from external application. + if dh.selfIsConnector() { + if err := dh.processConnectorToClient(p); err != nil { + switch err { + case nil, FlowNotFoundError: + // If we don't have a record of the flow, it could be normal + // traffic. We don't know if it's interesting connector return + // traffic unless we check the table, since it is not expected + // to have a Transit IP on it yet. + return filter.Accept + default: + return filter.Drop + } + } + } + + return filter.Accept +} + +func (dh *datapathHandler) HandlePacketsFromWireguard(p *packet.Parsed) filter.Response { + log.Printf("Handling packet from wireguard: %s", p.String()) + // Return traffic from connector, source is a Transit IP. + if dh.srcIsTransitIP(p) { + if err := dh.processClientFromConnector(p); err != nil { + // TODO: log error? return error? + // Packets coming in from wireguard with a source + // transit IP that don't have an entry in the flow table should + // be dropped. + return filter.Drop + } + return filter.Accept + } + + // Outgoing traffic for an external application. Destination is Transit IP. + if dh.selfIsConnector() && dh.dstIPIsTransitIP(p) { + if err := dh.processConnectorFromClient(p); err != nil { + // TODO: log or return error? + // Packets coming in from wireguard with a destination transit IP + // that error should be dropped. + return filter.Drop + } + } + return filter.Accept +} + +func (dh *datapathHandler) dnatAction(to netip.Addr) PacketAction { + return PacketAction(func(p *packet.Parsed) { checksum.UpdateDstAddr(p, to) }) +} + +func (dh *datapathHandler) snatAction(to netip.Addr) PacketAction { + return PacketAction(func(p *packet.Parsed) { checksum.UpdateSrcAddr(p, to) }) +} + +// processClientToConnector consults the flow table to determine which connector to send the packet to, +// and if this is a new flow, runs the connector selection algorithm, and installs a new flow. +// If the packet is valid, we DNAT from the Magic IP to the Transit IP. +// If there is no flow or the packet is otherwise invalid, we drop the packet. +func (dh *datapathHandler) processClientToConnector(p *packet.Parsed) error { + log.Printf("Proccessing on client to connector: %s", p.String()) + existing, err := dh.clientFlowTable.LookupFromTunDevice(flowtrack.MakeTuple(p.IPProto, p.Src, p.Dst)) + switch err { + case nil: + existing.Action(p) + log.Printf("Post-processing (existing) on client to connector: %s", p.String()) + return nil + case FlowNotFoundError: + magicIP := p.Dst.Addr() + transitIP, err := dh.conn25.ClientTransitIPForMagicIP(magicIP) + if err != nil { + return err + } + entry, err := dh.clientFlowTable.NewFlowFromTunDevice( + FlowData{ + Tuple: flowtrack.MakeTuple(p.IPProto, p.Src, p.Dst), + Action: dh.dnatAction(transitIP), + }, + FlowData{ + Tuple: flowtrack.MakeTuple(p.IPProto, netip.AddrPortFrom(transitIP, p.Dst.Port()), p.Src), + Action: dh.snatAction(magicIP), + }, + ) + if err != nil { + return err + } + entry.Action(p) + log.Printf("Post-processing (new) on client to connector: %s", p.String()) + return nil + default: + return err + } +} + +// processClientFromConnector consults the flow table to validate that the packet should +// be forwarded back to the local network stack. +// We SNAT the Transit IP back to the Magic IP. +// If there is no flow or the packet is otherwise invalid, we drop the packet. +func (dh *datapathHandler) processClientFromConnector(p *packet.Parsed) error { + log.Printf("Proccessing on client from connector: %s", p.String()) + existing, err := dh.clientFlowTable.LookupFromWireguard(flowtrack.MakeTuple(p.IPProto, p.Src, p.Dst)) + switch err { + case nil: + existing.Action(p) + log.Printf("Post-processing (existing) on client from connector: %s", p.String()) + return nil + default: + return err + } +} + +// processConnectorFromClient consults the flow table to see if this packet is part of +// an existing outbound flow to an application, or a new flow should be installed. +// If the packet is valid, we DNAT from the Transit IP to the external application IP. +// If there is no flow or the packet is otherwise invalid, we drop the packet. +func (dh *datapathHandler) processConnectorFromClient(p *packet.Parsed) error { + log.Printf("Proccessing on connector from client: %s", p.String()) + existing, err := dh.connectorFlowTable.LookupFromWireguard(flowtrack.MakeTuple(p.IPProto, p.Src, p.Dst)) + switch err { + case nil: + existing.Action(p) + log.Printf("Post-processing (new) on connector from client: %s", p.String()) + return nil + case FlowNotFoundError: + transitIP := p.Dst.Addr() + realIP, err := dh.conn25.ConnectorRealIPForTransitIPConnection(p.Src.Addr(), transitIP) + if err != nil { + return err + } + entry, err := dh.connectorFlowTable.NewFlowFromWireguard( + FlowData{ + Tuple: flowtrack.MakeTuple(p.IPProto, p.Src, p.Dst), + Action: dh.dnatAction(realIP), + }, + FlowData{ + Tuple: flowtrack.MakeTuple(p.IPProto, netip.AddrPortFrom(realIP, p.Dst.Port()), p.Src), + Action: dh.snatAction(transitIP), + }, + ) + if err != nil { + return err + } + entry.Action(p) + log.Printf("Post-processing (existing) on connector from client: %s", p.String()) + return nil + default: + return err + } +} + +// processConnectorToClient consults the flow table on a connector to determine which client +// to send the return traffic to. +// If the packet is valid, we SNAT the external application IP to the Transit IP. +// If there is no flow or the packet is otherwise invalid, we drop the packet. +func (dh *datapathHandler) processConnectorToClient(p *packet.Parsed) error { + log.Printf("Proccessing on connector to client: %s", p.String()) + existing, err := dh.connectorFlowTable.LookupFromTunDevice(flowtrack.MakeTuple(p.IPProto, p.Src, p.Dst)) + switch err { + case nil: + existing.Action(p) + log.Printf("Post-processing (existing) on connector to client: %s", p.String()) + return nil + default: + return err + } +} + +// dstIPIsMagicIP returns whether the destination IP address in p is Magic IP, +// which could indicate interesting traffic for outbound traffic from a client to a connector. +func (dh *datapathHandler) dstIPIsMagicIP(p *packet.Parsed) bool { + // TODO: implement for real + return magicIPs.Contains(p.Dst.Addr()) +} + +func (dh *datapathHandler) srcIsTransitIP(p *packet.Parsed) bool { + // TODO: implement for real + return transitIPs.Contains(p.Src.Addr()) +} + +func (dh *datapathHandler) dstIPIsTransitIP(p *packet.Parsed) bool { + // TODO: implement for real + return transitIPs.Contains(p.Dst.Addr()) +} + +// selfIsConnector returns whether this client is running on an app connector. +func (dh *datapathHandler) selfIsConnector() bool { + return dh.conn25.SelfIsConnector() +} diff --git a/appc/conn25_flowtable.go b/appc/conn25_flowtable.go new file mode 100644 index 000000000..9665c060a --- /dev/null +++ b/appc/conn25_flowtable.go @@ -0,0 +1,117 @@ +package appc + +import ( + "errors" + "sync" + + "tailscale.com/net/flowtrack" + "tailscale.com/net/packet" +) + +type PacketAction func(*packet.Parsed) + +type FlowData struct { + Tuple flowtrack.Tuple + Action PacketAction +} + +type Origin uint8 + +const ( + FromTun Origin = iota + FromWireguard +) + +type cachedFlow struct { + flow FlowData + paired flowtrack.Tuple // tuple for the other direction + allow Origin // which lookup is allowed to hit this entry +} + +var ( + FlowNotFoundError = errors.New("flow not found") + WrongDirectionError = errors.New("flow exists but wrong direction for lookup") +) + +type FlowTable struct { + mu sync.Mutex + lru flowtrack.Cache[cachedFlow] // guarded by mu +} + +func NewFlowTable(maxEntries int) *FlowTable { + t := &FlowTable{} + t.lru.MaxEntries = maxEntries + return t +} + +func opposite(o Origin) Origin { + if o == FromTun { + return FromWireguard + } + return FromTun +} + +// LookupFromTunDevice looks up a flow action that is valid to run for packets +// observed on the tun-device path. +func (t *FlowTable) LookupFromTunDevice(k flowtrack.Tuple) (FlowData, error) { + return t.lookup(k, FromTun) +} + +// LookupFromWireguard looks up a flow action that is valid to run for packets +// observed on the wireguard path. +func (t *FlowTable) LookupFromWireguard(k flowtrack.Tuple) (FlowData, error) { + return t.lookup(k, FromWireguard) +} + +func (t *FlowTable) lookup(k flowtrack.Tuple, want Origin) (FlowData, error) { + t.mu.Lock() + v, ok := t.lru.Get(k) + if !ok { + t.mu.Unlock() + return FlowData{}, FlowNotFoundError + } + if v.allow != want { + t.mu.Unlock() + return FlowData{}, WrongDirectionError + } + out := v.flow // copy + t.mu.Unlock() + return out, nil +} + +// NewFlowFromTunDevice installs (or overwrites) both the forward and return entries. +// The forward tuple is tagged as FromTun, and the return tuple is tagged as FromWireguard. +// If overwriting, it removes the old paired tuple for the forward key to avoid stale reverse mappings. +func (t *FlowTable) NewFlowFromTunDevice(fwd, ret FlowData) (FlowData, error) { + return t.newFlow(FromTun, fwd, ret) +} + +// NewFlowFromWireguard installs (or overwrites) both the forward and return entries, +// but tags the forward tuple as FromWireguard and the return tuple as FromTun. +// (Whether you *want* to allow installs from this direction is a separate policy question.) +func (t *FlowTable) NewFlowFromWireguard(fwd, ret FlowData) (FlowData, error) { + return t.newFlow(FromWireguard, fwd, ret) +} + +func (t *FlowTable) newFlow(primaryAllow Origin, fwd, ret FlowData) (FlowData, error) { + t.mu.Lock() + + // If overwriting an existing primary entry, remove its previously-paired mapping so + // we don't leave stale reverse tuples around. + if old, ok := t.lru.Get(fwd.Tuple); ok && old != nil { + t.lru.Remove(old.paired) + } + + t.lru.Add(fwd.Tuple, cachedFlow{ + flow: fwd, + paired: ret.Tuple, allow: primaryAllow, + }) + t.lru.Add(ret.Tuple, cachedFlow{ + flow: ret, + paired: fwd.Tuple, + allow: opposite(primaryAllow), + }) + + t.mu.Unlock() + return fwd, nil +} diff --git a/cmd/tailscaled/tailscaled.go b/cmd/tailscaled/tailscaled.go index df0d68e07..0cbb9594d 100644 --- a/cmd/tailscaled/tailscaled.go +++ b/cmd/tailscaled/tailscaled.go @@ -27,6 +27,7 @@ import ( "syscall" "time" + "tailscale.com/appc" "tailscale.com/cmd/tailscaled/childproc" "tailscale.com/control/controlclient" "tailscale.com/envknob" @@ -741,14 +742,15 @@ var tstunNew = tstun.New func tryEngine(logf logger.Logf, sys *tsd.System, name string) (onlyNetstack bool, err error) { conf := wgengine.Config{ - ListenPort: args.port, - NetMon: sys.NetMon.Get(), - HealthTracker: sys.HealthTracker.Get(), - Metrics: sys.UserMetricsRegistry(), - Dialer: sys.Dialer.Get(), - SetSubsystem: sys.Set, - ControlKnobs: sys.ControlKnobs(), - EventBus: sys.Bus.Get(), + ListenPort: args.port, + NetMon: sys.NetMon.Get(), + HealthTracker: sys.HealthTracker.Get(), + Metrics: sys.UserMetricsRegistry(), + Dialer: sys.Dialer.Get(), + SetSubsystem: sys.Set, + ControlKnobs: sys.ControlKnobs(), + EventBus: sys.Bus.Get(), + AppConnectorPacketHooks: appc.NewDatpathHooks(), } if f, ok := hookSetWgEnginConfigDrive.GetOk(); ok { f(&conf, logf) diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 300f7a4c3..5c7d1ebef 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -408,6 +408,8 @@ type LocalBackend struct { // getCertForTest is used to retrieve TLS certificates in tests. // See [LocalBackend.ConfigureCertsForTest]. getCertForTest func(hostname string) (*TLSCertKeyPair, error) + + conn25 *appc.Conn25 } // SetHardwareAttested enables hardware attestation key signatures in map @@ -525,6 +527,7 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo captiveCtx: captiveCtx, captiveCancel: nil, // so that we start checkCaptivePortalLoop when Running needsCaptiveDetection: make(chan bool), + conn25: &appc.Conn25{}, } nb := newNodeBackend(ctx, b.logf, b.sys.Bus.Get()) @@ -2876,10 +2879,10 @@ func (b *LocalBackend) updateFilterLocked(prefs ipn.PrefsView) { oldFilter := b.e.GetFilter() if shieldsUp { b.logf("[v1] netmap packet filter: (shields up)") - b.setFilter(filter.NewShieldsUpFilter(localNets, logNets, oldFilter, b.logf)) + b.setFilter(filter.NewShieldsUpFilter(localNets, logNets, oldFilter, b.logf, filter.WithLinkLocalDestinationAllower(b.conn25))) } else { b.logf("[v1] netmap packet filter: %v filters", len(packetFilter)) - b.setFilter(filter.New(packetFilter, b.srcIPHasCapForFilter, localNets, logNets, oldFilter, b.logf)) + b.setFilter(filter.New(packetFilter, b.srcIPHasCapForFilter, localNets, logNets, oldFilter, b.logf, filter.WithLinkLocalDestinationAllower(b.conn25))) } // The filter for a jailed node is the exact same as a ShieldsUp filter. oldJailedFilter := b.e.GetJailedFilter() @@ -5140,7 +5143,12 @@ func (b *LocalBackend) authReconfigLocked() { priv = key.NodePrivate{} } - cfg, err := nmcfg.WGCfg(priv, nm, b.logf, flags, prefs.ExitNodeID()) + var appConnectorTransitIPFn func(peer tailcfg.NodeView) []netip.Prefix + if b.conn25 != nil { + appConnectorTransitIPFn = b.conn25.AllTransitIPsForPeer + } + + cfg, err := nmcfg.WGCfg(priv, nm, b.logf, flags, prefs.ExitNodeID(), appConnectorTransitIPFn) if err != nil { b.logf("wgcfg: %v", err) return diff --git a/net/packet/capture.go b/net/packet/capture.go index 630a4b161..75da16f3a 100644 --- a/net/packet/capture.go +++ b/net/packet/capture.go @@ -45,7 +45,8 @@ type CaptureSink interface { RegisterOutput(w io.Writer) (unregister func()) } -// CaptureMeta contains metadata that is used when debugging. +// CaptureMeta contains metadata that is used when debugging, and +// for some filtering decisions. type CaptureMeta struct { DidSNAT bool // SNAT was performed & the address was updated. OriginalSrc netip.AddrPort // The source address before SNAT was performed. diff --git a/net/packet/packet.go b/net/packet/packet.go index b41e0dcd9..ec213653d 100644 --- a/net/packet/packet.go +++ b/net/packet/packet.go @@ -60,7 +60,8 @@ type Parsed struct { // TCPFlags is the packet's TCP flag bits. Valid iff IPProto == TCP. TCPFlags TCPFlag - // CaptureMeta contains metadata that is used when debugging. + // CaptureMeta contains metadata that is used when debugging, and + // for some filtering decisions. CaptureMeta CaptureMeta } diff --git a/net/tstun/wrap.go b/net/tstun/wrap.go index d463948a2..58180d7ca 100644 --- a/net/tstun/wrap.go +++ b/net/tstun/wrap.go @@ -171,6 +171,9 @@ type Wrapper struct { // PreFilterPacketInboundFromWireGuard is the inbound filter function that runs before the main filter // and therefore sees the packets that may be later dropped by it. PreFilterPacketInboundFromWireGuard FilterFunc + // PostFilterPacketInboundFromWireGuardAppConector runs after the filter, but before PostFilterPacketInboundFromWireGuard. + // Non-app connector traffic is passed along. Invalid app connector traffic is dropped. + PostFilterPacketInboundFromWireGuardAppConector FilterFunc // PostFilterPacketInboundFromWireGuard is the inbound filter function that runs after the main filter. PostFilterPacketInboundFromWireGuard GROFilterFunc // PreFilterPacketOutboundToWireGuardNetstackIntercept is a filter function that runs before the main filter @@ -183,6 +186,10 @@ type Wrapper struct { // packets which it handles internally. If both this and PreFilterFromTunToNetstack // filter functions are non-nil, this filter runs second. PreFilterPacketOutboundToWireGuardEngineIntercept FilterFunc + // PreFilterPacketOutboundToWireGuardAppConnectorIntercept runs after PreFilterPacketOutboundToWireGuardEngineIntercept + // for app connector specific traffic. Non-app connector traffic is passed along. Invalid app connector traffic is + // dropped. + PreFilterPacketOutboundToWireGuardAppConnectorIntercept FilterFunc // PostFilterPacketOutboundToWireGuard is the outbound filter function that runs after the main filter. PostFilterPacketOutboundToWireGuard FilterFunc @@ -872,6 +879,12 @@ func (t *Wrapper) filterPacketOutboundToWireGuard(p *packet.Parsed, pc *peerConf return res, gro } } + if t.PreFilterPacketOutboundToWireGuardAppConnectorIntercept != nil { + // TODO(mzb): write good comment hereHandled by userspaceEngine. + if res := t.PreFilterPacketOutboundToWireGuardAppConnectorIntercept(p, t); res.IsDrop() { + return res, gro + } + } // If the outbound packet is to a jailed peer, use our jailed peer // packet filter. @@ -1234,6 +1247,13 @@ func (t *Wrapper) filterPacketInboundFromWireGuard(p *packet.Parsed, captHook pa return filter.Drop, gro } + if t.PostFilterPacketInboundFromWireGuardAppConector != nil { + // TODO(mzb): write a good comment here + if res := t.PostFilterPacketInboundFromWireGuardAppConector(p, t); res.IsDrop() { + return res, gro + } + } + if t.PostFilterPacketInboundFromWireGuard != nil { var res filter.Response res, gro = t.PostFilterPacketInboundFromWireGuard(p, t, gro) diff --git a/types/appctype/appconnector.go b/types/appctype/appconnector.go index 5442e8290..9aed3fac9 100644 --- a/types/appctype/appconnector.go +++ b/types/appctype/appconnector.go @@ -74,6 +74,10 @@ type AppConnectorAttr struct { Connectors []string `json:"connectors,omitempty"` } +// AppConnectorExperimentalAttr is the same as AppConnectorAttr +// as it is being developed. +type AppConnectorExperimentalAttr = AppConnectorAttr + // RouteInfo is a data structure used to persist the in memory state of an AppConnector // so that we can know, even after a restart, which routes came from ACLs and which were // learned from domains. diff --git a/types/appctype/conn25.go b/types/appctype/conn25.go new file mode 100644 index 000000000..b928e6dda --- /dev/null +++ b/types/appctype/conn25.go @@ -0,0 +1,7 @@ +package appctype + +const AppConnectorExperimentalCap = "tailscale.com/app-connectors-experimental" + +// AppConnectorExperimentalAttr is the same as AppConnectorAttr +// as it is being developed. +type Conn25Attr = AppConnectorAttr diff --git a/wgengine/filter/filter.go b/wgengine/filter/filter.go index 63a7aee1e..aeaf10e8d 100644 --- a/wgengine/filter/filter.go +++ b/wgengine/filter/filter.go @@ -66,6 +66,19 @@ type Filter struct { state *filterState shieldsUp bool + + linkLocalDestinationAllower LinkLocalDestinationAllower +} +type FilterOption func(*Filter) + +type LinkLocalDestinationAllower interface { + AllowedLinkLocalDestination(netip.Addr) bool +} + +func WithLinkLocalDestinationAllower(a LinkLocalDestinationAllower) FilterOption { + return func(f *Filter) { + f.linkLocalDestinationAllower = a + } } // filterState is a state cache of past seen packets. @@ -174,12 +187,12 @@ func NewAllowNone(logf logger.Logf, logIPs *netipx.IPSet) *Filter { // // If shareStateWith is non-nil, the returned filter shares state with the previous one, // as long as the previous one was also a shields up filter. -func NewShieldsUpFilter(localNets *netipx.IPSet, logIPs *netipx.IPSet, shareStateWith *Filter, logf logger.Logf) *Filter { +func NewShieldsUpFilter(localNets *netipx.IPSet, logIPs *netipx.IPSet, shareStateWith *Filter, logf logger.Logf, opts ...FilterOption) *Filter { // Don't permit sharing state with a prior filter that wasn't a shields-up filter. if shareStateWith != nil && !shareStateWith.shieldsUp { shareStateWith = nil } - f := New(nil, nil, localNets, logIPs, shareStateWith, logf) + f := New(nil, nil, localNets, logIPs, shareStateWith, logf, opts...) f.shieldsUp = true return f } @@ -192,7 +205,7 @@ func NewShieldsUpFilter(localNets *netipx.IPSet, logIPs *netipx.IPSet, shareStat // If shareStateWith is non-nil, the returned filter shares state with the // previous one, to enable changing rules at runtime without breaking existing // stateful flows. -func New(matches []Match, capTest CapTestFunc, localNets, logIPs *netipx.IPSet, shareStateWith *Filter, logf logger.Logf) *Filter { +func New(matches []Match, capTest CapTestFunc, localNets, logIPs *netipx.IPSet, shareStateWith *Filter, logf logger.Logf, opts ...FilterOption) *Filter { var state *filterState if shareStateWith != nil { state = shareStateWith.state @@ -228,6 +241,10 @@ func New(matches []Match, capTest CapTestFunc, localNets, logIPs *netipx.IPSet, f.logIPs6 = ipset.NewContainsIPFunc(views.SliceOf(p6)) } + for _, o := range opts { + o(f) + } + return f } @@ -426,6 +443,7 @@ func (f *Filter) RunIn(q *packet.Parsed, rf RunFlags) Response { default: r, why = Drop, "not-ip" } + fmt.Println("run-in-4 result and reason:", r, why) f.logRateLimit(rf, q, dir, r, why) return r } @@ -459,7 +477,7 @@ func (f *Filter) runIn4(q *packet.Parsed) (r Response, why string) { // A compromised peer could try to send us packets for // destinations we didn't explicitly advertise. This check is to // prevent that. - if !f.local4(q.Dst.Addr()) { + if !f.local4(q.Dst.Addr()) && (f.linkLocalDestinationAllower == nil || !f.linkLocalDestinationAllower.AllowedLinkLocalDestination(q.Dst.Addr())) { return Drop, "destination not allowed" } @@ -519,7 +537,7 @@ func (f *Filter) runIn6(q *packet.Parsed) (r Response, why string) { // A compromised peer could try to send us packets for // destinations we didn't explicitly advertise. This check is to // prevent that. - if !f.local6(q.Dst.Addr()) { + if !f.local6(q.Dst.Addr()) && (f.linkLocalDestinationAllower == nil || f.linkLocalDestinationAllower.AllowedLinkLocalDestination(q.Dst.Addr())) { return Drop, "destination not allowed" } @@ -630,7 +648,15 @@ func (f *Filter) pre(q *packet.Parsed, rf RunFlags, dir direction) (Response, us f.logRateLimit(rf, q, dir, Drop, "multicast") return Drop, usermetric.ReasonMulticast } - if q.Dst.Addr().IsLinkLocalUnicast() && q.Dst.Addr() != gcpDNSAddr { + + // The special link-local destination for GCP DNS, and packets that have allow-listed + // link destination IPs, e.g. for app connectors, are allowed. + if q.Dst.Addr().IsLinkLocalUnicast() && + q.Dst.Addr() != gcpDNSAddr && + (f.linkLocalDestinationAllower == nil || !f.linkLocalDestinationAllower.AllowedLinkLocalDestination(q.Dst.Addr())) { + + fmt.Println("mzb dropping link local unicast") + f.logRateLimit(rf, q, dir, Drop, "link-local-unicast") return Drop, usermetric.ReasonLinkLocalUnicast } diff --git a/wgengine/userspace.go b/wgengine/userspace.go index e69712061..81c157c78 100644 --- a/wgengine/userspace.go +++ b/wgengine/userspace.go @@ -165,6 +165,9 @@ type userspaceEngine struct { // networkLogger logs statistics about network connections. networkLogger netlog.Logger + // appConnectorPacketHooks are the packet hooks for app connectors. + appConnectorPacketHooks AppConnectorPacketHooks + // Lock ordering: magicsock.Conn.mu, wgLock, then mu. } @@ -175,6 +178,11 @@ type BIRDClient interface { Close() error } +type AppConnectorPacketHooks interface { + HandlePacketsFromTunDevice(*packet.Parsed) filter.Response + HandlePacketsFromWireguard(*packet.Parsed) filter.Response +} + // Config is the engine configuration. type Config struct { // Tun is the device used by the Engine to exchange packets with @@ -247,6 +255,10 @@ type Config struct { // TODO(creachadair): As of 2025-03-19 this is optional, but is intended to // become required non-nil. EventBus *eventbus.Bus + + // AppConnectorPacketHooks, if non-nil, is used to hook packets for App Connector + // handling logic. + AppConnectorPacketHooks AppConnectorPacketHooks } // NewFakeUserspaceEngine returns a new userspace engine for testing. @@ -348,19 +360,20 @@ func NewUserspaceEngine(logf logger.Logf, conf Config) (_ Engine, reterr error) } e := &userspaceEngine{ - eventBus: conf.EventBus, - timeNow: mono.Now, - logf: logf, - reqCh: make(chan struct{}, 1), - waitCh: make(chan struct{}), - tundev: tsTUNDev, - router: rtr, - dialer: conf.Dialer, - confListenPort: conf.ListenPort, - birdClient: conf.BIRDClient, - controlKnobs: conf.ControlKnobs, - reconfigureVPN: conf.ReconfigureVPN, - health: conf.HealthTracker, + eventBus: conf.EventBus, + timeNow: mono.Now, + logf: logf, + reqCh: make(chan struct{}, 1), + waitCh: make(chan struct{}), + tundev: tsTUNDev, + router: rtr, + dialer: conf.Dialer, + confListenPort: conf.ListenPort, + birdClient: conf.BIRDClient, + controlKnobs: conf.ControlKnobs, + reconfigureVPN: conf.ReconfigureVPN, + health: conf.HealthTracker, + appConnectorPacketHooks: conf.AppConnectorPacketHooks, } if e.birdClient != nil { @@ -434,6 +447,20 @@ func NewUserspaceEngine(logf logger.Logf, conf Config) (_ Engine, reterr error) } e.tundev.PreFilterPacketOutboundToWireGuardEngineIntercept = e.handleLocalPackets + e.tundev.PreFilterPacketOutboundToWireGuardAppConnectorIntercept = func(p *packet.Parsed, _ *tstun.Wrapper) filter.Response { + if e.appConnectorPacketHooks.HandlePacketsFromTunDevice != nil { + return e.appConnectorPacketHooks.HandlePacketsFromTunDevice(p) + } + return filter.Accept + } + + e.tundev.PostFilterPacketInboundFromWireGuardAppConector = func(p *packet.Parsed, _ *tstun.Wrapper) filter.Response { + if e.appConnectorPacketHooks.HandlePacketsFromWireguard != nil { + return e.appConnectorPacketHooks.HandlePacketsFromWireguard(p) + } + return filter.Accept + } + if buildfeatures.HasDebug && envknob.BoolDefaultTrue("TS_DEBUG_CONNECT_FAILURES") { if e.tundev.PreFilterPacketInboundFromWireGuard != nil { return nil, errors.New("unexpected PreFilterIn already set") diff --git a/wgengine/wgcfg/nmcfg/nmcfg.go b/wgengine/wgcfg/nmcfg/nmcfg.go index f99b7b007..a0af34f70 100644 --- a/wgengine/wgcfg/nmcfg/nmcfg.go +++ b/wgengine/wgcfg/nmcfg/nmcfg.go @@ -13,6 +13,7 @@ import ( "tailscale.com/net/tsaddr" "tailscale.com/tailcfg" + "tailscale.com/types/appctype" "tailscale.com/types/key" "tailscale.com/types/logger" "tailscale.com/types/logid" @@ -46,7 +47,7 @@ func cidrIsSubnet(node tailcfg.NodeView, cidr netip.Prefix) bool { } // WGCfg returns the NetworkMaps's WireGuard configuration. -func WGCfg(pk key.NodePrivate, nm *netmap.NetworkMap, logf logger.Logf, flags netmap.WGConfigFlags, exitNode tailcfg.StableNodeID) (*wgcfg.Config, error) { +func WGCfg(pk key.NodePrivate, nm *netmap.NetworkMap, logf logger.Logf, flags netmap.WGConfigFlags, exitNode tailcfg.StableNodeID, transitIPsFn func(tailcfg.NodeView) []netip.Prefix) (*wgcfg.Config, error) { cfg := &wgcfg.Config{ PrivateKey: pk, Addresses: nm.GetAddresses().AsSlice(), @@ -118,6 +119,11 @@ func WGCfg(pk key.NodePrivate, nm *netmap.NetworkMap, logf logger.Logf, flags ne } cpeer.AllowedIPs = append(cpeer.AllowedIPs, allowedIP) } + + if nm.HasCap(appctype.AppConnectorExperimentalCap) && transitIPsFn != nil { + transitIPs := transitIPsFn(peer) + cpeer.AllowedIPs = append(cpeer.AllowedIPs, transitIPs...) + } } logList := func(title string, nodes []tailcfg.NodeView) { |
