Secure, in-browser SSH with Cloudflare

Learn how to connect securely (using short-lived credentials) to your Virtual Machine via SSH in your browser. Also, how to automate the setup process with Cloudflare and Terraform.

Open sign

Learn how to connect securely (using short-lived credentials) to your Virtual Machine via SSH in your browser. Also, how to automate the setup process with Cloudflare and Terraform.

( Photo by Viktor Forgacs )

Have you ever been on and about and wanting to connect to your VM, only to realize you don't have the key on your device? Perhaps you then thought to go back to password-based authentication. Have you forgotten your password? Well, nowadays you should be using "must-have" tools such as Bitwarden for that anyway.

Or, you could forget all the above nonsense and put Cloudflare in front of your SSH service. This could give you a browser-based "Zero Trust" set up in a matter of minutes - thanks to the automation capabilities via Terraform.

I've been meaning to try this setup for quite some time now, and in this article, we will look at it together. The code is also available on Github, feel free to clone the repo and have a look at it while you read this.

git clone --branch 1.0 git@github.com:Vortexmind/cloudflare-ssh-browser.git

Pre-Requisites

For this tutorial, you will need the following:

  • A Cloudflare account.
  • A domain name onboarded on Cloudflare.
  • A Digitalocean account (Note: signing up via this link gives you $100 in credits, and I may get $25 too!).
  • A Github account (I use it for Single Sign-On).
  • Terraform installed on your machine (I'm using Terraform v1.0.7).

Of course, you can experiment and use other IaaS / PaaS providers or even your own VM, or swap in Github for another supported Identity Provider. In that case, you will need to tweak my Terraform configuration accordingly.

In terms of costs, the only part that is not free is the Digitalocean Droplet. However, the cost should be quite minimal since we will use the smallest VM size possible s-1vcpu-1gb (unless you change it in the configs). You can destroy your test environment easily with Terraform once you have played with it.

The Cloudflare for Teams setup described is achievable with the Free Plan, which supports a maximum of 50 users (we use one in my demo).

Architecture & Requirements

In the tutorial, we will have a Virtual Machine running sshd in Digitalocean.

Our objective is to allow external users to reach our VM via SSH from anywhere (office, home, beach bar wi-fi etc...).

The access must be authenticated using a standardised, auditable Single Sign-On mechanism.

Additionally, the SSH endpoint of our VM must be protected and present as little attack surface as possible from the exterior.

Terraform Configuration

Let's have a look at the key parts of the Terraform configuration from the repository I shared.

digitalocean.tf

In the digitalocean.tf file, we have our usual - if you have read my previous tutorials - structure for deploying our VM on Digitalocean.

Most notably, we pass a Cloud-Init template to the VM, including configuration variables. Cloud-Init will then take care of all the bootstrapping tasks based on our instructions.

  user_data = templatefile("${path.module}/cloud-init/web-cloud-init.yaml", {
      account_id = var.cloudflare_account_id
      fqdn = local.cloudflare_fqdn
      cloudflare_tunnel_id = cloudflare_argo_tunnel.ssh_browser.id
      cloudflare_tunnel_name = cloudflare_argo_tunnel.ssh_browser.name
      cloudflare_tunnel_secret = cloudflare_argo_tunnel.ssh_browser.secret
      user = local.user_from_mail
  })

To satisfy our requirement for security and minimal attack surface, we will expose our VM to the world via a Cloudflare Tunnel. It was recently announced that Cloudflare Tunnels are available for free to everyone - why not try using it then?

To use it, we need to install a lightweight daemon (cloudflared) on the VM. This will create outbound encrypted connections to Cloudflare's edge datacenters near the VM. This allows us to route all incoming traffic to the SSH service to Cloudflare;s Edge, where we can perform the authentication checks before forwarding it to the VM.

At the same time, the VM itself can be locked down and reject all incoming traffic, since we will rely on the tunnel connection that was already created from within. Pretty clever!

For the tutorial, we will still configure the VM to accept incoming TCP connections on port 22 (SSH), but only from the IP of the machine we are using to run the demo. This is because Terraform needs to use SSH to manage the resource, and also because it's handy to have when experimenting.

data "http" "my_ip" {
  url = "https://ipv4.icanhazip.com"
}

resource "digitalocean_firewall" "cloudflare_browser_ssh" {
  name = "cloudflare-browser-ssh"

  inbound_rule {
    protocol    = "tcp"
    port_range  = "22"
    source_addresses = ["${chomp(data.http.my_ip.body)}"]
  }

  [...]

}

cloudflare.tf

Unsurprisingly, all the Cloudflare setup happens in the cloudflare.tf file.

Here, we Β set-up the following:

  • The Cloudflare Tunnel (a.k.a. Argo Tunnel) we described above.
  • The DNS record pointing our public-facing SSH endpoint to the tunnel (so that the traffic can be routed to our VM via Cloudflare).
  • The Cloudflare Access Application & Policy that is responsible for enforcing our security policy.
  • The Cloudflare Access Identity Provider - which we will tie to the above application/policy and which will be used for Single Sign-On (SSO).
resource "cloudflare_argo_tunnel" "ssh_browser" {
  account_id = var.cloudflare_account_id
  name       = "cloudflare_ssh_browser"
  secret     = base64encode(var.cloudflare_tunnel_secret)
}

Above, the Tunnel configuration includes a secret value that will be used by cloudflared to authenticate with the Cloudflare Edge servers and establish the tunnel. This resource is then passed to the Cloud-Init template, which will generate the necessary configuration files on the VM to run cloudflared successfully.

resource "cloudflare_access_application" "ssh_browser" {
  zone_id          = lookup(data.cloudflare_zones.configured_zone.zones[0], "id")
  name             = format("%s - Auth",local.cloudflare_fqdn)
  type             = "ssh"
  domain           = local.cloudflare_fqdn
  session_duration = "30m"
}

resource "cloudflare_access_policy" "ssh_policy" {
  application_id = cloudflare_access_application.ssh_browser.id
  zone_id        = lookup(data.cloudflare_zones.configured_zone.zones[0], "id")
  name           = "Allow Configured Users"
  precedence     = "1"
  decision       = "allow"

  include {
    email = [var.user_email]
  }

  require {
      login_method = [cloudflare_access_identity_provider.github_oauth.id]
  }
}

resource "cloudflare_access_identity_provider" "github_oauth" {
  account_id = var.cloudflare_account_id
  name       = "GitHub OAuth"
  type       = "github"
  config {
    client_id     = var.github_oauth_client_id
    client_secret = var.github_oauth_client_secret
  }
}

We then have our Access Application, protecting the FQDN that end-users will reach to start their SSH session.

The Access Policy determines who can use our application and authenticate. In the tutorial, we include a single user by e-mail address, and we require this user to be authenticating using Github.

The integration with Github OAuth is at the bottom. It requires two configuration parameters: client_id and client_secret. To obtain these, you will need to navigate to your Developer Settings > OAuth apps in your Github account. Create a new OAuth App and include the following values.

  • Homepage URL: https://<YOUR_TEAMS_ORG_NAME>.cloudflareaccess.com
  • Authorization Callback URL: https://<YOUR_TEAMS_ORG_NAME>.cloudflareaccess.com/cdn-cgi/access/callback
πŸ™‹ If you cannot remember your Cloudflare Teams Organization name, head to "Settings" > "General" to find it.

Once done, you will see your client_id and you will be able to generate a client_secret as well. Copy the values somewhere safe and paste them in your terraform.tfvars file, alongside all the other parameters.

web-cloud-init.yaml

This is an initialization file used by the VM to start up and create the required environment for our tutorial.

The key parts are the two write_files directives:

write_files:
  - content: |
      {
        "AccountTag"   : "${account_id}",
        "TunnelID"     : "${cloudflare_tunnel_id}",
        "TunnelName"   : "${cloudflare_tunnel_name}",
        "TunnelSecret" : "${cloudflare_tunnel_secret}"
      }
    path: /etc/cloudflared/cert.json
  - content: |
      tunnel: ${cloudflare_tunnel_id}
      credentials-file: /etc/cloudflared/cert.json
      logfile: /var/log/cloudflared.log
      loglevel: info
      ingress:
        - hostname: ${fqdn}
          service: ssh://localhost:22
        - service: http_status:404
    path: /etc/cloudflared/config.yml

With these, we use the template variables passed by Terraform and tell Cloud-Init to create our two Cloudflare Tunnel configuration files. The first one /etc/cloudflared/cert.json contains the credentials for establishing the connection from the VM to Cloudflare's Edge.

The second file contains the actual Tunnel configuration including the ingress rules which send the Tunnel traffic to the correct endpoint based on expression filters. In our case, we send all the traffic matching the public FQDN of our SSH service to the local SSH port, and for anything else, we will just serve an HTTP 404.

This also shows how a single tunnel could be used to route traffic to multiple services if needed. Learn more about ingress rules here.

We also have the bottom runcmd section of our Cloud-Init file. Let's have a look:

runcmd:
- adduser --disabled-password --gecos "" ${user}
- mkdir -p /etc/cloudflared
- wget -O /tmp/cloudflared-linux-amd64.deb https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
- dpkg -i /tmp/cloudflared-linux-amd64.deb
- cloudflared service install
- systemctl start cloudflared
- systemctl enable cloudflared

The adduser line will come in handy in the latter part of the tutorial, where we will set up short-lived certificate-based authentication. For that, we will need a user on our VM which will match with the SSO identity. We will pull this identity from the e-mail address of the authenticated user.

The rest of the commands are used to download and install the cloudflared daemon, and then run it as a service. This enables it to restart after reboots and establish a connection to Cloudflare.

terraform.tfvars.example

This file is a template for your terraform.tfvars configuration file. Most importantly you will need:

  • A Digitalocean API token.
  • An SSH Key for administrative access to the VM.
  • To change digitalocean_droplet_region if lon1 is not the best for you.
  • Github OAuth ID and Secret as described.
  • The e-mail address of the Github User that you will use to log in.
  • The Cloudflare domain that you onboarded, and the value for the CNAME record (so if you onboarded example.com and ssh is your CNAME, then your app will live at ssh.example.com).
  • A Cloudflare Tunnel secret: You can use any string over 32 bytes here.
  • A Cloudflare API Token.

For the Cloudflare API token, I've set the following permissions for it:

Account
---
Argo Tunnel:Edit
Access: Service Tokens:Edit
Access: Organizations, Identity Providers, and Groups:Edit
Access: Apps and Policies:Edit

Domain
---
Access: Apps and Policies:Edit 
DNS:Edit

It's time to see some action!

Demo

After you created and populated your terraform.tfvars file, you are ready to start.

We use the terraform init command to download the Cloudflare and Digitalocean providers needed for running all the set-up calls.

Next, terraform plan -out plan.txt will give you an overview of the resources and planned changes. This is your chance to review that you are not about to provision a nuclear powered, super-expensive top of the line Digitalocean droplet by mistake πŸ˜….

Ready? Then it's time for terraform apply "plan.txt" - if everything goes smoothly you should be welcomed with a message including a few useful commands

Output of Terraform Apply with useful commands

We could ssh from our terminal using the command shown: this would connect directly to the Digitalocean VM on port 22, bypassing Cloudflare.

Instead, let's try to reach our Access application in our browser. Here is my test:

Quite cool isn't it? We are prompted to authenticate via our selected Single Sign-On provider (Github). Then we are redirected in the browser and we can enter the user ( root in our example) and the private key/passphrase and let Cloudflare handle the rest. Eventually, we are connected to our droplet directly in our browser.

Still, this is not as smooth as it could be. We need to specify the ssh user, and then we need to pass our key (very much in the same way as we would do from our terminal). We can improve this even further by enabling authentication via Short-Lived Certificates:

Cloudflare Access can replace traditional SSH key models with short-lived certificates issued to your users based on the token generated by their Access login. In traditional models, users generate a keypair and commit their public key into an infrastructure management tool, like Salt, or otherwise upload it to an administrator. These keys can remain unchanged for months or years.

Cloudflare Access removes the burden on the end-user of generating a key, while also improving the security of access to infrastructure with ephemeral certificates.

Let's do this!

Short-Lived Certificates

Note: the below steps can in fact be automated with Terraform. The latest version of my code in Github will do these steps for you automatically. Just download the latest version instead of the 1.0 tag to see the configuration.

There are many reasons (as seen above) why it is a good idea to move away from SSH public keys and use instead short-lived credentials that are generated as needed when the authentication happens. There is an extensive blog post that dives into the reasons and also explains fully how the Cloudflare approach work.

Since the explanation is already covered in the above link, we will focus instead on trying it out ourselves. To set this up, we will need to head to our Cloudflare for Teams dashboard and then generate the Certificate for our application and retrieve the associated Public Key:

Cloudflare for Teams Dashboard - Service Authentication

Once generated, copy the public key and head back to your VM. We will update the SSH Daemon configuration to use this form of authentication so that we can streamline our browser-based access even further. You can head back to your in-browser, root SSH session to continue the setup from there.

First, we save the public key from above in /etc/ssh/ca.pub , then we modify the SSH Daemon configuration to enable Public Key authentication and link the above key as trusted.

PubkeyAuthentication yes
TrustedUserCAKeys /etc/ssh/ca.pub
/etc/ssh/sshd_config

I have recorded the steps in the below video.

And now - we can close our browser and try again ...

Very smooth! We get seamlessly authenticated into our SSH session and we are recognized from the information received from the identity provider. Of course, we can access our VM from anywhere now - and provided that we do not set up more restrictive policies for our application with Cloudflare Access.

In a multi user scenario, it could be very handy to add and revoke access permissions to various team members as needed, or even temporary collaborators. All the login attempts are also audited and can be reviewed in the Cloudflare for Teams dashboard 😎.

Once you're happy with your tests, do remember to issue a terraform destroy to remove all the resources we created

I hope you enjoyed this tutorial. Let me know if you have any questions in the comments section below!

If you liked this article, follow me on Twitter for more updates!

Important: Please DONATE and help me raise funds to support the war refugees from πŸ‡ΊπŸ‡¦ Ukraine. Thank you! πŸ™πŸ»

Comments

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Comments are moderated so there will be a delay before your comment is published.