// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause

package cli

import (
	"context"
	"encoding/base64"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"log"
	"net/netip"
	"os"
	"os/signal"
	"reflect"
	"runtime"
	"sort"
	"strings"
	"syscall"
	"time"

	shellquote "github.com/kballard/go-shellquote"
	"github.com/peterbourgon/ff/v3/ffcli"
	"tailscale.com/feature/buildfeatures"
	_ "tailscale.com/feature/condregister/awsparamstore"
	_ "tailscale.com/feature/condregister/identityfederation"
	_ "tailscale.com/feature/condregister/oauthkey"
	"tailscale.com/health/healthmsg"
	"tailscale.com/internal/client/tailscale"
	"tailscale.com/ipn"
	"tailscale.com/ipn/ipnstate"
	"tailscale.com/net/netutil"
	"tailscale.com/net/tsaddr"
	"tailscale.com/safesocket"
	"tailscale.com/tailcfg"
	"tailscale.com/types/logger"
	"tailscale.com/types/preftype"
	"tailscale.com/types/views"
	"tailscale.com/util/dnsname"
	"tailscale.com/util/qrcodes"
	"tailscale.com/util/syspolicy/policyclient"
	"tailscale.com/version/distro"
)

var upCmd = &ffcli.Command{
	Name:       "up",
	ShortUsage: "tailscale 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. (The flags --auth-key, --force-reauth, and --qr are not
considered settings that need to be re-specified when modifying
settings.)
`),
	FlagSet: upFlagSet,
	Exec: func(ctx context.Context, args []string) error {
		return runUp(ctx, "up", args, upArgsGlobal)
	},
}

func effectiveGOOS() string {
	if v := os.Getenv("TS_DEBUG_UP_FLAG_GOOS"); v != "" {
		return v
	}
	return runtime.GOOS
}

// acceptRouteDefault returns the CLI's default value of --accept-routes as
// a function of the platform it's running on.
func acceptRouteDefault(goos string) bool {
	var p *ipn.Prefs
	return p.DefaultRouteAll(goos)
}

var upFlagSet = newUpFlagSet(effectiveGOOS(), &upArgsGlobal, "up")

// newUpFlagSet returns a new flag set for the "up" and "login" commands.
func newUpFlagSet(goos string, upArgs *upArgsT, cmd string) *flag.FlagSet {
	if cmd != "up" && cmd != "login" {
		panic("cmd must be up or login")
	}
	upf := newFlagSet(cmd)

	// When adding new flags, prefer to put them under "tailscale set" instead
	// of here. Setting preferences via "tailscale up" is deprecated.
	if buildfeatures.HasQRCodes {
		upf.BoolVar(&upArgs.qr, "qr", false, "show QR code for login URLs")
		upf.StringVar(&upArgs.qrFormat, "qr-format", string(qrcodes.FormatAuto), fmt.Sprintf("QR code formatting (%s, %s, %s, %s)", qrcodes.FormatAuto, qrcodes.FormatASCII, qrcodes.FormatLarge, qrcodes.FormatSmall))
	}
	upf.StringVar(&upArgs.authKeyOrFile, "auth-key", "", `node authorization key; if it begins with "file:", then it's a path to a file containing the authkey`)
	upf.StringVar(&upArgs.audience, "audience", "", "Audience used when requesting an ID token from an identity provider for auth keys via workload identity federation")
	upf.StringVar(&upArgs.clientID, "client-id", "", "Client ID used to generate authkeys via workload identity federation")
	upf.StringVar(&upArgs.clientSecretOrFile, "client-secret", "", `Client Secret used to generate authkeys via OAuth; if it begins with "file:", then it's a path to a file containing the secret`)
	upf.StringVar(&upArgs.idTokenOrFile, "id-token", "", `ID token from the identity provider to exchange with the control server for workload identity federation; if it begins with "file:", then it's a path to a file containing the token`)

	upf.StringVar(&upArgs.server, "login-server", ipn.DefaultControlURL, "base URL of control server")
	upf.BoolVar(&upArgs.acceptRoutes, "accept-routes", acceptRouteDefault(goos), "accept routes advertised by other Tailscale nodes")
	upf.BoolVar(&upArgs.acceptDNS, "accept-dns", true, "accept DNS configuration from the admin panel")
	upf.Var(notFalseVar{}, "host-routes", hidden+"install host routes to other Tailscale nodes (must be true as of Tailscale 1.67+)")
	upf.StringVar(&upArgs.exitNodeIP, "exit-node", "", "Tailscale exit node (IP, base name, or auto:any) for internet traffic, or empty string to not use an exit node")
	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.BoolVar(&upArgs.runSSH, "ssh", false, "run an SSH server, permitting access per tailnet admin's declared policy")
	upf.StringVar(&upArgs.advertiseTags, "advertise-tags", "", "comma-separated ACL tags to request (e.g. \"tag:eng,tag:montreal,tag:ssh\"); the \"tag:\" prefix is optional and added automatically when omitted (e.g. \"eng,montreal,ssh\")")
	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\") or empty string to not advertise routes")
	upf.BoolVar(&upArgs.advertiseConnector, "advertise-connector", false, "advertise this node as an app connector")
	upf.BoolVar(&upArgs.advertiseDefaultRoute, "advertise-exit-node", false, "offer to be an exit node for internet traffic for the tailnet")
	upf.BoolVar(&upArgs.postureChecking, "report-posture", false, "allow management plane to gather device posture information")

	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.BoolVar(&upArgs.statefulFiltering, "stateful-filtering", false, "apply stateful filtering to forwarded packets (subnet routers, exit nodes, and so on)")
		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)")
	}
	upf.DurationVar(&upArgs.timeout, "timeout", 0, "maximum amount of time to wait for tailscaled to enter a Running state; default (0s) blocks forever")

	if cmd == "login" {
		upf.StringVar(&upArgs.profileName, "nickname", "", "short name for the account")
	}

	if cmd == "up" {
		// Some flags are only for "up", not "login".
		upf.BoolVar(&upArgs.json, "json", false, "output in JSON format (WARNING: format subject to change)")
		upf.BoolVar(&upArgs.reset, "reset", false, "reset unspecified settings to their default values")

		// There's no --force-reauth flag on "login" because all login commands
		// trigger a reauth.
		upf.BoolVar(&upArgs.forceReauth, "force-reauth", false, "force reauthentication (WARNING: this may bring down the Tailscale connection and thus should not be done remotely over SSH or RDP)")
		registerAcceptRiskFlag(upf, &upArgs.acceptedRisks)
	}

	return upf
}

