diff options
| author | Denton Gentry <dgentry@tailscale.com> | 2021-05-17 08:15:50 -0700 |
|---|---|---|
| committer | Denton Gentry <dgentry@tailscale.com> | 2021-05-17 08:16:50 -0700 |
| commit | 1dc90404f32e9f4437e188109622dc598cc185cb (patch) | |
| tree | 0b853c62f1b28589de037cd975f95f3e64e4e7f3 /cmd/tailscaled | |
| parent | 25df067dd0c854eebcd2841b82ad92ebb1d77165 (diff) | |
| download | tailscale-onebinary.tar.xz tailscale-onebinary.zip | |
cmd/tailscale{,d}: combine into a single binaryonebinary
To reduce size, combine tailscaled and tailscale into a single
binary which will figure out what it should do based on argv[0].
Signed-off-by: Denton Gentry <dgentry@tailscale.com>
Diffstat (limited to 'cmd/tailscaled')
| -rw-r--r-- | cmd/tailscaled/cli/bugreport.go | 38 | ||||
| -rw-r--r-- | cmd/tailscaled/cli/cli.go | 276 | ||||
| -rw-r--r-- | cmd/tailscaled/cli/cli_test.go | 668 | ||||
| -rw-r--r-- | cmd/tailscaled/cli/debug.go | 129 | ||||
| -rw-r--r-- | cmd/tailscaled/cli/down.go | 46 | ||||
| -rw-r--r-- | cmd/tailscaled/cli/file.go | 444 | ||||
| -rw-r--r-- | cmd/tailscaled/cli/ip.go | 105 | ||||
| -rw-r--r-- | cmd/tailscaled/cli/logout.go | 34 | ||||
| -rw-r--r-- | cmd/tailscaled/cli/netcheck.go | 178 | ||||
| -rw-r--r-- | cmd/tailscaled/cli/ping.go | 179 | ||||
| -rw-r--r-- | cmd/tailscaled/cli/status.go | 226 | ||||
| -rw-r--r-- | cmd/tailscaled/cli/up.go | 775 | ||||
| -rw-r--r-- | cmd/tailscaled/cli/version.go | 51 | ||||
| -rw-r--r-- | cmd/tailscaled/cli/web.css | 1337 | ||||
| -rw-r--r-- | cmd/tailscaled/cli/web.go | 327 | ||||
| -rw-r--r-- | cmd/tailscaled/cli/web.html | 143 | ||||
| -rw-r--r-- | cmd/tailscaled/main.go | 16 | ||||
| -rw-r--r-- | cmd/tailscaled/tailscale.go | 27 | ||||
| -rw-r--r-- | cmd/tailscaled/tailscaled.go | 2 |
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, learn 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, |
