Cloudflare Workers + Access + Argo Tunnel

Ok, so i’ve got a setup here to propose, that i’d like some feedback on. I’ve come up with an infrastructure plan of sorts here that I think might be a good idea… but i’m not sure. So here go’s.

My setup for now is simple, and it includes the following:

  1. Frontend static Next.js application (That is deployed to Workers Sites … ezpz)
  2. Dgraph instance (https://github.com/dgraph-io/dgraph) (Graph database) (Running on DigitalOcean droplet for now… will eventually move to Kubernetes)
  3. REST API (Also deployed to Cloudflare Workers)

So most of my setup is running on Cloudflare Workers, which is great because I don’t have to manage that part of it. The only thing that I need to manage is my Dgraph instance, which is built with Go (and requires a garbage collector)… so it needs to be run on it’s own server. So let’s get into that a little bit.

My Dgraph server (DigitalOcean droplet) is running Cloudflare’s Argo Tunnel, which makes a secure tunnel to Cloudflare and exposes a URL for my Dgraph instance (https://query.mydomain.com let’s say). To make queries against my Dgraph instance, it exposes port 8080… which is what cloudflared connects to on the server.

The command to accept TLS connections on port 8080 is: ./dgraph alpha --lru_mb=32784 --tls_dir=tls --acl_secret_file=acl_secret_file --encryption_key_file=enc_key_file

My command for setting up cloudflared is: cloudflared --origin-ca-pool ~/dgraph/dgraph/tls/ca.crt --origin-server-name localhost --hostname query.mydomain.com https://localhost:8080

I’ve also setup on Cloudflare Access on my query.mydomain.com URL because i’d like to only allow my Cloudflare Workers API to be able to query the endpoint. I created a service token that I will make requests to.

My API Worker (https://api.mydomain.com) look’s something like this (for now… but will eventually be turned into a REST API):

addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request))
})

async function handleRequest(request) {
  return await fetch('https://query.mydomain.com', {
      headers: {
        'CF-Access-Client-Id': 'XXXXXXXXXXXXXXXXXXXXXXXXXXXX',
        'CF-Access-Client-Secret': 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
      }
  })
}

Note: I will obviously not have the Access credentials inlined into the Worker script in the production version. I’ll use secret environment variables. https://developers.cloudflare.com/workers/tooling/wrangler/secrets/

This is just to test the Access connection to my Dgraph server/query endpoint :slight_smile: Essentially what i’m doing here… is making is so only my Worker API can access the Dgraph query endpoint (as I don’t want my users making requests to it)

Ok, I know this was alot to read…and might be confusing, so here’s a TLDR version:

My frontend and REST API are both deployed to Cloudflare Workers. I have a Dgraph instance running on a DigitalOcean Droplet in which i’ve set up a Cloudflare Argo Tunnel. I access this Dgraph instance by the URL https://query.mydomain.com. I’d like to make it so that only my Worker API (https://api.mydomain.com) can make requests to my Dgraph URL. So I setup Cloudflare Access on (https://query.mydomain.com) so that you need to supply service token credentials to do so.

Basically i’m asking if anyone else has tried this setup, and whether people think it’s a viable solution. Does anyone else have any other ideas, or concerns?

Note: It would be awesome to see Cloudflare Access integration into Workers themselves. So we could setup access controls, so we could limit who can access the Worker, etc. :slight_smile:

Update: I’m experimenting with Load Balancers w/ Argo Tunnels and I have setup a Load Balancer on https://lb.query.mydomain.com for example. I can’t seem to access https://lb.query.mydomain.com from my network… however inside my worker I can make a request and it works just fine.

If this is the case (which i’m not 100% on) Do I even need Cloudflare Access infront of my LB? Can users access my LB from their network? How come I can make the connection in my Worker?

Edit: Solved… forgot to add *.query.mydomain.com to my dedicated certificate!

Quick thought, don’t have the time to read through it all now, do you have a certificate covering *.query.mydomain.com? The default Cloudflare certificate can’t.

1 Like

Ahhhhhhhh you’re a genius. I was going to do that… and TOTALLY FORGOT. Thank you so much yep! That fixed it. Ok so yeah I do need Cloudflare Access afterall… if I want to cut it off from outside users :slight_smile:

1 Like

You could also do lb-query.mydomain.com, which doesn’t require the additional subdomain. Glad it’s solved though.

1 Like

I have a dedicated certificate… so I don’t mind adding *.query.mydomain.com :slight_smile:

But thanks again!

1 Like

Updated my API worker script :slight_smile: Dgraph includes ACL as an Enterprise feature, so i’m logging in a user and then caching the JWT in Workers KV. Once I have the accessJWT for Dgraph, I can pass it in the X-Dgraph-AccessToken header, along with the query I want to run in the body.

addEventListener('fetch', event => {
  event.respondWith(handleRequest(event))
})

const queryHeaders = {
  'CF-Access-Client-Id': CF_ACCESS_CLIENT_ID,
  'CF-Access-Client-Secret': CF_ACCESS_CLIENT_SECRET,
  'User-Agent': 'mydomain-api-production/0.0.1 (Cloudflare Worker)'
}

