diff --git a/CHANGELOG.md b/CHANGELOG.md index 5800cf7598..a394d5c25c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ Main (unreleased) - Add `otelcol.receiver.solace` component to receive traces from a Solace broker. (@wildum) +- Added `prometheus.exporter.ssh` custom metrics via ssh for remote hosts. (@EHSchmitt4395) + ### Enhancements - Add second metrics sample to the support bundle to provide delta information (@dehaansa) @@ -33,7 +35,7 @@ Main (unreleased) - Fixed an issue in the `otelcol.processor.attribute` component where the actions `delete` and `hash` could not be used with the `pattern` argument. (@wildum) -- Fixed a race condition that could lead to a deadlock when using `import` statements, which could lead to a memory leak on `/metrics` endpoint of an Alloy instance. (@thampiotr) +- Fixed a race condition that could lead to a deadlock when using `import` statements, which could lead to a memory leak on `/metrics` endpoint of an Alloy instance. (@thampiotr) ### Other changes @@ -86,7 +88,7 @@ v1.5.0 - Add support for relative paths to `import.file`. This new functionality allows users to use `import.file` blocks in modules imported via `import.git` and other `import.file`. (@wildum) -- `prometheus.exporter.cloudwatch`: The `discovery` block now has a `recently_active_only` configuration attribute +- `prometheus.exporter.cloudwatch`: The `discovery` block now has a `recently_active_only` configuration attribute to return only metrics which have been active in the last 3 hours. - Add Prometheus bearer authentication to a `prometheus.write.queue` component (@freak12techno) @@ -99,9 +101,9 @@ v1.5.0 - Fixed a bug in `import.git` which caused a `"non-fast-forward update"` error message. (@ptodev) -- Do not log error on clean shutdown of `loki.source.journal`. (@thampiotr) +- Do not log error on clean shutdown of `loki.source.journal`. (@thampiotr) -- `prometheus.operator.*` components: Fixed a bug which would sometimes cause a +- `prometheus.operator.*` components: Fixed a bug which would sometimes cause a "failed to create service discovery refresh metrics" error after a config reload. (@ptodev) ### Other changes @@ -140,7 +142,7 @@ v1.4.3 - `pyroscope.scrape` no longer tries to scrape endpoints which are not active targets anymore. (@wildum @mattdurham @dehaansa @ptodev) -- Fixed a bug with `loki.source.podlogs` not starting in large clusters due to short informer sync timeout. (@elburnetto-intapp) +- Fixed a bug with `loki.source.podlogs` not starting in large clusters due to short informer sync timeout. (@elburnetto-intapp) - `prometheus.exporter.windows`: Fixed bug with `exclude` regular expression config arguments which caused missing metrics. (@ptodev) @@ -159,7 +161,7 @@ v1.4.2 - Fix parsing of the Level configuration attribute in debug_metrics config block - Ensure "optional" debug_metrics config block really is optional -- Fixed an issue with `loki.process` where `stage.luhn` and `stage.timestamp` would not apply +- Fixed an issue with `loki.process` where `stage.luhn` and `stage.timestamp` would not apply default configuration settings correctly (@thampiotr) - Fixed an issue with `loki.process` where configuration could be reloaded even if there diff --git a/docs/sources/reference/compatibility/_index.md b/docs/sources/reference/compatibility/_index.md index 84830dabf3..fb2ab0bb8c 100644 --- a/docs/sources/reference/compatibility/_index.md +++ b/docs/sources/reference/compatibility/_index.md @@ -106,6 +106,7 @@ The following components, grouped by namespace, _export_ Targets. - [prometheus.exporter.snmp](../components/prometheus/prometheus.exporter.snmp) - [prometheus.exporter.snowflake](../components/prometheus/prometheus.exporter.snowflake) - [prometheus.exporter.squid](../components/prometheus/prometheus.exporter.squid) +- [prometheus.exporter.ssh](../components/prometheus/prometheus.exporter.ssh) - [prometheus.exporter.statsd](../components/prometheus/prometheus.exporter.statsd) - [prometheus.exporter.unix](../components/prometheus/prometheus.exporter.unix) - [prometheus.exporter.windows](../components/prometheus/prometheus.exporter.windows) diff --git a/docs/sources/reference/components/prometheus/prometheus.exporter.ssh.md b/docs/sources/reference/components/prometheus/prometheus.exporter.ssh.md new file mode 100644 index 0000000000..81a2b8e986 --- /dev/null +++ b/docs/sources/reference/components/prometheus/prometheus.exporter.ssh.md @@ -0,0 +1,205 @@ +--- +canonical: https://grafana.com/docs/alloy/latest/reference/components/prometheus/prometheus.exporter.ssh/ +aliases: + - ../prometheus.exporter.ssh/ # /docs/alloy/latest/reference/components/prometheus.exporter.ssh/ +description: Learn about prometheus.exporter.ssh +title: prometheus.exporter.ssh +--- + +# prometheus.exporter.ssh + +The `prometheus.exporter.ssh` component embeds an SSH exporter for collecting metrics from remote servers over SSH and exporting them as Prometheus metrics. + +## Usage + +``` +prometheus.exporter.ssh "LABEL" { + // Configuration options +} +``` + +## Arguments + +The following arguments can be used to configure the exporter's behavior. +All arguments are optional unless specified. Omitted fields take their default values. + +| Name | Type | Description | Default | Required | +| ----------------- | -------- | -------------------------------------------------- | ------- | -------- | +| `verbose_logging` | `bool` | Enable verbose logging for debugging purposes. | `false` | no | +| `targets` | `block` | One or more target configurations for SSH metrics. | | yes | + +## Blocks + +The following blocks are supported inside the definition of `prometheus.exporter.ssh`: + +| Block | Description | Required | +| -------------- | ----------------------------------------------------------- | -------- | +| `targets` | Configures an SSH target to collect metrics from. | yes | +| `custom_metrics` | Defines custom metrics to collect from the target server. | yes | + +### targets block + +The `targets` block defines the remote servers to connect to and the metrics to collect. It supports the following arguments: + +| Name | Type | Description | Default | Required | +| ----------------- | --------------------- | ---------------------------------------------------------------------- | ------- | -------- | +| `address` | `string` | The IP address or hostname of the target server. | | yes | +| `port` | `int` | SSH port number. | `22` | no | +| `username` | `string` | SSH username for authentication. | | yes | +| `password` | `secret` | Password for password-based SSH authentication. | | Required if `key_file` is not provided | +| `key_file` | `string` | Path to the private key file for key-based SSH authentication. | | Required if `password` is not provided | +| `command_timeout` | `int` | Timeout in seconds for each command execution over SSH. | `30` | no | +| `custom_metrics` | `block` | One or more custom metrics to collect from the target server. | | yes | + +#### Authentication + +You must provide either `password` or `key_file` for SSH authentication. If both are provided, `key_file` will be used. + +### custom_metrics block + +The `custom_metrics` block defines the metrics to collect from the target server. It supports the following arguments: + +| Name | Type | Description | Default | Required | +| -------------- | --------------------- | ---------------------------------------------------------------------------- | ------- | -------- | +| `name` | `string` | The name of the metric. | | yes | +| `command` | `string` | The command to execute over SSH to collect the metric. | | yes | +| `type` | `string` | The type of the metric (`gauge` or `counter`). | | yes | +| `help` | `string` | Help text for the metric. | | no | +| `labels` | `map(string, string)` | Key-value pairs of labels to associate with the metric. | `{}` | no | +| `parse_regex` | `string` | Regular expression to parse the command output and extract the metric value. | | no | + +#### Metric Types + +- `gauge`: Represents a numerical value that can go up or down. +- `counter`: Represents a cumulative value that only increases. + +#### parse_regex + +If the command output is not a simple numeric value, use `parse_regex` to extract the numeric value from the output. + +--- + +## Secure Known Hosts Setup + +### How It Works + +The `prometheus.exporter.ssh` component uses the `known_hosts` file to validate host keys and protect against man-in-the-middle (MITM) attacks. Here's how it handles this: + +1. **First-Time Setup**: + - If the `known_hosts` file does not exist, the component creates it and fetches the host key using `ssh-keyscan`. + - The fetched key is securely stored in the `known_hosts` file. + +2. **Subsequent Runs**: + - The component validates the server's host key against the stored key in `known_hosts`. + - If the keys match, the connection proceeds. + - If there is a mismatch, the component raises an error, requiring **manual intervention** to verify the legitimacy of the key change. + +3. **Adding or Modifying Targets**: + - When a new target is added, or its address changes, the component automatically scans and stores the host key. + - If the target's key already exists but has changed, the connection is blocked until the discrepancy is resolved manually. + +### Manual Resolution + +If a host key mismatch occurs due to a legitimate key update: +- Manually update the `known_hosts` file with the new key using `ssh-keyscan` or other secure methods. + +--- + +## Exported fields + +{{< docs/shared lookup="reference/components/exporter-component-exports.md" source="alloy" version="" >}} + +## Component health + +`prometheus.exporter.ssh` is only reported as unhealthy if given an invalid configuration. In those cases, exported fields retain their last healthy values. + +## Debug information + +`prometheus.exporter.ssh` doesn't expose any component-specific debug information. + +## Debug metrics + +`prometheus.exporter.ssh` doesn't expose any component-specific debug metrics. + +--- + +## Example + +This example uses a [`prometheus.scrape` component][scrape] to collect metrics from `prometheus.exporter.ssh`: + +``` +prometheus.exporter.ssh "example" { + verbose_logging = true + + targets { + address = "192.168.1.10" + port = 22 + username = "admin" + password = "password" + command_timeout = 10 + + custom_metrics { + name = "load_average" + command = "cat /proc/loadavg | awk '{print $1}'" + type = "gauge" + help = "Load average over 1 minute" + } + } + + targets { + address = "192.168.1.11" + port = 22 + username = "monitor" + key_file = "/path/to/private.key" + command_timeout = 15 + + custom_metrics { + name = "disk_usage" + command = "df / | tail -1 | awk '{print $5}'" + type = "gauge" + help = "Disk usage percentage" + parse_regex = "(\\d+)%" + } + } +} + +// Configure a prometheus.scrape component to collect SSH metrics. +prometheus.scrape "demo" { + targets = prometheus.exporter.ssh.example.targets + forward_to = [prometheus.remote_write.demo.receiver] +} + +prometheus.remote_write "demo" { + endpoint { + url = PROMETHEUS_REMOTE_WRITE_URL + + basic_auth { + username = USERNAME + password = PASSWORD + } + } +} +``` + +Replace the following: + +- `PROMETHEUS_REMOTE_WRITE_URL`: The URL of the Prometheus remote_write-compatible server to send metrics to. +- `USERNAME`: The username to use for authentication to the `remote_write` API. +- `PASSWORD`: The password to use for authentication to the `remote_write` API. + +[scrape]: ../prometheus.scrape/ + + + +## Compatible components + +`prometheus.exporter.ssh` has exports that can be consumed by the following components: + +- Components that consume [Targets](../../../compatibility/#targets-consumers) + +{{< admonition type="note" >}} +Connecting some components may not be sensible or components may require further configuration to make the connection work correctly. +Refer to the linked documentation for more details. +{{< /admonition >}} + + diff --git a/internal/component/all/all.go b/internal/component/all/all.go index caa0107764..2551938176 100644 --- a/internal/component/all/all.go +++ b/internal/component/all/all.go @@ -128,6 +128,7 @@ import ( _ "github.com/grafana/alloy/internal/component/prometheus/exporter/snmp" // Import prometheus.exporter.snmp _ "github.com/grafana/alloy/internal/component/prometheus/exporter/snowflake" // Import prometheus.exporter.snowflake _ "github.com/grafana/alloy/internal/component/prometheus/exporter/squid" // Import prometheus.exporter.squid + _ "github.com/grafana/alloy/internal/component/prometheus/exporter/ssh" // Import prometheus.exporter.ssh _ "github.com/grafana/alloy/internal/component/prometheus/exporter/statsd" // Import prometheus.exporter.statsd _ "github.com/grafana/alloy/internal/component/prometheus/exporter/unix" // Import prometheus.exporter.unix _ "github.com/grafana/alloy/internal/component/prometheus/exporter/windows" // Import prometheus.exporter.windows diff --git a/internal/component/prometheus/exporter/ssh/collector.go b/internal/component/prometheus/exporter/ssh/collector.go new file mode 100644 index 0000000000..13b5b7a0ed --- /dev/null +++ b/internal/component/prometheus/exporter/ssh/collector.go @@ -0,0 +1,131 @@ +package ssh + +import ( + "errors" + + "github.com/grafana/alloy/internal/component" + "github.com/grafana/alloy/internal/component/prometheus/exporter" + "github.com/grafana/alloy/internal/featuregate" + "github.com/grafana/alloy/internal/static/integrations" + "github.com/grafana/alloy/internal/static/integrations/ssh_exporter" +) + +func init() { + component.Register(component.Registration{ + Name: "prometheus.exporter.ssh", + Stability: featuregate.StabilityExperimental, + Args: Arguments{}, + Exports: exporter.Exports{}, + Build: exporter.New(createExporter, "ssh"), + }) +} + +func createExporter(opts component.Options, args component.Arguments, defaultInstanceKey string) (integrations.Integration, string, error) { + a := args.(Arguments) + return integrations.NewIntegrationWithInstanceKey(opts.Logger, a.Convert(), defaultInstanceKey) +} + +type Arguments struct { + VerboseLogging bool `alloy:"verbose_logging,attr,optional"` + Targets []Target `alloy:"targets,block"` +} + +func (a *Arguments) Validate() error { + if len(a.Targets) == 0 { + return errors.New("at least one target must be specified") + } + for _, target := range a.Targets { + if err := target.Validate(); err != nil { + return err + } + } + return nil +} + +func (a *Arguments) Convert() *ssh_exporter.Config { + targets := make([]ssh_exporter.Target, len(a.Targets)) + for i, t := range a.Targets { + targets[i] = t.Convert() + } + return &ssh_exporter.Config{ + VerboseLogging: a.VerboseLogging, + Targets: targets, + } +} + +type Target struct { + Address string `alloy:"address,attr"` + Port int `alloy:"port,attr,optional"` + Username string `alloy:"username,attr,optional"` + Password string `alloy:"password,attr,optional"` + KeyFile string `alloy:"key_file,attr,optional"` + CommandTimeout int `alloy:"command_timeout,attr,optional"` + CustomMetrics []CustomMetric `alloy:"custom_metrics,block,optional"` +} + +func (t *Target) Validate() error { + if t.Address == "" { + return errors.New("target address cannot be empty") + } + if t.Port <= 0 || t.Port > 65535 { + return errors.New("invalid port") + } + if t.Username == "" { + return errors.New("username cannot be empty") + } + for _, cm := range t.CustomMetrics { + if err := cm.Validate(); err != nil { + return err + } + } + return nil +} + +func (t *Target) Convert() ssh_exporter.Target { + customMetrics := make([]ssh_exporter.CustomMetric, len(t.CustomMetrics)) + for i, cm := range t.CustomMetrics { + customMetrics[i] = cm.Convert() + } + return ssh_exporter.Target{ + Address: t.Address, + Port: t.Port, + Username: t.Username, + Password: t.Password, + KeyFile: t.KeyFile, + CommandTimeout: t.CommandTimeout, + CustomMetrics: customMetrics, + } +} + +type CustomMetric struct { + Name string `alloy:"name,attr"` + Command string `alloy:"command,attr"` + Type string `alloy:"type,attr"` + Help string `alloy:"help,attr,optional"` + Labels map[string]string `alloy:"labels,attr,optional"` + ParseRegex string `alloy:"parse_regex,attr,optional"` +} + +func (cm *CustomMetric) Validate() error { + if cm.Name == "" { + return errors.New("custom metric name cannot be empty") + } + if cm.Command == "" { + return errors.New("custom metric command cannot be empty") + } + if cm.Type != "gauge" && cm.Type != "counter" { + return errors.New("unsupported metric type") + } + return nil +} + +func (cm *CustomMetric) Convert() ssh_exporter.CustomMetric { + return ssh_exporter.CustomMetric{ + Name: cm.Name, + Command: cm.Command, + Type: cm.Type, + Help: cm.Help, + Labels: cm.Labels, + ParseRegex: cm.ParseRegex, + } +} diff --git a/internal/component/prometheus/exporter/ssh/ssh_test.go b/internal/component/prometheus/exporter/ssh/ssh_test.go new file mode 100644 index 0000000000..504b1533be --- /dev/null +++ b/internal/component/prometheus/exporter/ssh/ssh_test.go @@ -0,0 +1,365 @@ +package ssh + +import ( + "testing" + + "github.com/grafana/alloy/internal/static/integrations/ssh_exporter" + "github.com/grafana/alloy/syntax" + "github.com/stretchr/testify/require" + +) + + +func TestAlloyUnmarshal(t *testing.T) { + alloyConfig := ` +verbose_logging = true + +targets { + address = "192.168.1.10" + port = 22 + username = "admin" + password = "password" + command_timeout = 10 + + custom_metrics { + name = "load_average" + command = "cat /proc/loadavg | awk '{print $1}'" + type = "gauge" + help = "Load average over 1 minute" + } +} + +targets { + address = "192.168.1.11" + port = 22 + username = "monitor" + key_file = "/path/to/private.key" + command_timeout = 15 +} +` + + var args Arguments + err := syntax.Unmarshal([]byte(alloyConfig), &args) + require.NoError(t, err) + + expected := Arguments{ + VerboseLogging: true, + Targets: []Target{ + { + Address: "192.168.1.10", + Port: 22, + Username: "admin", + Password: "password", + CommandTimeout: 10, + CustomMetrics: []CustomMetric{ + { + Name: "load_average", + Command: "cat /proc/loadavg | awk '{print $1}'", + Type: "gauge", + Help: "Load average over 1 minute", + }, + }, + }, + { + Address: "192.168.1.11", + Port: 22, + Username: "monitor", + KeyFile: "/path/to/private.key", + CommandTimeout: 15, + }, + }, + } + + require.Equal(t, expected, args) +} + + +func TestArgumentsValidate(t *testing.T) { + tests := []struct { + name string + args Arguments + wantErr bool + errMsg string + }{ + { + name: "no targets", + args: Arguments{ + Targets: nil, + }, + wantErr: true, + errMsg: "at least one target must be specified", + }, + { + name: "empty target address", + args: Arguments{ + Targets: []Target{ + { + Address: "", + Port: 22, + Username: "admin", + }, + }, + }, + wantErr: true, + errMsg: "target address cannot be empty", + }, + { + name: "missing username", + args: Arguments{ + Targets: []Target{ + { + Address: "192.168.1.10", + Port: 22, + Username: "", + }, + }, + }, + wantErr: true, + errMsg: "username cannot be empty", + }, + { + name: "invalid port number", + args: Arguments{ + Targets: []Target{ + { + Address: "192.168.1.10", + Port: -1, + Username: "admin", + }, + }, + }, + wantErr: true, + errMsg: "invalid port", + }, + { + name: "unsupported metric type", + args: Arguments{ + Targets: []Target{ + { + Address: "192.168.1.10", + Port: 22, + Username: "admin", + CustomMetrics: []CustomMetric{ + { + Name: "invalid_metric", + Command: "echo 42", + Type: "histogram", // Assuming only "gauge" and "counter" are supported + }, + }, + }, + }, + }, + wantErr: true, + errMsg: "unsupported metric type", + }, + { + name: "valid configuration", + args: Arguments{ + Targets: []Target{ + { + Address: "192.168.1.10", + Port: 22, + Username: "admin", + Password: "password", + CommandTimeout: 10, + CustomMetrics: []CustomMetric{ + { + Name: "metric1", + Command: "echo 42", + Type: "gauge", + Help: "Test metric", + }, + }, + }, + }, + }, + wantErr: false, + }, + // ... you can add more test cases if needed ... + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.args.Validate() + if tt.wantErr { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errMsg) + } else { + require.NoError(t, err) + } + }) + } +} + + +func TestConvert(t *testing.T) { + args := Arguments{ + VerboseLogging: true, + Targets: []Target{ + { + Address: "192.168.1.10", + Port: 22, + Username: "admin", + Password: "password", + CommandTimeout: 10, + CustomMetrics: []CustomMetric{ + { + Name: "metric1", + Command: "echo 42", + Type: "gauge", + Help: "Test metric", + }, + }, + }, + }, + } + + res := args.Convert() + + expected := &ssh_exporter.Config{ + VerboseLogging: true, + Targets: []ssh_exporter.Target{ + { + Address: "192.168.1.10", + Port: 22, + Username: "admin", + Password: "password", + CommandTimeout: 10, + CustomMetrics: []ssh_exporter.CustomMetric{ + { + Name: "metric1", + Command: "echo 42", + Type: "gauge", + Help: "Test metric", + }, + }, + }, + }, + } + + require.Equal(t, expected, res) +} + + +func TestAlloyUnmarshal_MultipleTargets(t *testing.T) { + alloyConfig := ` +verbose_logging = true + +targets { + address = "192.168.1.10" + port = 22 + username = "admin" + password = "password" + command_timeout = 10 + + custom_metrics { + name = "cpu_usage" + command = "top -bn1 | grep 'Cpu(s)' | awk '{print $2 + $4}'" + type = "gauge" + help = "CPU usage percentage" + } + + custom_metrics { + name = "memory_available" + command = "free -m | awk '/Mem:/ {print $7}'" + type = "gauge" + help = "Available memory in MB" + } +} + +targets { + address = "192.168.1.11" + port = 22 + username = "monitor" + key_file = "/path/to/private.key" + command_timeout = 15 + + custom_metrics { + name = "disk_usage" + command = "df / | tail -1 | awk '{print $5}'" + type = "gauge" + help = "Disk usage percentage" + parse_regex = "(\\d+)%" + } +} + +targets { + address = "192.168.1.12" + port = 22 + username = "user" + password = "secret" + command_timeout = 20 + + custom_metrics { + name = "network_in" + command = "ifconfig eth0 | grep 'RX packets' | awk '{print $5}'" + type = "counter" + help = "Network input packets" + } +} +` + + var args Arguments + err := syntax.Unmarshal([]byte(alloyConfig), &args) + require.NoError(t, err) + + expected := Arguments{ + VerboseLogging: true, + Targets: []Target{ + { + Address: "192.168.1.10", + Port: 22, + Username: "admin", + Password: "password", + CommandTimeout: 10, + CustomMetrics: []CustomMetric{ + { + Name: "cpu_usage", + Command: "top -bn1 | grep 'Cpu(s)' | awk '{print $2 + $4}'", + Type: "gauge", + Help: "CPU usage percentage", + }, + { + Name: "memory_available", + Command: "free -m | awk '/Mem:/ {print $7}'", + Type: "gauge", + Help: "Available memory in MB", + }, + }, + }, + { + Address: "192.168.1.11", + Port: 22, + Username: "monitor", + KeyFile: "/path/to/private.key", + CommandTimeout: 15, + CustomMetrics: []CustomMetric{ + { + Name: "disk_usage", + Command: "df / | tail -1 | awk '{print $5}'", + Type: "gauge", + Help: "Disk usage percentage", + ParseRegex: `(\d+)%`, + }, + }, + }, + { + Address: "192.168.1.12", + Port: 22, + Username: "user", + Password: "secret", + CommandTimeout: 20, + CustomMetrics: []CustomMetric{ + { + Name: "network_in", + Command: "ifconfig eth0 | grep 'RX packets' | awk '{print $5}'", + Type: "counter", + Help: "Network input packets", + }, + }, + }, + }, + } + + require.Equal(t, expected, args) +} diff --git a/internal/converter/internal/staticconvert/internal/build/builder_integrations.go b/internal/converter/internal/staticconvert/internal/build/builder_integrations.go index 9ac8608fa0..e8d6af50ad 100644 --- a/internal/converter/internal/staticconvert/internal/build/builder_integrations.go +++ b/internal/converter/internal/staticconvert/internal/build/builder_integrations.go @@ -39,6 +39,7 @@ import ( "github.com/grafana/alloy/internal/static/integrations/redis_exporter" "github.com/grafana/alloy/internal/static/integrations/snmp_exporter" "github.com/grafana/alloy/internal/static/integrations/snowflake_exporter" + "github.com/grafana/alloy/internal/static/integrations/ssh_exporter" "github.com/grafana/alloy/internal/static/integrations/squid_exporter" "github.com/grafana/alloy/internal/static/integrations/statsd_exporter" agent_exporter_v2 "github.com/grafana/alloy/internal/static/integrations/v2/agent" @@ -124,6 +125,8 @@ func (b *ConfigBuilder) appendV1Integrations() { exports = b.appendSnmpExporter(itg) case *snowflake_exporter.Config: exports = b.appendSnowflakeExporter(itg, nil) + case *ssh_exporter.Config: + exports = b.appendSSHExporter(itg, nil) case *squid_exporter.Config: exports = b.appendSquidExporter(itg, nil) case *statsd_exporter.Config: @@ -260,6 +263,8 @@ func (b *ConfigBuilder) appendV2Integrations() { exports = b.appendRedisExporter(v1_itg, itg.Common.InstanceKey) case *snowflake_exporter.Config: exports = b.appendSnowflakeExporter(v1_itg, itg.Common.InstanceKey) + case *ssh_exporter.Config: + exports = b.appendSSHExporter(v1_itg, itg.Common.InstanceKey) case *squid_exporter.Config: exports = b.appendSquidExporter(v1_itg, itg.Common.InstanceKey) case *statsd_exporter.Config: diff --git a/internal/converter/internal/staticconvert/internal/build/ssh_exporter.go b/internal/converter/internal/staticconvert/internal/build/ssh_exporter.go new file mode 100644 index 0000000000..5b52582d7d --- /dev/null +++ b/internal/converter/internal/staticconvert/internal/build/ssh_exporter.go @@ -0,0 +1,50 @@ +package build + +import ( + "github.com/grafana/alloy/internal/component/discovery" + "github.com/grafana/alloy/internal/component/prometheus/exporter/ssh" + "github.com/grafana/alloy/internal/static/integrations/ssh_exporter" + "github.com/grafana/alloy/syntax/alloytypes" +) + +func (b *ConfigBuilder) appendSSHExporter(config *ssh_exporter.Config, instanceKey *string) discovery.Exports { + args := toSSHExporter(config) + return b.appendExporterBlock(args, config.Name(), instanceKey, "ssh") +} + +func toSSHExporter(config *ssh_exporter.Config) *ssh.Arguments { + targets := make([]ssh.Target, len(config.Targets)) + for i, t := range config.Targets { + customMetrics := make([]ssh.CustomMetric, len(t.CustomMetrics)) + for j, cm := range t.CustomMetrics { + customMetrics[j] = ssh.CustomMetric{ + Name: cm.Name, + Command: cm.Command, + Type: cm.Type, + Help: cm.Help, + Labels: cm.Labels, + ParseRegex: cm.ParseRegex, + } + } + + var password alloytypes.Secret + if t.Password != "" { + password = alloytypes.Secret(t.Password) + } + + targets[i] = ssh.Target{ + Address: t.Address, + Port: t.Port, + Username: t.Username, + Password: string(password), + KeyFile: t.KeyFile, + CommandTimeout: t.CommandTimeout, + CustomMetrics: customMetrics, + } + } + + return &ssh.Arguments{ + VerboseLogging: config.VerboseLogging, + Targets: targets, + } +} diff --git a/internal/converter/internal/staticconvert/validate.go b/internal/converter/internal/staticconvert/validate.go index 9b7e4a8a82..262248229c 100644 --- a/internal/converter/internal/staticconvert/validate.go +++ b/internal/converter/internal/staticconvert/validate.go @@ -30,6 +30,7 @@ import ( "github.com/grafana/alloy/internal/static/integrations/redis_exporter" "github.com/grafana/alloy/internal/static/integrations/snmp_exporter" "github.com/grafana/alloy/internal/static/integrations/snowflake_exporter" + "github.com/grafana/alloy/internal/static/integrations/ssh_exporter" "github.com/grafana/alloy/internal/static/integrations/squid_exporter" "github.com/grafana/alloy/internal/static/integrations/statsd_exporter" v2 "github.com/grafana/alloy/internal/static/integrations/v2" @@ -149,6 +150,7 @@ func validateIntegrationsV1(integrationsConfig *v1.ManagerConfig) diag.Diagnosti case *redis_exporter.Config: case *snmp_exporter.Config: case *snowflake_exporter.Config: + case *ssh_exporter.Config: case *squid_exporter.Config: case *statsd_exporter.Config: case *windows_exporter.Config: @@ -201,6 +203,7 @@ func validateIntegrationsV2(integrationsConfig *v2.SubsystemOptions) diag.Diagno case *process_exporter.Config: case *redis_exporter.Config: case *snowflake_exporter.Config: + case *ssh_exporter.Config: case *squid_exporter.Config: case *statsd_exporter.Config: case *windows_exporter.Config: diff --git a/internal/static/integrations/ssh_exporter/config.go b/internal/static/integrations/ssh_exporter/config.go new file mode 100644 index 0000000000..c98a2bd531 --- /dev/null +++ b/internal/static/integrations/ssh_exporter/config.go @@ -0,0 +1,85 @@ +package ssh_exporter + +import ( + "errors" + "fmt" +) + +var DefaultConfig = Config{ + VerboseLogging: false, + Targets: []Target{}, +} + +type Config struct { + VerboseLogging bool `yaml:"verbose_logging,omitempty"` + Targets []Target `yaml:"targets,omitempty"` +} + +type Target struct { + Address string `yaml:"address"` + Port int `yaml:"port"` + Username string `yaml:"username"` + Password string `yaml:"password,omitempty"` + KeyFile string `yaml:"key_file,omitempty"` + CommandTimeout int `yaml:"command_timeout,omitempty"` + CustomMetrics []CustomMetric `yaml:"custom_metrics,omitempty"` +} + +func (t *Target) Validate() error { + if t.Address == "" { + return errors.New("target address cannot be empty") + } + if t.Port <= 0 || t.Port > 65535 { + return fmt.Errorf("invalid port: %d", t.Port) + } + if t.Username == "" { + return errors.New("username cannot be empty") + } + for _, cm := range t.CustomMetrics { + if err := cm.Validate(); err != nil { + return err + } + } + return nil +} + +type CustomMetric struct { + Name string `yaml:"name"` + Command string `yaml:"command"` + Type string `yaml:"type"` + Help string `yaml:"help"` + Labels map[string]string `yaml:"labels,omitempty"` + ParseRegex string `yaml:"parse_regex,omitempty"` +} + +func (cm *CustomMetric) Validate() error { + if cm.Name == "" { + return errors.New("custom metric name cannot be empty") + } + if cm.Command == "" { + return errors.New("custom metric command cannot be empty") + } + if cm.Type != "gauge" && cm.Type != "counter" { + return fmt.Errorf("unsupported metric type: %s", cm.Type) + } + return nil +} + +func (c *Config) UnmarshalYAML(unmarshal func(interface{}) error) error { + *c = DefaultConfig + + type plain Config + return unmarshal((*plain)(c)) +} + +func (c *Config) Validate() error { + if len(c.Targets) == 0 { + return errors.New("at least one target must be specified") + } + for _, target := range c.Targets { + if err := target.Validate(); err != nil { + return err + } + } + return nil +} diff --git a/internal/static/integrations/ssh_exporter/ssh_client.go b/internal/static/integrations/ssh_exporter/ssh_client.go new file mode 100644 index 0000000000..7900e8935d --- /dev/null +++ b/internal/static/integrations/ssh_exporter/ssh_client.go @@ -0,0 +1,194 @@ +package ssh_exporter + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "time" + "os/exec" + "strings" + + "github.com/go-kit/log" + "github.com/go-kit/log/level" + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/knownhosts" + "os/user" +) + + +type SSHClient struct { + config *ssh.ClientConfig + host string + port int + logger log.Logger + timeout time.Duration +} +var sshKeyscanCommand = func(targetAddress string) ([]byte, error) { + cmd := exec.Command("ssh-keyscan", "-H", targetAddress) + return cmd.Output() +} + +func ensureKnownHosts(knownHostsPath, targetAddress string) error { + // Ensure .ssh directory exists + if err := os.MkdirAll(filepath.Dir(knownHostsPath), 0700); err != nil { + return fmt.Errorf("failed to create .ssh directory: %w", err) + } + + var knownHostsContent []string + if _, err := os.Stat(knownHostsPath); err == nil { + content, err := os.ReadFile(knownHostsPath) + if err != nil { + return fmt.Errorf("failed to read known_hosts file: %w", err) + } + knownHostsContent = strings.Split(string(content), "\n") + } + + var output []byte + var scanErr error + for i := 0; i < 3; i++ { + output, scanErr = sshKeyscanCommand(targetAddress) + if scanErr == nil { + break + } + fmt.Printf("Attempt %d: failed to fetch host key for %s: %v\n", i+1, targetAddress, scanErr) + time.Sleep(time.Second) + } + if len(output) == 0 { + return fmt.Errorf("failed to fetch host key for %s after 3 attempts: last error: %w", targetAddress, scanErr) + } + scannedKey := strings.TrimSpace(string(output)) + + for _, line := range knownHostsContent { + if strings.Contains(line, targetAddress) { + if line != scannedKey { + return fmt.Errorf( + "host key mismatch for %s: existing key [%s] differs from scanned key [%s]. Manual verification required.", + targetAddress, line, scannedKey, + ) } + return nil + } + } + + file, err := os.OpenFile(knownHostsPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return fmt.Errorf("failed to open known_hosts file: %w", err) + } + defer file.Close() + + if _, err := file.WriteString(scannedKey + "\n"); err != nil { + return fmt.Errorf("failed to write to known_hosts file: %w", err) + } + + return nil +} + + +func NewSSHClient(target Target) (*SSHClient, error) { + usr, err := user.Current() + if err != nil { + return nil, fmt.Errorf("unable to determine current user: %w", err) + } + + knownHostsPath := filepath.Join(usr.HomeDir, ".ssh", "known_hosts") + + // Ensure known_hosts exists and is valid + if err := ensureKnownHosts(knownHostsPath, target.Address); err != nil { + return nil, fmt.Errorf("failed to ensure known_hosts: %w", err) + } + + hostKeyCallback, err := knownhosts.New(knownHostsPath) + if err != nil { + return nil, fmt.Errorf("failed to initialize known_hosts verification: %w", err) + } + + // Build SSH ClientConfig + config := &ssh.ClientConfig{ + User: target.Username, + Auth: []ssh.AuthMethod{}, + HostKeyCallback: hostKeyCallback, + Timeout: time.Duration(target.CommandTimeout) * time.Second, + } + + // Add Password Authentication + if target.Password != "" { + config.Auth = append(config.Auth, ssh.Password(target.Password)) + } + + // Add Private Key Authentication + if target.KeyFile != "" { + key, err := os.ReadFile(target.KeyFile) + if err != nil { + return nil, fmt.Errorf("unable to read private key file %s: %w", target.KeyFile, err) + } + signer, err := ssh.ParsePrivateKey(key) + if err != nil { + return nil, fmt.Errorf("unable to parse private key: %w", err) + } + config.Auth = append(config.Auth, ssh.PublicKeys(signer)) + } + + // Validate at least one auth method + if len(config.Auth) == 0 { + return nil, fmt.Errorf("no valid authentication method provided (password or private key)") + } + + return &SSHClient{ + config: config, + host: target.Address, + port: target.Port, + logger: log.NewNopLogger(), + timeout: time.Duration(target.CommandTimeout) * time.Second, + }, nil +} + + + +func (c *SSHClient) RunCommand(command string) (string, error) { + conn, err := ssh.Dial("tcp", fmt.Sprintf("%s:%d", c.host, c.port), c.config) + if err != nil { + if c.logger != nil { + level.Error(c.logger).Log("msg", "failed to connect to SSH", "host", c.host, "port", c.port, "err", err) + } + return "", fmt.Errorf("failed to connect to SSH: %w", err) + } + defer conn.Close() + + session, err := conn.NewSession() + if err != nil { + if c.logger != nil { + level.Error(c.logger).Log("msg", "failed to create SSH session", "err", err) + } + return "", fmt.Errorf("failed to create SSH session: %w", err) + } + defer session.Close() + + var output bytes.Buffer + session.Stdout = &output + session.Stderr = &output + + done := make(chan error, 1) + go func() { + done <- session.Run(command) + }() + + select { + case err := <-done: + if err != nil { + if c.logger != nil { + level.Error(c.logger).Log("msg", "command execution failed", "command", command, "err", err) + } + return "", fmt.Errorf("command execution failed: %w", err) + } + case <-time.After(c.timeout): + // Attempt to send a termination signal to the remote command + if err := session.Signal(ssh.SIGKILL); err != nil { + if c.logger != nil { + level.Error(c.logger).Log("msg", "failed to send SIGKILL to remote command", "err", err) + } + } + return "", fmt.Errorf("command execution timed out after %v", c.timeout) + } + + return output.String(), nil +} diff --git a/internal/static/integrations/ssh_exporter/ssh_collector.go b/internal/static/integrations/ssh_exporter/ssh_collector.go new file mode 100644 index 0000000000..b044c8b164 --- /dev/null +++ b/internal/static/integrations/ssh_exporter/ssh_collector.go @@ -0,0 +1,119 @@ +package ssh_exporter + +import ( + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/go-kit/log" + "github.com/go-kit/log/level" + "github.com/prometheus/client_golang/prometheus" +) + +type SSHClientInterface interface { + RunCommand(command string) (string, error) +} + +type SSHCollector struct { + logger log.Logger + target Target + client SSHClientInterface + metrics map[string]*prometheus.Desc +} + +func NewSSHCollector(logger log.Logger, target Target) (*SSHCollector, error) { + client, err := NewSSHClient(target) + if err != nil { + return nil, err + } + client.logger = logger + + collector := &SSHCollector{ + logger: logger, + target: target, + client: client, + metrics: make(map[string]*prometheus.Desc), + } + + // Initialize metric descriptors for custom metrics + for _, cm := range target.CustomMetrics { + var labels []string + for label := range cm.Labels { + labels = append(labels, label) + } + desc := prometheus.NewDesc(cm.Name, cm.Help, labels, nil) + collector.metrics[cm.Name] = desc + } + + return collector, nil +} + +func (c *SSHCollector) Describe(ch chan<- *prometheus.Desc) { + for _, desc := range c.metrics { + ch <- desc + } +} + +func (c *SSHCollector) Collect(ch chan<- prometheus.Metric) { + for _, cm := range c.target.CustomMetrics { + value, err := c.executeCustomCommand(cm) + if err != nil { + level.Error(c.logger).Log("msg", "failed to execute custom command", "command", cm.Command, "err", err) + continue + } + + level.Debug(c.logger).Log("msg", "executed custom command", "command", cm.Command, "value", value) + + var labelValues []string + for _, v := range cm.Labels { + labelValues = append(labelValues, v) + } + + desc := c.metrics[cm.Name] + + var metric prometheus.Metric + switch strings.ToLower(cm.Type) { + case "gauge": + metric = prometheus.MustNewConstMetric(desc, prometheus.GaugeValue, value, labelValues...) + case "counter": + metric = prometheus.MustNewConstMetric(desc, prometheus.CounterValue, value, labelValues...) + default: + level.Error(c.logger).Log("msg", "unsupported metric type", "type", cm.Type) + continue + } + + ch <- metric + } +} + +func (c *SSHCollector) executeCustomCommand(cm CustomMetric) (float64, error) { + output, err := c.client.RunCommand(cm.Command) + if err != nil { + level.Error(c.logger).Log("msg", "SSH command failed", "command", cm.Command, "err", err) + return 0, err + } + + level.Debug(c.logger).Log("msg", "SSH command output", "command", cm.Command, "output", output) + + output = strings.TrimSpace(output) + + if cm.ParseRegex != "" { + re, err := regexp.Compile(cm.ParseRegex) + if err != nil { + return 0, fmt.Errorf("invalid parse regex: %w", err) + } + matches := re.FindStringSubmatch(output) + if len(matches) < 2 { + return 0, fmt.Errorf("no matches found using regex") + } + output = matches[1] + } + + value, err := strconv.ParseFloat(output, 64) + if err != nil { + return 0, fmt.Errorf("failed to parse output '%s' as float: %w", output, err) + } + + return value, nil +} diff --git a/internal/static/integrations/ssh_exporter/ssh_exporter.go b/internal/static/integrations/ssh_exporter/ssh_exporter.go new file mode 100644 index 0000000000..74fcb3f66b --- /dev/null +++ b/internal/static/integrations/ssh_exporter/ssh_exporter.go @@ -0,0 +1,48 @@ +package ssh_exporter + +import ( + "github.com/go-kit/log" + "github.com/go-kit/log/level" + "github.com/grafana/alloy/internal/static/integrations" + integrations_v2 "github.com/grafana/alloy/internal/static/integrations/v2" + "github.com/grafana/alloy/internal/static/integrations/v2/metricsutils" + "github.com/prometheus/client_golang/prometheus" +) + +func (c *Config) Name() string { + return "ssh_exporter" +} + +func (c *Config) InstanceKey(agentKey string) (string, error) { + return "ssh_exporter", nil +} + +func (c *Config) NewIntegration(logger log.Logger) (integrations.Integration, error) { + // Adjust the logger based on VerboseLogging + if c.VerboseLogging { + logger = level.NewFilter(logger, level.AllowDebug()) + } else { + logger = level.NewFilter(logger, level.AllowInfo()) + } + + var collectors []prometheus.Collector + + // Create collectors for each target. + for _, target := range c.Targets { + collector, err := NewSSHCollector(logger, target) + if err != nil { + return nil, err + } + collectors = append(collectors, collector) + } + + return integrations.NewCollectorIntegration( + c.Name(), + integrations.WithCollectors(collectors...), + ), nil +} + +func init() { + integrations.RegisterIntegration(&Config{}) + integrations_v2.RegisterLegacy(&Config{}, integrations_v2.TypeMultiplex, metricsutils.NewNamedShim("ssh_exporter")) +} diff --git a/internal/static/integrations/ssh_exporter/ssh_exporter_test.go b/internal/static/integrations/ssh_exporter/ssh_exporter_test.go new file mode 100644 index 0000000000..aa3420e008 --- /dev/null +++ b/internal/static/integrations/ssh_exporter/ssh_exporter_test.go @@ -0,0 +1,359 @@ +package ssh_exporter + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "testing" + "gopkg.in/yaml.v2" + + "github.com/go-kit/log" + "github.com/go-kit/log/level" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh" + "os/user" +) + +var ( + currentUser = user.Current + privateKeyPath string + publicKeyPath string + mockKnownHostsDir string +) + +// Mock ssh-keyscan command +var mockSSHKeyscanCommand = func(targetAddress string) ([]byte, error) { + publicKey, err := ioutil.ReadFile(publicKeyPath) + if err != nil { + return nil, fmt.Errorf("failed to read test public key: %w", err) + } + return append([]byte(targetAddress+" "), publicKey...), nil +} + +// Override the production `sshKeyscanCommand` during tests - we arent scanning from real host connection +func init() { + sshKeyscanCommand = mockSSHKeyscanCommand +} +// TestMain handles test setup and teardown +func TestMain(m *testing.M) { + var err error + privateKeyPath, publicKeyPath, err = generateTempKeyPair() + if err != nil { + fmt.Printf("Failed to generate key pair: %v\n", err) + os.Exit(1) + } + defer os.Remove(privateKeyPath) + defer os.Remove(publicKeyPath) + + mockKnownHostsDir, err = setupKnownHosts(publicKeyPath) + if err != nil { + fmt.Printf("Failed to set up known_hosts: %v\n", err) + os.Exit(1) + } + defer os.RemoveAll(mockKnownHostsDir) + + os.Exit(m.Run()) +} + +// setupKnownHosts creates a mock known_hosts file +func setupKnownHosts(publicKeyPath string) (string, error) { + tempDir, err := ioutil.TempDir("", "ssh_exporter_test") + if err != nil { + return "", fmt.Errorf("failed to create temp dir: %w", err) + } + + knownHostsDir := filepath.Join(tempDir, ".ssh") + knownHostsPath := filepath.Join(knownHostsDir, "known_hosts") + + if err := os.MkdirAll(knownHostsDir, 0700); err != nil { + return "", fmt.Errorf("failed to create .ssh directory: %w", err) + } + + publicKey, err := ioutil.ReadFile(publicKeyPath) + if err != nil { + return "", fmt.Errorf("failed to read public key: %w", err) + } + + entry := fmt.Sprintf("192.168.1.10 %s", publicKey) + if err := ioutil.WriteFile(knownHostsPath, []byte(entry), 0600); err != nil { + return "", fmt.Errorf("failed to write known_hosts file: %w", err) + } + + return tempDir, nil +} + +// generateTempKeyPair generates a temporary private-public key pair and returns file paths +func generateTempKeyPair() (string, string, error) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return "", "", fmt.Errorf("failed to generate private key: %w", err) + } + + privateKeyBytes := x509.MarshalPKCS1PrivateKey(privateKey) + privateKeyPEM := &bytes.Buffer{} + pem.Encode(privateKeyPEM, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: privateKeyBytes}) + + publicKey, err := ssh.NewPublicKey(&privateKey.PublicKey) + if err != nil { + return "", "", fmt.Errorf("failed to create public key: %w", err) + } + publicKeyBytes := ssh.MarshalAuthorizedKey(publicKey) + + privateKeyPath := filepath.Join(os.TempDir(), "test_private_key.pem") + publicKeyPath := filepath.Join(os.TempDir(), "test_public_key.pem") + + if err := ioutil.WriteFile(privateKeyPath, privateKeyPEM.Bytes(), 0600); err != nil { + return "", "", fmt.Errorf("failed to write private key: %w", err) + } + if err := ioutil.WriteFile(publicKeyPath, publicKeyBytes, 0644); err != nil { + return "", "", fmt.Errorf("failed to write public key: %w", err) + } + + return privateKeyPath, publicKeyPath, nil +} + +// Test for unmarshalling multiple targets from YAML +func TestConfig_UnmarshalYAML_MultipleTargets(t *testing.T) { + yamlConfig := ` +verbose_logging: true +targets: + - address: "192.168.1.10" + port: 22 + username: "admin" + password: "password" + command_timeout: 10 + custom_metrics: + - name: "load_average" + command: "echo 1.23" + type: "gauge" + help: "Load average over 1 minute" + - address: "192.168.1.11" + port: 22 + username: "monitor" + key_file: "/path/to/private.key" + command_timeout: 15 + custom_metrics: + - name: "disk_usage" + command: "echo '50%'" + type: "gauge" + help: "Disk usage percentage" + parse_regex: '(\d+)%' +` + + var c Config + require.NoError(t, yaml.UnmarshalStrict([]byte(yamlConfig), &c)) + + expectedConfig := Config{ + VerboseLogging: true, + Targets: []Target{ + { + Address: "192.168.1.10", + Port: 22, + Username: "admin", + Password: "password", + CommandTimeout: 10, + CustomMetrics: []CustomMetric{ + { + Name: "load_average", + Command: "echo 1.23", + Type: "gauge", + Help: "Load average over 1 minute", + }, + }, + }, + { + Address: "192.168.1.11", + Port: 22, + Username: "monitor", + KeyFile: "/path/to/private.key", + CommandTimeout: 15, + CustomMetrics: []CustomMetric{ + { + Name: "disk_usage", + Command: "echo '50%'", + Type: "gauge", + Help: "Disk usage percentage", + ParseRegex: `(\d+)%`, + }, + }, + }, + }, + } + + require.Equal(t, expectedConfig, c) +} +type MockSSHClient struct { + logger log.Logger + executeCommand func(command string) (string, error) +} + +func (m *MockSSHClient) Execute(command string) (string, error) { + return m.executeCommand(command) +} + +func (m *MockSSHClient) RunCommand(command string) (string, error) { + return m.Execute(command) // Reuse the same mocked behavior +} + +func (m *MockSSHClient) Close() error { + return nil // Mock close +} + +// Updated TestSSHCollector_Collect +func TestSSHCollector_Collect(t *testing.T) { + // Set up mock known_hosts + knownHostsPath, err := setupKnownHosts(publicKeyPath) + require.NoError(t, err) + defer os.RemoveAll(knownHostsPath) + + currentUser = func() (*user.User, error) { + return &user.User{HomeDir: filepath.Dir(knownHostsPath)}, nil + } + defer func() { currentUser = user.Current }() + + // Create a target with a custom metric + target := Target{ + Address: "192.168.1.10", + Port: 22, + Username: "admin", + Password: "password", + CustomMetrics: []CustomMetric{ + { + Name: "mock_metric", + Command: "echo 1.23", + Type: "gauge", + Help: "A mock metric for testing", + }, + }, + } + + // Use a mock SSH client + mockClient := &MockSSHClient{ + logger: log.NewNopLogger(), + executeCommand: func(command string) (string, error) { + if command == "echo 1.23" { + return "1.23", nil + } + return "", fmt.Errorf("unexpected command: %s", command) + }, + } + + collector := &SSHCollector{ + logger: log.NewNopLogger(), + target: target, + client: mockClient, // Use the mock client + metrics: map[string]*prometheus.Desc{ + "mock_metric": prometheus.NewDesc("mock_metric", "A mock metric for testing", nil, nil), + }, + } + + // Test Collect + ch := make(chan prometheus.Metric) + go func() { + collector.Collect(ch) + close(ch) + }() + + var metrics []prometheus.Metric + for metric := range ch { + metrics = append(metrics, metric) + } + + require.NotEmpty(t, metrics) // Ensure metrics are collected +} + + +// Use centralized keys in TestNewSSHClient_AuthMethods +func TestNewSSHClient_AuthMethods(t *testing.T) { + knownHostsPath, err := setupKnownHosts(publicKeyPath) + require.NoError(t, err) + defer os.RemoveAll(knownHostsPath) + + tests := []struct { + name string + target Target + expectedError string + }{ + { + name: "password authentication", + target: Target{ + Address: "192.168.1.10", + Port: 22, + Username: "user", + Password: "password", + }, + expectedError: "", + }, + { + name: "private key authentication", + target: Target{ + Address: "192.168.1.10", + Port: 22, + Username: "user", + KeyFile: privateKeyPath, + }, + expectedError: "", + }, + { + name: "missing auth", + target: Target{ + Address: "192.168.1.10", + Port: 22, + Username: "user", + }, + expectedError: "no valid authentication method provided", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewSSHClient(tt.target) + if tt.expectedError != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.expectedError) + } else { + require.NoError(t, err) + require.NotNil(t, client) + } + }) + } +} + +// Additional tests preserved for new integrations +func TestConfig_NewIntegration(t *testing.T) { + c := &Config{ + VerboseLogging: true, + Targets: []Target{ + { + Address: "192.168.1.10", + Port: 22, + Username: "admin", + Password: "password", + CommandTimeout: 10, + CustomMetrics: []CustomMetric{ + { + Name: "load_average", + Command: "cat /proc/loadavg | awk '{print $1}'", + Type: "gauge", + Help: "Load average over 1 minute", + }, + }, + }, + }, + } + + logger := log.NewJSONLogger(os.Stdout) + i, err := c.NewIntegration(logger) + require.NoError(t, err) + require.NotNil(t, i) + + lvl := level.NewFilter(logger, level.AllowAll()) + level.Debug(lvl).Log("msg", "test debug log") +}