Repository: TwiN/gatus

Issue:
No option for SSH key
### Describe the feature request

When testing a server via the SSH endpoint, you can specify a username and password for an actual connection. You can also leave them empty to just test whether there is a daemon listening. However, you cannot seem to use SSH-keys (in the case of passwordless authentication). Could you please add this option?

### Why do you personally want this feature to be implemented?

In our case, we hardened our servers by (amongst others) disabling password authentication. This makes for a more secure setup. I still want to test the possibility of actually logging in and running a command.

### How long have you been using this project?

Just started

### Additional information

_No response_

Hints from issue comments:

Solution patch:
diff --git a/README.md b/README.md
index d234bbdb..ebe4939d 100644
--- a/README.md
+++ b/README.md
@@ -3048,7 +3048,8 @@ There are two placeholders that can be used in the conditions for endpoints of t
 You can monitor endpoints using SSH by prefixing `endpoints[].url` with `ssh://`:
 ```yaml
 endpoints:
-  - name: ssh-example
+  # Password-based SSH example
+  - name: ssh-example-password
     url: "ssh://example.com:22" # port is optional. Default is 22.
     ssh:
       username: "username"
@@ -3062,10 +3063,24 @@ endpoints:
       - "[CONNECTED] == true"
       - "[STATUS] == 0"
       - "[BODY].memory.used > 500"
+
+  # Key-based SSH example
+  - name: ssh-example-key
+    url: "ssh://example.com:22" # port is optional. Default is 22.
+    ssh:
+      username: "username"
+      private-key: |
+        -----BEGIN RSA PRIVATE KEY-----
+        TESTRSAKEY...
+        -----END RSA PRIVATE KEY-----
+    interval: 1m
+    conditions:
+      - "[CONNECTED] == true"
+      - "[STATUS] == 0"
 ```
 
