mirror of
https://github.com/tailscale/tailscale.git
synced 2025-04-03 14:55:47 +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"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"os/user"
|
"os/user"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
@ -43,14 +45,28 @@ The 'tailscale ssh' wrapper adds a few things:
|
|||||||
system 'ssh' command that connects via a pipe through tailscaled.
|
system 'ssh' command that connects via a pipe through tailscaled.
|
||||||
* It automatically checks the destination server's SSH host key against the
|
* It automatically checks the destination server's SSH host key against the
|
||||||
node's SSH host key as advertised via the Tailscale coordination server.
|
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,
|
Exec: runSSH,
|
||||||
|
FlagSet: (func() *flag.FlagSet {
|
||||||
|
fs := newFlagSet("ssh")
|
||||||
|
return fs
|
||||||
|
})(),
|
||||||
}
|
}
|
||||||
|
|
||||||
func runSSH(ctx context.Context, args []string) error {
|
func runSSH(ctx context.Context, args []string) error {
|
||||||
if runtime.GOOS == "darwin" && version.IsMacAppStore() && !envknob.UseWIPCode() {
|
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 {
|
if len(args) == 0 {
|
||||||
return errors.New("usage: tailscale ssh [user@]<host>")
|
return errors.New("usage: tailscale ssh [user@]<host>")
|
||||||
}
|
}
|
||||||
@ -150,7 +166,7 @@ func writeKnownHosts(st *ipnstate.Status) (knownHostsFile string, err error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
knownHostsFile = filepath.Join(tsConfDir, "ssh_known_hosts")
|
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 cur, err := os.ReadFile(knownHostsFile); err != nil || !bytes.Equal(cur, want) {
|
||||||
if err := os.WriteFile(knownHostsFile, want, 0644); err != nil {
|
if err := os.WriteFile(knownHostsFile, want, 0644); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@ -159,7 +175,40 @@ func writeKnownHosts(st *ipnstate.Status) (knownHostsFile string, err error) {
|
|||||||
return knownHostsFile, nil
|
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
|
var buf bytes.Buffer
|
||||||
for _, k := range st.Peers() {
|
for _, k := range st.Peers() {
|
||||||
ps := st.Peer[k]
|
ps := st.Peer[k]
|
||||||
@ -168,38 +217,70 @@ func genKnownHosts(st *ipnstate.Status) []byte {
|
|||||||
if strings.ContainsAny(hostKey, "\n\r") { // invalid
|
if strings.ContainsAny(hostKey, "\n\r") { // invalid
|
||||||
continue
|
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()
|
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
|
// nodeDNSNameFromArg returns the PeerStatus.DNSName value from a peer
|
||||||
// in st that matches the input arg which can be a base name, full
|
// in st that matches the input arg which can be a base name, full
|
||||||
// DNS name, or an IP.
|
// DNS name, or an IP.
|
||||||
func nodeDNSNameFromArg(st *ipnstate.Status, arg string) (dnsName string, ok bool) {
|
func nodeDNSNameFromArg(st *ipnstate.Status, arg string) (dnsName string, ok bool) {
|
||||||
if arg == "" {
|
ps, ok := nodeFromArg(st, arg)
|
||||||
return
|
if !ok {
|
||||||
|
return "", false
|
||||||
}
|
}
|
||||||
argIP, _ := netip.ParseAddr(arg)
|
return ps.DNSName, true
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSSHClientEnvVar returns the "SSH_CLIENT" environment variable
|
// getSSHClientEnvVar returns the "SSH_CLIENT" environment variable
|
||||||
@ -226,3 +307,10 @@ func isSSHOverTailscale() bool {
|
|||||||
}
|
}
|
||||||
return tsaddr.IsTailscaleIP(ip)
|
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.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.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.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
|
return fs
|
||||||
})(),
|
})(),
|
||||||
}
|
}
|
||||||
|
|
||||||
var statusArgs struct {
|
var statusArgs struct {
|
||||||
json bool // JSON output mode
|
json bool // JSON output mode
|
||||||
web bool // run webserver
|
web bool // run webserver
|
||||||
listen string // in web mode, webserver address to listen on, empty means auto
|
listen string // in web mode, webserver address to listen on, empty means auto
|
||||||
browser bool // in web mode, whether to open browser
|
browser bool // in web mode, whether to open browser
|
||||||
active bool // in CLI mode, filter output to only peers with active sessions
|
active bool // in CLI mode, filter output to only peers with active sessions
|
||||||
self bool // in CLI mode, show status of local machine
|
self bool // in CLI mode, show status of local machine
|
||||||
peers bool // in CLI mode, show status of peer machines
|
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 {
|
func runStatus(ctx context.Context, args []string) error {
|
||||||
@ -130,6 +134,30 @@ func runStatus(ctx context.Context, args []string) error {
|
|||||||
return err
|
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() {
|
printHealth := func() {
|
||||||
printf("# Health check:\n")
|
printf("# Health check:\n")
|
||||||
for _, m := range st.Health {
|
for _, m := range st.Health {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user