summaryrefslogtreecommitdiffhomepage
path: root/cmd/tailscaled/cli/up.go
blob: d8e24a095f1b44ac8393dfea36c3fda33d482c7b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package cli

import (
	"context"
	"errors"
	"flag"
	"fmt"
	"os"
	"reflect"
	"runtime"
	"sort"
	"strings"
	"sync"

	shellquote "github.com/kballard/go-shellquote"
	"github.com/peterbourgon/ff/v2/ffcli"
	"inet.af/netaddr"
	"tailscale.com/client/tailscale"
	"tailscale.com/ipn"
	"tailscale.com/ipn/ipnstate"
	"tailscale.com/safesocket"
	"tailscale.com/tailcfg"
	"tailscale.com/types/logger"
	"tailscale.com/types/preftype"
	"tailscale.com/version/distro"
)

var upCmd = &ffcli.Command{
	Name:       "up",
	ShortUsage: "up [flags]",
	ShortHelp:  "Connect to Tailscale, logging in if needed",

	LongHelp: strings.TrimSpace(`
"tailscale up" connects this machine to your Tailscale network,
triggering authentication if necessary.

With no flags, "tailscale up" brings the network online without
changing any settings. (That is, it's the opposite of "tailscale
down").

If flags are specified, the flags must be the complete set of desired
settings. An error is returned if any setting would be changed as a
result of an unspecified flag's default value, unless the --reset
flag is also used.
`),
	FlagSet: upFlagSet,
	Exec:    runUp,
}

var upFlagSet = newUpFlagSet(runtime.GOOS, &upArgs)

func newUpFlagSet(goos string, upArgs *upArgsT) *flag.FlagSet {
	upf := flag.NewFlagSet("up", flag.ExitOnError)

	upf.BoolVar(&upArgs.forceReauth, "force-reauth", false, "force reauthentication")
	upf.BoolVar(&upArgs.reset, "reset", false, "reset unspecified settings to their default values")

	upf.StringVar(&upArgs.server, "login-server", ipn.DefaultControlURL, "base URL of control server")
	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.exitNodeAllowLANAccess, "exit-node-allow-lan-access", false, "Allow direct access to the local network when routing traffic via an exit node")
	upf.BoolVar(&upArgs.shieldsUp, "shields-up", false, "don't allow incoming connections")
	upf.StringVar(&upArgs.advertiseTags, "advertise-tags", "", "comma-separated ACL tags to request; each must start with \"tag:\" (e.g. \"tag:eng,tag:montreal,tag:ssh\")")
	upf.StringVar(&upArgs.authKey, "authkey", "", "node authorization key")
	upf.StringVar(&upArgs.hostname, "hostname", "", "hostname to use instead of the one provided by the OS")
	upf.StringVar(&upArgs.advertiseRoutes, "advertise-routes", "", "routes to advertise to other nodes (comma-separated, e.g. \"10.0.0.0/8,192.168.0.0/24\")")
	upf.BoolVar(&upArgs.advertiseDefaultRoute, "advertise-exit-node", false, "offer to be an exit node for internet traffic for the tailnet")
	if safesocket.GOOSUsesPeerCreds(goos) {
		upf.StringVar(&upArgs.opUser, "operator", "", "Unix username to allow to operate on tailscaled without sudo")
	}
	switch goos {
	case "linux":
		upf.BoolVar(&upArgs.snat, "snat-subnet-routes", true, "source NAT traffic to local routes advertised with --advertise-routes")
		upf.StringVar(&upArgs.netfilterMode, "netfilter-mode", defaultNetfilterMode(), "netfilter mode (one of on, nodivert, off)")
	case "windows":
		upf.BoolVar(&upArgs.forceDaemon, "unattended", false, "run in \"Unattended Mode\" where Tailscale keeps running even after the current GUI user logs out (Windows-only)")
	}
	return upf
}

