diff --git a/README.md b/README.md index 467dd9f7..13ff8f1a 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga - [Monitoring a UDP endpoint](#monitoring-a-udp-endpoint) - [Monitoring a SCTP endpoint](#monitoring-a-sctp-endpoint) - [Monitoring a WebSocket endpoint](#monitoring-a-websocket-endpoint) + - [Monitoring an endpoint using gRPC](#monitoring-an-endpoint-using-grpc) - [Monitoring an endpoint using ICMP](#monitoring-an-endpoint-using-icmp) - [Monitoring an endpoint using DNS queries](#monitoring-an-endpoint-using-dns-queries) - [Monitoring an endpoint using SSH](#monitoring-an-endpoint-using-ssh) @@ -2956,6 +2957,45 @@ shows whether the connection was successfully established. You can use Go templa syntax. +### Monitoring an endpoint using gRPC +You can monitor gRPC services by prefixing `endpoints[].url` with `grpc://` or `grpcs://`. +Gatus executes the standard `grpc.health.v1.Health/Check` RPC against the target. + +```yaml +endpoints: + - name: my-grpc + url: grpc://localhost:50051 + interval: 30s + conditions: + - "[CONNECTED] == true" + - "[BODY].status == SERVING" # BODY is read only when referenced + client: + timeout: 5s +``` + +For TLS-enabled servers, use `grpcs://` and configure client TLS if necessary: + +```yaml +endpoints: + - name: my-grpcs + url: grpcs://example.com:443 + conditions: + - "[CONNECTED] == true" + - "[BODY].status == SERVING" + client: + timeout: 5s + insecure: false # set true to skip cert verification (not recommended) + tls: + certificate-file: /path/to/cert.pem # optional mTLS client cert + private-key-file: /path/to/key.pem # optional mTLS client key +``` + +Notes: +- The health check targets the default service (`service: ""`). Support for a custom service name can be added later if needed. +- The response body is exposed as a minimal JSON object like `{"status":"SERVING"}` only when required by conditions or suite store mappings. +- Timeouts, custom DNS resolvers and SSH tunnels are honored via the existing [`client` configuration](#client-configuration). + + ### Monitoring an endpoint using ICMP By prefixing `endpoints[].url` with `icmp://`, you can monitor endpoints at a very basic level using ICMP, or more commonly known as "ping" or "echo": diff --git a/client/grpc.go b/client/grpc.go new file mode 100644 index 00000000..0d2be6ea --- /dev/null +++ b/client/grpc.go @@ -0,0 +1,71 @@ +package client + +import ( + "context" + "crypto/tls" + "net" + "time" + + "github.com/TwiN/logr" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + health "google.golang.org/grpc/health/grpc_health_v1" +) + +// PerformGRPCHealthCheck dials a gRPC target and performs the standard Health/Check RPC. +// Returns whether a connection was established, the serving status string, an error (if any), and the elapsed duration. +func PerformGRPCHealthCheck(address string, useTLS bool, cfg *Config) (bool, string, error, time.Duration) { + if cfg == nil { + cfg = GetDefaultConfig() + } + ctx, cancel := context.WithTimeout(context.Background(), cfg.Timeout) + defer cancel() + + var opts []grpc.DialOption + // Transport credentials + if useTLS { + tlsCfg := &tls.Config{InsecureSkipVerify: cfg.Insecure} + if cfg.HasTLSConfig() && cfg.TLS.isValid() == nil { + tlsCfg = configureTLS(tlsCfg, *cfg.TLS) + } + opts = append(opts, grpc.WithTransportCredentials(credentials.NewTLS(tlsCfg))) + } else { + opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials())) + } + // Custom dialer for DNS resolver or SSH tunnel + opts = append(opts, grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) { + if cfg.ResolvedTunnel != nil { + return cfg.ResolvedTunnel.Dial("tcp", addr) + } + if cfg.HasCustomDNSResolver() { + resolverCfg, err := cfg.parseDNSResolver() + if err != nil { + // Shouldn't happen because already validated; log and fall back + logr.Errorf("[client.PerformGRPCHealthCheck] invalid DNS resolver: %v", err) + } else { + d := &net.Dialer{Resolver: &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, _ string) (net.Conn, error) { + d := net.Dialer{} + return d.DialContext(ctx, resolverCfg.Protocol, resolverCfg.Host+":"+resolverCfg.Port) + }}} + return d.DialContext(ctx, "tcp", addr) + } + } + var d net.Dialer + return d.DialContext(ctx, "tcp", addr) + })) + + start := time.Now() + conn, err := grpc.DialContext(ctx, address, opts...) + if err != nil { + return false, "", err, time.Since(start) + } + defer conn.Close() + + client := health.NewHealthClient(conn) + resp, err := client.Check(ctx, &health.HealthCheckRequest{Service: ""}) + if err != nil { + return false, "", err, time.Since(start) + } + return true, resp.GetStatus().String(), nil, time.Since(start) +} diff --git a/config/endpoint/endpoint.go b/config/endpoint/endpoint.go index 68cf0474..240e76b9 100644 --- a/config/endpoint/endpoint.go +++ b/config/endpoint/endpoint.go @@ -50,6 +50,7 @@ const ( TypeSTARTTLS Type = "STARTTLS" TypeTLS Type = "TLS" TypeHTTP Type = "HTTP" + TypeGRPC Type = "GRPC" TypeWS Type = "WEBSOCKET" TypeSSH Type = "SSH" TypeUNKNOWN Type = "UNKNOWN" @@ -177,6 +178,8 @@ func (e *Endpoint) Type() Type { return TypeTLS case strings.HasPrefix(e.URL, "http://") || strings.HasPrefix(e.URL, "https://"): return TypeHTTP + case strings.HasPrefix(e.URL, "grpc://") || strings.HasPrefix(e.URL, "grpcs://"): + return TypeGRPC case strings.HasPrefix(e.URL, "ws://") || strings.HasPrefix(e.URL, "wss://"): return TypeWS case strings.HasPrefix(e.URL, "ssh://"): @@ -528,6 +531,19 @@ func (e *Endpoint) call(result *Result) { result.Body = output } result.Duration = time.Since(startTime) + } else if endpointType == TypeGRPC { + useTLS := strings.HasPrefix(e.URL, "grpcs://") + address := strings.TrimPrefix(strings.TrimPrefix(e.URL, "grpcs://"), "grpc://") + connected, status, err, duration := client.PerformGRPCHealthCheck(address, useTLS, e.ClientConfig) + if err != nil { + result.AddError(err.Error()) + return + } + result.Connected = connected + result.Duration = duration + if e.needsToReadBody() { + result.Body = []byte(fmt.Sprintf("{\"status\":\"%s\"}", status)) + } } else { response, err = client.GetHTTPClient(e.ClientConfig).Do(request) result.Duration = time.Since(startTime) diff --git a/go.mod b/go.mod index ef0b730d..790954b1 100644 --- a/go.mod +++ b/go.mod @@ -33,6 +33,7 @@ require ( golang.org/x/oauth2 v0.32.0 golang.org/x/sync v0.17.0 google.golang.org/api v0.252.0 + google.golang.org/grpc v1.75.1 gopkg.in/mail.v2 v2.3.1 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.39.1