diff options
| -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) { |