func defaultNetfilterMode() string {
	if distro.Get() == distro.Synology {
		return "off"
	}
	return "on"
}

type upArgsT struct {
	reset                  bool
	server                 string
	acceptRoutes           bool
	acceptDNS              bool
	singleRoutes           bool
	exitNodeIP             string
	exitNodeAllowLANAccess bool
	shieldsUp              bool
	forceReauth            bool
	forceDaemon            bool
	advertiseRoutes        string
	advertiseDefaultRoute  bool
	advertiseTags          string
	snat                   bool
	netfilterMode          string
	authKey                string
	hostname               string
	opUser                 string
}

var upArgs upArgsT

func warnf(format string, args ...interface{}) {
	fmt.Printf("Warning: "+format+"\n", args...)
}

var (
	ipv4default = netaddr.MustParseIPPrefix("0.0.0.0/0")
	ipv6default = netaddr.MustParseIPPrefix("::/0")
)

// prefsFromUpArgs returns the ipn.Prefs for the provided args.
//
// Note that the parameters upArgs and warnf are named intentionally
// to shadow the globals to prevent accidental misuse of them. This
// function exists for testing and should have no side effects or
// outside interactions (e.g. no making Tailscale local API calls).
func prefsFromUpArgs(upArgs upArgsT, warnf logger.Logf, st *ipnstate.Status, goos string) (*ipn.Prefs, error) {
	routeMap := map[netaddr.IPPrefix]bool{}
	var default4, default6 bool
	if upArgs.advertiseRoutes != "" {
		advroutes := strings.Split(upArgs.advertiseRoutes, ",")
		for _, s := range advroutes {
			ipp, err := netaddr.ParseIPPrefix(s)
			if err != nil {
				return nil, fmt.Errorf("%q is not a valid IP address or CIDR prefix", s)
			}
			if ipp != ipp.Masked() {
				return nil, fmt.Errorf("%s has non-address bits set; expected %s", ipp, ipp.Masked())
			}
			if ipp == ipv4default {
				default4 = true
			} else if ipp == ipv6default {
				default6 = true
			}
			routeMap[ipp] = true
		}
		if default4 && !default6 {
			return nil, fmt.Errorf("%s advertised without its IPv6 counterpart, please also advertise %s", ipv4default, ipv6default)
		} else if default6 && !default4 {
			return nil, fmt.Errorf("%s advertised without its IPv6 counterpart, please also advertise %s", ipv6default, ipv4default)
		}
	}
	if upArgs.advertiseDefaultRoute {
		routeMap[netaddr.MustParseIPPrefix("0.0.0.0/0")] = true
		routeMap[netaddr.MustParseIPPrefix("::/0")] = true
	}
	routes := make([]netaddr.IPPrefix, 0, len(routeMap))
	for r := range routeMap {
		routes = append(routes, r)
	}
	sort.Slice(routes, func(i, j int) bool {
		if routes[i].Bits() != routes[j].Bits() {
			return routes[i].Bits() < routes[j].Bits()
		}
		return routes[i].IP().Less(routes[j].IP())
	})

	var exitNodeIP netaddr.IP
	if upArgs.exitNodeIP != "" {
		var err error
		exitNodeIP, err = netaddr.ParseIP(upArgs.exitNodeIP)
		if err != nil {
			return nil, fmt.Errorf("invalid IP address %q for --exit-node: %v", upArgs.exitNodeIP, err)
		}
	} else if upArgs.exitNodeAllowLANAccess {
		return nil, fmt.Errorf("--exit-node-allow-lan-access can only be used with --exit-node")
	}

	if upArgs.exitNodeIP != "" {
		for _, ip := range st.TailscaleIPs {
			if exitNodeIP == ip {
				return nil, fmt.Errorf("cannot use %s as the exit node as it is a local IP address to this machine, did you mean --advertise-exit-node?", upArgs.exitNodeIP)
			}
		}
	}

	var tags []string
	if upArgs.advertiseTags != "" {
		tags = strings.Split(upArgs.advertiseTags, ",")
		for _, tag := range tags {
			err := tailcfg.CheckTag(tag)
			if err != nil {
				return nil, fmt.Errorf("tag: %q: %s", tag, err)
			}
		}
	}

	if len(upArgs.hostname) > 256 {
		return nil, fmt.Errorf("hostname too long: %d bytes (max 256)", len(upArgs.hostname))
	}

	prefs := ipn.NewPrefs()
	prefs.ControlURL = upArgs.server
	prefs.WantRunning = true
	prefs.RouteAll = upArgs.acceptRoutes
	prefs.ExitNodeIP = exitNodeIP
	prefs.ExitNodeAllowLANAccess = upArgs.exitNodeAllowLANAccess
	prefs.CorpDNS = upArgs.acceptDNS
	prefs.AllowSingleHosts = upArgs.singleRoutes
	prefs.ShieldsUp = upArgs.shieldsUp
	prefs.AdvertiseRoutes = routes
	prefs.AdvertiseTags = tags
	prefs.Hostname = upArgs.hostname
	prefs.ForceDaemon = upArgs.forceDaemon
	prefs.OperatorUser = upArgs.opUser

	if goos == "linux" {
		prefs.NoSNAT = !upArgs.snat

		switch upArgs.netfilterMode {
		case "on":
			prefs.NetfilterMode = preftype.NetfilterOn
		case "nodivert":
			prefs.NetfilterMode = preftype.NetfilterNoDivert
			warnf("netfilter=nodivert; add iptables calls to ts-* chains manually.")
		case "off":
			prefs.NetfilterMode = preftype.NetfilterOff
			warnf("netfilter=off; configure iptables yourself.")
		default:
			return nil, fmt.Errorf("invalid value --netfilter-mode=%q", upArgs.netfilterMode)
		}
	}
	return prefs, nil
}

