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

  1. What a VPN solves and what it does not
  2. Use cases and which one Tramontana needs
  3. WireGuard versus OpenVPN and IPsec
  4. How WireGuard works: interface, peers and cryptokey routing
  5. AllowedIPs: routing and access control at the same time
  6. Installation and key generation
  7. Server configuration
  8. Client configuration: laptop and phone
  9. Routing, NAT and split tunnelling versus full tunnelling
  10. DNS inside the tunnel
  11. Integration with the firewall and closing ports
  12. Verification
  13. Client management: adding, removing and rotating
  14. Security: what it protects and what it does not
  15. Automation with Ansible
  16. 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:

  1. 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.
  2. 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.
  3. It runs in the kernel, without copying every packet into user space. On a 2 vCPU server, the difference shows.
  4. 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.

  1. 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 3

The practical consequences are large:

  • It is configured with ip and with wg, 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 nftables rules 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 Endpoint for the others.

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

  1. 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, AllowedIPs works as a routing table: the packet is encrypted with the key of the peer whose list contains the destination IP.
  • When receiving, AllowedIPs works as an anti-spoofing filter: a packet correctly decrypted with luis's key whose source IP is not in luis's AllowedIPs is 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.

  1. 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 show tells 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 tunnel

The 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.pub

The 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/32
$ sudo chmod 0600 /etc/wireguard/wg0.conf
$ sudo chown root:root /etc/wireguard/wg0.conf

Four 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 seconds

Phone, 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.conf

An 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:

$ shred -u /tmp/marta-mobile.png

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 = 25

In 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 = 1

IPv6 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:

  1. 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.
  2. 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.
  3. 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.15

With ~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 reload

And 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/24

What 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 connected

How 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: wg0

Check 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.conf

Why 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

PresharedKey = zY9xW8vU7tS6rQ5pO4nM3lK2jI1hG0fE9dC8bA7zY6x=

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 tcp

Since 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: true

Two 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.3

WireGuard 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/0 only 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-ips

4. 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=1420
# On the client, if there is fragmentation
[Interface]
MTU = 1380

Common Mistakes and Tips

  • Putting AllowedIPs = 0.0.0.0/0 in a peer on the server. It breaks all of its connectivity. Only the client's /32 goes there.
  • Overlapping AllowedIPs between 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 PersistentKeepalive on phones. The tunnel "drops" a few minutes after going idle.
  • Using wg-quick down && up to add a client. It cuts off everybody connected, including you if you are administering over the VPN. Use wg syncconf.
  • Regenerating the server key on every Ansible run. It locks out every client at once. creates: or a stat first.
  • 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 sent

There 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 0

Three 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=8404

There 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 wg0

Option 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 sent

The 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:

  1. wg show answers the most important question in one second: if there is a handshake, the problem is not the VPN. It is the first command, always.
  2. The received / sent asymmetry 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.
  3. 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's UFW BLOCK messages 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.conf

And in the Ansible repository, because if it is not removed from there, the next ansible-playbook adds 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 --diff

3. 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_keys on every host Operations
PostgreSQL password Rotate in pass and with ALTER ROLE Operations
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.7

The 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.conf to find out
Least 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:

  1. 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.
  2. 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

Module 2: Basic Linux Commands

Module 3: Advanced Command-Line Skills

Module 4: Shell Scripting

Module 5: System Administration

Module 6: Networking and Security

Module 7: Advanced Topics

Module 8: Practical Projects

© Copyright 2026. All rights reserved