Skip to content
This repository was archived by the owner on Jan 22, 2025. It is now read-only.

Commit 78171b8

Browse files
committed
Add ability to set a default SSH user for kssh
This feature is useful when using jumpboxes combined with ssh configs. For example, imagine you have an SSH config that translates to this command: ``` ssh -J jump.example.com server.internal ``` When run via kssh, you want it to use the `developer` user but when run via ssh you want it to use your username as the user (the default when no user is specified). Prior to this change, that was not possible. Now, kssh does two new things: 1. kssh --set-default-user It is now possible to set a default SSH user to use with kssh. This is implemented via an SSH config file that applies a User directive to all hosts. This config file then inherits from the default ssh config file at ~/.ssh/config. This makes it possible to set a default user without modifying the user's ssh config file. 2. kssh now adds to the SSH agent by default Now by default kssh adds to the running ssh agent. This is necessary in order for jumpboxes to work since they rely on ssh agent forwarding. As part of this, I also had to tweak the tests to make all of this work properly.
1 parent 917567a commit 78171b8

11 files changed

Lines changed: 229 additions & 49 deletions

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,9 +153,9 @@ client config file is how kssh determines which teams are using kssh and the nee
153153
channel name, the name of the bot, etc). When keybaseca stops, it deletes all of the client config files.
154154

155155
kssh reads the client config file in order to determine how to interact with a bot. kssh does not have any user controlled
156-
configuration. It does have one local config file stored in `~/.ssh/kssh.config` that is used to store the default bot
157-
if the kssh user has access to multiple running keybaseca bots. This config file is not meant to be manually edited and is only meant to be
158-
interacted with via the `--set-default-bot` and `--clear-default-bot` flags.
156+
configuration. It does have one local config file stored in `~/.ssh/kssh.config` that is used to store a few settings
157+
for kssh. By default, this config file is not used. It is only created and meant to be interacted with via the
158+
`--set-default-bot`, `--clear-default-bot`, `--set-default-user`, `--clear-default-user` flags.
159159

160160
#### Communication
161161

