Skip to content

Commit 40345a0

Browse files
authored
feat(client): Add support for SSH tunneling (#1298)
* feat(client): Add support for SSH tunneling * Fix test
1 parent 97a2be3 commit 40345a0

File tree

10 files changed

+917
-22
lines changed

10 files changed

+917
-22
lines changed

README.md

Lines changed: 59 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
5151
- [Functions](#functions)
5252
- [Storage](#storage)
5353
- [Client configuration](#client-configuration)
54+
- [Tunneling](#tunneling)
5455
- [Alerting](#alerting)
5556
- [Configuring AWS SES alerts](#configuring-aws-ses-alerts)
5657
- [Configuring Datadog alerts](#configuring-datadog-alerts)
@@ -597,24 +598,25 @@ See [examples/docker-compose-postgres-storage](.examples/docker-compose-postgres
597598
In order to support a wide range of environments, each monitored endpoint has a unique configuration for
598599
the client used to send the request.
599600

600-
| Parameter | Description | Default |
601-
|:---------------------------------------|:----------------------------------------------------------------------------|:----------------|
602-
| `client.insecure` | Whether to skip verifying the server's certificate chain and host name. | `false` |
603-
| `client.ignore-redirect` | Whether to ignore redirects (true) or follow them (false, default). | `false` |
604-
| `client.timeout` | Duration before timing out. | `10s` |
605-
| `client.dns-resolver` | Override the DNS resolver using the format `{proto}://{host}:{port}`. | `""` |
606-
| `client.oauth2` | OAuth2 client configuration. | `{}` |
607-
| `client.oauth2.token-url` | The token endpoint URL | required `""` |
608-
| `client.oauth2.client-id` | The client id which should be used for the `Client credentials flow` | required `""` |
609-
| `client.oauth2.client-secret` | The client secret which should be used for the `Client credentials flow` | required `""` |
610-
| `client.oauth2.scopes[]` | A list of `scopes` which should be used for the `Client credentials flow`. | required `[""]` |
611-
| `client.proxy-url` | The URL of the proxy to use for the client | `""` |
612-
| `client.identity-aware-proxy` | Google Identity-Aware-Proxy client configuration. | `{}` |
613-
| `client.identity-aware-proxy.audience` | The Identity-Aware-Proxy audience. (client-id of the IAP oauth2 credential) | required `""` |
614-
| `client.tls.certificate-file` | Path to a client certificate (in PEM format) for mTLS configurations. | `""` |
615-
| `client.tls.private-key-file` | Path to a client private key (in PEM format) for mTLS configurations. | `""` |
616-
| `client.tls.renegotiation` | Type of renegotiation support to provide. (`never`, `freely`, `once`). | `"never"` |
617-
| `client.network` | The network to use for ICMP endpoint client (`ip`, `ip4` or `ip6`). | `"ip"` |
601+
| Parameter | Description | Default |
602+
|:---------------------------------------|:------------------------------------------------------------------------------|:----------------|
603+
| `client.insecure` | Whether to skip verifying the server's certificate chain and host name. | `false` |
604+
| `client.ignore-redirect` | Whether to ignore redirects (true) or follow them (false, default). | `false` |
605+
| `client.timeout` | Duration before timing out. | `10s` |
606+
| `client.dns-resolver` | Override the DNS resolver using the format `{proto}://{host}:{port}`. | `""` |
607+
| `client.oauth2` | OAuth2 client configuration. | `{}` |
608+
| `client.oauth2.token-url` | The token endpoint URL | required `""` |
609+
| `client.oauth2.client-id` | The client id which should be used for the `Client credentials flow` | required `""` |
610+
| `client.oauth2.client-secret` | The client secret which should be used for the `Client credentials flow` | required `""` |
611+
| `client.oauth2.scopes[]` | A list of `scopes` which should be used for the `Client credentials flow`. | required `[""]` |
612+
| `client.proxy-url` | The URL of the proxy to use for the client | `""` |
613+
| `client.identity-aware-proxy` | Google Identity-Aware-Proxy client configuration. | `{}` |
614+
| `client.identity-aware-proxy.audience` | The Identity-Aware-Proxy audience. (client-id of the IAP oauth2 credential) | required `""` |
615+
| `client.tls.certificate-file` | Path to a client certificate (in PEM format) for mTLS configurations. | `""` |
616+
| `client.tls.private-key-file` | Path to a client private key (in PEM format) for mTLS configurations. | `""` |
617+
| `client.tls.renegotiation` | Type of renegotiation support to provide. (`never`, `freely`, `once`). | `"never"` |
618+
| `client.network` | The network to use for ICMP endpoint client (`ip`, `ip4` or `ip6`). | `"ip"` |
619+
| `client.tunnel` | Name of the SSH tunnel to use for this endpoint. See [Tunneling](#tunneling). | `""` |
618620

619621

620622
> 📝 Some of these parameters are ignored based on the type of endpoint. For instance, there's no certificate involved
@@ -705,6 +707,45 @@ endpoints:
705707

706708
> 📝 Note that if running in a container, you must volume mount the certificate and key into the container.
707709

710+
### Tunneling
711+
Gatus supports SSH tunneling to monitor internal services through jump hosts or bastion servers.
712+
This is particularly useful for monitoring services that are not directly accessible from where Gatus is deployed.
713+
714+
SSH tunnels are defined globally in the `tunneling` section and then referenced by name in endpoint client configurations.
715+
716+
| Parameter | Description | Default |
717+
|:--------------------------------------|:------------------------------------------------------------|:--------------|
718+
| `tunneling` | SSH tunnel configurations | `{}` |
719+
| `tunneling.<tunnel-name>` | Configuration for a named SSH tunnel | `{}` |
720+
| `tunneling.<tunnel-name>.type` | Type of tunnel (currently only `SSH` is supported) | Required `""` |
721+
| `tunneling.<tunnel-name>.host` | SSH server hostname or IP address | Required `""` |
722+
| `tunneling.<tunnel-name>.port` | SSH server port | `22` |
723+
| `tunneling.<tunnel-name>.username` | SSH username | Required `""` |
724+
| `tunneling.<tunnel-name>.password` | SSH password (use either this or private-key) | `""` |
725+
| `tunneling.<tunnel-name>.private-key` | SSH private key in PEM format (use either this or password) | `""` |
726+
| `client.tunnel` | Name of the tunnel to use for this endpoint | `""` |
727+
728+
```yaml
729+
tunneling:
730+
production:
731+
type: SSH
732+
host: "jumphost.example.com"
733+
username: "monitoring"
734+
private-key: |
735+
-----BEGIN RSA PRIVATE KEY-----
736+
MIIEpAIBAAKCAQEA...
737+
-----END RSA PRIVATE KEY-----
738+
739+
endpoints:
740+
- name: "internal-api"
741+
url: "http://internal-api.example.com:8080/health"
742+
client:
743+
tunnel: "production"
744+
conditions:
745+
- "[STATUS] == 200"
746+
```
747+
748+
708749
### Alerting
709750
Gatus supports multiple alerting providers, such as Slack and PagerDuty, and supports different alerts for each
710751
individual endpoints with configurable descriptions and thresholds.

client/client.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import (
44
"context"
55
"crypto/tls"
66
"crypto/x509"
7-
"encoding/json"
87
"encoding/hex"
8+
"encoding/json"
99
"errors"
1010
"fmt"
1111
"io"
@@ -516,4 +516,4 @@ func reverseNameForIP(ipStr string) (string, error) {
516516
nibbles[i], nibbles[j] = nibbles[j], nibbles[i]
517517
}
518518
return strings.Join(nibbles, ".") + ".ip6.arpa.", nil
519-
}
519+
}

client/config.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"strconv"
1212
"time"
1313

14+
"github.com/TwiN/gatus/v5/config/tunneling/sshtunnel"
1415
"github.com/TwiN/logr"
1516
"golang.org/x/oauth2"
1617
"golang.org/x/oauth2/clientcredentials"
@@ -69,13 +70,19 @@ type Config struct {
6970
// IAPConfig is the Google Cloud Identity-Aware-Proxy configuration used for the client. (e.g. audience)
7071
IAPConfig *IAPConfig `yaml:"identity-aware-proxy,omitempty"`
7172

72-
httpClient *http.Client
73-
7473
// Network (ip, ip4 or ip6) for the ICMP client
7574
Network string `yaml:"network"`
7675

7776
// TLS configuration (optional)
7877
TLS *TLSConfig `yaml:"tls,omitempty"`
78+
79+
// Tunnel is the name of the SSH tunnel to use for the client
80+
Tunnel string `yaml:"tunnel,omitempty"`
81+
82+
// ResolvedTunnel is the resolved SSH tunnel for this specific Config
83+
ResolvedTunnel *sshtunnel.SSHTunnel `yaml:"-"`
84+
85+
httpClient *http.Client
7986
}
8087

8188
// DNSResolverConfig is the parsed configuration from the DNSResolver config string.
@@ -265,6 +272,14 @@ func (c *Config) getHTTPClient() *http.Client {
265272
} else if c.HasIAPConfig() {
266273
c.httpClient = configureIAP(c.httpClient, *c.IAPConfig)
267274
}
275+
if c.ResolvedTunnel != nil {
276+
// Use SSH tunnel dialer
277+
if transport, ok := c.httpClient.Transport.(*http.Transport); ok {
278+
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
279+
return c.ResolvedTunnel.Dial(network, addr)
280+
}
281+
}
282+
}
268283
}
269284
return c.httpClient
270285
}

config/config.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@ import (
1414
"github.com/TwiN/gatus/v5/alerting"
1515
"github.com/TwiN/gatus/v5/alerting/alert"
1616
"github.com/TwiN/gatus/v5/alerting/provider"
17+
"github.com/TwiN/gatus/v5/client"
1718
"github.com/TwiN/gatus/v5/config/announcement"
1819
"github.com/TwiN/gatus/v5/config/connectivity"
1920
"github.com/TwiN/gatus/v5/config/endpoint"
2021
"github.com/TwiN/gatus/v5/config/key"
2122
"github.com/TwiN/gatus/v5/config/maintenance"
2223
"github.com/TwiN/gatus/v5/config/remote"
2324
"github.com/TwiN/gatus/v5/config/suite"
25+
"github.com/TwiN/gatus/v5/config/tunneling"
2426
"github.com/TwiN/gatus/v5/config/ui"
2527
"github.com/TwiN/gatus/v5/config/web"
2628
"github.com/TwiN/gatus/v5/security"
@@ -114,6 +116,9 @@ type Config struct {
114116
// Connectivity is the configuration for connectivity
115117
Connectivity *connectivity.Config `yaml:"connectivity,omitempty"`
116118

119+
// Tunneling is the configuration for SSH tunneling
120+
Tunneling *tunneling.Config `yaml:"tunneling,omitempty"`
121+
117122
// Announcements is the list of system-wide announcements
118123
Announcements []*announcement.Announcement `yaml:"announcements,omitempty"`
119124

@@ -320,6 +325,9 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
320325
if err := validateConnectivityConfig(config); err != nil {
321326
return nil, err
322327
}
328+
if err := validateTunnelingConfig(config); err != nil {
329+
return nil, err
330+
}
323331
if err := validateAnnouncementsConfig(config); err != nil {
324332
return nil, err
325333
}
@@ -343,6 +351,59 @@ func validateConnectivityConfig(config *Config) error {
343351
return nil
344352
}
345353

354+
// validateTunnelingConfig validates the tunneling configuration and resolves tunnel references
355+
// NOTE: This must be called after validateEndpointsConfig and validateSuitesConfig
356+
// because it resolves tunnel references in endpoint and suite client configurations
357+
func validateTunnelingConfig(config *Config) error {
358+
if config.Tunneling != nil {
359+
if err := config.Tunneling.ValidateAndSetDefaults(); err != nil {
360+
return err
361+
}
362+
// Resolve tunnel references in all endpoints
363+
for _, ep := range config.Endpoints {
364+
if err := resolveTunnelForClientConfig(config, ep.ClientConfig); err != nil {
365+
return fmt.Errorf("endpoint '%s': %w", ep.Key(), err)
366+
}
367+
}
368+
// Resolve tunnel references in suite endpoints
369+
for _, s := range config.Suites {
370+
for _, ep := range s.Endpoints {
371+
if err := resolveTunnelForClientConfig(config, ep.ClientConfig); err != nil {
372+
return fmt.Errorf("suite '%s' endpoint '%s': %w", s.Key(), ep.Key(), err)
373+
}
374+
}
375+
}
376+
// TODO: Add tunnel support for alert providers when needed
377+
}
378+
return nil
379+
}
380+
381+
// resolveTunnelForClientConfig resolves tunnel references in a client configuration
382+
func resolveTunnelForClientConfig(config *Config, clientConfig *client.Config) error {
383+
if clientConfig == nil || clientConfig.Tunnel == "" {
384+
return nil
385+
}
386+
// Validate tunnel name
387+
tunnelName := strings.TrimSpace(clientConfig.Tunnel)
388+
if tunnelName == "" {
389+
return fmt.Errorf("tunnel name cannot be empty")
390+
}
391+
if config.Tunneling == nil {
392+
return fmt.Errorf("tunnel '%s' referenced but no tunneling configuration defined", tunnelName)
393+
}
394+
_, exists := config.Tunneling.Tunnels[tunnelName]
395+
if !exists {
396+
return fmt.Errorf("tunnel '%s' not found in tunneling configuration", tunnelName)
397+
}
398+
// Get or create the SSH tunnel instance and store it directly in client config
399+
tunnel, err := config.Tunneling.GetTunnel(tunnelName)
400+
if err != nil {
401+
return fmt.Errorf("failed to get tunnel '%s': %w", tunnelName, err)
402+
}
403+
clientConfig.ResolvedTunnel = tunnel
404+
return nil
405+
}
406+
346407
func validateAnnouncementsConfig(config *Config) error {
347408
if config.Announcements != nil {
348409
if err := announcement.ValidateAndSetDefaults(config.Announcements); err != nil {

0 commit comments

Comments
 (0)