2023-01-27 13:37:20 -08:00
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
2022-03-24 12:22:36 -07:00
package cli
import (
2022-03-25 12:36:46 -07:00
"bytes"
2022-03-24 12:22:36 -07:00
"context"
"errors"
"fmt"
"log"
2022-07-25 20:55:44 -07:00
"net/netip"
2022-03-24 12:22:36 -07:00
"os"
"os/user"
2022-03-25 12:36:46 -07:00
"path/filepath"
2022-03-24 12:22:36 -07:00
"runtime"
"strings"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/envknob"
2022-03-25 12:36:46 -07:00
"tailscale.com/ipn/ipnstate"
2022-06-02 01:14:17 -07:00
"tailscale.com/net/tsaddr"
2022-12-08 13:54:52 -08:00
"tailscale.com/paths"
2022-06-06 08:09:51 -07:00
"tailscale.com/version"
2022-03-24 12:22:36 -07:00
)
var sshCmd = & ffcli . Command {
Name : "ssh" ,
2024-04-04 17:06:09 +01:00
ShortUsage : "tailscale ssh [user@]<host> [args...]" ,
2022-03-24 12:22:36 -07:00
ShortHelp : "SSH to a Tailscale machine" ,
2022-10-30 19:56:19 -07:00
LongHelp : strings . TrimSpace ( `
The ' tailscale ssh ' command is an optional wrapper around the system ' ssh '
command that ' s useful in some cases . Tailscale SSH does not require its use ;
most users running the Tailscale SSH server will prefer to just use the normal
' ssh ' command or their normal SSH client .
The ' tailscale ssh ' wrapper adds a few things :
2022-12-10 00:07:27 -05:00
* It resolves the destination server name in its arguments using MagicDNS ,
2022-10-30 19:56:19 -07:00
even if -- accept - dns = false .
* It works in userspace - networking mode , by supplying a ProxyCommand to the
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 .
` ) ,
Exec : runSSH ,
2022-03-24 12:22:36 -07:00
}
func runSSH ( ctx context . Context , args [ ] string ) error {
2024-03-14 14:28:06 -07:00
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." )
2022-06-06 08:09:51 -07:00
}
2022-03-24 12:22:36 -07:00
if len ( args ) == 0 {
2024-04-17 08:05:04 +01:00
return errors . New ( "usage: tailscale ssh [user@]<host>" )
2022-03-24 12:22:36 -07:00
}
arg , argRest := args [ 0 ] , args [ 1 : ]
username , host , ok := strings . Cut ( arg , "@" )
if ! ok {
host = arg
lu , err := user . Current ( )
if err != nil {
return nil
}
username = lu . Username
}
2022-03-25 14:27:22 -07:00
2022-04-29 11:20:11 -07:00
st , err := localClient . Status ( ctx )
2022-03-25 14:27:22 -07:00
if err != nil {
return err
}
// hostForSSH is the hostname we'll tell OpenSSH we're
// connecting to, so we have to maintain fewer entries in the
// known_hosts files.
hostForSSH := host
if v , ok := nodeDNSNameFromArg ( st , host ) ; ok {
hostForSSH = v
}
2022-06-26 14:26:21 +09:00
ssh , err := findSSH ( )
2022-03-24 12:22:36 -07:00
if err != nil {
// TODO(bradfitz): use Go's crypto/ssh client instead
// of failing. But for now:
return fmt . Errorf ( "no system 'ssh' command found: %w" , err )
}
tailscaleBin , err := os . Executable ( )
if err != nil {
return err
}
2022-03-25 12:36:46 -07:00
knownHostsFile , err := writeKnownHosts ( st )
if err != nil {
return err
}
2022-04-26 06:57:44 -07:00
argv := [ ] string { ssh }
2022-03-24 12:22:36 -07:00
2022-04-26 06:57:44 -07:00
if envknob . Bool ( "TS_DEBUG_SSH_EXEC" ) {
argv = append ( argv , "-vvv" )
}
argv = append ( argv ,
2022-04-18 09:52:52 -07:00
// Only trust SSH hosts that we know about.
2022-04-21 17:29:27 -07:00
"-o" , fmt . Sprintf ( "UserKnownHostsFile %q" , knownHostsFile ) ,
2022-04-18 09:52:52 -07:00
"-o" , "UpdateHostKeys no" ,
"-o" , "StrictHostKeyChecking yes" ,
2023-11-23 09:15:34 +00:00
"-o" , "CanonicalizeHostname no" , // https://github.com/tailscale/tailscale/issues/10348
2022-04-26 06:57:44 -07:00
)
2024-04-07 14:25:11 -07:00
// MagicDNS is usually working on macOS anyway and they're not in userspace
// mode, so 'nc' isn't very useful.
2022-04-26 06:57:44 -07:00
if runtime . GOOS != "darwin" {
2022-12-08 13:54:52 -08:00
socketArg := ""
2024-05-01 16:24:55 +01:00
if localClient . Socket != "" && localClient . Socket != paths . DefaultTailscaledSocket ( ) {
socketArg = fmt . Sprintf ( "--socket=%q" , localClient . Socket )
2022-12-08 13:54:52 -08:00
}
2022-04-26 06:57:44 -07:00
argv = append ( argv ,
2022-12-08 13:54:52 -08:00
"-o" , fmt . Sprintf ( "ProxyCommand %q %s nc %%h %%p" ,
2022-04-26 06:57:44 -07:00
tailscaleBin ,
2022-12-08 13:54:52 -08:00
socketArg ,
2022-04-26 06:57:44 -07:00
) )
}
// Explicitly rebuild the user@host argument rather than
// passing it through. In general, the use of OpenSSH's ssh
// binary is a crutch for now. We don't want to be
// Hyrum-locked into passing through all OpenSSH flags to the
// OpenSSH client forever. We try to make our flags and args
// be compatible, but only a subset. The "tailscale ssh"
// command should be a simple and portable one. If they want
// to use a different one, we'll later be making stock ssh
// work well by default too. (doing things like automatically
// setting known_hosts, etc)
argv = append ( argv , username + "@" + hostForSSH )
2022-04-18 09:52:52 -07:00
2022-04-26 06:57:44 -07:00
argv = append ( argv , argRest ... )
if envknob . Bool ( "TS_DEBUG_SSH_EXEC" ) {
log . Printf ( "Running: %q, %q ..." , ssh , argv )
}
2022-05-19 17:40:01 -07:00
return execSSH ( ssh , argv )
2022-03-24 12:22:36 -07:00
}
2022-03-25 12:36:46 -07:00
func writeKnownHosts ( st * ipnstate . Status ) ( knownHostsFile string , err error ) {
confDir , err := os . UserConfigDir ( )
if err != nil {
return "" , err
}
tsConfDir := filepath . Join ( confDir , "tailscale" )
if err := os . MkdirAll ( tsConfDir , 0700 ) ; err != nil {
return "" , err
}
knownHostsFile = filepath . Join ( tsConfDir , "ssh_known_hosts" )
want := genKnownHosts ( st )
if cur , err := os . ReadFile ( knownHostsFile ) ; err != nil || ! bytes . Equal ( cur , want ) {
if err := os . WriteFile ( knownHostsFile , want , 0644 ) ; err != nil {
return "" , err
}
}
return knownHostsFile , nil
}
func genKnownHosts ( st * ipnstate . Status ) [ ] byte {
var buf bytes . Buffer
for _ , k := range st . Peers ( ) {
ps := st . Peer [ k ]
2022-03-25 14:27:22 -07:00
for _ , hk := range ps . SSH_HostKeys {
hostKey := strings . TrimSpace ( hk )
if strings . ContainsAny ( hostKey , "\n\r" ) { // invalid
continue
}
fmt . Fprintf ( & buf , "%s %s\n" , ps . DNSName , hostKey )
2022-03-25 12:36:46 -07:00
}
2022-03-25 14:27:22 -07:00
}
return buf . Bytes ( )
}
// 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
}
2022-07-25 20:55:44 -07:00
argIP , _ := netip . ParseAddr ( arg )
2022-03-25 14:27:22 -07:00
for _ , ps := range st . Peer {
dnsName = ps . DNSName
2022-07-24 20:08:42 -07:00
if argIP . IsValid ( ) {
2022-03-25 14:27:22 -07:00
for _ , ip := range ps . TailscaleIPs {
if ip == argIP {
return dnsName , true
2022-03-25 12:36:46 -07:00
}
}
2022-03-25 14:27:22 -07:00
continue
2022-03-25 12:36:46 -07:00
}
2022-03-25 14:27:22 -07:00
if strings . EqualFold ( strings . TrimSuffix ( arg , "." ) , strings . TrimSuffix ( dnsName , "." ) ) {
return dnsName , true
2022-03-25 12:36:46 -07:00
}
2022-03-25 14:27:22 -07:00
if base , _ , ok := strings . Cut ( ps . DNSName , "." ) ; ok && strings . EqualFold ( base , arg ) {
return dnsName , true
2022-03-25 12:36:46 -07:00
}
}
2022-03-25 14:27:22 -07:00
return "" , false
2022-03-25 12:36:46 -07:00
}
2022-06-02 01:14:17 -07:00
// getSSHClientEnvVar returns the "SSH_CLIENT" environment variable
// for the current process group, if any.
var getSSHClientEnvVar = func ( ) string {
return ""
}
// isSSHOverTailscale checks if the invocation is in a SSH session over Tailscale.
// It is used to detect if the user is about to take an action that might result in them
// disconnecting from the machine (e.g. disabling SSH)
func isSSHOverTailscale ( ) bool {
sshClient := getSSHClientEnvVar ( )
if sshClient == "" {
return false
}
ipStr , _ , ok := strings . Cut ( sshClient , " " )
if ! ok {
return false
}
2022-07-25 20:55:44 -07:00
ip , err := netip . ParseAddr ( ipStr )
2022-06-02 01:14:17 -07:00
if err != nil {
return false
}
return tsaddr . IsTailscaleIP ( ip )
}