-you can also use no authentication to monitor the endpoint by not specifying the username
-and password fields.
+you can also use no authentication to monitor the endpoint by not specifying the username,
+password and private key fields.
 
 ```yaml
 endpoints:
@@ -3074,6 +3089,7 @@ endpoints:
     ssh:
       username: ""
       password: ""
+      private-key: ""
 
     interval: 1m
     conditions:
diff --git a/client/client.go b/client/client.go
index 52ed5baf..b7abf175 100644
--- a/client/client.go
+++ b/client/client.go
@@ -248,7 +248,7 @@ func CanPerformTLS(address string, body string, config *Config) (connected bool,
 
 // CanCreateSSHConnection checks whether a connection can be established and a command can be executed to an address
 // using the SSH protocol.
-func CanCreateSSHConnection(address, username, password string, config *Config) (bool, *ssh.Client, error) {
+func CanCreateSSHConnection(address, username, password, privateKey string, config *Config) (bool, *ssh.Client, error) {
 	var port string
 	if strings.Contains(address, ":") {
 		addressAndPort := strings.Split(address, ":")
@@ -260,13 +260,25 @@ func CanCreateSSHConnection(address, username, password string, config *Config)
 	} else {
 		port = "22"
 	}
-	cli, err := ssh.Dial("tcp", strings.Join([]string{address, port}, ":"), &ssh.ClientConfig{
+
+	// Build auth methods: prefer parsed private key if provided, fall back to password.
+	var authMethods []ssh.AuthMethod
+	if len(privateKey) > 0 {
+		if signer, err := ssh.ParsePrivateKey([]byte(privateKey)); err == nil {
+			authMethods = append(authMethods, ssh.PublicKeys(signer))
+		} else {
+			return false, nil, fmt.Errorf("invalid private key: %w", err)
+		}
+	}
+	if len(password) > 0 {
+		authMethods = append(authMethods, ssh.Password(password))
+	}
+
+	cli, err := ssh.Dial("tcp", net.JoinHostPort(address, port), &ssh.ClientConfig{
 		HostKeyCallback: ssh.InsecureIgnoreHostKey(),
 		User:            username,
-		Auth: []ssh.AuthMethod{
-			ssh.Password(password),
-		},
-		Timeout: config.Timeout,
+		Auth:            authMethods,
+		Timeout:         config.Timeout,
 	})
 	if err != nil {
 		return false, nil, err
diff --git a/config/endpoint/condition.go b/config/endpoint/condition.go
index fdec241e..02feb575 100644
--- a/config/endpoint/condition.go
+++ b/config/endpoint/condition.go
@@ -244,9 +244,7 @@ func formatDuration(d time.Duration) string {
 	if strings.HasSuffix(s, "0s") {
 		s = strings.TrimSuffix(s, "0s")
 		// Remove trailing "0m" if present after removing "0s"
-		if strings.HasSuffix(s, "0m") {
-			s = strings.TrimSuffix(s, "0m")
-		}
+		s = strings.TrimSuffix(s, "0m")
 	}
 	return s
 }
diff --git a/config/endpoint/endpoint.go b/config/endpoint/endpoint.go
index 240e76b9..2014e509 100644
--- a/config/endpoint/endpoint.go
+++ b/config/endpoint/endpoint.go
@@ -503,8 +503,8 @@ func (e *Endpoint) call(result *Result) {
 		}
 		result.Duration = time.Since(startTime)
 	} else if endpointType == TypeSSH {
-		// If there's no username/password specified, attempt to validate just the SSH banner
-		if e.SSHConfig == nil || (len(e.SSHConfig.Username) == 0 && len(e.SSHConfig.Password) == 0) {
+		// If there's no username, password or private key specified, attempt to validate just the SSH banner
+		if e.SSHConfig == nil || (len(e.SSHConfig.Username) == 0 && len(e.SSHConfig.Password) == 0 && len(e.SSHConfig.PrivateKey) == 0) {
 			result.Connected, result.HTTPStatus, err = client.CheckSSHBanner(strings.TrimPrefix(e.URL, "ssh://"), e.ClientConfig)
 			if err != nil {
 				result.AddError(err.Error())
@@ -515,7 +515,7 @@ func (e *Endpoint) call(result *Result) {
 			return
 		}
 		var cli *ssh.Client
-		result.Connected, cli, err = client.CanCreateSSHConnection(strings.TrimPrefix(e.URL, "ssh://"), e.SSHConfig.Username, e.SSHConfig.Password, e.ClientConfig)
+		result.Connected, cli, err = client.CanCreateSSHConnection(strings.TrimPrefix(e.URL, "ssh://"), e.SSHConfig.Username, e.SSHConfig.Password, e.SSHConfig.PrivateKey, e.ClientConfig)
 		if err != nil {
 			result.AddError(err.Error())
 			return
diff --git a/config/endpoint/ssh/ssh.go b/config/endpoint/ssh/ssh.go
index 4759e1d3..d0976781 100644
--- a/config/endpoint/ssh/ssh.go
+++ b/config/endpoint/ssh/ssh.go
@@ -8,26 +8,29 @@ var (
 	// ErrEndpointWithoutSSHUsername is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a user.
 	ErrEndpointWithoutSSHUsername = errors.New("you must specify a username for each SSH endpoint")
 
-	// ErrEndpointWithoutSSHPassword is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a password.
-	ErrEndpointWithoutSSHPassword = errors.New("you must specify a password for each SSH endpoint")
+	// ErrEndpointWithoutSSHAuth is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a password or private key.
+	ErrEndpointWithoutSSHAuth = errors.New("you must specify a password or private-key for each SSH endpoint")
 )
 
 type Config struct {
-	Username string `yaml:"username,omitempty"`
-	Password string `yaml:"password,omitempty"`
+	Username   string `yaml:"username,omitempty"`
+	Password   string `yaml:"password,omitempty"`
+	PrivateKey string `yaml:"private-key,omitempty"`
 }
 
 // Validate the SSH configuration
 func (cfg *Config) Validate() error {
-	// If there's no username and password, this endpoint can still check the SSH banner, so the endpoint is still valid
-	if len(cfg.Username) == 0 && len(cfg.Password) == 0 {
+	// If there's no username, password, or private key, this endpoint can still check the SSH banner, so the endpoint is still valid
+	if len(cfg.Username) == 0 && len(cfg.Password) == 0 && len(cfg.PrivateKey) == 0 {
 		return nil
 	}
+	// If any authentication method is provided (password or private key), a username is required
 	if len(cfg.Username) == 0 {
 		return ErrEndpointWithoutSSHUsername
 	}
-	if len(cfg.Password) == 0 {
-		return ErrEndpointWithoutSSHPassword
+	// If a username is provided, require at least a password or a private key
+	if len(cfg.Password) == 0 && len(cfg.PrivateKey) == 0 {
+		return ErrEndpointWithoutSSHAuth
 	}
 	return nil
 }



Test patch:
diff --git a/config/endpoint/endpoint_test.go b/config/endpoint/endpoint_test.go
index 740a478a..e4e3a35b 100644
--- a/config/endpoint/endpoint_test.go
+++ b/config/endpoint/endpoint_test.go
@@ -511,26 +511,40 @@ func TestEndpoint_ValidateAndSetDefaultsWithSSH(t *testing.T) {
 		name        string
 		username    string
 		password    string
+		privateKey  string
 		expectedErr error
 	}{
 		{
-			name:        "fail when has no user",
+			name:        "fail when has no user but has password",
 			username:    "",
 			password:    "password",
 			expectedErr: ssh.ErrEndpointWithoutSSHUsername,
 		},
 		{
-			name:        "fail when has no password",
+			name:        "fail when has no user but has private key",
+			username:    "",
+			privateKey:  "-----BEGIN",
+			expectedErr: ssh.ErrEndpointWithoutSSHUsername,
+		},
+		{
+			name:        "fail when has no password or private key",
 			username:    "username",
 			password:    "",
-			expectedErr: ssh.ErrEndpointWithoutSSHPassword,
+			privateKey:  "",
+			expectedErr: ssh.ErrEndpointWithoutSSHAuth,
 		},
 		{
-			name:        "success when all fields are set",
+			name:        "success when username and password are set",
 			username:    "username",
 			password:    "password",
 			expectedErr: nil,
 		},
+		{
+			name:        "success when username and private key are set",
+			username:    "username",
+			privateKey:  "-----BEGIN",
+			expectedErr: nil,
+		},
 	}
 
 	for _, scenario := range scenarios {
@@ -539,8 +553,9 @@ func TestEndpoint_ValidateAndSetDefaultsWithSSH(t *testing.T) {
 				Name: "ssh-test",
 				URL:  "https://example.com",
 				SSHConfig: &ssh.Config{
-					Username: scenario.username,
-					Password: scenario.password,
+					Username:   scenario.username,
+					Password:   scenario.password,
+					PrivateKey: scenario.privateKey,
 				},
 				Conditions: []Condition{Condition("[STATUS] == 0")},
 			}
@@ -1605,7 +1620,7 @@ func TestEndpoint_HideUIFeatures(t *testing.T) {
 				}
 			}
 			if tt.checkConditions {
-				hasConditions := result.ConditionResults != nil && len(result.ConditionResults) > 0
+				hasConditions := len(result.ConditionResults) > 0
 				if hasConditions != tt.expectConditions {
 					t.Errorf("Expected conditions=%v, got conditions=%v (actual: %v)", tt.expectConditions, hasConditions, result.ConditionResults)
 				}
diff --git a/config/endpoint/ssh/ssh_test.go b/config/endpoint/ssh/ssh_test.go
index d26fca90..13263bbb 100644
--- a/config/endpoint/ssh/ssh_test.go
+++ b/config/endpoint/ssh/ssh_test.go
@@ -5,7 +5,7 @@ import (
 	"testing"
 )
 
-func TestSSH_validate(t *testing.T) {
+func TestSSH_validatePasswordCfg(t *testing.T) {
 	cfg := &Config{}
 	if err := cfg.Validate(); err != nil {
 		t.Error("didn't expect an error")
@@ -13,11 +13,26 @@ func TestSSH_validate(t *testing.T) {
 	cfg.Username = "username"
 	if err := cfg.Validate(); err == nil {
 		t.Error("expected an error")
-	} else if !errors.Is(err, ErrEndpointWithoutSSHPassword) {
-		t.Errorf("expected error to be '%v', got '%v'", ErrEndpointWithoutSSHPassword, err)
+	} else if !errors.Is(err, ErrEndpointWithoutSSHAuth) {
+		t.Errorf("expected error to be '%v', got '%v'", ErrEndpointWithoutSSHAuth, err)
 	}
 	cfg.Password = "password"
 	if err := cfg.Validate(); err != nil {
 		t.Errorf("expected no error, got '%v'", err)
 	}
 }
+
+func TestSSH_validatePrivateKeyCfg(t *testing.T) {
+	t.Run("fail when username missing but private key provided", func(t *testing.T) {
+		cfg := &Config{PrivateKey: "-----BEGIN"}
+		if err := cfg.Validate(); !errors.Is(err, ErrEndpointWithoutSSHUsername) {
+			t.Fatalf("expected ErrEndpointWithoutSSHUsername, got %v", err)
+		}
+	})
+	t.Run("success when username with private key", func(t *testing.T) {
+		cfg := &Config{Username: "user", PrivateKey: "-----BEGIN"}
+		if err := cfg.Validate(); err != nil {
+			t.Fatalf("expected no error, got %v", err)
+		}
+	})
+}