summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--appc/conn25.go54
-rw-r--r--appc/conn25_datapath.go238
-rw-r--r--appc/conn25_flowtable.go117
-rw-r--r--cmd/tailscaled/tailscaled.go18
-rw-r--r--ipn/ipnlocal/local.go14
-rw-r--r--net/packet/capture.go3
-rw-r--r--net/packet/packet.go3
-rw-r--r--net/tstun/wrap.go20
-rw-r--r--types/appctype/appconnector.go4
-rw-r--r--types/appctype/conn25.go7
-rw-r--r--wgengine/filter/filter.go38
-rw-r--r--wgengine/userspace.go53
-rw-r--r--wgengine/wgcfg/nmcfg/nmcfg.go8
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) {