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

Commit fc863af

Browse files
authored
Merge pull request #18 from keybase/david/kssh-default-ssh-user
Add ability to set a default SSH user for kssh
2 parents 917567a + 3c1c55f commit fc863af

12 files changed

Lines changed: 265 additions & 52 deletions

README.md

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,21 @@ on the `ssh` binary being in the path. This can be installed in a number of diff
8787
[built in version](https://docs.microsoft.com/en-us/windows-server/administration/openssh/openssh_install_firstuse) on
8888
modern versions of windows.
8989

90+
# Using kssh with jumpboxes and bastion hosts
91+
92+
kssh should work correctly with jumpboxes and bastion hosts as long as they are configured to trust the SSH CA and the usernames are correct. For example:
93+
94+
```
95+
kssh -J developer@jumpbox.example.com developer@server.internal
96+
```
97+
98+
This can also be made easier by setting the kssh default ssh-username locally, then you won't have to specify it for each server.
99+
100+
```
101+
kssh --set-default-user developer
102+
kssh -J jumpbox.example.com server.internal
103+
```
104+
90105
# Contributing
91106

92107
There are two separate binaries built from the code in this repo:
@@ -153,9 +168,9 @@ client config file is how kssh determines which teams are using kssh and the nee
153168
channel name, the name of the bot, etc). When keybaseca stops, it deletes all of the client config files.
154169

155170
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.
171+
configuration. It does have one local config file stored in `~/.ssh/kssh-config.json` that is used to store a few settings
172+
for kssh. By default, this config file is not used. It is only created and meant to be interacted with via the
173+
`--set-default-bot`, `--clear-default-bot`, `--set-default-user`, `--clear-default-user` flags.
159174

160175
#### Communication
161176

docs/troubleshooting.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,4 @@ 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`.

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: 71 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,20 +280,64 @@ 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+
// Determine whether a default SSH user has been specified and configure it if so
286+
useConfig := false
287+
user, err := kssh.GetDefaultSSHUser()
288+
if err != nil {
289+
fmt.Printf("Failed to retrieve default SSH user: %v\n", err)
290+
os.Exit(1)
291+
}
292+
if user != "" {
293+
useConfig = true
294+
err = kssh.CreateDefaultUserConfigFile()
295+
if err != nil {
296+
fmt.Printf("Failed to set default user: %v\n", err)
297+
os.Exit(1)
298+
}
299+
}
300+
301+
// Add the key to the ssh-agent in case we are doing multiple connections (eg via the `-J` flag)
302+
err = kssh.AddKeyToSSHAgent(keyPath)
303+
if err != nil {
304+
fmt.Printf("Failed to add SSH key to the SSH agent: %v\n", err)
305+
os.Exit(1)
306+
}
307+
308+
// A new line to separate kssh output from ssh output
309+
fmt.Printf("\n")
310+
263311
argumentList := []string{"-i", keyPath, "-o", "IdentitiesOnly=yes"}
312+
checkAndWarnOnUnspecifiedBehavior(useConfig, remainingArgs)
313+
if useConfig {
314+
argumentList = append(argumentList, "-F", kssh.AlternateSSHConfigFile)
315+
}
316+
264317
argumentList = append(argumentList, remainingArgs...)
265318

266319
cmd := exec.Command("ssh", argumentList...)
267320
cmd.Stdout = os.Stdout
268321
cmd.Stderr = os.Stderr
269322
cmd.Stdin = os.Stdin
270-
err := cmd.Run()
323+
err = cmd.Run()
324+
271325
if err != nil {
272326
fmt.Printf("SSH exited with err: %v\n", err)
273327
os.Exit(1)
274328
}
275329
os.Exit(0)
276330
}
331+
332+
func checkAndWarnOnUnspecifiedBehavior(useConfig bool, arguments []string) {
333+
if useConfig {
334+
for _, arg := range arguments {
335+
if arg == "-F" {
336+
fmt.Println("Warning: You passed a -F flag, but kssh also uses this argument in " +
337+
"order to implement support for a default SSH username, which you're also using. " +
338+
"Either do not use the -F flag or run `kssh --clear-default-user` to reset the " +
339+
"default SSH user and delegate this to the running CA bot.")
340+
}
341+
}
342+
}
343+
}

src/cmd/kssh/kssh_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ func TestIsValidCert(t *testing.T) {
4646
}
4747

4848
func BenchmarkLoadConfigs(b *testing.B) {
49-
os.Remove("~/.ssh/kssh.config")
49+
os.Remove("~/.ssh/kssh-config.json")
5050
b.ResetTimer()
5151

5252
for n := 0; n < b.N; n++ {

src/kssh/config.go

Lines changed: 62 additions & 11 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
115-
var localConfigFileLocation = shared.ExpandPathWithTilde("~/.ssh/kssh.config")
116+
var localConfigFileLocation = shared.ExpandPathWithTilde("~/.ssh/kssh-config.json")
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: 56 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,56 @@ func AddKeyToSSHAgent(keyPath string) error {
1417
}
1518
return nil
1619
}
20+
21+
var AlternateSSHConfigFile = shared.ExpandPathWithTilde("~/.ssh/kssh-config")
22+
23+
// Create an SSH config file that inherits from the default SSH config file but sets a default SSH user
24+
func CreateDefaultUserConfigFile() error {
25+
user, err := GetDefaultSSHUser()
26+
if err != nil {
27+
return err
28+
}
29+
if user == "" {
30+
return nil
31+
}
32+
33+
err = MakeDotSSH()
34+
if err != nil {
35+
return err
36+
}
37+
38+
if _, err := os.Stat(shared.ExpandPathWithTilde("~/.ssh/config")); os.IsNotExist(err) {
39+
f, err := os.OpenFile(shared.ExpandPathWithTilde("~/.ssh/config"), os.O_RDONLY|os.O_CREATE, 0644)
40+
if err != nil {
41+
return fmt.Errorf("failed to touch ~/.ssh/config: %v", err)
42+
}
43+
f.Close()
44+
}
45+
46+
config := fmt.Sprintf("# kssh config file to set a default SSH user\n"+
47+
"Include config\n"+
48+
"Host *\n"+
49+
" User %s\n", user)
50+
51+
f, err := os.OpenFile(AlternateSSHConfigFile, os.O_CREATE|os.O_WRONLY, 0644)
52+
if err != nil {
53+
return err
54+
}
55+
defer f.Close()
56+
_, err = f.WriteString(config)
57+
if err != nil {
58+
return err
59+
}
60+
fmt.Printf("Using default ssh user %s\n", user)
61+
return nil
62+
}
63+
64+
func MakeDotSSH() error {
65+
if _, err := os.Stat(shared.ExpandPathWithTilde("~/.ssh/")); os.IsNotExist(err) {
66+
err = os.Mkdir(shared.ExpandPathWithTilde("~/.ssh/"), 0700)
67+
if err != nil {
68+
return fmt.Errorf("failed to create ~/.ssh directory: %v", err)
69+
}
70+
}
71+
return nil
72+
}

tests/tests/lib.py

Lines changed: 17 additions & 2 deletions
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:
@@ -76,7 +91,7 @@ def clear_keys():
7691
def clear_local_config():
7792
# Clear kssh's local config file
7893
try:
79-
run_command("rm -rf ~/.ssh/kssh.config")
94+
run_command("rm -rf ~/.ssh/kssh-config.json")
8095
except subprocess.CalledProcessError:
8196
pass
8297

0 commit comments

Comments
 (0)