Here’s a simple way to expose an Ingress controller running in a private network to the public. My use case is for adhoc standup of Kubernetes clusters via K3d on my computer while testing automation (eg, self managed ArgoCD) so that I can validate cert-manager & external-dns.

I purposefully kept the tools required to a minimum - this is proof of concept stage more than anything. The concepts aren’t new, but so far I haven’t see this exact solution shared. Not that there isn’t prior art in the space: see InletsPRO or ngrok. Both are paid tools - which is a good thing, pay people for good tools!

My use-case can be met with simpler tooling though:

  • 1 VPS w/ public IP (DigitalOcean is a great option at $5/mo)
  • An SSH client sidecar attached to an Ingress controller Pod

That’s it! I don’t have this polished up, but it is working on my home cluster right now.

Generate SSH Key

Use key-based authentication. Just do it.

❯ ssh-keygen -t ed25519 -f ./digitalocean.clusteraccess -C "remote port forward DO to private ingress"

Don’t create a passphrase. Copy the digitalocean.clusteraccess.pub file generated and use it when creating the VPS.

Configure VPS

Again, this isn’t polished - ideally I’d have a simple command here using doctl to create the perfect droplet with the appropriate sshd_config. Suck it up, we’re using the cloud console:

Login or create a DigitalOcean account - if it’s a new account you can get $5 credit by getting an invite from someone (drop me a line on Twitter). You don’t need anything crazy, just a basic droplet in a reasonably close zone. Under Authentication choose New SSH Key. Paste in the .pub key you copied in the previous step.

Once your droplet is created, grab the public IP address and login:

❯ ssh root@161.35.225.155 -i ./digitalocean.clusteraccess

We need to modify the sshd config to enable GatewayPorts. This will permit listening on the public interface so our SSH port forwarding will have the desired result. Edit /etc/ssh/sshd_config using your favorite editor, vim. Uncomment or add the GatewayPorts entry and set it to yes. Then reload ssh: service ssh restart.

Validate it works

Run an http server on your system. I already had mkdocs running on localhost:8000, but you could as easily do the following:

❯ echo "<b>hello world</b>" > index.html

❯ python3 -m http.server 8080
Serving HTTP on 0.0.0.0 port 8080 (http://0.0.0.0:8080/) ...
127.0.0.1 - - [10/Jul/2021 08:59:45] "GET / HTTP/1.1" 200 -

Use whatever port you like, I overrode the default 8000 to 8080 because… mkdocs was running.

Now, test your SSH forwarding:

❯ ssh root@161.35.225.155 -i ./digitalocean.clusteraccess -R 80:'*':8000

You should now be able to curl 161.35.225.155:

❯ curl 161.35.225.155
<b>hello world</b>

Patch Ingress Controller

Here’s the really fun part, and it’s going to depend heavily on how you deployed your Ingress controller. If you like, you could easily just edit the Deployment object directly. I use helmfile so I opted to use the built-in Kustomize support to patch it in.

I created a ConfigMap in my ingress controller namespace:

⚠️ this is just for the proof of concept, this should really go into a secret!

apiVersion: v1
kind: ConfigMap
metadata:
  name: digitalocean-externalip-key
data: 
  ssh_key: |
    -----BEGIN OPENSSH PRIVATE KEY-----
    **********************************************************************
    **********************************************************************
    *************************itsasecretyo*********************************
    **********************************************************************
    **********************************************************************
    *****=
    -----END OPENSSH PRIVATE KEY-----    

And then patched my Ingress controller deployment (note, this is a helmfile specific example):

- name: ingress-nginx-external
  namespace: nginx-system
  chart: nginx-stable/nginx-ingress
  labels:
    repo: nginx-stable
    chart: nginx-ingress
    component: ingress
    domain: external
  version: ~0.9.3
  values:
    - controller:
        defaultTLS.secret: nginx-system/default-{{ .Environment.Values | get "external_domain" "unknown" }}-tls
        wildcardTLS.secret: nginx-system/default-{{ .Environment.Values | get "external_domain" "unknown" }}-tls
        ingressClass: nginx-external
        service:
          annotations:
            metallb.universe.tf/address-pool: k8s-services
  strategicMergePatches:
    - apiVersion: apps/v1
      kind: Deployment
      metadata:
        name: ingress-nginx-external-nginx-ingress
        namespace: nginx-system
      spec:
        template:
          spec:
            containers:
            - name: ssh-sidecar
              image: gfleury/ssh-client
              command: ["ssh"]
              args:
                - "-i"
                - "/etc/sshkeys/ssh_key"
                - "-o"
                - "UserKnownHostsFile=/dev/null"
                - "-o"
                - "StrictHostKeyChecking=no"
                - "-N"
                - "-R"
                - "80:0.0.0.0:80"
                - "-R"
                - "443:0.0.0.0:443"
                - "-o ExitOnForwardFailure=yes"
                - "root@xxx.xxx.xxx.xx"
              volumeMounts:
              - name: sshkey-volume
                mountPath: /etc/sshkeys
            volumes:
              - name: sshkey-volume
                configMap:
                  name: digitalocean-externalip-key
                  defaultMode: 256

You’ll note the extra options in the SSH command. First, I want both 80 and 443 available. Since I’m not running interactively, I’d prefer to skip the host key checking. Finally, if the port forwarding fails I want to retry. So the SSH command will exit causing the container to be restarted. Nifty huh? You can verify by tailing the logs of the ssh-sidecar container, or connecting to :80 on the public IP. You should get an nginx 404.

Create an Ingress

Ok, almost done! Now to create an Ingress. I like using httpbin as a test application.

Run httpbin & create a Kubernetes service:

❯ kubectl -n default run httpbin --image=kennethreitz/httpbin:latest --port=80 --expose=true

And apply this ingress:

⚠️ This assumes you have cert-manager already setup, otherwise drop the cert-manager & tls items

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt
  name: httpbin
  namespace: default
spec:
  ingressClassName: nginx-external
  rules:
  - host: httpbin.somedomain.tld
    http:
      paths:
      - backend:
          service:
            name: httpbin
            port:
              number: 80
        path: /
        pathType: Prefix
  tls:
  - hosts:
    - httpbin.somedomain.tld
    secretName: httpbin-tls

I created the appropriate DNS record on my domain, but if you’re not using cert-manager you could as easily just add a host entry.

Let’s test:

curl -X GET "https://httpbin.somedomain.tld/base64/ZHVkZSwgdGhpcyBpcyBhYnNvbHV0ZWx5IGFtYXppbmcu" -H  "accept: text/html"

Next Steps & Acknowledgements

This could definitely use polishing up. I added a firewall to my DigitalOcean droplet to allow port 80/443 from anywhere but only accept 22 connections from my home IP. The whole VPS configuration is a simple Terraform or doctl automation. The SSH key should go in a secret, and this solution only works with a single Ingress controller instance at a time. But for a quick hack this works nicely!

I modified my ssh-sidecar config from the one found here. Thanks for the great example gfleury!