From 6f9db4107c79b8ff03f18e6b491854b2ef31b63f Mon Sep 17 00:00:00 2001 From: Mufeed Ali Date: Thu, 20 Nov 2025 03:06:36 +0530 Subject: [PATCH] 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 --- README.md | 22 ++++++++++++++++++--- client/client.go | 24 +++++++++++++++++------ config/endpoint/condition.go | 4 +--- config/endpoint/endpoint.go | 6 +++--- config/endpoint/endpoint_test.go | 33 +++++++++++++++++++++++--------- config/endpoint/ssh/ssh.go | 19 ++++++++++-------- config/endpoint/ssh/ssh_test.go | 21 +++++++++++++++++--- 7 files changed, 94 insertions(+), 35 deletions(-) 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/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", - username: "username", - password: "", - expectedErr: ssh.ErrEndpointWithoutSSHPassword, + name: "fail when has no user but has private key", + username: "", + privateKey: "-----BEGIN", + expectedErr: ssh.ErrEndpointWithoutSSHUsername, }, { - name: "success when all fields are set", + name: "fail when has no password or private key", + username: "username", + password: "", + privateKey: "", + expectedErr: ssh.ErrEndpointWithoutSSHAuth, + }, + { + 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.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 } 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) + } + }) +}