diff --git a/cmd/tailscale/cli/cli.go b/cmd/tailscale/cli/cli.go new file mode 100644 index 000000000..926ad0bf3 --- /dev/null +++ b/cmd/tailscale/cli/cli.go @@ -0,0 +1,103 @@ +// 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 + +// 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" + "flag" + "log" + "net" + "os" + "os/signal" + "runtime" + "strings" + "syscall" + + "github.com/peterbourgon/ff/v2/ffcli" + "tailscale.com/ipn" + "tailscale.com/paths" + "tailscale.com/safesocket" +) + +// Run runs the CLI. The args do npot include the binary name. +func Run(args []string) error { + 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 subcommand [flags]", + ShortHelp: "The easiest, most secure way to use WireGuard.", + LongHelp: strings.TrimSpace(` +This CLI is still under active development. Commands and flags will +change in the future. +`), + Subcommands: []*ffcli.Command{ + upCmd, + netcheckCmd, + statusCmd, + }, + FlagSet: rootfs, + Exec: func(context.Context, []string) error { return flag.ErrHelp }, + } + + if err := rootCmd.Parse(args); err != nil { + return err + } + + err := rootCmd.Run(context.Background()) + if err == flag.ErrHelp { + return nil + } + return err +} + +var rootArgs struct { + socket string +} + +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 == "" { + log.Fatalf("--socket cannot be empty") + } + log.Fatalf("Failed to connect 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) + <-interrupt + 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) { + defer conn.Close() + for ctx.Err() == nil { + msg, err := ipn.ReadMsg(conn) + if err != nil { + if ctx.Err() != nil { + return + } + log.Printf("ReadMsg: %v\n", err) + break + } + bc.GotNotifyMsg(msg) + } +} diff --git a/cmd/tailscale/netcheck.go b/cmd/tailscale/cli/netcheck.go similarity index 99% rename from cmd/tailscale/netcheck.go rename to cmd/tailscale/cli/netcheck.go index 6179eff68..de3e8ee4b 100644 --- a/cmd/tailscale/netcheck.go +++ b/cmd/tailscale/cli/netcheck.go @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -package main +package cli import ( "context" diff --git a/cmd/tailscale/status.go b/cmd/tailscale/cli/status.go similarity index 98% rename from cmd/tailscale/status.go rename to cmd/tailscale/cli/status.go index 4e8631e00..584747bff 100644 --- a/cmd/tailscale/status.go +++ b/cmd/tailscale/cli/status.go @@ -1,8 +1,8 @@ // 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. +// license that can be found in the LICENSE -package main +package cli import ( "bytes" diff --git a/cmd/tailscale/cli/up.go b/cmd/tailscale/cli/up.go new file mode 100644 index 000000000..9cb50723d --- /dev/null +++ b/cmd/tailscale/cli/up.go @@ -0,0 +1,242 @@ +// 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" + "flag" + "fmt" + "log" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + + "github.com/peterbourgon/ff/v2/ffcli" + "github.com/tailscale/wireguard-go/wgcfg" + "tailscale.com/ipn" + "tailscale.com/tailcfg" + "tailscale.com/wgengine/router" +) + +// globalStateKey is the ipn.StateKey that tailscaled loads on +// startup. +// +// We have to support multiple state keys for other OSes (Windows in +// particular), but right now Unix daemons run with a single +// node-global state. To keep open the option of having per-user state +// later, the global state key doesn't look like a username. +const globalStateKey = "_daemon" + +var upCmd = &ffcli.Command{ + Name: "up", + ShortUsage: "up [flags]", + ShortHelp: "Connect to your Tailscale network", + + LongHelp: strings.TrimSpace(` +"tailscale up" connects this machine to your Tailscale network, +triggering authentication if necessary. + +The flags passed to this command are specific to this machine. If you don't +specify any flags, options are reset to their default. +`), + FlagSet: (func() *flag.FlagSet { + upf := flag.NewFlagSet("up", flag.ExitOnError) + upf.StringVar(&upArgs.server, "login-server", "https://login.tailscale.com", "base URL of control server") + upf.BoolVar(&upArgs.acceptRoutes, "accept-routes", false, "accept routes advertised by other Tailscale nodes") + upf.BoolVar(&upArgs.singleRoutes, "host-routes", true, "install host routes to other Tailscale nodes") + upf.BoolVar(&upArgs.shieldsUp, "shields-up", false, "don't allow incoming connections") + upf.StringVar(&upArgs.advertiseTags, "advertise-tags", "", "ACL tags to request (comma-separated, e.g. eng,montreal,ssh)") + upf.StringVar(&upArgs.authKey, "authkey", "", "node authorization key") + upf.BoolVar(&upArgs.enableDERP, "enable-derp", true, "enable the use of DERP servers") + if runtime.GOOS == "linux" || isBSD(runtime.GOOS) { + 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)") + } + if runtime.GOOS == "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", "on", "netfilter mode (one of on, nodivert, off)") + } + return upf + })(), + Exec: runUp, +} + +var upArgs struct { + server string + acceptRoutes bool + singleRoutes bool + shieldsUp bool + advertiseRoutes string + advertiseTags string + enableDERP bool + snat bool + netfilterMode string + authKey string +} + +// parseIPOrCIDR parses an IP address or a CIDR prefix. If the input +// is an IP address, it is returned in CIDR form with a /32 mask for +// IPv4 or a /128 mask for IPv6. +func parseIPOrCIDR(s string) (wgcfg.CIDR, bool) { + if strings.Contains(s, "/") { + ret, err := wgcfg.ParseCIDR(s) + if err != nil { + return wgcfg.CIDR{}, false + } + return ret, true + } + + ip, ok := wgcfg.ParseIP(s) + if !ok { + return wgcfg.CIDR{}, false + } + if ip.Is4() { + return wgcfg.CIDR{ip, 32}, true + } else { + return wgcfg.CIDR{ip, 128}, true + } +} + +func isBSD(s string) bool { + return s == "dragonfly" || s == "freebsd" || s == "netbsd" || s == "openbsd" +} + +func warning(format string, args ...interface{}) { + fmt.Printf("Warning: "+format+"\n", args...) +} + +// checkIPForwarding prints warnings on linux if IP forwarding is not +// enabled, or if we were unable to verify the state of IP forwarding. +func checkIPForwarding() { + var key string + + if runtime.GOOS == "linux" { + key = "net.ipv4.ip_forward" + } else if isBSD(runtime.GOOS) { + key = "net.inet.ip.forwarding" + } else { + return + } + + bs, err := exec.Command("sysctl", "-n", key).Output() + if err != nil { + warning("couldn't check %s (%v).\nSubnet routes won't work without IP forwarding.", key, err) + return + } + on, err := strconv.ParseBool(string(bytes.TrimSpace(bs))) + if err != nil { + warning("couldn't parse %s (%v).\nSubnet routes won't work without IP forwarding.", key, err) + return + } + if !on { + warning("%s is disabled. Subnet routes won't work.", key) + } +} + +func runUp(ctx context.Context, args []string) error { + if len(args) > 0 { + log.Fatalf("too many non-flag arguments: %q", args) + } + + var routes []wgcfg.CIDR + if upArgs.advertiseRoutes != "" { + checkIPForwarding() + advroutes := strings.Split(upArgs.advertiseRoutes, ",") + for _, s := range advroutes { + cidr, ok := parseIPOrCIDR(s) + if !ok { + log.Fatalf("%q is not a valid IP address or CIDR prefix", s) + } + routes = append(routes, cidr) + } + } + + var tags []string + if upArgs.advertiseTags != "" { + tags = strings.Split(upArgs.advertiseTags, ",") + for _, tag := range tags { + err := tailcfg.CheckTag(tag) + if err != nil { + log.Fatalf("tag: %q: %s", tag, err) + } + } + } + + // TODO(apenwarr): fix different semantics between prefs and uflags + // TODO(apenwarr): allow setting/using CorpDNS + prefs := ipn.NewPrefs() + prefs.ControlURL = upArgs.server + prefs.WantRunning = true + prefs.RouteAll = upArgs.acceptRoutes + prefs.AllowSingleHosts = upArgs.singleRoutes + prefs.ShieldsUp = upArgs.shieldsUp + prefs.AdvertiseRoutes = routes + prefs.AdvertiseTags = tags + prefs.NoSNAT = !upArgs.snat + prefs.DisableDERP = !upArgs.enableDERP + if runtime.GOOS == "linux" { + switch upArgs.netfilterMode { + case "on": + prefs.NetfilterMode = router.NetfilterOn + case "nodivert": + prefs.NetfilterMode = router.NetfilterNoDivert + warning("netfilter=nodivert; add iptables calls to ts-* chains manually.") + case "off": + prefs.NetfilterMode = router.NetfilterOff + warning("netfilter=off; configure iptables yourself.") + default: + log.Fatalf("invalid value --netfilter-mode: %q", upArgs.netfilterMode) + } + } + + c, bc, ctx, cancel := connect(ctx) + defer cancel() + + var printed bool + + bc.SetPrefs(prefs) + opts := ipn.Options{ + StateKey: globalStateKey, + AuthKey: upArgs.authKey, + Notify: func(n ipn.Notify) { + if n.ErrMessage != nil { + log.Fatalf("backend error: %v\n", *n.ErrMessage) + } + if s := n.State; s != nil { + switch *s { + case ipn.NeedsLogin: + printed = true + bc.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") + } + cancel() + } + } + if url := n.BrowseToURL; url != nil { + fmt.Fprintf(os.Stderr, "\nTo authenticate, visit:\n\n\t%s\n\n", *url) + } + }, + } + // We still have to Start right now because it's the only way to + // set up notifications and whatnot. This causes a bunch of churn + // every time the CLI touches anything. + // + // TODO(danderson): redo the frontend/backend API to assume + // ephemeral frontends that read/modify/write state, once + // Windows/Mac state is moved into backend. + bc.Start(opts) + pump(ctx, bc, c) + + return nil +} diff --git a/cmd/tailscale/tailscale.go b/cmd/tailscale/tailscale.go index 8f1f477a5..2fda32f9b 100644 --- a/cmd/tailscale/tailscale.go +++ b/cmd/tailscale/tailscale.go @@ -7,319 +7,22 @@ package main // import "tailscale.com/cmd/tailscale" import ( - "bytes" - "context" - "flag" "fmt" "log" - "net" "os" - "os/exec" - "os/signal" - "runtime" - "strconv" - "strings" - "syscall" "github.com/apenwarr/fixconsole" - "github.com/peterbourgon/ff/v2/ffcli" - "github.com/tailscale/wireguard-go/wgcfg" - "tailscale.com/ipn" - "tailscale.com/paths" - "tailscale.com/safesocket" - "tailscale.com/tailcfg" - "tailscale.com/wgengine/router" + "tailscale.com/cmd/tailscale/cli" ) -// globalStateKey is the ipn.StateKey that tailscaled loads on -// startup. -// -// We have to support multiple state keys for other OSes (Windows in -// particular), but right now Unix daemons run with a single -// node-global state. To keep open the option of having per-user state -// later, the global state key doesn't look like a username. -const globalStateKey = "_daemon" - -var rootArgs struct { - socket string -} - func main() { err := fixconsole.FixConsoleIfNeeded() if err != nil { log.Printf("fixConsoleOutput: %v\n", err) } - upf := flag.NewFlagSet("up", flag.ExitOnError) - upf.StringVar(&upArgs.server, "login-server", "https://login.tailscale.com", "base URL of control server") - upf.BoolVar(&upArgs.acceptRoutes, "accept-routes", false, "accept routes advertised by other Tailscale nodes") - upf.BoolVar(&upArgs.singleRoutes, "host-routes", true, "install host routes to other Tailscale nodes") - upf.BoolVar(&upArgs.shieldsUp, "shields-up", false, "don't allow incoming connections") - upf.StringVar(&upArgs.advertiseTags, "advertise-tags", "", "ACL tags to request (comma-separated, e.g. eng,montreal,ssh)") - upf.StringVar(&upArgs.authKey, "authkey", "", "node authorization key") - upf.BoolVar(&upArgs.enableDERP, "enable-derp", true, "enable the use of DERP servers") - if runtime.GOOS == "linux" || isBSD(runtime.GOOS) { - 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)") - } - if runtime.GOOS == "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", "on", "netfilter mode (one of on, nodivert, off)") - } - upCmd := &ffcli.Command{ - Name: "up", - ShortUsage: "up [flags]", - ShortHelp: "Connect to your Tailscale network", - - LongHelp: strings.TrimSpace(` -"tailscale up" connects this machine to your Tailscale network, -triggering authentication if necessary. - -The flags passed to this command are specific to this machine. If you don't -specify any flags, options are reset to their default. -`), - FlagSet: upf, - Exec: runUp, - } - - 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 subcommand [flags]", - ShortHelp: "The easiest, most secure way to use WireGuard.", - LongHelp: strings.TrimSpace(` -This CLI is still under active development. Commands and flags will -change in the future. -`), - Subcommands: []*ffcli.Command{ - upCmd, - netcheckCmd, - statusCmd, - }, - FlagSet: rootfs, - Exec: func(context.Context, []string) error { return flag.ErrHelp }, - } - - if err := rootCmd.ParseAndRun(context.Background(), os.Args[1:]); err != nil && err != flag.ErrHelp { - log.Fatal(err) - } -} - -var upArgs struct { - server string - acceptRoutes bool - singleRoutes bool - shieldsUp bool - advertiseRoutes string - advertiseTags string - enableDERP bool - snat bool - netfilterMode string - authKey string -} - -// parseIPOrCIDR parses an IP address or a CIDR prefix. If the input -// is an IP address, it is returned in CIDR form with a /32 mask for -// IPv4 or a /128 mask for IPv6. -func parseIPOrCIDR(s string) (wgcfg.CIDR, bool) { - if strings.Contains(s, "/") { - ret, err := wgcfg.ParseCIDR(s) - if err != nil { - return wgcfg.CIDR{}, false - } - return ret, true - } - - ip, ok := wgcfg.ParseIP(s) - if !ok { - return wgcfg.CIDR{}, false - } - if ip.Is4() { - return wgcfg.CIDR{ip, 32}, true - } else { - return wgcfg.CIDR{ip, 128}, true - } -} - -func isBSD(s string) bool { - return s == "dragonfly" || s == "freebsd" || s == "netbsd" || s == "openbsd" -} - -func warning(format string, args ...interface{}) { - fmt.Printf("Warning: "+format+"\n", args...) -} - -// checkIPForwarding prints warnings on linux if IP forwarding is not -// enabled, or if we were unable to verify the state of IP forwarding. -func checkIPForwarding() { - var key string - - if runtime.GOOS == "linux" { - key = "net.ipv4.ip_forward" - } else if isBSD(runtime.GOOS) { - key = "net.inet.ip.forwarding" - } else { - return - } - - bs, err := exec.Command("sysctl", "-n", key).Output() - if err != nil { - warning("couldn't check %s (%v).\nSubnet routes won't work without IP forwarding.", key, err) - return - } - on, err := strconv.ParseBool(string(bytes.TrimSpace(bs))) - if err != nil { - warning("couldn't parse %s (%v).\nSubnet routes won't work without IP forwarding.", key, err) - return - } - if !on { - warning("%s is disabled. Subnet routes won't work.", key) - } -} - -func runUp(ctx context.Context, args []string) error { - if len(args) > 0 { - log.Fatalf("too many non-flag arguments: %q", args) - } - - var routes []wgcfg.CIDR - if upArgs.advertiseRoutes != "" { - checkIPForwarding() - advroutes := strings.Split(upArgs.advertiseRoutes, ",") - for _, s := range advroutes { - cidr, ok := parseIPOrCIDR(s) - if !ok { - log.Fatalf("%q is not a valid IP address or CIDR prefix", s) - } - routes = append(routes, cidr) - } - } - - var tags []string - if upArgs.advertiseTags != "" { - tags = strings.Split(upArgs.advertiseTags, ",") - for _, tag := range tags { - err := tailcfg.CheckTag(tag) - if err != nil { - log.Fatalf("tag: %q: %s", tag, err) - } - } - } - - // TODO(apenwarr): fix different semantics between prefs and uflags - // TODO(apenwarr): allow setting/using CorpDNS - prefs := ipn.NewPrefs() - prefs.ControlURL = upArgs.server - prefs.WantRunning = true - prefs.RouteAll = upArgs.acceptRoutes - prefs.AllowSingleHosts = upArgs.singleRoutes - prefs.ShieldsUp = upArgs.shieldsUp - prefs.AdvertiseRoutes = routes - prefs.AdvertiseTags = tags - prefs.NoSNAT = !upArgs.snat - prefs.DisableDERP = !upArgs.enableDERP - if runtime.GOOS == "linux" { - switch upArgs.netfilterMode { - case "on": - prefs.NetfilterMode = router.NetfilterOn - case "nodivert": - prefs.NetfilterMode = router.NetfilterNoDivert - warning("netfilter=nodivert; add iptables calls to ts-* chains manually.") - case "off": - prefs.NetfilterMode = router.NetfilterOff - warning("netfilter=off; configure iptables yourself.") - default: - log.Fatalf("invalid value --netfilter-mode: %q", upArgs.netfilterMode) - } - } - - c, bc, ctx, cancel := connect(ctx) - defer cancel() - - var printed bool - - bc.SetPrefs(prefs) - opts := ipn.Options{ - StateKey: globalStateKey, - AuthKey: upArgs.authKey, - Notify: func(n ipn.Notify) { - if n.ErrMessage != nil { - log.Fatalf("backend error: %v\n", *n.ErrMessage) - } - if s := n.State; s != nil { - switch *s { - case ipn.NeedsLogin: - printed = true - bc.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") - } - cancel() - } - } - if url := n.BrowseToURL; url != nil { - fmt.Fprintf(os.Stderr, "\nTo authenticate, visit:\n\n\t%s\n\n", *url) - } - }, - } - // We still have to Start right now because it's the only way to - // set up notifications and whatnot. This causes a bunch of churn - // every time the CLI touches anything. - // - // TODO(danderson): redo the frontend/backend API to assume - // ephemeral frontends that read/modify/write state, once - // Windows/Mac state is moved into backend. - bc.Start(opts) - pump(ctx, bc, c) - - return nil -} - -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 == "" { - log.Fatalf("--socket cannot be empty") - } - log.Fatalf("Failed to connect 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) - <-interrupt - 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) { - defer conn.Close() - for ctx.Err() == nil { - msg, err := ipn.ReadMsg(conn) - if err != nil { - if ctx.Err() != nil { - return - } - log.Printf("ReadMsg: %v\n", err) - break - } - bc.GotNotifyMsg(msg) + if err := cli.Run(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) } }