Michael Alyn Miller


I am in the process of packaging up and releasing a number of the internal tools that I built for Aspen Micro, a small software business that my friend and I started in 2022.

Aspen Micro was designed from the beginning to be lightweight, enabling a small team of people to build and maintain a number of software products, most of which would be offered “as-a-service.”

Key to minimizing our day-to-day maintenance burden was automation. That’s where our Watchtower-based deployment pipeline came in handy. Equally important was our ability to prototype quickly, and to be able to share those prototypes with each other.

Early on we used ngrok to open ports for anything that needed to be Internet-accessible. That worked great, but even with a paid ngrok subscription there was a bit more up-front provisioning than I wanted.

My ideal workflow would be to run a service on my machine and have that service automatically assigned a meaningful, public address without any configuration on my part. We already used Tailscale for sharing internal access to services, so the main challenge was finding a way to dynamically expose those same services to the Internet.

Note that the recently-released Tailscale Funnel feature does bridge this gap, but still requires a bit of per-service provisioning, which the approach in this article is able to avoid. (although that dynamism does come at the cost of fine-grained access control, which the extra configuration steps in Tailscale Funnel and ngrok help you retain)

Enter dialtun

dialtun was my solution to this problem and was able to achieve the following goal: dynamic mapping of public HTTPS endpoints to internal ports on our development machines.

Here is a more detailed breakdown of that goal:

Key to this solution was a trick I often used when picking port numbers: I would look at my (US-based) telephone dialpad, map the start of the service name to the keys on the dialpad, then use the corresponding numbers as part of the port number.

For example, given this US-standard dialpad…

│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │     │ │ ABC │ │ DEF │ │
│ │  1  │ │  2  │ │  3  │ │
│ └─────┘ └─────┘ └─────┘ │
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ GHI │ │ JKL │ │ MNO │ │
│ │  4  │ │  5  │ │  6  │ │
│ └─────┘ └─────┘ └─────┘ │
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │PQRS │ │ TUV │ │ WXYZ│ │
│ │  7  │ │  8  │ │  9  │ │
│ └─────┘ └─────┘ └─────┘ │
│         ┌─────┐         │
│         │     │         │
│         │  0  │         │
│         └─────┘         │

…and a service named “Agendas”, we come up with the digits 2-4-3-6-3-2-7 Assuming we only use the first three digits, and tack on a prefix of “23” (in the case of Aspen Micro), we end up with a port number of 23243.

The job of the dialtun service is to then map agendas-mam.dev.aspenmicro.com to port 23243 on my machine. Note that the “service” portion of the domain name – agendas – can be as long as you want; only the first three letters were used. The “host” portion was used to select the target machine (my dev box, in this example) and matches the name of the machine in the Tailnet.

Because dialtun’s mapping is automatic, exposing a new service is as simple as choosing a port number that maps to a meaningful name. Creating and sharing a prototype – even with an external party! – reduced down to the following exchange:

Person A: runs web server on port 23999 Hey Person B, check out the new web site design: www-mam.aspenmicro.com

Person B: clicks link Nice!

More information about how dialtun works can be found at dialtun’s GitHub repo. In brief, the service uses OpenResty and a custom Lua function to dynamically generate NGINX proxy_pass URLs based on the incoming service and host names.

Deployment is very simple, and requires only two environment variables: the subdomain on which you are hosting dialtun, and the secret Tailscale auth token for the dialtun service. Those can both be provided as Fly.io secrets if you are using Fly.io, which means that the public Docker image for dialtun can be specified in your fly.toml file; no Dockerfile required!

Again, complete setup and usage information is available at the dialtun repo.

Caveat Emptor

aka “The question that everyone should be asking right about now”

Q: Does this literally expose 1,000 ports on my machine to the Internet? With no authentication?

A: Yep!

Q: 😐

A: ¯\_(ツ)_/¯

The goal is not secrecy. The goal is agility and flexibility. The assumption is that whatever you are exposing has its own auth model, or is a simple website that is not secret. Please do not proxy a site like secret-yet-unprotected-customer-data.example.com using this tool.

We used dialtun to expose the development versions of our Slack apps to the Internet so that we could connect them to our production Slack instance. We had a config variable that disabled signups, so if someone figured out the domain name, the worst they could do is generate a bunch of HTTP errors. They couldn’t install or use the app, see what features were still under development, etc. The app was secure, it just wasn’t secret.

To put that another way, do not use this as a security-through-obscurity approach to protecting your services. Protect your services using industry standard best practices at all times, even during development.

But wait, there’s more!

The dialtun Docker image needs to run multiple services, make sure that they start in the right order, and perform Tailscale authorization before OpenResty starts up. We could do that with a shell script, but instead dialtun uses a process manager that I created called Ground Control. Ground Control also enables a bunch of other scenarios, all of which are explained in a separate post.