async function handleRequest(event) {
  // Check for Dgraph ACL accessJWT in KV
  let accessJWT = await DGRAPH.get("login/accessJWT")
  if (!accessJWT) {
    // Login with ACL credentials
    const login = await fetch('https://lb.query.mydomain.com/login', {
      method: 'POST',
      headers: {
        ...queryHeaders,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        userid: DGRAPH_ACL_USERID,
        password: DGRAPH_ACL_PASSWORD
      })
    })

    if (!login.ok) throw new Error('Could not login with credentials.')
    const credentials = await login.json()
    if (!credentials.data || !credentials.data.accessJWT || !credentials.data.refreshJWT) {
      throw new Error('Could not login with credentials.')
    }

    // Retrieve access token
    accessJWT = credentials.data.accessJWT

    // Add Dgraph ACL accessJWT to KV (Expire after 1 hour)
    event.waitUntil(DGRAPH.put('login/accessJWT', accessJWT, {
      expirationTtl: 3600
    }))
  }

  // Run query
  const query = "{ q(func: eq(key, 1)) { key, uid, bal, typ }}"
  let response = await fetch('https://lb.query.mydomain.com/query', {
    method: 'POST',
    headers: {
      ...queryHeaders,
      'Content-Type': 'application/graphql+-',
      'X-Dgraph-AccessToken': accessJWT
    },
    body: JSON.stringify(query)
  })
  response = new Response(response.body, response)
  response.headers.set('Content-Type', 'application/json')
  response.headers.set('Access-Control-Allow-Origin', 'https://postwoman.io')
  return response
}

Overall this seem’s like a decent approach. To recap; Cloudflare Access is protecting access to my https://lb.query.mydomain.com CF Load Balancer, and only Workers can access it with the proper service token credentials. The above worker code runs when you access https://api.mydomain.com. Also putting an extra layer of security, after that by using ACL within Dgraph to authenticate the query i’m running.

Definitely would love to know if anyone else has tried, or is using Cloudflare Access in front of their Cloudflare Workers URL’s to control access to it :slight_smile:

1 Like

Update: I’ve removed Workers KV in favor of the Workers Cache API in my script. Workers KV was a bit slow… and you’re never guaranteed that the KV data will be stored for (x) amount of time… as KV is only affective under heavy reads.

addEventListener('fetch', (event) => {
  // Have any uncaught errors thrown go directly to origin
  event.passThroughOnException()
  event.respondWith(handleRequest(event))
})

const requestHeaders = {
  'CF-Access-Client-Id': CF_ACCESS_CLIENT_ID,
  'CF-Access-Client-Secret': CF_ACCESS_CLIENT_SECRET,
  'User-Agent': 'mydomain-api-production/0.0.1 (Cloudflare Worker)',
}

async function handleRequest(event) {
  // Set debug to true to view stacktrace
  const debug = true
  
  let accessJWT, response
  try {
    const request = event.request
    const cacheUrl = new URL(request.url)
    
    // Only retrieve cache from local datacenter
    const cache = caches.default
    const cacheKey = new Request(cacheUrl, request)
    
    // Check for cached Dgraph ACL accessJWT  
    const match = await cache.match(cacheKey)
    if (!match) {
      // Login with ACL credentials
      response = await fetch('https://lb.dgraph.mydomain.com/login', {
        method: 'POST',
        headers: {
          ...requestHeaders,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          userid: DGRAPH_ACL_USERID,
          password: DGRAPH_ACL_PASSWORD,
        }),
      })

      if (!response.ok) throw new Error('Could not login with credentials.')
      const credentials = await response.json()
      if (!credentials.data || !credentials.data.accessJWT || !credentials.data.refreshJWT) {
        throw new Error('Could not login with credentials.')
      }

      // Retrieve access token
      accessJWT = credentials.data.accessJWT

      // Set the response body to the accessJWT
      response = new Response(accessJWT, { status: 200 })
      
      // Cache the Dgraph ACL accessJWT for 1 hour
      response.headers.append('Cache-Control', 'max-age=3600')
      event.waitUntil(cache.put(cacheKey, response.clone()))
    } else {
      // Retrieve Dgraph ACL accessJWT from the body
      accessJWT = await match.text()
    }

    // Run query
    const query = '{ q(func: eq(key, 1)) { key, uid, bal, typ }}'
    response = await fetch('https://lb.dgraph.mydomain.com/query', {
      method: 'POST',
      headers: {
        ...requestHeaders,
        'Content-Type': 'application/graphql+-',
        'X-Dgraph-AccessToken': accessJWT,
      },
      body: JSON.stringify(query),
    })
    if (!response.ok) {
      throw new Error('Request to Dgraph endpoint /query failed.')
    }

    response = new Response(response.body, response)
    response.headers.set('Content-Type', 'application/json')
    response.headers.set('Access-Control-Allow-Origin', 'https://mydomain.com')
    return response
  } catch (err) {
    const stack = JSON.stringify(debug ? err.stack : err.message) || err
    response = new Response(stack, response)
    response.headers.set('X-Debug-stack', stack)
    response.headers.set('X-Debug-err', err)
    return response
  }
}

Dgraph ACL keys expire after 6 hours by default (I think) so i’m storing the JWT in the local cache for 1 hour.

Note: I need to secure the local cache key, so that someone can’t just find a way to put the JWT from the cache (Maybe convert it to a hash or something… so even if someone got the hash… they wouldn’t be able to pass it to Dgraph)

Any feedback or ideas are welcome! (Note this is not production code… it’s just a proof of concept… so don’t be too harsh :P)

Want to learn more about Cloudflare Argo? Check this link https://developers.cloudflare.com/access/setting-up-access/argo-tunnel :slight_smile: