diff --git a/cmd/tailscale/cli/up.go b/cmd/tailscale/cli/up.go index e4690f021..f968a1598 100644 --- a/cmd/tailscale/cli/up.go +++ b/cmd/tailscale/cli/up.go @@ -45,6 +45,7 @@ specify any flags, options are reset to their default. upf.BoolVar(&upArgs.acceptRoutes, "accept-routes", false, "accept routes advertised by other Tailscale nodes") upf.BoolVar(&upArgs.acceptDNS, "accept-dns", true, "accept DNS configuration from the admin panel") upf.BoolVar(&upArgs.singleRoutes, "host-routes", true, "install host routes to other Tailscale nodes") + upf.StringVar(&upArgs.exitNodeIP, "exit-node", "", "Tailscale IP of the exit node for internet traffic") upf.BoolVar(&upArgs.shieldsUp, "shields-up", false, "don't allow incoming connections") upf.BoolVar(&upArgs.forceReauth, "force-reauth", false, "force reauthentication") upf.StringVar(&upArgs.advertiseTags, "advertise-tags", "", "ACL tags to request (comma-separated, e.g. eng,montreal,ssh)") @@ -74,6 +75,7 @@ var upArgs struct { acceptRoutes bool acceptDNS bool singleRoutes bool + exitNodeIP string shieldsUp bool forceReauth bool advertiseRoutes string @@ -138,6 +140,9 @@ func runUp(ctx context.Context, args []string) error { if upArgs.acceptRoutes { return errors.New("--accept-routes is " + notSupported) } + if upArgs.exitNodeIP != "" { + return errors.New("--exit-node is " + notSupported) + } if upArgs.netfilterMode != "off" { return errors.New("--netfilter-mode values besides \"off\" " + notSupported) } @@ -170,6 +175,15 @@ func runUp(ctx context.Context, args []string) error { checkIPForwarding() } + var exitNodeIP netaddr.IP + if upArgs.exitNodeIP != "" { + var err error + exitNodeIP, err = netaddr.ParseIP(upArgs.exitNodeIP) + if err != nil { + fatalf("invalid IP address %q for --exit-node: %v", upArgs.exitNodeIP, err) + } + } + var tags []string if upArgs.advertiseTags != "" { tags = strings.Split(upArgs.advertiseTags, ",") @@ -190,6 +204,7 @@ func runUp(ctx context.Context, args []string) error { prefs.ControlURL = upArgs.server prefs.WantRunning = true prefs.RouteAll = upArgs.acceptRoutes + prefs.ExitNodeIP = exitNodeIP prefs.CorpDNS = upArgs.acceptDNS prefs.AllowSingleHosts = upArgs.singleRoutes prefs.ShieldsUp = upArgs.shieldsUp diff --git a/cmd/tailscale/depaware.txt b/cmd/tailscale/depaware.txt index 2a1fa2833..3581fd047 100644 --- a/cmd/tailscale/depaware.txt +++ b/cmd/tailscale/depaware.txt @@ -45,7 +45,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep tailscale.com/net/packet from tailscale.com/wgengine/filter tailscale.com/net/stun from tailscale.com/net/netcheck tailscale.com/net/tlsdial from tailscale.com/control/controlclient+ - tailscale.com/net/tsaddr from tailscale.com/net/interfaces + tailscale.com/net/tsaddr from tailscale.com/net/interfaces+ 💣 tailscale.com/net/tshttpproxy from tailscale.com/control/controlclient+ tailscale.com/paths from tailscale.com/cmd/tailscale/cli tailscale.com/safesocket from tailscale.com/cmd/tailscale/cli diff --git a/control/controlclient/netmap.go b/control/controlclient/netmap.go index 40041a491..15548cb2c 100644 --- a/control/controlclient/netmap.go +++ b/control/controlclient/netmap.go @@ -14,6 +14,7 @@ import ( "time" "inet.af/netaddr" + "tailscale.com/net/tsaddr" "tailscale.com/tailcfg" "tailscale.com/types/logger" "tailscale.com/types/wgkey" @@ -249,7 +250,6 @@ type WGConfigFlags int const ( AllowSingleHosts WGConfigFlags = 1 << iota AllowSubnetRoutes - AllowDefaultRoute ) // EndpointDiscoSuffix is appended to the hex representation of a peer's discovery key @@ -271,10 +271,6 @@ func (nm *NetworkMap) WGCfg(logf logger.Logf, flags WGConfigFlags) (*wgcfg.Confi if Debug.OnlyDisco && peer.DiscoKey.IsZero() { continue } - if (flags&AllowSingleHosts) == 0 && len(peer.AllowedIPs) < 2 { - logf("wgcfg: %v skipping a single-host peer.", peer.Key.ShortString()) - continue - } cfg.Peers = append(cfg.Peers, wgcfg.Peer{ PublicKey: wgcfg.Key(peer.Key), }) @@ -298,13 +294,12 @@ func (nm *NetworkMap) WGCfg(logf logger.Logf, flags WGConfigFlags) (*wgcfg.Confi } } } + for _, allowedIP := range peer.AllowedIPs { - if allowedIP.Bits == 0 { - if (flags & AllowDefaultRoute) == 0 { - logf("[v1] wgcfg: not accepting default route from %q (%v)", - nodeDebugName(peer), peer.Key.ShortString()) - continue - } + if allowedIP.IsSingleIP() && tsaddr.IsTailscaleIP(allowedIP.IP) && (flags&AllowSingleHosts) == 0 { + logf("[v1] wgcfg: skipping node IP %v from %q (%v)", + allowedIP.IP, nodeDebugName(peer), peer.Key.ShortString()) + continue } else if cidrIsSubnet(peer, allowedIP) { if (flags & AllowSubnetRoutes) == 0 { logf("[v1] wgcfg: not accepting subnet route %v from %q (%v)", diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index cb42ce347..fd4bac434 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -306,8 +306,10 @@ func (b *LocalBackend) setClientStatus(st controlclient.Status) { prefsChanged = true } if st.NetMap != nil { + if b.keepOneExitNodeLocked(st.NetMap) { + prefsChanged = true + } b.setNetMapLocked(st.NetMap) - } if st.URL != "" { b.authURL = st.URL @@ -365,6 +367,57 @@ func (b *LocalBackend) setClientStatus(st controlclient.Status) { b.authReconfig() } +// keepOneExitNodeLocked edits nm to retain only the default +// routes provided by the exit node specified in b.prefs. It returns +// whether prefs was mutated as part of the process, due to an exit +// node IP being converted into a node ID. +func (b *LocalBackend) keepOneExitNodeLocked(nm *controlclient.NetworkMap) (prefsChanged bool) { + if b.prefs.ExitNodeID == "" && b.prefs.ExitNodeIP.IsZero() { + return false + } + + // If we have a desired IP on file, try to find the corresponding + // node. + if !b.prefs.ExitNodeIP.IsZero() { + // IP takes precedence over ID, so if both are set, clear ID. + if b.prefs.ExitNodeID != "" { + b.prefs.ExitNodeID = "" + prefsChanged = true + } + + peerLoop: + for _, peer := range nm.Peers { + for _, addr := range peer.Addresses { + if !addr.IsSingleIP() || addr.IP != b.prefs.ExitNodeIP { + continue + } + // Found the node being referenced, upgrade prefs to + // reference it directly for next time. + b.prefs.ExitNodeID = peer.StableID + b.prefs.ExitNodeIP = netaddr.IP{} + prefsChanged = true + break peerLoop + } + } + } + + // At this point, we have a node ID if the requested node is in + // the netmap. If not, the ID will be empty, and we'll strip out + // all default routes. + for _, peer := range nm.Peers { + out := peer.AllowedIPs[:0] + for _, allowedIP := range peer.AllowedIPs { + if allowedIP.Bits == 0 && peer.StableID != b.prefs.ExitNodeID { + continue + } + out = append(out, allowedIP) + } + peer.AllowedIPs = out + } + + return prefsChanged +} + // setWgengineStatus is the callback by the wireguard engine whenever it posts a new status. // This updates the endpoints both in the backend and in the control client. func (b *LocalBackend) setWgengineStatus(s *wgengine.Status, err error) { @@ -1203,8 +1256,6 @@ func (b *LocalBackend) authReconfig() { var flags controlclient.WGConfigFlags if uc.RouteAll { - flags |= controlclient.AllowDefaultRoute - // TODO(apenwarr): Make subnet routes a different pref? flags |= controlclient.AllowSubnetRoutes } if uc.AllowSingleHosts { @@ -1256,6 +1307,11 @@ func magicDNSRootDomains(nm *controlclient.NetworkMap) []string { return nil } +var ( + ipv4Default = netaddr.MustParseIPPrefix("0.0.0.0/0") + ipv6Default = netaddr.MustParseIPPrefix("::/0") +) + // routerConfig produces a router.Config from a wireguard config and IPN prefs. func routerConfig(cfg *wgcfg.Config, prefs *ipn.Prefs) *router.Config { rs := &router.Config{ @@ -1269,6 +1325,32 @@ func routerConfig(cfg *wgcfg.Config, prefs *ipn.Prefs) *router.Config { rs.Routes = append(rs.Routes, unmapIPPrefixes(peer.AllowedIPs)...) } + // Sanity check: we expect the control server to program both a v4 + // and a v6 default route, if default routing is on. Fill in + // blackhole routes appropriately if we're missing some. This is + // likely to break some functionality, but if the user expressed a + // preference for routing remotely, we want to avoid leaking + // traffic at the expense of functionality. + if prefs.ExitNodeID != "" || !prefs.ExitNodeIP.IsZero() { + var default4, default6 bool + for _, route := range rs.Routes { + if route == ipv4Default { + default4 = true + } else if route == ipv6Default { + default6 = true + } + if default4 && default6 { + break + } + } + if !default4 { + rs.Routes = append(rs.Routes, ipv4Default) + } + if !default6 { + rs.Routes = append(rs.Routes, ipv6Default) + } + } + rs.Routes = append(rs.Routes, netaddr.IPPrefix{ IP: tsaddr.TailscaleServiceIP(), Bits: 32, diff --git a/ipn/prefs.go b/ipn/prefs.go index cb6bf2869..c8841f5de 100644 --- a/ipn/prefs.go +++ b/ipn/prefs.go @@ -18,6 +18,7 @@ import ( "inet.af/netaddr" "tailscale.com/atomicfile" "tailscale.com/control/controlclient" + "tailscale.com/tailcfg" "tailscale.com/types/preftype" ) @@ -28,8 +29,10 @@ type Prefs struct { // ControlURL is the URL of the control server to use. ControlURL string - // RouteAll specifies whether to accept subnet and default routes - // advertised by other nodes on the Tailscale network. + // RouteAll specifies whether to accept subnets advertised by + // other nodes on the Tailscale network. Note that this does not + // include default routes (0.0.0.0/0 and ::/0), those are + // controlled by ExitNodeID/IP below. RouteAll bool // AllowSingleHosts specifies whether to install routes for each @@ -44,6 +47,24 @@ type Prefs struct { // packets stop flowing. What's up with that? AllowSingleHosts bool + // ExitNodeID and ExitNodeIP specify the node that should be used + // as an exit node for internet traffic. At most one of these + // should be non-zero. + // + // The preferred way to express the chosen node is ExitNodeID, but + // in some cases it's not possible to use that ID (e.g. in the + // linux CLI, before tailscaled has a netmap). For those + // situations, we allow specifying the exit node by IP, and + // ipnlocal.LocalBackend will translate the IP into an ID when the + // node is found in the netmap. + // + // If the selected exit node doesn't exist (e.g. it's not part of + // the current tailnet), or it doesn't offer exit node services, a + // blackhole route will be installed on the local system to + // prevent any traffic escaping to the local network. + ExitNodeID tailcfg.StableNodeID + ExitNodeIP netaddr.IP + // CorpDNS specifies whether to install the Tailscale network's // DNS configuration, if it exists. CorpDNS bool @@ -191,6 +212,8 @@ func (p *Prefs) Equals(p2 *Prefs) bool { p.ControlURL == p2.ControlURL && p.RouteAll == p2.RouteAll && p.AllowSingleHosts == p2.AllowSingleHosts && + p.ExitNodeID == p2.ExitNodeID && + p.ExitNodeIP == p2.ExitNodeIP && p.CorpDNS == p2.CorpDNS && p.WantRunning == p2.WantRunning && p.NotepadURLs == p2.NotepadURLs && diff --git a/ipn/prefs_clone.go b/ipn/prefs_clone.go index 950e34145..0691fc4fc 100644 --- a/ipn/prefs_clone.go +++ b/ipn/prefs_clone.go @@ -9,6 +9,7 @@ package ipn import ( "inet.af/netaddr" "tailscale.com/control/controlclient" + "tailscale.com/tailcfg" "tailscale.com/types/preftype" ) @@ -35,6 +36,8 @@ var _PrefsNeedsRegeneration = Prefs(struct { ControlURL string RouteAll bool AllowSingleHosts bool + ExitNodeID tailcfg.StableNodeID + ExitNodeIP netaddr.IP CorpDNS bool WantRunning bool ShieldsUp bool diff --git a/ipn/prefs_test.go b/ipn/prefs_test.go index 77278446c..a227b05b3 100644 --- a/ipn/prefs_test.go +++ b/ipn/prefs_test.go @@ -30,7 +30,7 @@ func fieldsOf(t reflect.Type) (fields []string) { func TestPrefsEqual(t *testing.T) { tstest.PanicOnLog() - prefsHandles := []string{"ControlURL", "RouteAll", "AllowSingleHosts", "CorpDNS", "WantRunning", "ShieldsUp", "AdvertiseTags", "Hostname", "OSVersion", "DeviceModel", "NotepadURLs", "ForceDaemon", "AdvertiseRoutes", "NoSNAT", "NetfilterMode", "Persist"} + prefsHandles := []string{"ControlURL", "RouteAll", "AllowSingleHosts", "ExitNodeID", "ExitNodeIP", "CorpDNS", "WantRunning", "ShieldsUp", "AdvertiseTags", "Hostname", "OSVersion", "DeviceModel", "NotepadURLs", "ForceDaemon", "AdvertiseRoutes", "NoSNAT", "NetfilterMode", "Persist"} if have := fieldsOf(reflect.TypeOf(Prefs{})); !reflect.DeepEqual(have, prefsHandles) { t.Errorf("Prefs.Equal check might be out of sync\nfields: %q\nhandled: %q\n", have, prefsHandles) @@ -99,6 +99,28 @@ func TestPrefsEqual(t *testing.T) { true, }, + { + &Prefs{ExitNodeID: "n1234"}, + &Prefs{}, + false, + }, + { + &Prefs{ExitNodeID: "n1234"}, + &Prefs{ExitNodeID: "n1234"}, + true, + }, + + { + &Prefs{ExitNodeIP: netaddr.MustParseIP("1.2.3.4")}, + &Prefs{}, + false, + }, + { + &Prefs{ExitNodeIP: netaddr.MustParseIP("1.2.3.4")}, + &Prefs{ExitNodeIP: netaddr.MustParseIP("1.2.3.4")}, + true, + }, + { &Prefs{CorpDNS: true}, &Prefs{CorpDNS: false},