A three-region (default) Asterisk phone system on Linode where no VoIP, SSH,
or web port is ever exposed to the internet — every trunk, every
call, every admin session, and every status page travels through a
Tailscale tailnet. One terraform apply builds the whole thing;
terraform destroy cleans it all up, including the tailnet devices.
All hostnames and identifiers in this README are placeholders
(your-tailnet.ts.net, etc.) — substitute your own.
Terraform provisions one Asterisk 20 PBX node in each of three Linode
regions — Seattle (us-sea), Newark (us-east), and Fremont
(us-west) — and connects them with full-mesh IAX2 trunks so a call
can hop between regions. Each node:
- joins the tailnet at first boot as a tagged, ephemeral node, using a per-region auth key minted by Terraform through a Tailscale OAuth client (no long-lived keys pasted anywhere);
- sits behind a default-deny Linode Cloud Firewall — the only
inbound port on the public internet is
udp/41641, so WireGuard can connect peer-to-peer; everything else, including SSH, is tailnet-only; - runs Tailscale SSH (with optional per-session OIDC re-auth) for management;
- publishes an interactive status page over Tailscale Serve with
an automatic HTTPS certificate at
https://asterisk-<region>.your-tailnet.ts.net/status— buttons run live Tailscale/SIP/IAX2 connectivity checks and can ring every registered softphone, above a live tail of the node's Asterisk log. Reachable only by tailnet admins: the ACL grants port 443 toautogroup:adminalone, so the page inherits identity-based access control without a line of auth code; - registers softphones from SIP clients on the tailnet, with access scoped per region by ACL grants.
Terraform also owns the tailnet ACL policy (modern grants
syntax), so the access model is reviewable, versioned code — not
console clicks.
VoIP is one of the most hostile workloads to run securely across the public internet, which makes it an honest stress test of what Tailscale replaces:
- It's a real, recognizable business problem. "Branch offices in three cities need internal extensions and low-latency calls" is a scenario any customer can map onto their own infrastructure — replace "PBX trunk" with "database replication" or "internal API" and the architecture is unchanged.
- VoIP is traditionally painful to secure. SIP servers exposed to the internet are scanned and brute-forced within minutes; RTP needs a huge UDP port range; NAT breaks SIP signaling in creative ways. The conventional fixes (SBCs, VPN concentrators, firewall rule sprawl) are exactly the legacy approaches Tailscale displaces.
- It exercises many Tailscale features naturally, not as a tour. MagicDNS names go straight into Asterisk's trunk configuration; ACL grants map one-to-one onto SIP/IAX2 port requirements; Tailscale SSH replaces a bastion; Serve replaces "expose a monitoring port"; ephemeral nodes + OAuth keep infrastructure-as-code clean.
- Latency is observable. Audio makes the value audible: an echo test across a direct WireGuard path demonstrates that the mesh is peer-to-peer, not hairpinned through a relay or VPN concentrator.
Three identical nodes, one per region, built from the same cloud-init template. The tailnet is the only network they share — there is no VPC peering, no public service ports, no cross-region private backbone. The Tailscale control plane coordinates keys, ACLs, and MagicDNS, but stays out of the data path: calls flow directly between nodes over WireGuard.
The full diagram with all flows is in docs/architecture.md. In miniature:
Tailscale control plane (keys / ACLs / MagicDNS)
┊ ┊ ┊
┌───────┴──────┐ ┌─────┴────────┐ ┌────┴─────────┐
│ Linode us-sea│ │Linode us-east│ │Linode us-west│ public inbound:
│ asterisk-sea │ │ asterisk-east│ │ asterisk-west│ udp/41641 only
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
●━━━ IAX2 trunks (full mesh, udp/4569) ━━━●
┊
SIP softphone (tag:sipclient-us-sea) ── 5060/5061 + RTP
Three distinct flows, all inside the tailnet, each permitted by its own ACL grant:
1. Trunk traffic (server ↔ server). Asterisk addresses its IAX2
peers by MagicDNS name (asterisk-us-east.your-tailnet.ts.net), which
resolves to a tailnet IP. The tag:iax → tag:iax grant allows
udp/4569 between any two nodes, forming the full mesh. On the wire,
this is WireGuard between Linode public IPs on udp/41641 — the
only port the cloud firewall accepts — so trunks ride direct,
encrypted paths with no relay hop in the common case.
2. Client traffic (softphone ↔ its regional server). A softphone
device tagged tag:sipclient-us-sea can reach exactly one server:
the one tagged tag:sipserve-us-sea, on the SIP ports (5060 UDP/TCP,
5061 TCP) and the RTP media range (udp/10000-20000). The mirror
grant (server → client) exists because inbound calls are new
connections initiated by the server — Asterisk sends the INVITE to
the client's registered contact. Grants are stateful, so without the
mirror grant outbound calls would work while inbound calls silently
failed.
3. Management traffic (admin → everything). Admins
(autogroup:admin) get full access for debugging and for
validate.sh. SSH specifically goes through Tailscale SSH: the
connection is authorized by tailnet identity, and with
tailscale_ssh_mode = "check" each new session forces a fresh OIDC
login in the browser before the shell opens. The Serve status pages
ride the same flow — HTTPS, tailnet-internal, no Funnel.
The entire policy lives in acl.tf and is applied by
Terraform — what's reviewed in the pull request is what the tailnet
enforces. Summary:
| Who | Can reach | On | Why |
|---|---|---|---|
tag:iax (all PBX nodes) |
tag:iax |
udp/4569 |
full-mesh IAX2 trunks |
tag:sipclient-<region> |
tag:sipserve-<region> |
5060 udp+tcp, 5061 tcp, udp/10000-20000 |
softphone registration, signaling, media |
tag:sipserve-<region> |
tag:sipclient-<region> |
same ports | inbound calls (server-initiated INVITE) |
autogroup:admin |
* |
* |
management, validation |
autogroup:admin (SSH) |
tag:asterisk, as root/nonroot |
Tailscale SSH | action = accept or check per tailscale_ssh_mode |
Everything not granted is denied — and that's only the tailnet
layer. At the internet layer the Linode Cloud Firewall is
default-deny inbound with a single rule (udp/41641, WireGuard), so
there is no public SSH, SIP, RTP, HTTP, or ICMP at all. Tailscale is
the only way in.
How this exact environment would look with the conventional tooling it replaces:
| Traditional | This project | |
|---|---|---|
| Server-to-server links | Hub-and-spoke VPN (IPsec/OpenVPN concentrator): every trunk hairpins through a hub — extra latency on every call leg, hub is a single point of failure, adding region N means re-doing tunnel configs | WireGuard mesh, configured by nothing more than three tags; trunks are direct peer-to-peer; region N+1 is one more list entry |
| Admin access | Bastion host: a public SSH port to harden, patch, monitor, and audit; shared key sprawl; one compromised bastion exposes everything behind it | No bastion exists. Tailscale SSH is authorized by identity + ACL, and check mode forces an OIDC re-login per session — MFA at the moment of access, not just at VPN connect time |
| Exposing SIP/RTP | Port-forwarding 5060 + a 10,000-port UDP range to the internet, then fighting NAT (STUN/TURN/SBC) and around-the-clock SIP scanners | Zero exposed ports. Clients reach exactly one server, scoped by region tag; NAT traversal is Tailscale's problem, not the PBX's |
| Monitoring page | Reverse proxy + TLS cert management + auth layer, or "just bind it to localhost and SSH-tunnel" | tailscale serve one-liner: automatic HTTPS cert, tailnet-only, no auth code written |
| Access policy | Firewall rules scattered across each box and each cloud console, drifting silently | One grants policy in version control, applied by Terraform, enforced identically everywhere |
| Decommissioning | Stale VPN configs, forgotten firewall holes, orphaned bastion accounts | terraform destroy deletes the devices via API; ephemeral nodes reap themselves if that ever fails |
The concrete proof points in this repo: the firewall config that
denies everything except WireGuard (main.tf), the absence
of any public SSH path, identity-based grants instead of IP allowlists
(acl.tf), and OIDC-checked SSH sessions
(tailscale_ssh_mode = "check").
- A Tailscale account and tailnet (a fresh/demo tailnet — Terraform overwrites the entire ACL policy)
- A Linode account
- Terraform >= 1.5 (
brew install terraformor developer.hashicorp.com/terraform/install) - An admin machine on the tailnet (runs
terraformandvalidate.sh) - Optionally, a SIP softphone app for live calls — tested with Linphone and MicroSIP — on a device that's on the tailnet
In the admin console:
-
DNS page: ensure MagicDNS is on, and enable HTTPS Certificates — both are required for the Tailscale Serve status pages. Note your tailnet's MagicDNS name, e.g.
your-tailnet.ts.net. (If you skip this, the PBX still deploys and works; only the three/statusServe checks fail. You can enable it later and runsystemctl restart tailscale-serve-statuson each node — no redeploy needed.) -
Access Controls page: the OAuth client you create in step 3 can only be granted tags that already exist, so add the tag definitions once by hand (Terraform takes ownership of the policy from the first
applyonward): -
OAuth clients page: generate a client with write access for scopes
auth_keys,devices:core, andpolicy_file. Theauth_keysanddevices:corerows each have their own Tags dropdown — in both, select exactly one tag:tag:asterisk-provisioner. Save the client ID and secret.
Create a personal access token with Linodes (R/W), Firewalls (R/W), Account (Read), and NodeBalancers (Read).
git clone https://github.com/alexvranas/pbx-ts-demo.git
cd pbx-ts-demo
cp config.auto.tfvars.example config.auto.tfvars
# edit config.auto.tfvars: tokens, OAuth credentials, tailnet name
# (it's gitignored; Terraform auto-loads it — no -var-file needed)
make init
make plan # review what will be created
make applyApply takes a couple of minutes; the nodes then need another ~3–5 minutes of cloud-init (Tailscale join, Asterisk install, Serve setup). The outputs print each node's MagicDNS name and status-page URL, and a reminder to validate.
make validateSee How to validate below.
- In the admin console, tag your phone/laptop's device with its home
region's client tag, e.g.
tag:sipclient-us-sea. - Configure the softphone from the generated
softphone-credentials.txt(UDP transport, serverasterisk-us-sea.your-tailnet.ts.net, port 5060). - Dial from the generated
reference-sheet.mddial table:600— local echo test6XX— echo test on a remote region, over the IAX2 trunk7XX— ring the softphone registered in a remote region8XX— remote dial-tone: bridge into the remote node's dialplan
make destroyThis deletes the Linodes, the firewall, and — via a destroy-time call to the Tailscale API — the tailnet devices, so nothing is left in the admin console. (If the API step ever fails it logs and continues; nodes are ephemeral, so the control plane would reap them anyway.)
make validate runs validate.sh, which saves raw
evidence under validation/output/ and prints a pass/fail summary.
What each check proves:
| Check | Proves |
|---|---|
tailscale status lists all three nodes |
nodes registered, tagged, and online on the tailnet |
ping asterisk-<region>.your-tailnet.ts.net × 3 |
MagicDNS resolution and tailnet reachability (remember: ICMP from the internet is blocked — this works only over the tailnet) |
curl https://…/status × 3 |
Tailscale Serve, automatic HTTPS certificates, and the tailnet-only exposure model |
iax2 show peers on each node via Tailscale SSH |
every trunk in the full mesh is authenticated and reachable; also exercises Tailscale SSH itself |
| cross-region test call (originate to a remote echo extension) | the actual use case: a call traversing an IAX2 trunk between regions, captured on both ends |
Heads-up: with
tailscale_ssh_mode = "check", the SSH-based checks pop a browser OIDC re-authentication on the first connection, then cache the authorization for the check period. A "hung" script is usually just waiting for that browser tab.SKIP_SSH=1 make validateruns only the network-level checks.Also: the first HTTPS request to each node makes tailscaled provision its TLS certificate from Let's Encrypt during the handshake, which can take up to a minute — the Serve checks hold the connection open and retry to absorb this, so the first check after a fresh deploy is slower than the rest. Note that Let's Encrypt issues at most 5 certificates per exact hostname per week; if you destroy/redeploy repeatedly, set
hostname_suffixin your tfvars to a fresh value so each iteration uses new names (seeconfig.auto.tfvars.example).
Real (redacted) output from a validation run lives in
validation/example/.
- Demo tailnet: Terraform overwrites the whole ACL policy and is the source of truth for it. Don't point this at a production tailnet.
- The admin machine running
terraform/validate.shis on the tailnet and its user is inautogroup:admin. - MagicDNS + HTTPS Certificates enabled (Serve requirement).
- SIP and IAX2 run unencrypted inside the tailnet — the WireGuard tunnel provides transport encryption. Acceptable for a demo; SRTP/TLS would be the production answer.
- Local Terraform state (no remote backend); state contains secrets, treat the working directory accordingly.
- Costs: three nanodes (~$5/mo each, billed hourly) — remember
make destroy.
- MagicDNS as PBX configuration glue. Asterisk trunk configs reference peers by stable DNS names; no IP addresses appear anywhere in the rendered configs. Region count is one variable.
- OAuth-issued, tagged, ephemeral keys. No auth key ever touches a human's clipboard: Terraform mints one per region with exactly the right tags, and the tag set is what the entire ACL hangs off.
- Grants mapped cleanly onto VoIP's port matrix. The per-region
client/server scoping (
tag:sipclient-us-seacan only talk to its home PBX) reads like the requirements doc. - Serve for the status page took one command in cloud-init and zero TLS or auth code.
I used Claude Code (Anthropic) throughout this project, and reviewed everything it produced.