func runUp(ctx context.Context, args []string) error {
	if len(args) > 0 {
		fatalf("too many non-flag arguments: %q", args)
	}

	st, err := tailscale.Status(ctx)
	if err != nil {
		fatalf("can't fetch status from tailscaled: %v", err)
	}
	origAuthURL := st.AuthURL

	// printAuthURL reports whether we should print out the
	// provided auth URL from an IPN notify.
	printAuthURL := func(url string) bool {
		if upArgs.authKey != "" {
			// Issue 1755: when using an authkey, don't
			// show an authURL that might still be pending
			// from a previous non-completed interactive
			// login.
			return false
		}
		if upArgs.forceReauth && url == origAuthURL {
			return false
		}
		return true
	}

	if distro.Get() == distro.Synology {
		notSupported := "not yet supported on Synology; see https://github.com/tailscale/tailscale/issues/451"
		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)
		}
	}

	prefs, err := prefsFromUpArgs(upArgs, warnf, st, runtime.GOOS)
	if err != nil {
		fatalf("%s", err)
	}

	if len(prefs.AdvertiseRoutes) > 0 {
		if err := tailscale.CheckIPForwarding(context.Background()); err != nil {
			warnf("%v", err)
		}
	}

	curPrefs, err := tailscale.GetPrefs(ctx)
	if err != nil {
		return err
	}

	if !upArgs.reset {
		applyImplicitPrefs(prefs, curPrefs, os.Getenv("USER"))

		if err := checkForAccidentalSettingReverts(upFlagSet, curPrefs, prefs, upCheckEnv{
			goos:          runtime.GOOS,
			curExitNodeIP: exitNodeIP(prefs, st),
		}); err != nil {
			fatalf("%s", err)
		}
	}

	controlURLChanged := curPrefs.ControlURL != prefs.ControlURL
	if controlURLChanged && st.BackendState == ipn.Running.String() && !upArgs.forceReauth {
		fatalf("can't change --login-server without --force-reauth")
	}

	// If we're already running and none of the flags require a
	// restart, we can just do an EditPrefs call and change the
	// prefs at runtime (e.g. changing hostname, changing
	// advertised tags, routes, etc)
	justEdit := st.BackendState == ipn.Running.String() &&
		!upArgs.forceReauth &&
		!upArgs.reset &&
		upArgs.authKey == "" &&
		!controlURLChanged
	if justEdit {
		mp := new(ipn.MaskedPrefs)
		mp.WantRunningSet = true
		mp.Prefs = *prefs
		upFlagSet.Visit(func(f *flag.Flag) {
			updateMaskedPrefsFromUpFlag(mp, f.Name)
		})

		_, err := tailscale.EditPrefs(ctx, mp)
		return err
	}

	// simpleUp is whether we're running a simple "tailscale up"
	// to transition to running from a previously-logged-in but
	// down state, without changing any settings.
	simpleUp := upFlagSet.NFlag() == 0 &&
		curPrefs.Persist != nil &&
		curPrefs.Persist.LoginName != "" &&
		st.BackendState != ipn.NeedsLogin.String()

	// At this point we need to subscribe to the IPN bus to watch
	// for state transitions and possible need to authenticate.
	c, bc, pumpCtx, cancel := connect(ctx)
	defer cancel()

	startingOrRunning := make(chan bool, 1) // gets value once starting or running
	gotEngineUpdate := make(chan bool, 1)   // gets value upon an engine update
	pumpErr := make(chan error, 1)
	go func() { pumpErr <- pump(pumpCtx, bc, c) }()

	printed := !simpleUp
	var loginOnce sync.Once
	startLoginInteractive := func() { loginOnce.Do(func() { bc.StartLoginInteractive() }) }

	bc.SetNotifyCallback(func(n ipn.Notify) {
		if n.Engine != nil {
			select {
			case gotEngineUpdate <- true:
			default:
			}
		}
		if n.ErrMessage != nil {
			msg := *n.ErrMessage
			if msg == ipn.ErrMsgPermissionDenied {
				switch runtime.GOOS {
				case "windows":
					msg += " (Tailscale service in use by other user?)"
				default:
					msg += " (try 'sudo tailscale up [...]')"
				}
			}
			fatalf("backend error: %v\n", msg)
		}
		if s := n.State; s != nil {
			switch *s {
			case ipn.NeedsLogin:
				printed = true
				startLoginInteractive()
			case ipn.NeedsMachineAuth:
				printed = true
				fmt.Fprintf(os.Stderr, "\nTo authorize your machine, visit (as admin):\n\n\t%s/admin/machines\n\n", upArgs.server)
			case ipn.Starting, ipn.Running:
				// Done full authentication process
				if printed {
					// Only need to print an update if we printed the "please click" message earlier.
					fmt.Fprintf(os.Stderr, "Success.\n")
				}
				select {
				case startingOrRunning <- true:
				default:
				}
				cancel()
			}
		}
		if url := n.BrowseToURL; url != nil && printAuthURL(*url) {
			printed = true
			fmt.Fprintf(os.Stderr, "\nTo authenticate, visit:\n\n\t%s\n\n", *url)
		}
	})
	// Wait for backend client to be connected so we know
	// we're subscribed to updates. Otherwise we can miss
	// an update upon its transition to running. Do so by causing some traffic
	// back to the bus that we then wait on.
	bc.RequestEngineStatus()
	select {
	case <-gotEngineUpdate:
	case <-pumpCtx.Done():
		return pumpCtx.Err()
	case err := <-pumpErr:
		return err
	}

	// Special case: bare "tailscale up" means to just start
	// running, if there's ever been a login.
	if simpleUp {
		_, err := tailscale.EditPrefs(ctx, &ipn.MaskedPrefs{
			Prefs: ipn.Prefs{
				WantRunning: true,
			},
			WantRunningSet: true,
		})
		if err != nil {
			return err
		}
	} else {
		opts := ipn.Options{
			StateKey:    ipn.GlobalDaemonStateKey,
			AuthKey:     upArgs.authKey,
			UpdatePrefs: prefs,
		}
		// On Windows, we still run in mostly the "legacy" way that
		// predated the server's StateStore. That is, we send an empty
		// StateKey and send the prefs directly. Although the Windows
		// supports server mode, though, the transition to StateStore
		// is only half complete. Only server mode uses it, and the
		// Windows service (~tailscaled) is the one that computes the
		// StateKey based on the connection identity. So for now, just
		// do as the Windows GUI's always done:
		if runtime.GOOS == "windows" {
			// The Windows service will set this as needed based
			// on our connection's identity.
			opts.StateKey = ""
			opts.Prefs = prefs
		}

		bc.Start(opts)
		if upArgs.forceReauth {
			startLoginInteractive()
		}
	}

	select {
	case <-startingOrRunning:
		return nil
	case <-pumpCtx.Done():
		select {
		case <-startingOrRunning:
			return nil
		default:
		}
		return pumpCtx.Err()
	case err := <-pumpErr:
		return err
	}
}

