Ansible automation for a RU-resident forward-proxy layer that tunnels selected egress traffic through WireGuard + Cloak to out-of-country exit nodes, with automatic failover and optional multipath load balancing.
RU-side hosts (ru_nodes) run Envoy as a forward HTTP/HTTPS proxy.
Envoy-owned traffic is marked fwmark 0x1 and policy-routed via table 100
through one or more WireGuard tunnels, each wrapped in Cloak to obfuscate
the handshake as TLS. Tunnels terminate on exit nodes (exit_nodes) hosted
abroad. Clients reach the proxy either directly on 3128/tcp or via a
PAC/WPAD-served proxy chain.
A separate playbook (setup-gre-forward.yml) lets non-HTTP traffic ingress over
GRE tunnels on the RU node and share the same WG egress path (fwmark 0x2,
same table 100, SNAT to wg_ip).
Observability: each RU node exposes a read-only Envoy admin listener on
0.0.0.0:9902 (configurable via envoy_admin_lan_port). It proxies the safe
admin paths (/stats, /clusters, /listeners, /server_info, /ready,
/runtime) back to the local-only admin on 127.0.0.1:9901, gated by an RBAC
filter that allows only RFC1918 source IPs. Destructive endpoints
(/quitquitquit, /runtime_modify, /drain_listeners, …) are not routed and
return 404. Prometheus scrape URL: http://<ru-node>:9902/stats/prometheus.
- Single source of truth for topology: group_vars/all/wg.yml.
Each
wg_connectionsentry pairs aru_nodewith anexit_node, assigns aru_interface(wg0, wg1, …), afailover_priority, and an optionalweightfor multipath. - Three route layers in table 100, lowest metric wins:
- Watchdog routes —
priority × 10metric, added/removed based on liveness pings. - Fallback — metric
1000, always present, points at the highest-priority tunnel. /32host routes for exit-node endpoints, pinned to their physical NIC.
- Watchdog routes —
- Zero-downtime transitions:
ip route replaceis atomic;wg-failover-watch.shlayers metric routes above the metric-1000 fallback so a failing tunnel never leaves the host without a route. - Two routing modes — per-host, toggled by
wg_load_balance:- LB mode (default,
wg_load_balance=true): watchdog installs a single weighted multipath route at metric5with onenexthopper healthy tunnel. Kernel distributes flows viafib_multipath_hash_policy(default1= L4 hash; set0if an upstream peer breaks with per-flow distribution). - Priority mode (
wg_load_balance=false): watchdog installs per-tunnel routes at metricpriority × 10. Kernel picks the lowest-metric healthy tunnel; others stand by. Classic active-standby. Both modes sit above the always-present metric-1000fallback, so the host is never without a route.
- LB mode (default,
- Domain allowlist at the proxy: Envoy's Lua filter parses
resources/resources.txt (the same list PAC consumes)
plus optional resources/proxy-only.txt. Unlisted
domains get
403. - Cloak obfuscation: WG client →
ck-client(127.0.0.1) →ck-serveron exit:443 → WG server. Exit nodes expose only port 443/TCP publicly.
See group_vars/all/wg.yml for the tunnel map and the header comments there for how to add a new ru_node or a standby tunnel.
A connection can be marked monitor_only: true — it is built and pinged like
a normal tunnel, but excluded from all routes. See
Adding a monitor-only tunnel below.
The original (internal) repository commits plaintext WireGuard and Cloak private keys under
wg-keys/andwg-cloak-keys/. This is an explicit threat-model choice for a closed internal git server, where ops convenience (zero-touch redeploy from any operator's clone) is valued higher than compartmentalisation of key material.In this snapshot the key directories have been removed. If you intend to reuse this codebase, decide before you do anything else:
- Never commit private keys to a public or shared repository. If you adopt this layout as-is, restrict the repo to operators who already have prod access — i.e. treat the repo itself as a secret.
- Better: refactor
setup-wg-cloak.ymlto store keys outside the git tree (ansible-vault, an external secret store, generated-on-host with the public key fetched back). This is left as an exercise — out of scope of the migration that produced this snapshot.About key regeneration.
setup-wg-cloak.yml's local key-gen tasks create files only if they're missing. So:
- Fresh install with empty
wg-keys/andwg-cloak-keys/→ new keys are generated locally, then deployed to both sides — works.- Existing prod cluster, run with empty key dirs → new keys generated, then pushed to one side, but the peer side already has the old configuration under its name, so the handshake breaks until both sides are re-keyed simultaneously. Do not delete the production key dirs. Only the snapshot in this repo has them removed.
inventory.inicontains commented-out example hosts only. Fill in real hostnames, IPs andwg_ips before running anything.group_vars/all/wg.ymlandgroup_vars/all/gre.ymlcarry the topology that was active at snapshot time — review and adjust them.
SSH pubkey auth is required on every host — inventory no longer carries passwords.
# Exit nodes (root). If not yet key-based:
ssh-copy-id -o PreferredAuthentications=password root@<exit-node-ip>
# RU nodes: your user must have sudo + pubkey installed
ssh-copy-id <user>@<ru-node-ip>RU nodes pull their SSH user and sudo password from the environment
(group_vars/ru_nodes.yml uses lookup('env', ...)).
Exit nodes are pinned to root in the inventory and need no env vars.
export RU_SSH_USER=your.username
export RU_BECOME_PASS='...'If either is unset, the RU connection/sudo will fail fast with an empty value — that's intentional.
| Playbook | When to run |
|---|---|
setup-wg-cloak.yml |
Initial tunnel bring-up, or after topology changes in group_vars/all/wg.yml. Creates WG + Cloak on both ends. Keys are generated once and never overwritten. |
setup-envoy-wg.yml |
Main RU-node deploy: installs Envoy, configures fwmark/policy routing, installs the watchdog, applies the domain allowlist. |
update-envoy-config.yml |
Only the Envoy config + reload — run after edits to resources/resources.txt or resources/proxy-only.txt. Skips install, routing, watchdog. |
setup-pac-wpad.yml |
Deploy PAC/WPAD hosting so clients auto-discover the proxy chain. |
setup-gre-forward.yml |
GRE ingress for non-HTTP traffic. Independent of Envoy; reuses table 100 and the WG egress. |
Typical full deploy on a fresh ru_node:
ansible-playbook -i inventory.ini setup-wg-cloak.yml
ansible-playbook -i inventory.ini setup-envoy-wg.yml
ansible-playbook -i inventory.ini setup-pac-wpad.yml # run this to deploy proxy autoconfig using PAC/WPAD conventions. Require system-level PAC config with automatic or manual URL.
ansible-playbook -i inventory.ini setup-gre-forward.yml # optional
ansible-playbook -i inventory.ini setup-monitoring.yml # per-tunnel external-IP probesTag-scoped runs are supported on setup-envoy-wg.yml:
ansible-playbook -i inventory.ini setup-envoy-wg.yml --tags envoy
ansible-playbook -i inventory.ini setup-envoy-wg.yml --tags verifyEdit resources/resources.txt (shared with PAC) and/or resources/proxy-only.txt (proxy-only superset), then:
ansible-playbook -i inventory.ini update-envoy-config.ymlThis regenerates /etc/envoy/envoy.yaml and hot-reloads Envoy. It does not
touch installation, routing, or the watchdog — fastest feedback loop for
allowlist churn. Also re-run setup-pac-wpad.yml if PAC-visible entries changed
and you want clients updated too.
- Provision the exit_node (fresh Debian/Ubuntu, root SSH with your pubkey in
/root/.ssh/authorized_keys, UDP egress allowed, port 443/TCP inbound open). - Add it to
[exit_nodes]in inventory.ini:Pick a freenewexit ansible_host=<public-ip> wg_ip=10.230.0.N
wg_ipin10.230.0.0/28. - Append
wg_connectionsentries in group_vars/all/wg.yml — one per ru_node that should tunnel through it. Pick the next freeru_interface(wgN) on each ru_node and assign afailover_priority(lower = preferred in priority mode; any non-overlapping integer is fine in LB mode):- ru_node: ru_node1 exit_node: newexit ru_interface: wg3 failover_priority: 3 weight: 1
- Deploy both ends, then refresh RU-node routing/watchdog:
ansible-playbook -i inventory.ini setup-wg-cloak.yml ansible-playbook -i inventory.ini setup-envoy-wg.yml
- Provision the ru_node (Ubuntu, sudo user with pubkey auth; sudo password
exported as
$RU_BECOME_PASS). - Add it to
[ru_nodes]in inventory.ini with a uniquewg_ip:ru_node3 ansible_host=<ru-node-3-lan-ip> wg_ip=10.230.0.12
- Append one
wg_connectionsentry per exit tunnel it should use (group_vars/all/wg.yml). - Optionally add
wg_load_balance=falseas a host_var to put this node in priority mode instead of LB. - Run the full deploy sequence, targeting just the new host to save time:
(Include
ansible-playbook -i inventory.ini setup-wg-cloak.yml --limit ru_node3,exit_nodes ansible-playbook -i inventory.ini setup-envoy-wg.yml --limit ru_node3 ansible-playbook -i inventory.ini setup-pac-wpad.yml --limit ru_node3 # if serving PAC locally ansible-playbook -i inventory.ini setup-gre-forward.yml --limit ru_node3 # if GRE ingress needed
exit_nodesin the first--limitso the peer side of the new tunnel is configured.)
A monitor-only tunnel is brought up exactly like a normal one (WG + Cloak on both
ends, watchdog pings the peer every 10 s, UP/DOWN events appear in
journalctl -t wg-failover), but it is never added to any kernel route — no
multipath nexthop in LB mode, no metric route in priority mode, never picked as
the metric-1000 fallback. Use it to verify that a standby exit is reachable
before you decide to route real traffic through it.
- Provision the exit node and append a
wg_connectionsentry as for a normal tunnel, then addmonitor_only: true:- ru_node: ru_node1 exit_node: exit_node5 ru_interface: wg3 failover_priority: 9 # required by the sort, otherwise ignored monitor_only: true
- Run
setup-wg-cloak.ymlthensetup-envoy-wg.ymlas usual. - Confirm pings:
sudo journalctl -u wg-failover-watch -t wg-failover -f.
To promote a monitor-only tunnel to a routed one, remove the monitor_only key
(do not set it to false — only presence is checked) and re-run
setup-envoy-wg.yml. The metric-1000 fallback's _primary_conn selection skips
monitor-only entries, so at least one routed connection must remain per ru_node.
The watchdog pings the peer's wg_ip over the tunnel interface — that proves WG
handshake + peer is up, not that the exit's own internet egress works. If you
need the latter, add a second probe through the tunnel to e.g. 1.1.1.1.
- Change
weightorfailover_priority: editgroup_vars/all/wg.yml, thensetup-envoy-wg.ymlis enough — it re-renders the policy script and the watchdog picks up the new metrics/weights. - Retire a connection: remove its entry from
wg_connections, re-runsetup-wg-cloak.yml(to tear down WG/Cloak units on both peers) andsetup-envoy-wg.yml(to refresh routes and watchdog). The stale interface is cleaned up idempotently. - Switch a ru_node between LB and priority modes: set/unset
wg_load_balancein inventory, thensetup-envoy-wg.yml— the watchdog and policy script are regenerated for the chosen mode.
| Aspect | LB mode (default) | Priority mode |
|---|---|---|
| Toggle | wg_load_balance=true (or unset) |
wg_load_balance=false per host |
| Active tunnels | All healthy, simultaneously | One (lowest priority × 10 metric) |
| Watchdog route | Single multipath route at metric 5 with one nexthop per healthy tunnel |
Per-tunnel routes at metric priority × 10 |
Role of weight |
Multipath weight — nexthop … weight N. Flows are distributed ~proportionally to weight. Default 1 = equal share. |
Ignored. |
Role of failover_priority |
Tie-breaker only — feeds the metric-1000 fallback (lowest priority wins). |
Primary ordering — lowest wins; others stand by. |
| Failover behaviour | Failing nexthop is removed from the multipath route; healthy ones keep carrying traffic. No flow re-hash on survivors. | Failing tunnel's metric route is removed; next-lower metric takes over. |
| Hash policy | net.ipv4.fib_multipath_hash_policy (1 = L4, 0 = L3). Set via envoy_multipath_hash_policy. |
N/A |
| When to prefer | Multiple exits with similar latency; aggregate throughput matters; you're OK with per-flow path churn on topology change. | One strongly preferred exit (geo, cost, trust); others are hot-standby; sticky single-path desirable. |
Weights — how to pick them: weights are integers, interpreted as relative
shares. [1, 1] → 50/50. [3, 1] → ~75/25 on the first tunnel. There's no
bandwidth awareness; you're telling the kernel what ratio to aim for, not a
bitrate. Start at 1 for all, and only change if you have a concrete reason
(one exit has twice the egress pipe, or you want to shift load away from an
expensive provider).
Priorities — how to pick them: pick non-overlapping integers across a given
ru_node's connections. 1 = most preferred. In LB mode priorities matter only
if all tunnels are down — the metric-1000 fallback uses the lowest-priority
tunnel. In priority mode they're the whole story.
group_vars/
all/ topology (wg.yml), GRE map (gre.yml), proxy CIDRs
ru_nodes.yml env-var lookups for RU user + become password
inventory.ini hosts, minimal non-secret vars
resources/ domain lists feeding PAC and Envoy's Lua allowlist
templates/ Envoy, PAC, WG configs
wg-keys/ WireGuard keypairs (generated on first run)
wg-cloak-keys/ Cloak UID + keypairs (generated on first run)
setup-*.yml playbooks (see runbook table)
- No credentials in inventory. SSH pubkey auth only; RU sudo password comes from
$RU_BECOME_PASSat runtime. - WG/Cloak private keys live under
wg-keys/andwg-cloak-keys/in plaintext. They are per-host, generated locally, and carry no value outside the tunnel mesh — acceptable for this repo's threat model. - Envoy's allowlist is a coarse guard, not an authN boundary. The proxy is assumed to be reached only from trusted internal networks.