{"id":3002,"date":"2026-11-12T14:57:50","date_gmt":"2026-11-12T14:57:50","guid":{"rendered":"https:\/\/hostry.com\/blog\/?p=3002"},"modified":"2026-11-12T14:57:51","modified_gmt":"2026-11-12T14:57:51","slug":"ssh-over-wireguard-ubuntu-24-04","status":"publish","type":"post","link":"https:\/\/hostry.com\/blog\/ssh-over-wireguard-ubuntu-24-04\/","title":{"rendered":"SSH Over WireGuard on Ubuntu 24.04: Close Port 22 Without a Lockout"},"content":{"rendered":"\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"1200\" height=\"675\" src=\"https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/ssh-over-wireguard-ubuntu-24-04-cover.webp\" alt=\"SSH over WireGuard on Ubuntu 24.04: a server with its front door bricked up and a lit tunnel running from a laptop to a side entrance\" class=\"wp-image-2992\" srcset=\"https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/ssh-over-wireguard-ubuntu-24-04-cover.webp 1200w, https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/ssh-over-wireguard-ubuntu-24-04-cover-300x169.webp 300w, https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/ssh-over-wireguard-ubuntu-24-04-cover-1024x576.webp 1024w, https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/ssh-over-wireguard-ubuntu-24-04-cover-450x253.webp 450w, https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/ssh-over-wireguard-ubuntu-24-04-cover-768x432.webp 768w\" sizes=\"auto, (max-width: 1200px) 100vw, 1200px\" \/><\/figure>\n\n\n\n<pre class=\"wp-block-preformatted\" style=\"font-size:14px\"><em><strong>SSH over WireGuard on Ubuntu 24.04 keeps port 22 inside the tunnel with three server changes: a wg0 tunnel with the server at 10.91.0.1, ListenAddress 10.91.0.1 in \/etc\/ssh\/sshd_config.d\/ applied by reloading systemd and restarting ssh.socket, and a UFW rule for TCP 22 on wg0 only. Arm a rollback timer before the last two. With socket activation turned off, a config that passes sshd -t still locks you out at the next boot.<\/strong><\/em><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">On a <strong><a href=\"https:\/\/hostry.com\/products\/vps\/\">Hostry VPS<\/a><\/strong> you have root and a VNC console in the control panel. Key-only logins, a sudo user and an active UFW with OpenSSH allowed are assumed from the start; the <strong><a href=\"https:\/\/hostry.com\/blog\/secure-new-linux-vps-first-30-minutes\/\">hardening pass for a fresh Linux server<\/a><\/strong> sets those up.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The target layout for SSH over WireGuard<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">With SSH over WireGuard in place, the server&#8217;s public address 203.0.113.10 exposes one port for administration, UDP 51820 for WireGuard, and sshd answers only on 10.91.0.1 inside the tunnel. These are documentation addresses; substitute your own subnet, IP and user name (<code>ops<\/code> below).<\/p>\n\n\n\n<ul class=\"wp-block-list\"><li>The admin laptop, 10.91.0.2 in the tunnel, reaches 10.91.0.1:22 through wg0.<\/li><li>A connection to 203.0.113.10:22 from anywhere else gets no answer, because UFW drops the SYN and sshd does not listen on that address either.<\/li><\/ul>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1200\" height=\"800\" src=\"https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/ssh-over-wireguard-ubuntu-24-04-layout.webp\" alt=\"Diagram of SSH over WireGuard: the admin laptop reaches the VPS over UDP 51820 and SSH to 10.91.0.1 inside the tunnel, while TCP 22 from the internet is blocked\" class=\"wp-image-2998\" srcset=\"https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/ssh-over-wireguard-ubuntu-24-04-layout.webp 1200w, https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/ssh-over-wireguard-ubuntu-24-04-layout-300x200.webp 300w, https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/ssh-over-wireguard-ubuntu-24-04-layout-1024x683.webp 1024w, https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/ssh-over-wireguard-ubuntu-24-04-layout-450x300.webp 450w, https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/ssh-over-wireguard-ubuntu-24-04-layout-768x512.webp 768w\" sizes=\"auto, (max-width: 1200px) 100vw, 1200px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">You need:<\/p>\n\n\n\n<ul class=\"wp-block-list\"><li>Ubuntu 24.04 with openssh-server 1:9.6p1-3ubuntu13.20, the noble-updates version at the time of writing (<code>dpkg-query -W openssh-server<\/code> shows yours).<\/li><li>sshd in its default socket-activated mode: <code>systemctl is-enabled ssh.socket ssh.service<\/code> prints <code>enabled<\/code> and then <code>disabled<\/code>. The cut-over and the rollback script work in this mode only; switch back first (the classic ssh.service section ends with the commands).<\/li><li>A Linux laptop with wireguard-tools, since the laptop commands use wg-quick.<\/li><li>A console login that works: before you arm the timer, sign in on the VNC console with a password account (SSH keys are no help there) and keep it open through the cut-over and the reboot. If that login fails, stop here.<\/li><\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Command output was reproduced with OpenSSH 9.6p1, wireguard-tools 1.0.20210914 and ufw 0.36.2, and boot behavior in a QEMU virtual machine running Ubuntu 24.04 (kernel 6.8.0-149, systemd 255.4-1ubuntu8.18). Boot order on the official cloud image and on a provider&#8217;s network is not covered.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">WireGuard AllowedIPs for one admin laptop<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This tunnel links one laptop to one server, so the AllowedIPs entry on each side holds a single \/32. Generate a key pair on each machine first.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># on the server and on the laptop (Ubuntu or Debian)\nsudo apt install wireguard-tools\nsudo -i\ncd \/etc\/wireguard\numask 077\nwg genkey | tee private.key | wg pubkey &gt; public.key<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">In that root shell, write <span class=\"highlighted\">\/etc\/wireguard\/wg0.conf<\/span> with your editor; the umask keeps the file readable by root only. Each <code>[Peer]<\/code> takes the other machine&#8217;s public key, which <code>cat \/etc\/wireguard\/public.key<\/code> prints on that machine.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># server: \/etc\/wireguard\/wg0.conf\n[Interface]\nAddress = 10.91.0.1\/24\nListenPort = 51820\nPrivateKey = &lt;server private.key&gt;\n\n[Peer]\n# admin laptop\nPublicKey = &lt;laptop public.key&gt;\nAllowedIPs = 10.91.0.2\/32<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code># laptop: \/etc\/wireguard\/wg0.conf\n[Interface]\nAddress = 10.91.0.2\/32\nPrivateKey = &lt;laptop private.key&gt;\n\n[Peer]\n# VPS\nPublicKey = &lt;server public.key&gt;\nEndpoint = 203.0.113.10:51820\nAllowedIPs = 10.91.0.1\/32\nPersistentKeepalive = 25<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">If you also use the firewall in the Hostry control panel, open UDP 51820 there too before the first ping.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># server, root shell\nufw allow 51820\/udp\nsystemctl enable --now wg-quick@wg0\n\n# laptop, root shell\nwg-quick up wg0\nping -c3 10.91.0.1<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A working tunnel shows up on the server in <code>wg show<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>[...]\npeer: henDvIbd5kGR25E+q82RUlhOgBeHjKHtKUcG6GR8EAk=\n  endpoint: 203.0.113.20:40619\n  allowed ips: 10.91.0.2\/32\n  latest handshake: 10 seconds ago\n  transfer: 564 B received, 476 B sent<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">On the laptop, WireGuard AllowedIPs doubles as the route list. With 10.91.0.1\/32, wg-quick sends only the server&#8217;s tunnel address into wg0, so traffic to 203.0.113.10 keeps using the normal link; a \/24 would claim the whole subnet:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>## AllowedIPs = 10.91.0.0\/24\n[#] ip -4 route add 10.91.0.0\/24 dev wg0\n## AllowedIPs = 10.91.0.1\/32\n[#] ip -4 route add 10.91.0.1\/32 dev wg0<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The server needs no NAT and no <code>net.ipv4.ip_forward=1<\/code>. 10.91.0.1 is a local address, so packets for it are delivered locally, and SSH over the tunnel worked with <code>ip_forward = 0<\/code> in both the container and the VM.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Leave out two lines from VPN-router guides. <code>MTU = 1420<\/code> pins a value that wg-quick computes anyway as the underlay MTU minus 80, which gave 1420 on a 1500-byte link and 1320 when the laptop&#8217;s link was set to 1400 (<strong><a href=\"https:\/\/www.man7.org\/linux\/man-pages\/man8\/wg-quick.8.html\" target=\"_blank\" rel=\"noopener\">wg-quick(8)<\/a><\/strong> describes the automatic choice).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>SaveConfig = true<\/code> makes every <code>wg-quick down<\/code>, and so every reboot, rewrite wg0.conf from the live interface: a second peer appended to the file while wg0 was up was gone after the next down, along with the comments.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If a second admin joins later, append a <code>[Peer]<\/code> with 10.91.0.3\/32 and run <code>sudo systemctl reload wg-quick@wg0<\/code>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Bind sshd to the WireGuard address with ListenAddress<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">In the default socket mode on Ubuntu 24.04, systemd owns the listening socket. ListenAddress takes an address, not an interface name such as wg0, so to bind sshd to the WireGuard interface, step 3 adds <code>ListenAddress 10.91.0.1<\/code> to <span class=\"highlighted\">\/etc\/ssh\/sshd_config.d\/10-wg-only.conf<\/span>. A generator shipped with openssh-server writes the matching ssh.socket drop-in from it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The generator runs at boot, on <code>systemctl daemon-reload<\/code> and on every openssh-server upgrade, whose postinst reloads systemd and restarts ssh.socket; sshd itself ignores ListenAddress in socket mode. A file created early therefore takes effect at the next boot or openssh-server upgrade, possibly with no rollback armed, so create it in the cut-over after arming the timer. After a reload or a boot, the VM showed this drop-in and merged socket:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>--- \/run\/systemd\/generator\/ssh.socket.d\/addresses.conf\n# Automatically generated by sshd-socket-generator\n\n[Socket]\nListenStream=\nListenStream=10.91.0.1:22\n# systemctl show ssh.socket -p Listen -p FreeBind\nFreeBind=yes\nListen=10.91.0.1:22 (Stream)<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The generator writes only <code>ListenStream=<\/code> lines, so the rest of the shipped ssh.socket stays in force, <span class=\"highlighted\">FreeBind=yes<\/span> included. That option lets a socket bind an address no interface carries yet, and <strong><a href=\"https:\/\/manpages.ubuntu.com\/manpages\/noble\/man5\/systemd.socket.5.html\" target=\"_blank\" rel=\"noopener\">systemd.socket(5)<\/a><\/strong> recommends it whenever a socket binds a specific IP. In four boots of the VM with this setup, ssh.socket bound 10.91.0.1:22 between 6 and 12 seconds before wg-quick added the address (emulated timing), and SSH answered afterwards with no ordering drop-in:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>[   34.631117] labvm systemd[1]: Listening on ssh.socket - OpenBSD Secure Shell server socket.\n[   40.298334] labvm systemd[1]: Starting wg-quick@wg0.service - WireGuard via wg-quick(8) for wg0...\n[   45.193455] labvm wg-quick[281]: [#] ip -4 address add 10.91.0.1\/24 dev wg0\n[   46.384665] labvm systemd[1]: Finished wg-quick@wg0.service - WireGuard via wg-quick(8) for wg0.<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\" style=\"font-size:14px\"><strong>Check the real listener<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two familiar checks mislead in socket mode. <code>sshd -T<\/code> reads the config files, so it prints <code>listenaddress 10.91.0.1:22<\/code> as soon as the file exists, even while sshd still serves 0.0.0.0:22 after a skipped reload (shown with an emulated hand-off of the stock socket). And <code>ss -tlnp | grep sshd<\/code> finds nothing until the first connection:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>LISTEN 0      4096       10.91.0.1:22        0.0.0.0:*    users:((\"systemd\",pid=1,fd=66))<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Use <code>systemctl show ssh.socket -p Listen<\/code> and <code>ss -tln<\/code>. The release-pocket 24.04 package (1:9.6p1-3ubuntu13) shipped <code>ListenStream=22<\/code> with no reload hint in sshd_config; 1:9.6p1-3ubuntu13.12 added a note naming <code>systemctl daemon-reload<\/code> and <code>systemctl restart ssh.socket<\/code> for <strong><a href=\"https:\/\/bugs.launchpad.net\/bugs\/2069041\" target=\"_blank\" rel=\"noopener\">Launchpad bug 2069041<\/a><\/strong>, whose Noble task still read In Progress on 2026-11-12. Socket activation has been the default since 22.10, per <strong><a href=\"https:\/\/discourse.ubuntu.com\/t\/sshd-now-uses-socket-based-activation-ubuntu-22-10-and-later\/30189\" target=\"_blank\" rel=\"noopener\">Ubuntu&#8217;s announcement of socket-activated sshd<\/a><\/strong>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Why classic ssh.service fails with &#8220;Cannot assign requested address&#8221;<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">With socket activation turned off, the listener depends on boot order. sshd binds 10.91.0.1 itself, and ssh.service starts after network.target while wg-quick@wg0 waits for network-online.target, so sshd runs before the address exists. It logs &#8220;Cannot assign requested address&#8221; and exits with status 255.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>[   38.162982] labvm systemd[1]: Starting ssh.service - OpenBSD Secure Shell server...\n[   44.344398] labvm sshd[266]: error: Bind to port 22 on 10.91.0.1 failed: Cannot assign requested address.\n[   44.367085] labvm sshd[266]: fatal: Cannot bind any address.\n[   44.422578] labvm systemd[1]: ssh.service: Main process exited, code=exited, status=255\/EXCEPTION\n[   45.627742] labvm systemd[1]: Starting wg-quick@wg0.service - WireGuard via wg-quick(8) for wg0...\n[   49.051546] labvm wg-quick[276]: [#] ip -4 address add 10.91.0.1\/24 dev wg0\n# systemctl show ssh.service -p Result -p ExecMainStatus -p NRestarts\nResult=exit-code\nNRestarts=0\nExecMainStatus=255<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The unit has <code>Restart=on-failure<\/code>, but also <span class=\"highlighted\">RestartPreventExitStatus=255<\/span>, so the restart counter stayed at zero and connections to both the tunnel address and the public NIC were refused. <code>sshd -t<\/code>, which the unit runs as ExecStartPre, returned 0 on the same config. Servers land in this mode through README.Debian&#8217;s procedure for leaving socket activation (mask the generator, disable ssh.socket, enable ssh.service).<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1200\" height=\"800\" src=\"https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/ssh-over-wireguard-ubuntu-24-04-boot-order.webp\" alt=\"Boot order on Ubuntu 24.04: in socket mode ssh.socket listens on 10.91.0.1:22 before wg-quick adds the address and SSH answers; with classic ssh.service the bind fails with exit 255, sshd is not restarted and SSH is refused\" class=\"wp-image-3005\" srcset=\"https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/ssh-over-wireguard-ubuntu-24-04-boot-order.webp 1200w, https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/ssh-over-wireguard-ubuntu-24-04-boot-order-300x200.webp 300w, https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/ssh-over-wireguard-ubuntu-24-04-boot-order-1024x683.webp 1024w, https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/ssh-over-wireguard-ubuntu-24-04-boot-order-450x300.webp 450w, https:\/\/hostry.com\/blog\/wp-content\/uploads\/2026\/11\/ssh-over-wireguard-ubuntu-24-04-boot-order-768x512.webp 768w\" sizes=\"auto, (max-width: 1200px) 100vw, 1200px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">In classic mode, order sshd after the tunnel with a drop-in on ssh.service:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># server, classic ssh.service only\nsudo mkdir -p \/etc\/systemd\/system\/ssh.service.d\nsudo tee \/etc\/systemd\/system\/ssh.service.d\/10-after-wg.conf &lt;&lt;'EOF'\n[Unit]\nWants=wg-quick@wg0.service\nAfter=wg-quick@wg0.service\nEOF\nsudo systemctl daemon-reload<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">With it, sshd started after wg-quick finished and logged <code>Server listening on 10.91.0.1 port 22.<\/code> <code>net.ipv4.ip_nonlocal_bind = 1<\/code> in a file under \/etc\/sysctl.d\/ also let classic sshd bind at boot, but it applies to every IPv4 socket on the host, so we would use the drop-in.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Do not put that drop-in on ssh.socket.<\/strong> The socket is ordered before <span class=\"highlighted\">sockets.target<\/span>, which basic.target pulls in, and wg-quick@wg0 starts after basic.target, so <code>After=wg-quick@wg0.service<\/code> on the socket closes a loop. At boot, systemd broke it by deleting a job:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>[   15.289465] labvm systemd[1]: basic.target: Found ordering cycle on sockets.target\/start\n[   15.289920] labvm systemd[1]: basic.target: Found dependency on ssh.socket\/start\n[   15.290376] labvm systemd[1]: basic.target: Found dependency on wg-quick@wg0.service\/start\n[   15.290495] labvm systemd[1]: basic.target: Found dependency on basic.target\/start\n[   15.290756] labvm systemd[1]: basic.target: Job sockets.target\/start deleted to break ordering cycle starting with basic.target\/start<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">SSH still answered in that boot, yet sockets.target was never reached. <code>systemd-analyze verify ssh.socket<\/code> printed nothing about the cycle, while <code>systemd-analyze verify basic.target<\/code> showed it and still exited 0, so read the target&#8217;s output whenever you change ordering.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\" style=\"font-size:14px\"><strong>Reference: back to socket mode<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">README.Debian documents only the way out. To come back, remove its generator mask and run what the package&#8217;s postinst runs when it moves older installs to socket activation; this path was not reproduced, so use the console:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># server console, classic mode only\nsudo rm \/etc\/systemd\/system-generators\/sshd-socket-generator\nsudo systemctl disable --now ssh.service\nsudo systemctl daemon-reload\nsudo systemctl unmask ssh.socket\nsudo systemctl enable --now ssh.socket<\/code><\/pre>\n\n\n\n<aside class=\"hostry-author-note\" style=\"margin:26px 0;padding:16px 20px 14px;background:#fbf7ef;border-left:3px solid #eea416;border-radius:0 10px 10px 0;font-size:15px;line-height:1.6;color:#2b2f3a\">\n<div style=\"font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#b07a06;margin:0 0 6px\">From the engineer<\/div>\n<p style=\"margin:0 0 12px\">If a server ended up in classic mode only because an old guide said so, I would move it back to socket mode before binding SSH to a VPN address. Socket mode is Ubuntu&#8217;s default since 22.10, and its FreeBind=yes lets the listener come up before wg0 does.<\/p>\n<div style=\"display:flex;align-items:center;gap:10px;font-size:13px;color:#5b6170\"><span style=\"display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:50%;background:#11235e;color:#fff;font-size:12px;font-weight:700;flex:none\">SY<\/span><span><strong style=\"color:#11235e\">Stas Yantselovskiy<\/strong> \u00b7 System administrator &amp; DevOps engineer, Hostry<\/span><\/div>\n<\/aside>\n\n\n\n<h2 class=\"wp-block-heading\">Arm a rollback, then move the listener<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Before the reload that restricts SSH to the VPN, schedule a job that undoes the cut-over in ten minutes. Keep two sessions open: one over the tunnel (<code>ssh ops@10.91.0.1<\/code> as your normal user, which the OpenSSH rule already allows) for the commands, and one over the public address. The first login to 10.91.0.1 asks to confirm a host key; it is the key 203.0.113.10 already shows, so compare the fingerprints. Read steps 3 to 6 once before you arm the timer.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\" style=\"font-size:14px\"><strong>1. Write the rollback script (server)<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Save this as <span class=\"highlighted\">\/usr\/local\/sbin\/rollback-ssh.sh<\/span>, then run <code>sudo chmod 700 \/usr\/local\/sbin\/rollback-ssh.sh<\/code>.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>#!\/bin\/sh\n# Undo the SSH cut-over: sshd back on all addresses, port 22 open again.\nrm -f \/etc\/ssh\/sshd_config.d\/10-wg-only.conf\nsystemctl daemon-reload\nsystemctl restart ssh.socket\nufw allow OpenSSH<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\" style=\"font-size:14px\"><strong>2. Arm it with systemd-run (server)<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>$ sudo systemd-run --on-active=10min --unit=ssh-rollback \/usr\/local\/sbin\/rollback-ssh.sh\nRunning timer as unit: ssh-rollback.timer\nWill run service as unit: ssh-rollback.service\n$ systemctl list-timers ssh-rollback.timer --no-pager\nNEXT                        LEFT LAST PASSED UNIT               ACTIVATES\nThu 2026-11-12 10:26:02 UTC 9min -         - ssh-rollback.timer ssh-rollback.service<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>sudo systemctl stop ssh-rollback.timer<\/code> cancels it. The timer is a transient unit and does not survive a reboot.<\/p>\n\n\n\n<aside class=\"hostry-author-note\" style=\"margin:26px 0;padding:16px 20px 14px;background:#fbf7ef;border-left:3px solid #eea416;border-radius:0 10px 10px 0;font-size:15px;line-height:1.6;color:#2b2f3a\">\n<div style=\"font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#b07a06;margin:0 0 6px\">From the engineer<\/div>\n<p style=\"margin:0 0 12px\">Arm a rollback like this before every change to sshd or the firewall, not only this one. Ten minutes is enough to open a fresh session and check it; if you need longer, you are debugging, and that is better done from the console.<\/p>\n<div style=\"display:flex;align-items:center;gap:10px;font-size:13px;color:#5b6170\"><span style=\"display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:50%;background:#11235e;color:#fff;font-size:12px;font-weight:700;flex:none\">SY<\/span><span><strong style=\"color:#11235e\">Stas Yantselovskiy<\/strong> \u00b7 System administrator &amp; DevOps engineer, Hostry<\/span><\/div>\n<\/aside>\n\n\n\n<p class=\"wp-block-paragraph\" style=\"font-size:14px\"><strong>3. Apply the address (server)<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>printf 'ListenAddress 10.91.0.1\\n' | sudo tee \/etc\/ssh\/sshd_config.d\/10-wg-only.conf\nsudo systemctl daemon-reload\nsudo systemctl restart ssh.socket\nsystemctl show ssh.socket -p Listen\nsudo ss -tlnp | grep ':22 '<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>systemctl show<\/code> should print only <code>Listen=10.91.0.1:22 (Stream)<\/code>. In the VM, the same reload and restart left a single listener:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>LISTEN 0      4096       10.91.0.1:22        0.0.0.0:*    users:((\"sshd\",pid=670,fd=3),(\"systemd\",pid=1,fd=53))<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">An open SSH session survived both restarts of ssh.socket in the VM, though that session was local to the VM. Now start a fresh session from the laptop to 10.91.0.1. If it fails, run <code>sudo \/usr\/local\/sbin\/rollback-ssh.sh<\/code> in a session you still have. If the timer fires before you finish, it has already deleted 10-wg-only.conf and re-added the OpenSSH rule: arm it again and redo the steps from this one.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Close port 22 in UFW and allow SSH only from WireGuard<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Delete the public rule in the same form it was created, or port 22 stays open; add the wg0 rule first. <code>ufw delete allow 22\/tcp<\/code> answers &#8220;Could not delete non-existent rule&#8221; for a rule added as <code>ufw allow OpenSSH<\/code>, and the reverse fails too.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\" style=\"font-size:14px\"><strong>4. Swap the UFW rules (server)<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo ufw allow in on wg0 to any port 22 proto tcp\nsudo ufw delete allow OpenSSH\nsudo ufw status numbered<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code>[...]\n     To                         Action      From\n     --                         ------      ----\n[ 1] 51820\/udp                  ALLOW IN    Anywhere\n[ 2] 22\/tcp on wg0              ALLOW IN    Anywhere<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">If you are unsure of the form, delete by number: <code>sudo ufw delete<\/code> plus the number from <code>ufw status numbered<\/code>. With IPv6, each <code>(v6)<\/code> twin has its own number and needs its own delete, while deleting by form removed both on the VM (<code>Rule deleted<\/code>, <code>Rule deleted (v6)<\/code>).<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Check from outside, then reboot once<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A connection to 203.0.113.10:22 from outside the tunnel should hang until the client times out. A quick &#8220;Connection refused&#8221; means the packet reached the server and only the missing listener stopped it, so the firewall layer is not doing its part.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\" style=\"font-size:14px\"><strong>5. Probe the public port (laptop), read the drop (server)<\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The probe leaves over the normal link even with wg0 up; a phone hotspot is better still, since it gives the probe a source address outside both the tunnel and your usual network.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># laptop, as your normal user (not the root shell)\nssh -o ConnectTimeout=8 ops@203.0.113.10\nssh ops@10.91.0.1 'echo SSH_CONNECTION=$SSH_CONNECTION'\n\n# server\nsudo journalctl -k -b | grep 'UFW BLOCK' | grep 'DPT=22 '<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">In the VM, the tunnel login worked, and a probe from the host (SRC=10.0.2.2) left the line to look for, a <code>[UFW BLOCK]<\/code> entry with <code>DPT=22<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>SSH_CONNECTION=10.91.0.2 43136 10.91.0.1 22\n\nNov 12 12:18:32 labvm kernel: [UFW BLOCK] IN=ens2 OUT= SRC=10.0.2.2 DST=10.0.2.15 LEN=44 [...] PROTO=TCP SPT=33238 DPT=22 WINDOW=65535 RES=0x00 SYN URGP=0<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\" style=\"font-size:14px\"><strong>6. Cancel the timer and reboot (server)<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>sudo systemctl stop ssh-rollback.timer\nsudo reboot\n\n# after the reboot, over the tunnel\nsudo journalctl -b -o short-monotonic --no-pager -u ssh.socket -u wg-quick@wg0 -u ufw | grep -E 'Listening|Finished|Starting'\nsudo journalctl -b --no-pager | grep -iE 'ordering cycle|deleted to break'<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The second journal command should print nothing; the first should show the socket listening before the tunnel comes up, as in the VM:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>[   26.460869] labvm systemd[1]: Starting ufw.service - Uncomplicated firewall...\n[   32.406440] labvm systemd[1]: Listening on ssh.socket - OpenBSD Secure Shell server socket.\n[   36.835308] labvm systemd[1]: Finished ufw.service - Uncomplicated firewall.\n[   40.382278] labvm systemd[1]: Starting wg-quick@wg0.service - WireGuard via wg-quick(8) for wg0...\n[   42.623170] labvm systemd[1]: Finished wg-quick@wg0.service - WireGuard via wg-quick(8) for wg0.<\/code><\/pre>\n\n\n\n<aside class=\"hostry-author-note\" style=\"margin:26px 0;padding:16px 20px 14px;background:#fbf7ef;border-left:3px solid #eea416;border-radius:0 10px 10px 0;font-size:15px;line-height:1.6;color:#2b2f3a\">\n<div style=\"font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#b07a06;margin:0 0 6px\">From the engineer<\/div>\n<p style=\"margin:0 0 12px\">Treat every openssh-server update as a reason to repeat that reboot check. The generator reads sshd_config.d again on each update, so that is the moment a forgotten ListenAddress file starts to matter.<\/p>\n<div style=\"display:flex;align-items:center;gap:10px;font-size:13px;color:#5b6170\"><span style=\"display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:50%;background:#11235e;color:#fff;font-size:12px;font-weight:700;flex:none\">SY<\/span><span><strong style=\"color:#11235e\">Stas Yantselovskiy<\/strong> \u00b7 System administrator &amp; DevOps engineer, Hostry<\/span><\/div>\n<\/aside>\n\n\n\n<p class=\"wp-block-paragraph\">Repeat the reboot check after <strong><a href=\"https:\/\/hostry.com\/blog\/upgrade-ubuntu-24-04-to-26-04\/\">the 26.04 release upgrade<\/a><\/strong> as well.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Behind the UFW rules in step 4<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Do not run this transcript of the delete mismatch: its first command re-adds the public rule, and its last command removes it again.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># demonstration from the reproduction; do not run\n$ ufw allow OpenSSH; ufw delete allow 22\/tcp\nRule added\nCould not delete non-existent rule\n$ ufw status | grep -i -E \"openssh|22\/tcp \"\n22\/tcp on wg0              ALLOW       Anywhere\nOpenSSH                    ALLOW       Anywhere\n$ ufw delete allow OpenSSH\nRule deleted<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">We would keep the interface rule over the common <code>ufw allow from 10.91.0.0\/24 to any port 22 proto tcp<\/code> because of what each one matches, as <code>sudo iptables -S ufw-user-input<\/code> shows:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>-A ufw-user-input -i wg0 -p tcp -m tcp --dport 22 -j ACCEPT\n-A ufw-user-input -s 10.91.0.0\/24 -p tcp -m tcp --dport 22 -j ACCEPT<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The source rule matches a 10.91.0.x source arriving on any interface, the public one included. The wg0 rule accepts only what arrives on wg0, where WireGuard has already checked the source against the peer&#8217;s AllowedIPs: a ping sourced from 10.91.0.9 through the tunnel got 100% loss, because the server accepts only 10.91.0.2 from that peer. A forged 10.91.0.x packet on the public NIC was not sent in the reproduction.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The wg0 rule also works when the firewall loads before the interface exists: in the emulated reboot above, ufw.service finished at 36.8 s, wg-quick started at 40.4 s, and SSH over the tunnel worked afterwards.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">If you are locked out of the VPS after closing SSH<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Sign in on the VNC console with the password account, then check the mode before you undo anything: <code>systemctl is-enabled ssh.socket ssh.service<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If it prints <code>enabled<\/code> and <code>disabled<\/code> (socket mode), <code>sudo \/usr\/local\/sbin\/rollback-ssh.sh<\/code> puts sshd back on 0.0.0.0:22 and [::]:22 and re-adds the OpenSSH rule; when the timer fired it in the VM, the result was:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>LISTEN 0      4096         0.0.0.0:22        0.0.0.0:*    users:((\"sshd\",pid=593,fd=3),(\"systemd\",pid=1,fd=54))\nLISTEN 0      4096            [::]:22           [::]:*    users:((\"sshd\",pid=593,fd=4),(\"systemd\",pid=1,fd=55))\nOpenSSH                    ALLOW       Anywhere\nOpenSSH (v6)               ALLOW       Anywhere (v6)<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">If it prints <code>disabled<\/code> and <code>enabled<\/code> (classic mode) and the journal has the bind error, the rollback script does not fit: add the ssh.service drop-in and run <code>sudo systemctl start ssh.service<\/code>; with wg0 up, sshd can bind 10.91.0.1.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">With SSH back, find the cause before you try again:<\/p>\n\n\n\n<ul class=\"wp-block-list\"><li>If <code>systemctl is-active wg-quick@wg0<\/code> is not <code>active<\/code>, the tunnel never came up, and <code>journalctl -b -u wg-quick@wg0<\/code> shows which wg-quick step failed.<\/li><li>Right after a WireGuard restart on the server, the first SSH attempt in the reproduction timed out at <code>ConnectTimeout=8<\/code>; a ping to 10.91.0.1 from the laptop brought a new handshake, and the next login worked, so ping before you connect.<\/li><\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Step 6 reboots with no timer armed. If SSH stays down after it, the VNC console on your <strong><a href=\"https:\/\/hostry.com\/products\/vps\/\">Hostry VPS with Ubuntu 24.04<\/a><\/strong> is where you run the rollback script by hand.<\/p>\n<!-- AddThis Advanced Settings generic via filter on the_content --><!-- AddThis Share Buttons generic via filter on the_content -->","protected":false},"excerpt":{"rendered":"<p>On Ubuntu 24.04, ListenAddress with the default ssh.socket kept SSH reachable on the WireGuard address in four QEMU VM boots. The lockout comes from t&#8230;<\/p>\n","protected":false},"author":8,"featured_media":2993,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[15,127,112],"tags":[],"class_list":["post-3002","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-linux-server","category-security","category-vpn"],"read_time":11,"_links":{"self":[{"href":"https:\/\/hostry.com\/blog\/wp-json\/wp\/v2\/posts\/3002","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/hostry.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/hostry.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/hostry.com\/blog\/wp-json\/wp\/v2\/users\/8"}],"replies":[{"embeddable":true,"href":"https:\/\/hostry.com\/blog\/wp-json\/wp\/v2\/comments?post=3002"}],"version-history":[{"count":2,"href":"https:\/\/hostry.com\/blog\/wp-json\/wp\/v2\/posts\/3002\/revisions"}],"predecessor-version":[{"id":3010,"href":"https:\/\/hostry.com\/blog\/wp-json\/wp\/v2\/posts\/3002\/revisions\/3010"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/hostry.com\/blog\/wp-json\/wp\/v2\/media\/2993"}],"wp:attachment":[{"href":"https:\/\/hostry.com\/blog\/wp-json\/wp\/v2\/media?parent=3002"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/hostry.com\/blog\/wp-json\/wp\/v2\/categories?post=3002"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/hostry.com\/blog\/wp-json\/wp\/v2\/tags?post=3002"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}