var (
	prefsOfFlag = map[string][]string{} // "exit-node" => ExitNodeIP, ExitNodeID
)

func init() {
	// Both these have the same ipn.Pref:
	addPrefFlagMapping("advertise-exit-node", "AdvertiseRoutes")
	addPrefFlagMapping("advertise-routes", "AdvertiseRoutes")

	// And this flag has two ipn.Prefs:
	addPrefFlagMapping("exit-node", "ExitNodeIP", "ExitNodeID")

	// The rest are 1:1:
	addPrefFlagMapping("accept-dns", "CorpDNS")
	addPrefFlagMapping("accept-routes", "RouteAll")
	addPrefFlagMapping("advertise-tags", "AdvertiseTags")
	addPrefFlagMapping("host-routes", "AllowSingleHosts")
	addPrefFlagMapping("hostname", "Hostname")
	addPrefFlagMapping("login-server", "ControlURL")
	addPrefFlagMapping("netfilter-mode", "NetfilterMode")
	addPrefFlagMapping("shields-up", "ShieldsUp")
	addPrefFlagMapping("snat-subnet-routes", "NoSNAT")
	addPrefFlagMapping("exit-node-allow-lan-access", "ExitNodeAllowLANAccess")
	addPrefFlagMapping("unattended", "ForceDaemon")
	addPrefFlagMapping("operator", "OperatorUser")
}

