Skip to content

Commit a4e7334

Browse files
committed
feat(endpoint): Add ssh key support
Fixes #1257
1 parent fe7b74f commit a4e7334

File tree

6 files changed

+52
-23
lines changed

6 files changed

+52
-23
lines changed

README.md

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3046,7 +3046,8 @@ There are two placeholders that can be used in the conditions for endpoints of t
30463046
You can monitor endpoints using SSH by prefixing `endpoints[].url` with `ssh://`:
30473047
```yaml
30483048
endpoints:
3049-
- name: ssh-example
3049+
# Password-based SSH example
3050+
- name: ssh-example-password
30503051
url: "ssh://example.com:22" # port is optional. Default is 22.
30513052
ssh:
30523053
username: "username"
@@ -3060,10 +3061,24 @@ endpoints:
30603061
- "[CONNECTED] == true"
30613062
- "[STATUS] == 0"
30623063
- "[BODY].memory.used > 500"
3064+
3065+
# Key-based SSH example
3066+
- name: ssh-example-key
3067+
url: "ssh://example.com:22" # port is optional. Default is 22.
3068+
ssh:
3069+
username: "username"
3070+
private-key: |
3071+
-----BEGIN RSA PRIVATE KEY-----
3072+
TESTRSAKEY...
3073+
-----END RSA PRIVATE KEY-----
3074+
interval: 1m
3075+
conditions:
3076+
- "[CONNECTED] == true"
3077+
- "[STATUS] == 0"
30633078
```
30643079

3065-
you can also use no authentication to monitor the endpoint by not specifying the username
3066-
and password fields.
3080+
you can also use no authentication to monitor the endpoint by not specifying the username,
3081+
password and private key fields.
30673082

30683083
```yaml
30693084
endpoints:
@@ -3072,6 +3087,7 @@ endpoints:
30723087
ssh:
30733088
username: ""
30743089
password: ""
3090+
private-key: ""
30753091
30763092
interval: 1m
30773093
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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1605,7 +1605,7 @@ func TestEndpoint_HideUIFeatures(t *testing.T) {
16051605
}
16061606
}
16071607
if tt.checkConditions {
1608-
hasConditions := result.ConditionResults != nil && len(result.ConditionResults) > 0
1608+
hasConditions := len(result.ConditionResults) > 0
16091609
if hasConditions != tt.expectConditions {
16101610
t.Errorf("Expected conditions=%v, got conditions=%v (actual: %v)", tt.expectConditions, hasConditions, result.ConditionResults)
16111611
}

config/endpoint/ssh/ssh.go

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,25 +8,28 @@ 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+
// ErrEndpointWithoutSSHPassword is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a password or private key.
12+
ErrEndpointWithoutSSHPassword = 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 {
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 {
3033
return ErrEndpointWithoutSSHPassword
3134
}
3235
return nil

0 commit comments

Comments
 (0)