// notFalseVar is a flag.Value that can only be "true", if set.
type notFalseVar struct{}

func (notFalseVar) IsBoolFlag() bool { return true }
func (notFalseVar) Set(v string) error {
	if v != "true" {
		return fmt.Errorf("unsupported value; only 'true' is allowed")
	}
	return nil
}
func (notFalseVar) String() string { return "true" }

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

// upArgsT is the type of upArgs, the argument struct for `tailscale up`.
// As of 2024-10-08, upArgsT is frozen and no new arguments should be
// added to it. Add new arguments to setArgsT instead.
type upArgsT struct {
	qr                     bool
	qrFormat               string
	reset                  bool
	server                 string
	acceptRoutes           bool
	acceptDNS              bool
	exitNodeIP             string
	exitNodeAllowLANAccess bool
	shieldsUp              bool
	runSSH                 bool
	runWebClient           bool
	forceReauth            bool
	forceDaemon            bool
	advertiseRoutes        string
	advertiseDefaultRoute  bool
	advertiseTags          string
	advertiseConnector     bool
	snat                   bool
	statefulFiltering      bool
	netfilterMode          string
	authKeyOrFile          string // "secret" or "file:/path/to/secret"
	clientID               string
	audience               string
	clientSecretOrFile     string // "secret" or "file:/path/to/secret"
	idTokenOrFile          string // "secret" or "file:/path/to/secret"
	hostname               string
	opUser                 string
	json                   bool
	timeout                time.Duration
	acceptedRisks          string
	profileName            string
	postureChecking        bool
}

// resolveValueFromFile returns the value as-is, or if it starts with "file:",
// reads and returns the trimmed contents of the file.
func resolveValueFromFile(v string) (string, error) {
	if file, ok := strings.CutPrefix(v, "file:"); ok {
		b, err := os.ReadFile(file)
		if err != nil {
			return "", err
		}
		return strings.TrimSpace(string(b)), nil
	}
	return v, nil
}

// resolveValueFromParameterStore resolves a value from AWS Parameter Store if
// the value looks like an SSM ARN. If the hook is not available or the value
// is not an SSM ARN, it returns the value unchanged.
func resolveValueFromParameterStore(ctx context.Context, v string) (string, error) {
	if f, ok := tailscale.HookResolveValueFromParameterStore.GetOk(); ok {
		return f(ctx, v)
	}
	return v, nil
}

// resolveValue will take the given value (e.g. as passed to --auth-key), and
// depending on the prefix, resolve the value from either a file or AWS
// Parameter Store. Values with an unknown prefix are returned as-is.
func resolveValue(ctx context.Context, v string) (string, error) {
	switch {
	case strings.HasPrefix(v, "file:"):
		return resolveValueFromFile(v)
	case strings.HasPrefix(v, tailscale.ResolvePrefixAWSParameterStore):
		return resolveValueFromParameterStore(ctx, v)
	}
	return v, nil
}

func (a upArgsT) getAuthKey(ctx context.Context) (string, error) {
	return resolveValue(ctx, a.authKeyOrFile)
}