func addPrefFlagMapping(flagName string, prefNames ...string) {
	prefsOfFlag[flagName] = prefNames
	prefType := reflect.TypeOf(ipn.Prefs{})
	for _, pref := range prefNames {
		// Crash at runtime if there's a typo in the prefName.
		if _, ok := prefType.FieldByName(pref); !ok {
			panic(fmt.Sprintf("invalid ipn.Prefs field %q", pref))
		}
	}
}

// preflessFlag reports whether flagName is a flag that doesn't
// correspond to an ipn.Pref.
func preflessFlag(flagName string) bool {
	switch flagName {
	case "authkey", "force-reauth", "reset":
		return true
	}
	return false
}

func updateMaskedPrefsFromUpFlag(mp *ipn.MaskedPrefs, flagName string) {
	if preflessFlag(flagName) {
		return
	}
	if prefs, ok := prefsOfFlag[flagName]; ok {
		for _, pref := range prefs {
			reflect.ValueOf(mp).Elem().FieldByName(pref + "Set").SetBool(true)
		}
		return
	}
	panic(fmt.Sprintf("internal error: unhandled flag %q", flagName))
}

const accidentalUpPrefix = "Error: changing settings via 'tailscale up' requires mentioning all\n" +
	"non-default flags. To proceed, either re-run your command with --reset or\n" +
	"use the command below to explicitly mention the current value of\n" +
	"all non-default settings:\n\n" +
	"\ttailscale up"

