Merge 1c44dbe8730ea80c92584cc4ec5e249aa8077e07 into b3455fa99a5e8d07133d5140017ec7c49f032a07

This commit is contained in:
Dan Mills 2025-03-24 18:23:09 -07:00 committed by GitHub
commit cc2a970e90
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 323 additions and 32 deletions

View 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
}

View File

@ -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
}

View File

@ -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 {