func (a upArgsT) getClientSecret(ctx context.Context) (string, error) {
	return resolveValue(ctx, a.clientSecretOrFile)
}

func (a upArgsT) getIDToken(ctx context.Context) (string, error) {
	return resolveValue(ctx, a.idTokenOrFile)
}

var upArgsGlobal upArgsT

// Fields output when `tailscale up --json` is used. Two JSON blocks will be output.
//
// When "tailscale up" is run it first outputs a block with AuthURL and QR populated,
// providing the link for where to authenticate this client. BackendState would be
// valid but boring, as it will almost certainly be "NeedsLogin". Error would be
// populated if something goes badly wrong.
//
// When the client is authenticated by having someone visit the AuthURL, a second
// JSON block will be output. The AuthURL and QR fields will not be present, the
// BackendState and Error fields will give the result of the authentication.
// Ex:
//
//	{
//	   "AuthURL": "https://login.tailscale.com/a/0123456789abcdef",
//	   "QR": "data:image/png;base64,0123...cdef"
//	   "BackendState": "NeedsLogin"
//	}
//
//	{
//	   "BackendState": "Running"
//	}
type upOutputJSON struct {
	AuthURL      string `json:",omitempty"` // Authentication URL of the form https://login.tailscale.com/a/0123456789
	QR           string `json:",omitempty"` // a DataURL (base64) PNG of a QR code AuthURL
	BackendState string `json:",omitempty"` // name of state like Running or NeedsMachineAuth
	Error        string `json:",omitempty"` // description of an error
}

func warnf(format string, args ...any) {
	printf("Warning: "+format+"\n", args...)
}

// 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 LocalAPI calls).
func prefsFromUpArgs(upArgs upArgsT, warnf logger.Logf, st *ipnstate.Status, goos string) (*ipn.Prefs, error) {
	routes, err := netutil.CalcAdvertiseRoutes(upArgs.advertiseRoutes, upArgs.advertiseDefaultRoute)
	if err != nil {
		return nil, err
	}

	if upArgs.exitNodeIP == "" && upArgs.exitNodeAllowLANAccess {
		return nil, fmt.Errorf("--exit-node-allow-lan-access can only be used with --exit-node")
	}

	var tags []string
	if upArgs.advertiseTags != "" {
		tags = strings.Split(upArgs.advertiseTags, ",")
		for i, tag := range tags {
			// Allow users to omit the "tag:" prefix; if the tag has no
			// colon at all, add it for them. Tags with a colon must be
			// fully qualified ("tag:foo") and are validated as-is.
			if !strings.Contains(tag, ":") {
				tag = "tag:" + tag
				tags[i] = tag
			}
			if err := tailcfg.CheckTag(tag); err != nil {
				return nil, fmt.Errorf("tag: %q: %s", tag, err)
			}
		}
	}

	if err := dnsname.ValidHostname(upArgs.hostname); upArgs.hostname != "" && err != nil {
		return nil, err
	}

	prefs := ipn.NewPrefs()
	prefs.ControlURL = upArgs.server
	prefs.WantRunning = true
	prefs.RouteAll = upArgs.acceptRoutes
	if distro.Get() == distro.Synology {
		// ipn.NewPrefs returns a non-zero Netfilter default. But Synology only
		// supports "off" mode.
		prefs.NetfilterMode = preftype.NetfilterOff
	}
	if upArgs.exitNodeIP != "" {
		if expr, useAutoExitNode := ipn.ParseAutoExitNodeString(upArgs.exitNodeIP); useAutoExitNode {
			prefs.AutoExitNode = expr
		} else if err := prefs.SetExitNodeIP(upArgs.exitNodeIP, st); err != nil {
			if _, ok := errors.AsType[ipn.ExitNodeLocalIPError](err); ok {
				return nil, fmt.Errorf("%w; did you mean --advertise-exit-node?", err)
			}
			return nil, err
		}
	}

	prefs.ExitNodeAllowLANAccess = upArgs.exitNodeAllowLANAccess
	prefs.CorpDNS = upArgs.acceptDNS
	prefs.ShieldsUp = upArgs.shieldsUp
	prefs.RunSSH = upArgs.runSSH
	prefs.RunWebClient = upArgs.runWebClient
	prefs.AdvertiseRoutes = routes
	prefs.AdvertiseTags = tags
	prefs.Hostname = upArgs.hostname
	prefs.ForceDaemon = upArgs.forceDaemon
	prefs.OperatorUser = upArgs.opUser
	prefs.ProfileName = upArgs.profileName
	prefs.AppConnector.Advertise = upArgs.advertiseConnector
	prefs.PostureChecking = upArgs.postureChecking

	if goos == "linux" {
		prefs.NoSNAT = !upArgs.snat
		// We want to make sure user is aware setting --snat-subnet-routes=false with --advertise-exit-node would break exitnode,
		// but we won't prevent them from doing it since there are current dependencies on that combination. (as of 2026-03-25)
		if prefs.NoSNAT && prefs.AdvertisesExitNode() {
			warnf("--snat-subnet-routes=false is set with --advertise-exit-node; internet traffic through this exit node may not work as expected")
		}

		// Backfills for NoStatefulFiltering occur when loading a profile; just set it explicitly here.
		prefs.NoStatefulFiltering.Set(!upArgs.statefulFiltering)
		v, warning, err := netfilterModeFromFlag(upArgs.netfilterMode)
		if err != nil {
			return nil, err
		}
		prefs.NetfilterMode = v
		if warning != "" {
			warnf(warning)
		}
	}
	return prefs, nil
}

