Skip to content

Commit c90d902

Browse files
committed
feat: ssh timeout
1 parent 9beea41 commit c90d902

File tree

8 files changed

+129
-43
lines changed

8 files changed

+129
-43
lines changed

README.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ ManagedSSH is a terminal-first SSH connection manager built with Go, Cobra, and
1919
- Encrypted vault using Argon2 + AES-GCM for stored credentials.
2020
- Host key verification with known_hosts integration and trust confirmation.
2121
- Host profiles with:
22-
- Alias, hostname, port, group, tags
22+
- Alias, hostname, port, SSH timeout, group, tags
2323
- Multiple user accounts per host
2424
- Per-user password or SSH key authentication
2525
- Support for SSH key path or inline encrypted key data.
@@ -88,15 +88,35 @@ make install
8888
Common commands:
8989

9090
```bash
91+
make help
9192
make build
9293
make run
9394
make test
95+
make test-race
96+
make vet
97+
make lint
9498
make fmt
9599
make tidy
96100
make install
101+
make uninstall
97102
make clean
98103
```
99104

105+
Target summary:
106+
107+
- make help: show available targets
108+
- make build: build the binary as managedssh in the project root
109+
- make run: run the app with go run
110+
- make test: run all tests
111+
- make test-race: run all tests with the race detector
112+
- make vet: run go vet checks
113+
- make lint: run golangci-lint if installed, otherwise skip with a message
114+
- make fmt: format Go files (excluding vendor)
115+
- make tidy: run go mod tidy
116+
- make install: install to GOBIN or GOPATH/bin, with ~/.local/bin fallback
117+
- make uninstall: remove installed binary from GOBIN, GOPATH/bin, or ~/.local/bin fallback
118+
- make clean: remove the local built binary
119+
100120
Install behavior:
101121

102122
- `make install` uses `go install .` when `GOBIN` or `GOPATH` is set.

