mirror of
https://github.com/TwiN/gatus.git
synced 2026-02-04 12:56:48 +00:00
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 <twin@linux.com>
This commit is contained in:
22
README.md
22
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://`:
|
You can monitor endpoints using SSH by prefixing `endpoints[].url` with `ssh://`:
|
||||||
```yaml
|
```yaml
|
||||||
endpoints:
|
endpoints:
|
||||||
- name: ssh-example
|
# Password-based SSH example
|
||||||
|
- name: ssh-example-password
|
||||||
url: "ssh://example.com:22" # port is optional. Default is 22.
|
url: "ssh://example.com:22" # port is optional. Default is 22.
|
||||||
ssh:
|
ssh:
|
||||||
username: "username"
|
username: "username"
|
||||||
@@ -3062,10 +3063,24 @@ endpoints:
|
|||||||
- "[CONNECTED] == true"
|
- "[CONNECTED] == true"
|
||||||
- "[STATUS] == 0"
|
- "[STATUS] == 0"
|
||||||
- "[BODY].memory.used > 500"
|
- "[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
|
you can also use no authentication to monitor the endpoint by not specifying the username,
|
||||||
and password fields.
|
password and private key fields.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
endpoints:
|
endpoints:
|
||||||
@@ -3074,6 +3089,7 @@ endpoints:
|
|||||||
ssh:
|
ssh:
|
||||||
username: ""
|
username: ""
|
||||||
password: ""
|
password: ""
|
||||||
|
private-key: ""
|
||||||
|
|
||||||
interval: 1m
|
interval: 1m
|
||||||
conditions:
|
conditions:
|
||||||
|
|||||||
@@ -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
|
// CanCreateSSHConnection checks whether a connection can be established and a command can be executed to an address
|
||||||
// using the SSH protocol.
|
// 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
|
var port string
|
||||||
if strings.Contains(address, ":") {
|
if strings.Contains(address, ":") {
|
||||||
addressAndPort := strings.Split(address, ":")
|
addressAndPort := strings.Split(address, ":")
|
||||||
@@ -260,12 +260,24 @@ func CanCreateSSHConnection(address, username, password string, config *Config)
|
|||||||
} else {
|
} else {
|
||||||
port = "22"
|
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(),
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||||
User: username,
|
User: username,
|
||||||
Auth: []ssh.AuthMethod{
|
Auth: authMethods,
|
||||||
ssh.Password(password),
|
|
||||||
},
|
|
||||||
Timeout: config.Timeout,
|
Timeout: config.Timeout,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -244,10 +244,8 @@ func formatDuration(d time.Duration) string {
|
|||||||
if strings.HasSuffix(s, "0s") {
|
if strings.HasSuffix(s, "0s") {
|
||||||
s = strings.TrimSuffix(s, "0s")
|
s = strings.TrimSuffix(s, "0s")
|
||||||
// Remove trailing "0m" if present after removing "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
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -503,8 +503,8 @@ func (e *Endpoint) call(result *Result) {
|
|||||||
}
|
}
|
||||||
result.Duration = time.Since(startTime)
|
result.Duration = time.Since(startTime)
|
||||||
} else if endpointType == TypeSSH {
|
} else if endpointType == TypeSSH {
|
||||||
// If there's no username/password specified, attempt to validate just the SSH banner
|
// 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) {
|
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)
|
result.Connected, result.HTTPStatus, err = client.CheckSSHBanner(strings.TrimPrefix(e.URL, "ssh://"), e.ClientConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.AddError(err.Error())
|
result.AddError(err.Error())
|
||||||
@@ -515,7 +515,7 @@ func (e *Endpoint) call(result *Result) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var cli *ssh.Client
|
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 {
|
if err != nil {
|
||||||
result.AddError(err.Error())
|
result.AddError(err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -511,26 +511,40 @@ func TestEndpoint_ValidateAndSetDefaultsWithSSH(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
username string
|
username string
|
||||||
password string
|
password string
|
||||||
|
privateKey string
|
||||||
expectedErr error
|
expectedErr error
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "fail when has no user",
|
name: "fail when has no user but has password",
|
||||||
username: "",
|
username: "",
|
||||||
password: "password",
|
password: "password",
|
||||||
expectedErr: ssh.ErrEndpointWithoutSSHUsername,
|
expectedErr: ssh.ErrEndpointWithoutSSHUsername,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "fail when has no password",
|
name: "fail when has no user but has private key",
|
||||||
username: "username",
|
username: "",
|
||||||
password: "",
|
privateKey: "-----BEGIN",
|
||||||
expectedErr: ssh.ErrEndpointWithoutSSHPassword,
|
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",
|
username: "username",
|
||||||
password: "password",
|
password: "password",
|
||||||
expectedErr: nil,
|
expectedErr: nil,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "success when username and private key are set",
|
||||||
|
username: "username",
|
||||||
|
privateKey: "-----BEGIN",
|
||||||
|
expectedErr: nil,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
@@ -541,6 +555,7 @@ func TestEndpoint_ValidateAndSetDefaultsWithSSH(t *testing.T) {
|
|||||||
SSHConfig: &ssh.Config{
|
SSHConfig: &ssh.Config{
|
||||||
Username: scenario.username,
|
Username: scenario.username,
|
||||||
Password: scenario.password,
|
Password: scenario.password,
|
||||||
|
PrivateKey: scenario.privateKey,
|
||||||
},
|
},
|
||||||
Conditions: []Condition{Condition("[STATUS] == 0")},
|
Conditions: []Condition{Condition("[STATUS] == 0")},
|
||||||
}
|
}
|
||||||
@@ -1605,7 +1620,7 @@ func TestEndpoint_HideUIFeatures(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if tt.checkConditions {
|
if tt.checkConditions {
|
||||||
hasConditions := result.ConditionResults != nil && len(result.ConditionResults) > 0
|
hasConditions := len(result.ConditionResults) > 0
|
||||||
if hasConditions != tt.expectConditions {
|
if hasConditions != tt.expectConditions {
|
||||||
t.Errorf("Expected conditions=%v, got conditions=%v (actual: %v)", tt.expectConditions, hasConditions, result.ConditionResults)
|
t.Errorf("Expected conditions=%v, got conditions=%v (actual: %v)", tt.expectConditions, hasConditions, result.ConditionResults)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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")
|
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.
|
// ErrEndpointWithoutSSHAuth is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a password or private key.
|
||||||
ErrEndpointWithoutSSHPassword = errors.New("you must specify a password for each SSH endpoint")
|
ErrEndpointWithoutSSHAuth = errors.New("you must specify a password or private-key for each SSH endpoint")
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Username string `yaml:"username,omitempty"`
|
Username string `yaml:"username,omitempty"`
|
||||||
Password string `yaml:"password,omitempty"`
|
Password string `yaml:"password,omitempty"`
|
||||||
|
PrivateKey string `yaml:"private-key,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the SSH configuration
|
// Validate the SSH configuration
|
||||||
func (cfg *Config) Validate() error {
|
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 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 {
|
if len(cfg.Username) == 0 && len(cfg.Password) == 0 && len(cfg.PrivateKey) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// If any authentication method is provided (password or private key), a username is required
|
||||||
if len(cfg.Username) == 0 {
|
if len(cfg.Username) == 0 {
|
||||||
return ErrEndpointWithoutSSHUsername
|
return ErrEndpointWithoutSSHUsername
|
||||||
}
|
}
|
||||||
if len(cfg.Password) == 0 {
|
// If a username is provided, require at least a password or a private key
|
||||||
return ErrEndpointWithoutSSHPassword
|
if len(cfg.Password) == 0 && len(cfg.PrivateKey) == 0 {
|
||||||
|
return ErrEndpointWithoutSSHAuth
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSSH_validate(t *testing.T) {
|
func TestSSH_validatePasswordCfg(t *testing.T) {
|
||||||
cfg := &Config{}
|
cfg := &Config{}
|
||||||
if err := cfg.Validate(); err != nil {
|
if err := cfg.Validate(); err != nil {
|
||||||
t.Error("didn't expect an error")
|
t.Error("didn't expect an error")
|
||||||
@@ -13,11 +13,26 @@ func TestSSH_validate(t *testing.T) {
|
|||||||
cfg.Username = "username"
|
cfg.Username = "username"
|
||||||
if err := cfg.Validate(); err == nil {
|
if err := cfg.Validate(); err == nil {
|
||||||
t.Error("expected an error")
|
t.Error("expected an error")
|
||||||
} else if !errors.Is(err, ErrEndpointWithoutSSHPassword) {
|
} else if !errors.Is(err, ErrEndpointWithoutSSHAuth) {
|
||||||
t.Errorf("expected error to be '%v', got '%v'", ErrEndpointWithoutSSHPassword, err)
|
t.Errorf("expected error to be '%v', got '%v'", ErrEndpointWithoutSSHAuth, err)
|
||||||
}
|
}
|
||||||
cfg.Password = "password"
|
cfg.Password = "password"
|
||||||
if err := cfg.Validate(); err != nil {
|
if err := cfg.Validate(); err != nil {
|
||||||
t.Errorf("expected no error, got '%v'", err)
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user