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)
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:
- “Dynamic mapping” means that no pre-provisioning was required. Both machines and services could come and go without any configuration or registration.
- “HTTPS endpoints” means that we were exposing HTTPS services with real TLS certificates. This was required in order to ensure that, for example, third-party services could reach our internal services, even if our internal services were using non-standard port numbers or didn’t have the capability of generating properly-signed TLS certs.
- Crucially, “internal ports” didn’t mean “every port on our machine,” but only a specific range of ports we had allocated to the proxy.
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
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:
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
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
Again, complete setup and usage information is available at the dialtun repo.
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?
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
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.