// netfilterModeFromFlag returns the preftype.NetfilterMode for the provided
// flag value. It returns a warning if there is something the user should know
// about the value.
func netfilterModeFromFlag(v string) (_ preftype.NetfilterMode, warning string, _ error) {
	switch v {
	case "on", "nodivert", "off":
	default:
		return preftype.NetfilterOn, "", fmt.Errorf("invalid value --netfilter-mode=%q", v)
	}
	m, err := preftype.ParseNetfilterMode(v)
	if err != nil {
		return preftype.NetfilterOn, "", err
	}
	switch m {
	case preftype.NetfilterNoDivert:
		warning = "netfilter=nodivert; add iptables calls to ts-* chains manually."
	case preftype.NetfilterOff:
		if defaultNetfilterMode() != "off" {
			warning = "netfilter=off; configure iptables yourself."
		}
	}
	return m, warning, nil
}

// updatePrefs returns how to edit preferences based on the
// flag-provided 'prefs' and the currently active 'curPrefs'.
//
// It returns a non-nil justEditMP if we're already running and none of
// the flags require a restart, so we can just do an EditPrefs call and
// change the prefs at runtime (e.g. changing hostname, changing
// advertised routes, etc).
//
// It returns simpleUp if we're running a simple "tailscale up" to
// transition to running from a previously-logged-in but down state,
// without changing any settings.
//
// Note this can also mutate prefs to add implicit preferences for the
// user operator.
//
// TODO(alexc): the name of this function is confusing, and perhaps a
// sign that it's doing too much. Consider refactoring this so it's just
// telling the caller what to do next, but not changing anything itself.
func updatePrefs(prefs, curPrefs *ipn.Prefs, env upCheckEnv) (simpleUp bool, justEditMP *ipn.MaskedPrefs, err error) {
	if !env.upArgs.reset {
		applyImplicitPrefs(prefs, curPrefs, env)

		simpleUp, err = checkForAccidentalSettingReverts(prefs, curPrefs, env)
		if err != nil {
			return false, nil, err
		}
	}

	controlURLChanged := curPrefs.ControlURL != prefs.ControlURL &&
		!(ipn.IsLoginServerSynonym(curPrefs.ControlURL) && ipn.IsLoginServerSynonym(prefs.ControlURL))
	if controlURLChanged && env.backendState == ipn.Running.String() && !env.upArgs.forceReauth {
		return false, nil, fmt.Errorf("can't change --login-server without --force-reauth")
	}

	// Do this after validations to avoid the 5s delay if we're going to error
	// out anyway.
	wantSSH, haveSSH := env.upArgs.runSSH, curPrefs.RunSSH
	if err := presentSSHToggleRisk(wantSSH, haveSSH, env.upArgs.acceptedRisks); err != nil {
		return false, nil, err
	}

	if env.goos == "darwin" && env.upArgs.advertiseConnector {
		if err := presentRiskToUser(riskMacAppConnector, riskMacAppConnectorMessage, env.upArgs.acceptedRisks); err != nil {
			return false, nil, err
		}
	}

	if env.upArgs.forceReauth && isSSHOverTailscale() {
		if err := presentRiskToUser(riskLoseSSH, `You are connected over Tailscale; this action may result in your SSH session disconnecting.`, env.upArgs.acceptedRisks); err != nil {
			return false, nil, err
		}
	}

	tagsChanged := !reflect.DeepEqual(curPrefs.AdvertiseTags, prefs.AdvertiseTags)

	justEdit := env.backendState == ipn.Running.String() &&
		!env.upArgs.forceReauth &&
		env.upArgs.authKeyOrFile == "" &&
		!controlURLChanged &&
		!tagsChanged

	if justEdit {
		justEditMP = new(ipn.MaskedPrefs)
		justEditMP.WantRunningSet = true
		justEditMP.Prefs = *prefs
		visitFlags := env.flagSet.Visit
		if env.upArgs.reset {
			visitFlags = env.flagSet.VisitAll
		}
		if prefs.AutoExitNode.IsSet() {
			justEditMP.AutoExitNodeSet = true
		}
		visitFlags(func(f *flag.Flag) {
			updateMaskedPrefsFromUpOrSetFlag(justEditMP, f.Name)
		})
	}

	return simpleUp, justEditMP, nil
}

