-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathssh_config.go
More file actions
209 lines (190 loc) · 6.06 KB
/
ssh_config.go
File metadata and controls
209 lines (190 loc) · 6.06 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
package iago
import (
"errors"
"fmt"
"io/fs"
"net"
"os"
"os/user"
"path/filepath"
"strings"
"sync"
"github.com/kevinburke/ssh_config"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
"golang.org/x/crypto/ssh/knownhosts"
)
var (
homeDir string
homeDirOnce = sync.OnceValues(func() (string, error) {
return os.UserHomeDir()
})
)
func initHomeDir() (err error) {
homeDir, err = homeDirOnce()
if err != nil {
return fmt.Errorf("iago: failed to initialize home directory: %w", err)
}
return nil
}
// ParseSSHConfig returns a ssh configuration object that can be used to create
// a [ssh.ClientConfig] for a given host alias.
func ParseSSHConfig(configFile string) (*sshConfig, error) {
if configFile == "" {
return nil, fmt.Errorf("iago: no ssh config file provided")
}
if err := initHomeDir(); err != nil {
return nil, err
}
fd, err := os.Open(expand(configFile))
if err != nil {
return nil, fmt.Errorf("iago: failed to open ssh config file: %w", err)
}
defer fd.Close()
decodedConfig, err := ssh_config.Decode(fd)
if err != nil {
return nil, fmt.Errorf("iago: failed to decode ssh config file: %w", err)
}
return &sshConfig{decodedConfig}, nil
}
type sshConfig struct {
config *ssh_config.Config
}
// ClientConfig returns a [ssh.ClientConfig] for the given host alias.
func (cw *sshConfig) ClientConfig(hostAlias string) (*ssh.ClientConfig, error) {
hostKeyCallback, err := cw.getHostKeyCallback(hostAlias)
if err != nil {
return nil, err
}
signers := agentSigners()
identityFile, err := cw.get(hostAlias, "IdentityFile")
if err != nil {
return nil, err
}
pubkey := fileSigner(identityFile)
if pubkey != nil {
signers = append(signers, pubkey)
}
if len(signers) == 0 {
// Cannot authenticate without any signers in ssh agent or the provided identity file.
// If the identity file contains a passphrase protected private key, this will fail
// as the passphrase cannot be provided here.
return nil, fmt.Errorf("iago: no valid authentication methods found for %s", hostAlias)
}
username, err := cw.get(hostAlias, "User")
if err != nil {
return nil, err
}
if username == "" {
// default to the current user if User not specified in the config file
currentUser, err := user.Current()
if err != nil {
return nil, fmt.Errorf("iago: failed to get current user: %w", err)
}
username = currentUser.Username
}
clientConfig := &ssh.ClientConfig{
Config: ssh.Config{},
User: username,
Auth: []ssh.AuthMethod{ssh.PublicKeys(signers...)},
HostKeyCallback: hostKeyCallback,
}
return clientConfig, nil
}
// ConnectAddr returns the connection address for the given host alias.
// If no hostname is specified in the SSH config, it defaults to the provide host alias.
// An empty string is returned if there was an error retrieving the hostname or port
// for the host alias.
func (cw *sshConfig) ConnectAddr(hostAlias string) string {
hostname, err := cw.get(hostAlias, "Hostname")
if err != nil {
return ""
}
// if no hostname is specified, use the host alias (SSH default behavior)
if hostname == "" {
hostname = hostAlias
}
port, err := cw.get(hostAlias, "Port")
if err != nil {
return ""
}
return net.JoinHostPort(hostname, port)
}
// get retrieves the value for the specified key for the given host alias.
// If the value is not set in the config file, it returns the default value for that key.
func (cw *sshConfig) get(alias, key string) (string, error) {
val, err := cw.config.Get(alias, key)
if err != nil {
return "", fmt.Errorf("iago: failed to get %s for %s: %w", key, alias, err)
}
if val == "" {
val = ssh_config.Default(key)
}
return val, nil
}
// fileSigner returns a SSH signer based on the private key in the specified IdentityFile.
// If the file cannot be read, parsed, or if the private key is passphrase protected, it returns nil.
func fileSigner(file string) ssh.Signer {
buffer, err := os.ReadFile(expand(file))
if err != nil {
return nil
}
key, err := ssh.ParsePrivateKey(buffer)
if err != nil {
return nil
}
return key
}
// agentSigners returns a list of SSH signers obtained from the SSH agent.
// It returns nil if there are no signers available or if there is an error connecting to the agent.
func agentSigners() []ssh.Signer {
if sshAgent, err := net.Dial("unix", os.Getenv("SSH_AUTH_SOCK")); err == nil {
signers, err := agent.NewClient(sshAgent).Signers()
if err != nil {
return nil
}
return signers
}
return nil
}
// getHostKeyCallback returns a [ssh.HostKeyCallback] for use with [ssh.ClientConfig].
// If StrictHostKeyChecking is set to "no", host key checking is disabled, ignoring
// any host keys. Otherwise, it creates a host key callback using the known hosts files
// specified by UserKnownHostsFile.
func (cw *sshConfig) getHostKeyCallback(hostAlias string) (hostKeyCallback ssh.HostKeyCallback, err error) {
strictHostKeyChecking, err := cw.get(hostAlias, "StrictHostKeyChecking")
if err != nil {
return nil, err
}
if strictHostKeyChecking == "no" {
return ssh.InsecureIgnoreHostKey(), nil
}
userKnownHostsFile, err := cw.get(hostAlias, "UserKnownHostsFile")
if err != nil {
return nil, err
}
hostKeyCallback, err = createHostKeyCallback(strings.Split(userKnownHostsFile, " "))
if err != nil {
return nil, fmt.Errorf("iago: failed to create host key callback for %s: %w", hostAlias, err)
}
return hostKeyCallback, nil
}
// createHostKeyCallback returns a HostKeyCallback that checks the host keys against the known hosts files.
// It skips files that do not exist and returns an error if no valid known hosts files are provided.
func createHostKeyCallback(userKnownHostsFilesPaths []string) (ssh.HostKeyCallback, error) {
var userKnownHostsFiles []string
for _, file := range userKnownHostsFilesPaths {
file = expand(file)
if _, err := os.Stat(file); errors.Is(err, fs.ErrNotExist) {
continue
}
userKnownHostsFiles = append(userKnownHostsFiles, file)
}
return knownhosts.New(userKnownHostsFiles...)
}
func expand(path string) string {
if len(path) >= 2 && path[:2] == "~/" {
return filepath.Join(homeDir, path[2:])
}
return path
}