|
|
||
|---|---|---|
| .. | ||
| examples | ||
| modules | ||
| .gitignore | ||
| README.md | ||
Elektrine VPN fleet (Terraform)
Provisions WireGuard exit nodes across providers and lets them self-register
with the control plane. Nothing here talks to Elektrine's database or API —
each node registers itself on first boot via the fleet agent in scripts/vpn.
The shape
One cloud-init blob is rendered per region, not per node. Everything node-specific resolves on the box itself:
| Field | Where it comes from |
|---|---|
NODE_NAME |
the instance hostname, which is the instance name |
PUBLIC_IP |
autodetected from the default route |
ENDPOINT_HOST |
defaults to PUBLIC_IP |
internal /24 |
allocated by the control plane at registration |
So adding ten nodes in Falkenstein is ten entries in a map, not ten config
files. Only user-facing region metadata (location, country_code, city)
varies, and that varies by region.
Layout
modules/user-data/ provider-agnostic; renders cloud-init per region
modules/hetzner/ hcloud_server + firewall
modules/digitalocean/ digitalocean_droplet + firewall
modules/vultr/ vultr_instance + firewall group
examples/hetzner/ complete root, single provider
examples/digitalocean/ complete root, single provider
examples/multi-provider/ complete root, all three
modules/user-data reads scripts/vpn/elektrine-vpn-agent.sh and its unit file
directly, so the agent stays single-source-of-truth exactly as it is for
gen-cloud-init.sh. Only the cloud-config envelope is mirrored between the two
— if you change one, change the other.
Adding a provider is a new directory with the same contract: take nodes
(name → region) and user_data (region → blob), create instances, open the
UDP port, output name → IPv4.
Use
Copy the example closest to your setup and edit it — these are roots, not things to reference in place.
cp -r deploy/terraform/vpn-fleet/examples/hetzner ~/my-vpn-fleet
cd ~/my-vpn-fleet
export HCLOUD_TOKEN='...'
export TF_VAR_fleet_registration_key='...' # keep it out of tfvars
terraform init
terraform plan
terraform apply
Set the same key as VPN_FLEET_REGISTRATION_KEY on the control plane first, or
every node will boot and fail to register.
If you copy the examples elsewhere, fix source and agent_dir — agent_dir
must point at this repo's scripts/vpn, since that is where the agent is read
from at plan time.
Drop providers you don't use. Terraform requires configuration for every
provider a referenced module declares, even one that creates nothing — an empty
node map is not enough. To go Hetzner-only in the multi-provider example, delete
the digitalocean/vultr provider blocks, their required_providers entries,
and their module blocks together. (The single-provider example is already
this.)
Scaling a region is editing its map:
nodes = {
fsn-edge-01 = "fsn1"
fsn-edge-02 = "fsn1"
fsn-edge-03 = "fsn1" # new node, no other change needed
}
A node pointed at a region with no regions entry fails at plan time with a
named error, rather than booting with empty user-data and never registering.
Before you grow the fleet
Widen the supernet first. VPN_WG_SUPERNET defaults to 10.8.0.0/16,
which is 256 /24 allocations — a hard ceiling on node count. Widen it before
nodes claim ranges; doing it afterwards is far messier. The node_count output
is there to compare against that ceiling.
Node names must be unique fleet-wide, not just per provider — the name
becomes the hostname, which becomes NODE_NAME.
Destroying a node strands its record. Registration is keyed on public IP and
never re-issues credentials for an IP that already has a record, so once the
provider recycles that address the next node handed it cannot register. The
control plane reaps long-offline records daily
(Elektrine.VPN.StaleNodeReaperWorker); after a deliberate teardown you can run
it immediately:
mix elektrine.vpn.reap_stale_nodes --grace-hours 1 # dry run
mix elektrine.vpn.reap_stale_nodes --grace-hours 1 --yes
The fleet key is exposed to every node. It lives in user-data, readable via each provider's metadata API. It only grants registration, but rotate it periodically; at larger scale, fetch it at boot from a secrets store instead of baking it in.
Terraform state holds the key. fleet_registration_key is marked sensitive
so it stays out of CLI output, but it is still in state — use a remote backend
with encryption. .gitignore here keeps local state and real tfvars out of the
repo.
Verifying
terraform output nodes gives name → public IPv4. Diff that against the control
plane's server list to spot nodes that booted but never registered.
Note that providers store user_data in state as a hash rather than the blob
(Hetzner base64 SHA-1, DigitalOcean hex SHA-1), so a plan diff shows an opaque
change when the agent script or region metadata changes. Equal hashes across
nodes in a region is the expected state, not a bug. To read what a node will
actually receive:
echo 'nonsensitive(module.user_data.user_data["fsn1"])' | terraform console
On a node:
journalctl -u elektrine-vpn-agent -f
wg show wg0