docs/troubleshooting.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,10 @@ ssh-keygen \
5959
/path/to/key.pub
6060
```
6161

62-
You can then use the signed SSH key to SSH via `ssh -i /path/to/key.pub user@server`.
62+
You can then use the signed SSH key to SSH via `ssh -i /path/to/key.pub user@server`.
63+
64+
## Using kssh with jumpboxes and bastion hosts
65+
66+
kssh does work correctly with jumpboxes and bastion hosts. Eg the command `kssh -J jumpbox.example.com server.internal`
67+
does work correctly. If using this with an SSH config, it may be useful to use `kssh --set-default-user foo` in order
68+
to specify a user to use for all kssh connections.

go.mod

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,4 @@ require (
99
github.com/urfave/cli v1.21.0
1010
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4
1111
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e // indirect
12-
gopkg.in/yaml.v2 v2.2.2 // indirect
1312
)

src/cmd/kssh/kssh.go

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ var cliArguments = []kssh.CLIArgument{
7777
{Name: "--clear-default-bot", HasArgument: false},
7878
{Name: "--bot", HasArgument: true},
7979
{Name: "--provision", HasArgument: false},
80+
{Name: "--set-default-user", HasArgument: true},
81+
{Name: "--clear-default-user", HasArgument: false},
8082
{Name: "--help", HasArgument: false},
8183
}
8284

@@ -98,7 +100,10 @@ GLOBAL OPTIONS:
98100
is using Keybase SSH CA
99101
--clear-default-bot Clear the default bot
100102
--bot Specify a specific bot to be used for kssh. Not necessary if you are only in one team that
101-
is using Keybase SSH CA`)
103+
is using Keybase SSH CA
104+
--set-default-user Set the default SSH user to be used for kssh. Useful if you use ssh configs that do not set
105+
a default SSH user
106+
--clear-default-user Clear the default SSH user`)
102107
}
103108

104109
type Action int
@@ -122,6 +127,24 @@ func handleArgs(args []string) (string, []string, Action, error) {
122127
if arg.Argument.Name == "--bot" {
123128
team = arg.Value
124129
}
130+
if arg.Argument.Name == "--set-default-user" {
131+
err := kssh.SetDefaultSSHUser(arg.Value)
132+
if err != nil {
133+
fmt.Printf("Failed to set the default ssh user: %v\n", err)
134+
os.Exit(1)
135+
}
136+
fmt.Println("Set default ssh user, exiting...")
137+
os.Exit(0)
138+
}
139+
if arg.Argument.Name == "--clear-default-user" {
140+
err := kssh.SetDefaultSSHUser("")
141+
if err != nil {
142+
fmt.Printf("Failed to clear the default ssh user: %v\n", err)
143+
os.Exit(1)
144+
}
145+
fmt.Println("Cleared default ssh user, exiting...")
146+
os.Exit(0)
147+
}
125148
if arg.Argument.Name == "--set-default-bot" {
126149
// We exit immediately after setting the default bot
127150
err := kssh.SetDefaultBot(arg.Value)
@@ -257,17 +280,38 @@ func provisionNewKey(config kssh.ConfigFile, keyPath string) error {
257280
return nil
258281
}
259282

260-
// Run SSH with the given key. Calls os.Exit if SSH returns
283+
// Run SSH with the given key. Calls os.Exit and does not return.
261284
func runSSHWithKey(keyPath string, remainingArgs []string) {
262-
fmt.Printf("\n") // a new line to separate kssh output from ssh output
285+
// Create a config file for the default user if necessary
286+
useConfig, err := kssh.CreateDefaultUserConfigFile()
287+
if err != nil {
288+
fmt.Printf("Failed to set default user: %v\n", err)
289+
os.Exit(1)
290+
}
291+
292+
// Add the key to the ssh-agent in case we are doing multiple connections (eg via the `-J` flag)
293+
err = kssh.AddKeyToSSHAgent(keyPath)
294+
if err != nil {
295+
fmt.Printf("Failed to add SSH key to the SSH agent: %v\n", err)
296+
os.Exit(1)
297+
}
298+
299+
// A new line to separate kssh output from ssh output
300+
fmt.Printf("\n")
301+
263302
argumentList := []string{"-i", keyPath, "-o", "IdentitiesOnly=yes"}
303+
if useConfig {
304+
argumentList = append(argumentList, "-F", kssh.AlternateSSHConfigFile)
305+
}
306+
264307
argumentList = append(argumentList, remainingArgs...)
265308

266309
cmd := exec.Command("ssh", argumentList...)
267310
cmd.Stdout = os.Stdout
268311
cmd.Stderr = os.Stderr
269312
cmd.Stdin = os.Stdin
270-
err := cmd.Run()
313+
err = cmd.Run()
314+
271315
if err != nil {
272316
fmt.Printf("SSH exited with err: %v\n", err)
273317
os.Exit(1)

src/kssh/config.go

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -109,40 +109,91 @@ func LoadConfig(kbfsFilename string) (ConfigFile, error) {
109109
type LocalConfigFile struct {
110110
DefaultBotName string `json:"default_bot"`
111111
DefaultBotTeam string `json:"default_team"`
112+
DefaultSSHUser string `json:"default_ssh_user"`
112113
}
113114

114115
// Where to store the local config file. Just stash it in ~/.ssh
115116
var localConfigFileLocation = shared.ExpandPathWithTilde("~/.ssh/kssh.config")
116117

117-
func SetDefaultBot(botname string) error {
118-
if botname == "" {
119-
return os.Remove(localConfigFileLocation)
118+
func GetDefaultSSHUser() (string, error) {
119+
lcf, err := getCurrentConfig()
120+
if err != nil {
121+
return "", err
120122
}
121-
teamname, err := GetTeamFromBot(botname)
123+
124+
return lcf.DefaultSSHUser, nil
125+
}
126+
127+
func SetDefaultSSHUser(username string) error {
128+
if strings.ContainsAny(username, " \t\n\r'\"") {
129+
return fmt.Errorf("invalid username: %s", username)
130+
}
131+
132+
lcf, err := getCurrentConfig()
122133
if err != nil {
123134
return err
124135
}
125-
bytes, err := json.Marshal(&LocalConfigFile{DefaultBotName: botname, DefaultBotTeam: teamname})
136+
137+
lcf.DefaultSSHUser = username
138+
return writeConfig(lcf)
139+
}
140+
141+
func writeConfig(lcf LocalConfigFile) error {
142+
bytes, err := json.Marshal(&lcf)
143+
if err != nil {
144+
return fmt.Errorf("failed to marshal json into config file: %v", err)
145+
}
146+
147+
// Create ~/.ssh/ if it does not yet exist
148+
err = MakeDotSSH()
126149
if err != nil {
127150
return err
128151
}
152+
129153
err = ioutil.WriteFile(localConfigFileLocation, bytes, 0600)
130154
if err != nil {
131-
return err
155+
return fmt.Errorf("failed to write config file: %v", err)
132156
}
133157
return nil
134158
}
135159

136-
func GetDefaultBotAndTeam() (string, string, error) {
160+
func getCurrentConfig() (lcf LocalConfigFile, err error) {
137161
if _, err := os.Stat(localConfigFileLocation); os.IsNotExist(err) {
138-
return "", "", nil
162+
return lcf, nil
139163
}
140164
bytes, err := ioutil.ReadFile(localConfigFileLocation)
141165
if err != nil {
142-
return "", "", err
166+
return lcf, fmt.Errorf("failed to read local config file: %v", err)
143167
}
144-
var lcf LocalConfigFile
145168
err = json.Unmarshal(bytes, &lcf)
169+
if err != nil {
170+
return lcf, fmt.Errorf("failed to parse local config file: %v", err)
171+
}
172+
return lcf, nil
173+
}
174+
175+
func SetDefaultBot(botname string) error {
176+
teamname := ""
177+
var err error
178+
if botname != "" {
179+
teamname, err = GetTeamFromBot(botname)
180+
if err != nil {
181+
return err
182+
}
183+
}
184+
185+
lcf, err := getCurrentConfig()
186+
if err != nil {
187+
return err
188+
}
189+
lcf.DefaultBotName = botname
190+
lcf.DefaultBotTeam = teamname
191+
192+
return writeConfig(lcf)
193+
}
194+
195+
func GetDefaultBotAndTeam() (string, string, error) {
196+
lcf, err := getCurrentConfig()
146197
if err != nil {
147198
return "", "", err
148199
}

src/kssh/ssh.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ package kssh
22

33
import (
44
"fmt"
5+
"os"
56
"os/exec"
67
"strings"
8+
9+
"github.com/keybase/bot-ssh-ca/src/shared"
710
)
811

912
func AddKeyToSSHAgent(keyPath string) error {
@@ -14,3 +17,55 @@ func AddKeyToSSHAgent(keyPath string) error {
1417
}
1518
return nil
1619
}
20+
21+
var AlternateSSHConfigFile = shared.ExpandPathWithTilde("~/.ssh/kssh-config")
22+
23+
func CreateDefaultUserConfigFile() (bool, error) {
24+
user, err := GetDefaultSSHUser()
25+
if err != nil {
26+
return false, err
27+
}
28+
if user == "" {
29+
return false, nil
30+
}
31+
32+
err = MakeDotSSH()
33+
if err != nil {
34+
return false, err
35+
}
36+
37+
if _, err := os.Stat(shared.ExpandPathWithTilde("~/.ssh/config")); os.IsNotExist(err) {
38+
f, err := os.OpenFile(shared.ExpandPathWithTilde("~/.ssh/config"), os.O_RDONLY|os.O_CREATE, 0644)
39+
if err != nil {
40+
return false, fmt.Errorf("failed to touch ~/.ssh/config: %v", err)
41+
}
42+
f.Close()
43+
}
44+
45+
config := fmt.Sprintf("# kssh config file\n"+
46+
"Include config\n"+
47+
"Host *\n"+
48+
" User %s\n", user)
49+
50+
f, err := os.OpenFile(AlternateSSHConfigFile, os.O_CREATE|os.O_WRONLY, 0644)
51+
if err != nil {
52+
return false, err
53+
}
54+
defer f.Close()
55+
_, err = f.WriteString(config)
56+
if err != nil {
57+
return false, err
58+
}
59+
fmt.Printf("Using default ssh user %s\n", user)
60+
return true, nil
61+
}
62+
63+
func MakeDotSSH() error {
64+
if _, err := os.Stat(shared.ExpandPathWithTilde("~/.ssh/")); os.IsNotExist(err) {
65+
err = os.Mkdir(shared.ExpandPathWithTilde("~/.ssh/"), 0700)
66+
if err != nil {
67+
return fmt.Errorf("failed to create ~/.ssh directory: %v", err)
68+
}
69+
}
70+
return nil
71+
}

tests/tests/lib.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,21 @@ def getDefaultTestConfig():
3737
[os.environ['SUBTEAM'] + postfix for postfix in [".ssh.prod", ".ssh.staging", ".ssh.root_everywhere"]]
3838
)
3939

40-
def run_command(cmd: str, timeout: int=10) -> bytes:
40+
def run_command_with_agent(cmd: str) -> bytes:
41+
"""
42+
Run the given command in a shell session with a running ssh-agent
43+
:param cmd: The command to run
44+
:return: The stdout of the process
45+
"""
46+
return run_command("eval `ssh-agent` && " + cmd)
47+
48+
def run_command(cmd: str, timeout: int=15) -> bytes:
49+
"""
50+
Run the given command in a shell with the given timeout
51+
:param cmd: The command to run
52+
:param timeout: The timeout in seconds
53+
:return: The stdout of the process
54+
"""
4155
# In order to properly run a command with a timeout and shell=True, we use Popen with a shell and group all child
4256
# processes so we can kill all of them. See:
4357
# - https://stackoverflow.com/questions/36952245/subprocess-timeout-failure
@@ -46,6 +60,7 @@ def run_command(cmd: str, timeout: int=10) -> bytes:
4660
try:
4761
stdout, stderr = process.communicate(timeout=timeout)
4862
if process.returncode != 0:
63+
print(f"Output before return: {repr(stdout)}, {repr(stderr)}")
4964
raise subprocess.CalledProcessError(process.returncode, cmd, stdout, stderr)
5065
return stdout
5166
except subprocess.TimeoutExpired as e:

0 commit comments

Comments
 (0)