func presentSSHToggleRisk(wantSSH, haveSSH bool, acceptedRisks string) error {
	if !isSSHOverTailscale() || wantSSH == haveSSH {
		return nil
	}
	if wantSSH {
		return presentRiskToUser(riskLoseSSH, `You are connected over Tailscale; this action will reroute SSH traffic to Tailscale SSH and will result in your session disconnecting.`, acceptedRisks)
	}
	return presentRiskToUser(riskLoseSSH, `You are connected using Tailscale SSH; this action will result in your session disconnecting.`, acceptedRisks)
}

func runUp(ctx context.Context, cmd string, args []string, upArgs upArgsT) (retErr error) {
	var egg bool
	if len(args) > 0 {
		egg = fmt.Sprint(args) == "[up down down left right left right b a]"
		if !egg {
			fatalf("too many non-flag arguments: %q", args)
		}
	}

	st, err := localClient.Status(ctx)
	if err != nil {
		return fixTailscaledConnectError(err)
	}
	origAuthURL := st.AuthURL
	origNodeKey := st.Self.PublicKey

	// printAuthURL reports whether we should print out the
	// provided auth URL from an IPN notify.
	printAuthURL := func(url string) bool {
		if url == "" {
			// Probably unnecessary but we used to have a bug where tailscaled
			// could send an empty URL over the IPN bus. ~Harmless to keep.
			return false
		}
		if upArgs.authKeyOrFile != "" {
			// 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 supported on Synology; see https://github.com/tailscale/tailscale/issues/1995"
		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, effectiveGOOS())
	if err != nil {
		fatalf("%s", err)
	}

	warnOnAdvertiseRoutes(ctx, prefs)

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

	if cmd == "up" {
		// "tailscale up" should not be able to change the
		// profile name.
		prefs.ProfileName = curPrefs.ProfileName
	}

	env := upCheckEnv{
		goos:          effectiveGOOS(),
		distro:        distro.Get(),
		user:          os.Getenv("USER"),
		flagSet:       upFlagSet,
		upArgs:        upArgs,
		backendState:  st.BackendState,
		curExitNodeIP: exitNodeIP(curPrefs, st),
	}

	defer func() {
		if retErr == nil {
			checkUpWarnings(ctx)
		}
	}()

	simpleUp, justEditMP, err := updatePrefs(prefs, curPrefs, env)
	if err != nil {
		fatalf("%s", err)
	}
	if justEditMP != nil {
		justEditMP.EggSet = egg
		_, err := localClient.EditPrefs(ctx, justEditMP)
		return err
	}

	watchCtx, cancelWatch := context.WithCancel(ctx)
	defer cancelWatch()

	go func() {
		interrupt := make(chan os.Signal, 1)
		signal.Notify(interrupt, syscall.SIGINT, syscall.SIGTERM)
		select {
		case <-interrupt:
			cancelWatch()
		case <-watchCtx.Done():
		}
	}()

	if !buildfeatures.HasIPNBus {
		fmt.Fprintln(Stderr, "binary built with ts_omit_ipnbus; not waiting for completion")
		return nil
	}

	// Start watching the IPN bus before we call Start() or StartLoginInteractive(),
	// or we could miss IPN notifications.
	//
	// In particular, if we're doing a force-reauth, we could miss the
	// notification with the auth URL we should print for the user.
	watcher, err := localClient.WatchIPNBus(watchCtx, 0)
	if err != nil {
		return err
	}
	defer watcher.Close()

	// Special case: bare "tailscale up" means to just start
	// running, if there's ever been a login.
	if simpleUp {
		_, err := localClient.EditPrefs(ctx, &ipn.MaskedPrefs{
			Prefs: ipn.Prefs{
				WantRunning: true,
			},
			WantRunningSet: true,
		})
		if err != nil {
			return err
		}
	} else {
		if err := localClient.CheckPrefs(ctx, prefs); err != nil {
			return err
		}

		authKey, err := upArgs.getAuthKey(ctx)
		if err != nil {
			return err
		}
		// Try to use an OAuth secret to generate an auth key if that functionality
		// is available.
		if f, ok := tailscale.HookResolveAuthKey.GetOk(); ok {
			clientSecret := authKey // the authkey argument accepts client secrets, if both arguments are provided authkey has precedence
			if clientSecret == "" {
				clientSecret, err = upArgs.getClientSecret(ctx)
				if err != nil {
					return err
				}
			}

			authKey, err = f(ctx, clientSecret, prefs.AdvertiseTags)
			if err != nil {
				return err
			}
		}
		// Try to resolve the auth key via workload identity federation if that functionality
		// is available and no auth key is yet determined.
		if f, ok := tailscale.HookResolveAuthKeyViaWIF.GetOk(); ok && authKey == "" {
			idToken, err := upArgs.getIDToken(ctx)
			if err != nil {
				return err
			}

			authKey, err = f(ctx, prefs.ControlURL, upArgs.clientID, idToken, upArgs.audience, prefs.AdvertiseTags)
			if err != nil {
				return err
			}
		}

		err = localClient.Start(ctx, ipn.Options{
			AuthKey:     authKey,
			UpdatePrefs: prefs,
		})
		if err != nil {
			return err
		}
		effectivePrefs = prefs
		if upArgs.forceReauth || !st.HaveNodeKey {
			err := localClient.StartLoginInteractive(ctx)
			if err != nil {
				return err
			}
		}
	}

	upComplete := make(chan bool, 1)
	watchErr := make(chan error, 1)

	go func() {
		var printed bool // whether we've yet printed anything to stdout or stderr
		lastURLPrinted := ""

		// If we're doing a force-reauth, we need to get two notifications:
		//
		//	 1. IPN is running
		//	 2. The node key has changed
		//
		// These two notifications arrive separately, and trying to combine them
		// has caused unexpected issues elsewhere in `tailscale up`.  For now, we
		// track them separately.
		ipnIsRunning := false
		waitingForKeyChange := upArgs.forceReauth

		// If we're doing a simple up (i.e. `tailscale up`, no flags) and
		// the initial state is NeedsMachineAuth, then we never receive a
		// state notification from ipn, so we print the device approval URL
		// immediately.
		if simpleUp && st.BackendState == ipn.NeedsMachineAuth.String() {
			printed = true
			printDeviceApprovalInfo(env.upArgs.json, effectivePrefs, &lastURLPrinted)
		}

		for {
			n, err := watcher.Next()
			if err != nil {
				watchErr <- err
				return
			}
			if n.ErrMessage != nil {
				msg := *n.ErrMessage
				fatalf("backend error: %v\n", msg)
			}
			if s := n.State; s != nil && *s == ipn.NeedsMachineAuth {
				printed = true
				printDeviceApprovalInfo(env.upArgs.json, effectivePrefs, &lastURLPrinted)
			}
			if s := n.State; s != nil {
				ipnIsRunning = *s == ipn.Running
			}
			if n.SelfChange != nil && n.SelfChange.Key != origNodeKey {
				waitingForKeyChange = false
			}
			if ipnIsRunning && !waitingForKeyChange {
				// Done full authentication process
				if env.upArgs.json {
					printUpDoneJSON(ipn.Running, "")
				} else if printed {
					// Only need to print an update if we printed the "please click" message earlier.
					fmt.Fprintf(Stderr, "Success.\n")
				}
				select {
				case upComplete <- true:
				default:
				}
				cancelWatch()
				return
			}
			if url := n.BrowseToURL; url != nil {
				authURL := *url
				if !printAuthURL(authURL) || authURL == lastURLPrinted {
					continue
				}
				printed = true
				lastURLPrinted = authURL
				if upArgs.json {
					js := &upOutputJSON{AuthURL: authURL, BackendState: st.BackendState}

					if buildfeatures.HasQRCodes {
						png, err := qrcodes.EncodePNG(authURL, 128)
						if err == nil {
							js.QR = "data:image/png;base64," + base64.StdEncoding.EncodeToString(png)
						}
					}

					data, err := json.MarshalIndent(js, "", "\t")
					if err != nil {
						printf("upOutputJSON marshalling error: %v", err)
					} else {
						outln(string(data))
					}
				} else {
					fmt.Fprintf(Stderr, "\nTo authenticate, visit:\n\n\t%s\n\n", authURL)
					if upArgs.qr && buildfeatures.HasQRCodes {
						_, err := qrcodes.Fprintln(Stderr, qrcodes.Format(upArgs.qrFormat), authURL)
						if err != nil {
							log.Print(err)
						}
					}
				}
			}
		}
	}()

	// This whole 'up' mechanism is too complicated and results in
	// hairy stuff like this select. We're ultimately waiting for
	// 'running' to be done, but even in the case where
	// it succeeds, other parts may shut down concurrently so we
	// need to prioritize reads from 'running' if it's
	// readable; its send does happen before the pump mechanism
	// shuts down. (Issue 2333)
	var timeoutCh <-chan time.Time
	if upArgs.timeout > 0 {
		timeoutTimer := time.NewTimer(upArgs.timeout)
		defer timeoutTimer.Stop()
		timeoutCh = timeoutTimer.C
	}
	select {
	case <-upComplete:
		return nil
	case <-watchCtx.Done():
		select {
		case <-upComplete:
			return nil
		default:
		}
		return watchCtx.Err()
	case err := <-watchErr:
		select {
		case <-upComplete:
			return nil
		default:
		}
		return err
	case <-timeoutCh:
		return errors.New(`timeout waiting for Tailscale service to enter a Running state; check health with "tailscale status"`)
	}
}

func printDeviceApprovalInfo(printJson bool, prefs *ipn.Prefs, lastURLPrinted *string) {
	if printJson {
		printUpDoneJSON(ipn.NeedsMachineAuth, "")
	} else {
		deviceApprovalURL := prefs.AdminPageURL(policyclient.Get())

		if lastURLPrinted != nil && deviceApprovalURL == *lastURLPrinted {
			return
		}

		*lastURLPrinted = deviceApprovalURL
		errf("\nTo approve your machine, visit (as admin):\n\n\t%s\n\n", deviceApprovalURL)
	}
}

// upWorthWarning reports whether the health check message s is worth warning
// about during "tailscale up". Many of the health checks are noisy or confusing
// or very ephemeral and happen especially briefly at startup.
//
// TODO(bradfitz): change the server to send typed warnings with metadata about
// the health check, rather than just a string.
func upWorthyWarning(s string) bool {
	return strings.Contains(s, healthmsg.TailscaleSSHOnBut) ||
		strings.Contains(s, healthmsg.WarnAcceptRoutesOff) ||
		strings.Contains(s, healthmsg.LockedOut) ||
		strings.Contains(s, healthmsg.InMemoryTailnetLockState) ||
		strings.Contains(strings.ToLower(s), "update available: ")
}

func checkUpWarnings(ctx context.Context) {
	st, err := localClient.StatusWithoutPeers(ctx)
	if err != nil {
		// Ignore. Don't spam more.
		return
	}
	var warn []string
	for _, w := range st.Health {
		if upWorthyWarning(w) {
			warn = append(warn, w)
		}
	}
	if len(warn) == 0 {
		return
	}
	if len(warn) == 1 {
		printf("%s\n", warn[0])
		return
	}
	printf("# Health check warnings:\n")
	for _, m := range warn {
		printf("#     - %s\n", m)
	}
}

func printUpDoneJSON(state ipn.State, errorString string) {
	js := &upOutputJSON{BackendState: state.String(), Error: errorString}
	data, err := json.MarshalIndent(js, "", "  ")
	if err != nil {
		log.Printf("printUpDoneJSON marshalling error: %v", err)
	} else {
		outln(string(data))
	}
}

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("hostname", "Hostname")
	addPrefFlagMapping("login-server", "ControlURL")
	addPrefFlagMapping("netfilter-mode", "NetfilterMode")
	addPrefFlagMapping("shields-up", "ShieldsUp")
	addPrefFlagMapping("snat-subnet-routes", "NoSNAT")
	addPrefFlagMapping("stateful-filtering", "NoStatefulFiltering")
	addPrefFlagMapping("exit-node-allow-lan-access", "ExitNodeAllowLANAccess")
	addPrefFlagMapping("unattended", "ForceDaemon")
	addPrefFlagMapping("operator", "OperatorUser")
	addPrefFlagMapping("ssh", "RunSSH")
	addPrefFlagMapping("webclient", "RunWebClient")
	addPrefFlagMapping("nickname", "ProfileName")
	addPrefFlagMapping("update-check", "AutoUpdate.Check")
	addPrefFlagMapping("auto-update", "AutoUpdate.Apply")
	addPrefFlagMapping("advertise-connector", "AppConnector")
	addPrefFlagMapping("report-posture", "PostureChecking")
	addPrefFlagMapping("relay-server-port", "RelayServerPort")
	addPrefFlagMapping("sync", "Sync")
	addPrefFlagMapping("relay-server-static-endpoints", "RelayServerStaticEndpoints")
}

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

