Skip to content

AlexVranas/pbx-ts-demo

Repository files navigation

Multi-Region VoIP over Tailscale

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.


What I built

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 to autogroup:admin alone, 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.

Why this use case

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.

Architecture overview

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

How traffic flows

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.

Access control

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.

Tailscale vs. traditional approaches

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").

Setup and deployment

Prerequisites

  • A Tailscale account and tailnet (a fresh/demo tailnet — Terraform overwrites the entire ACL policy)
  • A Linode account
  • Terraform >= 1.5 (brew install terraform or developer.hashicorp.com/terraform/install)
  • An admin machine on the tailnet (runs terraform and validate.sh)
  • Optionally, a SIP softphone app for live calls — tested with Linphone and MicroSIP — on a device that's on the tailnet

1. One-time tailnet setup

In the admin console:

  1. 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 /status Serve checks fail. You can enable it later and run systemctl restart tailscale-serve-status on each node — no redeploy needed.)

  2. 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 apply onward):

    "tagOwners": {
      // The OAuth client's own tag. An OAuth client can only mint keys
      // whose tags are its exact tag set OR are owned by its tags —
      // so the client gets this one owner tag, never the node tags.
      "tag:asterisk-provisioner": ["autogroup:admin"],
    
      "tag:asterisk":          ["autogroup:admin", "tag:asterisk-provisioner"],
      "tag:iax":               ["autogroup:admin", "tag:asterisk-provisioner"],
      "tag:sipserve-us-sea":   ["autogroup:admin", "tag:asterisk-provisioner"],
      "tag:sipserve-us-east":  ["autogroup:admin", "tag:asterisk-provisioner"],
      "tag:sipserve-us-west":  ["autogroup:admin", "tag:asterisk-provisioner"],
      "tag:sipclient-us-sea":  ["autogroup:admin", "tag:asterisk-provisioner"],
      "tag:sipclient-us-east": ["autogroup:admin", "tag:asterisk-provisioner"],
      "tag:sipclient-us-west": ["autogroup:admin", "tag:asterisk-provisioner"],
    }
  3. OAuth clients page: generate a client with write access for scopes auth_keys, devices:core, and policy_file. The auth_keys and devices:core rows each have their own Tags dropdown — in both, select exactly one tag: tag:asterisk-provisioner. Save the client ID and secret.

2. Linode token

Create a personal access token with Linodes (R/W), Firewalls (R/W), Account (Read), and NodeBalancers (Read).

3. Configure and deploy

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 apply

Apply 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.

4. Validate

make validate

See How to validate below.

5. Optional: connect a softphone and make calls

  1. In the admin console, tag your phone/laptop's device with its home region's client tag, e.g. tag:sipclient-us-sea.
  2. Configure the softphone from the generated softphone-credentials.txt (UDP transport, server asterisk-us-sea.your-tailnet.ts.net, port 5060).
  3. Dial from the generated reference-sheet.md dial table:
    • 600 — local echo test
    • 6XX — echo test on a remote region, over the IAX2 trunk
    • 7XX — ring the softphone registered in a remote region
    • 8XX — remote dial-tone: bridge into the remote node's dialplan

Teardown

make destroy

This 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.)

How to validate

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 validate runs 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_suffix in your tfvars to a fresh value so each iteration uses new names (see config.auto.tfvars.example).

Real (redacted) output from a validation run lives in validation/example/.

Assumptions and prerequisites

  • 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.sh is on the tailnet and its user is in autogroup: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.

What worked well

  • 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-sea can 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.

AI assistance disclosure

I used Claude Code (Anthropic) throughout this project, and reviewed everything it produced.

About

Multi-Region VoIP over Tailscale

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages