aboutsummaryrefslogtreecommitdiffstats
path: root/internal/ssh/client.go
diff options
context:
space:
mode:
authorClawd <ai@clawd.bot>2026-04-18 14:40:17 -0700
committerClawd <ai@clawd.bot>2026-04-18 14:40:17 -0700
commit778bef5ee6941056e06326d1eaaa6956d7307a85 (patch)
tree23b85f32fb69f85078b3debec08c1353694def6f /internal/ssh/client.go
parenteb76b1f6e1697ef170fc45d25e81b21679ea7b0d (diff)
Remove Go implementation — ship is skills-only nowmain
The skills/ directory fully replaces the old Go CLI. Drop all Go source, build files, planning docs, and the stale SECURITY.md (which described the old git-user push-deploy model that no longer exists). Trim .gitignore to match the new tree.
Diffstat (limited to 'internal/ssh/client.go')
-rw-r--r--internal/ssh/client.go393
1 files changed, 0 insertions, 393 deletions
diff --git a/internal/ssh/client.go b/internal/ssh/client.go
deleted file mode 100644
index b9c8d0f..0000000
--- a/internal/ssh/client.go
+++ /dev/null
@@ -1,393 +0,0 @@
1package ssh
2
3import (
4 "bufio"
5 "bytes"
6 "fmt"
7 "net"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "strings"
12
13 "golang.org/x/crypto/ssh"
14 "golang.org/x/crypto/ssh/agent"
15)
16
17// Client represents an SSH connection to a remote host
18type Client struct {
19 host string
20 client *ssh.Client
21}
22
23// sshConfig holds SSH configuration for a host
24type sshConfig struct {
25 Host string
26 HostName string
27 User string
28 Port string
29 IdentityFile string
30}
31
32// Connect establishes an SSH connection to the remote host
33// Supports both SSH config aliases (e.g., "myserver") and user@host format
34func Connect(host string) (*Client, error) {
35 var user, addr string
36 var identityFile string
37
38 // Try to read SSH config first
39 cfg, err := readSSHConfig(host)
40 if err == nil && cfg.HostName != "" {
41 // Use SSH config
42 user = cfg.User
43 addr = cfg.HostName
44 if cfg.Port != "" {
45 addr = addr + ":" + cfg.Port
46 } else {
47 addr = addr + ":22"
48 }
49 identityFile = cfg.IdentityFile
50 } else {
51 // Fall back to parsing user@host format
52 parts := strings.SplitN(host, "@", 2)
53 if len(parts) != 2 {
54 return nil, fmt.Errorf("host '%s' not found in SSH config and not in user@host format", host)
55 }
56 user = parts[0]
57 addr = parts[1]
58
59 // Add default port if not specified
60 if !strings.Contains(addr, ":") {
61 addr = addr + ":22"
62 }
63 }
64
65 // Build authentication methods
66 var authMethods []ssh.AuthMethod
67
68 // Try identity file from SSH config first
69 if identityFile != "" {
70 if authMethod, err := publicKeyFromFile(identityFile); err == nil {
71 authMethods = append(authMethods, authMethod)
72 }
73 }
74
75 // Try SSH agent
76 if authMethod, err := sshAgent(); err == nil {
77 authMethods = append(authMethods, authMethod)
78 }
79
80 // Try default key files
81 if authMethod, err := publicKeyFile(); err == nil {
82 authMethods = append(authMethods, authMethod)
83 }
84
85 if len(authMethods) == 0 {
86 return nil, fmt.Errorf("no SSH authentication method available")
87 }
88
89 config := &ssh.ClientConfig{
90 User: user,
91 Auth: authMethods,
92 HostKeyCallback: ssh.InsecureIgnoreHostKey(), // TODO: Consider using known_hosts
93 }
94
95 client, err := ssh.Dial("tcp", addr, config)
96 if err != nil {
97 return nil, fmt.Errorf("failed to connect to %s: %w", host, err)
98 }
99
100 return &Client{
101 host: host,
102 client: client,
103 }, nil
104}
105
106// Close closes the SSH connection
107func (c *Client) Close() error {
108 return c.client.Close()
109}
110
111// Run executes a command on the remote host and returns the output
112func (c *Client) Run(cmd string) (string, error) {
113 session, err := c.client.NewSession()
114 if err != nil {
115 return "", err
116 }
117 defer session.Close()
118
119 var stdout, stderr bytes.Buffer
120 session.Stdout = &stdout
121 session.Stderr = &stderr
122
123 if err := session.Run(cmd); err != nil {
124 return "", fmt.Errorf("command failed: %w\nstderr: %s", err, stderr.String())
125 }
126
127 return stdout.String(), nil
128}
129
130// RunSudo executes a command with sudo on the remote host
131func (c *Client) RunSudo(cmd string) (string, error) {
132 return c.Run("sudo " + cmd)
133}
134
135// RunSudoStream executes a command with sudo and streams output to stdout/stderr
136func (c *Client) RunSudoStream(cmd string) error {
137 session, err := c.client.NewSession()
138 if err != nil {
139 return err
140 }
141 defer session.Close()
142
143 session.Stdout = os.Stdout
144 session.Stderr = os.Stderr
145
146 if err := session.Run("sudo " + cmd); err != nil {
147 return fmt.Errorf("command failed: %w", err)
148 }
149
150 return nil
151}
152
153// RunStream executes a command and streams output to stdout/stderr
154func (c *Client) RunStream(cmd string) error {
155 session, err := c.client.NewSession()
156 if err != nil {
157 return err
158 }
159 defer session.Close()
160
161 session.Stdout = os.Stdout
162 session.Stderr = os.Stderr
163
164 if err := session.Run(cmd); err != nil {
165 return fmt.Errorf("command failed: %w", err)
166 }
167
168 return nil
169}
170
171// Upload copies a local file to the remote host using scp
172func (c *Client) Upload(localPath, remotePath string) error {
173 // Use external scp command for simplicity
174 // Format: scp -o StrictHostKeyChecking=no localPath user@host:remotePath
175 cmd := exec.Command("scp", "-o", "StrictHostKeyChecking=no", localPath, c.host+":"+remotePath)
176
177 var stderr bytes.Buffer
178 cmd.Stderr = &stderr
179
180 if err := cmd.Run(); err != nil {
181 return fmt.Errorf("scp failed: %w\nstderr: %s", err, stderr.String())
182 }
183
184 return nil
185}
186
187// UploadDir copies a local directory to the remote host using rsync
188func (c *Client) UploadDir(localDir, remoteDir string) error {
189 // Use rsync for directory uploads
190 // Format: rsync -avz --delete localDir/ user@host:remoteDir/
191 localDir = strings.TrimSuffix(localDir, "/") + "/"
192 remoteDir = strings.TrimSuffix(remoteDir, "/") + "/"
193
194 cmd := exec.Command("rsync", "-avz", "--delete",
195 "-e", "ssh -o StrictHostKeyChecking=no",
196 localDir, c.host+":"+remoteDir)
197
198 var stderr bytes.Buffer
199 cmd.Stderr = &stderr
200
201 if err := cmd.Run(); err != nil {
202 return fmt.Errorf("rsync failed: %w\nstderr: %s", err, stderr.String())
203 }
204
205 return nil
206}
207
208// WriteFile creates a file with the given content on the remote host
209func (c *Client) WriteFile(remotePath, content string) error {
210 session, err := c.client.NewSession()
211 if err != nil {
212 return err
213 }
214 defer session.Close()
215
216 // Use cat to write content to file
217 cmd := fmt.Sprintf("cat > %s", remotePath)
218 session.Stdin = strings.NewReader(content)
219
220 var stderr bytes.Buffer
221 session.Stderr = &stderr
222
223 if err := session.Run(cmd); err != nil {
224 return fmt.Errorf("write file failed: %w\nstderr: %s", err, stderr.String())
225 }
226
227 return nil
228}
229
230// WriteSudoFile creates a file with the given content using sudo
231func (c *Client) WriteSudoFile(remotePath, content string) error {
232 session, err := c.client.NewSession()
233 if err != nil {
234 return err
235 }
236 defer session.Close()
237
238 // Use sudo tee to write content to file
239 cmd := fmt.Sprintf("sudo tee %s > /dev/null", remotePath)
240 session.Stdin = strings.NewReader(content)
241
242 var stderr bytes.Buffer
243 session.Stderr = &stderr
244
245 if err := session.Run(cmd); err != nil {
246 return fmt.Errorf("write file with sudo failed: %w\nstderr: %s", err, stderr.String())
247 }
248
249 return nil
250}
251
252// readSSHConfig reads and parses the SSH config file for a given host
253func readSSHConfig(host string) (*sshConfig, error) {
254 home, err := os.UserHomeDir()
255 if err != nil {
256 return nil, err
257 }
258
259 configPath := filepath.Join(home, ".ssh", "config")
260 file, err := os.Open(configPath)
261 if err != nil {
262 return nil, err
263 }
264 defer file.Close()
265
266 cfg := &sshConfig{}
267 var currentHost string
268 var matchedHost bool
269
270 scanner := bufio.NewScanner(file)
271 for scanner.Scan() {
272 line := strings.TrimSpace(scanner.Text())
273
274 // Skip comments and empty lines
275 if line == "" || strings.HasPrefix(line, "#") {
276 continue
277 }
278
279 fields := strings.Fields(line)
280 if len(fields) < 2 {
281 continue
282 }
283
284 key := strings.ToLower(fields[0])
285 value := fields[1]
286
287 // Expand ~ in paths
288 if strings.HasPrefix(value, "~/") {
289 value = filepath.Join(home, value[2:])
290 }
291
292 switch key {
293 case "host":
294 currentHost = value
295 if currentHost == host {
296 matchedHost = true
297 cfg.Host = host
298 } else {
299 matchedHost = false
300 }
301 case "hostname":
302 if matchedHost {
303 cfg.HostName = value
304 }
305 case "user":
306 if matchedHost {
307 cfg.User = value
308 }
309 case "port":
310 if matchedHost {
311 cfg.Port = value
312 }
313 case "identityfile":
314 if matchedHost {
315 cfg.IdentityFile = value
316 }
317 }
318 }
319
320 if err := scanner.Err(); err != nil {
321 return nil, err
322 }
323
324 if cfg.Host == "" {
325 return nil, fmt.Errorf("host %s not found in SSH config", host)
326 }
327
328 return cfg, nil
329}
330
331// sshAgent returns an auth method using SSH agent
332func sshAgent() (ssh.AuthMethod, error) {
333 socket := os.Getenv("SSH_AUTH_SOCK")
334 if socket == "" {
335 return nil, fmt.Errorf("SSH_AUTH_SOCK not set")
336 }
337
338 conn, err := net.Dial("unix", socket)
339 if err != nil {
340 return nil, fmt.Errorf("failed to connect to SSH agent: %w", err)
341 }
342
343 agentClient := agent.NewClient(conn)
344 return ssh.PublicKeysCallback(agentClient.Signers), nil
345}
346
347// publicKeyFromFile returns an auth method from a specific private key file
348func publicKeyFromFile(keyPath string) (ssh.AuthMethod, error) {
349 key, err := os.ReadFile(keyPath)
350 if err != nil {
351 return nil, err
352 }
353
354 signer, err := ssh.ParsePrivateKey(key)
355 if err != nil {
356 return nil, err
357 }
358
359 return ssh.PublicKeys(signer), nil
360}
361
362// publicKeyFile returns an auth method using a private key file
363func publicKeyFile() (ssh.AuthMethod, error) {
364 home, err := os.UserHomeDir()
365 if err != nil {
366 return nil, err
367 }
368
369 // Try common key locations
370 keyPaths := []string{
371 filepath.Join(home, ".ssh", "id_rsa"),
372 filepath.Join(home, ".ssh", "id_ed25519"),
373 filepath.Join(home, ".ssh", "id_ecdsa"),
374 }
375
376 for _, keyPath := range keyPaths {
377 if _, err := os.Stat(keyPath); err == nil {
378 key, err := os.ReadFile(keyPath)
379 if err != nil {
380 continue
381 }
382
383 signer, err := ssh.ParsePrivateKey(key)
384 if err != nil {
385 continue
386 }
387
388 return ssh.PublicKeys(signer), nil
389 }
390 }
391
392 return nil, fmt.Errorf("no SSH private key found")
393}