diff --git a/cmd/tailscale/cli/configure-ssh.go b/cmd/tailscale/cli/configure-ssh.go new file mode 100644 index 000000000..d98e502b8 --- /dev/null +++ b/cmd/tailscale/cli/configure-ssh.go @@ -0,0 +1,175 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package cli + +import ( + "context" + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/peterbourgon/ff/v3/ffcli" + "tailscale.com/util/lineiter" + "tailscale.com/version" +) + +const tsConfigStartMark = "## BEGIN Tailscale ##" +const tsConfigEndMark = "## END Tailscale ##" + +func init() { + + longHelp := strings.TrimSpace(` +Run this command to add a snippet to your $HOME/.ssh/config file that will use +Tailscale to check for KnownHosts.`) + + d := false + + if version.IsSandboxedMacOS() { + longHelp = longHelp + ` + +On MacOS sandboxed apps the output will be displayed on stdout instead of +modifying the file in place. You can redirect the output to the file manually. +tailscale configure sshconfig >> $HOME/.ssh/config` + + d = true + + } + configureSSHconfigCmd := &ffcli.Command{ + Name: "sshconfig", + ShortHelp: "[ALPHA] Configure $HOME/.ssh/config to check Tailscale for KnownHosts", + ShortUsage: "tailscale configure sshconfig >> $HOME/.ssh/config", + LongHelp: longHelp, + FlagSet: (func() *flag.FlagSet { + fs := newFlagSet("sshconfig") + fs.BoolVar(&sshConfigArgs.display, "display", d, "Display the config snippet on stdout instead of modifying the file in place") + return fs + })(), + Exec: runConfigureSSHconfig, + } + configureCmd.Subcommands = append(configureCmd.Subcommands, configureSSHconfigCmd) +} + +var sshConfigArgs struct { + display bool // display the config snippet on stdout or modify in place +} + +// findConfigMark finds and returns the index of the tsConfigStartMark and +// tsConfigEndmark in a file. If the file doesn't contain the marks, it returns +// -1, -1 +func findConfigMark(file []string) (int, int) { + start := -1 + end := -1 + for i, v := range file { + if strings.Contains(v, tsConfigStartMark) { + start = i + } + if strings.Contains(v, tsConfigEndMark) { + end = i + } + } + + return start, end +} + +// replaceBetweenConfigMark replaces the lines between the tsConfigStartMark and +// tsConfigEndMark with the replacement string. If the marks are not present, it +// returns the original slice. +func replaceBetweenConfigMark(s []string, replacement string, start, end int) []string { + if start == -1 || end == -1 { + return s + } + n := append(s[:start+1], replacement, tsConfigEndMark) + n = append(n, s[end+1:]...) + return n +} + +// runConfigureSSHconfig updates the user's $HOME/.ssh/config file to add the +// Tailscale config snippet. If the snippet is not present, it will be appended +// between the BEGIN and END marks. If it is present it will be updated if needed. +func runConfigureSSHconfig(ctx context.Context, args []string) error { + if len(args) > 0 { + return errors.New("unexpected non-flag arguments to 'tailscale status'") + } + tailscaleBin, err := os.Executable() + if err != nil { + return err + } + st, err := localClient.Status(ctx) + if err != nil { + return err + } + + tsSshConfig, err := genSSHConfig(st, tailscaleBin) + if err != nil { + return err + } + h, err := os.UserHomeDir() + if err != nil { + return err + } + + if !sshConfigArgs.display { + sshConfigFilePath := filepath.Join(h, ".ssh", "config") + var sshConfig []string + + // Create the file if it does not exist + _, err = os.OpenFile(sshConfigFilePath, os.O_RDONLY|os.O_CREATE, 0644) + if err != nil { + return err + } + + for lr := range lineiter.File(sshConfigFilePath) { + line, err := lr.Value() + if err != nil { + return err + } + sshConfig = append(sshConfig, string(line)) + } + + start, end := findConfigMark(sshConfig) + if start > end { + return fmt.Errorf(strings.TrimSpace(` +Invalid config file. Start mark is after end mark. Please ensure that the +following is in your ~/.ssh/config file: + +%s +%s +%s`), + tsConfigStartMark, tsSshConfig, tsConfigEndMark) + + } + if start == -1 || end == -1 { + sshConfig = append(sshConfig, tsConfigStartMark) + sshConfig = append(sshConfig, tsSshConfig) + sshConfig = append(sshConfig, tsConfigEndMark) + } else { + existingConfig := strings.Join(sshConfig[start+1:end], "\n") + if existingConfig != tsSshConfig { + sshConfig = replaceBetweenConfigMark(sshConfig, tsSshConfig, start, end) + } + } + + sshFile, err := os.Create(sshConfigFilePath) + if err != nil { + return err + + } + defer sshFile.Close() + + for _, line := range sshConfig { + _, err := sshFile.WriteString(line + "\n") + if err != nil { + return err + } + } + fmt.Printf("Updated %s\n", sshConfigFilePath) + } else { + fmt.Println(tsSshConfig) + } + + return nil +} diff --git a/cmd/tailscale/cli/ssh.go b/cmd/tailscale/cli/ssh.go index ba70e97e9..f2e146dcb 100644 --- a/cmd/tailscale/cli/ssh.go +++ b/cmd/tailscale/cli/ssh.go @@ -7,10 +7,12 @@ import ( "bytes" "context" "errors" + "flag" "fmt" "log" "net/netip" "os" + "os/exec" "os/user" "path/filepath" "runtime" @@ -43,14 +45,28 @@ The 'tailscale ssh' wrapper adds a few things: system 'ssh' command that connects via a pipe through tailscaled. * It automatically checks the destination server's SSH host key against the node's SSH host key as advertised via the Tailscale coordination server. + + +Tailscale can also be integrated with the system 'ssh' and related commands +by using the --config flag. This will output an SSH config snippet that can +be added to your ~/.ssh/config file to enable Tailscale for all SSH connections. `), Exec: runSSH, + FlagSet: (func() *flag.FlagSet { + fs := newFlagSet("ssh") + return fs + })(), } func runSSH(ctx context.Context, args []string) error { if runtime.GOOS == "darwin" && version.IsMacAppStore() && !envknob.UseWIPCode() { - return errors.New("The 'tailscale ssh' subcommand is not available on macOS builds distributed through the App Store or TestFlight.\nInstall the Standalone variant of Tailscale (download it from https://pkgs.tailscale.com), or use the regular 'ssh' client instead.") + return errors.New(strings.TrimSpace(` +The 'tailscale ssh' subcommand is not available on macOS builds distributed through the App Store or TestFlight. +Install the Standalone variant of Tailscale (download it from https://pkgs.tailscale.com). +Or run tailscale configure sshconfig --display >> ~/.ssh/config to use the regular 'ssh' client instead. +`)) } + if len(args) == 0 { return errors.New("usage: tailscale ssh [user@]") } @@ -150,7 +166,7 @@ func writeKnownHosts(st *ipnstate.Status) (knownHostsFile string, err error) { return "", err } knownHostsFile = filepath.Join(tsConfDir, "ssh_known_hosts") - want := genKnownHosts(st) + want := genKnownHostsFile(st) if cur, err := os.ReadFile(knownHostsFile); err != nil || !bytes.Equal(cur, want) { if err := os.WriteFile(knownHostsFile, want, 0644); err != nil { return "", err @@ -159,7 +175,40 @@ func writeKnownHosts(st *ipnstate.Status) (knownHostsFile string, err error) { return knownHostsFile, nil } -func genKnownHosts(st *ipnstate.Status) []byte { +// sshSupportsKnownHostsCommand reports whether the OpenSSH binary at sshBin +// supports the KnownHostsCommand option. +func sshSupportsKnownHostsCommand(sshBin string) bool { + _, err := exec.Command(sshBin, "-G", "-o", "KnownHostsCommand=true", "127.0.0.1").Output() + if err != nil { + // If the command errored then it doesn't support KnownHostsCommand. + return false + } + return true +} + +// genKnownHostsOption generates either a UserKnownHostsFile or KnownHostsCommand option +// based on the OpenSSH version. If the version doesn't support the KnownHostsCommand, +// it will return a UserKnownHostsFile option, otherwise it will return a KnownHostsCommand. +func genKnownHostsOption(st *ipnstate.Status, tailscaleBin string) (string, error) { + // OpenSSH added the KnownHostsCommand option in 8.4, this is more flexible than + // the UserKnownHostsFile option and allows using the system 'ssh' command on MacOs. + // But we need to support older versions of OpenSSH so we fallback to the UserKnownHostsFile + ssh, err := findSSH() + if err != nil { + return "", err + } + + if sshSupportsKnownHostsCommand(ssh) { + return fmt.Sprintf(`KnownHostsCommand %s status --ssh-host-keys`, tailscaleBin), nil + } + knownhostsFile, err := writeKnownHosts(st) + if err != nil { + return "", err + } + return fmt.Sprintf(`UserKnownHostsFile %s`, knownhostsFile), nil +} + +func genKnownHostsFile(st *ipnstate.Status) []byte { var buf bytes.Buffer for _, k := range st.Peers() { ps := st.Peer[k] @@ -168,38 +217,70 @@ func genKnownHosts(st *ipnstate.Status) []byte { if strings.ContainsAny(hostKey, "\n\r") { // invalid continue } - fmt.Fprintf(&buf, "%s %s\n", ps.DNSName, hostKey) + // Join all ps.TailscaleIPs as strings separated by commas. + ips := make([]string, len(ps.TailscaleIPs)) + for i, ip := range ps.TailscaleIPs { + ips[i] = ip.String() + } + // Generate comma separated string of all possible names for the host. + n := strings.Join(append(ips, ps.DNSName, strings.TrimSuffix(ps.DNSName, "."), strings.Split(ps.DNSName, ".")[0]), ",") + fmt.Fprintf(&buf, "%s %s\n", n, hostKey) } } return buf.Bytes() } +// genSSHConfig generates an SSH config snippet that can be used to integrate Tailscale +// with the system 'ssh' command. +func genSSHConfig(st *ipnstate.Status, tailscaleBin string) (string, error) { + knownHostsOption, err := genKnownHostsOption(st, tailscaleBin) + if err != nil { + return "", err + } + return fmt.Sprintf(` +# Tailscale ssh config +Match exec "%s status --check-ssh-host %%h" + %s + UpdateHostKeys no + StrictHostKeyChecking yes +`, tailscaleBin, knownHostsOption), nil +} + +// nodeFromArg returns the PeerStatus value from a peer in st that matches the input arg +// which can be a base name, full DNS name, or an IP. +func nodeFromArg(st *ipnstate.Status, arg string) (ps *ipnstate.PeerStatus, ok bool) { + if arg == "" { + return + } + argIP, _ := netip.ParseAddr(arg) + for _, ps = range st.Peer { + if argIP.IsValid() { + for _, ip := range ps.TailscaleIPs { + if ip == argIP { + return ps, true + } + } + continue + } + if strings.EqualFold(strings.TrimSuffix(arg, "."), strings.TrimSuffix(ps.DNSName, ".")) { + return ps, true + } + if base, _, ok := strings.Cut(ps.DNSName, "."); ok && strings.EqualFold(base, arg) { + return ps, true + } + } + return nil, false +} + // nodeDNSNameFromArg returns the PeerStatus.DNSName value from a peer // in st that matches the input arg which can be a base name, full // DNS name, or an IP. func nodeDNSNameFromArg(st *ipnstate.Status, arg string) (dnsName string, ok bool) { - if arg == "" { - return + ps, ok := nodeFromArg(st, arg) + if !ok { + return "", false } - argIP, _ := netip.ParseAddr(arg) - for _, ps := range st.Peer { - dnsName = ps.DNSName - if argIP.IsValid() { - for _, ip := range ps.TailscaleIPs { - if ip == argIP { - return dnsName, true - } - } - continue - } - if strings.EqualFold(strings.TrimSuffix(arg, "."), strings.TrimSuffix(dnsName, ".")) { - return dnsName, true - } - if base, _, ok := strings.Cut(ps.DNSName, "."); ok && strings.EqualFold(base, arg) { - return dnsName, true - } - } - return "", false + return ps.DNSName, true } // getSSHClientEnvVar returns the "SSH_CLIENT" environment variable @@ -226,3 +307,10 @@ func isSSHOverTailscale() bool { } return tsaddr.IsTailscaleIP(ip) } + +// isSSHHost reports whether the node describe somehow by arg in st has its SSH +// is managed by Tailscale. +func isSSHHost(st *ipnstate.Status, arg string) bool { + ps, ok := nodeFromArg(st, arg) + return ok && len(ps.SSH_HostKeys) > 0 +} diff --git a/cmd/tailscale/cli/status.go b/cmd/tailscale/cli/status.go index e4dccc247..a58f7dd5b 100644 --- a/cmd/tailscale/cli/status.go +++ b/cmd/tailscale/cli/status.go @@ -56,18 +56,22 @@ https://github.com/tailscale/tailscale/blob/main/ipn/ipnstate/ipnstate.go 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") + fs.StringVar(&statusArgs.checkSSHHost, "check-ssh-host", "", "check if a host's SSH is managed by Tailscale. Exits with 0 if managed, 1 if not.") + fs.BoolVar(&statusArgs.sshHostKeys, "ssh-host-keys", false, "show SSH host keys for hosts managed by Tailscale") 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 + 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 + checkSSHHost string // check if this host's SSH is managed by Tailscale + sshHostKeys bool // output the known hosts file } func runStatus(ctx context.Context, args []string) error { @@ -130,6 +134,30 @@ func runStatus(ctx context.Context, args []string) error { return err } + if statusArgs.checkSSHHost != "" { + if isSSHHost(st, statusArgs.checkSSHHost) { + // If the host SSH is managed by Tailscale, we anticipate that it + // we may need to update the known_hosts file for a subsequent SSH + // connection. + ssh, _ := findSSH() + if ssh != "" { + if !sshSupportsKnownHostsCommand(ssh) { + _, err := writeKnownHosts(st) + if err != nil { + return err + } + } + } + return nil + } else { + return fmt.Errorf("Host %s's SSH is not managed by Tailscale", statusArgs.checkSSHHost) + } + } + if statusArgs.sshHostKeys { + fmt.Print(string(genKnownHostsFile(st))) + return nil + } + printHealth := func() { printf("# Health check:\n") for _, m := range st.Health {