// preflessFlag reports whether flagName is a flag that doesn't
// correspond to an ipn.Pref.
func preflessFlag(flagName string) bool {
	switch flagName {
	case "auth-key", "force-reauth", "reset", "qr", "qr-format", "json", "timeout", "accept-risk", "host-routes", "client-id", "audience", "client-secret", "id-token":
		return true
	}
	return false
}

func updateMaskedPrefsFromUpOrSetFlag(mp *ipn.MaskedPrefs, flagName string) {
	if preflessFlag(flagName) {
		return
	}
	if prefs, ok := prefsOfFlag[flagName]; ok {
		for _, pref := range prefs {
			f := reflect.ValueOf(mp).Elem()
			for name := range strings.SplitSeq(pref, ".") {
				f = f.FieldByName(name + "Set")
			}
			f.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
	user          string
	flagSet       *flag.FlagSet
	upArgs        upArgsT
	backendState  string
	curExitNodeIP netip.Addr
	distro        distro.Distro
}

// 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(newPrefs, curPrefs *ipn.Prefs, env upCheckEnv) (simpleUp bool, err error) {
	if curPrefs.ControlURL == "" {
		// Don't validate things on initial "up" before a control URL has been set.
		return false, nil
	}

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

	if len(flagIsSet) == 0 &&
		curPrefs.Persist != nil &&
		curPrefs.Persist.UserProfile.LoginName != "" &&
		env.backendState != ipn.NeedsLogin.String() {
		// A bare "tailscale up" is a special case to just
		// mean bringing the network up without any changes.
		return true, 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
		}
		if flagName == "login-server" && ipn.IsLoginServerSynonym(valCur) && ipn.IsLoginServerSynonym(valNew) {
			continue
		}
		if flagName == "accept-routes" && valNew == false && env.goos == "linux" && env.distro == distro.Synology {
			// Issue 3176. Old prefs had 'RouteAll: true' on disk, so ignore that.
			continue
		}
		if flagName == "netfilter-mode" && valNew == preftype.NetfilterOn && env.goos == "linux" && env.distro == distro.Synology {
			// Issue 6811. Ignore on Synology.
			continue
		}
		if flagName == "stateful-filtering" && valCur == true && valNew == false && env.goos == "linux" {
			// See https://github.com/tailscale/tailscale/issues/12307
			// Stateful filtering was on by default in tailscale 1.66.0-1.66.3, then off in 1.66.4.
			// This broke Tailscale installations in containerized
			// environments that use the default containerboot
			// configuration that configures tailscale using
			// 'tailscale up' command, which requires that all
			// previously set flags are explicitly provided on
			// subsequent restarts.
			continue
		}
		missing = append(missing, fmtFlagValueArg(flagName, valCur))
	}
	if len(missing) == 0 {
		return false, nil
	}

	// Some previously provided flags are missing. This run of 'tailscale
	// up' will error out.

	sort.Strings(missing)

	// Compute the stringification of the explicitly provided args in flagSet
	// to prepend to the command to run.
	var explicit []string
	env.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 false, errors.New(sb.String())
}