// upCheckEnv are extra parameters describing the environment as
// needed by checkForAccidentalSettingReverts and friends.
type upCheckEnv struct {
	goos          string
	curExitNodeIP netaddr.IP
}

// checkForAccidentalSettingReverts (the "up checker") checks for
// people running "tailscale up" with a subset of the flags they
// originally ran it with.
//
// For example, in Tailscale 1.6 and prior, a user might've advertised
// a tag, but later tried to change just one other setting and forgot
// to mention the tag later and silently wiped it out. We now
// require --reset to change preferences to flag default values when
// the flag is not mentioned on the command line.
//
// curPrefs is what's currently active on the server.
//
// mp is the mask of settings actually set, where mp.Prefs is the new
// preferences to set, including any values set from implicit flags.
func checkForAccidentalSettingReverts(flagSet *flag.FlagSet, curPrefs, newPrefs *ipn.Prefs, env upCheckEnv) error {
	if curPrefs.ControlURL == "" {
		// Don't validate things on initial "up" before a control URL has been set.
		return nil
	}

	flagIsSet := map[string]bool{}
	flagSet.Visit(func(f *flag.Flag) {
		flagIsSet[f.Name] = true
	})

	if len(flagIsSet) == 0 {
		// A bare "tailscale up" is a special case to just
		// mean bringing the network up without any changes.
		return nil
	}

	// flagsCur is what flags we'd need to use to keep the exact
	// settings as-is.
	flagsCur := prefsToFlags(env, curPrefs)
	flagsNew := prefsToFlags(env, newPrefs)

	var missing []string
	for flagName := range flagsCur {
		valCur, valNew := flagsCur[flagName], flagsNew[flagName]
		if flagIsSet[flagName] {
			continue
		}
		if reflect.DeepEqual(valCur, valNew) {
			continue
		}
		missing = append(missing, fmtFlagValueArg(flagName, valCur))
	}
	if len(missing) == 0 {
		return nil
	}
	sort.Strings(missing)

	// Compute the stringification of the explicitly provided args in flagSet
	// to prepend to the command to run.
	var explicit []string
	flagSet.Visit(func(f *flag.Flag) {
		type isBool interface {
			IsBoolFlag() bool
		}
		if ib, ok := f.Value.(isBool); ok && ib.IsBoolFlag() {
			if f.Value.String() == "false" {
				explicit = append(explicit, "--"+f.Name+"=false")
			} else {
				explicit = append(explicit, "--"+f.Name)
			}
		} else {
			explicit = append(explicit, fmtFlagValueArg(f.Name, f.Value.String()))
		}
	})

	var sb strings.Builder
	sb.WriteString(accidentalUpPrefix)

	for _, a := range append(explicit, missing...) {
		fmt.Fprintf(&sb, " %s", a)
	}
	sb.WriteString("\n\n")
	return errors.New(sb.String())
}

// applyImplicitPrefs mutates prefs to add implicit preferences. Currently
// this is just the operator user, which only needs to be set if it doesn't
// match the current user.
//
// curUser is os.Getenv("USER"). It's pulled out for testability.
func applyImplicitPrefs(prefs, oldPrefs *ipn.Prefs, curUser string) {
	if prefs.OperatorUser == "" && oldPrefs.OperatorUser == curUser {
		prefs.OperatorUser = oldPrefs.OperatorUser
	}
}

func flagAppliesToOS(flag, goos string) bool {
	switch flag {
	case "netfilter-mode", "snat-subnet-routes":
		return goos == "linux"
	case "unattended":
		return goos == "windows"
	}
	return true
}

