You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
tailscale/cmd/tailscale/cli/cli.go

407 lines
10 KiB
Go

// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// 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"
"os"
"runtime"
"strings"
"sync"
"text/tabwriter"
cmd/tailscale/cli: stop spamming os.Stdout/os.Stderr in tests After: bradfitz@book1pro tailscale.com % ./tool/go test -c ./cmd/tailscale/cli bradfitz@book1pro tailscale.com % ./cli.test bradfitz@book1pro tailscale.com % Before: bradfitz@book1pro tailscale.com % ./tool/go test -c ./cmd/tailscale/cli bradfitz@book1pro tailscale.com % ./cli.test Warning: funnel=on for foo.test.ts.net:443, but no serve config run: `tailscale serve --help` to see how to configure handlers Warning: funnel=on for foo.test.ts.net:443, but no serve config run: `tailscale serve --help` to see how to configure handlers USAGE funnel <serve-port> {on|off} funnel status [--json] Funnel allows you to publish a 'tailscale serve' server publicly, open to the entire internet. Turning off Funnel only turns off serving to the internet. It does not affect serving to your tailnet. SUBCOMMANDS status show current serve/funnel status error: path must be absolute error: invalid TCP source "localhost:5432": missing port in address error: invalid TCP source "tcp://somehost:5432" must be one of: localhost or 127.0.0.1 tcp://somehost:5432error: invalid TCP source "tcp://somehost:0" must be one of: localhost or 127.0.0.1 tcp://somehost:0error: invalid TCP source "tcp://somehost:65536" must be one of: localhost or 127.0.0.1 tcp://somehost:65536error: path must be absolute error: cannot serve web; already serving TCP You don't have permission to enable this feature. This also moves the color handling up to a generic spot so it's not just one subcommand doing it itself. See https://github.com/tailscale/tailscale/issues/11626#issuecomment-2041795129 Fixes #11643 Updates #11626 Change-Id: I3a49e659dcbce491f4a2cb784be20bab53f72303 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2 months ago
"github.com/mattn/go-colorable"
"github.com/mattn/go-isatty"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/client/tailscale"
"tailscale.com/envknob"
"tailscale.com/paths"
"tailscale.com/version/distro"
)
var Stderr io.Writer = os.Stderr
var Stdout io.Writer = os.Stdout
func errf(format string, a ...any) {
fmt.Fprintf(Stderr, format, a...)
}
func printf(format string, a ...any) {
fmt.Fprintf(Stdout, format, a...)
}
// outln is like fmt.Println in the common case, except when Stdout is
// changed (as in js/wasm).
//
// It's not named println because that looks like the Go built-in
// which goes to stderr and formats slightly differently.
func outln(a ...any) {
fmt.Fprintln(Stdout, a...)
}
func newFlagSet(name string) *flag.FlagSet {
onError := flag.ExitOnError
if runtime.GOOS == "js" {
onError = flag.ContinueOnError
}
fs := flag.NewFlagSet(name, onError)
fs.SetOutput(Stderr)
return fs
}
// CleanUpArgs rewrites command line arguments for simplicity and backwards compatibility.
// In particular, it rewrites --authkey to --auth-key.
func CleanUpArgs(args []string) []string {
out := make([]string, 0, len(args))
for _, arg := range args {
// Rewrite --authkey to --auth-key, and --authkey=x to --auth-key=x,
// and the same for the -authkey variant.
switch {
case arg == "--authkey", arg == "-authkey":
arg = "--auth-key"
case strings.HasPrefix(arg, "--authkey="), strings.HasPrefix(arg, "-authkey="):
arg = strings.TrimLeft(arg, "-")
arg = strings.TrimPrefix(arg, "authkey=")
arg = "--auth-key=" + arg
}
out = append(out, arg)
}
return out
}
var localClient tailscale.LocalClient
// Run runs the CLI. The args do not include the binary name.
func Run(args []string) (err error) {
args = CleanUpArgs(args)
if len(args) == 1 && (args[0] == "-V" || args[0] == "--version") {
args = []string{"version"}
}
var warnOnce sync.Once
tailscale.SetVersionMismatchHandler(func(clientVer, serverVer string) {
warnOnce.Do(func() {
fmt.Fprintf(Stderr, "Warning: client version %q != tailscaled server version %q\n", clientVer, serverVer)
})
})
rootCmd := newRootCmd()
if err := rootCmd.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return nil
}
if noexec := (ffcli.NoExecError{}); errors.As(err, &noexec) {
// When the user enters an unknown subcommand, ffcli tries to run
// the closest valid parent subcommand with everything else as args,
// returning NoExecError if it doesn't have an Exec function.
cmd := noexec.Command
args := cmd.FlagSet.Args()
if len(cmd.Subcommands) > 0 {
if len(args) > 0 {
return fmt.Errorf("%s: unknown subcommand: %s", fullCmd(rootCmd, cmd), args[0])
}
subs := make([]string, 0, len(cmd.Subcommands))
for _, sub := range cmd.Subcommands {
subs = append(subs, sub.Name)
}
return fmt.Errorf("%s: missing subcommand: %s", fullCmd(rootCmd, cmd), strings.Join(subs, ", "))
}
}
return err
}
if envknob.Bool("TS_DUMP_HELP") {
walkCommands(rootCmd, func(w cmdWalk) bool {
fmt.Println("===")
// UsageFuncs are typically called during Command.Run which ensures
// FlagSet is not nil.
c := w.Command
if c.FlagSet == nil {
c.FlagSet = flag.NewFlagSet(c.Name, flag.ContinueOnError)
}
if c.UsageFunc != nil {
fmt.Println(c.UsageFunc(c))
} else {
fmt.Println(ffcli.DefaultUsageFunc(c))
}
return true
})
return
}
localClient.Socket = rootArgs.socket
rootCmd.FlagSet.Visit(func(f *flag.Flag) {
if f.Name == "socket" {
localClient.UseSocketOnly = true
}
})
err = rootCmd.Run(context.Background())
if tailscale.IsAccessDeniedError(err) && os.Getuid() != 0 && runtime.GOOS != "windows" {
return fmt.Errorf("%v\n\nUse 'sudo tailscale %s' or 'tailscale up --operator=$USER' to not require root.", err, strings.Join(args, " "))
}
if errors.Is(err, flag.ErrHelp) {
return nil
}
return err
}
func newRootCmd() *ffcli.Command {
rootfs := newFlagSet("tailscale")
rootfs.StringVar(&rootArgs.socket, "socket", paths.DefaultTailscaledSocket(), "path to tailscaled 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,
setCmd,
loginCmd,
logoutCmd,
switchCmd,
configureCmd,
netcheckCmd,
ipCmd,
statusCmd,
pingCmd,
ncCmd,
sshCmd,
funnelCmd(),
serveCmd(),
versionCmd,
webCmd,
fileCmd,
bugReportCmd,
certCmd,
netlockCmd,
licensesCmd,
exitNodeCmd(),
updateCmd,
whoisCmd,
debugCmd,
driveCmd,
},
FlagSet: rootfs,
Exec: func(ctx context.Context, args []string) error {
if len(args) > 0 {
return fmt.Errorf("tailscale: unknown subcommand: %s", args[0])
}
return flag.ErrHelp
},
}
if envknob.UseWIPCode() {
rootCmd.Subcommands = append(rootCmd.Subcommands,
idTokenCmd,
)
}
if runtime.GOOS == "linux" && distro.Get() == distro.Synology {
rootCmd.Subcommands = append(rootCmd.Subcommands, configureHostCmd)
}
walkCommands(rootCmd, func(w cmdWalk) bool {
if w.UsageFunc == nil {
w.UsageFunc = usageFunc
}
return true
})
return rootCmd
}
func fatalf(format string, a ...any) {
if Fatalf != nil {
Fatalf(format, a...)
return
}
log.SetFlags(0)
log.Fatalf(format, a...)
}
// Fatalf, if non-nil, is used instead of log.Fatalf.
var Fatalf func(format string, a ...any)
var rootArgs struct {
socket string
}
type cmdWalk struct {
*ffcli.Command
parents []*ffcli.Command
}
func (w cmdWalk) Path() string {
if len(w.parents) == 0 {
return w.Name
}
var sb strings.Builder
for _, p := range w.parents {
sb.WriteString(p.Name)
sb.WriteString(" ")
}
sb.WriteString(w.Name)
return sb.String()
}
// walkCommands calls f for root and all of its nested subcommands until f
// returns false or all have been visited.
func walkCommands(root *ffcli.Command, f func(w cmdWalk) (more bool)) {
var walk func(cmd *ffcli.Command, parents []*ffcli.Command, f func(cmdWalk) bool) bool
walk = func(cmd *ffcli.Command, parents []*ffcli.Command, f func(cmdWalk) bool) bool {
if !f(cmdWalk{cmd, parents}) {
return false
}
parents = append(parents, cmd)
for _, sub := range cmd.Subcommands {
if !walk(sub, parents, f) {
return false
}
}
return true
}
walk(root, nil, f)
}
// fullCmd returns the full "tailscale ... cmd" invocation for a subcommand.
func fullCmd(root, cmd *ffcli.Command) (full string) {
walkCommands(root, func(w cmdWalk) bool {
if w.Command == cmd {
full = w.Path()
return false
}
return true
})
if full == "" {
return cmd.Name
}
return full
}
// usageFuncNoDefaultValues is like usageFunc but doesn't print default values.
func usageFuncNoDefaultValues(c *ffcli.Command) string {
return usageFuncOpt(c, false)
}
func usageFunc(c *ffcli.Command) string {
return usageFuncOpt(c, true)
}
func usageFuncOpt(c *ffcli.Command, withDefaults bool) string {
var b strings.Builder
const hiddenPrefix = "HIDDEN: "
if c.ShortHelp != "" {
fmt.Fprintf(&b, "%s\n\n", c.ShortHelp)
}
fmt.Fprintf(&b, "USAGE\n")
if c.ShortUsage != "" {
fmt.Fprintf(&b, " %s\n", strings.ReplaceAll(c.ShortUsage, "\n", "\n "))
} else {
fmt.Fprintf(&b, " %s\n", c.Name)
}
fmt.Fprintf(&b, "\n")
if c.LongHelp != "" {
help, _ := strings.CutPrefix(c.LongHelp, hiddenPrefix)
fmt.Fprintf(&b, "%s\n\n", help)
}
if len(c.Subcommands) > 0 {
fmt.Fprintf(&b, "SUBCOMMANDS\n")
tw := tabwriter.NewWriter(&b, 0, 2, 2, ' ', 0)
for _, subcommand := range c.Subcommands {
if strings.HasPrefix(subcommand.LongHelp, hiddenPrefix) {
continue
}
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 strings.HasPrefix(usage, hiddenPrefix) {
return
}
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")
showDefault := f.DefValue != "" && withDefaults
// Issue 6766: don't show the default Windows socket path. It's long
// and distracting. And people on on Windows aren't likely to ever
// change it anyway.
if runtime.GOOS == "windows" && f.Name == "socket" && strings.HasPrefix(f.DefValue, `\\.\pipe\ProtectedPrefix\`) {
showDefault = false
}
if showDefault {
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
}
cmd/tailscale/cli: stop spamming os.Stdout/os.Stderr in tests After: bradfitz@book1pro tailscale.com % ./tool/go test -c ./cmd/tailscale/cli bradfitz@book1pro tailscale.com % ./cli.test bradfitz@book1pro tailscale.com % Before: bradfitz@book1pro tailscale.com % ./tool/go test -c ./cmd/tailscale/cli bradfitz@book1pro tailscale.com % ./cli.test Warning: funnel=on for foo.test.ts.net:443, but no serve config run: `tailscale serve --help` to see how to configure handlers Warning: funnel=on for foo.test.ts.net:443, but no serve config run: `tailscale serve --help` to see how to configure handlers USAGE funnel <serve-port> {on|off} funnel status [--json] Funnel allows you to publish a 'tailscale serve' server publicly, open to the entire internet. Turning off Funnel only turns off serving to the internet. It does not affect serving to your tailnet. SUBCOMMANDS status show current serve/funnel status error: path must be absolute error: invalid TCP source "localhost:5432": missing port in address error: invalid TCP source "tcp://somehost:5432" must be one of: localhost or 127.0.0.1 tcp://somehost:5432error: invalid TCP source "tcp://somehost:0" must be one of: localhost or 127.0.0.1 tcp://somehost:0error: invalid TCP source "tcp://somehost:65536" must be one of: localhost or 127.0.0.1 tcp://somehost:65536error: path must be absolute error: cannot serve web; already serving TCP You don't have permission to enable this feature. This also moves the color handling up to a generic spot so it's not just one subcommand doing it itself. See https://github.com/tailscale/tailscale/issues/11626#issuecomment-2041795129 Fixes #11643 Updates #11626 Change-Id: I3a49e659dcbce491f4a2cb784be20bab53f72303 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2 months ago
// colorableOutput returns a colorable writer if stdout is a terminal (not, say,
// redirected to a file or pipe), the Stdout writer is os.Stdout (we're not
// embedding the CLI in wasm or a mobile app), and NO_COLOR is not set (see
// https://no-color.org/). If any of those is not the case, ok is false
// and w is Stdout.
func colorableOutput() (w io.Writer, ok bool) {
if Stdout != os.Stdout ||
os.Getenv("NO_COLOR") != "" ||
!isatty.IsTerminal(os.Stdout.Fd()) {
return Stdout, false
}
return colorable.NewColorableStdout(), true
}