A customer’s virtual machines do not all need public IP addresses for administrators to reach them. One Ubuntu VM can provide the VPN entry point: connect a laptop to WireGuard, then use SSH, RDP or another service directly against each VM’s private IP.
This guide follows that process from creating the private network to logging in to a separate Ubuntu VM over the VPN. It finishes with the complete Python script that automates the gateway configuration, followed by its prompts and expected output. Everything needed to save and run the script is included here.
The implementation was tested with Ubuntu Server 24.04 and a macOS WireGuard client. A successful SSH login to a second private Ubuntu VM confirmed the full path. Windows RDP is included as the equivalent service configuration, but was not part of that live test.
Summary
Build one Ubuntu VPN gateway on a customer’s private cloud LAN, connect a laptop through WireGuard, then reach private VMs directly by IP. This guide covers network creation, gateway preparation, DNS, public access, laptop setup and a real SSH test. The complete automation script and its prompts/output are included at the end; no companion document is required.
Table of contents
- What we are building
- 1. Create the private LAN and router
- 2. Create the Ubuntu VPN gateway
- 3. Give the gateway a public VPN endpoint
- 4. Understand the gateway configuration
- 5. Prepare the laptop and run the installer
- 6. Update the laptop tunnel
- 7. Create a private VM and SSH into it
- 8. Use the same VPN for Windows RDP and other services
- When a connection fails
- Before using it for a customer
- Complete automated gateway script
- Running the script: prompts and expected output
- Adding and removing devices after installation
What we are building
The laptop connects to the Ubuntu gateway’s public endpoint. The gateway forwards traffic into the customer LAN, where Linux and Windows VMs keep their private addresses.
There are two separate networks:
| Component | Example address or name |
|---|---|
| Customer private network |
CUSTOMER-LAN, 10.50.10.0/24
|
| SDDC router’s LAN address | 10.50.10.1 |
| Ubuntu VPN gateway’s LAN address | 10.50.10.10 |
| VPN gateway’s public endpoint | vpn.example.com:51820 |
| WireGuard tunnel network | 10.200.50.0/24 |
| Gateway’s tunnel address | 10.200.50.1 |
| First laptop’s tunnel address | 10.200.50.2 |
| Private Linux VM | 10.50.10.20 |
| Private Windows VM | 10.50.10.30 |
Replace these examples with your own addresses. vpn.example.com is a placeholder for a real hostname or public IP. Choose LAN and VPN ranges that do not overlap with each other or with networks already used by the laptop.
Only one private LAN needs to be created in the cloud platform. The WireGuard tunnel network is configured inside WireGuard; it does not need another SDDC virtual network.
This design grants access to the configured customer LAN. VMs on other subnets require additional routing and policy changes. The gateway does not act as an Internet exit for the laptop.
1. Create the private LAN and router
In the cloud or SDDC portal, create a private virtual network named CUSTOMER-LAN with these settings:
| Setting | Example value |
|---|---|
| IPv4 subnet | 10.50.10.0/24 |
| Gateway | 10.50.10.1 |
| DHCP | Enabled |
| DNS | Your provider’s or customer’s DNS servers |
Supply DNS through DHCP where possible so new VMs receive working resolver settings. If the customer uses Active Directory, use their internal DNS servers. Otherwise, permitted public resolvers such as 1.1.1.1 and 8.8.8.8 can be used.
Next, create a virtual router. Select the provider’s external network as its external gateway, enable outbound SNAT if the platform requires it, and attach CUSTOMER-LAN as an internal interface. That interface should use 10.50.10.1.
The virtual router provides the LAN’s normal external connectivity. The Ubuntu VPN VM will have its own address, 10.50.10.10. These are different devices and must not share an IP.
Keep 10.50.10.1 as the normal default gateway on workload VMs. The VPN design below does not require changing their default gateway to the Ubuntu VM.
2. Create the Ubuntu VPN gateway
Create a dedicated VM with the following starting configuration:
| Setting | Value |
|---|---|
| Name | VPN-GW01 |
| OS | Ubuntu Server 24.04 LTS |
| CPU | 2 vCPUs |
| RAM | 2–4 GB |
| Disk | 20–30 GB |
| Network | CUSTOMER-LAN |
| Private IP |
10.50.10.10, reserved or fixed on the VM’s cloud port |
Enable OpenSSH during installation, or use the cloud console to install it. Keep console access available while configuring networking. Use a fresh gateway: the automation stops if it detects active UFW, firewalld, Docker or certain existing firewall rules that need manual integration.
On the gateway, check its address, route, DNS and Python:
ip -4 -brief address
ip -4 route
resolvectl status
python3 --version
The LAN interface should have the reserved private IP and a default route through 10.50.10.1. The interface name in this example is enp1s0; use the actual name shown on your VM.
If DNS is missing
During the build, package downloads failed with Temporary failure resolving archive.ubuntu.com. The VM could reach an external IP, but had no working DNS servers.
For an immediate repair on the gateway:
sudo resolvectl dns enp1s0 1.1.1.1 8.8.8.8
sudo resolvectl domain enp1s0 '~.'
sudo resolvectl default-route enp1s0 yes
resolvectl query archive.ubuntu.com
These settings are temporary. For a VM whose existing Netplan entry is enp1s0 with dhcp4: true, make DNS persistent using an overlay:
sudo netplan get
sudo nano /etc/netplan/99-vpn-dns.yaml
Add:
network:
version: 2
ethernets:
enp1s0:
dhcp4: true
dhcp4-overrides:
use-dns: false
nameservers:
addresses:
- 1.1.1.1
- 8.8.8.8
If the interface name or existing network configuration differs, update the matching entry instead. Keep the existing addressing and routes.
sudo chmod 600 /etc/netplan/99-vpn-dns.yaml
sudo netplan generate
sudo netplan try --timeout 120
Press Enter to accept while connectivity is working. Keep the console open and verify any rollback if the trial fails. Editing /etc/resolv.conf directly may restore DNS temporarily, but the system can overwrite it later.
Verify package access:
resolvectl query archive.ubuntu.com
sudo apt update
Python 3 is normally already present on Ubuntu Server. If it is missing:
sudo apt install -y python3
3. Give the gateway a public VPN endpoint
Assign a floating public IP to the Ubuntu gateway’s private address, 10.50.10.10. If the platform uses an edge firewall instead, forward UDP 51820 from a public address to 10.50.10.10:51820.
If using a hostname, create its DNS record pointing to that public address. Leave the guest NIC configured with its private IP; the cloud platform normally handles the floating-IP translation.
Apply the following security-group or edge-firewall rules:
| Location | Rule |
|---|---|
| VPN gateway inbound | Allow UDP 51820 from the intended clients’ public IPs, or any source for roaming clients |
| VPN gateway management | Allow TCP 22 only from approved administration sources |
| VPN gateway outbound | Allow required DNS, package downloads and traffic to the customer LAN |
| Linux workload inbound | Allow TCP 22 from 10.50.10.10/32
|
| Windows workload inbound | Allow TCP 3389 from 10.50.10.10/32; UDP 3389 is optional |
Check all groups attached to the VM’s actual interface. In platforms with cumulative allow rules, an unrestricted default group can undermine a restrictive additional group.
The automation configures the Ubuntu guest. The public-IP mapping and cloud security rules remain portal-side tasks.
4. Understand the gateway configuration
The script at the end of this article performs the gateway setup. Read this section to understand what it creates; you do not need to create these configuration files manually before running it.
It installs WireGuard and nftables, generates server keys, assigns 10.200.50.1/24 to wg0, registers the laptop’s public key and enables IPv4 forwarding.
A first device receives 10.200.50.2/32. On the server, its peer configuration permits that device to use only its assigned tunnel address. The laptop configuration routes the customer LAN through the gateway.
The script also configures source NAT. An SSH packet leaves the laptop with source 10.200.50.2 and destination 10.50.10.20:22. Before entering the LAN, its source becomes the gateway’s address, 10.50.10.10. The target replies to the gateway, which translates and encrypts the reply back to the laptop.
That is why workload firewall rules use the gateway’s LAN IP as their source. Individual workload login accounts still identify the user, but target network logs see the NAT address.
Forwarding is allowed for IPv4 services across the configured LAN, not only RDP. Traffic outside that LAN and forwarding between VPN clients are blocked. The script enables systemd startup for WireGuard and its firewall rules.
5. Prepare the laptop and run the installer
Install the official WireGuard client on the laptop. In the app, create an empty tunnel named CustomerVPN-Client. Copy its public key and keep its private key on the laptop.
This is the WireGuard public key, not the SSH public key used to log into a Linux VM. Do not generate another laptop key after registering it unless you also update the server.
On the Ubuntu gateway, create the script file:
nano ~/vpn-gateway.py
Paste the complete Python code at the end of this article, save the file, then run:
sudo python3 ~/vpn-gateway.py install
The installer asks for the public endpoint, LAN CIDR, tunnel CIDR, a device name and the laptop public key. A complete prompt-and-output example appears immediately after the code.
No pip packages are required. The script uses Python’s standard library and installs its OS packages with APT. Once it finishes, it prints the laptop settings, including the new server public key.
6. Update the laptop tunnel
In the laptop’s WireGuard app, edit CustomerVPN-Client. Keep its existing private key and use the address and peer settings printed by the installer:
[Interface]
PrivateKey = YOUR_EXISTING_LAPTOP_PRIVATE_KEY
Address = 10.200.50.2/32
[Peer]
PublicKey = SERVER_PUBLIC_KEY_PRINTED_BY_INSTALLER
Endpoint = vpn.example.com:51820
AllowedIPs = 10.200.50.1/32, 10.50.10.0/24
PersistentKeepalive = 25
Replace the placeholders with actual values. Do not paste a placeholder over your generated private key. If reusing a tunnel from an older gateway, update its peer public key, endpoint and AllowedIPs.
Save and activate the tunnel. The laptop now routes traffic to 10.50.10.0/24 through WireGuard, while ordinary Internet traffic continues using its normal connection.
On macOS, test the gateway tunnel address:
ping -c 4 10.200.50.1
On the Ubuntu gateway, check:
sudo wg show wg0
Look for a recent handshake and traffic in both directions. An Active badge alone does not prove the remote connection works. In the completed test, all four tunnel pings returned successfully with zero loss.
7. Create a private VM and SSH into it
Create a second Ubuntu VM on the same CUSTOMER-LAN. For this example, its address is 10.50.10.20. It does not need a floating public IP, a WireGuard installation or another laptop VPN connection.
Use its console to confirm the address and enable SSH:
# Run on the new private Ubuntu VM
hostname -I
sudo apt update
sudo apt install -y openssh-server
sudo systemctl enable --now ssh
sudo ss -lntp | grep ':22'
The cloud security group must allow TCP 22 from 10.50.10.10/32. If UFW is already active on this workload VM, add the corresponding guest rule:
sudo ufw allow from 10.50.10.10 to any port 22 proto tcp
From the Mac with WireGuard active, connect directly:
ssh appadmin@10.50.10.20
Use the account created on that VM. Verify its host-key fingerprint through the console before accepting a first connection. You can display the target fingerprint on its console with:
sudo ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
Our first attempt returned Connection refused. Installing/enabling the SSH server resolved it; the next attempt reached the host-key prompt, accepted authentication and opened the separate VM’s Ubuntu shell.
That login confirmed the complete path: laptop → WireGuard gateway → private workload VM.
New VMs on this subnet are covered by the existing 10.50.10.0/24 client route. They still need their own services, credentials and firewall rules configured.
8. Use the same VPN for Windows RDP and other services
For a Windows VM at 10.50.10.30, enable Remote Desktop, keep Network Level Authentication enabled and grant the intended account permission to sign in remotely.
Allow TCP 3389 from the VPN gateway in the Windows guest firewall and cloud security group. Example in Administrator PowerShell on the Windows workload:
New-NetFirewallRule `
-DisplayName "RDP from VPN gateway" `
-Direction Inbound `
-Action Allow `
-Protocol TCP `
-LocalPort 3389 `
-RemoteAddress 10.50.10.10 `
-Profile Any
Review existing broader RDP rules as well. With the laptop VPN active, open your RDP client and connect to 10.50.10.30. A public RDP port is not needed.
Web applications and other services follow the same pattern: connect to the private IP, with the service listening and its firewall permitting the gateway’s source. The gateway is not restricted to SSH or RDP.
When a connection fails
| Symptom | Check |
|---|---|
| No handshake | Public endpoint, floating-IP association, UDP 51820 rules and reciprocal public keys |
| Handshake works but tunnel ping fails | Laptop tunnel address, client routes and gateway firewall |
| Tunnel ping works but workload times out | Workload LAN attachment, forwarding/NAT, cloud security group and guest firewall |
SSH says Connection refused
|
SSH server installation, service/socket and listener on the workload |
SSH says Permission denied
|
Workload username, password/SSH key and login policy |
| Private IP works but hostname does not | Internal DNS server reachability and laptop DNS configuration |
For a Linux target, these are the first checks to run in its console:
sudo systemctl status ssh --no-pager
sudo ss -lntp | grep ':22'
sudo ufw status
On the gateway:
sudo customer-vpn status
sudo nft list table inet customer_vpn
sudo journalctl -u wg-quick@wg0 -u customer-vpn-firewall -n 50 --no-pager
Before using it for a customer
The successful test demonstrated the tunnel and SSH access to a separate Ubuntu VM. Test Windows RDP if required, device revocation and access after a planned gateway reboot before handover.
The script enables startup automatically. You can inspect that configuration with:
sudo systemctl is-enabled customer-vpn-firewall.service wg-quick@wg0.service
Keep the gateway IP reserved and back up its secret configuration through your approved process. /etc/customer-vpn/state.json and /etc/wireguard/wg0.conf contain the server private key and must not be published.
This implementation supports one customer IPv4 LAN and a /24 VPN pool. All enrolled devices share that LAN access. It does not configure MFA, high availability or per-user destination restrictions. Its host firewall preserves normal non-VPN access, so retain the cloud-side restrictions on public services. Re-running install preserves existing settings and peers; it does not prompt to change the stored network or endpoint.
Complete automated gateway script
Save the following code as vpn-gateway.py on a fresh Ubuntu gateway. It installs and configures WireGuard, forwarding, LAN NAT, startup services and the first device. It also installs the customer-vpn command for adding and revoking later devices.
The endpoint prompt defaults to the example vpn.example.com; enter your real endpoint when asked. The public example default is the only change from the script used in the successful deployment.
#!/usr/bin/env python3
"""Dedicated Ubuntu WireGuard gateway. sudo python3 vpn-gateway.py install
Commands: install, add-peer, revoke-peer NAME, client NAME, status, firewall.
All VPN peers may initiate any IPv4 protocol to the configured customer LAN.
Does not expose the whole SDDC or provide an Internet exit VPN.
"""
import argparse
import base64
import fcntl
import ipaddress
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
ROOT = Path('/etc/customer-vpn')
STATE = ROOT / 'state.json'
WG = Path('/etc/wireguard/wg0.conf')
SELF = Path('/usr/local/sbin/customer-vpn')
TABLE = 'customer_vpn'
def run(*args, data=None, check=True):
return subprocess.run(args, input=data, text=True, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, check=check).stdout.strip()
def put(path, text, mode=0o600):
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
fd, name = tempfile.mkstemp(dir=path.parent)
try:
os.fchmod(fd, mode)
with os.fdopen(fd, 'w') as out:
out.write(text)
out.flush()
os.fsync(out.fileno())
os.replace(name, path)
finally:
if os.path.exists(name):
os.unlink(name)
def ask(label, default=None):
value = input(label + (f' [{default}]' if default else '') + ': ').strip()
value = value or default
if not value:
raise ValueError('A value is required.')
return value
def pubkey(value):
if len(base64.b64decode(value, validate=True)) != 32:
raise ValueError('WireGuard public key must decode to 32 bytes.')
return value
def peername(value):
if not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9_-]{0,31}', value):
raise ValueError('Device name: 1–32 letters, digits, hyphens or underscores.')
return value
def endpoint(value):
try:
ipaddress.IPv4Address(value)
except ValueError:
if not re.fullmatch(r'(?=.{1,253}$)[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?', value):
raise ValueError('Enter a public IPv4 address or DNS hostname, without a port.')
return value
def validate_networks(lan, tunnel):
a, b = ipaddress.IPv4Network(lan), ipaddress.IPv4Network(tunnel)
if a.overlaps(b) or b.prefixlen != 24:
raise ValueError('VPN must be a /24 and must not overlap the customer LAN.')
return a, b
def load():
if not STATE.exists():
raise ValueError('Run install first.')
return json.loads(STATE.read_text())
def config(s):
lines = ['[Interface]', f"PrivateKey = {s['private_key']}",
f"Address = {s['gateway']}/24", f"ListenPort = {s['port']}", '']
for name, p in sorted(s['peers'].items()):
lines += [f'# {name}', '[Peer]', f"PublicKey = {p['key']}",
f"AllowedIPs = {p['ip']}/32", '']
return '\n'.join(lines) + '\n'
def rules(s):
lan, vpn, iface = s['lan'], s['vpn'], s['interface']
# Only own table is replaced. Other host input/output rules remain untouched.
return f'''table inet {TABLE} {{
chain input {{
type filter hook input priority -10; policy accept;
iifname "wg0" ip saddr {vpn} meta l4proto icmp counter accept
iifname "wg0" ip saddr {vpn} tcp dport 22 counter accept
iifname "wg0" counter drop
udp dport {s['port']} counter accept
}}
chain forward {{
type filter hook forward priority -10; policy accept;
iifname "wg0" oifname "{iface}" ip saddr {vpn} ip daddr {lan} ct state new,established,related counter accept
iifname "{iface}" oifname "wg0" ip saddr {lan} ip daddr {vpn} ct state established,related counter accept
iifname "wg0" counter drop
oifname "wg0" counter drop
}}
chain postrouting {{
type nat hook postrouting priority srcnat; policy accept;
oifname "{iface}" ip saddr {vpn} ip daddr {lan} counter masquerade
}}
}}
'''
def firewall(s):
exists = subprocess.run(['nft', 'list', 'table', 'inet', TABLE],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0
transaction = (f'delete table inet {TABLE}\n' if exists else '') + rules(s)
run('nft', '-c', '-f', '-', data=transaction)
run('nft', '-f', '-', data=transaction)
def sync():
stripped = run('wg-quick', 'strip', 'wg0')
fd, path = tempfile.mkstemp(dir=ROOT)
try:
with os.fdopen(fd, 'w') as out:
out.write(stripped + '\n')
run('wg', 'syncconf', 'wg0', path)
finally:
os.unlink(path)
def commit(s, live=True):
old_state = STATE.read_text() if STATE.exists() else None
old_wg = WG.read_text() if WG.exists() else None
if old_state:
put(ROOT / 'state.previous.json', old_state)
try:
put(WG, config(s))
if live:
sync()
put(STATE, json.dumps(s, indent=2) + '\n')
except Exception:
if old_wg is not None:
put(WG, old_wg)
if live:
sync()
if old_state is not None:
put(STATE, old_state)
raise
def allocate(s, name, key):
peername(name)
pubkey(key)
if key == s['public_key']:
raise ValueError('Use the laptop public key, not the server public key.')
for n, p in s['peers'].items():
if p['key'] == key and n != name:
raise ValueError('This key already belongs to another device.')
if name in s['peers']:
if s['peers'][name]['key'] != key:
raise ValueError('Name already exists with another key. Revoke it first.')
return
used = {p['ip'] for p in s['peers'].values()} | {s['gateway']}
for ip in ipaddress.IPv4Network(s['vpn']).hosts():
if str(ip) not in used:
s['peers'][name] = {'key': key, 'ip': str(ip)}
return
raise ValueError('VPN address pool is full.')
def client(s, name):
p = s['peers'].get(name)
if p is None:
raise ValueError('Unknown device name.')
print(f'''\nKeep your laptop's EXISTING PrivateKey line. Use the settings below.
Do not import the placeholder literally.
[Interface]
PrivateKey = KEEP_YOUR_LAPTOP_PRIVATE_KEY
Address = {p['ip']}/32
[Peer]
PublicKey = {s['public_key']}
Endpoint = {s['endpoint']}:{s['port']}
AllowedIPs = {s['gateway']}/32, {s['lan']}
PersistentKeepalive = 25
''')
def preflight():
release = Path('/etc/os-release').read_text()
if not re.search(r'^ID=ubuntu$', release, re.M) or not re.search(r'^VERSION_ID="?(22.04|24.04)"?$', release, re.M):
raise ValueError('Supported: Ubuntu Server 22.04 or 24.04 LTS.')
if shutil.which('ufw') and 'Status: active' in run('ufw', 'status'):
raise ValueError('Active UFW detected. Integrate it separately; this installer will not disable it.')
for service in ('docker', 'firewalld'):
if subprocess.run(['systemctl', 'is-active', '--quiet', service]).returncode == 0:
raise ValueError(f'Active {service} detected. Use a dedicated gateway.')
if WG.exists() and not STATE.exists():
raise ValueError('Unmanaged wg0.conf exists. Refusing to overwrite it.')
def install():
preflight()
if STATE.exists():
s = load()
print('Existing installation found. Keeping server keys and devices.')
else:
public = endpoint(ask('Floating public IPv4 or VPN hostname', 'vpn.example.com'))
lan = ask('Customer LAN CIDR', '10.50.10.0/24')
vpn = ask('VPN tunnel CIDR (must not overlap laptop networks)', '10.200.50.0/24')
lan_net, vpn_net = validate_networks(lan, vpn)
route = json.loads(run('ip', '-j', '-4', 'route', 'get', str(next(lan_net.hosts()))))[0]
iface = route['dev']
if not re.fullmatch(r'[a-zA-Z0-9_.:-]+', iface):
raise ValueError('Unsupported interface name.')
addresses = json.loads(run('ip', '-j', '-4', 'address', 'show', 'dev', iface))
local = [a['local'] for item in addresses for a in item['addr_info'] if a['family'] == 'inet']
if not any(ipaddress.IPv4Address(a) in lan_net for a in local):
raise ValueError('No address on customer LAN. Correct the VM network first.')
for item in json.loads(run('ip', '-j', '-4', 'address', 'show')):
for addr in item.get('addr_info', []):
if addr.get('family') == 'inet':
connected = ipaddress.IPv4Network(f"{addr['local']}/{addr['prefixlen']}", strict=False)
if connected.overlaps(vpn_net):
raise ValueError('VPN range overlaps an existing server network.')
name = peername(ask('First device name', 'my-laptop'))
key = pubkey(ask('Paste the LAPTOP public key (never its private key)'))
s = {'endpoint': public, 'lan': str(lan_net), 'vpn': str(vpn_net),
'gateway': str(next(vpn_net.hosts())), 'interface': iface, 'port': 51820, 'peers': {}}
print(f"LAN interface: {iface}; VM IPs: {', '.join(local)}")
env = dict(os.environ, DEBIAN_FRONTEND='noninteractive')
subprocess.run(['apt-get', 'update'], check=True, env=env)
subprocess.run(['apt-get', 'install', '-y', 'wireguard', 'nftables'], check=True, env=env)
# Refuse conflicting rule managers rather than pretending accept overrides drop.
tables = json.loads(run('nft', '-j', 'list', 'tables'))['nftables']
other = [x['table']['name'] for x in tables if 'table' in x and x['table']['name'] != TABLE]
if other:
raise ValueError('Existing nftables tables found: ' + ', '.join(other) + '. Use a clean gateway or integrate rules manually.')
if shutil.which('iptables-save'):
saved = run('iptables-save')
if re.search(r'^-A |^:\S+ DROP ', saved, re.M):
raise ValueError('Existing iptables filtering detected; manual integration required.')
run('modprobe', 'wireguard')
ROOT.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(ROOT, 0o700)
WG.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
if not STATE.exists():
s['private_key'] = run('wg', 'genkey')
s['public_key'] = run('wg', 'pubkey', data=s['private_key'] + '\n')
allocate(s, name, key)
commit(s, live=False)
source = Path(__file__).resolve()
if source != SELF:
put(SELF, source.read_text(), 0o700)
put('/etc/sysctl.d/90-customer-vpn.conf', 'net.ipv4.ip_forward = 1\n')
run('sysctl', '-p', '/etc/sysctl.d/90-customer-vpn.conf')
put('/etc/systemd/system/customer-vpn-firewall.service', '''[Unit]
Description=Customer VPN firewall (own table only)
After=network-online.target nftables.service
Wants=network-online.target
Before=wg-quick@wg0.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/customer-vpn firewall
ExecReload=/usr/local/sbin/customer-vpn firewall
[Install]
WantedBy=multi-user.target
''', 0o644)
put('/etc/systemd/system/wg-quick@wg0.service.d/customer-vpn.conf', '''[Unit]
Requires=customer-vpn-firewall.service
After=customer-vpn-firewall.service
''', 0o644)
run('systemctl', 'daemon-reload')
# The firewall subprocess takes the same lock; parent releases it beforehand.
run('systemctl', 'enable', 'customer-vpn-firewall.service', 'wg-quick@wg0.service')
firewall(s)
print('Configuration written. Starting services after releasing installer lock.')
return s
def status(s):
print('Customer LAN:', s['lan'], '| LAN interface:', s['interface'])
print('Endpoint:', f"{s['endpoint']}:{s['port']}")
print('Server public key:', s['public_key'])
for n, p in s['peers'].items():
print('Device:', n, '| VPN IP:', p['ip'])
print(run('wg', 'show', 'wg0'))
print('IPv4 forwarding:', run('sysctl', '-n', 'net.ipv4.ip_forward'))
print('Handshake and service reachability still require a connected laptop test.')
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('command', choices=['install', 'add-peer', 'revoke-peer', 'client', 'status', 'firewall'])
parser.add_argument('name', nargs='?')
args = parser.parse_args()
if os.geteuid() != 0:
raise ValueError('Run with sudo.')
os.umask(0o077)
lock = open('/run/customer-vpn.lock', 'w')
fcntl.flock(lock, fcntl.LOCK_EX)
if args.command == 'install':
s = install()
fcntl.flock(lock, fcntl.LOCK_UN)
# dependency invokes this script's firewall command, which acquires the lock
run('systemctl', 'start', 'customer-vpn-firewall.service')
run('systemctl', 'start', 'wg-quick@wg0.service')
sync()
status(s)
for name in s['peers']:
client(s, name)
return
s = load()
if args.command == 'firewall':
firewall(s)
elif args.command == 'status':
status(s)
elif args.command == 'client':
client(s, args.name or ask('Device name'))
elif args.command == 'add-peer':
name = peername(args.name or ask('Device name'))
allocate(s, name, pubkey(ask('Laptop public key')))
commit(s)
client(s, name)
elif args.command == 'revoke-peer':
name = args.name or ask('Device name to revoke')
if name not in s['peers']:
raise ValueError('Unknown device name.')
del s['peers'][name]
commit(s)
print('Revoked:', name)
if __name__ == '__main__':
try:
main()
except (ValueError, OSError, subprocess.CalledProcessError) as exc:
# Never print command stdin or state: these can contain private keys.
if isinstance(exc, subprocess.CalledProcessError):
print('ERROR:', exc.cmd[0], 'failed:', exc.stderr or 'check service logs', file=sys.stderr)
else:
print('ERROR:', exc, file=sys.stderr)
sys.exit(1)
Running the script: prompts and expected output
Run this on the Ubuntu gateway after saving the code:
sudo python3 ~/vpn-gateway.py install
The following is an illustrative transcript. Addresses and key placeholders are examples; package versions and interface names depend on the VM.
Prompt 1: the public endpoint
Floating public IPv4 or VPN hostname [vpn.example.com]: YOUR_REAL_PUBLIC_IP_OR_HOSTNAME
Enter the public address assigned to this Ubuntu VM, or a hostname that resolves to it. Do not enter the private LAN address. If the cloud platform uses port forwarding, enter the public address on that mapping.
Prompt 2: the customer LAN
Customer LAN CIDR [10.50.10.0/24]: 10.50.10.0/24
Enter the subnet containing the gateway and the customer’s workload VMs. Press Enter to accept the example only if it matches the actual LAN.
Prompt 3: the WireGuard subnet
VPN tunnel CIDR (must not overlap laptop networks) [10.200.50.0/24]:
Press Enter to use 10.200.50.0/24, or enter another non-overlapping /24. This is the tunnel address pool, not another cloud LAN to create. The script uses its first usable address for the gateway and allocates subsequent addresses to devices.
Prompt 4: the first device name
First device name [my-laptop]: admin-mac-1
Choose a recognizable inventory name. Names accept 1–32 letters, digits, hyphens and underscores, starting with a letter or digit.
Prompt 5: the laptop public key
Paste the LAPTOP public key (never its private key): YOUR_LAPTOP_WIREGUARD_PUBLIC_KEY
Paste the actual public key shown by the laptop’s WireGuard client. The placeholder above is not a valid key. The script checks the key format; the laptop must retain the matching private key.
Automatic installation
After the last answer, no additional configuration input is required. The installer detects the LAN interface, updates APT indexes and installs WireGuard/nftables. Typical progress includes:
LAN interface: enp1s0; VM IPs: 10.50.10.10
Hit:1 ... noble InRelease
Reading package lists... Done
...
Setting up wireguard-tools ...
Setting up wireguard ...
Configuration written. Starting services after releasing installer lock.
APT may also report updates, pending kernel changes or deferred service restarts. Read those messages, but distinguish them from an installer error. If a reboot is needed, schedule it after configuration and repeat the VPN/workload tests afterward.
If a preflight check stops the installation, the message identifies the issue. For example:
ERROR: Active UFW detected. Integrate it separately; this installer will not disable it.
Use a dedicated gateway or deliberately integrate existing rules. Do not remove firewall protection just to bypass the check. Installation failures can leave packages or partial configuration present, so inspect the error before retrying.
Completion output
A completed installation prints a summary like this:
Customer LAN: 10.50.10.0/24 | LAN interface: enp1s0
Endpoint: YOUR_REAL_PUBLIC_IP_OR_HOSTNAME:51820
Server public key: GENERATED_SERVER_PUBLIC_KEY
Device: admin-mac-1 | VPN IP: 10.200.50.2
interface: wg0
public key: GENERATED_SERVER_PUBLIC_KEY
private key: (hidden)
listening port: 51820
peer: YOUR_LAPTOP_WIREGUARD_PUBLIC_KEY
allowed ips: 10.200.50.2/32
IPv4 forwarding: 1
Handshake and service reachability still require a connected laptop test.
The actual output contains real public keys. The server private key is not printed. A missing handshake at this stage is normal if the laptop has not yet been updated and activated.
Laptop configuration printed by the script
The installer then prints:
[Interface]
PrivateKey = KEEP_YOUR_LAPTOP_PRIVATE_KEY
Address = 10.200.50.2/32
[Peer]
PublicKey = GENERATED_SERVER_PUBLIC_KEY
Endpoint = YOUR_REAL_PUBLIC_IP_OR_HOSTNAME:51820
AllowedIPs = 10.200.50.1/32, 10.50.10.0/24
PersistentKeepalive = 25
Apply this to the laptop tunnel, keeping its actual existing private key. KEEP_YOUR_LAPTOP_PRIVATE_KEY is an instruction placeholder, not a usable value.
After activating the client and testing traffic, sudo wg show wg0 should include a recent latest handshake, a learned client endpoint and transfer counters. The subsequent SSH login to a separate private VM confirms LAN access beyond the VPN gateway.
Running installation again
On a managed installation, the script reports:
Existing installation found. Keeping server keys and devices.
It reapplies the stored setup and prints the existing device templates. It does not regenerate server keys or ask again for the endpoint/network settings. This prevents a routine rerun from unexpectedly replacing the identity already configured on laptops.
Adding and removing devices after installation
The installer makes the management command available as customer-vpn. Run it on the Ubuntu gateway.
Add a device
sudo customer-vpn add-peer customer-laptop-2
It asks:
Laptop public key: SECOND_LAPTOP_WIREGUARD_PUBLIC_KEY
The command allocates the next unused tunnel IP and prints that device’s client template. If 10.200.50.2 is already assigned, a new device will normally receive 10.200.50.3/32. Keep the second laptop’s own private key when applying its settings.
Using an already registered name with the same key retains that peer. A duplicate key under a different name, or an existing name with a different key, produces an error instead of silently replacing another device.
Display a device’s settings again
sudo customer-vpn client customer-laptop-2
This prints the template for that device without asking for its key again. The template contains a placeholder for the private key because the gateway never needs the laptop’s private key.
Revoke a device
sudo customer-vpn revoke-peer customer-laptop-2
Expected confirmation:
Revoked: customer-laptop-2
This removes its peer from the saved and running WireGuard configuration. It is an allow-list/revocation mechanism, not a separate permanent blocklist. Test that the removed device can no longer access the LAN. Adding and revoking devices expects the gateway tunnel to be running.
Check status or reapply the gateway firewall
sudo customer-vpn status
sudo customer-vpn firewall
status prints the LAN, endpoint, registered devices, WireGuard interface and forwarding state. firewall reapplies the script-owned nftables table and normally returns without output on success. Neither command modifies the provider’s security groups or public-IP mapping.
With the gateway configured, everyday use is straightforward: activate WireGuard on the laptop, then connect directly to a workload’s private IP using SSH, RDP or its application client.
Top comments (0)