From 3ae701f0ebe053a1f7b6a3fa345a56b3132c818f Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Wed, 30 Mar 2022 08:47:16 -0700 Subject: [PATCH] net/tsaddr, wgengine/netstack: add IPv6 range that forwards to site-relative IPv4 This defines a new magic IPv6 prefix, fd7a:115c:a1e0:b1a::/64, a subset of our existing /48, where the final 32 bits are an IPv4 address, and the middle 32 bits are a user-chosen "site ID". (which must currently be 0000:00xx; the top 3 bytes must be zero for now) e.g., I can say my home LAN's "site ID" is "0000:00bb" and then advertise its 10.2.0.0/16 IPv4 range via IPv6, like: tailscale up --advertise-routes=fd7a:115c:a1e0:b1a::bb:10.2.0.0/112 (112 being /128 minuse the /96 v6 prefix length) Then people in my tailnet can: $ curl '[fd7a:115c:a1e0:b1a::bb:10.2.0.230]' --- cmd/tailscale/cli/cli_test.go | 33 +++++++++++++++++++++++++++++ cmd/tailscale/cli/up.go | 27 ++++++++++++++++++++++++ ipn/ipnlocal/local.go | 31 ++++++++++++++++++++++++---- net/tsaddr/tsaddr.go | 39 +++++++++++++++++++++++++++++++++++ net/tsaddr/tsaddr_test.go | 16 ++++++++++++++ wgengine/netstack/netstack.go | 14 +++++++++++++ 6 files changed, 156 insertions(+), 4 deletions(-) diff --git a/cmd/tailscale/cli/cli_test.go b/cmd/tailscale/cli/cli_test.go index 639fcc1a2..b2fecc127 100644 --- a/cmd/tailscale/cli/cli_test.go +++ b/cmd/tailscale/cli/cli_test.go @@ -659,6 +659,39 @@ func TestPrefsFromUpArgs(t *testing.T) { NoSNAT: true, }, }, + { + name: "via_route_good", + goos: "linux", + args: upArgsT{ + advertiseRoutes: "fd7a:115c:a1e0:b1a::bb:10.0.0.0/112", + netfilterMode: "off", + }, + want: &ipn.Prefs{ + WantRunning: true, + NoSNAT: true, + AdvertiseRoutes: []netaddr.IPPrefix{ + netaddr.MustParseIPPrefix("fd7a:115c:a1e0:b1a::bb:10.0.0.0/112"), + }, + }, + }, + { + name: "via_route_short_prefix", + goos: "linux", + args: upArgsT{ + advertiseRoutes: "fd7a:115c:a1e0:b1a::/64", + netfilterMode: "off", + }, + wantErr: "fd7a:115c:a1e0:b1a::/64 4-in-6 prefix must be at least a /96", + }, + { + name: "via_route_short_reserved_siteid", + goos: "linux", + args: upArgsT{ + advertiseRoutes: "fd7a:115c:a1e0:b1a:1234:5678::/112", + netfilterMode: "off", + }, + wantErr: "route fd7a:115c:a1e0:b1a:1234:5678::/112 contains invalid site ID 12345678; must be 0xff or less", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/cmd/tailscale/cli/up.go b/cmd/tailscale/cli/up.go index 4b495f41e..548499d42 100644 --- a/cmd/tailscale/cli/up.go +++ b/cmd/tailscale/cli/up.go @@ -7,6 +7,7 @@ import ( "context" "encoding/base64" + "encoding/binary" "encoding/json" "errors" "flag" @@ -27,6 +28,7 @@ "tailscale.com/envknob" "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" + "tailscale.com/net/tsaddr" "tailscale.com/safesocket" "tailscale.com/tailcfg" "tailscale.com/types/logger" @@ -201,6 +203,26 @@ func warnf(format string, args ...any) { ipv6default = netaddr.MustParseIPPrefix("::/0") ) +func validateViaPrefix(ipp netaddr.IPPrefix) error { + if !tsaddr.IsViaPrefix(ipp) { + return fmt.Errorf("%v is not a 4-in-6 prefix", ipp) + } + if ipp.Bits() < (128 - 32) { + return fmt.Errorf("%v 4-in-6 prefix must be at least a /%v", ipp, 128-32) + } + a := ipp.IP().As16() + // The first 64 bits of a are the via prefix. + // The next 32 bits are the "site ID". + // The last 32 bits are the IPv4. + // For now, we reserve the top 3 bytes of the site ID, + // and only allow users to use site IDs 0-255. + siteID := binary.BigEndian.Uint32(a[8:12]) + if siteID > 0xFF { + return fmt.Errorf("route %v contains invalid site ID %08x; must be 0xff or less", ipp, siteID) + } + return nil +} + func calcAdvertiseRoutes(advertiseRoutes string, advertiseDefaultRoute bool) ([]netaddr.IPPrefix, error) { routeMap := map[netaddr.IPPrefix]bool{} if advertiseRoutes != "" { @@ -214,6 +236,11 @@ func calcAdvertiseRoutes(advertiseRoutes string, advertiseDefaultRoute bool) ([] if ipp != ipp.Masked() { return nil, fmt.Errorf("%s has non-address bits set; expected %s", ipp, ipp.Masked()) } + if tsaddr.IsViaPrefix(ipp) { + if err := validateViaPrefix(ipp); err != nil { + return nil, err + } + } if ipp == ipv4default { default4 = true } else if ipp == ipv6default { diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 12b6fad11..af69d00b5 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -106,7 +106,8 @@ type LocalBackend struct { filterHash deephash.Sum - filterAtomic atomic.Value // of *filter.Filter + filterAtomic atomic.Value // of *filter.Filter + containsViaIPFuncAtomic atomic.Value // of func(netaddr.IP) bool // The mutex protects the following elements. mu sync.Mutex @@ -1573,11 +1574,23 @@ func (b *LocalBackend) loadStateLocked(key ipn.StateKey, prefs *ipn.Prefs) (err b.logf("using backend prefs for %q: %s", key, b.prefs.Pretty()) - b.sshAtomicBool.Set(b.prefs != nil && b.prefs.RunSSH && canSSH) + b.setAtomicValuesFromPrefs(b.prefs) return nil } +// setAtomicValuesFromPrefs populates sshAtomicBool and containsViaIPFuncAtomic +// from the prefs p, which may be nil. +func (b *LocalBackend) setAtomicValuesFromPrefs(p *ipn.Prefs) { + b.sshAtomicBool.Set(p != nil && p.RunSSH && canSSH) + + if p == nil { + b.containsViaIPFuncAtomic.Store(tsaddr.NewContainsIPFunc(nil)) + } else { + b.containsViaIPFuncAtomic.Store(tsaddr.NewContainsIPFunc(tsaddr.FilterPrefixesCopy(p.AdvertiseRoutes, tsaddr.IsViaPrefix))) + } +} + // State returns the backend state machine's current state. func (b *LocalBackend) State() ipn.State { b.mu.Lock() @@ -1746,7 +1759,7 @@ func (b *LocalBackend) setPrefsLockedOnEntry(caller string, newp *ipn.Prefs) { netMap := b.netMap stateKey := b.stateKey - b.sshAtomicBool.Set(newp.RunSSH && canSSH) + b.setAtomicValuesFromPrefs(newp) oldp := b.prefs newp.Persist = oldp.Persist // caller isn't allowed to override this @@ -2690,11 +2703,21 @@ func (b *LocalBackend) ResetForClientDisconnect() { b.authURL = "" b.authURLSticky = "" b.activeLogin = "" - b.sshAtomicBool.Set(false) + b.setAtomicValuesFromPrefs(nil) } func (b *LocalBackend) ShouldRunSSH() bool { return b.sshAtomicBool.Get() && canSSH } +// ShouldHandleViaIP reports whether whether ip is an IPv6 address in the +// Tailscale ULA's v6 "via" range embedding an IPv4 address to be forwarded to +// by Tailscale. +func (b *LocalBackend) ShouldHandleViaIP(ip netaddr.IP) bool { + if f, ok := b.containsViaIPFuncAtomic.Load().(func(netaddr.IP) bool); ok { + return f(ip) + } + return false +} + // Logout tells the controlclient that we want to log out, and // transitions the local engine to the logged-out state without // waiting for controlclient to be in that state. diff --git a/net/tsaddr/tsaddr.go b/net/tsaddr/tsaddr.go index c582163ce..0f32b069b 100644 --- a/net/tsaddr/tsaddr.go +++ b/net/tsaddr/tsaddr.go @@ -34,6 +34,7 @@ func CGNATRange() netaddr.IPPrefix { cgnatRange oncePrefix ulaRange oncePrefix tsUlaRange oncePrefix + tsViaRange oncePrefix ula4To6Range oncePrefix ulaEph6Range oncePrefix serviceIPv6 oncePrefix @@ -72,6 +73,14 @@ func TailscaleULARange() netaddr.IPPrefix { return tsUlaRange.v } +// TailscaleViaRange returns the IPv6 Unique Local Address subset range +// TailscaleULARange that's used for IPv4 tunneling via IPv6. +func TailscaleViaRange() netaddr.IPPrefix { + // Mnemonic: "b1a" sounds like "via". + tsViaRange.Do(func() { mustPrefix(&tsViaRange.v, "fd7a:115c:a1e0:b1a::/64") }) + return tsViaRange.v +} + // Tailscale4To6Range returns the subset of TailscaleULARange used for // auto-translated Tailscale ipv4 addresses. func Tailscale4To6Range() netaddr.IPPrefix { @@ -241,3 +250,33 @@ func AllIPv6() netaddr.IPPrefix { return allIPv6 } // ExitRoutes returns a slice containing AllIPv4 and AllIPv6. func ExitRoutes() []netaddr.IPPrefix { return []netaddr.IPPrefix{allIPv4, allIPv6} } + +// FilterPrefixes returns a new slice, not aliasing in, containing elements of +// in that match f. +func FilterPrefixesCopy(in []netaddr.IPPrefix, f func(netaddr.IPPrefix) bool) []netaddr.IPPrefix { + var out []netaddr.IPPrefix + for _, v := range in { + if f(v) { + out = append(out, v) + } + } + return out +} + +// IsViaPrefix reports whether p is a CIDR in the Tailscale "via" range. +// See TailscaleViaRange. +func IsViaPrefix(p netaddr.IPPrefix) bool { + return TailscaleViaRange().Contains(p.IP()) +} + +// UnmapVia returns the IPv4 address that corresponds to the provided Tailscale +// "via" IPv4-in-IPv6 address. +// +// If ip is not a via address, it returns ip unchanged. +func UnmapVia(ip netaddr.IP) netaddr.IP { + if TailscaleViaRange().Contains(ip) { + a := ip.As16() + return netaddr.IPFrom4(*(*[4]byte)(a[12:16])) + } + return ip +} diff --git a/net/tsaddr/tsaddr_test.go b/net/tsaddr/tsaddr_test.go index f49f44a46..0cb9277d7 100644 --- a/net/tsaddr/tsaddr_test.go +++ b/net/tsaddr/tsaddr_test.go @@ -88,3 +88,19 @@ func BenchmarkTailscaleServiceAddr(b *testing.B) { sinkIP = TailscaleServiceIP() } } + +func TestUnmapVia(t *testing.T) { + tests := []struct { + ip string + want string + }{ + {"1.2.3.4", "1.2.3.4"}, // unchanged v4 + {"fd7a:115c:a1e0:b1a::bb:10.2.1.3", "10.2.1.3"}, + {"fd7a:115c:a1e0:b1b::bb:10.2.1.4", "fd7a:115c:a1e0:b1b:0:bb:a02:104"}, // "b1b",not "bia" + } + for _, tt := range tests { + if got := UnmapVia(netaddr.MustParseIP(tt.ip)).String(); got != tt.want { + t.Errorf("for %q: got %q, want %q", tt.ip, got, tt.want) + } + } +} diff --git a/wgengine/netstack/netstack.go b/wgengine/netstack/netstack.go index 9d2bd59e9..77800784b 100644 --- a/wgengine/netstack/netstack.go +++ b/wgengine/netstack/netstack.go @@ -431,6 +431,8 @@ func (ns *Impl) peerAPIPortAtomic(ip netaddr.IP) *uint32 { } } +var viaRange = tsaddr.TailscaleViaRange() + // shouldProcessInbound reports whether an inbound packet should be // handled by netstack. func (ns *Impl) shouldProcessInbound(p *packet.Parsed, t *tstun.Wrapper) bool { @@ -453,6 +455,9 @@ func (ns *Impl) shouldProcessInbound(p *packet.Parsed, t *tstun.Wrapper) bool { if ns.isInboundTSSH(p) && ns.processSSH() { return true } + if p.IPVersion == 6 && viaRange.Contains(p.Dst.IP()) { + return ns.lb != nil && ns.lb.ShouldHandleViaIP(p.Dst.IP()) + } if !ns.ProcessLocalIPs && !ns.ProcessSubnets { // Fast path for common case (e.g. Linux server in TUN mode) where // netstack isn't used at all; don't even do an isLocalIP lookup. @@ -624,6 +629,12 @@ func (ns *Impl) acceptTCP(r *tcp.ForwarderRequest) { dialIP := netaddrIPFromNetstackIP(reqDetails.LocalAddress) isTailscaleIP := tsaddr.IsTailscaleIP(dialIP) + + if viaRange.Contains(dialIP) { + isTailscaleIP = false + dialIP = tsaddr.UnmapVia(dialIP) + } + defer func() { if !isTailscaleIP { // if this is a subnet IP, we added this in before the TCP handshake @@ -775,6 +786,9 @@ func (ns *Impl) forwardUDP(client *gonet.UDPConn, wq *waiter.Queue, clientAddr, backendRemoteAddr = &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: int(port)} backendListenAddr = &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: int(srcPort)} } else { + if dstIP := dstAddr.IP(); viaRange.Contains(dstIP) { + dstAddr = netaddr.IPPortFrom(tsaddr.UnmapVia(dstIP), dstAddr.Port()) + } backendRemoteAddr = dstAddr.UDPAddr() if dstAddr.IP().Is4() { backendListenAddr = &net.UDPAddr{IP: net.ParseIP("0.0.0.0"), Port: int(srcPort)}