1
0
mirror of https://github.com/TwiN/gatus.git synced 2026-02-16 14:58:40 +00:00

feat(ssh): Add BODY placeholder support for SSH endpoints

- Modify ExecuteSSHCommand to capture stdout output
- Update SSH endpoint handling to use needsToReadBody() mechanism
- Add comprehensive test cases for SSH BODY functionality
- Support basic body content, pattern matching, JSONPath, and functions
- Maintain backward compatibility with existing SSH endpoints
This commit is contained in:
YanSH
2025-09-23 09:34:33 +08:00
parent 5a06a74cc3
commit 25d1e1044e
3 changed files with 110 additions and 8 deletions

View File

@@ -1,6 +1,7 @@
package client package client
import ( import (
"bytes"
"context" "context"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
@@ -301,7 +302,7 @@ func CheckSSHBanner(address string, cfg *Config) (bool, int, error) {
} }
// ExecuteSSHCommand executes a command to an address using the SSH protocol. // 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 { type Body struct {
Command string `json:"command"` Command string `json:"command"`
} }
@@ -309,26 +310,35 @@ func ExecuteSSHCommand(sshClient *ssh.Client, body string, config *Config) (bool
var b Body var b Body
body = parseLocalAddressPlaceholder(body, sshClient.Conn.LocalAddr()) body = parseLocalAddressPlaceholder(body, sshClient.Conn.LocalAddr())
if err := json.Unmarshal([]byte(body), &b); err != nil { if err := json.Unmarshal([]byte(body), &b); err != nil {
return false, 0, err return false, 0, nil, err
} }
sess, err := sshClient.NewSession() sess, err := sshClient.NewSession()
if err != nil { 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) err = sess.Start(b.Command)
if err != nil { if err != nil {
return false, 0, err return false, 0, nil, err
} }
defer sess.Close() defer sess.Close()
err = sess.Wait() err = sess.Wait()
// Get the output
output := stdout.Bytes()
if err == nil { if err == nil {
return true, 0, nil return true, 0, output, nil
} }
var exitErr *ssh.ExitError var exitErr *ssh.ExitError
if ok := errors.As(err, &exitErr); !ok { 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 // Ping checks if an address can be pinged and returns the round-trip time if the address can be pinged

View File

@@ -514,11 +514,16 @@ func (e *Endpoint) call(result *Result) {
result.AddError(err.Error()) result.AddError(err.Error())
return 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 { if err != nil {
result.AddError(err.Error()) result.AddError(err.Error())
return 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) result.Duration = time.Since(startTime)
} else { } else {
response, err = client.GetHTTPClient(e.ClientConfig).Do(request) response, err = client.GetHTTPClient(e.ClientConfig).Do(request)

View File

@@ -834,6 +834,93 @@ func TestIntegrationEvaluateHealthForSSH(t *testing.T) {
conditions: []Condition{Condition("[STATUS] == 1")}, conditions: []Condition{Condition("[STATUS] == 1")},
success: false, 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 { for _, scenario := range scenarios {