internal/host/store.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type Host struct {
2121
Alias string `json:"alias"`
2222
Hostname string `json:"hostname"`
2323
Port int `json:"port"`
24+
TimeoutSec int `json:"timeout_sec,omitempty"`
2425
Group string `json:"group,omitempty"`
2526
Tags []string `json:"tags,omitempty"`
2627
DefaultUser string `json:"default_user,omitempty"`
@@ -113,6 +114,9 @@ func (s *Store) Add(h Host) error {
113114
if h.Port == 0 {
114115
h.Port = 22
115116
}
117+
if h.TimeoutSec == 0 {
118+
h.TimeoutSec = 10
119+
}
116120
h.Normalize()
117121
s.Hosts = append(s.Hosts, h)
118122
return s.Save()
@@ -195,6 +199,9 @@ func (h *Host) Normalize() {
195199
if h.Port == 0 {
196200
h.Port = 22
197201
}
202+
if h.TimeoutSec <= 0 {
203+
h.TimeoutSec = 10
204+
}
198205

199206
defaultAuth := normalizeAuthType(h.DefaultAuthType)
200207
if defaultAuth == "" {

internal/host/store_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,3 +143,21 @@ func TestStoreUpdateRejectsDuplicateIdentity(t *testing.T) {
143143
t.Fatalf("expected ErrDuplicateAlias, got %v", err)
144144
}
145145
}
146+
147+
func TestHostNormalizeTimeoutDefaultsAndCustom(t *testing.T) {
148+
t.Run("defaults to ten seconds", func(t *testing.T) {
149+
h := Host{}
150+
h.Normalize()
151+
if h.TimeoutSec != 10 {
152+
t.Fatalf("expected default timeout 10, got %d", h.TimeoutSec)
153+
}
154+
})
155+
156+
t.Run("preserves positive timeout", func(t *testing.T) {
157+
h := Host{TimeoutSec: 17}
158+
h.Normalize()
159+
if h.TimeoutSec != 17 {
160+
t.Fatalf("expected timeout 17, got %d", h.TimeoutSec)
161+
}
162+
})
163+
}

internal/sshclient/client.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const defaultDialTimeout = 10 * time.Second
2626
type VerifyConfig struct {
2727
Host string
2828
Port int
29+
DialTimeout time.Duration
2930
User string
3031
Password []byte
3132
KeyPath string
@@ -58,6 +59,7 @@ func (e *UnknownHostError) Error() string {
5859
type Session struct {
5960
Host string
6061
Port int
62+
DialTimeout time.Duration
6163
User string
6264
Password []byte
6365
KeyPath string
@@ -83,7 +85,7 @@ func (s *Session) RunWithContext(ctx context.Context) error {
8385
if ctx == nil {
8486
ctx = context.Background()
8587
}
86-
dialCtx, cancel := withDefaultTimeout(ctx)
88+
dialCtx, cancel := withDefaultTimeout(ctx, s.DialTimeout)
8789
defer cancel()
8890

8991
defer s.zeroPassword()
@@ -454,7 +456,7 @@ func VerifyWithContext(ctx context.Context, cfg VerifyConfig) error {
454456
if ctx == nil {
455457
ctx = context.Background()
456458
}
457-
dialCtx, cancel := withDefaultTimeout(ctx)
459+
dialCtx, cancel := withDefaultTimeout(ctx, cfg.DialTimeout)
458460
defer cancel()
459461

460462
authMethods, err := buildAuthMethods(cfg.Password, cfg.KeyPath, cfg.KeyData, cfg.KeyPassphrase)
@@ -545,11 +547,14 @@ func ensureKnownHostsFile() (string, error) {
545547
return khPath, nil
546548
}
547549

548-
func withDefaultTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
550+
func withDefaultTimeout(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
549551
if _, ok := ctx.Deadline(); ok {
550552
return ctx, func() {}
551553
}
552-
return context.WithTimeout(ctx, defaultDialTimeout)
554+
if timeout <= 0 {
555+
timeout = defaultDialTimeout
556+
}
557+
return context.WithTimeout(ctx, timeout)
553558
}
554559

555560
func dialSSHClientWithContext(ctx context.Context, addr string, config *ssh.ClientConfig) (*ssh.Client, error) {

internal/tui/app.go

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"os"
77
"path/filepath"
88
"strings"
9+
"time"
910

1011
"github.com/charmbracelet/bubbles/textinput"
1112
tea "github.com/charmbracelet/bubbletea"
@@ -126,6 +127,13 @@ func zeroBytes(b []byte) {
126127
}
127128
}
128129

130+
func hostDialTimeout(h host.Host) time.Duration {
131+
if h.TimeoutSec <= 0 {
132+
return 10 * time.Second
133+
}
134+
return time.Duration(h.TimeoutSec) * time.Second
135+
}
136+
129137
func newPasswordInput(placeholder string) textinput.Model {
130138
ti := textinput.New()
131139
ti.Placeholder = placeholder
@@ -968,12 +976,13 @@ func verifyHostBeforeSave(h host.Host, encKey []byte) (host.Host, error) {
968976
}
969977

970978
err := sshclient.Verify(sshclient.VerifyConfig{
971-
Host: h.Hostname,
972-
Port: h.Port,
973-
User: username,
974-
Password: password,
975-
KeyPath: resolved.KeyPath,
976-
KeyData: keyData,
979+
Host: h.Hostname,
980+
Port: h.Port,
981+
DialTimeout: hostDialTimeout(h),
982+
User: username,
983+
Password: password,
984+
KeyPath: resolved.KeyPath,
985+
KeyData: keyData,
977986
})
978987
zeroBytes(password)
979988
zeroBytes(keyData)

internal/tui/dashboard.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ func (m model) connectSSHWithResolved(h host.Host, user string, resolved host.Re
208208
sess := &sshclient.Session{
209209
Host: h.Hostname,
210210
Port: h.Port,
211+
DialTimeout: hostDialTimeout(h),
211212
User: user,
212213
Password: password,
213214
KeyPath: keyPath,
@@ -484,6 +485,7 @@ func (m model) renderDetails() string {
484485
render("Host", h.Hostname),
485486
render("Users", strings.Join(users, ", ")),
486487
render("Port", fmt.Sprintf("%d", h.Port)),
488+
render("Timeout", fmt.Sprintf("%ds", h.TimeoutSec)),
487489
render("Group", h.Group),
488490
render("Tags", strings.Join(h.Tags, ", ")),
489491
}

internal/tui/healthcheck.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,17 +118,16 @@ func checkHostHealth(h host.Host, encKey []byte) hostHealthStatus {
118118
keyPassphrase = dec
119119
}
120120

121-
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
122-
err := sshclient.VerifyWithContext(ctx, sshclient.VerifyConfig{
121+
err := sshclient.Verify(sshclient.VerifyConfig{
123122
Host: h.Hostname,
124123
Port: h.Port,
124+
DialTimeout: hostDialTimeout(h),
125125
User: username,
126126
Password: password,
127127
KeyPath: resolved.KeyPath,
128128
KeyData: keyData,
129129
KeyPassphrase: keyPassphrase,
130130
})
131-
cancel()
132131
zeroBytes(password)
133132
zeroBytes(keyData)
134133
zeroBytes(keyPassphrase)

0 commit comments

Comments
 (0)