func prefsToFlags(env upCheckEnv, prefs *ipn.Prefs) (flagVal map[string]interface{}) {
	ret := make(map[string]interface{})

	exitNodeIPStr := func() string {
		if !prefs.ExitNodeIP.IsZero() {
			return prefs.ExitNodeIP.String()
		}
		if prefs.ExitNodeID.IsZero() || env.curExitNodeIP.IsZero() {
			return ""
		}
		return env.curExitNodeIP.String()
	}

	fs := newUpFlagSet(env.goos, new(upArgsT) /* dummy */)
	fs.VisitAll(func(f *flag.Flag) {
		if preflessFlag(f.Name) {
			return
		}
		set := func(v interface{}) {
			if flagAppliesToOS(f.Name, env.goos) {
				ret[f.Name] = v
			} else {
				ret[f.Name] = nil
			}
		}
		switch f.Name {
		default:
			panic(fmt.Sprintf("unhandled flag %q", f.Name))
		case "login-server":
			set(prefs.ControlURL)
		case "accept-routes":
			set(prefs.RouteAll)
		case "host-routes":
			set(prefs.AllowSingleHosts)
		case "accept-dns":
			set(prefs.CorpDNS)
		case "shields-up":
			set(prefs.ShieldsUp)
		case "exit-node":
			set(exitNodeIPStr())
		case "exit-node-allow-lan-access":
			set(prefs.ExitNodeAllowLANAccess)
		case "advertise-tags":
			set(strings.Join(prefs.AdvertiseTags, ","))
		case "hostname":
			set(prefs.Hostname)
		case "operator":
			set(prefs.OperatorUser)
		case "advertise-routes":
			var sb strings.Builder
			for i, r := range withoutExitNodes(prefs.AdvertiseRoutes) {
				if i > 0 {
					sb.WriteByte(',')
				}
				sb.WriteString(r.String())
			}
			set(sb.String())
		case "advertise-exit-node":
			set(hasExitNodeRoutes(prefs.AdvertiseRoutes))
		case "snat-subnet-routes":
			set(!prefs.NoSNAT)
		case "netfilter-mode":
			set(prefs.NetfilterMode.String())
		case "unattended":
			set(prefs.ForceDaemon)
		}
	})
	return ret
}

func fmtFlagValueArg(flagName string, val interface{}) string {
	if val == true {
		return "--" + flagName
	}
	if val == "" {
		return "--" + flagName + "="
	}
	return fmt.Sprintf("--%s=%v", flagName, shellquote.Join(fmt.Sprint(val)))
}

func hasExitNodeRoutes(rr []netaddr.IPPrefix) bool {
	var v4, v6 bool
	for _, r := range rr {
		if r.Bits() == 0 {
			if r.IP().Is4() {
				v4 = true
			} else if r.IP().Is6() {
				v6 = true
			}
		}
	}
	return v4 && v6
}

// withoutExitNodes returns rr unchanged if it has only 1 or 0 /0
// routes. If it has both IPv4 and IPv6 /0 routes, then it returns
// a copy with all /0 routes removed.
func withoutExitNodes(rr []netaddr.IPPrefix) []netaddr.IPPrefix {
	if !hasExitNodeRoutes(rr) {
		return rr
	}
	var out []netaddr.IPPrefix
	for _, r := range rr {
		if r.Bits() > 0 {
			out = append(out, r)
		}
	}
	return out
}

// exitNodeIP returns the exit node IP from p, using st to map
// it from its ID form to an IP address if needed.
func exitNodeIP(p *ipn.Prefs, st *ipnstate.Status) (ip netaddr.IP) {
	if p == nil {
		return
	}
	if !p.ExitNodeIP.IsZero() {
		return p.ExitNodeIP
	}
	id := p.ExitNodeID
	if id.IsZero() {
		return
	}
	for _, p := range st.Peer {
		if p.ID == id {
			if len(p.TailscaleIPs) > 0 {
				return p.TailscaleIPs[0]
			}
			break
		}
	}
	return
}