GKE gRPC Ingress Health Check with mTLS

11/21/2018

I am trying to implement a gRPC service on GKE (v1.11.2-gke.18) with mutual TLS auth.

When not enforcing client auth, the HTTP2 health check that GKE automatically creates responds, and everything connects issue.

When I turn on mutual auth, the health check fails - presumably because it cannot complete a connection since it lacks a client certificate and key.

As always, documentation is light and conflicting. I require a solution that is fully programmatic (I.e. no console tweaking), but I have not been able to find a solution, other than manually changing the health check to TCP.

From what I can see I am guessing that I will either need to:

  • implement a custom mTLS health check that will prevent GKE automatically creating a HTTP2 check
  • find an alternative way to do SSL termination at the container that doesn't use the service.alpha.kubernetes.io/app-protocols: '{"grpc":"HTTP2"}' proprietary annotation
  • find some way to provide the health check with the credentials it needs
  • alter my go implementation to somehow server a health check without requiring mTLS, while enforcing mTLS on all other endpoints

Or perhaps there is something else that I have not considered? The config below works perfectly for REST and gRPC with TLS but breaks with mTLS.

service.yaml

apiVersion: v1
kind: Service
metadata:
  name: grpc-srv
  labels:
    type: grpc-srv
  annotations:
    service.alpha.kubernetes.io/app-protocols: '{"grpc":"HTTP2"}'
spec:
  type: NodePort
  ports:
  - name: grpc
    port: 9999
    protocol: TCP
    targetPort: 9999
  - name: http
    port: 8080
    protocol: TCP
    targetPort: 8080
  selector:
    app: myapp

ingress.yaml

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: io-ingress
  annotations:
    kubernetes.io/ingress.global-static-ip-name: "grpc-ingress"
    kubernetes.io/ingress.allow-http: "true"
