Skip to content

Commit 6f9db41

Browse files
mufeedaliTwiN
andauthored
feat(client): Add ssh private-key support (#1390)
* feat(endpoint): Add ssh key support Fixes #1257 * test(config): Add tests for private key config --------- Co-authored-by: TwiN <[email protected]>
1 parent 5d626f2 commit 6f9db41

File tree

7 files changed

+92
-33
lines changed

7 files changed

+92
-33
lines changed

README.md

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3048,7 +3048,8 @@ There are two placeholders that can be used in the conditions for endpoints of t
30483048
You can monitor endpoints using SSH by prefixing `endpoints[].url` with `ssh://`:
30493049
```yaml
30503050
endpoints:
3051-
- name: ssh-example
3051+
# Password-based SSH example
3052+
- name: ssh-example-password
30523053
url: "ssh://example.com:22" # port is optional. Default is 22.
30533054
ssh:
30543055
username: "username"
@@ -3062,10 +3063,24 @@ endpoints:
30623063
- "[CONNECTED] == true"
30633064
- "[STATUS] == 0"
30643065
- "[BODY].memory.used > 500"
3066+
3067+
# Key-based SSH example
3068+
- name: ssh-example-key
3069+
url: "ssh://example.com:22" # port is optional. Default is 22.
3070+
ssh:
3071+
username: "username"
3072+
private-key: |
3073+
-----BEGIN RSA PRIVATE KEY-----
3074+
TESTRSAKEY...
3075+
-----END RSA PRIVATE KEY-----
3076+
interval: 1m
3077+
conditions:
3078+
- "[CONNECTED] == true"
3079+
- "[STATUS] == 0"
30653080
```
30663081

3067-
you can also use no authentication to monitor the endpoint by not specifying the username
3068-
and password fields.
3082+
you can also use no authentication to monitor the endpoint by not specifying the username,
3083+
password and private key fields.
30693084

30703085
```yaml
30713086
endpoints:
@@ -3074,6 +3089,7 @@ endpoints:
30743089
ssh:
30753090
username: ""
30763091
password: ""
3092+
private-key: ""
30773093
30783094
interval: 1m
30793095
conditions:

client/client.go

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ func CanPerformTLS(address string, body string, config *Config) (connected bool,
248248

249249
// CanCreateSSHConnection checks whether a connection can be established and a command can be executed to an address
250250
// using the SSH protocol.
251-
func CanCreateSSHConnection(address, username, password string, config *Config) (bool, *ssh.Client, error) {
251+
func CanCreateSSHConnection(address, username, password, privateKey string, config *Config) (bool, *ssh.Client, error) {
252252
var port string
253253
if strings.Contains(address, ":") {
254254
addressAndPort := strings.Split(address, ":")
@@ -260,13 +260,25 @@ func CanCreateSSHConnection(address, username, password string, config *Config)
260260
} else {
261261
port = "22"
262262
}
263-
cli, err := ssh.Dial("tcp", strings.Join([]string{address, port}, ":"), &ssh.ClientConfig{
263+
264+
// Build auth methods: prefer parsed private key if provided, fall back to password.
265+
var authMethods []ssh.AuthMethod
266+
if len(privateKey) > 0 {
267+
if signer, err := ssh.ParsePrivateKey([]byte(privateKey)); err == nil {
268+
authMethods = append(authMethods, ssh.PublicKeys(signer))
269+
} else {
270+
return false, nil, fmt.Errorf("invalid private key: %w", err)
271+
}
272+
}
273+
if len(password) > 0 {
274+
authMethods = append(authMethods, ssh.Password(password))
275+
}
276+
277+
cli, err := ssh.Dial("tcp", net.JoinHostPort(address, port), &ssh.ClientConfig{
264278
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
265279
User: username,
266-
Auth: []ssh.AuthMethod{
267-
ssh.Password(password),
268-
},
269-
Timeout: config.Timeout,
280+
Auth: authMethods,
281+
Timeout: config.Timeout,
270282
})
271283
if err != nil {
272284
return false, nil, err

config/endpoint/condition.go

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -244,9 +244,7 @@ func formatDuration(d time.Duration) string {
244244
if strings.HasSuffix(s, "0s") {
245245
s = strings.TrimSuffix(s, "0s")
246246
// Remove trailing "0m" if present after removing "0s"
247-
if strings.HasSuffix(s, "0m") {
248-
s = strings.TrimSuffix(s, "0m")
249-
}
247+
s = strings.TrimSuffix(s, "0m")
250248
}
251249
return s
252250
}

config/endpoint/endpoint.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -503,8 +503,8 @@ func (e *Endpoint) call(result *Result) {
503503
}
504504
result.Duration = time.Since(startTime)
505505
} else if endpointType == TypeSSH {
506-
// If there's no username/password specified, attempt to validate just the SSH banner
507-
if e.SSHConfig == nil || (len(e.SSHConfig.Username) == 0 && len(e.SSHConfig.Password) == 0) {
506+
// If there's no username, password or private key specified, attempt to validate just the SSH banner
507+
if e.SSHConfig == nil || (len(e.SSHConfig.Username) == 0 && len(e.SSHConfig.Password) == 0 && len(e.SSHConfig.PrivateKey) == 0) {
508508
result.Connected, result.HTTPStatus, err = client.CheckSSHBanner(strings.TrimPrefix(e.URL, "ssh://"), e.ClientConfig)
509509
if err != nil {
510510
result.AddError(err.Error())
@@ -515,7 +515,7 @@ func (e *Endpoint) call(result *Result) {
515515
return
516516
}
517517
var cli *ssh.Client
518-
result.Connected, cli, err = client.CanCreateSSHConnection(strings.TrimPrefix(e.URL, "ssh://"), e.SSHConfig.Username, e.SSHConfig.Password, e.ClientConfig)
518+
result.Connected, cli, err = client.CanCreateSSHConnection(strings.TrimPrefix(e.URL, "ssh://"), e.SSHConfig.Username, e.SSHConfig.Password, e.SSHConfig.PrivateKey, e.ClientConfig)
519519
if err != nil {
520520
result.AddError(err.Error())
521521
return

config/endpoint/endpoint_test.go

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -511,26 +511,40 @@ func TestEndpoint_ValidateAndSetDefaultsWithSSH(t *testing.T) {
511511
name string
512512
username string
513513
password string
514+
privateKey string
514515
expectedErr error
515516
}{
516517
{
517-
name: "fail when has no user",
518+
name: "fail when has no user but has password",
518519
username: "",
519520
password: "password",
520521
expectedErr: ssh.ErrEndpointWithoutSSHUsername,
521522
},
522523
{
523-
name: "fail when has no password",
524+
name: "fail when has no user but has private key",
525+
username: "",
526+
privateKey: "-----BEGIN",
527+
expectedErr: ssh.ErrEndpointWithoutSSHUsername,
528+
},
529+
{
530+
name: "fail when has no password or private key",
524531
username: "username",
525532
password: "",
526-
expectedErr: ssh.ErrEndpointWithoutSSHPassword,
533+
privateKey: "",
534+
expectedErr: ssh.ErrEndpointWithoutSSHAuth,
527535
},
528536
{
529-
name: "success when all fields are set",
537+
name: "success when username and password are set",
530538
username: "username",
531539
password: "password",
532540
expectedErr: nil,
533541
},
542+
{
543+
name: "success when username and private key are set",
544+
username: "username",
545+
privateKey: "-----BEGIN",
546+
expectedErr: nil,
547+
},
534548
}
535549

536550
for _, scenario := range scenarios {
@@ -539,8 +553,9 @@ func TestEndpoint_ValidateAndSetDefaultsWithSSH(t *testing.T) {
539553
Name: "ssh-test",
540554
URL: "https://example.com",
541555
SSHConfig: &ssh.Config{
542-
Username: scenario.username,
543-
Password: scenario.password,
556+
Username: scenario.username,
557+
Password: scenario.password,
558+
PrivateKey: scenario.privateKey,
544559
},
545560
Conditions: []Condition{Condition("[STATUS] == 0")},
546561
}
@@ -1605,7 +1620,7 @@ func TestEndpoint_HideUIFeatures(t *testing.T) {
16051620
}
16061621
}
16071622
if tt.checkConditions {
1608-
hasConditions := result.ConditionResults != nil && len(result.ConditionResults) > 0
1623+
hasConditions := len(result.ConditionResults) > 0
16091624
if hasConditions != tt.expectConditions {
16101625
t.Errorf("Expected conditions=%v, got conditions=%v (actual: %v)", tt.expectConditions, hasConditions, result.ConditionResults)
16111626
}

config/endpoint/ssh/ssh.go

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,26 +8,29 @@ var (
88
// ErrEndpointWithoutSSHUsername is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a user.
99
ErrEndpointWithoutSSHUsername = errors.New("you must specify a username for each SSH endpoint")
1010

11-
// ErrEndpointWithoutSSHPassword is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a password.
12-
ErrEndpointWithoutSSHPassword = errors.New("you must specify a password for each SSH endpoint")
11+
// ErrEndpointWithoutSSHAuth is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a password or private key.
12+
ErrEndpointWithoutSSHAuth = errors.New("you must specify a password or private-key for each SSH endpoint")
1313
)
1414

1515
type Config struct {
16-
Username string `yaml:"username,omitempty"`
17-
Password string `yaml:"password,omitempty"`
16+
Username string `yaml:"username,omitempty"`
17+
Password string `yaml:"password,omitempty"`
18+
PrivateKey string `yaml:"private-key,omitempty"`
1819
}
1920

2021
// Validate the SSH configuration
2122
func (cfg *Config) Validate() error {
22-
// If there's no username and password, this endpoint can still check the SSH banner, so the endpoint is still valid
23-
if len(cfg.Username) == 0 && len(cfg.Password) == 0 {
23+
// If there's no username, password, or private key, this endpoint can still check the SSH banner, so the endpoint is still valid
24+
if len(cfg.Username) == 0 && len(cfg.Password) == 0 && len(cfg.PrivateKey) == 0 {
2425
return nil
2526
}
27+
// If any authentication method is provided (password or private key), a username is required
2628
if len(cfg.Username) == 0 {
2729
return ErrEndpointWithoutSSHUsername
2830
}
29-
if len(cfg.Password) == 0 {
30-
return ErrEndpointWithoutSSHPassword
31+
// If a username is provided, require at least a password or a private key
32+
if len(cfg.Password) == 0 && len(cfg.PrivateKey) == 0 {
33+
return ErrEndpointWithoutSSHAuth
3134
}
3235
return nil
3336
}

config/endpoint/ssh/ssh_test.go

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,34 @@ import (
55
"testing"
66
)
77

8-
func TestSSH_validate(t *testing.T) {
8+
func TestSSH_validatePasswordCfg(t *testing.T) {
99
cfg := &Config{}
1010
if err := cfg.Validate(); err != nil {
1111
t.Error("didn't expect an error")
1212
}
1313
cfg.Username = "username"
1414
if err := cfg.Validate(); err == nil {
1515
t.Error("expected an error")
16-
} else if !errors.Is(err, ErrEndpointWithoutSSHPassword) {
17-
t.Errorf("expected error to be '%v', got '%v'", ErrEndpointWithoutSSHPassword, err)
16+
} else if !errors.Is(err, ErrEndpointWithoutSSHAuth) {
17+
t.Errorf("expected error to be '%v', got '%v'", ErrEndpointWithoutSSHAuth, err)
1818
}
1919
cfg.Password = "password"
2020
if err := cfg.Validate(); err != nil {
2121
t.Errorf("expected no error, got '%v'", err)
2222
}
2323
}
24+
25+
func TestSSH_validatePrivateKeyCfg(t *testing.T) {
26+
t.Run("fail when username missing but private key provided", func(t *testing.T) {
27+
cfg := &Config{PrivateKey: "-----BEGIN"}
28+
if err := cfg.Validate(); !errors.Is(err, ErrEndpointWithoutSSHUsername) {
29+
t.Fatalf("expected ErrEndpointWithoutSSHUsername, got %v", err)
30+
}
31+
})
32+
t.Run("success when username with private key", func(t *testing.T) {
33+
cfg := &Config{Username: "user", PrivateKey: "-----BEGIN"}
34+
if err := cfg.Validate(); err != nil {
35+
t.Fatalf("expected no error, got %v", err)
36+
}
37+
})
38+
}

0 commit comments

Comments
 (0)