mirror of
https://github.com/tailscale/tailscale.git
synced 2025-03-28 12:02:23 +00:00
Merge 1c44dbe8730ea80c92584cc4ec5e249aa8077e07 into b3455fa99a5e8d07133d5140017ec7c49f032a07
This commit is contained in:
commit
cc2a970e90
175
cmd/tailscale/cli/configure-ssh.go
Normal file
175
cmd/tailscale/cli/configure-ssh.go
Normal file
@ -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
|
||||
}
|
@ -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@]<host>")
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
@ -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 {
|
||||
|
Loading…
x
Reference in New Issue
Block a user