// applyImplicitPrefs mutates prefs to add implicit preferences for the user operator.
// If the operator flag is passed no action is taken, otherwise this 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, env upCheckEnv) {
	explicitOperator := false
	env.flagSet.Visit(func(f *flag.Flag) {
		if f.Name == "operator" {
			explicitOperator = true
		}
	})

	if prefs.OperatorUser == "" && oldPrefs.OperatorUser == env.user && !explicitOperator {
		prefs.OperatorUser = oldPrefs.OperatorUser
	}
}

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

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

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

	fs := newUpFlagSet(env.goos, new(upArgsT) /* dummy */, "up")
	fs.VisitAll(func(f *flag.Flag) {
		if preflessFlag(f.Name) {
			return
		}
		set := func(v any) {
			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 "ssh":
			set(prefs.RunSSH)
		case "webclient":
			set(prefs.RunWebClient)
		case "login-server":
			set(prefs.ControlURL)
		case "accept-routes":
			set(prefs.RouteAll)
		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 tsaddr.WithoutExitRoutes(views.SliceOf(prefs.AdvertiseRoutes)).All() {
				if i > 0 {
					sb.WriteByte(',')
				}
				sb.WriteString(r.String())
			}
			set(sb.String())
		case "advertise-exit-node":
			set(tsaddr.ContainsExitRoutes(views.SliceOf(prefs.AdvertiseRoutes)))
		case "advertise-connector":
			set(prefs.AppConnector.Advertise)
		case "snat-subnet-routes":
			set(!prefs.NoSNAT)
		case "stateful-filtering":
			// We only set the stateful-filtering flag to false if
			// the pref (negated!) is explicitly set to true; unset
			// or false is treated as enabled.
			val, ok := prefs.NoStatefulFiltering.Get()
			if ok && val {
				set(false)
			} else {
				set(true)
			}
		case "netfilter-mode":
			set(prefs.NetfilterMode.String())
		case "unattended":
			set(prefs.ForceDaemon)
		case "report-posture":
			set(prefs.PostureChecking)
		}
	})
	return ret
}

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

// 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 netip.Addr) {
	if p == nil {
		return
	}
	if p.ExitNodeIP.IsValid() {
		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
}

func warnOnAdvertiseRoutes(ctx context.Context, prefs *ipn.Prefs) {
	if buildfeatures.HasAdvertiseRoutes && len(prefs.AdvertiseRoutes) > 0 ||
		buildfeatures.HasAppConnectors && prefs.AppConnector.Advertise {
		// TODO(jwhited): compress CheckIPForwarding and CheckUDPGROForwarding
		//  into a single HTTP req.
		if err := localClient.CheckIPForwarding(ctx); err != nil {
			warnf("%v", err)
		}
		if runtime.GOOS == "linux" {
			if err := localClient.CheckUDPGROForwarding(ctx); err != nil {
				warnf("%v", err)
			}
		}
	}
}