spec:
  tls:
  - secretName: io-grpc
  - secretName: io-api
  rules:
  - host: grpc.xxx.com
    http:
      paths:
      - path: /*
        backend:
          serviceName: grpc-srv
          servicePort: 9999
  - host: rest.xxx.com
    http:
      paths:
      - path: /*
        backend:
          serviceName: grpc-srv
          servicePort: 8080
-- PassKit
google-kubernetes-engine
grpc
kubernetes-health-check
kubernetes-ingress
mutual-authentication

2 Answers

11/21/2018

HTTP/2 and gRPC support on GKE is not available yet. Please see limitation. There is already a feature request in the works in order to address the issue.

-- dany L
Source: StackOverflow

5/15/2019

It seems that there is currently no way to achieve this using the GKE L7 ingress. But I have been successful deploying an NGINX Ingress Controller. Google have a not bad tutorial on how to deploy one here.

This installs a L4 TCP load balancer with no health checks on the services, leaving NGINX to handle the L7 termination and routing. This gives you a lot more flexibility, but the devil is in the detail, and the detail isn't easy to come by. Most of what I found was learned trawling through github issues.

What I have managed to achieve is for NGINX to handle the TLS termination, and still pass through the certificate to the back end, so you can handle things such as user auth via the CN, or check the certificate serial against a CRL.

Below is my ingress file. The annotations are the minimum required to achieve mTLS authentication, and still have access to the certificate in the back end.

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: grpc-ingress
  namespace: master
  annotations:
    kubernetes.io/ingress.class: nginx
    nginx.ingress.kubernetes.io/auth-tls-verify-client: "on"
    nginx.ingress.kubernetes.io/auth-tls-secret: "master/auth-tls-chain"
    nginx.ingress.kubernetes.io/auth-tls-verify-depth: "2"
    nginx.ingress.kubernetes.io/auth-tls-pass-certificate-to-upstream: "true"
    nginx.ingress.kubernetes.io/backend-protocol: "GRPCS"
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
    nginx.ingress.kubernetes.io/grpc-backend: "true"
spec:
  tls:
    - hosts:
        - grpc.example.com
      secretName: auth-tls-chain
  rules:
    - host: grpc.example.com
      http:
        paths:
          - path: /grpc.AwesomeService
            backend:
              serviceName: awesome-srv
              servicePort: 9999
          - path: /grpc.FantasticService
            backend:
              serviceName: fantastic-srv
              servicePort: 9999

A few things to note:

  • The auth-ls-chain secret contains 3 files. ca.crt is the certificate chain and should include any intermediate certificates. tls.crt contains your server certificate and tls.key contains your private key.
  • If this secret lies in a namespace that is different to the NGINX ingress, then you should give the full path in the annotation.
  • My verify-depth is 2, but that is because I am using intermediate certificates. If you are using self signed, then you will only need a depth of 1.
  • backend-protocol: "GRPCS" is required to prevent NGINX terminating the TLS. If you want to have NGINX terminate the TLS and run your services without encryption, use GRPC as the protocol.
  • grpc-backend: "true" is required to let NGINX know to use HTTP2 for the backend requests.
  • You can list multiple paths and direct to multiple services. Unlike with the GKE ingress, these paths should not have a forward slash or asterisk suffix.

The best part is that if you have multiple namespaces, or if you are running a REST service as well (E.g. gRPC Gateway), NGINX will reuse the same load balancer. This provides some savings over the GKE ingress, that would use a separate LB for each ingress.

The above is from the master namespace and below is a REST ingress from the staging namespace.

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  namespace: staging
  annotations:
    kubernetes.io/ingress.class: nginx
    kubernetes.io/tls-acme: "true"
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
  tls:
    - hosts:
      - api-stage.example.com
      secretName: letsencrypt-staging
  rules:
    - host: api-stage.example.com
      http:
        paths:
          - path: /awesome
            backend:
              serviceName: awesom-srv
              servicePort: 8080
          - path: /fantastic
            backend:
              serviceName: fantastic-srv
              servicePort: 8080

For HTTP, I am using LetsEncrypt, but there's plenty of information available on how to set that up.

If you exec into the ingress-nginx pod, you will be able to see how NGINX has been configured:

...
        server {
                server_name grpc.example.com ;
                listen 80;
                set $proxy_upstream_name "-";
                set $pass_access_scheme $scheme;
                set $pass_server_port $server_port;
                set $best_http_host $http_host;
                set $pass_port $pass_server_port;

                listen 442 proxy_protocol   ssl http2;

                # PEM sha: 142600b0866df5ed9b8a363294b5fd2490c8619d
                ssl_certificate                         /etc/ingress-controller/ssl/default-fake-certificate.pem;
                ssl_certificate_key                     /etc/ingress-controller/ssl/default-fake-certificate.pem;

                ssl_certificate_by_lua_block {
                        certificate.call()
                }

                # PEM sha: 142600b0866df5ed9b8a363294b5fd2490c8619d
                ssl_client_certificate                  /etc/ingress-controller/ssl/master-auth-tls-chain.pem;
                ssl_verify_client                       on;
                ssl_verify_depth                        2;

                error_page 495 496 = https://help.example.com/auth;

                location /grpc.AwesomeService {

                        set $namespace      "master";
                        set $ingress_name   "grpc-ingress";
                        set $service_name   "awesome-srv";
                        set $service_port   "9999";
                        set $location_path  "/grpc.AwesomeServices";

                        rewrite_by_lua_block {
                                lua_ingress.rewrite({
                                        force_ssl_redirect = true,
                                        use_port_in_redirects = false,
                                })
                                balancer.rewrite()
                                plugins.run()
                        }

                        header_filter_by_lua_block {
                                plugins.run()
                        }
                        body_filter_by_lua_block {
                        }

                        log_by_lua_block {
                                balancer.log()
                                monitor.call()
                                plugins.run()
                        }

                        if ($scheme = https) {
                                more_set_headers                        "Strict-Transport-Security: max-age=15724800; includeSubDomains";
                        }

                        port_in_redirect off;
                        set $proxy_upstream_name    "master-analytics-srv-9999";
                        set $proxy_host             $proxy_upstream_name;
                        client_max_body_size                    1m;
                        grpc_set_header Host                   $best_http_host;

                        # Pass the extracted client certificate to the backend
                        grpc_set_header ssl-client-cert        $ssl_client_escaped_cert;
                        grpc_set_header ssl-client-verify      $ssl_client_verify;
                        grpc_set_header ssl-client-subject-dn  $ssl_client_s_dn;
                        grpc_set_header ssl-client-issuer-dn   $ssl_client_i_dn;

                        # Allow websocket connections
                        grpc_set_header                        Upgrade           $http_upgrade;
                        grpc_set_header                        Connection        $connection_upgrade;
                        grpc_set_header X-Request-ID           $req_id;
                        grpc_set_header X-Real-IP              $the_real_ip;
                        grpc_set_header X-Forwarded-For        $the_real_ip;
                        grpc_set_header X-Forwarded-Host       $best_http_host;
                        grpc_set_header X-Forwarded-Port       $pass_port;
                        grpc_set_header X-Forwarded-Proto      $pass_access_scheme;
                        grpc_set_header X-Original-URI         $request_uri;
                        grpc_set_header X-Scheme               $pass_access_scheme;
                        # Pass the original X-Forwarded-For
                        grpc_set_header X-Original-Forwarded-For $http_x_forwarded_for;
                        # mitigate HTTPoxy Vulnerability
                        # https://www.nginx.com/blog/mitigating-the-httpoxy-vulnerability-with-nginx/
                        grpc_set_header Proxy                  "";

                        # Custom headers to proxied server
                        proxy_connect_timeout                   5s;
                        proxy_send_timeout                      60s;
                        proxy_read_timeout                      60s;
                        proxy_buffering                         off;
                        proxy_buffer_size                       4k;
                        proxy_buffers                           4 4k;
                        proxy_request_buffering                 on;
                        proxy_http_version                      1.1;
                        proxy_cookie_domain                     off;
                        proxy_cookie_path                       off;

                        # In case of errors try the next upstream server before returning an error
                        proxy_next_upstream                     error timeout;
                        proxy_next_upstream_tries               3;
                        grpc_pass grpcs://upstream_balancer;
                        proxy_redirect                          off;

                }
                location /grpc.FantasticService {

                        set $namespace      "master";
                        set $ingress_name   "grpc-ingress";
                        set $service_name   "fantastic-srv";
                        set $service_port   "9999";
                        set $location_path  "/grpc.FantasticService";

...

This is just an extract of the generated nginx.conf. But you should be able to see how a single configuration could handle multiple services across multiple namespaces.

The last piece is a go snippet of how we get hold of the certificate via the context. As you can see from the config above, NGINX adds the authenticated cert and other details into the gRPC metadata.

meta, ok := metadata.FromIncomingContext(*ctx)
if !ok {
    return status.Error(codes.Unauthenticated, "missing metadata")
}

// Check if SSL has been handled upstream
if len(meta.Get("ssl-client-verify")) == 1 && meta.Get("ssl-client-verify")[0] == "SUCCESS" {
    if len(meta.Get("ssl-client-cert")) > 0 {
        certPEM, err := url.QueryUnescape(meta.Get("ssl-client-cert")[0])
        if err != nil {
            return status.Errorf(codes.Unauthenticated, "bad or corrupt certificate")
        }
        block, _ := pem.Decode([]byte(certPEM))
        if block == nil {
            return status.Error(codes.Unauthenticated, "failed to parse certificate PEM")
        }
        cert, err := x509.ParseCertificate(block.Bytes)
        if err != nil {
            return status.Error(codes.Unauthenticated, "failed to parse certificate PEM")
        }
        return authUserFromCertificate(ctx, cert)
    }
}
// if fallen through, then try to authenticate via the peer object for gRPCS, 
// or via a JWT in the metadata for gRPC Gateway.
-- PassKit
Source: StackOverflow