feat(ssh): Support authless SSH health check (#956)

* Feature + Test +  Documentation: added no-auth ssh health cheack feature, changed documentation to fit new behavior, added ssh test cases.

* Refactor: refactored authenticate field to infer from username and password insted of specifying it inside config.

* Refactor: removed non used field.

* Refactor: changed error, removed spaces.

* Refactor: added comments.
This commit is contained in:
Rani 2025-01-19 23:22:41 +02:00 committed by GitHub
parent 0bba77ab2b
commit fa3e5dcc6e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 99 additions and 4 deletions

View File

@ -2058,6 +2058,23 @@ endpoints:
- "[STATUS] == 0"
```
you can also use no authentication to monitor the endpoint by not specifying the username
and password fields.
```yaml
endpoints:
- name: ssh-example
url: "ssh://example.com:22" # port is optional. Default is 22.
ssh:
username: ""
password: ""
interval: 1m
conditions:
- "[CONNECTED] == true"
- "[STATUS] == 0"
```
The following placeholders are supported for endpoints of type SSH:
- `[CONNECTED]` resolves to `true` if the SSH connection was successful, `false` otherwise
- `[STATUS]` resolves the exit code of the command executed on the remote server (e.g. `0` for success)

View File

@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/smtp"
@ -197,6 +198,34 @@ func CanCreateSSHConnection(address, username, password string, config *Config)
return true, cli, nil
}
func CheckSSHBanner(address string, cfg *Config) (bool, int, error) {
var port string
if strings.Contains(address, ":") {
addressAndPort := strings.Split(address, ":")
if len(addressAndPort) != 2 {
return false, 1, errors.New("invalid address for ssh, format must be ssh://host:port")
}
address = addressAndPort[0]
port = addressAndPort[1]
} else {
port = "22"
}
dialer := net.Dialer{}
connStr := net.JoinHostPort(address, port)
conn, err := dialer.Dial("tcp", connStr)
if err != nil {
return false, 1, err
}
defer conn.Close()
conn.SetReadDeadline(time.Now().Add(time.Second))
buf := make([]byte, 256)
_, err = io.ReadAtLeast(conn, buf, 1)
if err != nil {
return false, 1, err
}
return true, 0, err
}
// ExecuteSSHCommand executes a command to an address using the SSH protocol.
func ExecuteSSHCommand(sshClient *ssh.Client, body string, config *Config) (bool, int, error) {
type Body struct {

View File

@ -474,3 +474,38 @@ func TestQueryDNS(t *testing.T) {
time.Sleep(10 * time.Millisecond)
}
}
func TestCheckSSHBanner(t *testing.T) {
cfg := &Config{Timeout: 3}
t.Run("no-auth-ssh", func(t *testing.T) {
connected, status, err := CheckSSHBanner("tty.sdf.org", cfg)
if err != nil {
t.Errorf("Expected: error != nil, got: %v ", err)
}
if connected == false {
t.Errorf("Expected: connected == true, got: %v", connected)
}
if status != 0 {
t.Errorf("Expected: 0, got: %v", status)
}
})
t.Run("invalid-address", func(t *testing.T) {
connected, status, err := CheckSSHBanner("idontplaytheodds.com", cfg)
if err == nil {
t.Errorf("Expected: error, got: %v ", err)
}
if connected != false {
t.Errorf("Expected: connected == false, got: %v", connected)
}
if status != 1 {
t.Errorf("Expected: 1, got: %v", status)
}
})
}

View File

@ -363,6 +363,18 @@ 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 len(e.SSHConfig.Username) == 0 && len(e.SSHConfig.Password) == 0 {
result.Connected, result.HTTPStatus, err =
client.CheckSSHBanner(strings.TrimPrefix(e.URL, "ssh://"), e.ClientConfig)
if err != nil {
result.AddError(err.Error())
return
}
result.Success = result.Connected
result.Duration = time.Since(startTime)
return
}
var cli *ssh.Client
result.Connected, cli, err = client.CanCreateSSHConnection(strings.TrimPrefix(e.URL, "ssh://"), e.SSHConfig.Username, e.SSHConfig.Password, e.ClientConfig)
if err != nil {

View File

@ -19,6 +19,10 @@ type Config struct {
// 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 {
return nil
}
if len(cfg.Username) == 0 {
return ErrEndpointWithoutSSHUsername
}

View File

@ -7,10 +7,8 @@ import (
func TestSSH_validate(t *testing.T) {
cfg := &Config{}
if err := cfg.Validate(); err == nil {
t.Error("expected an error")
} else if !errors.Is(err, ErrEndpointWithoutSSHUsername) {
t.Errorf("expected error to be '%v', got '%v'", ErrEndpointWithoutSSHUsername, err)
if err := cfg.Validate(); err != nil {
t.Error("didn't expect an error")
}
cfg.Username = "username"
if err := cfg.Validate(); err == nil {