2015-06-17 16:39:49 +00:00
|
|
|
package command
|
|
|
|
|
|
|
|
import (
|
2016-05-25 19:02:31 +00:00
|
|
|
"encoding/json"
|
2015-06-17 16:39:49 +00:00
|
|
|
"fmt"
|
2015-06-18 00:33:03 +00:00
|
|
|
"io/ioutil"
|
2015-06-24 22:13:12 +00:00
|
|
|
"net"
|
2015-06-18 00:33:03 +00:00
|
|
|
"os"
|
|
|
|
"os/exec"
|
2015-07-29 18:21:36 +00:00
|
|
|
"os/user"
|
2015-06-17 16:39:49 +00:00
|
|
|
"strings"
|
2015-07-02 23:00:14 +00:00
|
|
|
|
2015-07-23 21:20:28 +00:00
|
|
|
"github.com/hashicorp/vault/builtin/logical/ssh"
|
2016-04-01 17:16:05 +00:00
|
|
|
"github.com/hashicorp/vault/meta"
|
2015-08-18 01:22:03 +00:00
|
|
|
"github.com/mitchellh/mapstructure"
|
2015-06-17 16:39:49 +00:00
|
|
|
)
|
|
|
|
|
2015-07-27 20:42:03 +00:00
|
|
|
// SSHCommand is a Command that establishes a SSH connection
|
|
|
|
// with target by generating a dynamic key
|
2015-07-01 15:58:49 +00:00
|
|
|
type SSHCommand struct {
|
2016-04-01 17:16:05 +00:00
|
|
|
meta.Meta
|
2015-06-17 16:39:49 +00:00
|
|
|
}
|
|
|
|
|
2015-08-18 01:22:03 +00:00
|
|
|
// Structure to hold the fields returned when asked for a credential from SSHh backend.
|
|
|
|
type SSHCredentialResp struct {
|
|
|
|
KeyType string `mapstructure:"key_type"`
|
|
|
|
Key string `mapstructure:"key"`
|
|
|
|
Username string `mapstructure:"username"`
|
|
|
|
IP string `mapstructure:"ip"`
|
2016-05-25 19:02:31 +00:00
|
|
|
Port string `mapstructure:"port"`
|
2015-08-18 01:22:03 +00:00
|
|
|
}
|
|
|
|
|
2015-07-01 15:58:49 +00:00
|
|
|
func (c *SSHCommand) Run(args []string) int {
|
2016-05-30 05:26:51 +00:00
|
|
|
var role, mountPoint, format, userKnownHostsFile, strictHostKeyChecking string
|
2015-07-29 18:21:36 +00:00
|
|
|
var noExec bool
|
2015-07-23 21:20:28 +00:00
|
|
|
var sshCmdArgs []string
|
2016-04-01 17:16:05 +00:00
|
|
|
flags := c.Meta.FlagSet("ssh", meta.FlagSetDefault)
|
2016-06-01 15:26:19 +00:00
|
|
|
flags.StringVar(&strictHostKeyChecking, "strict-host-key-checking", "", "")
|
|
|
|
flags.StringVar(&userKnownHostsFile, "user-known-hosts-file", "", "")
|
2015-08-13 23:55:47 +00:00
|
|
|
flags.StringVar(&format, "format", "table", "")
|
2015-06-26 18:08:03 +00:00
|
|
|
flags.StringVar(&role, "role", "", "")
|
2015-08-12 17:30:50 +00:00
|
|
|
flags.StringVar(&mountPoint, "mount-point", "ssh", "")
|
2015-07-29 18:21:36 +00:00
|
|
|
flags.BoolVar(&noExec, "no-exec", false, "")
|
|
|
|
|
2015-06-17 16:39:49 +00:00
|
|
|
flags.Usage = func() { c.Ui.Error(c.Help()) }
|
|
|
|
if err := flags.Parse(args); err != nil {
|
|
|
|
return 1
|
|
|
|
}
|
2016-06-01 03:31:53 +00:00
|
|
|
|
2016-06-01 15:26:19 +00:00
|
|
|
// If the flag is already set then it takes the precedence. If the flag is not
|
|
|
|
// set, try setting it from env var.
|
|
|
|
if os.Getenv("VAULT_SSH_STRICT_HOST_KEY_CHECKING") != "" && strictHostKeyChecking == "" {
|
|
|
|
strictHostKeyChecking = os.Getenv("VAULT_SSH_STRICT_HOST_KEY_CHECKING")
|
|
|
|
}
|
|
|
|
// Assign default value if both flag and env var are not set
|
|
|
|
if strictHostKeyChecking == "" {
|
|
|
|
strictHostKeyChecking = "ask"
|
2016-06-01 03:31:53 +00:00
|
|
|
}
|
|
|
|
|
2016-06-01 15:26:19 +00:00
|
|
|
// If the flag is already set then it takes the precedence. If the flag is not
|
|
|
|
// set, try setting it from env var.
|
|
|
|
if os.Getenv("VAULT_SSH_USER_KNOWN_HOSTS_FILE") != "" && userKnownHostsFile == "" {
|
|
|
|
userKnownHostsFile = os.Getenv("VAULT_SSH_USER_KNOWN_HOSTS_FILE")
|
|
|
|
}
|
|
|
|
// Assign default value if both flag and env var are not set
|
|
|
|
if userKnownHostsFile == "" {
|
|
|
|
userKnownHostsFile = "~/.ssh/known_hosts"
|
2016-06-01 03:31:53 +00:00
|
|
|
}
|
|
|
|
|
2015-06-26 18:08:03 +00:00
|
|
|
args = flags.Args()
|
|
|
|
if len(args) < 1 {
|
|
|
|
c.Ui.Error("ssh expects at least one argument")
|
2015-08-19 02:00:27 +00:00
|
|
|
return 1
|
2015-06-26 18:08:03 +00:00
|
|
|
}
|
2015-06-30 02:00:08 +00:00
|
|
|
|
2015-06-17 16:39:49 +00:00
|
|
|
client, err := c.Client()
|
|
|
|
if err != nil {
|
2016-10-18 16:46:54 +00:00
|
|
|
c.Ui.Error(fmt.Sprintf("Error initializing client: %v", err))
|
2015-08-19 02:00:27 +00:00
|
|
|
return 1
|
2015-06-17 16:39:49 +00:00
|
|
|
}
|
2015-07-27 20:42:03 +00:00
|
|
|
|
2015-08-13 23:55:47 +00:00
|
|
|
// split the parameter username@ip
|
2015-06-24 22:13:12 +00:00
|
|
|
input := strings.Split(args[0], "@")
|
2015-07-29 18:21:36 +00:00
|
|
|
var username string
|
|
|
|
var ipAddr string
|
2015-08-13 23:55:47 +00:00
|
|
|
|
|
|
|
// If only IP is mentioned and username is skipped, assume username to
|
|
|
|
// be the current username. Vault SSH role's default username could have
|
|
|
|
// been used, but in order to retain the consistency with SSH command,
|
|
|
|
// current username is employed.
|
2015-07-29 18:21:36 +00:00
|
|
|
if len(input) == 1 {
|
|
|
|
u, err := user.Current()
|
|
|
|
if err != nil {
|
2016-10-18 16:46:54 +00:00
|
|
|
c.Ui.Error(fmt.Sprintf("Error fetching username: %v", err))
|
2015-12-26 22:09:07 +00:00
|
|
|
return 1
|
2015-07-29 18:21:36 +00:00
|
|
|
}
|
|
|
|
username = u.Username
|
|
|
|
ipAddr = input[0]
|
|
|
|
} else if len(input) == 2 {
|
|
|
|
username = input[0]
|
|
|
|
ipAddr = input[1]
|
|
|
|
} else {
|
2016-10-18 16:46:54 +00:00
|
|
|
c.Ui.Error(fmt.Sprintf("Invalid parameter: %q", args[0]))
|
2015-08-19 02:00:27 +00:00
|
|
|
return 1
|
2015-07-27 20:42:03 +00:00
|
|
|
}
|
|
|
|
|
2015-08-13 23:55:47 +00:00
|
|
|
// Resolving domain names to IP address on the client side.
|
2016-05-15 16:58:36 +00:00
|
|
|
// Vault only deals with IP addresses.
|
2015-07-29 18:21:36 +00:00
|
|
|
ip, err := net.ResolveIPAddr("ip", ipAddr)
|
2015-06-30 02:00:08 +00:00
|
|
|
if err != nil {
|
2016-10-18 16:46:54 +00:00
|
|
|
c.Ui.Error(fmt.Sprintf("Error resolving IP Address: %v", err))
|
2015-08-19 02:00:27 +00:00
|
|
|
return 1
|
2015-06-30 02:00:08 +00:00
|
|
|
}
|
|
|
|
|
2015-08-13 23:55:47 +00:00
|
|
|
// Credentials are generated only against a registered role. If user
|
|
|
|
// does not specify a role with the SSH command, then lookup API is used
|
|
|
|
// to fetch all the roles with which this IP is associated. If there is
|
|
|
|
// only one role associated with it, use it to establish the connection.
|
2015-06-30 02:00:08 +00:00
|
|
|
if role == "" {
|
2015-08-12 17:30:50 +00:00
|
|
|
role, err = c.defaultRole(mountPoint, ip.String())
|
2015-06-30 02:00:08 +00:00
|
|
|
if err != nil {
|
2016-10-18 16:46:54 +00:00
|
|
|
c.Ui.Error(fmt.Sprintf("Error choosing role: %v", err))
|
2015-06-30 02:00:08 +00:00
|
|
|
return 1
|
|
|
|
}
|
2015-08-13 23:55:47 +00:00
|
|
|
// Print the default role chosen so that user knows the role name
|
|
|
|
// if something doesn't work. If the role chosen is not allowed to
|
|
|
|
// be used by the user (ACL enforcement), then user should see an
|
|
|
|
// error message accordingly.
|
2016-10-18 16:46:54 +00:00
|
|
|
c.Ui.Output(fmt.Sprintf("Vault SSH: Role: %q", role))
|
2015-06-30 02:00:08 +00:00
|
|
|
}
|
|
|
|
|
2015-06-24 22:13:12 +00:00
|
|
|
data := map[string]interface{}{
|
|
|
|
"username": username,
|
2015-06-30 02:00:08 +00:00
|
|
|
"ip": ip.String(),
|
2015-06-24 22:13:12 +00:00
|
|
|
}
|
2015-07-27 20:42:03 +00:00
|
|
|
|
2015-08-12 17:30:50 +00:00
|
|
|
keySecret, err := client.SSHWithMountPoint(mountPoint).Credential(role, data)
|
2015-06-17 16:39:49 +00:00
|
|
|
if err != nil {
|
2016-10-18 16:46:54 +00:00
|
|
|
c.Ui.Error(fmt.Sprintf("Error getting key for SSH session: %v", err))
|
2015-08-19 02:00:27 +00:00
|
|
|
return 1
|
2015-06-17 16:39:49 +00:00
|
|
|
}
|
2015-06-30 02:00:08 +00:00
|
|
|
|
2015-08-13 23:55:47 +00:00
|
|
|
// if no-exec was chosen, just print out the secret and return.
|
2015-07-29 18:21:36 +00:00
|
|
|
if noExec {
|
2015-08-13 23:55:47 +00:00
|
|
|
return OutputSecret(c.Ui, format, keySecret)
|
2015-07-29 18:21:36 +00:00
|
|
|
}
|
|
|
|
|
2016-05-25 19:02:31 +00:00
|
|
|
// Port comes back as a json.Number which mapstructure doesn't like, so convert it
|
2016-05-25 21:26:32 +00:00
|
|
|
if keySecret.Data["port"] != nil {
|
|
|
|
keySecret.Data["port"] = keySecret.Data["port"].(json.Number).String()
|
|
|
|
}
|
2015-08-18 01:22:03 +00:00
|
|
|
var resp SSHCredentialResp
|
|
|
|
if err := mapstructure.Decode(keySecret.Data, &resp); err != nil {
|
2016-10-18 16:46:54 +00:00
|
|
|
c.Ui.Error(fmt.Sprintf("Error parsing the credential response: %v", err))
|
2015-08-18 01:22:03 +00:00
|
|
|
return 1
|
|
|
|
}
|
|
|
|
|
|
|
|
if resp.KeyType == ssh.KeyTypeDynamic {
|
|
|
|
if len(resp.Key) == 0 {
|
2015-07-23 21:20:28 +00:00
|
|
|
c.Ui.Error(fmt.Sprintf("Invalid key"))
|
2015-08-19 02:00:27 +00:00
|
|
|
return 1
|
2015-07-23 21:20:28 +00:00
|
|
|
}
|
2016-10-18 16:46:54 +00:00
|
|
|
sshDynamicKeyFile, err := ioutil.TempFile("", fmt.Sprintf("vault_ssh_%s_%s_", username, ip.String()))
|
|
|
|
if err != nil {
|
|
|
|
c.Ui.Error(fmt.Sprintf("Error creating temporary file: %v", err))
|
|
|
|
return 1
|
|
|
|
}
|
|
|
|
|
|
|
|
// Ensure that we delete the temporary file
|
|
|
|
defer os.Remove(sshDynamicKeyFile.Name())
|
|
|
|
|
|
|
|
if err = ioutil.WriteFile(sshDynamicKeyFile.Name(),
|
|
|
|
[]byte(resp.Key), 0600); err != nil {
|
|
|
|
c.Ui.Error(fmt.Sprintf("Error storing the dynamic key into the temporary file: %v", err))
|
|
|
|
return 1
|
|
|
|
}
|
|
|
|
sshCmdArgs = append(sshCmdArgs, []string{"-i", sshDynamicKeyFile.Name()}...)
|
2015-07-02 23:00:14 +00:00
|
|
|
|
2015-08-18 01:22:03 +00:00
|
|
|
} else if resp.KeyType == ssh.KeyTypeOTP {
|
2015-08-13 23:55:47 +00:00
|
|
|
// Check if the application 'sshpass' is installed in the client machine.
|
|
|
|
// If it is then, use it to automate typing in OTP to the prompt. Unfortunately,
|
|
|
|
// it was not possible to automate it without a third-party application, with
|
|
|
|
// only the Go libraries.
|
|
|
|
// Feel free to try and remove this dependency.
|
2015-08-06 21:00:50 +00:00
|
|
|
sshpassPath, err := exec.LookPath("sshpass")
|
|
|
|
if err == nil {
|
2016-05-30 05:26:51 +00:00
|
|
|
sshCmdArgs = append(sshCmdArgs, []string{"-p", string(resp.Key), "ssh", "-o UserKnownHostsFile=" + userKnownHostsFile, "-o StrictHostKeyChecking=" + strictHostKeyChecking, "-p", resp.Port, username + "@" + ip.String()}...)
|
2016-08-01 18:53:00 +00:00
|
|
|
if len(args) > 1 {
|
|
|
|
sshCmdArgs = append(sshCmdArgs, args[1:]...)
|
|
|
|
}
|
2015-08-06 21:00:50 +00:00
|
|
|
sshCmd := exec.Command(sshpassPath, sshCmdArgs...)
|
|
|
|
sshCmd.Stdin = os.Stdin
|
|
|
|
sshCmd.Stdout = os.Stdout
|
|
|
|
err = sshCmd.Run()
|
|
|
|
if err != nil {
|
2016-10-18 16:46:54 +00:00
|
|
|
c.Ui.Error(fmt.Sprintf("Failed to establish SSH connection: %q", err))
|
2015-08-06 21:00:50 +00:00
|
|
|
}
|
|
|
|
return 0
|
|
|
|
}
|
2015-08-18 01:22:03 +00:00
|
|
|
c.Ui.Output("OTP for the session is " + resp.Key)
|
2015-08-13 23:55:47 +00:00
|
|
|
c.Ui.Output("[Note: Install 'sshpass' to automate typing in OTP]")
|
2015-07-23 21:20:28 +00:00
|
|
|
}
|
2016-05-30 05:26:51 +00:00
|
|
|
sshCmdArgs = append(sshCmdArgs, []string{"-o UserKnownHostsFile=" + userKnownHostsFile, "-o StrictHostKeyChecking=" + strictHostKeyChecking, "-p", resp.Port, username + "@" + ip.String()}...)
|
2016-08-01 18:53:00 +00:00
|
|
|
if len(args) > 1 {
|
|
|
|
sshCmdArgs = append(sshCmdArgs, args[1:]...)
|
|
|
|
}
|
2015-07-10 15:56:14 +00:00
|
|
|
|
|
|
|
sshCmd := exec.Command("ssh", sshCmdArgs...)
|
|
|
|
sshCmd.Stdin = os.Stdin
|
|
|
|
sshCmd.Stdout = os.Stdout
|
|
|
|
|
2015-08-13 23:55:47 +00:00
|
|
|
// Running the command as a separate command. The reason for using exec.Command instead
|
|
|
|
// of using crypto/ssh package is that, this way, user can have the same feeling of
|
|
|
|
// connecting to remote hosts with the ssh command. Package crypto/ssh did not have a way
|
|
|
|
// to establish an independent session like this.
|
2015-07-10 15:56:14 +00:00
|
|
|
err = sshCmd.Run()
|
2015-06-18 00:33:03 +00:00
|
|
|
if err != nil {
|
2016-10-18 16:46:54 +00:00
|
|
|
c.Ui.Error(fmt.Sprintf("Error while running ssh command: %q", err))
|
2015-07-06 15:05:02 +00:00
|
|
|
}
|
|
|
|
|
2015-08-13 23:55:47 +00:00
|
|
|
// If the session established was longer than the lease expiry, the secret
|
|
|
|
// might have been revoked already. If not, then revoke it. Since the key
|
|
|
|
// file is deleted and since user doesn't know the credential anymore, there
|
2016-05-15 16:58:36 +00:00
|
|
|
// is not point in Vault maintaining this secret anymore. Every time the command
|
2015-08-13 23:55:47 +00:00
|
|
|
// is run, a fresh credential is generated anyways.
|
2015-07-29 18:21:36 +00:00
|
|
|
err = client.Sys().Revoke(keySecret.LeaseID)
|
2015-07-06 15:05:02 +00:00
|
|
|
if err != nil {
|
2016-10-18 16:46:54 +00:00
|
|
|
c.Ui.Error(fmt.Sprintf("Error revoking the key: %q", err))
|
2015-06-18 00:33:03 +00:00
|
|
|
}
|
2015-07-02 23:00:14 +00:00
|
|
|
|
2015-06-17 16:39:49 +00:00
|
|
|
return 0
|
|
|
|
}
|
|
|
|
|
2015-07-29 18:21:36 +00:00
|
|
|
// If user did not provide the role with which SSH connection has
|
|
|
|
// to be established and if there is only one role associated with
|
|
|
|
// the IP, it is used by default.
|
2015-08-12 17:30:50 +00:00
|
|
|
func (c *SSHCommand) defaultRole(mountPoint, ip string) (string, error) {
|
2015-07-02 23:00:14 +00:00
|
|
|
data := map[string]interface{}{
|
|
|
|
"ip": ip,
|
|
|
|
}
|
2015-07-29 18:21:36 +00:00
|
|
|
client, err := c.Client()
|
|
|
|
if err != nil {
|
|
|
|
return "", err
|
|
|
|
}
|
2015-08-12 17:30:50 +00:00
|
|
|
secret, err := client.Logical().Write(mountPoint+"/lookup", data)
|
2015-07-02 23:00:14 +00:00
|
|
|
if err != nil {
|
2016-10-18 16:46:54 +00:00
|
|
|
return "", fmt.Errorf("Error finding roles for IP %q: %q", ip, err)
|
2015-07-02 23:00:14 +00:00
|
|
|
|
|
|
|
}
|
|
|
|
if secret == nil {
|
2016-10-18 16:46:54 +00:00
|
|
|
return "", fmt.Errorf("Error finding roles for IP %q: %q", ip, err)
|
2015-07-02 23:00:14 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if secret.Data["roles"] == nil {
|
2016-10-18 16:46:54 +00:00
|
|
|
return "", fmt.Errorf("No matching roles found for IP %q", ip)
|
2015-07-02 23:00:14 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if len(secret.Data["roles"].([]interface{})) == 1 {
|
|
|
|
return secret.Data["roles"].([]interface{})[0].(string), nil
|
|
|
|
} else {
|
2015-07-29 18:21:36 +00:00
|
|
|
var roleNames string
|
|
|
|
for _, item := range secret.Data["roles"].([]interface{}) {
|
|
|
|
roleNames += item.(string) + ", "
|
|
|
|
}
|
|
|
|
roleNames = strings.TrimRight(roleNames, ", ")
|
2016-10-18 16:46:54 +00:00
|
|
|
return "", fmt.Errorf("Roles:%q. "+`
|
2015-08-13 23:55:47 +00:00
|
|
|
Multiple roles are registered for this IP.
|
|
|
|
Select a role using '-role' option.
|
|
|
|
Note that all roles may not be permitted, based on ACLs.`, roleNames)
|
2015-07-02 23:00:14 +00:00
|
|
|
}
|
2015-06-24 22:13:12 +00:00
|
|
|
}
|
|
|
|
|
2015-07-01 15:58:49 +00:00
|
|
|
func (c *SSHCommand) Synopsis() string {
|
2015-06-17 16:39:49 +00:00
|
|
|
return "Initiate a SSH session"
|
|
|
|
}
|
|
|
|
|
2015-07-01 15:58:49 +00:00
|
|
|
func (c *SSHCommand) Help() string {
|
2015-06-17 16:39:49 +00:00
|
|
|
helpText := `
|
2015-07-02 21:23:09 +00:00
|
|
|
Usage: vault ssh [options] username@ip
|
|
|
|
|
|
|
|
Establishes an SSH connection with the target machine.
|
|
|
|
|
2015-07-27 20:42:03 +00:00
|
|
|
This command generates a key and uses it to establish an SSH
|
|
|
|
connection with the target machine. This operation requires
|
2015-07-02 21:23:09 +00:00
|
|
|
that SSH backend is mounted and at least one 'role' be registed
|
|
|
|
with vault at priori.
|
|
|
|
|
2015-07-29 18:21:36 +00:00
|
|
|
For setting up SSH backends with one-time-passwords, installation
|
|
|
|
of agent in target machines is required.
|
|
|
|
See [https://github.com/hashicorp/vault-ssh-agent]
|
|
|
|
|
2015-07-02 21:23:09 +00:00
|
|
|
General Options:
|
2016-04-01 20:50:12 +00:00
|
|
|
` + meta.GeneralOptionsUsage() + `
|
2015-07-02 21:23:09 +00:00
|
|
|
SSH Options:
|
|
|
|
|
2016-05-30 05:26:51 +00:00
|
|
|
-role Role to be used to create the key.
|
|
|
|
Each IP is associated with a role. To see the associated
|
|
|
|
roles with IP, use "lookup" endpoint. If you are certain
|
|
|
|
that there is only one role associated with the IP, you can
|
|
|
|
skip mentioning the role. It will be chosen by default. If
|
|
|
|
there are no roles associated with the IP, register the
|
|
|
|
CIDR block of that IP using the "roles/" endpoint.
|
|
|
|
|
|
|
|
-no-exec Shows the credentials but does not establish connection.
|
|
|
|
|
|
|
|
-mount-point Mount point of SSH backend. If the backend is mounted at
|
|
|
|
'ssh', which is the default as well, this parameter can be
|
|
|
|
skipped.
|
|
|
|
|
|
|
|
-format If no-exec option is enabled, then the credentials will be
|
|
|
|
printed out and SSH connection will not be established. The
|
|
|
|
format of the output can be 'json' or 'table'. JSON output
|
|
|
|
is useful when writing scripts. Default is 'table'.
|
|
|
|
|
|
|
|
-strict-host-key-checking This option corresponds to StrictHostKeyChecking of SSH configuration.
|
|
|
|
If 'sshpass' is employed to enable automated login, then if host key
|
|
|
|
is not "known" to the client, 'vault ssh' command will fail. Set this
|
|
|
|
option to "no" to bypass the host key checking. Defaults to "ask".
|
2016-06-01 15:26:19 +00:00
|
|
|
Can also be specified with VAULT_SSH_STRICT_HOST_KEY_CHECKING environment
|
2016-06-01 03:31:53 +00:00
|
|
|
variable.
|
2016-05-30 05:26:51 +00:00
|
|
|
|
|
|
|
-user-known-hosts-file This option corresponds to UserKnownHostsFile of SSH configuration.
|
|
|
|
Assigns the file to use for storing the host keys. If this option is
|
|
|
|
set to "/dev/null" along with "-strict-host-key-checking=no", both
|
|
|
|
warnings and host key checking can be avoided while establishing the
|
2016-06-01 03:31:53 +00:00
|
|
|
connection. Defaults to "~/.ssh/known_hosts". Can also be specified
|
2016-06-01 15:26:19 +00:00
|
|
|
with VAULT_SSH_USER_KNOWN_HOSTS_FILE environment variable.
|
2015-07-02 21:23:09 +00:00
|
|
|
`
|
2015-06-17 16:39:49 +00:00
|
|
|
return strings.TrimSpace(helpText)
|
|
|
|
}
|