There is a problem you have been carrying since 06-03 without solving it completely. PostgreSQL listens on 10.0.2.15:5432 and ufw only allows it from 10.0.2.0/24, which is fine as long as you are inside that network. The HAProxy statistics panel from 07-07 has the same restriction. And when Marta needs to look at a report from home, or when you have to diagnose something on a Sunday, the only route is SSH to the server and work from there — which works, but does not scale and is no use for a graphical tool.
The obvious temptation is to open 5432 to the world with a strong password. That is exactly what you do not do: you would be exposing the database engine to the automated scanners that sweep the internet, and the day an authentication vulnerability appears in PostgreSQL — they have appeared — your entire database would be at stake while you applied the patch.
This lesson builds the correct alternative: a VPN with WireGuard. By the end, the internal network will be reachable from anywhere with a single UDP port open, PostgreSQL and the statistics panel will be closed to the outside, and you will have the same mechanism ready for reaching the media server from 08-03 from a hotel.
Contents
- What a VPN solves and what it does not
- Use cases and which one Tramontana needs
- WireGuard versus OpenVPN and IPsec
- How WireGuard works: interface, peers and cryptokey routing
- AllowedIPs: routing and access control at the same time
- Installation and key generation
- Server configuration
- Client configuration: laptop and phone
- Routing, NAT and split tunnelling versus full tunnelling
- DNS inside the tunnel
- Integration with the firewall and closing ports
- Verification
- Client management: adding, removing and rotating
- Security: what it protects and what it does not
- Automation with Ansible
- Operation and common problems
What a VPN solves and what it does not
A virtual private network creates an encrypted tunnel between two points so that the traffic travelling inside it is undecipherable and unalterable to anybody observing from outside, and the endpoints appear as if they were on the same local network.
That is all it does. And it is worth stating precisely, because the marketing of commercial VPNs has created a cloud of false expectations:
| A VPN does | A VPN does not |
|---|---|
| Encrypt the traffic between the client and the VPN server | Make you anonymous |
| Let you reach internal services without publishing them | Protect you from a malicious website |
| Authenticate the client before giving it access to the network | Protect you from malware on your machine |
| Hide the content on a public Wi-Fi network | Encrypt beyond the VPN server |
| Reduce to one the ports you have to expose | Turn an internal network into a secure one |
| Allow access policies by origin | Replace each service's own authentication |
The two most important rows on the right:
"It does not encrypt beyond the VPN server." The traffic goes encrypted from Marta's laptop to the server. From there it leaves like any other traffic. If it goes to PostgreSQL without TLS, it travels in the clear across the internal network. The VPN moves the point of trust, it does not remove it — and that is why in 08-02 you configured hostssl for replication.
"It does not turn an internal network into a secure one." It is the conceptual mistake with the worst consequences, and we will come back to it in section 14.
Use cases and which one Tramontana needs
| Case | Topology | Who initiates it | Example |
|---|---|---|---|
| Remote access | Client → internal network | People | Marta looks at the panel from home |
| Joining two sites | Network ↔ network | The routers, permanently | The office and the warehouse as a single network |
| Exiting via another IP | Client → internet | People | Watching content from another country; hiding the IP |
| Remote access | Two sites | Exit via another IP | |
|---|---|---|---|
| Tunnel | Usually split | Always split | Full |
| Who the "client" is | A device | A whole network | A device |
| Persistent | No: it connects when used | Yes, always | While it is being used |
| NAT needed | Only with a full tunnel | No | Yes |
| Main risk | A compromised device | One site compromises the other | Trusting the provider |
Tramontana needs the first one: remote access. The concrete, bounded objective:
- That Marta reaches the HAProxy statistics panel and the reports from home.
- That you reach PostgreSQL on 10.0.2.15:5432 with a graphical tool, without exposing it.
- That SSH access stops depending on having port 22 published on the internet.
- That, as a consequence, 5432 and 8404 can be closed to the outside.
What is not needed: joining sites (there is only one), or exiting via another IP (nobody needs it, and setting it up would mean routing all of Marta's personal traffic through the company server, which also has privacy implications we do not want).
WireGuard versus OpenVPN and IPsec
| WireGuard | OpenVPN | IPsec (strongSwan) | |
|---|---|---|---|
| Lines of code | ~4,000 | ~100,000 | ~400,000 (+ IKE) |
| Where it runs | In the kernel | User space | Kernel + IKE daemon |
| Typical performance | Very high, close to wire speed | Moderate | High |
| Cryptography | Fixed, no options: ChaCha20, Poly1305, Curve25519, BLAKE2s | Configurable (and misconfigurable) | Very configurable |
| Configuration | A 15-line file | Dozens of directives + PKI | Complex |
| Transport | UDP only | UDP or TCP (can go through proxies) | UDP 500/4500 + ESP |
| Traverses NAT | Yes, very well: keepalive | Yes | With NAT-T, sometimes problematic |
| Changing network without dropping (mobile) | Yes, transparently | A visible reconnection | With MOBIKE |
| Authentication | Public keys per peer | Certificates, username/password, TOTP | Certificates, PSK, EAP |
| Username and password, MFA | Not out of the box | Yes | Yes |
| Auditability | High: it can be read in full | Low because of its size | Very low |
| Industry standard | Growing, in the Linux kernel | Very widespread | The one used by network appliances |
The choice is WireGuard, and the reasons in order of weight:
- The 4,000 lines of code are not a curiosity: they are the reason it can genuinely be audited and the reason the attack surface is tiny. OpenVPN and IPsec have had serious vulnerabilities; with twenty-five times less code, there are twenty-five times fewer places to hide them.
- The cryptography is not configurable, and that is an advantage. There is no algorithm negotiation, no weak suites to disable, no downgrade attacks. Most of the insecure VPNs in the world are not insecure because of a software flaw, but because of a badly done configuration; WireGuard eliminates that entire category of mistake.
- It runs in the kernel, without copying every packet into user space. On a 2 vCPU server, the difference shows.
- The configuration fits on one screen and can be understood in its entirety, which means it can be reviewed.
And the two honest reasons for choosing something else:
- If you need a username, a password and a second factor, WireGuard does not do it: it authenticates by public key, full stop. There are layers you can put on top, but it is not native. In a company with a hundred employees and staff turnover, OpenVPN with LDAP and TOTP may be better.
- If the destination network blocks UDP — some corporate and hotel networks only let 80 and 443 out over TCP — WireGuard simply will not connect. OpenVPN over TCP/443 gets through almost anywhere disguised as HTTPS.
For Tramontana, with two or three trusted people and control over the devices, WireGuard is clearly the right option.
How WireGuard works: interface, peers and cryptokey routing
Here are the three concepts that make WireGuard either click or not. They are worth reading slowly.
- It is a network interface, not a service
OpenVPN is a daemon that runs, is configured, starts, stops and maintains connections. WireGuard is not a daemon: it is a type of kernel network interface, like eth0 or a VLAN interface.
$ ip -brief link show
lo UNKNOWN 00:00:00:00:00:00
enp0s3 UP 08:00:27:a1:b2:c3
wg0 UNKNOWN # <- no MAC address: it is layer 3The practical consequences are large:
- It is configured with
ipand withwg, the network tools you have always used. - The routing is normal Linux routing:
ip route, tables, metrics. Everything from 06-01 applies as it is. - The firewall treats it like any other interface: the
nftablesrules from 06-03 work the same. - There is no "server" and no "client" in the protocol. Both ends are identical peers. What we call the server is simply the peer that has a fixed public IP and does not specify an
Endpointfor the others.
- One key pair per device, and nothing else
There is no certificate authority, no certificates, no revocation lists, no PKI. Each device generates a Curve25519 key pair. For two devices to talk, each one needs the other's public key. That is the whole of identity management.
| Element | Where it lives | Is it shared |
|---|---|---|
| Private key | Only on the device that generated it | Never |
| Public key | On the device and in the other peer's configuration | Yes, freely |
| Preshared key (optional) | On both ends | Only between those two |
- Cryptokey routing: the central concept
Here is the idea that makes WireGuard different from everything else. In a classic VPN there are two separate mechanisms: one decides where a packet goes (routing) and another decides whether it is allowed (access control). WireGuard fuses them into one.
Each peer has a list of IP addresses associated with it: AllowedIPs. And that list is used in both directions, with two different meanings:
graph LR
subgraph OUTBOUND["OUTBOUND packet (which peer do I send it to?)"]
A["Packet with<br/>destination 10.8.0.3"] --> B{"Search the peers'<br/>AllowedIPs tables"}
B -->|"matches<br/>peer luis"| C["Encrypt with luis's<br/>public key and send<br/>to his Endpoint"]
B -->|"matches<br/>none of them"| D["DISCARD<br/>(no route)"]
end
subgraph INBOUND["INBOUND packet (do I accept it?)"]
E["Packet encrypted<br/>with luis's key"] --> F["Decrypt and look at<br/>the SOURCE IP"]
F --> G{"Is the source IP in<br/>luis's AllowedIPs?"}
G -->|Yes| H["Accept and deliver<br/>to the system"]
G -->|"No"| I["DISCARD<br/>(spoofing)"]
end
Read in words:
- When sending,
AllowedIPsworks as a routing table: the packet is encrypted with the key of the peer whose list contains the destination IP. - When receiving,
AllowedIPsworks as an anti-spoofing filter: a packet correctly decrypted withluis's key whose source IP is not inluis'sAllowedIPsis discarded.
That second part is what makes WireGuard secure by design. Even if Luis's laptop is compromised and his private key stolen, the attacker cannot send packets pretending to be 10.8.0.99 or 10.0.2.15: the cryptography and the routing are tied together, and the kernel discards the packet before it reaches anywhere.
- There is no connection state
WireGuard has no "connect" and no "disconnect". The interface exists or it does not. When there is a packet to send, WireGuard performs a one-round-trip handshake — if it has not done one in the last two minutes — and sends it. If there is no traffic, there are no packets.
This has three very practical consequences:
- Changing network drops nothing. A phone that moves from Wi-Fi to 5G carries on working: WireGuard detects the change of origin in the next valid packet and updates the endpoint automatically (roaming).
- An inactive peer is indistinguishable from one that is switched off.
wg showtells you when the last handshake was, not whether it is "connected". - WireGuard is silent to anybody without keys. A packet that does not decrypt correctly is discarded without any reply. To a port scanner, WireGuard's UDP port is indistinguishable from a closed port. That property, on its own, already justifies preferring it to exposing services.
Installation and key generation
$ sudo apt install wireguard wireguard-tools qrencode
$ modinfo wireguard | head -3
filename: /lib/modules/6.8.0-41-generic/kernel/drivers/net/wireguard/wireguard.ko.zst
license: GPL v2
description: WireGuard secure network tunnelThe module has been in the kernel since version 5.6, so on Ubuntu 24.04 with kernel 6.8 there is nothing to compile.
Generating the keys with the right permissions
# umask 077 BEFORE generating: otherwise the private key is born with
# 644 permissions for an instant, and that instant is enough.
$ umask 077
$ sudo mkdir -p /etc/wireguard/keys
$ cd /etc/wireguard
# Server
$ wg genkey | sudo tee keys/server.key | wg pubkey | sudo tee keys/server.pub
kLm3Xq7pR2vN8sT4uY9wA1bC5dE6fG0hI2jK3lM4nO8=
# A preshared key per client (section 14)
$ wg genpsk | sudo tee keys/marta.psk >/dev/null
$ sudo chmod 0600 keys/*.key keys/*.psk
$ sudo chmod 0644 keys/*.pub
$ sudo ls -l keys/
-rw------- 1 root root 45 Aug 18 10:12 marta.psk
-rw------- 1 root root 45 Aug 18 10:11 server.key
-rw-r--r-- 1 root root 45 Aug 18 10:11 server.pubThe three commands deserve a note: wg genkey generates a Curve25519 private key in base64; wg pubkey derives the public one by reading the private one from standard input; wg genpsk generates 32 random bytes for the preshared key. The derivation is one-way: you cannot get back from the public key to the private one.
The server's private key never leaves the server. Each client's private key never leaves the client. That means that, ideally, it is the client who generates its own pair and sends you only the public key. In practice, for a phone, it is usually generated on the server and transferred by QR code; that is an acceptable compromise if the transfer is direct and the file is deleted afterwards.
The addressing plan
Before writing anything, you have to decide the VPN's range. It must not overlap with any network the clients might use:
| Network | Range | Comment |
|---|---|---|
| Tramontana's internal one | 10.0.2.0/24 | The one that has to be reached |
| VPN | 10.8.0.0/24 | Chosen: uncommon in home routers |
| Marta's house | 192.168.1.0/24 | Very common: avoid that range |
| Hotels and cafés | 192.168.0.0/24, 10.0.0.0/24 | Avoid these too |
That 10.0.0.0/24 in the last row is a real trap: it is the default range of many routers, and if a client is on a 10.0.2.0/24 network — the same as Tramontana's internal one — it will have an unsolvable routing conflict. Choosing uncommon ranges for your own infrastructure avoids that problem for ever.
| Peer | IP on the VPN | Device |
|---|---|---|
| server | 10.8.0.1 | srv-tramontana |
| operator-laptop | 10.8.0.2 | Your laptop |
| marta-laptop | 10.8.0.3 | Marta's laptop |
| marta-mobile | 10.8.0.4 | Marta's phone |
| luis-laptop | 10.8.0.5 | Luis's laptop |
Server configuration
# /etc/wireguard/wg0.conf
# Permissions 0600, owned by root: it contains the private key.
[Interface]
# The address of THIS peer inside the VPN. The /24 mask makes the
# kernel add a route to the whole 10.8.0.0/24 network via wg0
# automatically when the interface comes up.
Address = 10.8.0.1/24
# The UDP listening port. Choosing a high, uncommon one reduces the
# noise from scanners, although it is NOT a real security measure:
# WireGuard does not answer anybody without a key, so the port is
# indistinguishable from a closed one in any case.
ListenPort = 51820
# The private key. It is injected from a file so as not to have it in
# the clear here: PostUp reads it. A simpler but less clean alternative:
# PrivateKey = <contents of server.key>
PostUp = wg set %i private-key /etc/wireguard/keys/server.key
# --- Network rules when the interface comes up ---
# %i is replaced by the interface name (wg0).
#
# 1. Allow the forwarding of packets entering or leaving via wg0
# 2. Masquerade the traffic going out to the internal network, so that
# the replies come back to the VPN server and do not get lost
# looking for 10.8.0.x
PostUp = nft add table inet wg
PostUp = nft 'add chain inet wg forward { type filter hook forward priority 0; }'
PostUp = nft add rule inet wg forward iifname "%i" oifname "enp0s3" accept
PostUp = nft add rule inet wg forward iifname "enp0s3" oifname "%i" ct state related,established accept
PostUp = nft 'add chain inet wg postrouting { type nat hook postrouting priority 100; }'
PostUp = nft add rule inet wg postrouting oifname "enp0s3" ip saddr 10.8.0.0/24 masquerade
# When the interface goes down the whole table is removed: a single
# command, idempotent, leaving no orphan rules to accumulate.
PostDown = nft delete table inet wg
# Accounting: who connects and from where is recorded in the journal
PostUp = logger -t wireguard "interface %i up"
PostDown = logger -t wireguard "interface %i down"
# ==========================================================
# P E E R S
# ==========================================================
# --- operator (administration laptop) ---
[Peer]
# PublicKey identifies the peer. It is its cryptographic name.
PublicKey = aB3cD4eF5gH6iJ7kL8mN9oP0qR1sT2uV3wX4yZ5aB6c=
# Preshared key: an additional symmetric layer (section 14)
PresharedKey = zY9xW8vU7tS6rQ5pO4nM3lK2jI1hG0fE9dC8bA7zY6x=
# AllowedIPs for a CLIENT: only its own IP, with a /32 mask.
# It means two things: (1) traffic towards 10.8.0.2 goes to this peer;
# (2) this peer can ONLY send packets with source 10.8.0.2.
AllowedIPs = 10.8.0.2/32
# --- Marta, laptop ---
[Peer]
PublicKey = cD5eF6gH7iJ8kL9mN0oP1qR2sT3uV4wX5yZ6aB7cD8e=
PresharedKey = xW7vU6tS5rQ4pO3nM2lK1jI0hG9fE8dC7bA6zY5xW4v=
AllowedIPs = 10.8.0.3/32
# --- Marta, phone ---
[Peer]
PublicKey = eF7gH8iJ9kL0mN1oP2qR3sT4uV5wX6yZ7aB8cD9eF0g=
PresharedKey = vU5tS4rQ3pO2nM1lK0jI9hG8fE7dC6bA5zY4xW3vU2t=
AllowedIPs = 10.8.0.4/32
# The phone is behind NAT and changes network constantly. The
# keepalive is set by the CLIENT, not by the server: it is the one
# that has to keep its carrier's NAT association alive.
# --- Luis, laptop (restricted access) ---
[Peer]
PublicKey = gH9iJ0kL1mN2oP3qR4sT5uV6wX7yZ8aB9cD0eF1gH2i=
PresharedKey = tS3rQ2pO1nM0lK9jI8hG7fE6dC5bA4zY3xW2vU1tS0r=
AllowedIPs = 10.8.0.5/32Four details of that configuration worth understanding:
Address = 10.8.0.1/24, with /24 and not /32. The mask determines the route wg-quick adds automatically: with /24, all of 10.8.0.0/24 is routed via wg0. With /32 you would have to add the routes by hand.
The rules go in their own inet wg table. It is the application of what you learned in 06-03: a separate table is removed in one go with nft delete table and does not interfere with the rules ufw manages. The alternative — adding rules to the existing table — leaves leftovers accumulating every time the interface is restarted.
The private key is read from a file with PostUp. Putting it directly in wg0.conf works just the same, but it mixes secret and configuration in a single file, which complicates managing it with Ansible without no_log on everything. Separating them lets you version the configuration and handle the key separately.
AllowedIPs = 10.8.0.2/32 for each client, with /32. It is the correct thing to do and it is where most people go wrong. See section 9.
Client configuration: laptop and phone
Linux laptop
# /etc/wireguard/wg0.conf on the operator's laptop
[Interface]
Address = 10.8.0.2/32
PrivateKey = <the laptop's private key, generated HERE>
# Internal DNS while the tunnel is up (section 10)
DNS = 10.0.2.15
[Peer]
# The server's PUBLIC key
PublicKey = kLm3Xq7pR2vN8sT4uY9wA1bC5dE6fG0hI2jK3lM4nO8=
PresharedKey = zY9xW8vU7tS6rQ5pO4nM3lK2jI1hG0fE9dC8bA7zY6x=
# Where the server is. A DNS name can be used: WireGuard resolves it
# when the interface comes up, which allows a dynamic IP.
Endpoint = vpn.tramontana.example:51820
# --- SPLIT TUNNEL: only the internal network and the VPN go through it ---
# The rest of the laptop's traffic (browsing, email, video calls) goes
# out over its normal connection.
AllowedIPs = 10.0.2.0/24, 10.8.0.0/24
# Keep the NAT association on the client's router alive. 25 s is the
# recommended value: below the typical expiry time of NAT tables
# (30 s on many home routers).
PersistentKeepalive = 25$ sudo systemctl enable --now wg-quick@wg0
$ sudo wg show
interface: wg0
public key: aB3cD4eF5gH6iJ7kL8mN9oP0qR1sT2uV3wX4yZ5aB6c=
private key: (hidden)
listening port: 45182
peer: kLm3Xq7pR2vN8sT4uY9wA1bC5dE6fG0hI2jK3lM4nO8=
preshared key: (hidden)
endpoint: 203.0.113.45:51820
allowed ips: 10.0.2.0/24, 10.8.0.0/24
latest handshake: 8 seconds ago
transfer: 12.41 KiB received, 18.02 KiB sent
persistent keepalive: every 25 secondsPhone, with a QR code
The official WireGuard apps for Android and iOS read the configuration from a QR code, which saves typing forty-four base64 characters three times:
$ sudo qrencode -t ansiutf8 < /etc/wireguard/clients/marta-mobile.conf
█████████████████████████████████████
██ ▄▄▄▄▄ █▀ █▀▀██▀▄ ▀█ ▄█ ▄▄▄▄▄ ██
██ █ █ █▄ ▀▄█▄▀▄█▄ ▀█ █ █ ██
██ █▄▄▄█ █ ▀▄ ▄▀▄ █▀▄▀██ █▄▄▄█ ██
...
# And to send it over a secure channel, as a PNG
$ sudo qrencode -t png -o /tmp/marta-mobile.png \
< /etc/wireguard/clients/marta-mobile.confAn important warning about the QR code: it contains the private key in the clear. It is not sent by email, or by WhatsApp, and it is not left in /tmp. It is displayed in the terminal, scanned in front of the phone, and deleted:
A WireGuard QR code photographed over your shoulder is complete access to the internal network.
# marta-mobile.conf
[Interface]
Address = 10.8.0.4/32
PrivateKey = <generated for this phone>
DNS = 10.0.2.15
[Peer]
PublicKey = kLm3Xq7pR2vN8sT4uY9wA1bC5dE6fG0hI2jK3lM4nO8=
PresharedKey = vU5tS4rQ3pO2nM1lK0jI9hG8fE7dC6bA5zY4xW3vU2t=
Endpoint = vpn.tramontana.example:51820
AllowedIPs = 10.0.2.0/24, 10.8.0.0/24
# On a phone the keepalive is essential: without it, the carrier's NAT
# association expires and the server cannot initiate traffic towards
# the phone until the phone sends something.
PersistentKeepalive = 25In the mobile app it is also worth enabling "on-demand VPN" excluding the office Wi-Fi network: that way the tunnel comes up automatically outside and does not get in the way inside.
Routing, NAT and split tunnelling versus full tunnelling
Packet forwarding
By default, Linux does not forward packets between interfaces: it is a machine, not a router. For the traffic arriving over wg0 to go out towards 10.0.2.0/24, it has to be enabled:
$ cat /proc/sys/net/ipv4/ip_forward
0
# Persistent, in the hardening file that already exists (06-06)
$ echo 'net.ipv4.ip_forward = 1' | \
sudo tee /etc/sysctl.d/72-wireguard.conf
$ echo 'net.ipv6.conf.all.forwarding = 0' | \
sudo tee -a /etc/sysctl.d/72-wireguard.conf
$ sudo sysctl --system | grep forward
net.ipv4.ip_forward = 1IPv6 is deliberately left disabled: if you do not use it, enabling it opens a path you are not watching. It is the same logic as 06-06.
Why masquerading is needed
Marta, from 10.8.0.3, queries PostgreSQL on 10.0.2.15. Without NAT, the packet reaches PostgreSQL with source 10.8.0.3, PostgreSQL replies to 10.8.0.3... and its routing table does not know where that network is, so it sends the reply to the default gateway and it gets lost.
There are two solutions:
| Masquerading (NAT) | A static route on each destination | |
|---|---|---|
| What it does | The VPN server rewrites the source | Every internal machine learns how to reach 10.8.0.0/24 |
| Configuration | One rule, in one place | A route on every machine or on the router |
| What the internal services see | The VPN server's IP | The VPN client's real IP |
| Logs and control by IP | The granularity is lost | It is preserved |
| When to use it | Networks you do not control | Your own networks, and it is preferable |
At Tramontana, with everything inside 10.0.2.0/24 and the VPN server being the same machine, masquerading simplifies things and the cost is acceptable. On a larger network, the static route on the router is better: it lets pg_hba.conf tell Marta from Luis by their VPN IP, and it makes the access logs useful.
Split tunnelling versus full tunnelling
It is the design decision with the most consequences in the whole lesson, and it is taken with a single line: AllowedIPs on the client.
| Split tunnel | Full tunnel | |
|---|---|---|
The client's AllowedIPs |
10.0.2.0/24, 10.8.0.0/24 |
0.0.0.0/0, ::/0 |
| What goes through the tunnel | Only the internal traffic | Everything |
| Bandwidth on the server | Minimal | All the traffic of everybody |
| Latency of normal browsing | Unchanged | Worse: it detours via the server |
| Video calls and streaming | Direct | Via the server: they can go badly |
| Protects on public Wi-Fi | Only the internal traffic | All the traffic |
| The user's privacy | It is respected | The company sees all their browsing |
| Allows egress policies to be enforced | No | Yes: filtering, logging |
| DNS leak possible | Yes, it has to be looked after | No |
For Tramontana the decision is the split tunnel, and with three arguments:
- Bandwidth. All the traffic of three people passing through a 2 vCPU server on a shared connection would degrade everybody's browsing and compete with the bookings application, which is what pays the bills.
- Privacy. With a full tunnel, all of Marta's personal browsing — including what she does out of hours on her company laptop — passes through a company server and appears in its logs. That has data-protection implications and, above all, it is not necessary for the objective.
- The objective is to reach the internal services, not to protect general browsing. HTTPS and common sense on public networks are there for that.
When a full tunnel does make sense: a corporate laptop that has to comply with the company's filtering policy wherever it is, or somebody who habitually works from networks that cannot be trusted and needs everything to be encrypted. In that case, AllowedIPs = 0.0.0.0/0 on the client, and the server's masquerading rules already support it exactly as they are written.
DNS inside the tunnel
With a split tunnel a detail appears that is easy to overlook: internal names.
# Without internal DNS, with the tunnel up
$ dig +short srv-tramontana.internal
# (empty: the hotel's DNS does not know that name)The client's DNS = 10.0.2.15 directive makes wg-quick configure the resolver while the tunnel is active. On Ubuntu with systemd-resolved, wg-quick uses resolvectl and does something elegant:
$ resolvectl status wg0
Link 5 (wg0)
Current Scopes: DNS
Protocols: +DefaultRoute
Current DNS Server: 10.0.2.15
DNS Servers: 10.0.2.15With ~tramontana.example as the search domain, only queries for that domain would go to the internal DNS and the rest would carry on via the normal DNS — which is called split DNS and is the ideal arrangement with a split tunnel:
# On the client, for genuine split DNS
PostUp = resolvectl dns %i 10.0.2.15; resolvectl domain %i '~tramontana.example'The DNS leak is the symmetrical problem: if the client carries on using the hotel's DNS for everything, that server sees which internal names you look up, which reveals information about your infrastructure. Checking it:
$ resolvectl query panel.tramontana.example
panel.tramontana.example: 10.0.2.20 -- link: wg0
$ sudo tcpdump -ni enp0s3 port 53 -c 5
# With split DNS properly configured, queries for internal names
# must NOT appear here.A prerequisite, of course: that there is an internal DNS resolving those names. With three machines, dnsmasq on srv-tramontana is more than enough.
Integration with the firewall and closing ports
This is the section where the VPN pays for itself, closing ports that had been open since Module 6.
# 1. Open the WireGuard port. UDP, and only that one.
$ sudo ufw allow 51820/udp comment 'WireGuard'
# 2. Allow the traffic arriving THROUGH the tunnel
$ sudo ufw allow in on wg0 comment 'VPN internal traffic'
# 3. Allow forwarding, which ufw denies by default
$ sudo sed -i 's/^DEFAULT_FORWARD_POLICY=.*/DEFAULT_FORWARD_POLICY="ACCEPT"/' \
/etc/default/ufw
$ sudo ufw reloadAnd now the important part. Before closing anything, you have to verify that the VPN works — it is literally the case of "never close the door you are coming in through":
# --- VERIFY FIRST, from the client with the tunnel up ---
$ psql -h 10.0.2.15 -U operator -d tramontana -c 'SELECT 1;' >/dev/null && echo OK
OK
$ curl -sI http://10.0.2.20:8404/ | head -1
HTTP/1.1 200 OK
$ ssh [email protected] 'hostname'
srv-tramontana
# --- ONLY THEN, close ---
$ sudo ufw status numbered | grep -E '5432|8404|22'
[ 3] 5432/tcp ALLOW IN 10.0.2.0/24
[ 5] 8404/tcp ALLOW IN 10.0.2.0/24
[ 7] 22/tcp LIMIT IN Anywhere
# PostgreSQL: only from the VPN and from the internal network itself
$ sudo ufw delete 5
$ sudo ufw delete 3
$ sudo ufw allow from 10.8.0.0/24 to any port 5432 proto tcp comment 'PostgreSQL via VPN'
$ sudo ufw allow from 10.8.0.0/24 to any port 8404 proto tcp comment 'Panel via VPN'
# SSH: it stays open with LIMIT as a safety net. Closing it completely
# means that a WireGuard failure leaves you with no access to the
# machine. It is closed when there is a second, tested route (the
# provider's console or a KVM), not before.That last decision deserves defending, because the temptation to close 22 is strong. SSH is not closed until you have a second, tested access route. If WireGuard fails after a kernel update, or if the configuration gets corrupted, or if the module does not load, port 22 open with LIMIT and fail2ban is the difference between a scare and a physical trip to the data centre. It is exactly the lesson from 06-02.
The final result:
$ sudo ufw status verbose
Status: active
Default: deny (incoming), allow (outgoing), allow (routed)
To Action From
-- ------ ----
22/tcp LIMIT IN Anywhere
80,443/tcp ALLOW IN Anywhere
51820/udp (WireGuard) ALLOW IN Anywhere
Anywhere on wg0 ALLOW IN Anywhere
5432/tcp (PostgreSQL VPN) ALLOW IN 10.8.0.0/24
8404/tcp (Panel VPN) ALLOW IN 10.8.0.0/24What has changed, and it is the summary of the project:
| Service | Before | After |
|---|---|---|
| PostgreSQL 5432 | Open to 10.0.2.0/24; unreachable from outside | Only over the VPN, reachable from anywhere |
| Panel 8404 | The same | Only over the VPN |
| TCP ports exposed to the internet | 22, 80, 443 | 22, 80, 443 (unchanged) |
| UDP ports exposed | 0 | 1, which does not answer anybody without a key |
| Marta's remote access | SSH and work on the server | Direct, with her own tools |
Verification
# 1. The interface exists and has its address
$ ip -brief addr show wg0
wg0 UNKNOWN 10.8.0.1/24
# 2. The routes wg-quick has created
$ ip route show dev wg0
10.8.0.0/24 proto kernel scope link src 10.8.0.1
# 3. The state of the peers (from the server)
$ sudo wg show
interface: wg0
public key: kLm3Xq7pR2vN8sT4uY9wA1bC5dE6fG0hI2jK3lM4nO8=
private key: (hidden)
listening port: 51820
peer: cD5eF6gH7iJ8kL9mN0oP1qR2sT3uV4wX5yZ6aB7cD8e=
preshared key: (hidden)
endpoint: 198.51.100.87:41922
allowed ips: 10.8.0.3/32
latest handshake: 41 seconds ago
transfer: 4.18 MiB received, 28.44 MiB sent
peer: eF7gH8iJ9kL0mN1oP2qR3sT4uV5wX6yZ7aB8cD9eF0g=
allowed ips: 10.8.0.4/32
# no 'latest handshake': this peer has NEVER connectedHow to read wg show, which is the main diagnostic tool:
| Field | What it means |
|---|---|
endpoint |
Where the peer is now. Absent = never seen |
latest handshake |
When. More than 3 min = inactive or disconnected |
transfer |
Encrypted bytes exchanged |
No latest handshake |
The peer has never connected: check the keys or the network |
# 4. Basic connectivity (from the client)
$ ping -c3 10.8.0.1
64 bytes from 10.8.0.1: icmp_seq=1 ttl=64 time=24.1 ms
# 5. Reaching the internal network
$ ping -c2 10.0.2.15 && nc -z -v 10.0.2.15 5432
Connection to 10.0.2.15 5432 port [tcp/postgresql] succeeded!
# 6. CHECK THAT THE TRAFFIC IS ENCRYPTED
# On enp0s3 (the physical interface) only opaque UDP is visible:
$ sudo tcpdump -ni enp0s3 udp port 51820 -c 3 -X | head -12
14:02:11.482 IP 198.51.100.87.41922 > 10.0.2.15.51820: UDP, length 128
0x0000: 4500 009c 0000 4000 3611 ...
0x0020: 0400 0000 9f2c 1a4b 8e73 d012 4a91 ...
# Not one readable byte: no "SELECT", no headers, nothing.
# On wg0 (inside the tunnel) the REAL traffic is visible, decrypted:
$ sudo tcpdump -ni wg0 -c 3
14:02:14.118 IP 10.8.0.3.51422 > 10.0.2.15.5432: Flags [P.], length 34
14:02:14.121 IP 10.0.2.15.5432 > 10.8.0.3.51422: Flags [P.], length 8
# 7. With a split tunnel, the exit IP does NOT change
$ curl -s https://ifconfig.me; echo
198.51.100.87 # <- the hotel's, not the server's
# With a full tunnel (AllowedIPs = 0.0.0.0/0) it would be:
# 203.0.113.45 # <- the VPN server's
# 8. Check that there is no DNS leak
$ resolvectl query srv-tramontana.internal
srv-tramontana.internal: 10.0.2.15 -- link: wg0Check 6 is the one that demonstrates the value of the whole setup: two captures of the same communication, one unreadable on the public network and the other readable inside the tunnel. It is what to show somebody when they ask what exactly a VPN does.
And 7 is the one that confirms the split-tunnel decision: Marta's normal browsing carries on going out over her own connection, as it should.
Client management: adding, removing and rotating
Adding a client
#!/usr/bin/env bash
#
# vpn_add_client.sh - Adds a WireGuard client
#
# Usage: vpn_add_client.sh <name> [ip]
#
set -euo pipefail
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/lib/common.sh"
readonly WG_DIR=/etc/wireguard
readonly WG_IF=wg0
readonly WG_NET=10.8.0
readonly WG_ENDPOINT="vpn.tramontana.example:51820"
readonly WG_ALLOWED="10.0.2.0/24, 10.8.0.0/24"
umask 077
next_ip() {
# The first free one from .2 onwards, looking at the real configuration
local used i
used="$(grep -oP 'AllowedIPs\s*=\s*'"${WG_NET}"'\.\K[0-9]+' \
"${WG_DIR}/${WG_IF}.conf" | sort -n)"
for i in $(seq 2 254); do
grep -qx "$i" <<<"$used" || { echo "${WG_NET}.${i}"; return 0; }
done
die 73 "no free addresses left in ${WG_NET}.0/24"
}
main() {
local name="${1:?usage: vpn_add_client.sh <name> [ip]}"
[[ "$name" =~ ^[a-z0-9-]+$ ]] || die 2 "invalid name: only [a-z0-9-]"
[[ -f "${WG_DIR}/clients/${name}.conf" ]] && die 2 "the client $name already exists"
require_command wg
require_command qrencode
local ip; ip="${2:-$(next_ip)}"
log "adding '$name' with IP $ip"
mkdir -p "${WG_DIR}/clients" "${WG_DIR}/keys"
local priv pub psk
priv="$(wg genkey)"
pub="$(wg pubkey <<<"$priv")"
psk="$(wg genpsk)"
# 1. The client's configuration
cat > "${WG_DIR}/clients/${name}.conf" <<END
[Interface]
Address = ${ip}/32
PrivateKey = ${priv}
DNS = 10.0.2.15
[Peer]
PublicKey = $(wg pubkey < "${WG_DIR}/keys/server.key")
PresharedKey = ${psk}
Endpoint = ${WG_ENDPOINT}
AllowedIPs = ${WG_ALLOWED}
PersistentKeepalive = 25
END
chmod 0600 "${WG_DIR}/clients/${name}.conf"
# 2. Add the peer to the persistent configuration
cat >> "${WG_DIR}/${WG_IF}.conf" <<END
# --- ${name} (added: $(date +%F)) ---
[Peer]
PublicKey = ${pub}
PresharedKey = ${psk}
AllowedIPs = ${ip}/32
END
# 3. And LIVE, without cutting off the others. 'wg syncconf' applies
# the changes without bringing the interface down; 'wg-quick
# down/up' would cut off every connected client.
wg syncconf "$WG_IF" <(wg-quick strip "$WG_IF")
log "client '$name' added"
printf '\nScan this code with the WireGuard app:\n\n'
qrencode -t ansiutf8 < "${WG_DIR}/clients/${name}.conf"
printf '\nConfiguration in: %s/clients/%s.conf\n' "$WG_DIR" "$name"
printf 'DELETE IT from the server once you have handed it over.\n'
}
main "$@"$ sudo ~/scripts/vpn_add_client.sh marta-tablet
[2026-08-18 14:22:01] adding 'marta-tablet' with IP 10.8.0.6
[2026-08-18 14:22:01] client 'marta-tablet' added
Scan this code with the WireGuard app:
█████████████████████████
...The key line is wg syncconf: it applies the changes without bringing the interface down, so that adding a new client does not cut off the ones that are connected. With wg-quick down && wg-quick up they would all be cut off, and it is a frequent mistake.
Removing a client
# Immediate, live: it is enough to remove the public key
$ sudo wg set wg0 peer 'cD5eF6gH7iJ8kL9mN0oP1qR2sT3uV4wX5yZ6aB7cD8e=' remove
# And from the persistent configuration, so that it does not come back on restart
$ sudo sed -i '/--- marta-laptop/,/^$/d' /etc/wireguard/wg0.conf
$ sudo wg-quick strip wg0 | sudo wg syncconf wg0 /dev/stdin
$ sudo shred -u /etc/wireguard/clients/marta-laptop.confWhy removing the peer is enough, and it is an architectural advantage of WireGuard: there is no revocation list, no certificates that remain valid, no expiry window. The server only accepts packets from public keys that are in its configuration. As soon as the key disappears, that device is indistinguishable from any stranger on the internet: its packets are discarded without a reply.
Compare that with OpenVPN, where revoking a certificate requires updating the CRL, making sure the server reads it, and that there are no active sessions. Here it is one command that takes effect on the next packet.
Key rotation
| Situation | Action | Urgency |
|---|---|---|
| A device is stolen or lost | Remove the peer immediately | Minutes |
| Somebody leaves the company | Remove the peer | The same day |
| Preventive rotation | Regenerate the client's key pair | Annually |
| The server is compromised | Regenerate EVERYTHING: server and all clients | Immediately |
Rotating the server's key is the disruptive operation, because its public key is in every client's configuration. The procedure without interruptions: bring up a second interface wg1 on another port with the new key, migrate the clients one by one, and retire wg0 when the last one has migrated.
Security: what it protects and what it does not
The private key never leaves the device
It is the basic principle, and it has a concrete operational implication: the correct approach is for the client to generate its own key pair and send you only the public key. The public key can be emailed without any problem at all.
# On Luis's laptop
$ umask 077 && wg genkey | tee private.key | wg pubkey
gH9iJ0kL1mN2oP3qR4sT5uV6wX7yZ8aB9cD0eF1gH2i=
# Luis sends ONLY that line. The private key never leaves his laptop.The vpn_add_client.sh script generates the pair on the server because for a phone that is the practical thing to do. It is a conscious compromise, and that is why the script insists on deleting the file once it has been handed over.
The preshared key
It is a 256-bit symmetric layer of encryption mixed into the key derivation, on top of the Curve25519 exchange.
What it protects against, which is a future threat rather than a current one: a sufficiently large quantum computer could break Curve25519 using Shor's algorithm. It does not exist today, nor is it close, but there is a threat model called "harvest now, decrypt later": an adversary with resources captures and stores encrypted traffic today in order to decrypt it in fifteen years' time. The preshared key, being symmetric, is resistant to that attack — Grover only reduces the effective security from 256 to 128 bits, which is still out of reach.
What it does not protect against: anything happening today. It adds no security against classical attackers. It is a cheap insurance policy against a distant risk, and that is why it is in the configuration: it costs one line.
What has to be said out loud
A VPN does not turn an internal network into a secure network.
It is the most important conclusion of the lesson, and the most ignored. With the VPN up, anybody who arrives through the tunnel is inside 10.0.2.0/24 and sees everything a machine on that network sees. If Luis's laptop gets infected with a trojan, that trojan has direct access to PostgreSQL, to the statistics panel and to any service that trusts the internal network.
| Threat | Does the VPN protect? |
|---|---|
| Traffic being spied on over a public network | Yes |
| Port scanning from the internet | Yes: there are no new TCP ports |
| Brute force against PostgreSQL from the internet | Yes: it is no longer reachable |
| An infected user laptop | No: the trojan comes in through the tunnel |
| A weak password on an internal service | No |
| A vulnerability in an internal service | No, if the attacker arrives over the VPN |
| A disgruntled employee | No |
| Lateral movement once inside | No |
What follows from that table is the zero-trust architecture: every service authenticates and encrypts on its own account, without trusting the network. In practice, for Tramontana, it means that the VPN replaces nothing you already have: pg_hba.conf still demands scram-sha-256, the statistics panel still needs its restriction, SSH still uses keys and fail2ban, and TLS is still needed on the inside. The VPN is one more layer, not a perimeter that lets you relax the rest.
And an additional restriction that the VPN does let you do well, using AllowedIPs as access control:
# Luis is a developer: he does not need production PostgreSQL.
# His VPN IP is fixed (10.8.0.5), so it can be filtered.
$ sudo ufw deny from 10.8.0.5 to any port 5432 proto tcp \
comment 'Luis: no access to the production DB'
$ sudo ufw allow from 10.8.0.5 to any port 8404 proto tcpSince each peer has a fixed IP guaranteed by the protocol itself — remember: AllowedIPs stops it being spoofed — this rule is reliable in a way it would not be on a normal network.
Automation with Ansible
# ~/tramontana-infra/roles/vpn/defaults/main.yml
---
vpn_interface: wg0
vpn_port: 51820
vpn_net: 10.8.0.0/24
vpn_server_ip: 10.8.0.1
vpn_egress_interface: enp0s3
vpn_endpoint: vpn.tramontana.example
vpn_internal_dns: 10.0.2.15
vpn_reachable_nets: "10.0.2.0/24, 10.8.0.0/24"
vpn_clients:
- { name: operator-laptop, ip: 10.8.0.2, db_access: true }
- { name: marta-laptop, ip: 10.8.0.3, db_access: true }
- { name: marta-mobile, ip: 10.8.0.4, db_access: false }
- { name: luis-laptop, ip: 10.8.0.5, db_access: false }The public and preshared keys live in Vault, integrated with pass as in 07-06:
# group_vars/all/vault.yml (encrypted with ansible-vault)
vault_vpn_clients:
operator-laptop:
pubkey: "aB3cD4eF5gH6iJ7kL8mN9oP0qR1sT2uV3wX4yZ5aB6c="
psk: "zY9xW8vU7tS6rQ5pO4nM3lK2jI1hG0fE9dC8bA7zY6x="
marta-laptop:
pubkey: "cD5eF6gH7iJ8kL9mN0oP1qR2sT3uV4wX5yZ6aB7cD8e="
psk: "xW7vU6tS5rQ4pO3nM2lK1jI0hG9fE8dC7bA6zY5xW4v="# ~/tramontana-infra/roles/vpn/tasks/main.yml
---
- name: Install WireGuard
ansible.builtin.apt:
name: [wireguard, wireguard-tools, qrencode]
state: present
tags: [packages]
- name: Check that the kernel module is available
ansible.builtin.command: modinfo wireguard
register: modwg
changed_when: false
failed_when: modwg.rc != 0
- name: WireGuard directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: '0700'
loop:
- /etc/wireguard
- /etc/wireguard/keys
- name: Generate the server's private key if it does not exist
ansible.builtin.shell:
cmd: "umask 077 && wg genkey > /etc/wireguard/keys/server.key"
creates: /etc/wireguard/keys/server.key
# 'creates' makes the task idempotent: it does NOT regenerate the key
# on every run, which would lock out every client (07-06).
- name: Read the server's public key
ansible.builtin.shell:
cmd: "wg pubkey < /etc/wireguard/keys/server.key"
register: wg_pub
changed_when: false
- name: Enable IPv4 forwarding
ansible.posix.sysctl:
name: net.ipv4.ip_forward
value: '1'
sysctl_file: /etc/sysctl.d/72-wireguard.conf
reload: true
- name: Deploy the server configuration
ansible.builtin.template:
src: wg0.conf.j2
dest: "/etc/wireguard/{{ vpn_interface }}.conf"
owner: root
group: root
mode: '0600'
backup: true
no_log: true # it contains preshared keys
notify: Sync wireguard
- name: Generate each client's configuration
ansible.builtin.template:
src: client.conf.j2
dest: "/etc/wireguard/clients/{{ item.name }}.conf"
owner: root
group: root
mode: '0600'
loop: "{{ vpn_clients }}"
loop_control:
label: "{{ item.name }}"
no_log: true
tags: [clients]
- name: Open the WireGuard port
community.general.ufw:
rule: allow
port: "{{ vpn_port }}"
proto: udp
comment: WireGuard
tags: [firewall]
- name: Allow inbound traffic on the tunnel interface
community.general.ufw:
rule: allow
interface: "{{ vpn_interface }}"
direction: in
tags: [firewall]
- name: PostgreSQL access only for the authorised clients
community.general.ufw:
rule: "{{ 'allow' if item.db_access else 'deny' }}"
src: "{{ item.ip }}"
port: '5432'
proto: tcp
comment: "VPN {{ item.name }}"
loop: "{{ vpn_clients }}"
loop_control:
label: "{{ item.name }}"
tags: [firewall]
- name: Bring the interface up
ansible.builtin.systemd:
name: "wg-quick@{{ vpn_interface }}"
enabled: true
state: started
- name: Force the handlers before verifying
ansible.builtin.meta: flush_handlers
# --- Verification ---
- name: Check that the interface is up with its address
ansible.builtin.command: "ip -brief addr show {{ vpn_interface }}"
register: ipwg
changed_when: false
failed_when: vpn_server_ip not in ipwg.stdout
tags: [verify]
- name: Check that every configured peer is present
ansible.builtin.command: "wg show {{ vpn_interface }} peers"
register: peers
changed_when: false
failed_when: peers.stdout_lines | length != vpn_clients | length
tags: [verify]# roles/vpn/handlers/main.yml
---
- name: Sync wireguard
# 'wg syncconf' applies the changes WITHOUT BRINGING DOWN the
# interface: it does not cut off connected clients. 'wg-quick
# down/up' would cut off all of them, and you too if you are
# administering OVER the VPN.
ansible.builtin.shell:
cmd: "wg syncconf {{ vpn_interface }} <(wg-quick strip {{ vpn_interface }})"
executable: /bin/bash
changed_when: trueTwo details of this role sum up what was learned in 07-06: the creates: that prevents the server key being regenerated on every run — which would lock out every client at once — and the handler with wg syncconf instead of a restart, which is the WireGuard version of reload rather than restart from 08-01.
Operation and common problems
What to look at
# Peers and last handshake, one line per peer
$ sudo wg show wg0 dump | awk 'NR>1 {
printf "%-12s %-22s %s\n", substr($1,1,10)"...", $3,
($5==0 ? "NEVER" : strftime("%F %T", $5)) }'
cD5eF6gH7i... 198.51.100.87:41922 2026-08-18 14:02:11
eF7gH8iJ9k... (none) NEVER
# Volume per peer (useful for spotting a runaway client)
$ sudo wg show wg0 transfer
cD5eF6gH7iJ8kL9mN0oP1qR2sT3uV4wX5yZ6aB7cD8e= 4382144 29827072| Frequency | What you look at | Warning sign |
|---|---|---|
| Continuous (08-06) | The wg0 interface up |
Absent |
| Continuous | Peers with a recent handshake | None during working hours |
| Weekly | Peers that have never connected | An addition that was not completed, or a key badly delivered |
| Monthly | Peers with no activity for 90 days | Candidates for removal |
| Monthly | Anomalous traffic per peer | A compromised client |
The five problems that really turn up
1. Overlapping AllowedIPs — the most common error and the most baffling.
# WRONG: two peers claim the same address
[Peer] # marta
AllowedIPs = 10.8.0.3/32
[Peer] # luis
AllowedIPs = 10.8.0.0/24 # <-- includes 10.8.0.3WireGuard resolves the routing by most specific prefix, just like the routing table. With that configuration, traffic to 10.8.0.3 goes to Marta (more specific), but Luis can send packets forging any IP in 10.8.0.0/24, because his inbound filter allows it. That is a security hole, not just a routing problem.
Worse still: AllowedIPs = 0.0.0.0/0 in a peer on the server means "all internet traffic goes via this peer". It is correct in the configuration of a client with a full tunnel and catastrophic in the server's: it breaks all of its connectivity.
Rule: on the server, each client carries only its
/32.0.0.0.0/0only appears in the client's configuration, never in the server's.
# Detect overlaps before they bite
$ sudo wg show wg0 allowed-ips | awk '{$1=""; print}' | tr ' ' '\n' | \
grep -v '^$' | sort | uniq -d
# (empty: no overlaps)2. The handshake never happens.
$ sudo wg show wg0 | grep -A2 'peer:'
peer: eF7gH8iJ9kL0mN1oP2qR3sT4uV5wX6yZ7aB8cD9eF0g=
allowed ips: 10.8.0.4/32
# no 'latest handshake'In order of probability: the UDP port does not get through (the server's firewall, the router's, or the client's provider); the keys do not correspond (the client's public key on the server is not derived from its private one); the Endpoint is wrong or the DNS does not resolve; or there is a PresharedKey on one side and not on the other.
# Check whether the packets arrive at all
$ sudo tcpdump -ni enp0s3 udp port 51820
# If NOTHING appears while the client is trying to connect, the problem
# is before the server: the network, the router or a firewall in between.
# Verify that a public key corresponds to a private one
$ wg pubkey < /etc/wireguard/keys/server.key
kLm3Xq7pR2vN8sT4uY9wA1bC5dE6fG0hI2jK3lM4nO8=3. A correct handshake but no traffic. The tunnel is up and nothing goes through. It is almost always one of three things: net.ipv4.ip_forward=1 is missing, the masquerading rule is missing, or the destination network is missing from the client's AllowedIPs.
$ sysctl net.ipv4.ip_forward
$ sudo nft list table inet wg | grep masquerade
$ sudo wg show wg0 allowed-ips4. It drops after a few minutes of inactivity. It is NAT: the client's router or the mobile carrier forgets the association. The solution: PersistentKeepalive = 25 on the client.
5. Fragmentation and MTU. The symptom is characteristic and misleading: ping works, SSH connects and then hangs when listing a large directory, web pages load halfway.
# WireGuard's default MTU is 1420 (1500 - 80 of headers).
# With PPPoE or other encapsulations it can still be too much.
$ ping -M do -s 1372 -c2 10.0.2.15 # 1372 + 28 = 1400
$ ping -M do -s 1392 -c2 10.0.2.15 # 1392 + 28 = 1420
ping: local error: message too long, mtu=1420Common Mistakes and Tips
- Putting
AllowedIPs = 0.0.0.0/0in a peer on the server. It breaks all of its connectivity. Only the client's/32goes there. - Overlapping
AllowedIPsbetween peers. It is not just a routing problem: it lets one client spoof another's IP. - Choosing 192.168.1.0/24 or 10.0.0.0/24 for the VPN. It will clash with somebody's home network or a hotel's, and the conflict cannot be fixed from your side.
- Sending the QR code or the configuration file by email or messaging. It contains the private key in the clear: it is complete access to the internal network.
- Generating the keys without
umask 077. They are born with 644 permissions, even if only for an instant. - Closing the SSH port the same day you build the VPN. If WireGuard fails after a kernel update, you are locked out. A second, tested route first.
- Forgetting
net.ipv4.ip_forward=1. The tunnel comes up perfectly and not a single packet reaches the internal network. - Forgetting
PersistentKeepaliveon phones. The tunnel "drops" a few minutes after going idle. - Using
wg-quick down && upto add a client. It cuts off everybody connected, including you if you are administering over the VPN. Usewg syncconf. - Regenerating the server key on every Ansible run. It locks out every client at once.
creates:or astatfirst. - Believing that the VPN makes the internal network secure. An infected laptop comes in through the tunnel with full permissions. Every service authenticates on its own account.
- Removing TLS from internal services "because it already goes over the VPN". The encryption ends at the VPN server, not at the service.
- Diagnosing without
tcpdump. Seeing whether the UDP packets even reach the server separates network problems from configuration problems in a minute. - Ignoring the MTU. The symptom — it connects and then hangs on large transfers — looks like anything but fragmentation.
- A tip on method. Before closing a port, verify from the client that you already get through over the VPN. The course's rule admits no exceptions: never close the door you are coming in through.
Exercises
Exercise 1
Marta calls you from a hotel: "the VPN says it is connected but I cannot get into the panel". Diagnose the problem systematically, from the most likely cause to the least, and solve it.
Exercise 2
Design the complete response procedure for the loss of Marta's phone with the VPN configured, in the form of a runbook, and include what has to be done afterwards so that the incident does not repeat itself in the same way.
Exercise 3
Marta asks whether with the VPN "we are safe now" and whether other measures can be simplified now that the network is protected. Draft the reply.
Solutions
Solution 1
Method: from the outside in, and from the most likely to the least. Each step rules out a whole layer, and that order saves most of the time.
# ===== STEP 1: is there a handshake? (separates the network from everything else) =====
# From the server:
$ sudo wg show wg0 | grep -A4 'cD5eF6gH7iJ8'
peer: cD5eF6gH7iJ8kL9mN0oP1qR2sT3uV4wX5yZ6aB7cD8e=
endpoint: 198.51.100.87:41922
latest handshake: 14 seconds ago
transfer: 892 B received, 0 B sentThere is a recent handshake: the tunnel is cryptographically established, the key is correct, the UDP port gets through and the hotel's Wi-Fi lets it pass. That rules out the four most frequent causes in one go.
But look at 0 B sent: the server receives from Marta and sends her nothing. That detail points straight at routing or filtering, not at the VPN.
# ===== STEP 2: do the packets reach the server through the tunnel? =====
$ sudo tcpdump -ni wg0 -c 5
14:31:02 IP 10.8.0.3.52118 > 10.0.2.20.8404: Flags [S], seq 118..., length 0
14:31:03 IP 10.8.0.3.52118 > 10.0.2.20.8404: Flags [S], seq 118..., length 0
14:31:05 IP 10.8.0.3.52118 > 10.0.2.20.8404: Flags [S], seq 118..., length 0Three SYNs with no reply. Marta's packet crosses the tunnel correctly and reaches the server. The problem is after the VPN: something between the server and port 8404 is not answering. The VPN is completely ruled out.
# ===== STEP 3: is it the firewall? =====
$ sudo ufw status numbered | grep 8404
[ 9] 8404/tcp ALLOW IN 10.8.0.0/24
# The rule exists. Is it being applied? Count the hits:
$ sudo nft list ruleset | grep -B2 -A2 8404
$ sudo journalctl -k --since "5 min ago" | grep -i 'UFW BLOCK' | tail -3
[UFW BLOCK] IN=wg0 OUT=enp0s3 SRC=10.8.0.3 DST=10.0.2.20 PROTO=TCP DPT=8404There it is. IN=wg0 OUT=enp0s3 means the packet is being forwarded, not delivered locally: the HAProxy panel lives on 10.0.2.20, which is another machine. And ufw denies forwarding by default — the allow ... to any port 8404 rule applies to the input chain, not to the forward chain.
# ===== STEP 4: confirm the root cause =====
$ grep DEFAULT_FORWARD_POLICY /etc/default/ufw
DEFAULT_FORWARD_POLICY="DROP"Confirmed. Step 11 of the lesson was applied by halves: the traffic to the server was allowed but not the forwarding through it.
Resolution:
# Option A: allow forwarding in general (simpler)
$ sudo cp /etc/default/ufw /etc/default/ufw.bak-$(date +%F)
$ sudo sed -i 's/^DEFAULT_FORWARD_POLICY=.*/DEFAULT_FORWARD_POLICY="ACCEPT"/' \
/etc/default/ufw
$ sudo diff -u /etc/default/ufw.bak-$(date +%F) /etc/default/ufw
-DEFAULT_FORWARD_POLICY="DROP"
+DEFAULT_FORWARD_POLICY="ACCEPT"
$ sudo ufw reload
# Option B (preferable): forward ONLY what is needed, keeping DROP
$ sudo ufw route allow in on wg0 out on enp0s3 to 10.0.2.20 port 8404 proto tcp
$ sudo ufw route allow in on wg0 out on enp0s3 to 10.0.2.15 port 5432 proto tcp
$ sudo ufw route allow in on wg0 out on enp0s3 to 10.0.2.0/24 port 22 proto tcp
$ sudo ufw reload
$ sudo ufw status | grep ROUTE
Anywhere on enp0s3 ALLOW FWD Anywhere on wg0
10.0.2.20 8404/tcp ALLOW FWD Anywhere on wg0Option B is the correct one and it deserves defending: it keeps the allowlist policy from 06-03, where forwarding is still denied by default and only what is needed is explicitly permitted. Option A opens up the forwarding of anything to anywhere through the VPN server, which is precisely the kind of broad permission that the threat model in 06-06 advises against.
Verification:
# From Marta's laptop
$ curl -sI http://10.0.2.20:8404/ | head -1
HTTP/1.1 200 OK
# And from the server, there is now traffic in both directions
$ sudo wg show wg0 | grep -A3 'cD5eF6gH7iJ8' | tail -1
transfer: 128.42 KiB received, 891.20 KiB sentThe decision tree in summary, which goes into the runbook for next time:
| Symptom | What it rules out | Next step |
|---|---|---|
| No handshake | Nothing yet | The network, the UDP port, the keys, the Endpoint |
A handshake, 0 B sent |
The network and the cryptography are correct | Routing or the server's firewall |
| A handshake, symmetric traffic, still not working | The tunnel is completely healthy | The destination service, or DNS |
| It works, but hangs on large transfers | Everything above | MTU |
And the three lessons from this incident:
wg showanswers the most important question in one second: if there is a handshake, the problem is not the VPN. It is the first command, always.- The
received/sentasymmetry is a very powerful indicator that hardly anybody looks at: it tells you whether the failure is on the way out or on the way back. - A firewall with forwarding denied is the number one cause of "the VPN connects but I cannot reach anything", and it does not show up obviously in
ufw status. The kernel'sUFW BLOCKmessages give it away in ten seconds.
Solution 2
Runbook: Loss or theft of a device with the VPN
Document: RB-SEC-04 · Version: 1.0 · Date: 2026-08-18 Severity: High · Target containment time: 15 minutes from the report Location: the operations file and
~/tramontana-infra/docs/. A printed copy: the response may have to be given from a phone, without access to the server.0. Initial assessment (2 min)
Three questions, and you do not wait for the answers before starting step 1:
Question Why it matters Does the device have a screen lock and encryption? It determines whether the key is accessible Did it have other credentials saved (email, pass, SSH)?It widens the scope far beyond the VPN Lost or stolen? A targeted theft implies an adversary with intent The worst case is assumed until proven otherwise. Withdrawing the access of a device that later turns up costs five minutes of reconfiguration; not withdrawing it can cost the database.
1. Immediate containment (5 min) — do this BEFORE investigating
# 1.1 Identify the device's public key $ grep -B3 '10.8.0.4/32' /etc/wireguard/wg0.conf # --- marta-mobile (added: 2026-08-18) --- [Peer] PublicKey = eF7gH8iJ9kL0mN1oP2qR3sT4uV5wX6yZ7aB8cD9eF0g= # 1.2 REMOVE THE PEER LIVE. Immediate effect, without cutting off anybody else. $ sudo wg set wg0 peer 'eF7gH8iJ9kL0mN1oP2qR3sT4uV5wX6yZ7aB8cD9eF0g=' remove # 1.3 Confirm that it has gone $ sudo wg show wg0 peers | grep -c 'eF7gH8iJ9k' 0 # 1.4 Block the IP as well, just in case (defence in depth) $ sudo ufw insert 1 deny from 10.8.0.4 comment 'INCIDENT 2026-08-18'From 1.2 onwards, that device is indistinguishable from a stranger on the internet. There is no revocation list to propagate and no expiry window: WireGuard discards packets from keys it does not know without replying.
2. Making the change persistent (3 min)
# Without this, the peer comes back when the interface or the server restarts $ sudo cp /etc/wireguard/wg0.conf /etc/wireguard/wg0.conf.bak-$(date +%F) $ sudo sed -i '/--- marta-mobile/,/^$/d' /etc/wireguard/wg0.conf $ sudo diff -u /etc/wireguard/wg0.conf.bak-$(date +%F) /etc/wireguard/wg0.conf $ sudo shred -u /etc/wireguard/clients/marta-mobile.confAnd in the Ansible repository, because if it is not removed from there, the next
ansible-playbookadds it back:$ cd ~/tramontana-infra $ vim roles/vpn/defaults/main.yml # remove the marta-mobile entry $ ansible-vault edit group_vars/all/vault.yml # remove its keys $ git commit -am "Remove marta-mobile: device lost 2026-08-18" $ ansible-playbook site.yml --limit srv-tramontana --tags vpn --check --diff3. Scope assessment (15 min)
# 3.1 Was there any activity after the moment of the loss? $ sudo journalctl -t wireguard --since "2026-08-18 09:00" | tail -20 # 3.2 Database accesses from its VPN IP $ sudo journalctl -u postgresql@16-main --since "2026-08-18 09:00" | \\ grep '10.8.0.4' # 3.3 Accesses to the statistics panel $ journalctl -t nginx_access --since "2026-08-18 09:00" | grep '10.8.0.4' # 3.4 SSH sessions $ sudo last -i | grep '10.8.0.4' $ sudo journalctl -u ssh --since "2026-08-18 09:00" | grep 'Accepted'
Finding Conclusion Additional action No activity after the loss Containment in time None Activity consistent with Marta's normal use Probably her Confirm with her Anomalous activity (odd hours, bulk queries) Compromise likely Escalate to step 6 4. Other credentials on the device (20 min)
The VPN is almost never the only thing on a phone. Everything that was there is reviewed and rotated:
Credential Action Owner Corporate email Close remote sessions and change the password Marta SSH key (if it had one) Remove from authorized_keyson every hostOperations PostgreSQL password Rotate in passand withALTER ROLEOperations Tramontana Bookings sessions Invalidate all of them for that account Luis Password manager Change the master password Marta Second factor (TOTP) Regenerate and review the backup codes Marta # Example: rotating the database password $ pass generate -f tramontana/db 32 $ sudo -u postgres psql -c "ALTER ROLE operator PASSWORD '$(pass tramontana/db)';"5. Restoring the service (10 min)
# When Marta has a new device: a NEW key pair, a NEW IP. # Neither the key nor the address is reused: reusing the IP would # confuse the incident logs with the legitimate activity that comes # afterwards. $ sudo ~/scripts/vpn_add_client.sh marta-mobile2 [..] adding 'marta-mobile2' with IP 10.8.0.7The QR code is scanned in person or on a video call with Marta showing the new phone's screen. Never over messaging.
6. Escalation
A full security incident is declared — and the procedure from 06-04 is followed — if any of these conditions holds:
- There is activity from the device's IP after the moment of the loss.
- The device had no screen lock or encryption.
- It contained SSH keys or administrative credentials.
- It was a targeted theft, not a mislaid device.
7. Prevention: what changes for next time
This is the part that turns an incident into an improvement, and the part almost every procedure omits.
Measure Status Justification Mandatory encryption and screen lock on every device with the VPN Pending: a policy to be written Without encryption, the private key is readable by extracting the memory Record who has which device and with what access Pending: an inventory Today you have to search wg0.confto find outLeast privilege per peer: the phone does not need PostgreSQL Apply now It reduces the scope of the next loss An automatic alert if a new or inactive peer connects Pending (08-06) Detection, not just prevention A quarterly review of peers and removals Apply now Peers accumulate and nobody removes them An annual rehearsal of this runbook Apply now An unrehearsed procedure is an assumption # Least privilege for mobile devices, applied today $ sudo ufw deny from 10.8.0.7 to any port 5432 proto tcp \\ comment 'mobile: no DB access' $ sudo ufw deny from 10.8.0.7 to any port 22 proto tcp \\ comment 'mobile: no SSH'8. Record
It is written up in the duty log (08-06): the date and time of the report, the time of containment, the scope assessed, the credentials rotated and the preventive measures agreed. No culprits: losing a phone is not a misdemeanour, and treating it as one guarantees that next time it will take two days for somebody to say anything — which is the only thing that would turn this incident into a disaster.
Solution 3
About the VPN: what we have gained and what does not change To: Marta Vidal · From: Systems Operations · 18 August 2026
Short answer: we have gained quite a lot, and no, we cannot simplify anything. In fact, the VPN works precisely because the rest of the measures are still in place.
What we have built. An encrypted tunnel that lets your laptop and your phone behave, from anywhere in the world, as if they were plugged into the office network. You need a digital key that is unique to your device; without it, the server does not even reply: to anybody scanning it from the internet, that entry point is indistinguishable from one that does not exist.
What we have gained, specifically:
Before Now To see the reports from home you had to ask me You get in directly with your own tools The database was unreachable from outside Reachable only through the tunnel The status panel, the same The same On a hotel's Wi-Fi, the internal traffic would travel in the clear It travels encrypted end to end Any new service you wanted to use from outside had to be published It is already accessible, without publishing anything The important part: we have closed doors, not opened them. Before, for you to be able to work from outside, the only option would have been to publish the database on the internet. With the VPN we have done the opposite: there is less exposed than yesterday, and even so you can reach more.
Now to the question you asked me, and the answer is no. I understand why you are asking it: if the network is protected, relaxing what is inside seems reasonable. It is a very widespread line of reasoning and it is the cause of a significant share of the security breaches that make the news.
The reason, with a concrete example. Imagine your laptop gets infected with a malicious program — through an email, a website, a USB stick. That program is inside your laptop, and your laptop is inside the tunnel. For it, the VPN is not an obstacle: it is a direct motorway to the bookings database. The VPN checks that the device is yours; it does not check what the device is doing.
That is why everything else is still necessary:
Measure Can it be relaxed? Why The database password No It is the only thing that stops a malicious program already inside the tunnel Encryption of the internal connections No The tunnel ends at the server; from there on it does not protect Blocking repeated login attempts No Somebody can arrive by other routes Backups No The VPN does not protect against mistakes or deletions Security updates No A flaw in a program is exploited just the same from inside Logging who accesses what No, quite the opposite There are now more ways in: we have to watch more The right way to see it, and it is how the industry works today: there is no "safe inside" and "dangerous outside". Every service checks who you are and encrypts its own traffic, whoever is on the other end. The VPN is one more layer, not a wall that lets you drop your guard behind it.
What the VPN has let us do, and this is a real simplification: restrict things person by person. Because each device has a fixed address guaranteed by the system itself — it cannot be forged — I have been able to let you reach the database while Luis, who is a developer and does not need it in production, cannot. That was not reliably possible before. It is less access, better distributed.
Two things I need from you:
- That the phone and the laptop have the screen lock and encryption enabled. The digital key lives on the device; if somebody switches it on and gets in without a password, they have the key. It is the only measure that depends on you and it is the most important of all.
- That you tell me immediately if you lose a device, at any hour. Withdrawing its access takes me two minutes and it is instantaneous: there are no windows and no waiting. Losing a phone is not a problem; taking a day to say so is. I have the procedure written down and rehearsed.
And one more thing, for the record. I have taken the opportunity to close the database and the status panel to the outside; until now they depended on being physically on the office network. I have left the usual administrative access open, and not by oversight: if the VPN failed after an update, without that second route I would have to travel physically to the server to fix it. I will close it when we have a tested alternative, not before. It is the rule I always apply: you never close the door you are coming in through.
Conclusion
The VPN is up and it has paid for itself immediately: PostgreSQL and the statistics panel are closed to the outside and, at the same time, accessible from anywhere. The server exposes one more UDP port than yesterday, and that port does not answer anybody without a key, which makes it indistinguishable from a closed one to any scanner on the internet. It is the trade you were after: less exposed surface and more reach.
You understand WireGuard from the inside, which was the real objective. You know that it is a network interface and not a daemon, and that the tools from 06-01 and the rules from 06-03 therefore apply as they are. You know there is no server and no client in the protocol, only peers with public keys. And above all you understand AllowedIPs, which is the concept that separates somebody who has copied a configuration from somebody who knows what they are doing: when sending it is a routing table, when receiving it is an anti-spoofing filter, and that is why on the server each client carries its /32 and 0.0.0.0/0 only appears on the client side. That dual role is what makes each person's VPN IP a reliable identifier, and what has allowed you to give database access to some devices and deny it to others.
You have decided on the split tunnel with arguments — bandwidth, people's privacy and the real objective, which is reaching the internal services — instead of out of habit. You have verified the encryption by capturing the same packets on both interfaces: opaque on enp0s3, readable on wg0. You have automated adding clients with wg syncconf, which applies changes without cutting anybody off, and with a creates: in Ansible that prevents the disaster of regenerating the server key. And you have not closed SSH, because a second, tested access route comes before elegance.
And you take away the sentence that matters most in the whole lesson: a VPN does not turn an internal network into a secure network. It moves the point of trust, it reduces the exposure and it gives a reliable identifier per device. It does not protect against an infected laptop, or a weak password, or lateral movement once inside. Everything you built in Module 6 is exactly as necessary today as it was yesterday.
In 08-05 comes the biggest project of the module and the one that demands the most honesty: a Kubernetes cluster. The lesson is going to tell you from the first paragraph that for Tramontana it is disproportionate, and it is going to explain exactly why, with numbers. And even so you are going to do the whole of it, because it is the industry's dominant technology and because you are going to run into it at work. You will set up k3s on srv-tramontana-test, you will understand the control plane piece by piece, you will deploy Tramontana Bookings with Deployments, Services, Ingress, ConfigMaps and Secrets — and you will see that a Secret is base64, not encryption, which links directly back to 06-05 — and you will learn to read a CrashLoopBackOff instead of fearing it. With an honest closing note on the real operational complexity of keeping a cluster alive.
Linux Course: From Beginner to System Administrator
Module 1: Introduction to Linux
- What Is Linux?
- History of Linux
- Linux Distributions
- Installing Linux
- First Contact with the System
- The Linux File System Structure
Module 2: Basic Linux Commands
- Introduction to the Command Line
- Getting Help and System Documentation
- Navigating the File System
- File and Directory Operations
- Viewing and Editing Files
- Hard and Symbolic Links
- File Permissions and Ownership
Module 3: Advanced Command-Line Skills
- The Shell Environment: Variables, Aliases and History
- Using Wildcards and Regular Expressions
- Searching Files and Content: find, locate and grep
- Pipes and Redirection
- Text Processing: cut, sort, uniq, sed and awk
- Process Management
- Scheduling Tasks with Cron
- Networking Commands
Module 4: Shell Scripting
- Introduction to Shell Scripting
- Variables and Data Types
- Script Input, Output and Arguments
- Control Structures
- Functions and Libraries
- Debugging and Error Handling
- Production Scripts: Best Practices
Module 5: System Administration
- User and Group Management
- sudo and Special Permissions
- Package Management
- Disk Management
- systemd and Service Management
- System Logs: journald and syslog
- System Monitoring and Performance Tuning
- Backup and Restore
Module 6: Networking and Security
- Network Configuration
- SSH and Remote Access
- Firewalls and Perimeter Security
- Intrusion Detection Systems
- Secrets Management and TLS Certificates
- Securing Linux Systems
Module 7: Advanced Topics
- The Boot Process and System Recovery
- Advanced Diagnostics: strace, perf and eBPF
- Linux Kernel Tuning
- Virtualization with Linux
- Linux Containers and Docker
- Automation with Ansible
- High Availability and Load Balancing
