Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2951,6 +2951,57 @@ endpoints:
- "[STATUS] == 0"
```

You can also use the `[BODY]` placeholder to validate the command output:
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove the two configuration examples you added, it's a bit excessive

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two examples have been removed


```yaml
endpoints:
- name: ssh-body-check
url: "ssh://example.com:22"
ssh:
username: "username"
password: "password"
body: |
{
"command": "echo 'system is healthy'"
}
interval: 1m
conditions:
- "[CONNECTED] == true"
- "[STATUS] == 0"
- "[BODY] == system is healthy"
- "[BODY] == pat(*healthy*)"
- "len([BODY]) == 16"
- "has([BODY]) == true"
- "[BODY] != system is unhealthy"
```

For JSON output, you can use JSONPath expressions:

```yaml
endpoints:
- name: ssh-json-check
url: "ssh://example.com:22"
ssh:
username: "username"
password: "password"
body: |
{
"command": "echo '{\"status\": \"healthy\", \"memory\": {\"used\": 512, \"total\": 1024}, \"users\": [\"admin\", \"user1\"]}'"
}
interval: 1m
conditions:
- "[CONNECTED] == true"
- "[STATUS] == 0"
- "[BODY].status == healthy"
- "[BODY].memory.used > 500"
- "[BODY].memory.used < 600"
- "[BODY].memory.total == 1024"
- "len([BODY].users) == 2"
- "has([BODY].errors) == false"
- "[BODY].users[0] == admin"
- "[BODY].users == any(admin, user1)"
```

you can also use no authentication to monitor the endpoint by not specifying the username
and password fields.

Expand All @@ -2971,6 +3022,7 @@ endpoints:
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)
- `[BODY]` resolves to the stdout output of the command executed on the remote server
- `[IP]` resolves to the IP address of the server
- `[RESPONSE_TIME]` resolves to the time it took to establish the connection and execute the command

Expand Down
24 changes: 17 additions & 7 deletions client/client.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package client

import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
Expand Down Expand Up @@ -301,34 +302,43 @@ func CheckSSHBanner(address string, cfg *Config) (bool, int, error) {
}

// ExecuteSSHCommand executes a command to an address using the SSH protocol.
func ExecuteSSHCommand(sshClient *ssh.Client, body string, config *Config) (bool, int, error) {
func ExecuteSSHCommand(sshClient *ssh.Client, body string, config *Config) (bool, int, []byte, error) {
type Body struct {
Command string `json:"command"`
}
defer sshClient.Close()
var b Body
body = parseLocalAddressPlaceholder(body, sshClient.Conn.LocalAddr())
if err := json.Unmarshal([]byte(body), &b); err != nil {
return false, 0, err
return false, 0, nil, err
}
sess, err := sshClient.NewSession()
if err != nil {
return false, 0, err
return false, 0, nil, err
}

// Capture stdout
var stdout bytes.Buffer
sess.Stdout = &stdout

err = sess.Start(b.Command)
if err != nil {
return false, 0, err
return false, 0, nil, err
}
defer sess.Close()
err = sess.Wait()

// Get the output
output := stdout.Bytes()

if err == nil {
return true, 0, nil
return true, 0, output, nil
}
var exitErr *ssh.ExitError
if ok := errors.As(err, &exitErr); !ok {
return false, 0, err
return false, 0, nil, err
}
return true, exitErr.ExitStatus(), nil
return true, exitErr.ExitStatus(), output, nil
}

// Ping checks if an address can be pinged and returns the round-trip time if the address can be pinged
Expand Down
7 changes: 6 additions & 1 deletion config/endpoint/endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -514,11 +514,16 @@ func (e *Endpoint) call(result *Result) {
result.AddError(err.Error())
return
}
result.Success, result.HTTPStatus, err = client.ExecuteSSHCommand(cli, e.getParsedBody(), e.ClientConfig)
var output []byte
result.Success, result.HTTPStatus, output, err = client.ExecuteSSHCommand(cli, e.getParsedBody(), e.ClientConfig)
if err != nil {
result.AddError(err.Error())
return
}
// Only store the output in result.Body if there's a condition that uses the BodyPlaceholder
if e.needsToReadBody() {
result.Body = output
}
result.Duration = time.Since(startTime)
} else {
response, err = client.GetHTTPClient(e.ClientConfig).Do(request)
Expand Down
87 changes: 87 additions & 0 deletions config/endpoint/endpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -834,6 +834,93 @@ func TestIntegrationEvaluateHealthForSSH(t *testing.T) {
conditions: []Condition{Condition("[STATUS] == 1")},
success: false,
},
{
name: "ssh-body-basic",
endpoint: Endpoint{
Name: "ssh-body-basic",
URL: "ssh://localhost",
SSHConfig: &ssh.Config{
Username: "scenario",
Password: "scenario",
},
Body: "{ \"command\": \"echo 'test-output'\" }",
},
conditions: []Condition{
Condition("[STATUS] == 0"),
Condition("[BODY] == test-output"),
},
success: true,
},
{
name: "ssh-body-pattern",
endpoint: Endpoint{
Name: "ssh-body-pattern",
URL: "ssh://localhost",
SSHConfig: &ssh.Config{
Username: "scenario",
Password: "scenario",
},
Body: "{ \"command\": \"echo 'test-pattern-match'\" }",
},
conditions: []Condition{
Condition("[STATUS] == 0"),
Condition("[BODY] == pat(*pattern*)"),
},
success: true,
},
{
name: "ssh-body-json-path",
endpoint: Endpoint{
Name: "ssh-body-json",
URL: "ssh://localhost",
SSHConfig: &ssh.Config{
Username: "scenario",
Password: "scenario",
},
Body: "{ \"command\": \"echo '{\\\"status\\\": \\\"healthy\\\", \\\"memory\\\": {\\\"used\\\": 512}}'\" }",
},
conditions: []Condition{
Condition("[STATUS] == 0"),
Condition("[BODY].status == healthy"),
Condition("[BODY].memory.used > 500"),
},
success: true,
},
{
name: "ssh-body-functions",
endpoint: Endpoint{
Name: "ssh-body-functions",
URL: "ssh://localhost",
SSHConfig: &ssh.Config{
Username: "scenario",
Password: "scenario",
},
Body: "{ \"command\": \"echo -n '12345'\" }",
},
conditions: []Condition{
Condition("[STATUS] == 0"),
Condition("len([BODY]) == 5"),
Condition("has([BODY]) == true"),
},
success: true,
},
{
name: "ssh-command-fail-with-body",
endpoint: Endpoint{
Name: "ssh-command-fail-body",
URL: "ssh://localhost",
SSHConfig: &ssh.Config{
Username: "scenario",
Password: "scenario",
},
Body: "{ \"command\": \"echo 'error message'; exit 1\" }",
},
conditions: []Condition{
Condition("[STATUS] == 1"),
Condition("[BODY] == pat(*error*)"),
},
success: true,
},
}

for _, scenario := range scenarios {
Expand Down