summaryrefslogtreecommitdiffhomepage
path: root/cmd/tailscaled
diff options
context:
space:
mode:
Diffstat (limited to 'cmd/tailscaled')
-rw-r--r--cmd/tailscaled/cli/bugreport.go38
-rw-r--r--cmd/tailscaled/cli/cli.go276
-rw-r--r--cmd/tailscaled/cli/cli_test.go668
-rw-r--r--cmd/tailscaled/cli/debug.go129
-rw-r--r--cmd/tailscaled/cli/down.go46
-rw-r--r--cmd/tailscaled/cli/file.go444
-rw-r--r--cmd/tailscaled/cli/ip.go105
-rw-r--r--cmd/tailscaled/cli/logout.go34
-rw-r--r--cmd/tailscaled/cli/netcheck.go178
-rw-r--r--cmd/tailscaled/cli/ping.go179
-rw-r--r--cmd/tailscaled/cli/status.go226
-rw-r--r--cmd/tailscaled/cli/up.go775
-rw-r--r--cmd/tailscaled/cli/version.go51
-rw-r--r--cmd/tailscaled/cli/web.css1337
-rw-r--r--cmd/tailscaled/cli/web.go327
-rw-r--r--cmd/tailscaled/cli/web.html143
-rw-r--r--cmd/tailscaled/main.go16
-rw-r--r--cmd/tailscaled/tailscale.go27
-rw-r--r--cmd/tailscaled/tailscaled.go2
19 files changed, 5000 insertions, 1 deletions
diff --git a/cmd/tailscaled/cli/bugreport.go b/cmd/tailscaled/cli/bugreport.go
new file mode 100644
index 000000000..46c32bf96
--- /dev/null
+++ b/cmd/tailscaled/cli/bugreport.go
@@ -0,0 +1,38 @@
+// Copyright (c) 2021 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"
+ "fmt"
+
+ "github.com/peterbourgon/ff/v2/ffcli"
+ "tailscale.com/client/tailscale"
+)
+
+var bugReportCmd = &ffcli.Command{
+ Name: "bugreport",
+ Exec: runBugReport,
+ ShortHelp: "Print a shareable identifier to help diagnose issues",
+ ShortUsage: "bugreport [note]",
+}
+
+func runBugReport(ctx context.Context, args []string) error {
+ var note string
+ switch len(args) {
+ case 0:
+ case 1:
+ note = args[0]
+ default:
+ return errors.New("unknown argumets")
+ }
+ logMarker, err := tailscale.BugReport(ctx, note)
+ if err != nil {
+ return err
+ }
+ fmt.Println(logMarker)
+ return nil
+}
diff --git a/cmd/tailscaled/cli/cli.go b/cmd/tailscaled/cli/cli.go
new file mode 100644
index 000000000..ab58eb4a3
--- /dev/null
+++ b/cmd/tailscaled/cli/cli.go
@@ -0,0 +1,276 @@
+// 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 contains the cmd/tailscale CLI code in a package that can be included
+// in other wrapper binaries such as the Mac and Windows clients.
+package cli
+
+import (
+ "context"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "net"
+ "os"
+ "os/signal"
+ "runtime"
+ "strconv"
+ "strings"
+ "syscall"
+ "text/tabwriter"
+
+ "github.com/peterbourgon/ff/v2/ffcli"
+ "tailscale.com/client/tailscale"
+ "tailscale.com/ipn"
+ "tailscale.com/paths"
+ "tailscale.com/safesocket"
+ "tailscale.com/syncs"
+)
+
+// ActLikeCLI reports whether a GUI application should act like the
+// CLI based on os.Args, GOOS, the context the process is running in
+// (pty, parent PID), etc.
+func ActLikeCLI() bool {
+ // This function is only used on macOS.
+ if runtime.GOOS != "darwin" {
+ return false
+ }
+
+ // Escape hatch to let people force running the macOS
+ // GUI Tailscale binary as the CLI.
+ if v, _ := strconv.ParseBool(os.Getenv("TAILSCALE_BE_CLI")); v {
+ return true
+ }
+
+ // If our parent is launchd, we're definitely not
+ // being run as a CLI.
+ if os.Getppid() == 1 {
+ return false
+ }
+
+ // Xcode adds the -NSDocumentRevisionsDebugMode flag on execution.
+ // If present, we are almost certainly being run as a GUI.
+ for _, arg := range os.Args {
+ if arg == "-NSDocumentRevisionsDebugMode" {
+ return false
+ }
+ }
+
+ // Looking at the environment of the GUI Tailscale app (ps eww
+ // $PID), empirically none of these environment variables are
+ // present. But all or some of these should be present with
+ // Terminal.all and bash or zsh.
+ for _, e := range []string{
+ "SHLVL",
+ "TERM",
+ "TERM_PROGRAM",
+ "PS1",
+ } {
+ if os.Getenv(e) != "" {
+ return true
+ }
+ }
+ return false
+}
+
+// Run runs the CLI. The args do not include the binary name.
+func Run(args []string) error {
+ if len(args) == 1 && (args[0] == "-V" || args[0] == "--version") {
+ args = []string{"version"}
+ }
+
+ rootfs := flag.NewFlagSet("tailscale", flag.ExitOnError)
+ rootfs.StringVar(&rootArgs.socket, "socket", paths.DefaultTailscaledSocket(), "path to tailscaled's unix socket")
+
+ rootCmd := &ffcli.Command{
+ Name: "tailscale",
+ ShortUsage: "tailscale [flags] <subcommand> [command flags]",
+ ShortHelp: "The easiest, most secure way to use WireGuard.",
+ LongHelp: strings.TrimSpace(`
+For help on subcommands, add --help after: "tailscale status --help".
+
+This CLI is still under active development. Commands and flags will
+change in the future.
+`),
+ Subcommands: []*ffcli.Command{
+ upCmd,
+ downCmd,
+ logoutCmd,
+ netcheckCmd,
+ ipCmd,
+ statusCmd,
+ pingCmd,
+ versionCmd,
+ webCmd,
+ fileCmd,
+ bugReportCmd,
+ },
+ FlagSet: rootfs,
+ Exec: func(context.Context, []string) error { return flag.ErrHelp },
+ UsageFunc: usageFunc,
+ }
+ for _, c := range rootCmd.Subcommands {
+ c.UsageFunc = usageFunc
+ }
+
+ // Don't advertise the debug command, but it exists.
+ if strSliceContains(args, "debug") {
+ rootCmd.Subcommands = append(rootCmd.Subcommands, debugCmd)
+ }
+
+ if err := rootCmd.Parse(args); err != nil {
+ return err
+ }
+
+ tailscale.TailscaledSocket = rootArgs.socket
+
+ err := rootCmd.Run(context.Background())
+ if err == flag.ErrHelp {
+ return nil
+ }
+ return err
+}
+
+func fatalf(format string, a ...interface{}) {
+ log.SetFlags(0)
+ log.Fatalf(format, a...)
+}
+
+var rootArgs struct {
+ socket string
+}
+
+var gotSignal syncs.AtomicBool
+
+func connect(ctx context.Context) (net.Conn, *ipn.BackendClient, context.Context, context.CancelFunc) {
+ c, err := safesocket.Connect(rootArgs.socket, 41112)
+ if err != nil {
+ if runtime.GOOS != "windows" && rootArgs.socket == "" {
+ fatalf("--socket cannot be empty")
+ }
+ fatalf("Failed to connect to tailscaled. (safesocket.Connect: %v)\n", err)
+ }
+ clientToServer := func(b []byte) {
+ ipn.WriteMsg(c, b)
+ }
+
+ ctx, cancel := context.WithCancel(ctx)
+
+ go func() {
+ interrupt := make(chan os.Signal, 1)
+ signal.Notify(interrupt, syscall.SIGINT, syscall.SIGTERM)
+ select {
+ case <-interrupt:
+ case <-ctx.Done():
+ // Context canceled elsewhere.
+ signal.Reset(syscall.SIGINT, syscall.SIGTERM)
+ return
+ }
+ gotSignal.Set(true)
+ c.Close()
+ cancel()
+ }()
+
+ bc := ipn.NewBackendClient(log.Printf, clientToServer)
+ return c, bc, ctx, cancel
+}
+
+// pump receives backend messages on conn and pushes them into bc.
+func pump(ctx context.Context, bc *ipn.BackendClient, conn net.Conn) error {
+ defer conn.Close()
+ for ctx.Err() == nil {
+ msg, err := ipn.ReadMsg(conn)
+ if err != nil {
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+ if errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed) {
+ return fmt.Errorf("%w (tailscaled stopped running?)", err)
+ }
+ return err
+ }
+ bc.GotNotifyMsg(msg)
+ }
+ return ctx.Err()
+}
+
+func strSliceContains(ss []string, s string) bool {
+ for _, v := range ss {
+ if v == s {
+ return true
+ }
+ }
+ return false
+}
+
+func usageFunc(c *ffcli.Command) string {
+ var b strings.Builder
+
+ fmt.Fprintf(&b, "USAGE\n")
+ if c.ShortUsage != "" {
+ fmt.Fprintf(&b, " %s\n", c.ShortUsage)
+ } else {
+ fmt.Fprintf(&b, " %s\n", c.Name)
+ }
+ fmt.Fprintf(&b, "\n")
+
+ if c.LongHelp != "" {
+ fmt.Fprintf(&b, "%s\n\n", c.LongHelp)
+ }
+
+ if len(c.Subcommands) > 0 {
+ fmt.Fprintf(&b, "SUBCOMMANDS\n")
+ tw := tabwriter.NewWriter(&b, 0, 2, 2, ' ', 0)
+ for _, subcommand := range c.Subcommands {
+ fmt.Fprintf(tw, " %s\t%s\n", subcommand.Name, subcommand.ShortHelp)
+ }
+ tw.Flush()
+ fmt.Fprintf(&b, "\n")
+ }
+
+ if countFlags(c.FlagSet) > 0 {
+ fmt.Fprintf(&b, "FLAGS\n")
+ tw := tabwriter.NewWriter(&b, 0, 2, 2, ' ', 0)
+ c.FlagSet.VisitAll(func(f *flag.Flag) {
+ var s string
+ name, usage := flag.UnquoteUsage(f)
+ if isBoolFlag(f) {
+ s = fmt.Sprintf(" --%s, --%s=false", f.Name, f.Name)
+ } else {
+ s = fmt.Sprintf(" --%s", f.Name) // Two spaces before --; see next two comments.
+ if len(name) > 0 {
+ s += " " + name
+ }
+ }
+ // Four spaces before the tab triggers good alignment
+ // for both 4- and 8-space tab stops.
+ s += "\n \t"
+ s += strings.ReplaceAll(usage, "\n", "\n \t")
+
+ if f.DefValue != "" {
+ s += fmt.Sprintf(" (default %s)", f.DefValue)
+ }
+
+ fmt.Fprintln(&b, s)
+ })
+ tw.Flush()
+ fmt.Fprintf(&b, "\n")
+ }
+
+ return strings.TrimSpace(b.String())
+}
+
+func isBoolFlag(f *flag.Flag) bool {
+ bf, ok := f.Value.(interface {
+ IsBoolFlag() bool
+ })
+ return ok && bf.IsBoolFlag()
+}
+
+func countFlags(fs *flag.FlagSet) (n int) {
+ fs.VisitAll(func(*flag.Flag) { n++ })
+ return n
+}
diff --git a/cmd/tailscaled/cli/cli_test.go b/cmd/tailscaled/cli/cli_test.go
new file mode 100644
index 000000000..fecdb76b2
--- /dev/null
+++ b/cmd/tailscaled/cli/cli_test.go
@@ -0,0 +1,668 @@
+// Copyright (c) 2021 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 (
+ "bytes"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "reflect"
+ "strings"
+ "testing"
+
+ "inet.af/netaddr"
+ "tailscale.com/ipn"
+ "tailscale.com/ipn/ipnstate"
+ "tailscale.com/types/preftype"
+)
+
+// geese is a collection of gooses. It need not be complete.
+// But it should include anything handled specially (e.g. linux, windows)
+// and at least one thing that's not (darwin, freebsd).
+var geese = []string{"linux", "darwin", "windows", "freebsd"}
+
+// Test that checkForAccidentalSettingReverts's updateMaskedPrefsFromUpFlag can handle
+// all flags. This will panic if a new flag creeps in that's unhandled.
+//
+// Also, issue 1880: advertise-exit-node was being ignored. Verify that all flags cause an edit.
+func TestUpdateMaskedPrefsFromUpFlag(t *testing.T) {
+ for _, goos := range geese {
+ var upArgs upArgsT
+ fs := newUpFlagSet(goos, &upArgs)
+ fs.VisitAll(func(f *flag.Flag) {
+ mp := new(ipn.MaskedPrefs)
+ updateMaskedPrefsFromUpFlag(mp, f.Name)
+ got := mp.Pretty()
+ wantEmpty := preflessFlag(f.Name)
+ isEmpty := got == "MaskedPrefs{}"
+ if isEmpty != wantEmpty {
+ t.Errorf("flag %q created MaskedPrefs %s; want empty=%v", f.Name, got, wantEmpty)
+ }
+ })
+ }
+}
+
+func TestCheckForAccidentalSettingReverts(t *testing.T) {
+ tests := []struct {
+ name string
+ flags []string // argv to be parsed by FlagSet
+ curPrefs *ipn.Prefs
+
+ curExitNodeIP netaddr.IP
+ curUser string // os.Getenv("USER") on the client side
+ goos string // empty means "linux"
+
+ want string
+ }{
+ {
+ name: "bare_up_means_up",
+ flags: []string{},
+ curPrefs: &ipn.Prefs{
+ ControlURL: ipn.DefaultControlURL,
+ WantRunning: false,
+ Hostname: "foo",
+ },
+ want: "",
+ },
+ {
+ name: "losing_hostname",
+ flags: []string{"--accept-dns"},
+ curPrefs: &ipn.Prefs{
+ ControlURL: ipn.DefaultControlURL,
+ WantRunning: false,
+ Hostname: "foo",
+ CorpDNS: true,
+ NetfilterMode: preftype.NetfilterOn,
+ AllowSingleHosts: true,
+ },
+ want: accidentalUpPrefix + " --accept-dns --hostname=foo",
+ },
+ {
+ name: "hostname_changing_explicitly",
+ flags: []string{"--hostname=bar"},
+ curPrefs: &ipn.Prefs{
+ ControlURL: ipn.DefaultControlURL,
+ CorpDNS: true,
+ NetfilterMode: preftype.NetfilterOn,
+ AllowSingleHosts: true,
+ Hostname: "foo",
+ },
+ want: "",
+ },
+ {
+ name: "hostname_changing_empty_explicitly",
+ flags: []string{"--hostname="},
+ curPrefs: &ipn.Prefs{
+ ControlURL: ipn.DefaultControlURL,
+ CorpDNS: true,
+ NetfilterMode: preftype.NetfilterOn,
+ AllowSingleHosts: true,
+ Hostname: "foo",
+ },
+ want: "",
+ },
+ {
+ // Issue 1725: "tailscale up --authkey=..." (or other non-empty flags) works from
+ // a fresh server's initial prefs.
+ name: "up_with_default_prefs",
+ flags: []string{"--authkey=foosdlkfjskdljf"},
+ curPrefs: ipn.NewPrefs(),
+ want: "",
+ },
+ {
+ name: "implicit_operator_change",
+ flags: []string{"--hostname=foo"},
+ curPrefs: &ipn.Prefs{
+ ControlURL: ipn.DefaultControlURL,
+ OperatorUser: "alice",
+ AllowSingleHosts: true,
+ CorpDNS: true,
+ NetfilterMode: preftype.NetfilterOn,
+ },
+ curUser: "eve",
+ want: accidentalUpPrefix + " --hostname=foo --operator=alice",
+ },
+ {
+ name: "implicit_operator_matches_shell_user",
+ flags: []string{"--hostname=foo"},
+ curPrefs: &ipn.Prefs{
+ ControlURL: ipn.DefaultControlURL,
+ AllowSingleHosts: true,
+ CorpDNS: true,
+ NetfilterMode: preftype.NetfilterOn,
+ OperatorUser: "alice",
+ },
+ curUser: "alice",
+ want: "",
+ },
+ {
+ name: "error_advertised_routes_exit_node_removed",
+ flags: []string{"--advertise-routes=10.0.42.0/24"},
+ curPrefs: &ipn.Prefs{
+ ControlURL: ipn.DefaultControlURL,
+ AllowSingleHosts: true,
+ CorpDNS: true,
+ NetfilterMode: preftype.NetfilterOn,
+ AdvertiseRoutes: []netaddr.IPPrefix{
+ netaddr.MustParseIPPrefix("10.0.42.0/24"),
+ netaddr.MustParseIPPrefix("0.0.0.0/0"),
+ netaddr.MustParseIPPrefix("::/0"),
+ },
+ },
+ want: accidentalUpPrefix + " --advertise-routes=10.0.42.0/24 --advertise-exit-node",
+ },
+ {
+ name: "advertised_routes_exit_node_removed_explicit",
+ flags: []string{"--advertise-routes=10.0.42.0/24", "--advertise-exit-node=false"},
+ curPrefs: &ipn.Prefs{
+ ControlURL: ipn.DefaultControlURL,
+ AllowSingleHosts: true,
+ CorpDNS: true,
+ NetfilterMode: preftype.NetfilterOn,
+ AdvertiseRoutes: []netaddr.IPPrefix{
+ netaddr.MustParseIPPrefix("10.0.42.0/24"),
+ netaddr.MustParseIPPrefix("0.0.0.0/0"),
+ netaddr.MustParseIPPrefix("::/0"),
+ },
+ },
+ want: "",
+ },
+ {
+ name: "advertised_routes_includes_the_0_routes", // but no --advertise-exit-node
+ flags: []string{"--advertise-routes=11.1.43.0/24,0.0.0.0/0,::/0"},
+ curPrefs: &ipn.Prefs{
+ ControlURL: ipn.DefaultControlURL,
+ AllowSingleHosts: true,
+ CorpDNS: true,
+ NetfilterMode: preftype.NetfilterOn,
+ AdvertiseRoutes: []netaddr.IPPrefix{
+ netaddr.MustParseIPPrefix("10.0.42.0/24"),
+ netaddr.MustParseIPPrefix("0.0.0.0/0"),
+ netaddr.MustParseIPPrefix("::/0"),
+ },
+ },
+ want: "",
+ },
+ {
+ name: "advertise_exit_node", // Issue 1859
+ flags: []string{"--advertise-exit-node"},
+ curPrefs: &ipn.Prefs{
+ ControlURL: ipn.DefaultControlURL,
+ AllowSingleHosts: true,
+ CorpDNS: true,
+ NetfilterMode: preftype.NetfilterOn,
+ },
+ want: "",
+ },
+ {
+ name: "advertise_exit_node_over_existing_routes",
+ flags: []string{"--advertise-exit-node"},
+ curPrefs: &ipn.Prefs{
+ ControlURL: ipn.DefaultControlURL,
+ AllowSingleHosts: true,
+ CorpDNS: true,
+ NetfilterMode: preftype.NetfilterOn,
+
+ AdvertiseRoutes: []netaddr.IPPrefix{
+ netaddr.MustParseIPPrefix("1.2.0.0/16"),
+ },
+ },
+ want: accidentalUpPrefix + " --advertise-exit-node --advertise-routes=1.2.0.0/16",
+ },
+ {
+ name: "advertise_exit_node_over_existing_routes_and_exit_node",
+ flags: []string{"--advertise-exit-node"},
+ curPrefs: &ipn.Prefs{
+ ControlURL: ipn.DefaultControlURL,
+ AllowSingleHosts: true,
+ CorpDNS: true,
+ NetfilterMode: preftype.NetfilterOn,
+ AdvertiseRoutes: []netaddr.IPPrefix{
+ netaddr.MustParseIPPrefix("0.0.0.0/0"),
+ netaddr.MustParseIPPrefix("::/0"),
+ netaddr.MustParseIPPrefix("1.2.0.0/16"),
+ },
+ },
+ want: accidentalUpPrefix + " --advertise-exit-node --advertise-routes=1.2.0.0/16",
+ },
+ {
+ name: "exit_node_clearing", // Issue 1777
+ flags: []string{"--exit-node="},
+ curPrefs: &ipn.Prefs{
+ ControlURL: ipn.DefaultControlURL,
+ AllowSingleHosts: true,
+ CorpDNS: true,
+ NetfilterMode: preftype.NetfilterOn,
+
+ ExitNodeID: "fooID",
+ },
+ want: "",
+ },
+ {
+ name: "remove_all_implicit",
+ flags: []string{"--force-reauth"},
+ curPrefs: &ipn.Prefs{
+ WantRunning: true,
+ ControlURL: ipn.DefaultControlURL,
+ RouteAll: true,
+ AllowSingleHosts: false,
+ ExitNodeIP: netaddr.MustParseIP("100.64.5.6"),
+ CorpDNS: false,
+ ShieldsUp: true,
+ AdvertiseTags: []string{"tag:foo", "tag:bar"},
+ Hostname: "myhostname",
+ ForceDaemon: true,
+ AdvertiseRoutes: []netaddr.IPPrefix{
+ netaddr.MustParseIPPrefix("10.0.0.0/16"),
+ netaddr.MustParseIPPrefix("0.0.0.0/0"),
+ netaddr.MustParseIPPrefix("::/0"),
+ },
+ NetfilterMode: preftype.NetfilterNoDivert,
+ OperatorUser: "alice",
+ },
+ curUser: "eve",
+ want: accidentalUpPrefix + " --force-reauth --accept-dns=false --accept-routes --advertise-exit-node --advertise-routes=10.0.0.0/16 --advertise-tags=tag:foo,tag:bar --exit-node=100.64.5.6 --host-routes=false --hostname=myhostname --netfilter-mode=nodivert --operator=alice --shields-up",
+ },
+ {
+ name: "remove_all_implicit_except_hostname",
+ flags: []string{"--hostname=newhostname"},
+ curPrefs: &ipn.Prefs{
+ WantRunning: true,
+ ControlURL: ipn.DefaultControlURL,
+ RouteAll: true,
+ AllowSingleHosts: false,
+ ExitNodeIP: netaddr.MustParseIP("100.64.5.6"),
+ CorpDNS: false,
+ ShieldsUp: true,
+ AdvertiseTags: []string{"tag:foo", "tag:bar"},
+ Hostname: "myhostname",
+ ForceDaemon: true,
+ AdvertiseRoutes: []netaddr.IPPrefix{
+ netaddr.MustParseIPPrefix("10.0.0.0/16"),
+ },
+ NetfilterMode: preftype.NetfilterNoDivert,
+ OperatorUser: "alice",
+ },
+ curUser: "eve",
+ want: accidentalUpPrefix + " --hostname=newhostname --accept-dns=false --accept-routes --advertise-routes=10.0.0.0/16 --advertise-tags=tag:foo,tag:bar --exit-node=100.64.5.6 --host-routes=false --netfilter-mode=nodivert --operator=alice --shields-up",
+ },
+ {
+ name: "loggedout_is_implicit",
+ flags: []string{"--hostname=foo"},
+ curPrefs: &ipn.Prefs{
+ ControlURL: ipn.DefaultControlURL,
+ LoggedOut: true,
+ AllowSingleHosts: true,
+ CorpDNS: true,
+ NetfilterMode: preftype.NetfilterOn,
+ },
+ want: "", // not an error. LoggedOut is implicit.
+ },
+ {
+ // Test that a pre-1.8 version of Tailscale with bogus NoSNAT pref
+ // values is able to enable exit nodes without warnings.
+ name: "make_windows_exit_node",
+ flags: []string{"--advertise-exit-node"},
+ curPrefs: &ipn.Prefs{
+ ControlURL: ipn.DefaultControlURL,
+ AllowSingleHosts: true,
+ CorpDNS: true,
+
+ // And assume this no-op accidental pre-1.8 value:
+ NoSNAT: true,
+ },
+ goos: "windows",
+ want: "", // not an error
+ },
+ {
+ name: "ignore_netfilter_change_non_linux",
+ flags: []string{"--accept-dns"},
+ curPrefs: &ipn.Prefs{
+ ControlURL: ipn.DefaultControlURL,
+ AllowSingleHosts: true,
+
+ NetfilterMode: preftype.NetfilterNoDivert, // we never had this bug, but pretend it got set non-zero on Windows somehow
+ },
+ goos: "windows",
+ want: "", // not an error
+ },
+ {
+ name: "operator_losing_routes_step1", // https://twitter.com/EXPbits/status/1390418145047887877
+ flags: []string{"--operator=expbits"},
+ curPrefs: &ipn.Prefs{
+ ControlURL: ipn.DefaultControlURL,
+ AllowSingleHosts: true,
+ CorpDNS: true,
+ NetfilterMode: preftype.NetfilterOn,
+ AdvertiseRoutes: []netaddr.IPPrefix{
+ netaddr.MustParseIPPrefix("0.0.0.0/0"),
+ netaddr.MustParseIPPrefix("::/0"),
+ netaddr.MustParseIPPrefix("1.2.0.0/16"),
+ },
+ },
+ want: accidentalUpPrefix + " --operator=expbits --advertise-exit-node --advertise-routes=1.2.0.0/16",
+ },
+ {
+ name: "operator_losing_routes_step2", // https://twitter.com/EXPbits/status/1390418145047887877
+ flags: []string{"--operator=expbits", "--advertise-routes=1.2.0.0/16"},
+ curPrefs: &ipn.Prefs{
+ ControlURL: ipn.DefaultControlURL,
+ AllowSingleHosts: true,
+ CorpDNS: true,
+ NetfilterMode: preftype.NetfilterOn,
+ AdvertiseRoutes: []netaddr.IPPrefix{
+ netaddr.MustParseIPPrefix("0.0.0.0/0"),
+ netaddr.MustParseIPPrefix("::/0"),
+ netaddr.MustParseIPPrefix("1.2.0.0/16"),
+ },
+ },
+ want: accidentalUpPrefix + " --advertise-routes=1.2.0.0/16 --operator=expbits --advertise-exit-node",
+ },
+ {
+ name: "errors_preserve_explicit_flags",
+ flags: []string{"--reset", "--force-reauth=false", "--authkey=secretrand"},
+ curPrefs: &ipn.Prefs{
+ ControlURL: ipn.DefaultControlURL,
+ WantRunning: false,
+ CorpDNS: true,
+ NetfilterMode: preftype.NetfilterOn,
+ AllowSingleHosts: true,
+
+ Hostname: "foo",
+ },
+ want: accidentalUpPrefix + " --authkey=secretrand --force-reauth=false --reset --hostname=foo",
+ },
+ {
+ name: "error_exit_node_omit_with_ip_pref",
+ flags: []string{"--hostname=foo"},
+ curPrefs: &ipn.Prefs{
+ ControlURL: ipn.DefaultControlURL,
+ AllowSingleHosts: true,
+ CorpDNS: true,
+ NetfilterMode: preftype.NetfilterOn,
+
+ ExitNodeIP: netaddr.MustParseIP("100.64.5.4"),
+ },
+ want: accidentalUpPrefix + " --hostname=foo --exit-node=100.64.5.4",
+ },
+ {
+ name: "error_exit_node_omit_with_id_pref",
+ flags: []string{"--hostname=foo"},
+ curExitNodeIP: netaddr.MustParseIP("100.64.5.7"),
+ curPrefs: &ipn.Prefs{
+ ControlURL: ipn.DefaultControlURL,
+ AllowSingleHosts: true,
+ CorpDNS: true,
+ NetfilterMode: preftype.NetfilterOn,
+
+ ExitNodeID: "some_stable_id",
+ },
+ want: accidentalUpPrefix + " --hostname=foo --exit-node=100.64.5.7",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ goos := "linux"
+ if tt.goos != "" {
+ goos = tt.goos
+ }
+ var upArgs upArgsT
+ flagSet := newUpFlagSet(goos, &upArgs)
+ flagSet.Parse(tt.flags)
+ newPrefs, err := prefsFromUpArgs(upArgs, t.Logf, new(ipnstate.Status), goos)
+ if err != nil {
+ t.Fatal(err)
+ }
+ applyImplicitPrefs(newPrefs, tt.curPrefs, tt.curUser)
+ var got string
+ if err := checkForAccidentalSettingReverts(flagSet, tt.curPrefs, newPrefs, upCheckEnv{
+ goos: goos,
+ curExitNodeIP: tt.curExitNodeIP,
+ }); err != nil {
+ got = err.Error()
+ }
+ if strings.TrimSpace(got) != tt.want {
+ t.Errorf("unexpected result\n got: %s\nwant: %s\n", got, tt.want)
+ }
+ })
+ }
+}
+
+func upArgsFromOSArgs(goos string, flagArgs ...string) (args upArgsT) {
+ fs := newUpFlagSet(goos, &args)
+ fs.Parse(flagArgs) // populates args
+ return
+}
+
+func TestPrefsFromUpArgs(t *testing.T) {
+ tests := []struct {
+ name string
+ args upArgsT
+ goos string // runtime.GOOS; empty means linux
+ st *ipnstate.Status // or nil
+ want *ipn.Prefs
+ wantErr string
+ wantWarn string
+ }{
+ {
+ name: "default_linux",
+ goos: "linux",
+ args: upArgsFromOSArgs("linux"),
+ want: &ipn.Prefs{
+ ControlURL: ipn.DefaultControlURL,
+ WantRunning: true,
+ NoSNAT: false,
+ NetfilterMode: preftype.NetfilterOn,
+ CorpDNS: true,
+ AllowSingleHosts: true,
+ },
+ },
+ {
+ name: "default_windows",
+ goos: "windows",
+ args: upArgsFromOSArgs("windows"),
+ want: &ipn.Prefs{
+ ControlURL: ipn.DefaultControlURL,
+ WantRunning: true,
+ CorpDNS: true,
+ AllowSingleHosts: true,
+ NetfilterMode: preftype.NetfilterOn,
+ },
+ },
+ {
+ name: "advertise_default_route",
+ args: upArgsFromOSArgs("linux", "--advertise-exit-node"),
+ want: &ipn.Prefs{
+ ControlURL: ipn.DefaultControlURL,
+ WantRunning: true,
+ AllowSingleHosts: true,
+ CorpDNS: true,
+ AdvertiseRoutes: []netaddr.IPPrefix{
+ netaddr.MustParseIPPrefix("0.0.0.0/0"),
+ netaddr.MustParseIPPrefix("::/0"),
+ },
+ NetfilterMode: preftype.NetfilterOn,
+ },
+ },
+ {
+ name: "error_advertise_route_invalid_ip",
+ args: upArgsT{
+ advertiseRoutes: "foo",
+ },
+ wantErr: `"foo" is not a valid IP address or CIDR prefix`,
+ },
+ {
+ name: "error_advertise_route_unmasked_bits",
+ args: upArgsT{
+ advertiseRoutes: "1.2.3.4/16",
+ },
+ wantErr: `1.2.3.4/16 has non-address bits set; expected 1.2.0.0/16`,
+ },
+ {
+ name: "error_exit_node_bad_ip",
+ args: upArgsT{
+ exitNodeIP: "foo",
+ },
+ wantErr: `invalid IP address "foo" for --exit-node: ParseIP("foo"): unable to parse IP`,
+ },
+ {
+ name: "error_exit_node_allow_lan_without_exit_node",
+ args: upArgsT{
+ exitNodeAllowLANAccess: true,
+ },
+ wantErr: `--exit-node-allow-lan-access can only be used with --exit-node`,
+ },
+ {
+ name: "error_tag_prefix",
+ args: upArgsT{
+ advertiseTags: "foo",
+ },
+ wantErr: `tag: "foo": tags must start with 'tag:'`,
+ },
+ {
+ name: "error_long_hostname",
+ args: upArgsT{
+ hostname: strings.Repeat("a", 300),
+ },
+ wantErr: `hostname too long: 300 bytes (max 256)`,
+ },
+ {
+ name: "error_linux_netfilter_empty",
+ args: upArgsT{
+ netfilterMode: "",
+ },
+ wantErr: `invalid value --netfilter-mode=""`,
+ },
+ {
+ name: "error_linux_netfilter_bogus",
+ args: upArgsT{
+ netfilterMode: "bogus",
+ },
+ wantErr: `invalid value --netfilter-mode="bogus"`,
+ },
+ {
+ name: "error_exit_node_ip_is_self_ip",
+ args: upArgsT{
+ exitNodeIP: "100.105.106.107",
+ },
+ st: &ipnstate.Status{
+ TailscaleIPs: []netaddr.IP{netaddr.MustParseIP("100.105.106.107")},
+ },
+ wantErr: `cannot use 100.105.106.107 as the exit node as it is a local IP address to this machine, did you mean --advertise-exit-node?`,
+ },
+ {
+ name: "warn_linux_netfilter_nodivert",
+ goos: "linux",
+ args: upArgsT{
+ netfilterMode: "nodivert",
+ },
+ wantWarn: "netfilter=nodivert; add iptables calls to ts-* chains manually.",
+ want: &ipn.Prefs{
+ WantRunning: true,
+ NetfilterMode: preftype.NetfilterNoDivert,
+ NoSNAT: true,
+ },
+ },
+ {
+ name: "warn_linux_netfilter_off",
+ goos: "linux",
+ args: upArgsT{
+ netfilterMode: "off",
+ },
+ wantWarn: "netfilter=off; configure iptables yourself.",
+ want: &ipn.Prefs{
+ WantRunning: true,
+ NetfilterMode: preftype.NetfilterOff,
+ NoSNAT: true,
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var warnBuf bytes.Buffer
+ warnf := func(format string, a ...interface{}) {
+ fmt.Fprintf(&warnBuf, format, a...)
+ }
+ goos := tt.goos
+ if goos == "" {
+ goos = "linux"
+ }
+ st := tt.st
+ if st == nil {
+ st = new(ipnstate.Status)
+ }
+ got, err := prefsFromUpArgs(tt.args, warnf, st, goos)
+ gotErr := fmt.Sprint(err)
+ if tt.wantErr != "" {
+ if tt.wantErr != gotErr {
+ t.Errorf("wrong error.\n got error: %v\nwant error: %v\n", gotErr, tt.wantErr)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ if tt.want == nil {
+ t.Fatal("tt.want is nil")
+ }
+ if !got.Equals(tt.want) {
+ jgot, _ := json.MarshalIndent(got, "", "\t")
+ jwant, _ := json.MarshalIndent(tt.want, "", "\t")
+ if bytes.Equal(jgot, jwant) {
+ t.Logf("prefs differ only in non-JSON-visible ways (nil/non-nil zero-length arrays)")
+ }
+ t.Errorf("wrong prefs\n got: %s\nwant: %s\n\ngot: %s\nwant: %s\n",
+ got.Pretty(), tt.want.Pretty(),
+ jgot, jwant,
+ )
+
+ }
+ })
+ }
+
+}
+
+func TestPrefFlagMapping(t *testing.T) {
+ prefHasFlag := map[string]bool{}
+ for _, pv := range prefsOfFlag {
+ for _, pref := range pv {
+ prefHasFlag[pref] = true
+ }
+ }
+
+ prefType := reflect.TypeOf(ipn.Prefs{})
+ for i := 0; i < prefType.NumField(); i++ {
+ prefName := prefType.Field(i).Name
+ if prefHasFlag[prefName] {
+ continue
+ }
+ switch prefName {
+ case "WantRunning", "Persist", "LoggedOut":
+ // All explicitly handled (ignored) by checkForAccidentalSettingReverts.
+ continue
+ case "OSVersion", "DeviceModel":
+ // Only used by Android, which doesn't have a CLI mode anyway, so
+ // fine to not map.
+ continue
+ case "NotepadURLs":
+ // TODO(bradfitz): https://github.com/tailscale/tailscale/issues/1830
+ continue
+ }
+ t.Errorf("unexpected new ipn.Pref field %q is not handled by up.go (see addPrefFlagMapping and checkForAccidentalSettingReverts)", prefName)
+ }
+}
+
+func TestFlagAppliesToOS(t *testing.T) {
+ for _, goos := range geese {
+ var upArgs upArgsT
+ fs := newUpFlagSet(goos, &upArgs)
+ fs.VisitAll(func(f *flag.Flag) {
+ if !flagAppliesToOS(f.Name, goos) {
+ t.Errorf("flagAppliesToOS(%q, %q) = false but found in %s set", f.Name, goos, goos)
+ }
+ })
+ }
+}
diff --git a/cmd/tailscaled/cli/debug.go b/cmd/tailscaled/cli/debug.go
new file mode 100644
index 000000000..851bb97de
--- /dev/null
+++ b/cmd/tailscaled/cli/debug.go
@@ -0,0 +1,129 @@
+// Copyright (c) 2021 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"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "os"
+ "runtime"
+ "strings"
+
+ "github.com/peterbourgon/ff/v2/ffcli"
+ "tailscale.com/client/tailscale"
+ "tailscale.com/ipn"
+ "tailscale.com/paths"
+ "tailscale.com/safesocket"
+)
+
+var debugCmd = &ffcli.Command{
+ Name: "debug",
+ Exec: runDebug,
+ FlagSet: (func() *flag.FlagSet {
+ fs := flag.NewFlagSet("debug", flag.ExitOnError)
+ fs.BoolVar(&debugArgs.goroutines, "daemon-goroutines", false, "If true, dump the tailscaled daemon's goroutines")
+ fs.BoolVar(&debugArgs.ipn, "ipn", false, "If true, subscribe to IPN notifications")
+ fs.BoolVar(&debugArgs.prefs, "prefs", false, "If true, dump active prefs")
+ fs.BoolVar(&debugArgs.pretty, "pretty", false, "If true, pretty-print output (for --prefs)")
+ fs.BoolVar(&debugArgs.netMap, "netmap", true, "whether to include netmap in --ipn mode")
+ fs.BoolVar(&debugArgs.localCreds, "local-creds", false, "print how to connect to local tailscaled")
+ fs.StringVar(&debugArgs.file, "file", "", "get, delete:NAME, or NAME")
+ return fs
+ })(),
+}
+
+var debugArgs struct {
+ localCreds bool
+ goroutines bool
+ ipn bool
+ netMap bool
+ file string
+ prefs bool
+ pretty bool
+}
+
+func runDebug(ctx context.Context, args []string) error {
+ if len(args) > 0 {
+ return errors.New("unknown arguments")
+ }
+ if debugArgs.localCreds {
+ port, token, err := safesocket.LocalTCPPortAndToken()
+ if err == nil {
+ fmt.Printf("curl -u:%s http://localhost:%d/localapi/v0/status\n", token, port)
+ return nil
+ }
+ if runtime.GOOS == "windows" {
+ fmt.Printf("curl http://localhost:41112/localapi/v0/status\n")
+ return nil
+ }
+ fmt.Printf("curl --unix-socket %s http://foo/localapi/v0/status\n", paths.DefaultTailscaledSocket())
+ return nil
+ }
+ if debugArgs.prefs {
+ prefs, err := tailscale.GetPrefs(ctx)
+ if err != nil {
+ return err
+ }
+ if debugArgs.pretty {
+ fmt.Println(prefs.Pretty())
+ } else {
+ j, _ := json.MarshalIndent(prefs, "", "\t")
+ fmt.Println(string(j))
+ }
+ return nil
+ }
+ if debugArgs.goroutines {
+ goroutines, err := tailscale.Goroutines(ctx)
+ if err != nil {
+ return err
+ }
+ os.Stdout.Write(goroutines)
+ return nil
+ }
+ if debugArgs.ipn {
+ c, bc, ctx, cancel := connect(ctx)
+ defer cancel()
+
+ bc.SetNotifyCallback(func(n ipn.Notify) {
+ if !debugArgs.netMap {
+ n.NetMap = nil
+ }
+ j, _ := json.MarshalIndent(n, "", "\t")
+ fmt.Printf("%s\n", j)
+ })
+ bc.RequestEngineStatus()
+ pump(ctx, bc, c)
+ return errors.New("exit")
+ }
+ if debugArgs.file != "" {
+ if debugArgs.file == "get" {
+ wfs, err := tailscale.WaitingFiles(ctx)
+ if err != nil {
+ log.Fatal(err)
+ }
+ e := json.NewEncoder(os.Stdout)
+ e.SetIndent("", "\t")
+ e.Encode(wfs)
+ return nil
+ }
+ delete := strings.HasPrefix(debugArgs.file, "delete:")
+ if delete {
+ return tailscale.DeleteWaitingFile(ctx, strings.TrimPrefix(debugArgs.file, "delete:"))
+ }
+ rc, size, err := tailscale.GetWaitingFile(ctx, debugArgs.file)
+ if err != nil {
+ return err
+ }
+ log.Printf("Size: %v\n", size)
+ io.Copy(os.Stdout, rc)
+ return nil
+ }
+ return nil
+}
diff --git a/cmd/tailscaled/cli/down.go b/cmd/tailscaled/cli/down.go
new file mode 100644
index 000000000..c0a9034fe
--- /dev/null
+++ b/cmd/tailscaled/cli/down.go
@@ -0,0 +1,46 @@
+// 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"
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/peterbourgon/ff/v2/ffcli"
+ "tailscale.com/client/tailscale"
+ "tailscale.com/ipn"
+)
+
+var downCmd = &ffcli.Command{
+ Name: "down",
+ ShortUsage: "down",
+ ShortHelp: "Disconnect from Tailscale",
+
+ Exec: runDown,
+}
+
+func runDown(ctx context.Context, args []string) error {
+ if len(args) > 0 {
+ log.Fatalf("too many non-flag arguments: %q", args)
+ }
+
+ st, err := tailscale.Status(ctx)
+ if err != nil {
+ return fmt.Errorf("error fetching current status: %w", err)
+ }
+ if st.BackendState == "Stopped" {
+ fmt.Fprintf(os.Stderr, "Tailscale was already stopped.\n")
+ return nil
+ }
+ _, err = tailscale.EditPrefs(ctx, &ipn.MaskedPrefs{
+ Prefs: ipn.Prefs{
+ WantRunning: false,
+ },
+ WantRunningSet: true,
+ })
+ return err
+}
diff --git a/cmd/tailscaled/cli/file.go b/cmd/tailscaled/cli/file.go
new file mode 100644
index 000000000..01dc68d83
--- /dev/null
+++ b/cmd/tailscaled/cli/file.go
@@ -0,0 +1,444 @@
+// Copyright (c) 2021 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 (
+ "bytes"
+ "context"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "io/ioutil"
+ "log"
+ "mime"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+ "unicode/utf8"
+
+ "github.com/peterbourgon/ff/v2/ffcli"
+ "golang.org/x/time/rate"
+ "inet.af/netaddr"
+ "tailscale.com/client/tailscale"
+ "tailscale.com/client/tailscale/apitype"
+ "tailscale.com/ipn"
+ "tailscale.com/net/tsaddr"
+ "tailscale.com/version"
+)
+
+var fileCmd = &ffcli.Command{
+ Name: "file",
+ ShortUsage: "file <cp|get> ...",
+ ShortHelp: "Send or receive files",
+ Subcommands: []*ffcli.Command{
+ fileCpCmd,
+ fileGetCmd,
+ },
+ Exec: func(context.Context, []string) error {
+ // TODO(bradfitz): is there a better ffcli way to
+ // annotate subcommand-required commands that don't
+ // have an exec body of their own?
+ return errors.New("file subcommand required; run 'tailscale file -h' for details")
+ },
+}
+
+var fileCpCmd = &ffcli.Command{
+ Name: "cp",
+ ShortUsage: "file cp <files...> <target>:",
+ ShortHelp: "Copy file(s) to a host",
+ Exec: runCp,
+ FlagSet: (func() *flag.FlagSet {
+ fs := flag.NewFlagSet("cp", flag.ExitOnError)
+ fs.StringVar(&cpArgs.name, "name", "", "alternate filename to use, especially useful when <file> is \"-\" (stdin)")
+ fs.BoolVar(&cpArgs.verbose, "verbose", false, "verbose output")
+ fs.BoolVar(&cpArgs.targets, "targets", false, "list possible file cp targets")
+ return fs
+ })(),
+}
+
+var cpArgs struct {
+ name string
+ verbose bool
+ targets bool
+}
+
+func runCp(ctx context.Context, args []string) error {
+ if cpArgs.targets {
+ return runCpTargets(ctx, args)
+ }
+ if len(args) < 2 {
+ //lint:ignore ST1005 no sorry need that colon at the end
+ return errors.New("usage: tailscale file cp <files...> <target>:")
+ }
+ files, target := args[:len(args)-1], args[len(args)-1]
+ if !strings.HasSuffix(target, ":") {
+ return fmt.Errorf("final argument to 'tailscale file cp' must end in colon")
+ }
+ target = strings.TrimSuffix(target, ":")
+ hadBrackets := false
+ if strings.HasPrefix(target, "[") && strings.HasSuffix(target, "]") {
+ hadBrackets = true
+ target = strings.TrimSuffix(strings.TrimPrefix(target, "["), "]")
+ }
+ if ip, err := netaddr.ParseIP(target); err == nil && ip.Is6() && !hadBrackets {
+ return fmt.Errorf("an IPv6 literal must be written as [%s]", ip)
+ } else if hadBrackets && (err != nil || !ip.Is6()) {
+ return errors.New("unexpected brackets around target")
+ }
+ ip, err := tailscaleIPFromArg(ctx, target)
+ if err != nil {
+ return err
+ }
+
+ peerAPIBase, lastSeen, isOffline, err := discoverPeerAPIBase(ctx, ip)
+ if err != nil {
+ return fmt.Errorf("can't send to %s: %v", target, err)
+ }
+ if isOffline {
+ fmt.Fprintf(os.Stderr, "# warning: %s is offline\n", target)
+ } else if !lastSeen.IsZero() && time.Since(lastSeen) > lastSeenOld {
+ fmt.Fprintf(os.Stderr, "# warning: %s last seen %v ago\n", target, time.Since(lastSeen).Round(time.Minute))
+ }
+
+ if len(files) > 1 {
+ if cpArgs.name != "" {
+ return errors.New("can't use --name= with multiple files")
+ }
+ for _, fileArg := range files {
+ if fileArg == "-" {
+ return errors.New("can't use '-' as STDIN file when providing filename arguments")
+ }
+ }
+ }
+
+ for _, fileArg := range files {
+ var fileContents io.Reader
+ var name = cpArgs.name
+ var contentLength int64 = -1
+ if fileArg == "-" {
+ fileContents = os.Stdin
+ if name == "" {
+ name, fileContents, err = pickStdinFilename()
+ if err != nil {
+ return err
+ }
+ }
+ } else {
+ f, err := os.Open(fileArg)
+ if err != nil {
+ if version.IsSandboxedMacOS() {
+ return errors.New("the GUI version of Tailscale on macOS runs in a macOS sandbox that can't read files")
+ }
+ return err
+ }
+ defer f.Close()
+ fi, err := f.Stat()
+ if err != nil {
+ return err
+ }
+ if fi.IsDir() {
+ return errors.New("directories not supported")
+ }
+ contentLength = fi.Size()
+ fileContents = io.LimitReader(f, contentLength)
+ if name == "" {
+ name = filepath.Base(fileArg)
+ }
+
+ if slow, _ := strconv.ParseBool(os.Getenv("TS_DEBUG_SLOW_PUSH")); slow {
+ fileContents = &slowReader{r: fileContents}
+ }
+ }
+
+ dstURL := peerAPIBase + "/v0/put/" + url.PathEscape(name)
+ req, err := http.NewRequestWithContext(ctx, "PUT", dstURL, fileContents)
+ if err != nil {
+ return err
+ }
+ req.ContentLength = contentLength
+ if cpArgs.verbose {
+ log.Printf("sending to %v ...", dstURL)
+ }
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return err
+ }
+ if res.StatusCode == 200 {
+ io.Copy(ioutil.Discard, res.Body)
+ res.Body.Close()
+ continue
+ }
+ io.Copy(os.Stdout, res.Body)
+ res.Body.Close()
+ return errors.New(res.Status)
+ }
+ return nil
+}
+
+func discoverPeerAPIBase(ctx context.Context, ipStr string) (base string, lastSeen time.Time, isOffline bool, err error) {
+ ip, err := netaddr.ParseIP(ipStr)
+ if err != nil {
+ return "", time.Time{}, false, err
+ }
+ fts, err := tailscale.FileTargets(ctx)
+ if err != nil {
+ return "", time.Time{}, false, err
+ }
+ for _, ft := range fts {
+ n := ft.Node
+ for _, a := range n.Addresses {
+ if a.IP() != ip {
+ continue
+ }
+ if n.LastSeen != nil {
+ lastSeen = *n.LastSeen
+ }
+ isOffline = n.Online != nil && !*n.Online
+ return ft.PeerAPIURL, lastSeen, isOffline, nil
+ }
+ }
+ return "", time.Time{}, false, fileTargetErrorDetail(ctx, ip)
+}
+
+// fileTargetErrorDetail returns a non-nil error saying why ip is an
+// invalid file sharing target.
+func fileTargetErrorDetail(ctx context.Context, ip netaddr.IP) error {
+ found := false
+ if st, err := tailscale.Status(ctx); err == nil && st.Self != nil {
+ for _, peer := range st.Peer {
+ for _, pip := range peer.TailscaleIPs {
+ if pip == ip {
+ found = true
+ if peer.UserID != st.Self.UserID {
+ return errors.New("owned by different user; can only send files to your own devices")
+ }
+ }
+ }
+ }
+ }
+ if found {
+ return errors.New("target seems to be running an old Tailscale version")
+ }
+ if !tsaddr.IsTailscaleIP(ip) {
+ return fmt.Errorf("unknown target; %v is not a Tailscale IP address", ip)
+ }
+ return errors.New("unknown target; not in your Tailnet")
+}
+
+const maxSniff = 4 << 20
+
+func ext(b []byte) string {
+ if len(b) < maxSniff && utf8.Valid(b) {
+ return ".txt"
+ }
+ if exts, _ := mime.ExtensionsByType(http.DetectContentType(b)); len(exts) > 0 {
+ return exts[0]
+ }
+ return ""
+}
+
+// pickStdinFilename reads a bit of stdin to return a good filename
+// for its contents. The returned Reader is the concatenation of the
+// read and unread bits.
+func pickStdinFilename() (name string, r io.Reader, err error) {
+ sniff, err := io.ReadAll(io.LimitReader(os.Stdin, maxSniff))
+ if err != nil {
+ return "", nil, err
+ }
+ return "stdin" + ext(sniff), io.MultiReader(bytes.NewReader(sniff), os.Stdin), nil
+}
+
+type slowReader struct {
+ r io.Reader
+ rl *rate.Limiter
+}
+
+func (r *slowReader) Read(p []byte) (n int, err error) {
+ const burst = 4 << 10
+ plen := len(p)
+ if plen > burst {
+ plen = burst
+ }
+ if r.rl == nil {
+ r.rl = rate.NewLimiter(rate.Limit(1<<10), burst)
+ }
+ n, err = r.r.Read(p[:plen])
+ r.rl.WaitN(context.Background(), n)
+ return
+}
+
+const lastSeenOld = 20 * time.Minute
+
+func runCpTargets(ctx context.Context, args []string) error {
+ if len(args) > 0 {
+ return errors.New("invalid arguments with --targets")
+ }
+ fts, err := tailscale.FileTargets(ctx)
+ if err != nil {
+ return err
+ }
+ for _, ft := range fts {
+ n := ft.Node
+ var detail string
+ if n.Online != nil {
+ if !*n.Online {
+ detail = "offline"
+ }
+ } else {
+ detail = "unknown-status"
+ }
+ if detail != "" && n.LastSeen != nil {
+ d := time.Since(*n.LastSeen)
+ detail += fmt.Sprintf("; last seen %v ago", d.Round(time.Minute))
+ }
+ if detail != "" {
+ detail = "\t" + detail
+ }
+ fmt.Printf("%s\t%s%s\n", n.Addresses[0].IP(), n.ComputedName, detail)
+ }
+ return nil
+}
+
+var fileGetCmd = &ffcli.Command{
+ Name: "get",
+ ShortUsage: "file get [--wait] [--verbose] <target-directory>",
+ ShortHelp: "Move files out of the Tailscale file inbox",
+ Exec: runFileGet,
+ FlagSet: (func() *flag.FlagSet {
+ fs := flag.NewFlagSet("get", flag.ExitOnError)
+ fs.BoolVar(&getArgs.wait, "wait", false, "wait for a file to arrive if inbox is empty")
+ fs.BoolVar(&getArgs.verbose, "verbose", false, "verbose output")
+ return fs
+ })(),
+}
+
+var getArgs struct {
+ wait bool
+ verbose bool
+}
+
+func runFileGet(ctx context.Context, args []string) error {
+ if len(args) != 1 {
+ return errors.New("usage: file get <target-directory>")
+ }
+ log.SetFlags(0)
+
+ dir := args[0]
+ if dir == "/dev/null" {
+ return wipeInbox(ctx)
+ }
+
+ if fi, err := os.Stat(dir); err != nil || !fi.IsDir() {
+ return fmt.Errorf("%q is not a directory", dir)
+ }
+
+ var wfs []apitype.WaitingFile
+ var err error
+ for {
+ wfs, err = tailscale.WaitingFiles(ctx)
+ if err != nil {
+ return fmt.Errorf("getting WaitingFiles: %v", err)
+ }
+ if len(wfs) != 0 || !getArgs.wait {
+ break
+ }
+ if getArgs.verbose {
+ log.Printf("waiting for file...")
+ }
+ if err := waitForFile(ctx); err != nil {
+ return err
+ }
+ }
+
+ deleted := 0
+ for _, wf := range wfs {
+ rc, size, err := tailscale.GetWaitingFile(ctx, wf.Name)
+ if err != nil {
+ return fmt.Errorf("opening inbox file %q: %v", wf.Name, err)
+ }
+ targetFile := filepath.Join(dir, wf.Name)
+ of, err := os.OpenFile(targetFile, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0644)
+ if err != nil {
+ if _, err := os.Stat(targetFile); err == nil {
+ return fmt.Errorf("refusing to overwrite %v", targetFile)
+ }
+ return err
+ }
+ _, err = io.Copy(of, rc)
+ rc.Close()
+ if err != nil {
+ return fmt.Errorf("failed to write %v: %v", targetFile, err)
+ }
+ if err := of.Close(); err != nil {
+ return err
+ }
+ if getArgs.verbose {
+ log.Printf("wrote %v (%d bytes)", wf.Name, size)
+ }
+ if err := tailscale.DeleteWaitingFile(ctx, wf.Name); err != nil {
+ return fmt.Errorf("deleting %q from inbox: %v", wf.Name, err)
+ }
+ deleted++
+ }
+ if getArgs.verbose {
+ log.Printf("moved %d files", deleted)
+ }
+ return nil
+}
+
+func wipeInbox(ctx context.Context) error {
+ if getArgs.wait {
+ return errors.New("can't use --wait with /dev/null target")
+ }
+ wfs, err := tailscale.WaitingFiles(ctx)
+ if err != nil {
+ return fmt.Errorf("getting WaitingFiles: %v", err)
+ }
+ deleted := 0
+ for _, wf := range wfs {
+ if getArgs.verbose {
+ log.Printf("deleting %v ...", wf.Name)
+ }
+ if err := tailscale.DeleteWaitingFile(ctx, wf.Name); err != nil {
+ return fmt.Errorf("deleting %q: %v", wf.Name, err)
+ }
+ deleted++
+ }
+ if getArgs.verbose {
+ log.Printf("deleted %d files", deleted)
+ }
+ return nil
+}
+
+func waitForFile(ctx context.Context) error {
+ c, bc, pumpCtx, cancel := connect(ctx)
+ defer cancel()
+ fileWaiting := make(chan bool, 1)
+ bc.SetNotifyCallback(func(n ipn.Notify) {
+ if n.ErrMessage != nil {
+ log.Fatal(*n.ErrMessage)
+ }
+ if n.FilesWaiting != nil {
+ select {
+ case fileWaiting <- true:
+ default:
+ }
+ }
+ })
+ go pump(pumpCtx, bc, c)
+ select {
+ case <-fileWaiting:
+ return nil
+ case <-pumpCtx.Done():
+ return pumpCtx.Err()
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+}
diff --git a/cmd/tailscaled/cli/ip.go b/cmd/tailscaled/cli/ip.go
new file mode 100644
index 000000000..2122d6022
--- /dev/null
+++ b/cmd/tailscaled/cli/ip.go
@@ -0,0 +1,105 @@
+// 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"
+
+ "github.com/peterbourgon/ff/v2/ffcli"
+ "inet.af/netaddr"
+ "tailscale.com/client/tailscale"
+ "tailscale.com/ipn/ipnstate"
+)
+
+var ipCmd = &ffcli.Command{
+ Name: "ip",
+ ShortUsage: "ip [-4] [-6] [peername]",
+ ShortHelp: "Show current Tailscale IP address(es)",
+ LongHelp: "Shows the Tailscale IP address of the current machine without an argument. With an argument, it shows the IP of a named peer.",
+ Exec: runIP,
+ FlagSet: (func() *flag.FlagSet {
+ fs := flag.NewFlagSet("ip", flag.ExitOnError)
+ fs.BoolVar(&ipArgs.want4, "4", false, "only print IPv4 address")
+ fs.BoolVar(&ipArgs.want6, "6", false, "only print IPv6 address")
+ return fs
+ })(),
+}
+
+var ipArgs struct {
+ want4 bool
+ want6 bool
+}
+
+func runIP(ctx context.Context, args []string) error {
+ if len(args) > 1 {
+ return errors.New("unknown arguments")
+ }
+ var of string
+ if len(args) == 1 {
+ of = args[0]
+ }
+
+ v4, v6 := ipArgs.want4, ipArgs.want6
+ if v4 && v6 {
+ return errors.New("tailscale up -4 and -6 are mutually exclusive")
+ }
+ if !v4 && !v6 {
+ v4, v6 = true, true
+ }
+ st, err := tailscale.Status(ctx)
+ if err != nil {
+ return err
+ }
+ ips := st.TailscaleIPs
+ if of != "" {
+ ip, err := tailscaleIPFromArg(ctx, of)
+ if err != nil {
+ return err
+ }
+ peer, ok := peerMatchingIP(st, ip)
+ if !ok {
+ return fmt.Errorf("no peer found with IP %v", ip)
+ }
+ ips = peer.TailscaleIPs
+ }
+ if len(ips) == 0 {
+ return fmt.Errorf("no current Tailscale IPs; state: %v", st.BackendState)
+ }
+
+ match := false
+ for _, ip := range ips {
+ if ip.Is4() && v4 || ip.Is6() && v6 {
+ match = true
+ fmt.Println(ip)
+ }
+ }
+ if !match {
+ if ipArgs.want4 {
+ return errors.New("no Tailscale IPv4 address")
+ }
+ if ipArgs.want6 {
+ return errors.New("no Tailscale IPv6 address")
+ }
+ }
+ return nil
+}
+
+func peerMatchingIP(st *ipnstate.Status, ipStr string) (ps *ipnstate.PeerStatus, ok bool) {
+ ip, err := netaddr.ParseIP(ipStr)
+ if err != nil {
+ return
+ }
+ for _, ps = range st.Peer {
+ for _, pip := range ps.TailscaleIPs {
+ if ip == pip {
+ return ps, true
+ }
+ }
+ }
+ return nil, false
+}
diff --git a/cmd/tailscaled/cli/logout.go b/cmd/tailscaled/cli/logout.go
new file mode 100644
index 000000000..6356b2452
--- /dev/null
+++ b/cmd/tailscaled/cli/logout.go
@@ -0,0 +1,34 @@
+// 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"
+ "log"
+ "strings"
+
+ "github.com/peterbourgon/ff/v2/ffcli"
+ "tailscale.com/client/tailscale"
+)
+
+var logoutCmd = &ffcli.Command{
+ Name: "logout",
+ ShortUsage: "logout [flags]",
+ ShortHelp: "Disconnect from Tailscale and expire current node key",
+
+ LongHelp: strings.TrimSpace(`
+"tailscale logout" brings the network down and invalidates
+the current node key, forcing a future use of it to cause
+a reauthentication.
+`),
+ Exec: runLogout,
+}
+
+func runLogout(ctx context.Context, args []string) error {
+ if len(args) > 0 {
+ log.Fatalf("too many non-flag arguments: %q", args)
+ }
+ return tailscale.Logout(ctx)
+}
diff --git a/cmd/tailscaled/cli/netcheck.go b/cmd/tailscaled/cli/netcheck.go
new file mode 100644
index 000000000..09ce664cd
--- /dev/null
+++ b/cmd/tailscaled/cli/netcheck.go
@@ -0,0 +1,178 @@
+// 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"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "log"
+ "os"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/peterbourgon/ff/v2/ffcli"
+ "tailscale.com/derp/derpmap"
+ "tailscale.com/net/netcheck"
+ "tailscale.com/net/portmapper"
+ "tailscale.com/tailcfg"
+ "tailscale.com/types/logger"
+)
+
+var netcheckCmd = &ffcli.Command{
+ Name: "netcheck",
+ ShortUsage: "netcheck",
+ ShortHelp: "Print an analysis of local network conditions",
+ Exec: runNetcheck,
+ FlagSet: (func() *flag.FlagSet {
+ fs := flag.NewFlagSet("netcheck", flag.ExitOnError)
+ fs.StringVar(&netcheckArgs.format, "format", "", `output format; empty (for human-readable), "json" or "json-line"`)
+ fs.DurationVar(&netcheckArgs.every, "every", 0, "if non-zero, do an incremental report with the given frequency")
+ fs.BoolVar(&netcheckArgs.verbose, "verbose", false, "verbose logs")
+ return fs
+ })(),
+}
+
+var netcheckArgs struct {
+ format string
+ every time.Duration
+ verbose bool
+}
+
+func runNetcheck(ctx context.Context, args []string) error {
+ c := &netcheck.Client{
+ UDPBindAddr: os.Getenv("TS_DEBUG_NETCHECK_UDP_BIND"),
+ PortMapper: portmapper.NewClient(logger.WithPrefix(log.Printf, "portmap: ")),
+ }
+ if netcheckArgs.verbose {
+ c.Logf = logger.WithPrefix(log.Printf, "netcheck: ")
+ c.Verbose = true
+ } else {
+ c.Logf = logger.Discard
+ }
+
+ if strings.HasPrefix(netcheckArgs.format, "json") {
+ fmt.Fprintln(os.Stderr, "# Warning: this JSON format is not yet considered a stable interface")
+ }
+
+ dm := derpmap.Prod()
+ for {
+ t0 := time.Now()
+ report, err := c.GetReport(ctx, dm)
+ d := time.Since(t0)
+ if netcheckArgs.verbose {
+ c.Logf("GetReport took %v; err=%v", d.Round(time.Millisecond), err)
+ }
+ if err != nil {
+ log.Fatalf("netcheck: %v", err)
+ }
+ if err := printReport(dm, report); err != nil {
+ return err
+ }
+ if netcheckArgs.every == 0 {
+ return nil
+ }
+ time.Sleep(netcheckArgs.every)
+ }
+}
+
+func printReport(dm *tailcfg.DERPMap, report *netcheck.Report) error {
+ var j []byte
+ var err error
+ switch netcheckArgs.format {
+ case "":
+ break
+ case "json":
+ j, err = json.MarshalIndent(report, "", "\t")
+ case "json-line":
+ j, err = json.Marshal(report)
+ default:
+ return fmt.Errorf("unknown output format %q", netcheckArgs.format)
+ }
+ if err != nil {
+ return err
+ }
+ if j != nil {
+ j = append(j, '\n')
+ os.Stdout.Write(j)
+ return nil
+ }
+
+ fmt.Printf("\nReport:\n")
+ fmt.Printf("\t* UDP: %v\n", report.UDP)
+ if report.GlobalV4 != "" {
+ fmt.Printf("\t* IPv4: yes, %v\n", report.GlobalV4)
+ } else {
+ fmt.Printf("\t* IPv4: (no addr found)\n")
+ }
+ if report.GlobalV6 != "" {
+ fmt.Printf("\t* IPv6: yes, %v\n", report.GlobalV6)
+ } else if report.IPv6 {
+ fmt.Printf("\t* IPv6: (no addr found)\n")
+ } else {
+ fmt.Printf("\t* IPv6: no\n")
+ }
+ fmt.Printf("\t* MappingVariesByDestIP: %v\n", report.MappingVariesByDestIP)
+ fmt.Printf("\t* HairPinning: %v\n", report.HairPinning)
+ fmt.Printf("\t* PortMapping: %v\n", portMapping(report))
+
+ // When DERP latency checking failed,
+ // magicsock will try to pick the DERP server that
+ // most of your other nodes are also using
+ if len(report.RegionLatency) == 0 {
+ fmt.Printf("\t* Nearest DERP: unknown (no response to latency probes)\n")
+ } else {
+ fmt.Printf("\t* Nearest DERP: %v\n", dm.Regions[report.PreferredDERP].RegionName)
+ fmt.Printf("\t* DERP latency:\n")
+ var rids []int
+ for rid := range dm.Regions {
+ rids = append(rids, rid)
+ }
+ sort.Slice(rids, func(i, j int) bool {
+ l1, ok1 := report.RegionLatency[rids[i]]
+ l2, ok2 := report.RegionLatency[rids[j]]
+ if ok1 != ok2 {
+ return ok1 // defined things sort first
+ }
+ if !ok1 {
+ return rids[i] < rids[j]
+ }
+ return l1 < l2
+ })
+ for _, rid := range rids {
+ d, ok := report.RegionLatency[rid]
+ var latency string
+ if ok {
+ latency = d.Round(time.Millisecond / 10).String()
+ }
+ r := dm.Regions[rid]
+ var derpNum string
+ if netcheckArgs.verbose {
+ derpNum = fmt.Sprintf("derp%d, ", rid)
+ }
+ fmt.Printf("\t\t- %3s: %-7s (%s%s)\n", r.RegionCode, latency, derpNum, r.RegionName)
+ }
+ }
+ return nil
+}
+
+func portMapping(r *netcheck.Report) string {
+ if !r.AnyPortMappingChecked() {
+ return "not checked"
+ }
+ var got []string
+ if r.UPnP.EqualBool(true) {
+ got = append(got, "UPnP")
+ }
+ if r.PMP.EqualBool(true) {
+ got = append(got, "NAT-PMP")
+ }
+ if r.PCP.EqualBool(true) {
+ got = append(got, "PCP")
+ }
+ return strings.Join(got, ", ")
+}
diff --git a/cmd/tailscaled/cli/ping.go b/cmd/tailscaled/cli/ping.go
new file mode 100644
index 000000000..25470aa69
--- /dev/null
+++ b/cmd/tailscaled/cli/ping.go
@@ -0,0 +1,179 @@
+// 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"
+ "log"
+ "net"
+ "strings"
+ "time"
+
+ "github.com/peterbourgon/ff/v2/ffcli"
+ "tailscale.com/client/tailscale"
+ "tailscale.com/ipn"
+ "tailscale.com/ipn/ipnstate"
+)
+
+var pingCmd = &ffcli.Command{
+ Name: "ping",
+ ShortUsage: "ping <hostname-or-IP>",
+ ShortHelp: "Ping a host at the Tailscale layer, see how it routed",
+ LongHelp: strings.TrimSpace(`
+
+The 'tailscale ping' command pings a peer node at the Tailscale layer
+and reports which route it took for each response. The first ping or
+so will likely go over DERP (Tailscale's TCP relay protocol) while NAT
+traversal finds a direct path through.
+
+If 'tailscale ping' works but a normal ping does not, that means one
+side's operating system firewall is blocking packets; 'tailscale ping'
+does not inject packets into either side's TUN devices.
+
+By default, 'tailscale ping' stops after 10 pings or once a direct
+(non-DERP) path has been established, whichever comes first.
+
+The provided hostname must resolve to or be a Tailscale IP
+(e.g. 100.x.y.z) or a subnet IP advertised by a Tailscale
+relay node.
+
+`),
+ Exec: runPing,
+ FlagSet: (func() *flag.FlagSet {
+ fs := flag.NewFlagSet("ping", flag.ExitOnError)
+ fs.BoolVar(&pingArgs.verbose, "verbose", false, "verbose output")
+ fs.BoolVar(&pingArgs.untilDirect, "until-direct", true, "stop once a direct path is established")
+ fs.BoolVar(&pingArgs.tsmp, "tsmp", false, "do a TSMP-level ping (through IP + wireguard, but not involving host OS stack)")
+ fs.IntVar(&pingArgs.num, "c", 10, "max number of pings to send")
+ fs.DurationVar(&pingArgs.timeout, "timeout", 5*time.Second, "timeout before giving up on a ping")
+ return fs
+ })(),
+}
+
+var pingArgs struct {
+ num int
+ untilDirect bool
+ verbose bool
+ tsmp bool
+ timeout time.Duration
+}
+
+func runPing(ctx context.Context, args []string) error {
+ c, bc, ctx, cancel := connect(ctx)
+ defer cancel()
+
+ if len(args) != 1 || args[0] == "" {
+ return errors.New("usage: ping <hostname-or-IP>")
+ }
+ var ip string
+ prc := make(chan *ipnstate.PingResult, 1)
+ bc.SetNotifyCallback(func(n ipn.Notify) {
+ if n.ErrMessage != nil {
+ log.Fatal(*n.ErrMessage)
+ }
+ if pr := n.PingResult; pr != nil && pr.IP == ip {
+ prc <- pr
+ }
+ })
+ pumpErr := make(chan error, 1)
+ go func() { pumpErr <- pump(ctx, bc, c) }()
+
+ hostOrIP := args[0]
+ ip, err := tailscaleIPFromArg(ctx, hostOrIP)
+ if err != nil {
+ return err
+ }
+
+ if pingArgs.verbose && ip != hostOrIP {
+ log.Printf("lookup %q => %q", hostOrIP, ip)
+ }
+
+ n := 0
+ anyPong := false
+ for {
+ n++
+ bc.Ping(ip, pingArgs.tsmp)
+ timer := time.NewTimer(pingArgs.timeout)
+ select {
+ case <-timer.C:
+ fmt.Printf("timeout waiting for ping reply\n")
+ case err := <-pumpErr:
+ return err
+ case pr := <-prc:
+ timer.Stop()
+ if pr.Err != "" {
+ return errors.New(pr.Err)
+ }
+ latency := time.Duration(pr.LatencySeconds * float64(time.Second)).Round(time.Millisecond)
+ via := pr.Endpoint
+ if pr.DERPRegionID != 0 {
+ via = fmt.Sprintf("DERP(%s)", pr.DERPRegionCode)
+ }
+ if pingArgs.tsmp {
+ // TODO(bradfitz): populate the rest of ipnstate.PingResult for TSMP queries?
+ // For now just say it came via TSMP.
+ via = "TSMP"
+ }
+ anyPong = true
+ extra := ""
+ if pr.PeerAPIPort != 0 {
+ extra = fmt.Sprintf(", %d", pr.PeerAPIPort)
+ }
+ fmt.Printf("pong from %s (%s%s) via %v in %v\n", pr.NodeName, pr.NodeIP, extra, via, latency)
+ if pingArgs.tsmp {
+ return nil
+ }
+ if pr.Endpoint != "" && pingArgs.untilDirect {
+ return nil
+ }
+ time.Sleep(time.Second)
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+ if n == pingArgs.num {
+ if !anyPong {
+ return errors.New("no reply")
+ }
+ if pingArgs.untilDirect {
+ return errors.New("direct connection not established")
+ }
+ return nil
+ }
+ }
+}
+
+func tailscaleIPFromArg(ctx context.Context, hostOrIP string) (ip string, err error) {
+ // If the argument is an IP address, use it directly without any resolution.
+ if net.ParseIP(hostOrIP) != nil {
+ return hostOrIP, nil
+ }
+
+ // Otherwise, try to resolve it first from the network peer list.
+ st, err := tailscale.Status(ctx)
+ if err != nil {
+ return "", err
+ }
+ for _, ps := range st.Peer {
+ if hostOrIP == dnsOrQuoteHostname(st, ps) || hostOrIP == ps.DNSName {
+ if len(ps.TailscaleIPs) == 0 {
+ return "", errors.New("node found but lacks an IP")
+ }
+ return ps.TailscaleIPs[0].String(), nil
+ }
+ }
+
+ // Finally, use DNS.
+ var res net.Resolver
+ if addrs, err := res.LookupHost(ctx, hostOrIP); err != nil {
+ return "", fmt.Errorf("error looking up IP of %q: %v", hostOrIP, err)
+ } else if len(addrs) == 0 {
+ return "", fmt.Errorf("no IPs found for %q", hostOrIP)
+ } else {
+ return addrs[0], nil
+ }
+}
diff --git a/cmd/tailscaled/cli/status.go b/cmd/tailscaled/cli/status.go
new file mode 100644
index 000000000..af887f427
--- /dev/null
+++ b/cmd/tailscaled/cli/status.go
@@ -0,0 +1,226 @@
+// 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 (
+ "bytes"
+ "context"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "net"
+ "net/http"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/peterbourgon/ff/v2/ffcli"
+ "github.com/toqueteos/webbrowser"
+ "inet.af/netaddr"
+ "tailscale.com/client/tailscale"
+ "tailscale.com/ipn"
+ "tailscale.com/ipn/ipnstate"
+ "tailscale.com/net/interfaces"
+ "tailscale.com/util/dnsname"
+)
+
+var statusCmd = &ffcli.Command{
+ Name: "status",
+ ShortUsage: "status [--active] [--web] [--json]",
+ ShortHelp: "Show state of tailscaled and its connections",
+ Exec: runStatus,
+ FlagSet: (func() *flag.FlagSet {
+ fs := flag.NewFlagSet("status", flag.ExitOnError)
+ fs.BoolVar(&statusArgs.json, "json", false, "output in JSON format (WARNING: format subject to change)")
+ fs.BoolVar(&statusArgs.web, "web", false, "run webserver with HTML showing status")
+ fs.BoolVar(&statusArgs.active, "active", false, "filter output to only peers with active sessions (not applicable to web mode)")
+ fs.BoolVar(&statusArgs.self, "self", true, "show status of local machine")
+ fs.BoolVar(&statusArgs.peers, "peers", true, "show status of peers")
+ fs.StringVar(&statusArgs.listen, "listen", "127.0.0.1:8384", "listen address for web mode; use port 0 for automatic")
+ fs.BoolVar(&statusArgs.browser, "browser", true, "Open a browser in web mode")
+ return fs
+ })(),
+}
+
+var statusArgs struct {
+ json bool // JSON output mode
+ web bool // run webserver
+ listen string // in web mode, webserver address to listen on, empty means auto
+ browser bool // in web mode, whether to open browser
+ active bool // in CLI mode, filter output to only peers with active sessions
+ self bool // in CLI mode, show status of local machine
+ peers bool // in CLI mode, show status of peer machines
+}
+
+func runStatus(ctx context.Context, args []string) error {
+ st, err := tailscale.Status(ctx)
+ if err != nil {
+ return err
+ }
+ if statusArgs.json {
+ if statusArgs.active {
+ for peer, ps := range st.Peer {
+ if !peerActive(ps) {
+ delete(st.Peer, peer)
+ }
+ }
+ }
+ j, err := json.MarshalIndent(st, "", " ")
+ if err != nil {
+ return err
+ }
+ fmt.Printf("%s", j)
+ return nil
+ }
+ if statusArgs.web {
+ ln, err := net.Listen("tcp", statusArgs.listen)
+ if err != nil {
+ return err
+ }
+ statusURL := interfaces.HTTPOfListener(ln)
+ fmt.Printf("Serving Tailscale status at %v ...\n", statusURL)
+ go func() {
+ <-ctx.Done()
+ ln.Close()
+ }()
+ if statusArgs.browser {
+ go webbrowser.Open(statusURL)
+ }
+ err = http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.RequestURI != "/" {
+ http.NotFound(w, r)
+ return
+ }
+ st, err := tailscale.Status(ctx)
+ if err != nil {
+ http.Error(w, err.Error(), 500)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ st.WriteHTML(w)
+ }))
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+ return err
+ }
+
+ switch st.BackendState {
+ default:
+ fmt.Fprintf(os.Stderr, "unexpected state: %s\n", st.BackendState)
+ os.Exit(1)
+ case ipn.Stopped.String():
+ fmt.Println("Tailscale is stopped.")
+ os.Exit(1)
+ case ipn.NeedsLogin.String():
+ fmt.Println("Logged out.")
+ if st.AuthURL != "" {
+ fmt.Printf("\nLog in at: %s\n", st.AuthURL)
+ }
+ os.Exit(1)
+ case ipn.NeedsMachineAuth.String():
+ fmt.Println("Machine is not yet authorized by tailnet admin.")
+ os.Exit(1)
+ case ipn.Running.String():
+ // Run below.
+ }
+
+ var buf bytes.Buffer
+ f := func(format string, a ...interface{}) { fmt.Fprintf(&buf, format, a...) }
+ printPS := func(ps *ipnstate.PeerStatus) {
+ active := peerActive(ps)
+ f("%-15s %-20s %-12s %-7s ",
+ firstIPString(ps.TailscaleIPs),
+ dnsOrQuoteHostname(st, ps),
+ ownerLogin(st, ps),
+ ps.OS,
+ )
+ relay := ps.Relay
+ anyTraffic := ps.TxBytes != 0 || ps.RxBytes != 0
+ if !active {
+ if ps.ExitNode {
+ f("idle; exit node")
+ } else if anyTraffic {
+ f("idle")
+ } else {
+ f("-")
+ }
+ } else {
+ f("active; ")
+ if ps.ExitNode {
+ f("exit node; ")
+ }
+ if relay != "" && ps.CurAddr == "" {
+ f("relay %q", relay)
+ } else if ps.CurAddr != "" {
+ f("direct %s", ps.CurAddr)
+ }
+ }
+ if anyTraffic {
+ f(", tx %d rx %d", ps.TxBytes, ps.RxBytes)
+ }
+ f("\n")
+ }
+
+ if statusArgs.self && st.Self != nil {
+ printPS(st.Self)
+ }
+ if statusArgs.peers {
+ var peers []*ipnstate.PeerStatus
+ for _, peer := range st.Peers() {
+ ps := st.Peer[peer]
+ if ps.ShareeNode {
+ continue
+ }
+ peers = append(peers, ps)
+ }
+ ipnstate.SortPeers(peers)
+ for _, ps := range peers {
+ active := peerActive(ps)
+ if statusArgs.active && !active {
+ continue
+ }
+ printPS(ps)
+ }
+ }
+ os.Stdout.Write(buf.Bytes())
+ return nil
+}
+
+// peerActive reports whether ps has recent activity.
+//
+// TODO: have the server report this bool instead.
+func peerActive(ps *ipnstate.PeerStatus) bool {
+ return !ps.LastWrite.IsZero() && time.Since(ps.LastWrite) < 2*time.Minute
+}
+
+func dnsOrQuoteHostname(st *ipnstate.Status, ps *ipnstate.PeerStatus) string {
+ baseName := dnsname.TrimSuffix(ps.DNSName, st.MagicDNSSuffix)
+ if baseName != "" {
+ return baseName
+ }
+ return fmt.Sprintf("(%q)", dnsname.SanitizeHostname(ps.HostName))
+}
+
+func ownerLogin(st *ipnstate.Status, ps *ipnstate.PeerStatus) string {
+ if ps.UserID.IsZero() {
+ return "-"
+ }
+ u, ok := st.User[ps.UserID]
+ if !ok {
+ return fmt.Sprint(ps.UserID)
+ }
+ if i := strings.Index(u.LoginName, "@"); i != -1 {
+ return u.LoginName[:i+1]
+ }
+ return u.LoginName
+}
+
+func firstIPString(v []netaddr.IP) string {
+ if len(v) == 0 {
+ return ""
+ }
+ return v[0].String()
+}
diff --git a/cmd/tailscaled/cli/up.go b/cmd/tailscaled/cli/up.go
new file mode 100644
index 000000000..d8e24a095
--- /dev/null
+++ b/cmd/tailscaled/cli/up.go
@@ -0,0 +1,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
+}
diff --git a/cmd/tailscaled/cli/version.go b/cmd/tailscaled/cli/version.go
new file mode 100644
index 000000000..2c6f97a3b
--- /dev/null
+++ b/cmd/tailscaled/cli/version.go
@@ -0,0 +1,51 @@
+// 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"
+ "flag"
+ "fmt"
+ "log"
+
+ "github.com/peterbourgon/ff/v2/ffcli"
+ "tailscale.com/client/tailscale"
+ "tailscale.com/version"
+)
+
+var versionCmd = &ffcli.Command{
+ Name: "version",
+ ShortUsage: "version [flags]",
+ ShortHelp: "Print Tailscale version",
+ FlagSet: (func() *flag.FlagSet {
+ fs := flag.NewFlagSet("version", flag.ExitOnError)
+ fs.BoolVar(&versionArgs.daemon, "daemon", false, "also print local node's daemon version")
+ return fs
+ })(),
+ Exec: runVersion,
+}
+
+var versionArgs struct {
+ daemon bool // also check local node's daemon version
+}
+
+func runVersion(ctx context.Context, args []string) error {
+ if len(args) > 0 {
+ log.Fatalf("too many non-flag arguments: %q", args)
+ }
+ if !versionArgs.daemon {
+ fmt.Println(version.String())
+ return nil
+ }
+
+ fmt.Printf("Client: %s\n", version.String())
+
+ st, err := tailscale.StatusWithoutPeers(ctx)
+ if err != nil {
+ return err
+ }
+ fmt.Printf("Daemon: %s\n", st.Version)
+ return nil
+}
diff --git a/cmd/tailscaled/cli/web.css b/cmd/tailscaled/cli/web.css
new file mode 100644
index 000000000..64672224d
--- /dev/null
+++ b/cmd/tailscaled/cli/web.css
@@ -0,0 +1,1337 @@
+*,
+::before,
+::after {
+ box-sizing: border-box;
+ border-width: 0;
+ border-style: solid;
+ border-color: #e5e7eb;
+}
+
+html {
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
+ "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif,
+ "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ line-height: 1.5;
+ -webkit-text-size-adjust: 100%;
+}
+
+::selection {
+ background-color: rgba(97, 122, 255, 0.2);
+}
+
+body {
+ margin: 0;
+ font-family: inherit;
+ line-height: inherit;
+}
+
+hr {
+ height: 0;
+ color: inherit;
+ border-top-width: 1px;
+}
+
+code,
+kbd,
+samp,
+pre {
+ font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo,
+ monospace;
+ font-size: 1em;
+}
+
+img,
+svg,
+video,
+canvas,
+audio,
+iframe,
+embed,
+object {
+ display: block;
+ vertical-align: middle;
+}
+
+img,
+video {
+ max-width: 100%;
+ height: auto;
+}
+
+button,
+input,
+optgroup,
+select,
+textarea {
+ font-family: inherit;
+ font-size: 100%;
+ line-height: 1.15;
+ margin: 0;
+}
+
+button,
+select {
+ text-transform: none;
+}
+
+button,
+[type="button"],
+[type="submit"] {
+ -webkit-appearance: button;
+}
+
+blockquote,
+dl,
+dd,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+hr,
+figure,
+p,
+pre {
+ margin: 0;
+}
+
+button,
+input,
+optgroup,
+select,
+textarea {
+ padding: 0;
+ line-height: inherit;
+ color: inherit;
+}
+
+button {
+ cursor: pointer;
+ background-color: transparent;
+ background-image: none;
+}
+
+button:focus {
+ outline: 1px dotted;
+ outline: 5px auto -webkit-focus-ring-color;
+}
+
+fieldset {
+ margin: 0;
+ padding: 0;
+}
+
+ol,
+ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+textarea {
+ resize: vertical;
+}
+
+input::-moz-placeholder,
+textarea::-moz-placeholder {
+ opacity: 1;
+ color: #9ca3af;
+}
+
+input:-ms-input-placeholder,
+textarea:-ms-input-placeholder {
+ opacity: 1;
+ color: #9ca3af;
+}
+
+input::placeholder,
+textarea::placeholder {
+ opacity: 1;
+ color: #9ca3af;
+}
+
+table {
+ border-collapse: collapse;
+}
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ font-size: inherit;
+ font-weight: inherit;
+}
+
+a {
+ color: inherit;
+ text-decoration: inherit;
+}
+
+.container {
+ width: 100%;
+}
+
+@media (min-width: 640px) {
+ .container {
+ max-width: 640px;
+ }
+}
+
+@media (min-width: 768px) {
+ .container {
+ max-width: 768px;
+ }
+}
+
+@media (min-width: 1024px) {
+ .container {
+ max-width: 1024px;
+ }
+}
+
+@media (min-width: 1280px) {
+ .container {
+ max-width: 1280px;
+ }
+}
+
+@media (min-width: 1536px) {
+ .container {
+ max-width: 1536px;
+ }
+}
+
+.space-x-2 > :not([hidden]) ~ :not([hidden]) {
+ --tw-space-x-reverse: 0;
+ margin-right: calc(0.5rem * var(--tw-space-x-reverse));
+ margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse)));
+}
+
+.bg-white {
+ --tw-bg-opacity: 1;
+ background-color: rgba(255, 255, 255, var(--tw-bg-opacity));
+}
+
+.bg-gray-0 {
+ --tw-bg-opacity: 1;
+ background-color: rgba(250, 249, 248, var(--tw-bg-opacity));
+}
+
+.bg-gray-50 {
+ --tw-bg-opacity: 1;
+ background-color: rgba(249, 247, 246, var(--tw-bg-opacity));
+}
+
+.border-gray-200 {
+ --tw-border-opacity: 1;
+ border-color: rgba(238, 235, 234, var(--tw-border-opacity));
+}
+
+.border-gray-400 {
+ --tw-border-opacity: 1;
+ border-color: rgba(175, 172, 171, var(--tw-border-opacity));
+}
+
+.rounded-md {
+ border-radius: 0.375rem;
+}
+
+.rounded-lg {
+ border-radius: 0.5rem;
+}
+
+.rounded-full {
+ border-radius: 9999px;
+}
+
+.border-dashed {
+ border-style: dashed;
+}
+
+.border {
+ border-width: 1px;
+}
+
+.flex {
+ display: flex;
+}
+
+.table {
+ display: table;
+}
+
+.items-center {
+ align-items: center;
+}
+
+.justify-start {
+ justify-content: flex-start;
+}
+
+.justify-end {
+ justify-content: flex-end;
+}
+
+.justify-center {
+ justify-content: center;
+}
+
+.justify-between {
+ justify-content: space-between;
+}
+
+.justify-around {
+ justify-content: space-around;
+}
+
+.justify-evenly {
+ justify-content: space-evenly;
+}
+
+.flex-shrink-0 {
+ flex-shrink: 0;
+}
+
+.font-medium {
+ font-weight: 500;
+}
+
+.font-semibold {
+ font-weight: 600;
+}
+
+.h-8 {
+ height: 2rem;
+}
+
+.mx-auto {
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.mt-0 {
+ margin-top: 0px;
+}
+
+.mr-0 {
+ margin-right: 0px;
+}
+
+.mb-0 {
+ margin-bottom: 0px;
+}
+
+.ml-0 {
+ margin-left: 0px;
+}
+
+.mt-1 {
+ margin-top: 0.25rem;
+}
+
+.mr-1 {
+ margin-right: 0.25rem;
+}
+
+.mb-1 {
+ margin-bottom: 0.25rem;
+}
+
+.ml-1 {
+ margin-left: 0.25rem;
+}
+
+.mt-2 {
+ margin-top: 0.5rem;
+}
+
+.mr-2 {
+ margin-right: 0.5rem;
+}
+
+.mb-2 {
+ margin-bottom: 0.5rem;
+}
+
+.ml-2 {
+ margin-left: 0.5rem;
+}
+
+.mt-3 {
+ margin-top: 0.75rem;
+}
+
+.mr-3 {
+ margin-right: 0.75rem;
+}
+
+.mb-3 {
+ margin-bottom: 0.75rem;
+}
+
+.ml-3 {
+ margin-left: 0.75rem;
+}
+
+.mt-4 {
+ margin-top: 1rem;
+}
+
+.mr-4 {
+ margin-right: 1rem;
+}
+
+.mb-4 {
+ margin-bottom: 1rem;
+}
+
+.ml-4 {
+ margin-left: 1rem;
+}
+
+.mt-5 {
+ margin-top: 1.25rem;
+}
+
+.mr-5 {
+ margin-right: 1.25rem;
+}
+
+.mb-5 {
+ margin-bottom: 1.25rem;
+}
+
+.ml-5 {
+ margin-left: 1.25rem;
+}
+
+.mt-6 {
+ margin-top: 1.5rem;
+}
+
+.mr-6 {
+ margin-right: 1.5rem;
+}
+
+.mb-6 {
+ margin-bottom: 1.5rem;
+}
+
+.ml-6 {
+ margin-left: 1.5rem;
+}
+
+.mt-7 {
+ margin-top: 1.75rem;
+}
+
+.mr-7 {
+ margin-right: 1.75rem;
+}
+
+.mb-7 {
+ margin-bottom: 1.75rem;
+}
+
+.ml-7 {
+ margin-left: 1.75rem;
+}
+
+.mt-8 {
+ margin-top: 2rem;
+}
+
+.mr-8 {
+ margin-right: 2rem;
+}
+
+.mb-8 {
+ margin-bottom: 2rem;
+}
+
+.ml-8 {
+ margin-left: 2rem;
+}
+
+.mt-9 {
+ margin-top: 2.25rem;
+}
+
+.mr-9 {
+ margin-right: 2.25rem;
+}
+
+.mb-9 {
+ margin-bottom: 2.25rem;
+}
+
+.ml-9 {
+ margin-left: 2.25rem;
+}
+
+.mt-10 {
+ margin-top: 2.5rem;
+}
+
+.mr-10 {
+ margin-right: 2.5rem;
+}
+
+.mb-10 {
+ margin-bottom: 2.5rem;
+}
+
+.ml-10 {
+ margin-left: 2.5rem;
+}
+
+.mt-11 {
+ margin-top: 2.75rem;
+}
+
+.mr-11 {
+ margin-right: 2.75rem;
+}
+
+.mb-11 {
+ margin-bottom: 2.75rem;
+}
+
+.ml-11 {
+ margin-left: 2.75rem;
+}
+
+.mt-12 {
+ margin-top: 3rem;
+}
+
+.mr-12 {
+ margin-right: 3rem;
+}
+
+.mb-12 {
+ margin-bottom: 3rem;
+}
+
+.ml-12 {
+ margin-left: 3rem;
+}
+
+.mt-14 {
+ margin-top: 3.5rem;
+}
+
+.mr-14 {
+ margin-right: 3.5rem;
+}
+
+.mb-14 {
+ margin-bottom: 3.5rem;
+}
+
+.ml-14 {
+ margin-left: 3.5rem;
+}
+
+.mt-16 {
+ margin-top: 4rem;
+}
+
+.mr-16 {
+ margin-right: 4rem;
+}
+
+.mb-16 {
+ margin-bottom: 4rem;
+}
+
+.ml-16 {
+ margin-left: 4rem;
+}
+
+.mt-20 {
+ margin-top: 5rem;
+}
+
+.mr-20 {
+ margin-right: 5rem;
+}
+
+.mb-20 {
+ margin-bottom: 5rem;
+}
+
+.ml-20 {
+ margin-left: 5rem;
+}
+
+.mt-24 {
+ margin-top: 6rem;
+}
+
+.mr-24 {
+ margin-right: 6rem;
+}
+
+.mb-24 {
+ margin-bottom: 6rem;
+}
+
+.ml-24 {
+ margin-left: 6rem;
+}
+
+.mt-28 {
+ margin-top: 7rem;
+}
+
+.mr-28 {
+ margin-right: 7rem;
+}
+
+.mb-28 {
+ margin-bottom: 7rem;
+}
+
+.ml-28 {
+ margin-left: 7rem;
+}
+
+.mt-32 {
+ margin-top: 8rem;
+}
+
+.mr-32 {
+ margin-right: 8rem;
+}
+
+.mb-32 {
+ margin-bottom: 8rem;
+}
+
+.ml-32 {
+ margin-left: 8rem;
+}
+
+.mt-36 {
+ margin-top: 9rem;
+}
+
+.mr-36 {
+ margin-right: 9rem;
+}
+
+.mb-36 {
+ margin-bottom: 9rem;
+}
+
+.ml-36 {
+ margin-left: 9rem;
+}
+
+.mt-40 {
+ margin-top: 10rem;
+}
+
+.mr-40 {
+ margin-right: 10rem;
+}
+
+.mb-40 {
+ margin-bottom: 10rem;
+}
+
+.ml-40 {
+ margin-left: 10rem;
+}
+
+.mt-44 {
+ margin-top: 11rem;
+}
+
+.mr-44 {
+ margin-right: 11rem;
+}
+
+.mb-44 {
+ margin-bottom: 11rem;
+}
+
+.ml-44 {
+ margin-left: 11rem;
+}
+
+.mt-48 {
+ margin-top: 12rem;
+}
+
+.mr-48 {
+ margin-right: 12rem;
+}
+
+.mb-48 {
+ margin-bottom: 12rem;
+}
+
+.ml-48 {
+ margin-left: 12rem;
+}
+
+.mt-52 {
+ margin-top: 13rem;
+}
+
+.mr-52 {
+ margin-right: 13rem;
+}
+
+.mb-52 {
+ margin-bottom: 13rem;
+}
+
+.ml-52 {
+ margin-left: 13rem;
+}
+
+.mt-56 {
+ margin-top: 14rem;
+}
+
+.mr-56 {
+ margin-right: 14rem;
+}
+
+.mb-56 {
+ margin-bottom: 14rem;
+}
+
+.ml-56 {
+ margin-left: 14rem;
+}
+
+.mt-60 {
+ margin-top: 15rem;
+}
+
+.mr-60 {
+ margin-right: 15rem;
+}
+
+.mb-60 {
+ margin-bottom: 15rem;
+}
+
+.ml-60 {
+ margin-left: 15rem;
+}
+
+.mt-64 {
+ margin-top: 16rem;
+}
+
+.mr-64 {
+ margin-right: 16rem;
+}
+
+.mb-64 {
+ margin-bottom: 16rem;
+}
+
+.ml-64 {
+ margin-left: 16rem;
+}
+
+.mt-72 {
+ margin-top: 18rem;
+}
+
+.mr-72 {
+ margin-right: 18rem;
+}
+
+.mb-72 {
+ margin-bottom: 18rem;
+}
+
+.ml-72 {
+ margin-left: 18rem;
+}
+
+.mt-80 {
+ margin-top: 20rem;
+}
+
+.mr-80 {
+ margin-right: 20rem;
+}
+
+.mb-80 {
+ margin-bottom: 20rem;
+}
+
+.ml-80 {
+ margin-left: 20rem;
+}
+
+.mt-96 {
+ margin-top: 24rem;
+}
+
+.mr-96 {
+ margin-right: 24rem;
+}
+
+.mb-96 {
+ margin-bottom: 24rem;
+}
+
+.ml-96 {
+ margin-left: 24rem;
+}
+
+.max-w-lg {
+ max-width: 32rem;
+}
+
+.max-w-xl {
+ max-width: 36rem;
+}
+
+.overflow-hidden {
+ overflow: hidden;
+}
+
+.p-2 {
+ padding: 0.5rem;
+}
+
+.py-0 {
+ padding-top: 0px;
+ padding-bottom: 0px;
+}
+
+.px-0 {
+ padding-left: 0px;
+ padding-right: 0px;
+}
+
+.py-1 {
+ padding-top: 0.25rem;
+ padding-bottom: 0.25rem;
+}
+
+.px-1 {
+ padding-left: 0.25rem;
+ padding-right: 0.25rem;
+}
+
+.py-2 {
+ padding-top: 0.5rem;
+ padding-bottom: 0.5rem;
+}
+
+.px-2 {
+ padding-left: 0.5rem;
+ padding-right: 0.5rem;
+}
+
+.py-3 {
+ padding-top: 0.75rem;
+ padding-bottom: 0.75rem;
+}
+
+.px-3 {
+ padding-left: 0.75rem;
+ padding-right: 0.75rem;
+}
+
+.py-4 {
+ padding-top: 1rem;
+ padding-bottom: 1rem;
+}
+
+.px-4 {
+ padding-left: 1rem;
+ padding-right: 1rem;
+}
+
+.py-5 {
+ padding-top: 1.25rem;
+ padding-bottom: 1.25rem;
+}
+
+.px-5 {
+ padding-left: 1.25rem;
+ padding-right: 1.25rem;
+}
+
+.py-6 {
+ padding-top: 1.5rem;
+ padding-bottom: 1.5rem;
+}
+
+.px-6 {
+ padding-left: 1.5rem;
+ padding-right: 1.5rem;
+}
+
+.py-7 {
+ padding-top: 1.75rem;
+ padding-bottom: 1.75rem;
+}
+
+.px-7 {
+ padding-left: 1.75rem;
+ padding-right: 1.75rem;
+}
+
+.py-8 {
+ padding-top: 2rem;
+ padding-bottom: 2rem;
+}
+
+.px-8 {
+ padding-left: 2rem;
+ padding-right: 2rem;
+}
+
+.py-9 {
+ padding-top: 2.25rem;
+ padding-bottom: 2.25rem;
+}
+
+.px-9 {
+ padding-left: 2.25rem;
+ padding-right: 2.25rem;
+}
+
+.py-10 {
+ padding-top: 2.5rem;
+ padding-bottom: 2.5rem;
+}
+
+.px-10 {
+ padding-left: 2.5rem;
+ padding-right: 2.5rem;
+}
+
+.py-11 {
+ padding-top: 2.75rem;
+ padding-bottom: 2.75rem;
+}
+
+.px-11 {
+ padding-left: 2.75rem;
+ padding-right: 2.75rem;
+}
+
+.py-12 {
+ padding-top: 3rem;
+ padding-bottom: 3rem;
+}
+
+.px-12 {
+ padding-left: 3rem;
+ padding-right: 3rem;
+}
+
+.py-14 {
+ padding-top: 3.5rem;
+ padding-bottom: 3.5rem;
+}
+
+.px-14 {
+ padding-left: 3.5rem;
+ padding-right: 3.5rem;
+}
+
+.py-16 {
+ padding-top: 4rem;
+ padding-bottom: 4rem;
+}
+
+.px-16 {
+ padding-left: 4rem;
+ padding-right: 4rem;
+}
+
+.py-20 {
+ padding-top: 5rem;
+ padding-bottom: 5rem;
+}
+
+.px-20 {
+ padding-left: 5rem;
+ padding-right: 5rem;
+}
+
+.py-24 {
+ padding-top: 6rem;
+ padding-bottom: 6rem;
+}
+
+.px-24 {
+ padding-left: 6rem;
+ padding-right: 6rem;
+}
+
+.py-28 {
+ padding-top: 7rem;
+ padding-bottom: 7rem;
+}
+
+.px-28 {
+ padding-left: 7rem;
+ padding-right: 7rem;
+}
+
+.py-32 {
+ padding-top: 8rem;
+ padding-bottom: 8rem;
+}
+
+.px-32 {
+ padding-left: 8rem;
+ padding-right: 8rem;
+}
+
+.py-36 {
+ padding-top: 9rem;
+ padding-bottom: 9rem;
+}
+
+.px-36 {
+ padding-left: 9rem;
+ padding-right: 9rem;
+}
+
+.pr-3 {
+ padding-right: 0.75rem;
+}
+
+.pl-3 {
+ padding-left: 0.75rem;
+}
+
+.pointer-events-none {
+ pointer-events: none;
+}
+
+.relative {
+ position: relative;
+}
+
+* {
+ --tw-shadow: 0 0 #0000;
+}
+
+.shadow-2xl {
+ --tw-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
+ box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000),
+ var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
+}
+
+* {
+ --tw-ring-inset: var(--tw-empty, /*!*/ /*!*/);
+ --tw-ring-offset-width: 0px;
+ --tw-ring-offset-color: #fff;
+ --tw-ring-color: rgba(75, 112, 204, 0.5);
+ --tw-ring-offset-shadow: 0 0 #0000;
+ --tw-ring-shadow: 0 0 #0000;
+}
+
+.text-xs {
+ font-size: 0.75rem;
+ line-height: 1rem;
+}
+
+.text-sm {
+ font-size: 0.875rem;
+ line-height: 1.25rem;
+}
+
+.text-base {
+ font-size: 1rem;
+ line-height: 1.5rem;
+}
+
+.text-lg {
+ font-size: 1.125rem;
+ line-height: 1.75rem;
+}
+
+.text-xl {
+ font-size: 1.25rem;
+ line-height: 1.75rem;
+}
+
+.text-2xl {
+ font-size: 1.5rem;
+ line-height: 2rem;
+}
+
+.text-3xl {
+ font-size: 1.875rem;
+ line-height: 2.25rem;
+}
+
+.text-4xl {
+ font-size: 2.25rem;
+ line-height: 2.5rem;
+}
+
+.text-left {
+ text-align: left;
+}
+
+.text-center {
+ text-align: center;
+}
+
+.text-right {
+ text-align: right;
+}
+
+.text-justify {
+ text-align: justify;
+}
+
+.text-gray-500 {
+ --tw-text-opacity: 1;
+ color: rgba(112, 110, 109, var(--tw-text-opacity));
+}
+
+.text-gray-600 {
+ --tw-text-opacity: 1;
+ color: rgba(68, 67, 66, var(--tw-text-opacity));
+}
+
+.text-gray-700 {
+ --tw-text-opacity: 1;
+ color: rgba(46, 45, 45, var(--tw-text-opacity));
+}
+
+.text-gray-800 {
+ --tw-text-opacity: 1;
+ color: rgba(35, 34, 34, var(--tw-text-opacity));
+}
+
+.leading-3 {
+ line-height: 0.75rem;
+}
+
+.leading-4 {
+ line-height: 1rem;
+}
+
+.leading-5 {
+ line-height: 1.25rem;
+}
+
+.leading-6 {
+ line-height: 1.5rem;
+}
+
+.leading-7 {
+ line-height: 1.75rem;
+}
+
+.leading-8 {
+ line-height: 2rem;
+}
+
+.leading-9 {
+ line-height: 2.25rem;
+}
+
+.leading-10 {
+ line-height: 2.5rem;
+}
+
+.leading-none {
+ line-height: 1;
+}
+
+.leading-tight {
+ line-height: 1.25;
+}
+
+.leading-snug {
+ line-height: 1.375;
+}
+
+.leading-normal {
+ line-height: 1.5;
+}
+
+.leading-relaxed {
+ line-height: 1.625;
+}
+
+.leading-loose {
+ line-height: 2;
+}
+
+.truncate {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.w-8 {
+ width: 2rem;
+}
+
+.w-1\/2 {
+ width: 50%;
+}
+
+.w-2\/3 {
+ width: 66.666667%;
+}
+
+.w-full {
+ width: 100%;
+}
+
+.hover\:text-gray-0:hover {
+ --tw-text-opacity: 1;
+ color: rgba(250, 249, 248, var(--tw-text-opacity));
+}
+
+.hover\:text-gray-50:hover {
+ --tw-text-opacity: 1;
+ color: rgba(249, 247, 246, var(--tw-text-opacity));
+}
+
+.hover\:text-gray-100:hover {
+ --tw-text-opacity: 1;
+ color: rgba(247, 245, 244, var(--tw-text-opacity));
+}
+
+.hover\:text-gray-200:hover {
+ --tw-text-opacity: 1;
+ color: rgba(238, 235, 234, var(--tw-text-opacity));
+}
+
+.hover\:text-gray-300:hover {
+ --tw-text-opacity: 1;
+ color: rgba(218, 214, 213, var(--tw-text-opacity));
+}
+
+.hover\:text-gray-400:hover {
+ --tw-text-opacity: 1;
+ color: rgba(175, 172, 171, var(--tw-text-opacity));
+}
+
+.hover\:text-gray-500:hover {
+ --tw-text-opacity: 1;
+ color: rgba(112, 110, 109, var(--tw-text-opacity));
+}
+
+.hover\:text-gray-600:hover {
+ --tw-text-opacity: 1;
+ color: rgba(68, 67, 66, var(--tw-text-opacity));
+}
+
+.hover\:text-gray-700:hover {
+ --tw-text-opacity: 1;
+ color: rgba(46, 45, 45, var(--tw-text-opacity));
+}
+
+.hover\:text-gray-800:hover {
+ --tw-text-opacity: 1;
+ color: rgba(35, 34, 34, var(--tw-text-opacity));
+}
+
+.hover\:text-gray-900:hover {
+ --tw-text-opacity: 1;
+ color: rgba(31, 30, 30, var(--tw-text-opacity));
+}
+
+/**
+ * Non-Tailwind styles begin here.
+ */
+
+html {
+ letter-spacing: -0.015em;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+.link {
+ --text-opacity: 1;
+ color: #4b70cc;
+ color: rgba(75, 112, 204, var(--text-opacity));
+}
+
+.link:hover,
+.link:active {
+ --text-opacity: 1;
+ color: #19224a;
+ color: rgba(25, 34, 74, var(--text-opacity));
+}
+
+.button {
+ font-weight: 500;
+ padding-top: 0.45rem;
+ padding-bottom: 0.45rem;
+ padding-left: 1rem;
+ padding-right: 1rem;
+ border-radius: 0.375rem;
+ border-width: 1px;
+ border-color: transparent;
+ transition-property: background-color, border-color, color, box-shadow;
+ transition-duration: 120ms;
+ box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04);
+ min-width: 80px;
+}
+
+.button:focus {
+ outline: 0;
+ box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.5);
+}
+
+.button:disabled {
+ cursor: not-allowed;
+ -webkit-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+}
+
+.button-blue {
+ --bg-opacity: 1;
+ background-color: #4b70cc;
+ background-color: rgba(75, 112, 204, var(--bg-opacity));
+ --border-opacity: 1;
+ border-color: #4b70cc;
+ border-color: rgba(75, 112, 204, var(--border-opacity));
+ --text-opacity: 1;
+ color: #fff;
+ color: rgba(255, 255, 255, var(--text-opacity));
+}
+
+.button-blue:enabled:hover {
+ --bg-opacity: 1;
+ background-color: #3f5db3;
+ background-color: rgba(63, 93, 179, var(--bg-opacity));
+ --border-opacity: 1;
+ border-color: #3f5db3;
+ border-color: rgba(63, 93, 179, var(--border-opacity));
+}
+
+.button-blue:disabled {
+ --text-opacity: 1;
+ color: #cedefd;
+ color: rgba(206, 222, 253, var(--text-opacity));
+ --bg-opacity: 1;
+ background-color: #6c94ec;
+ background-color: rgba(108, 148, 236, var(--bg-opacity));
+ --border-opacity: 1;
+ border-color: #6c94ec;
+ border-color: rgba(108, 148, 236, var(--border-opacity));
+}
diff --git a/cmd/tailscaled/cli/web.go b/cmd/tailscaled/cli/web.go
new file mode 100644
index 000000000..67de1c29c
--- /dev/null
+++ b/cmd/tailscaled/cli/web.go
@@ -0,0 +1,327 @@
+// Copyright (c) 2021 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 (
+ "bytes"
+ "context"
+ _ "embed"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "html/template"
+ "log"
+ "net/http"
+ "net/http/cgi"
+ "os/exec"
+ "runtime"
+ "strings"
+
+ "github.com/peterbourgon/ff/v2/ffcli"
+ "tailscale.com/client/tailscale"
+ "tailscale.com/ipn"
+ "tailscale.com/tailcfg"
+ "tailscale.com/types/preftype"
+ "tailscale.com/version/distro"
+)
+
+//go:embed web.html
+var webHTML string
+
+//go:embed web.css
+var webCSS string
+
+var tmpl *template.Template
+
+func init() {
+ tmpl = template.Must(template.New("web.html").Parse(webHTML))
+ template.Must(tmpl.New("web.css").Parse(webCSS))
+}
+
+type tmplData struct {
+ Profile tailcfg.UserProfile
+ SynologyUser string
+ Status string
+ DeviceName string
+ IP string
+}
+
+var webCmd = &ffcli.Command{
+ Name: "web",
+ ShortUsage: "web [flags]",
+ ShortHelp: "Run a web server for controlling Tailscale",
+
+ FlagSet: (func() *flag.FlagSet {
+ webf := flag.NewFlagSet("web", flag.ExitOnError)
+ webf.StringVar(&webArgs.listen, "listen", "localhost:8088", "listen address; use port 0 for automatic")
+ webf.BoolVar(&webArgs.cgi, "cgi", false, "run as CGI script")
+ return webf
+ })(),
+ Exec: runWeb,
+}
+
+var webArgs struct {
+ listen string
+ cgi bool
+}
+
+func runWeb(ctx context.Context, args []string) error {
+ if len(args) > 0 {
+ log.Fatalf("too many non-flag arguments: %q", args)
+ }
+
+ if webArgs.cgi {
+ if err := cgi.Serve(http.HandlerFunc(webHandler)); err != nil {
+ log.Printf("tailscale.cgi: %v", err)
+ return err
+ }
+ return nil
+ }
+ return http.ListenAndServe(webArgs.listen, http.HandlerFunc(webHandler))
+}
+
+func auth() (string, error) {
+ if distro.Get() == distro.Synology {
+ cmd := exec.Command("/usr/syno/synoman/webman/modules/authenticate.cgi")
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ return "", fmt.Errorf("auth: %v: %s", err, out)
+ }
+ return string(out), nil
+ }
+
+ return "", nil
+}
+
+func synoTokenRedirect(w http.ResponseWriter, r *http.Request) bool {
+ if distro.Get() != distro.Synology {
+ return false
+ }
+ if r.Header.Get("X-Syno-Token") != "" {
+ return false
+ }
+ if r.URL.Query().Get("SynoToken") != "" {
+ return false
+ }
+ if r.Method == "POST" && r.FormValue("SynoToken") != "" {
+ return false
+ }
+ // We need a SynoToken for authenticate.cgi.
+ // So we tell the client to get one.
+ serverURL := r.URL.Scheme + "://" + r.URL.Host
+ fmt.Fprintf(w, synoTokenRedirectHTML, serverURL)
+ return true
+}
+
+const synoTokenRedirectHTML = `<html><body>
+Redirecting with session token...
+<script>
+var serverURL = %q;
+var req = new XMLHttpRequest();
+req.overrideMimeType("application/json");
+req.open("GET", serverURL + "/webman/login.cgi", true);
+req.onload = function() {
+ var jsonResponse = JSON.parse(req.responseText);
+ var token = jsonResponse["SynoToken"];
+ document.location.href = serverURL + "/webman/3rdparty/Tailscale/?SynoToken=" + token;
+};
+req.send(null);
+</script>
+</body></html>
+`
+
+const authenticationRedirectHTML = `
+<html>
+<head>
+ <title>Redirecting...</title>
+ <style>
+ html,
+ body {
+ height: 100%;
+ }
+
+ html {
+ background-color: rgb(249, 247, 246);
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ line-height: 1.5;
+ -webkit-text-size-adjust: 100%;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ }
+
+ body {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ }
+
+ .spinner {
+ margin-bottom: 2rem;
+ border: 4px rgba(112, 110, 109, 0.5) solid;
+ border-left-color: transparent;
+ border-radius: 9999px;
+ width: 4rem;
+ height: 4rem;
+ -webkit-animation: spin 700ms linear infinite;
+ animation: spin 800ms linear infinite;
+ }
+
+ .label {
+ color: rgb(112, 110, 109);
+ padding-left: 0.4rem;
+ }
+
+ @-webkit-keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+ }
+
+ @keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+ }
+ </style>
+</head>
+<body>
+ <div class="spinner"></div>
+ <div class="label">Redirecting...</div>
+</body>
+`
+
+func webHandler(w http.ResponseWriter, r *http.Request) {
+ if synoTokenRedirect(w, r) {
+ return
+ }
+
+ user, err := auth()
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusForbidden)
+ return
+ }
+
+ if r.URL.Path == "/redirect" || r.URL.Path == "/redirect/" {
+ w.Write([]byte(authenticationRedirectHTML))
+ return
+ }
+
+ if r.Method == "POST" {
+ type mi map[string]interface{}
+ w.Header().Set("Content-Type", "application/json")
+ url, err := tailscaleUpForceReauth(r.Context())
+ if err != nil {
+ json.NewEncoder(w).Encode(mi{"error": err})
+ return
+ }
+ json.NewEncoder(w).Encode(mi{"url": url})
+ return
+ }
+
+ st, err := tailscale.Status(r.Context())
+ if err != nil {
+ http.Error(w, err.Error(), 500)
+ return
+ }
+
+ profile := st.User[st.Self.UserID]
+ deviceName := strings.Split(st.Self.DNSName, ".")[0]
+ data := tmplData{
+ SynologyUser: user,
+ Profile: profile,
+ Status: st.BackendState,
+ DeviceName: deviceName,
+ }
+ if len(st.TailscaleIPs) != 0 {
+ data.IP = st.TailscaleIPs[0].String()
+ }
+
+ buf := new(bytes.Buffer)
+ if err := tmpl.Execute(buf, data); err != nil {
+ http.Error(w, err.Error(), 500)
+ return
+ }
+ w.Write(buf.Bytes())
+}
+
+// TODO(crawshaw): some of this is very similar to the code in 'tailscale up', can we share anything?
+func tailscaleUpForceReauth(ctx context.Context) (authURL string, retErr error) {
+ prefs := ipn.NewPrefs()
+ prefs.ControlURL = ipn.DefaultControlURL
+ prefs.WantRunning = true
+ prefs.CorpDNS = true
+ prefs.AllowSingleHosts = true
+ prefs.ForceDaemon = (runtime.GOOS == "windows")
+
+ if distro.Get() == distro.Synology {
+ prefs.NetfilterMode = preftype.NetfilterOff
+ }
+
+ st, err := tailscale.Status(ctx)
+ if err != nil {
+ return "", fmt.Errorf("can't fetch status: %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 {
+ return url != origAuthURL
+ }
+
+ c, bc, pumpCtx, cancel := connect(ctx)
+ defer cancel()
+
+ gotEngineUpdate := make(chan bool, 1) // gets value upon an engine update
+ go pump(pumpCtx, bc, c)
+
+ 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 [...]')"
+ }
+ }
+ retErr = fmt.Errorf("backend error: %v", msg)
+ cancel()
+ } else if url := n.BrowseToURL; url != nil && printAuthURL(*url) {
+ authURL = *url
+ cancel()
+ }
+ })
+ // 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 authURL, pumpCtx.Err()
+ }
+
+ bc.SetPrefs(prefs)
+
+ bc.Start(ipn.Options{
+ StateKey: ipn.GlobalDaemonStateKey,
+ })
+ bc.StartLoginInteractive()
+
+ if authURL == "" && retErr == nil {
+ return "", fmt.Errorf("login failed with no backend error message")
+ }
+ return authURL, retErr
+}
diff --git a/cmd/tailscaled/cli/web.html b/cmd/tailscaled/cli/web.html
new file mode 100644
index 000000000..2789d1e68
--- /dev/null
+++ b/cmd/tailscaled/cli/web.html
@@ -0,0 +1,143 @@
+<!doctype html>
+<html class="bg-gray-50">
+
+<head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <link rel="shortcut icon"
+ href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAQAAADZc7J/AAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QA/4ePzL8AAAAHdElNRQflAx4QGA4EvmzDAAAA30lEQVRIx2NgGAWMCKa8JKM4A8Ovt88ekyLCDGOoyDBJMjExMbFy8zF8/EKsCAMDE8yAPyIwFps48SJIBpAL4AZwvoSx/r0lXgQpDN58EWL5x/7/H+vL20+JFxluQKVe5b3Ke5V+0kQQCamfoYKBg4GDwUKI8d0BYkWQkrLKewYBKPPDHUFiRaiZkBgmwhj/F5IgggyUJ6i8V3mv0kCayDAAeEsklXqGAgYGhgV3CnGrwVciYSYk0kokhgS44/JxqqFpiYSZbEgskd4dEBRk1GD4wdB5twKXmlHAwMDAAACdEZau06NQUwAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyMC0wNy0xNVQxNTo1Mzo0MCswMDowMCVXsDIAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjAtMDctMTVUMTU6NTM6NDArMDA6MDBUCgiOAAAAAElFTkSuQmCC" />
+ <title>Tailscale</title>
+ <style>{{template "web.css"}}</style>
+</head>
+
+<body class="py-14">
+<main class="container max-w-lg mx-auto py-6 px-8 bg-white rounded-md shadow-2xl" style="width: 95%">
+ <header class="flex justify-between items-center min-width-0 py-2 mb-8">
+ <svg width="26" height="26" viewBox="0 0 23 23" title="Tailscale" fill="none" xmlns="http://www.w3.org/2000/svg"
+ class="flex-shrink-0 mr-4">
+ <circle opacity="0.2" cx="3.4" cy="3.25" r="2.7" fill="currentColor"></circle>
+ <circle cx="3.4" cy="11.3" r="2.7" fill="currentColor"></circle>
+ <circle opacity="0.2" cx="3.4" cy="19.5" r="2.7" fill="currentColor"></circle>
+ <circle cx="11.5" cy="11.3" r="2.7" fill="currentColor"></circle>
+ <circle cx="11.5" cy="19.5" r="2.7" fill="currentColor"></circle>
+ <circle opacity="0.2" cx="11.5" cy="3.25" r="2.7" fill="currentColor"></circle>
+ <circle opacity="0.2" cx="19.5" cy="3.25" r="2.7" fill="currentColor"></circle>
+ <circle cx="19.5" cy="11.3" r="2.7" fill="currentColor"></circle>
+ <circle opacity="0.2" cx="19.5" cy="19.5" r="2.7" fill="currentColor"></circle>
+ </svg>
+ <div class="flex items-center justify-end space-x-2 w-2/3">
+ {{ with .Profile.LoginName }}
+ <div class="text-right truncate leading-4">
+ <h4 class="truncate">{{.}}</h4>
+ <a href="#" class="text-xs text-gray-500 hover:text-gray-700 js-loginButton">Switch account</a>
+ </div>
+ {{ end }}
+ <div class="relative flex-shrink-0 w-8 h-8 rounded-full overflow-hidden">
+ {{ with .Profile.ProfilePicURL }}
+ <div class="w-8 h-8 flex pointer-events-none rounded-full bg-gray-200"
+ style="background-image: url('{{.}}'); background-size: cover;"></div>
+ {{ else }}
+ <div class="w-8 h-8 flex pointer-events-none rounded-full border border-gray-400 border-dashed"></div>
+ {{ end }}
+ </div>
+ </div>
+ </header>
+ {{ if .IP }}
+ <div
+ class="border border-gray-200 bg-gray-0 rounded-lg p-2 pl-3 pr-3 mb-8 width-full flex items-center justify-between">
+ <div class="flex items-center min-width-0">
+ <svg class="flex-shrink-0 text-gray-600 mr-3 ml-1" xmlns="http://www.w3.org/2000/svg" width="20" height="20"
+ viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
+ stroke-linejoin="round">
+ <rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
+ <rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
+ <line x1="6" y1="6" x2="6.01" y2="6"></line>
+ <line x1="6" y1="18" x2="6.01" y2="18"></line>
+ </svg>
+ <h4 class="font-semibold truncate mr-2">{{.DeviceName}}</h4>
+ </div>
+ <h5>{{.IP}}</h5>
+ </div>
+ {{ end }}
+ {{ if or (eq .Status "NeedsLogin") (eq .Status "NoState") }}
+ {{ if .IP }}
+ <div class="mb-6">
+ <p class="text-gray-700">Your device's key has expired. Reauthenticate this device by logging in again, or <a
+ href="https://tailscale.com/kb/1028/key-expiry" class="link" target="_blank">learn more</a>.</p>
+ </div>
+ <a href="#" class="mb-4 js-loginButton" target="_blank">
+ <button class="button button-blue w-full">Reauthenticate</button>
+ </a>
+ {{ else }}
+ <div class="mb-6">
+ <h3 class="text-3xl font-semibold mb-3">Log in</h3>
+ <p class="text-gray-700">Get started by logging in to your Tailscale network. Or,&nbsp;learn&nbsp;more at <a
+ href="https://tailscale.com/" class="link" target="_blank">tailscale.com</a>.</p>
+ </div>
+ <a href="#" class="mb-4 js-loginButton" target="_blank">
+ <button class="button button-blue w-full">Log In</button>
+ </a>
+ {{ end }}
+ {{ else if eq .Status "NeedsMachineAuth" }}
+ <div class="mb-4">
+ This device is authorized, but needs approval from a network admin before it can connect to the network.
+ </div>
+ {{ else }}
+ <div class="mb-4">
+ <p>You are connected! Access this device over Tailscale using the device name or IP address above.</p>
+ </div>
+ <a href="#" class="mb-4 link font-medium js-loginButton" target="_blank">Reauthenticate</a>
+ {{ end }}
+</main>
+<script>(function () {
+let loginButtons = document.querySelectorAll(".js-loginButton");
+let fetchingUrl = false;
+
+function handleClick(e) {
+ e.preventDefault();
+
+ if (fetchingUrl) {
+ return;
+ }
+
+ fetchingUrl = true;
+ const urlParams = new URLSearchParams(window.location.search);
+ const token = urlParams.get("SynoToken");
+ const nextParams = new URLSearchParams({ up: true });
+ if (token) {
+ nextParams.set("SynoToken", token)
+ }
+ const nextUrl = new URL(window.location);
+ nextUrl.search = nextParams.toString()
+ const url = nextUrl.toString();
+
+ fetch(url, {
+ method: "POST",
+ headers: {
+ "Accept": "application/json",
+ "Content-Type": "application/json",
+ }
+ }).then(res => res.json()).then(res => {
+ fetchingUrl = false;
+ const err = res["error"];
+ if (err) {
+ throw new Error(err);
+ }
+ const url = res["url"];
+ if (url) {
+ document.location.href = url;
+ } else {
+ location.reload();
+ }
+ }).catch(err => {
+ alert("Failed to log in: " + err.message);
+ });
+}
+
+Array.from(loginButtons).forEach(el => {
+ el.addEventListener("click", handleClick);
+})
+})();</script>
+</body>
+
+</html>
diff --git a/cmd/tailscaled/main.go b/cmd/tailscaled/main.go
new file mode 100644
index 000000000..5b37f08dc
--- /dev/null
+++ b/cmd/tailscaled/main.go
@@ -0,0 +1,16 @@
+package main
+
+import (
+ "os"
+ "strings"
+)
+
+func main() {
+ if strings.HasSuffix(os.Args[0], "tailscaled") {
+ tailscaled_main()
+ } else if strings.HasSuffix(os.Args[0], "tailscale") {
+ tailscale_main()
+ } else {
+ panic(os.Args[0])
+ }
+}
diff --git a/cmd/tailscaled/tailscale.go b/cmd/tailscaled/tailscale.go
new file mode 100644
index 000000000..94a3563a0
--- /dev/null
+++ b/cmd/tailscaled/tailscale.go
@@ -0,0 +1,27 @@
+// 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.
+
+// The tailscale command is the Tailscale command-line client. It interacts
+// with the tailscaled node agent.
+package main // import "tailscale.com/cmd/tailscaled"
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "tailscale.com/cmd/tailscaled/cli"
+)
+
+func tailscale_main() {
+ args := os.Args[1:]
+ if name, _ := os.Executable(); strings.HasSuffix(filepath.Base(name), ".cgi") {
+ args = []string{"web", "-cgi"}
+ }
+ if err := cli.Run(args); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
diff --git a/cmd/tailscaled/tailscaled.go b/cmd/tailscaled/tailscaled.go
index 63296204d..1adf9a01e 100644
--- a/cmd/tailscaled/tailscaled.go
+++ b/cmd/tailscaled/tailscaled.go
@@ -101,7 +101,7 @@ var subCommands = map[string]*func([]string) error{
"debug": &debugModeFunc,
}
-func main() {
+func tailscaled_main() {
// We aren't very performance sensitive, and the parts that are
// performance sensitive (wireguard) try hard not to do any memory
// allocations. So let's be aggressive about garbage collection,