<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Daanial Farrukh</title>
    <description>The latest articles on DEV Community by Daanial Farrukh (@daanial).</description>
    <link>https://dev.to/daanial</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3966946%2F9807d012-5e5a-4dc5-8326-fc594b56bc62.png</url>
      <title>DEV Community: Daanial Farrukh</title>
      <link>https://dev.to/daanial</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/daanial"/>
    <language>en</language>
    <item>
      <title>Postfix in a Chroot Cannot See Your MySQL Socket — Why Mail Stopped Both Ways</title>
      <dc:creator>Daanial Farrukh</dc:creator>
      <pubDate>Sun, 08 Nov 2026 17:21:12 +0000</pubDate>
      <link>https://dev.to/daanial/postfix-in-a-chroot-cannot-see-your-mysql-socket-why-mail-stopped-both-ways-11pd</link>
      <guid>https://dev.to/daanial/postfix-in-a-chroot-cannot-see-your-mysql-socket-why-mail-stopped-both-ways-11pd</guid>
      <description>&lt;p&gt;&lt;em&gt;A field note about a self-hosted mail server where nothing moved in or out for weeks — the one master.cf column almost nobody reads — and the four checks I now run before touching a single password.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The first sign was not an error. It was silence.&lt;/p&gt;

&lt;p&gt;No bounce. No angry reply. No “message could not be delivered”. Just a mailbox that had stopped receiving anything, and outgoing mail that never arrived anywhere.&lt;/p&gt;

&lt;p&gt;The server was a self-hosted grommunio box on Ubuntu 24.04. Postfix in front, MySQL behind it holding the mailboxes and aliases. A normal, modern stack.&lt;/p&gt;

&lt;p&gt;I opened the queue.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;postqueue &lt;span class="nt"&gt;-p&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It was full. The oldest message was about six weeks old.&lt;/p&gt;

&lt;p&gt;And the part that changed the whole investigation: some of the stuck messages were from one mailbox on the domain to another mailbox on the same domain. Same server. No internet in the path. No remote MX, no blacklist, no DNS.&lt;/p&gt;

&lt;p&gt;If internal mail is stuck, the problem is not out there. It is inside the box.&lt;/p&gt;

&lt;p&gt;That’s the trap. A broken mail server usually shouts. This one only deferred — politely, forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Log Actually Said
&lt;/h2&gt;

&lt;p&gt;The mail log had the same pair of lines over and over:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 50 /var/log/mail.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;postfix/cleanup: warning: mysql:/etc/postfix/grommunio-virtual-mailbox-alias-maps.cf lookup error
... virtual_alias_maps map lookup problem ... message not accepted, try again later
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that second line slowly. “Try again later” is a temporary failure. Postfix did not reject the mail — it asked every sender to come back. Remote servers obeyed and retried. Local submissions sat in the queue and retried. Nobody got a bounce, because a temporary failure is not a bounce until the queue lifetime runs out.&lt;/p&gt;

&lt;p&gt;That is why it stayed quiet for weeks.&lt;/p&gt;

&lt;p&gt;The first guess almost everyone makes from this log is the database: wrong password in the &lt;code&gt;.cf&lt;/code&gt; file, MySQL down, the grommunio user lost its grant. So I tested exactly that — the same map, the same lookup, by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;postmap &lt;span class="nt"&gt;-q&lt;/span&gt; user@example.com mysql:/etc/postfix/grommunio-virtual-mailbox-alias-maps.cf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It answered. Instantly. Correct result.&lt;/p&gt;

&lt;p&gt;Here’s the part that trips people up. The lookup works from the shell and fails inside Postfix. Same file, same credentials, same database. &lt;strong&gt;When the same lookup works for you and fails for the daemon, the problem is not the lookup — it is where the daemon is standing.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Postfix Chroot Actually Is
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A chroot is a jail: the process is told that one directory is the whole filesystem, and it cannot see anything outside it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For Postfix, that directory is the queue directory, usually &lt;code&gt;/var/spool/postfix&lt;/code&gt;. When a service in &lt;code&gt;master.cf&lt;/code&gt; is chrooted, a path like &lt;code&gt;/run/mysqld/mysqld.sock&lt;/code&gt; is really read as &lt;code&gt;/var/spool/postfix/run/mysqld/mysqld.sock&lt;/code&gt;. That file does not exist. So the MySQL client cannot connect, and every &lt;code&gt;mysql:&lt;/code&gt; map lookup fails.&lt;/p&gt;

&lt;p&gt;The flow, inside the jail:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;cleanup → mysql: map → host = localhost → Unix socket /run/mysqld/mysqld.sock → not found inside /var/spool/postfix → lookup error → "try again later"&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;And the same flow from your root shell:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;postmap -q → mysql: map → host = localhost → /run/mysqld/mysqld.sock → found → answer&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;cleanup&lt;/code&gt; daemon matters more than people think. It runs on every message, inbound, outbound and local, and it is where &lt;code&gt;virtual_alias_maps&lt;/code&gt; is expanded. Break it and you break mail in every direction at once — which is exactly what the queue showed.&lt;/p&gt;

&lt;p&gt;Where this setup is common:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Debian and Ubuntu style &lt;code&gt;master.cf&lt;/code&gt; files, where many services are marked chrooted.&lt;/li&gt;
&lt;li&gt;Mail stacks that keep users and aliases in MySQL or MariaDB: grommunio, Postfix admin panels, hand-built virtual mailbox servers.&lt;/li&gt;
&lt;li&gt;Servers rebuilt or upgraded where &lt;code&gt;master.cf&lt;/code&gt; came from a package and the MySQL maps were added later.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why Nobody Sees It in Testing
&lt;/h2&gt;

&lt;p&gt;Three reasons, and they stack.&lt;/p&gt;

&lt;p&gt;First, every manual test runs outside the jail. &lt;code&gt;postmap -q&lt;/code&gt;, the &lt;code&gt;mysql&lt;/code&gt; client, a PHP admin page — all of them see &lt;code&gt;/run/mysqld/mysqld.sock&lt;/code&gt; and all of them pass. The test you trust is the one that cannot fail.&lt;/p&gt;

&lt;p&gt;Second, the failure is a deferral. No bounce reaches a user, no alert fires on a queue that is “only” growing, and senders’ servers keep retrying quietly.&lt;/p&gt;

&lt;p&gt;Third, the fix that looks obvious makes it worse. From the log, the natural move is to edit the &lt;code&gt;.cf&lt;/code&gt; map and change &lt;code&gt;hosts = localhost&lt;/code&gt; to &lt;code&gt;hosts = 127.0.0.1&lt;/code&gt; — “use TCP instead of the socket”. On this server that is a new failure, not a fix. The MySQL client treats &lt;code&gt;localhost&lt;/code&gt; as “use the Unix socket” and &lt;code&gt;127.0.0.1&lt;/code&gt; as a TCP connection, and MySQL matches accounts by host. The grommunio database user was granted on &lt;code&gt;localhost&lt;/code&gt;. Change the host and the login itself stops matching. You have swapped a jail problem for an access-denied problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Actually Fix This
&lt;/h2&gt;

&lt;p&gt;Everything up to Step 4 is read-only.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Confirm mail is deferred, not rejected.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;postqueue &lt;span class="nt"&gt;-p&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last line gives the count of queued requests and their size. Then look at the oldest entries at the top of the full list. If messages are days or weeks old and include local-to-local mail, stop looking at DNS and remote servers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: Read the exact error.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"lookup error|map lookup problem"&lt;/span&gt; /var/log/mail.log | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You are looking for &lt;code&gt;mysql:&lt;/code&gt; named in the warning and &lt;code&gt;map lookup problem&lt;/code&gt; with &lt;code&gt;try again later&lt;/code&gt;. The map file named in the warning is the one to test next.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3: Run the same lookup as root, outside Postfix.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;postmap &lt;span class="nt"&gt;-q&lt;/span&gt; user@example.com mysql:/etc/postfix/grommunio-virtual-mailbox-alias-maps.cf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use an address you know exists. If it returns a result, the credentials, the grant and the database are fine. This is the step that turns “database problem” into “Postfix environment problem”.&lt;/p&gt;

&lt;p&gt;If it fails here too, you do have a database or credentials problem, and the rest of this article is not your fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4: Read the chroot column in master.cf.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^(cleanup|smtp\s|smtpd|pickup)'&lt;/span&gt; /etc/postfix/master.cf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The columns are: service, type, private, unpriv, &lt;strong&gt;chroot&lt;/strong&gt;, wakeup, maxproc, command. The fifth column is the one that matters. A &lt;code&gt;y&lt;/code&gt; there means that service runs inside &lt;code&gt;/var/spool/postfix&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Postfix can also print the value for every service in one go:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;postconf &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s1"&gt;'*/*/chroot'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the broken server, &lt;code&gt;cleanup&lt;/code&gt;, &lt;code&gt;smtpd&lt;/code&gt; and &lt;code&gt;smtp&lt;/code&gt; were all &lt;code&gt;y&lt;/code&gt;. That plus a working &lt;code&gt;postmap -q&lt;/code&gt; is the proof.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 5 (changes state): Turn chroot off for all services.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;postconf &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s1"&gt;'*/*/chroot=n'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This edits &lt;code&gt;master.cf&lt;/code&gt; for you — no hand editing, no missed line. Then restart Postfix and flush the queue:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl restart postfix
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;postqueue &lt;span class="nt"&gt;-f&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;postqueue -f&lt;/code&gt; asks Postfix to retry everything in the queue now instead of waiting for the next retry interval.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 6: Verify.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This step is where most tutorials stop and where mine starts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;postconf &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s1"&gt;'*/*/chroot'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every line must now end in &lt;code&gt;= n&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /var/log/mail.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see the old messages leave: &lt;code&gt;status=sent&lt;/code&gt; lines for remote delivery, local delivery lines for internal mail, and no new &lt;code&gt;lookup error&lt;/code&gt; warnings. Then watch the queue drain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;postqueue &lt;span class="nt"&gt;-p&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What success looks like: the count drops, and finally Postfix reports &lt;code&gt;Mail queue is empty&lt;/code&gt;. What a failure looks like: the count stays flat and the &lt;code&gt;lookup error&lt;/code&gt; lines keep appearing — in that case the restart did not happen, or a service still carries &lt;code&gt;y&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Last, send one fresh test message from one local mailbox to another and one to an outside address. The old queue draining proves the fix; a new message proves the server is healthy now.&lt;/p&gt;

&lt;p&gt;On this server, mail flowed again in both directions right after the restart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Not Keep the Chroot?
&lt;/h2&gt;

&lt;p&gt;A fair question. A chroot is a security feature, and turning it off is a trade-off, not a free win.&lt;/p&gt;

&lt;p&gt;You can keep it, but then the socket has to exist inside the jail — for example by making MySQL’s socket available under &lt;code&gt;/var/spool/postfix/run/mysqld/&lt;/code&gt;. That works until a reboot, a package update or a systemd change removes it, and then you are back to a silent queue. And moving the maps to TCP needs matching database grants for the TCP host, which, as above, is exactly what this server did not have.&lt;/p&gt;

&lt;p&gt;For a mail server whose users and aliases live in a database, I prefer the boring setup that cannot quietly break: no chroot on the Postfix services, the database socket where the client expects it, and the grants left alone. Postfix still drops privileges and runs its daemons as an unprivileged user; the chroot is one extra layer, not the only one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Checklist I Run Now
&lt;/h2&gt;

&lt;p&gt;When a mail server goes quiet, in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Is anything queued, and how old is the oldest message? &lt;code&gt;postqueue -p&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Is local-to-local mail stuck too? If yes, the cause is inside the box. Read the sender and recipient in the queue list.&lt;/li&gt;
&lt;li&gt;Which map is failing? &lt;code&gt;grep "lookup error" /var/log/mail.log&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Does the same lookup work outside Postfix? &lt;code&gt;postmap -q &amp;lt;address&amp;gt; &amp;lt;map&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Which services are chrooted? &lt;code&gt;postconf -F '//chroot'&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;After any fix: does the queue drain to empty? &lt;code&gt;postqueue -p | tail -n 1&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Plain takeaway: &lt;strong&gt;a lookup that works as root and fails in the daemon is an environment problem, not a credentials problem.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Tell Every Customer Now
&lt;/h2&gt;

&lt;p&gt;Whenever someone runs their own mail on Postfix with a database behind it, I ask one question in the review:&lt;/p&gt;

&lt;p&gt;“Who gets told when the oldest message in the queue is more than an hour old?”&lt;/p&gt;

&lt;p&gt;If the answer is a named person and a working alert, they’re in good shape. If the answer is “users would complain”, we’re not done yet — because in this failure, nobody complains. The mail just waits.&lt;/p&gt;

&lt;p&gt;I write about the questions I ask in real customer rooms — the ones that change architectures and surface the problems no one else noticed. If you found this useful, drop your email below and I’ll send the next one when it’s ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why does postmap -q work but Postfix says “map lookup problem”?
&lt;/h3&gt;

&lt;p&gt;postmap runs in your shell, outside any chroot, so it can reach the MySQL socket at /run/mysqld/mysqld.sock. A chrooted Postfix daemon such as cleanup looks for the same path inside /var/spool/postfix, where it does not exist. Same file and credentials, different view of the filesystem.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does “virtual_alias_maps map lookup problem … try again later” mean?
&lt;/h3&gt;

&lt;p&gt;Postfix could not complete a lookup in the virtual alias map, so it refused the message with a temporary error instead of guessing. Senders retry and local mail stays queued, so nothing bounces for days. The fix is to make the lookup work again, then flush the queue.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I check if Postfix services run in a chroot?
&lt;/h3&gt;

&lt;p&gt;Run postconf -F ‘&lt;em&gt;/&lt;/em&gt;/chroot’ to print the chroot setting for every service, or read the fifth column of each line in /etc/postfix/master.cf. A y means that service is chrooted to the Postfix queue directory.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I change hosts = localhost to 127.0.0.1 in the MySQL map files?
&lt;/h3&gt;

&lt;p&gt;Not as a fix for this problem. With the MySQL client, localhost means the Unix socket and 127.0.0.1 means TCP, and MySQL accounts are matched by host. If the mail user is granted only on localhost, the change makes the login fail.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is it safe to turn off chroot for Postfix?
&lt;/h3&gt;

&lt;p&gt;It removes one layer of isolation, so it is a trade-off. Postfix still runs its daemons as an unprivileged user. For a database-backed mail server, a setup that cannot silently lose access to the database socket is usually the safer choice in practice.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I get the stuck mail delivered after the fix?
&lt;/h3&gt;

&lt;p&gt;Restart Postfix so the new master.cf is read, then run postqueue -f to retry every queued message now. Watch /var/log/mail.log for delivery lines and run postqueue -p until it reports that the mail queue is empty.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://quabin.com/wp-content/uploads/2026/11/quabin-postfix-chroot-mysql-socket-mail-stuck.pdf" rel="noopener noreferrer"&gt;Download the PDF&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://quabin.com/postfix-chroot-mysql-socket-mail-stuck/" rel="noopener noreferrer"&gt;quabin.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postfix</category>
      <category>mysql</category>
      <category>email</category>
      <category>linux</category>
    </item>
    <item>
      <title>Dozens of VPN Credentials in a Leak — What to Do in the First 24 Hours</title>
      <dc:creator>Daanial Farrukh</dc:creator>
      <pubDate>Sat, 07 Nov 2026 17:14:22 +0000</pubDate>
      <link>https://dev.to/daanial/dozens-of-vpn-credentials-in-a-leak-what-to-do-in-the-first-24-hours-1pd2</link>
      <guid>https://dev.to/daanial/dozens-of-vpn-credentials-in-a-leak-what-to-do-in-the-first-24-hours-1pd2</guid>
      <description>&lt;p&gt;&lt;em&gt;A field note about a threat report that said "no evidence of valid credentials" — the one sentence almost nobody challenges — and the order of work I now put on the table the moment a VPN domain shows up in a leak.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I was reading a threat-intelligence appraisal.&lt;/p&gt;

&lt;p&gt;It was a careful document. Sources, dates, a clean summary. It covered one of the large credential leaks of this year and it ended with a calm conclusion: "no evidence of valid or active credentials."&lt;/p&gt;

&lt;p&gt;That sentence is the one people forward to their managers.&lt;/p&gt;

&lt;p&gt;So I asked one question. Has anybody searched for the domain?&lt;/p&gt;

&lt;p&gt;I searched. The lookup took less than a minute.&lt;/p&gt;

&lt;p&gt;The organisation's own domain was there — dozens of SSL-VPN accounts, listed against its own edge firewalls. Not a staff member reusing a password on a shopping site. Real VPN portals, on the organisation's own public ranges, several on non-standard ports and a few on plain HTTP.&lt;/p&gt;

&lt;p&gt;"No evidence" had only meant nobody had looked.&lt;/p&gt;

&lt;p&gt;That's the trap. A leak doesn't show up as an alert. Your firewall is healthy, your patch level is fine, your monitoring is green — and someone else is holding the password.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Credential Leak Actually Is
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A credential leak is not a vulnerability. It is a list.&lt;/strong&gt; Someone already has the username and the password, and the attacker's next step is an ordinary login.&lt;/p&gt;

&lt;p&gt;The public example this year was FortiBleed. Researchers reported a dataset covering about 74,000 FortiGate firewall URLs across 194 countries, and public analysis confirmed that sampled logins were real. Fortinet's position is that this was not a new vulnerability — the data came from earlier incidents and from brute-force attacks. That detail matters more than the headline number.&lt;/p&gt;

&lt;p&gt;It means the path looks like this:&lt;/p&gt;

&lt;p&gt;Old theft → dataset → password spray or direct login → a normal-looking VPN session&lt;/p&gt;

&lt;p&gt;Nothing in that flow is an exploit. Your IPS has nothing to match. The login succeeds, and the log line looks exactly like Monday morning.&lt;/p&gt;

&lt;p&gt;This pattern is common in three places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SSL-VPN portals with local accounts and no MFA&lt;/li&gt;
&lt;li&gt;Firewall management pages that were left open to the internet&lt;/li&gt;
&lt;li&gt;Directory bind accounts stored in the firewall configuration, which rarely change&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why the Usual Response Misses It
&lt;/h2&gt;

&lt;p&gt;The usual response is one word: patch.&lt;/p&gt;

&lt;p&gt;Patching is necessary. It is not sufficient. Here's why.&lt;/p&gt;

&lt;p&gt;A patch closes a door. It does not change a key that was copied last year. If the password was taken before the patch, it still works after the patch. Public guidance on this leak says the same thing: if your domain or IP appears, assume compromise, and treat the upgrade as one step among several.&lt;/p&gt;

&lt;p&gt;There is a second reason. Fortinet documented an attacker technique where a symbolic link was planted in the folder that serves SSL-VPN language files, and that link could survive a firmware upgrade. So an upgrade can leave you with a patched box that is still not clean.&lt;/p&gt;

&lt;p&gt;And there is a third reason, the one that surprised me most. &lt;strong&gt;The date of the leak is not the date of the theft.&lt;/strong&gt; In the case I was reading, the most likely source was not the firewall at all. It was a Windows server on the same network — logged in daily as the built-in Administrator, no antivirus, passwords saved in the browser. An infostealer had taken about a hundred saved logins, a handful of them corporate, years before the leak made the news.&lt;/p&gt;

&lt;p&gt;That is still a theory until the cleartext list is compared, and I'd rather say so. But it changes the question. "Did we change our passwords after the news?" is the wrong test. The right test is: &lt;strong&gt;was this password changed after the date it was stolen?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Actually Fix This — The First 24 Hours
&lt;/h2&gt;

&lt;p&gt;This is the order I recommend, and the order matters. The commands are FortiOS because that was the case in front of me. The sequence works on any VPN gateway.&lt;/p&gt;

&lt;p&gt;Commands marked as read-only change nothing. Run those first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Hour 0 — confirm, then freeze the evidence. (Read-only.)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Search your domains and public IPs in a reputable lookup, such as the public checker Hudson Rock published for this dataset. Then take a configuration backup and export the logs before you change anything. In the next steps you will be removing the very things you want to study.&lt;/p&gt;

&lt;p&gt;Record the firmware on every listed device:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;get system status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plain takeaway: a listing is a reason to start, not proof of a breach. It is enough to act on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: Hour 1 — see who is connected, then cut them off.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;List the active SSL-VPN users. (Read-only.)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;get vpn ssl monitor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read every line. Look for a source IP you can't explain, an account of someone on leave, a service account with an interactive session. Then remove the tunnels you do not trust. (This changes state.)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;execute vpn sslvpn del-tunnel &amp;lt;index&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cut sessions first and rotate second. A live session does not always care that the password changed underneath it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3: Hours 1–6 — rotate everything the firewall trusts. (This changes state.)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Most teams change the VPN user passwords and stop. That is about a third of the job. The real list is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Local administrator accounts on every firewall&lt;/li&gt;
&lt;li&gt;Local SSL-VPN users&lt;/li&gt;
&lt;li&gt;LDAP and RADIUS bind accounts and shared secrets&lt;/li&gt;
&lt;li&gt;API tokens and SNMP strings&lt;/li&gt;
&lt;li&gt;Any password that was reused anywhere else&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Item 3 is the one that hurts. A directory bind account stored in a firewall configuration is often years old, rarely reviewed, and very useful to an attacker. Treat the whole configuration as disclosed.&lt;/p&gt;

&lt;p&gt;Check the bind account's age on the directory side. (Read-only.)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Get-ADUser&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;vpn-bind-account&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Properties&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;PasswordLastSet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nx"&gt;LastLogonDate&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;PasswordLastSet&lt;/code&gt; is older than the suspected theft, that password is still in play — no matter what the news cycle says.&lt;/p&gt;

&lt;p&gt;One more detail from the public advice: have each administrator log in after the rotation so the firewall can re-hash the password with PBKDF2. Older hashes are the ones that crack.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4: Hours 2–12 — hunt for what the login left behind. (Read-only.)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Rotation locks out the password. It does not remove an account the attacker created with it.&lt;/p&gt;

&lt;p&gt;List every administrator:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;show system admin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;List every local VPN user:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;show user local
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;List the local-in policies and the automation stitches:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;show firewall local-in-policy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;show system automation-stitch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fortinet's published incident write-ups name accounts like &lt;code&gt;audit&lt;/code&gt;, &lt;code&gt;backup&lt;/code&gt;, &lt;code&gt;itadmin&lt;/code&gt;, &lt;code&gt;secadmin&lt;/code&gt; and &lt;code&gt;support&lt;/code&gt; as ones attackers have created. A plausible name is the whole point. Do not ask whether the name looks suspicious. Ask who created it and on what date.&lt;/p&gt;

&lt;p&gt;Then read the login history. Filter the event log for logins:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;execute log filter category 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;execute log filter field action login
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;execute log display
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You are looking for a source that has never appeared before, a success after a burst of failures, and a dormant account that suddenly works. Then search the same window for configuration downloads and for changes to trusted hosts, MFA, logging or DNS.&lt;/p&gt;

&lt;p&gt;Here's the part that trips people up. Log memory on a small firewall is short. If logs are not forwarded off the box, "nothing in the logs" may only mean the logs have rolled over. That is the appraisal's mistake again.&lt;/p&gt;

&lt;p&gt;If you find a rogue account or an unexplained change, stop cleaning. Restore a known-clean configuration or rebuild the device, and escalate it as an incident.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 5: Hours 6–18 — close the door the password opened. (This changes state.)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;First, patch to a current release. Then remove the exposure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Take firewall management off the internet. Researcher Kevin Beaumont noted in his analysis of this leak that management was exposed on most of the affected devices.&lt;/li&gt;
&lt;li&gt;Restrict each administrator to known sources:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cisco_ios"&gt;&lt;code&gt;&lt;span class="k"&gt;config&lt;/span&gt; system admin
&lt;span class="k"&gt;edit&lt;/span&gt; &amp;lt;admin-name&amp;gt;
&lt;span class="k"&gt;set&lt;/span&gt; trusthost1 &lt;span class="m"&gt;203.0.113.10&lt;/span&gt; &lt;span class="m"&gt;255.255.255.255&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Enforce MFA on every admin and VPN account, with no exceptions for service desks or "just this one director".&lt;/li&gt;
&lt;li&gt;Force HTTPS. A VPN portal on plain HTTP sends its login in the clear.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the same pattern as any admin lockdown: the port should answer only the people who need it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 6: Hours 12–24 — find the machine the passwords came from.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the step everyone skips, because the firewall team has gone home feeling finished.&lt;/p&gt;

&lt;p&gt;If the source was an infostealer, the firewall is only where the stolen password was used. The infected host has other secrets. Ask four questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Which machine had these logins saved in a browser?&lt;/li&gt;
&lt;li&gt;Is it still in service? If so, reimage it. Do not clean it.&lt;/li&gt;
&lt;li&gt;Is it used daily as Administrator? Stop that.&lt;/li&gt;
&lt;li&gt;Does it run endpoint protection that someone actually watches?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then widen the rotation to every login on that machine — mail, control panels, personal accounts that share a pattern. A password scheme with one changing word is one password.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 7: Verify — the step that proves any of this happened.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This step is where most tutorials stop and where mine starts.&lt;/p&gt;

&lt;p&gt;Check three things. First, list the bind account again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Get-ADUser&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;vpn-bind-account&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Properties&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;PasswordLastSet&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The date must be today's. Second, run &lt;code&gt;show system admin&lt;/code&gt; and match every line to a named person. Third, from outside your network, try to reach the management page. It must time out.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-k&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 10 https://203.0.113.20:8443/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected result: a timeout. If you get a login page, step 5 didn't take.&lt;/p&gt;

&lt;p&gt;Then do the check nobody enjoys. Have one user try to connect with a password but without the second factor. It must fail.&lt;/p&gt;

&lt;p&gt;A refused login here is not a fault. It is the verification.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bigger Lesson
&lt;/h2&gt;

&lt;p&gt;Three things from this case have stayed with me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"No evidence" is not a finding.&lt;/strong&gt; It is a statement about how hard you looked. Before you accept it, ask what was searched, and for which domains.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A patch is a date, not a state.&lt;/strong&gt; It tells you when the door closed. It says nothing about who already has a key.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The firewall is rarely where the story begins.&lt;/strong&gt; One unprotected server used for daily work can feed a VPN, a mail system and a control panel at the same time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Checklist I Run Now
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Domains and public IPs searched in a leak lookup — by a person, with a date&lt;/li&gt;
&lt;li&gt;Active sessions listed and untrusted ones cut: &lt;code&gt;get vpn ssl monitor&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Admin, VPN, bind, RADIUS, API and SNMP secrets rotated, not just user passwords&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PasswordLastSet&lt;/code&gt; newer than the suspected theft date&lt;/li&gt;
&lt;li&gt;Every administrator matched to a named person: &lt;code&gt;show system admin&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Management closed from outside, proven with &lt;code&gt;curl&lt;/code&gt; from an outside network&lt;/li&gt;
&lt;li&gt;MFA proven by a failed login without the second factor&lt;/li&gt;
&lt;li&gt;Logs forwarded off the box, so the next review has a history to read&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Plain takeaway: rotate first, hunt second, and do not close the ticket until you have found the source.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Tell Every Customer Now
&lt;/h2&gt;

&lt;p&gt;When a report lands saying the organisation is not affected, I ask one question:&lt;/p&gt;

&lt;p&gt;"Who searched for our domain in the dataset, on what date, and which of our passwords are older than the theft?"&lt;/p&gt;

&lt;p&gt;If the answer is a name, a date and a list, they're in good shape. If the answer is "the report says we're fine", we're not done yet.&lt;/p&gt;

&lt;p&gt;I write about the questions I ask in real customer rooms — the ones that change architectures and surface the problems no one else noticed. If you found this useful, drop your email below and I'll send the next one when it's ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What should I do first if my VPN credentials appear in a leak?
&lt;/h3&gt;

&lt;p&gt;Confirm the listing, back up the configuration and export the logs, then list active VPN sessions and cut any you cannot explain. Do this before rotating passwords. Assume the listed accounts are compromised until the passwords have been changed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does patching the firewall fix a credential leak?
&lt;/h3&gt;

&lt;p&gt;No. A patch closes a software flaw, but a stolen password still works after the upgrade. You must also rotate credentials, check for rogue accounts and enforce MFA. Fortinet has also documented an attacker technique that could survive a firmware upgrade, so a patched box is not automatically clean.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which passwords need to change after a firewall credential leak?
&lt;/h3&gt;

&lt;p&gt;All of them that the firewall holds or trusts: local administrators, local VPN users, LDAP and RADIUS bind accounts, shared secrets, API tokens and SNMP strings. Also change any password reused elsewhere. Treat the whole configuration as disclosed, because a backup of it may have been taken.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I know if a leaked VPN password was actually used?
&lt;/h3&gt;

&lt;p&gt;Read the login history for new source addresses, successes after a burst of failures and dormant accounts that suddenly log in. Then check for new administrators, changed trusted hosts and configuration downloads. If logs were not forwarded off the firewall, an empty result proves little.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why would old passwords appear in a leak from this year?
&lt;/h3&gt;

&lt;p&gt;The date a dataset is published is not the date the passwords were stolen. An infostealer on one machine can capture saved browser logins years before they appear in a public dataset. That is why the useful test is whether a password was changed after the theft, not after the news.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is it enough to enable MFA after a VPN credential leak?
&lt;/h3&gt;

&lt;p&gt;It is one of the strongest steps, but it does not replace rotation. Cut existing sessions, rotate the passwords and look for rogue accounts as well, because an attacker who already got in may have created a way back. Then prove MFA works by trying a login without the second factor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Download the PDF:&lt;/strong&gt; &lt;a href="https://quabin.com/wp-content/uploads/2026/11/quabin-vpn-credentials-leak-first-24-hours.pdf" rel="noopener noreferrer"&gt;https://quabin.com/wp-content/uploads/2026/11/quabin-vpn-credentials-leak-first-24-hours.pdf&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://quabin.com/vpn-credentials-leak-first-24-hours/" rel="noopener noreferrer"&gt;quabin.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>networking</category>
      <category>vpn</category>
      <category>devops</category>
    </item>
    <item>
      <title>WHM Is IP-Locked, So Why Can't I Reach It? — Cloudflare Proxied Hostnames vs Origin IPs</title>
      <dc:creator>Daanial Farrukh</dc:creator>
      <pubDate>Fri, 06 Nov 2026 17:17:31 +0000</pubDate>
      <link>https://dev.to/daanial/whm-is-ip-locked-so-why-cant-i-reach-it-cloudflare-proxied-hostnames-vs-origin-ips-52o1</link>
      <guid>https://dev.to/daanial/whm-is-ip-locked-so-why-cant-i-reach-it-cloudflare-proxied-hostnames-vs-origin-ips-52o1</guid>
      <description>&lt;p&gt;&lt;em&gt;A field note about the day my own firewall rule locked me out of WHM — a rule that was correct, on a hostname that was orange — and the read-only checks I now run before I touch a single admin allowlist.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This one has no customer quote at the start, because the person who got it wrong was me.&lt;/p&gt;

&lt;p&gt;A cPanel server was sitting behind a distributed firewall, the kind that is enforced outside the VM. The job was simple and sensible: stop the world from reaching the admin ports. SSH on 22 and WHM on 2086 and 2087 would answer one source only — my admin IP.&lt;/p&gt;

&lt;p&gt;So I wrote the rules the way you are supposed to write them. Allow from the admin IP. Then an explicit drop from any, placed above the catch-all allow at the bottom of the policy. The firewall reads top-down and the first match wins, so the order was doing real work.&lt;/p&gt;

&lt;p&gt;SSH still worked. WHM did not.&lt;/p&gt;

&lt;p&gt;My IP had not changed. The rule was enabled. The order was right. I had just locked myself out with a rule I could read line by line and find nothing wrong with.&lt;/p&gt;

&lt;p&gt;There was nothing wrong with it.&lt;/p&gt;

&lt;p&gt;That's the trap. &lt;strong&gt;An IP allowlist does not check who you are. It checks who connected last.&lt;/strong&gt; And when your hostname is orange in Cloudflare, the last one to connect was not you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Happens to a Request on Port 2087
&lt;/h2&gt;

&lt;p&gt;Most people think of Cloudflare as a web thing. Port 80, port 443, done. It isn't.&lt;/p&gt;

&lt;p&gt;Cloudflare's proxy also listens on a published list of other ports, and that list includes the whole cPanel family:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;2082 and 2083 — cPanel&lt;/li&gt;
&lt;li&gt;2086 and 2087 — WHM&lt;/li&gt;
&lt;li&gt;2095 and 2096 — Webmail&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So a proxied hostname does not fail on 2087. It works, and that is the problem. Your browser reaches Cloudflare, and Cloudflare opens a brand-new TCP connection to your origin:&lt;/p&gt;

&lt;p&gt;Admin laptop → Cloudflare edge → Firewall → WHM&lt;/p&gt;

&lt;p&gt;There are two connections here, not one. The first one is yours. The second one belongs to Cloudflare. &lt;strong&gt;The firewall in front of the origin only ever sees the second one.&lt;/strong&gt; Its source address is a Cloudflare address, so the walk down the policy goes like this:&lt;/p&gt;

&lt;p&gt;Source = Cloudflare IP → Allow admin IP? No → Drop from any? Yes → Dropped&lt;/p&gt;

&lt;p&gt;The rule did exactly what I told it to do. It dropped a connection from a stranger. The stranger just happened to be carrying my request.&lt;/p&gt;

&lt;p&gt;Here is the part that trips people up: Cloudflare does pass your real address along, in the &lt;code&gt;CF-Connecting-IP&lt;/code&gt; header. That is no help at this layer. A network firewall decides on the TCP source address before any HTTP header exists. The header is inside a connection the firewall never lets start.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why SSH Tells You Nothing — and Why That Helps
&lt;/h2&gt;

&lt;p&gt;Port 22 is not on Cloudflare's normal proxy list. So SSH to the origin IP goes straight to the server, and the firewall sees your real address. (Point SSH at an orange hostname and it simply fails, because Cloudflare's normal proxy does not carry it. That is a different symptom.)&lt;/p&gt;

&lt;p&gt;That difference misleads you first. "SSH works, so my IP is allowed, so the firewall is fine." You go looking in WHM, in cPHulk, in the service status.&lt;/p&gt;

&lt;p&gt;Then it helps you. Same machine, same firewall, same allowlist, same laptop. One port works and the other does not. The rule cannot be the difference, because both ports use the same rule logic. &lt;strong&gt;The difference is the path.&lt;/strong&gt; When SSH works and WHM does not, stop reading the firewall and start asking which IP WHM's traffic is really coming from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why You Never See This in Testing
&lt;/h2&gt;

&lt;p&gt;Three things hide it.&lt;/p&gt;

&lt;p&gt;First, the rule is usually tested with the wrong URL. Many admins test against the raw IP while building the rule, it passes, and then their bookmark goes to the friendly hostname from then on.&lt;/p&gt;

&lt;p&gt;Second, the hostname may have turned orange long after the firewall was built. Somebody importing a zone into Cloudflare, or proxying everything to be safe, can flip it months later. The firewall did not change. The DNS did.&lt;/p&gt;

&lt;p&gt;Third, the error points away from the cause. A dropped connection gets no answer, so a browser going through Cloudflare should show Cloudflare's own 522 connection-timed-out page, not anything from your firewall or from cPanel. If the firewall rejected instead of dropped, the same mistake would normally show up as a 521. Either way, the page tells you the origin is unwell. The origin is fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Actually Fix This
&lt;/h2&gt;

&lt;p&gt;Everything up to Step 3 is read-only.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Find out what your hostname really resolves to.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short server.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you get your origin IP back, the hostname is grey and this article is not your problem. If you get addresses that are not yours, usually starting &lt;code&gt;104.&lt;/code&gt; or &lt;code&gt;172.67.&lt;/code&gt;, you are proxied. Compare against the list Cloudflare publishes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://www.cloudflare.com/ips-v4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 2: Ask the port who answered.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-skI&lt;/span&gt; https://server.example.com:2087/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the &lt;code&gt;server:&lt;/code&gt; line. If it says &lt;code&gt;cloudflare&lt;/code&gt; and there is a &lt;code&gt;cf-ray:&lt;/code&gt; header, then whatever you are talking to on 2087, it is not your WHM. A status of 522 here is the whole diagnosis in one line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3: Bypass Cloudflare for one request and compare.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This forces curl to the origin IP while keeping the correct hostname for TLS. It changes nothing anywhere.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-skI&lt;/span&gt; &lt;span class="nt"&gt;--resolve&lt;/span&gt; server.example.com:2087:203.0.113.10 https://server.example.com:2087/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it from the admin IP. Now the server line should name &lt;code&gt;cpsrvd&lt;/code&gt;, which is cPanel's own service. Same hostname, same port, same laptop — and it answers. That is your proof: the rule allows you, and only the proxied path is blocked.&lt;/p&gt;

&lt;p&gt;If this one fails too, the firewall or the service really is the problem, and you should go back to the rule. That result is useful as well.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4: Make the admin hostname grey. (This changes state.)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In Cloudflare, go to DNS → Records, find the server hostname, and set Proxy status to DNS only. Leave the public websites orange. They need the proxy and they are not the ones being locked.&lt;/p&gt;

&lt;p&gt;This matters even if you plan to browse to the IP. When I open the origin IP on port 2087, WHM redirects me to the server hostname on 2087. If that hostname is orange, the redirect walks me straight back into Cloudflare and into the drop rule. &lt;strong&gt;The IP gets you to the door. The grey hostname keeps you there.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;After the change, wait for the old TTL to expire, then repeat Step 1. You should see the origin IP. Then bookmark the direct path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://203.0.113.10:2087
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 5: Verify from both sides.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This step is where most tutorials stop and where mine starts. Working for you proves half of it. The other half is failing for everyone else.&lt;/p&gt;

&lt;p&gt;From the admin IP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-skI&lt;/span&gt; https://server.example.com:2087/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expect the &lt;code&gt;cpsrvd&lt;/code&gt; server line and no &lt;code&gt;cf-ray&lt;/code&gt; header.&lt;/p&gt;

&lt;p&gt;Then, from anywhere else — a phone hotspot is fine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-skI&lt;/span&gt; &lt;span class="nt"&gt;--connect-timeout&lt;/span&gt; 10 https://203.0.113.10:2087/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expect a timeout and nothing else. curl exits with code 28.&lt;/p&gt;

&lt;p&gt;This error is not a bug. It is the verification. If that second command returns a login page, your lock is not locking.&lt;/p&gt;

&lt;p&gt;On the server itself, one more read-only line shows which address WHM thinks you are:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 5 /usr/local/cpanel/logs/access_log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first field of each line is the client. It should be your admin IP.&lt;/p&gt;

&lt;p&gt;One warning from the way this firewall works: when the drop happens outside the VM, a capture inside the VM shows nothing at all. An empty &lt;code&gt;tcpdump&lt;/code&gt; on port 2087 does not mean no traffic was sent. It means the traffic died before it arrived. Use the hit counters on the firewall rule for that view.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix You Must Never Make
&lt;/h2&gt;

&lt;p&gt;Sooner or later somebody says it: "Just add Cloudflare's IP ranges to the WHM allow rule."&lt;/p&gt;

&lt;p&gt;It will work. WHM will load. Please don't.&lt;/p&gt;

&lt;p&gt;Those ranges are not your ranges. They are shared by every Cloudflare customer on the planet. Anyone can add a domain to a Cloudflare account, create a proxied record pointing at your origin IP, and send their browser through it on 2087. The connection then arrives from an address you allowed. &lt;strong&gt;You did not narrow your admin port. You opened it to the world with one extra step.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The same goes for SSH. Whichever port you lock, the allow rule names your addresses, and no one else's.&lt;/p&gt;

&lt;p&gt;If your admin IP changes often, the answer is a VPN or a jump host with a fixed address. If you truly want WHM behind Cloudflare, that is a different design: Cloudflare Tunnel with Access in front of it, and nothing listening to the public internet. It is a good design. It is not a one-line allow rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Honest Trade-Off
&lt;/h2&gt;

&lt;p&gt;A grey hostname publishes your origin IP in public DNS. Some people will object to that, and they are not wrong to care.&lt;/p&gt;

&lt;p&gt;On a cPanel server the mail hostnames usually publish that IP anyway, because mail cannot go through Cloudflare's normal proxy. Hiding the origin was never going to be the control here. &lt;strong&gt;The control is the drop rule.&lt;/strong&gt; A published IP with a closed port is safe. A hidden IP with an open port is only safe until someone finds it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Checklist I Run Now
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Is the admin hostname grey? &lt;code&gt;dig +short server.example.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Who answers on the admin port? &lt;code&gt;curl -skI https://server.example.com:2087/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Does the bypass work from the admin IP? &lt;code&gt;curl -skI --resolve server.example.com:2087:203.0.113.10 https://server.example.com:2087/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Does the origin refuse a stranger? &lt;code&gt;curl -skI --connect-timeout 10 https://203.0.113.10:2087/&lt;/code&gt; from another network.&lt;/li&gt;
&lt;li&gt;Is the drop rule above the catch-all allow? Read the policy top-down, and check the hit counter is rising.&lt;/li&gt;
&lt;li&gt;Is 443 a side door? cPanel's proxy subdomains can carry &lt;code&gt;whm.example.com&lt;/code&gt; over ordinary 80 and 443, which a rule on 2087 does not touch. &lt;code&gt;grep proxysubdomains /var/cpanel/cpanel.config&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Is anything else on the same list? Check 2082, 2083, 2086, 2095 and 2096 the same way, on every hostname that points at the box.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Plain takeaway: lock the port, then prove it from outside, every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Tell Every Customer Now
&lt;/h2&gt;

&lt;p&gt;When someone tells me their admin panel is locked to one IP, I don't ask to see the rule. I ask this:&lt;/p&gt;

&lt;p&gt;"From a network that is not on the list, what does port 2087 on your origin IP do — and is your admin hostname orange or grey?"&lt;/p&gt;

&lt;p&gt;If the answer is "it times out, and it's grey," they're in good shape. If the answer is "I think so, the rule is in there," we're not done yet.&lt;/p&gt;

&lt;p&gt;I write about the questions I ask in real customer rooms — the ones that change architectures and surface the problems no one else noticed. If you found this useful, drop your email below and I'll send the next one when it's ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why can't I reach WHM after locking it to my IP in the firewall?
&lt;/h3&gt;

&lt;p&gt;If your WHM hostname is proxied through Cloudflare, the origin firewall sees a Cloudflare address, not yours. The allow rule misses and the drop rule fires. Check with &lt;code&gt;dig +short&lt;/code&gt; on the hostname: if it does not return your origin IP, set the record to DNS only in Cloudflare or reach WHM through the origin IP.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Cloudflare proxy port 2087?
&lt;/h3&gt;

&lt;p&gt;Yes. Cloudflare's proxy supports a published list of HTTP and HTTPS ports, and it includes the cPanel and WHM ports 2082, 2083, 2086, 2087, 2095 and 2096. A request to an orange-cloud hostname on 2087 reaches Cloudflare first, and Cloudflare opens its own connection to your server.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I allow Cloudflare IP ranges to reach WHM?
&lt;/h3&gt;

&lt;p&gt;No. Those ranges are shared by every Cloudflare customer, so anyone can create a proxied record pointing at your origin and reach your WHM login from an allowed address. Keep WHM limited to your own admin IP, and use a VPN, a jump host or Cloudflare Tunnel with Access if your address changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does SSH work when WHM does not?
&lt;/h3&gt;

&lt;p&gt;SSH on port 22 is not carried by Cloudflare's normal proxy, so a session to the origin IP arrives directly and the firewall sees your real address. That makes the mismatch a useful clue: the same allowlist passes one port and blocks the other, so the rule is not the cause. The path taken by WHM traffic is.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why do I get a Cloudflare 522 error on port 2087?
&lt;/h3&gt;

&lt;p&gt;A 522 means Cloudflare could not complete a TCP connection to your origin. On an IP-locked server, that usually means your firewall is silently dropping Cloudflare's connection, which is what the rule was written to do. Your server is healthy; only the proxied path is blocked.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does a DNS-only hostname expose my server's IP?
&lt;/h3&gt;

&lt;p&gt;Yes, the origin IP becomes visible in public DNS. On a cPanel server, the mail hostnames usually show it anyway. Protection should come from the firewall rule that closes the admin ports, not from keeping the address secret.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Download the PDF:&lt;/strong&gt; &lt;a href="https://quabin.com/wp-content/uploads/2026/11/quabin-whm-ip-locked-cloudflare-proxied-hostname.pdf" rel="noopener noreferrer"&gt;https://quabin.com/wp-content/uploads/2026/11/quabin-whm-ip-locked-cloudflare-proxied-hostname.pdf&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://quabin.com/whm-ip-locked-cloudflare-proxied-hostname/" rel="noopener noreferrer"&gt;quabin.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>cpanel</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>FortiGate Upgrade Dropped LAN-to-LAN Traffic — allow-traffic-redirect and the Implicit Deny Nobody Planned For</title>
      <dc:creator>Daanial Farrukh</dc:creator>
      <pubDate>Fri, 06 Nov 2026 09:25:19 +0000</pubDate>
      <link>https://dev.to/daanial/fortigate-upgrade-dropped-lan-to-lan-traffic-allow-traffic-redirect-and-the-implicit-deny-nobody-5cab</link>
      <guid>https://dev.to/daanial/fortigate-upgrade-dropped-lan-to-lan-traffic-allow-traffic-redirect-and-the-implicit-deny-nobody-5cab</guid>
      <description>&lt;p&gt;&lt;em&gt;A war story about a firewall upgrade that finished cleanly and cut two LANs apart — traffic that had never had a policy, never written a log line, and never been on anyone’s test list — and the checks I now run before and after any firmware change.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The firmware upgrade was done at night. It finished. The firewall came back. The team sent the usual note asking the customer to verify their services.&lt;/p&gt;

&lt;p&gt;The customer found the problem the next morning. Virtual machines on one of their LANs could not reach virtual machines on the other. Packet loss between them was 100 percent, and the system they called their key system was down.&lt;/p&gt;

&lt;p&gt;The fix itself was small — one explicit policy, in place by early afternoon. The conversation was harder.&lt;/p&gt;

&lt;p&gt;“Whose lapse is this?” their lead asked in the group. “And what measures will follow?”&lt;/p&gt;

&lt;p&gt;Two explanations came back from the provider’s side. Monitoring could not validate LAN-to-LAN traffic. And the customer should have flagged the problem after the verification note.&lt;/p&gt;

&lt;p&gt;Neither one answered the question. The customer rejected both, and they were right to.&lt;/p&gt;

&lt;p&gt;I accepted it as our lapse, and I apologised. Then I went looking for the question we should have asked before the change window opened:&lt;/p&gt;

&lt;p&gt;“What on this firewall is working without a rule?”&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trap, Named
&lt;/h2&gt;

&lt;p&gt;That’s the trap. A firewall review reads the policy table, and a change plan tests what the policy table says. But a flow that passes with &lt;strong&gt;no policy&lt;/strong&gt; does not appear in that table at all. It has no rule to read, no owner to ask, and — as you will see — often no log line to find.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The most dangerous traffic on your firewall is the traffic nobody ever had to allow.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Happened to the Traffic
&lt;/h2&gt;

&lt;p&gt;The customer had two LANs behind the same firewall interface. There was no LAN-to-LAN policy, because nobody had ever needed one. The old firmware passed the traffic. The new firmware dropped it under the implicit deny.&lt;/p&gt;

&lt;p&gt;The path between the two LANs looks like this:&lt;/p&gt;

&lt;p&gt;VM on LAN A → firewall interface → route lookup says “out the same interface” → VM on LAN B&lt;/p&gt;

&lt;p&gt;Traffic that arrives on an interface and leaves on the same interface is called &lt;strong&gt;hairpin traffic&lt;/strong&gt;. On a FortiGate, what happens next is decided by one global setting, &lt;code&gt;allow-traffic-redirect&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;enable&lt;/strong&gt; — same-interface traffic can pass on the routing decision alone, with no matching policy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;disable&lt;/strong&gt; — it needs a firewall policy whose source and destination interface are the same. Without one, implicit deny drops it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here’s the part that trips people up. That default changed. Fortinet’s &lt;a href="https://docs.fortinet.com/document/fortigate/7.4.10/fortios-release-notes/230510/changes-in-default-behavior" rel="noopener noreferrer"&gt;FortiOS 7.4.10 release notes&lt;/a&gt; list it under “Changes in default behavior”: the default moved from enable to disable, for IPv4 and for IPv6. Fortinet’s &lt;a href="https://community.fortinet.com/fortigate-3/technical-tip-traffic-handled-by-fortigate-for-packets-with-ingress-and-egress-as-the-same-interface-98332" rel="noopener noreferrer"&gt;technical tip&lt;/a&gt; gives the same change for 7.6.5 and 8.0.0 onward. And the release note says the upgrade sets it to disable &lt;strong&gt;even if it was enabled before&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So a box can be fully healthy, with every rule intact, and lose a whole class of traffic in the reboot.&lt;/p&gt;

&lt;p&gt;I want to be exact about what is proven. The symptom, the old behaviour, the new behaviour and the fix are proven in this case. The specific firmware versions are not in my notes, so I cannot prove this setting was the mechanism on that box. It is the leading explanation, because it matches on every point. The first check in this article is the one that confirms it on yours.&lt;/p&gt;

&lt;p&gt;This shape is common. You will find it wherever:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Two subnets share one interface, usually through a secondary IP address.&lt;/li&gt;
&lt;li&gt;A router or layer 3 switch sits on the same LAN as the firewall, and the firewall is the default gateway that hands traffic back.&lt;/li&gt;
&lt;li&gt;A site grew a second LAN years ago and someone added an IP address instead of an interface.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why Nobody Sees It in Testing
&lt;/h2&gt;

&lt;p&gt;Three things hide this flow, and they stack.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It has no rule.&lt;/strong&gt; A policy review cannot find what is not there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It often has no log.&lt;/strong&gt; Fortinet’s tip notes that traffic logs are generated only when traffic passes a firewall policy. Hairpin traffic passing without a policy leaves no trail in the traffic log, so a “top talkers” report will not show the dependency either.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is not on the monitor.&lt;/strong&gt; Most monitoring checks the firewall itself, its uplinks and its published services. All of those were green. The traffic between two groups of a customer’s own VMs was never a monitored item, and “firewall is up” says nothing about it.&lt;/p&gt;

&lt;p&gt;So the post-upgrade check passed, honestly, on every item it contained. &lt;strong&gt;The list was the failure, not the box.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Actually Fix This
&lt;/h2&gt;

&lt;p&gt;Steps 1 to 3 are read-only. Step 4 changes state, so do it in a window. I have used documentation addresses here: 192.0.2.0/24 for LAN A, 198.51.100.0/24 for LAN B, and &lt;code&gt;port2&lt;/code&gt; for the shared interface.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Read the setting and the version.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;get system status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;get system global | &lt;span class="nb"&gt;grep &lt;/span&gt;allow-traffic
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you are on 7.4.10, 7.6.5, 8.0.0 or later, expect &lt;code&gt;allow-traffic-redirect: disable&lt;/code&gt;. On a box still below those versions, it may say enable — and that box is the one to worry about, because it may depend on this today.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: Prove the drop with a flow trace.&lt;/strong&gt; Start a ping from a VM on LAN A to a VM on LAN B, then run this on the firewall. Keep the count small; this is a production box.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cisco_ios"&gt;&lt;code&gt;&lt;span class="k"&gt;diagnose&lt;/span&gt; debug flow filter addr &lt;span class="m"&gt;192.0.2.10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cisco_ios"&gt;&lt;code&gt;&lt;span class="k"&gt;diagnose&lt;/span&gt; debug flow show function-name enable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cisco_ios"&gt;&lt;code&gt;&lt;span class="k"&gt;diagnose&lt;/span&gt; debug enable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;diagnose debug flow trace start 20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you are done, switch it off:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cisco_ios"&gt;&lt;code&gt;&lt;span class="k"&gt;diagnose&lt;/span&gt; debug disable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The line you are looking for is this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Denied by forward policy check (policy 0)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Policy 0 is the implicit deny. This is not a bug in the firmware. It is the firewall doing exactly what the new default tells it to do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3: Find the dependency before you upgrade.&lt;/strong&gt; On a box that has not been upgraded yet, the log will not help you, so look at the wire. Start a fresh ping between the two LANs and run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;diagnose sniffer packet port2 'net 192.0.2.0/24 and net 198.51.100.0/24' 4 20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same packet going in on &lt;code&gt;port2&lt;/code&gt; and then out on &lt;code&gt;port2&lt;/code&gt; is hairpin traffic. Now read the policy table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;show firewall policy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look for a policy whose &lt;code&gt;srcintf&lt;/code&gt; and &lt;code&gt;dstintf&lt;/code&gt; are the same. If the sniffer shows the traffic and the table has no such policy, that flow is alive on the old default alone. It will die in the upgrade.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4: Write the policy (this changes state).&lt;/strong&gt; Create the two address objects, then the policy. Set these up in a window, and take a config backup first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;config firewall address
    edit "LAN-A"
        set subnet 192.0.2.0 255.255.255.0
    next
    edit "LAN-B"
        set subnet 198.51.100.0 255.255.255.0
    next
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;config firewall policy
    edit 0
        set name "LAN-A-to-LAN-B"
        set srcintf "port2"
        set dstintf "port2"
        set srcaddr "LAN-A"
        set dstaddr "LAN-B"
        set action accept
        set schedule "always"
        set service "ALL"
        set logtraffic all
    next
end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repeat it with source and destination swapped if sessions start from both sides. Then narrow &lt;code&gt;service&lt;/code&gt; once the logs show you what really flows.&lt;/p&gt;

&lt;p&gt;There is a one-line alternative: &lt;code&gt;set allow-traffic-redirect enable&lt;/code&gt; under &lt;code&gt;config system global&lt;/code&gt;. It restores the old behaviour and it will stop the outage in a second. I use it only as an emergency measure. Fortinet’s own article warns that it bypasses the policy check for hairpin flows, which leaves you exactly where you started — a production dependency with no rule, no log and no owner.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plain takeaway: the setting hides the dependency; the policy documents it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 5: Verify.&lt;/strong&gt; This step is where most tutorials stop and where mine starts. Run the same ping, then the same trace. You should now see the policy id in the trace instead of policy 0. Then confirm the flow has its own trail: open Log &amp;amp; Report › Forward Traffic and filter on the policy name. You should see entries for LAN A to LAN B.&lt;/p&gt;

&lt;p&gt;Now test real traffic, not just ping. If ping works but TCP sessions hang, Fortinet’s tip points at anti-replay on the same-interface policy: &lt;code&gt;set anti-replay disable&lt;/code&gt; under that policy. Ping alone would have given you a green tick and a broken application.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Checklist I Run Around Any Firewall Upgrade
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Read “Changes in default behavior” in the release notes&lt;/strong&gt; for every version on the path, not just the last one. This section exists so that you do not meet it in production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Save the setting before and after.&lt;/strong&gt; &lt;code&gt;get system global | grep allow-traffic&lt;/code&gt; goes in the change record, both times.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sniff for hairpin traffic on every interface that carries more than one subnet.&lt;/strong&gt; Any flow without a same-interface policy gets a policy first, before the upgrade.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask the customer for east-west flows in writing.&lt;/strong&gt; “Which of your servers talk to each other through this firewall?” is a different question from “which services do you publish?”&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give notice, and get a named person on their side.&lt;/strong&gt; Ask them to test during the window, not the next morning.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test it yourself after the reboot.&lt;/strong&gt; A provider’s “please verify” note is not a verification. Keep one host in each LAN that can ping the other, and watch that path during the window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add east-west probes to monitoring.&lt;/strong&gt; If the path matters, a monitor should measure the path, not the firewall that carries it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the rest of the fleet.&lt;/strong&gt; If one box has this shape, others built by the same people probably do. I have no proof of that for any specific firewall — that is exactly why you run the check.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Absence of errors is not health.&lt;/strong&gt; A clean upgrade log proves the firmware installed. It does not prove the customer’s traffic still flows.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Tell Every Customer Now
&lt;/h2&gt;

&lt;p&gt;Before any firewall change, I ask one question:&lt;/p&gt;

&lt;p&gt;“Name the traffic between your own servers that would stop the business if it failed — and tell me who tests it, and when, after our change.”&lt;/p&gt;

&lt;p&gt;If the answer is a list of flows and a name on each side, they’re in good shape. If the answer is “the firewall doesn’t touch that”, we’re not done yet.&lt;/p&gt;

&lt;p&gt;I write about the questions I ask in real customer rooms — the ones that change architectures and surface the problems no one else noticed. If you found this useful, drop your email below and I’ll send the next one when it’s ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why did LAN-to-LAN traffic stop after a FortiGate upgrade?
&lt;/h3&gt;

&lt;p&gt;A leading reason is the changed default of allow-traffic-redirect. From FortiOS 7.4.10, 7.6.5 and 8.0.0, it is disable, and the upgrade sets it to disable even if it was enabled before. Two subnets on one interface then need an explicit policy with the same source and destination interface, or implicit deny drops the traffic.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does “Denied by forward policy check (policy 0)” mean?
&lt;/h3&gt;

&lt;p&gt;It means the traffic matched no firewall policy and was dropped by the implicit deny at the end of the policy table. You see it in a debug flow trace. For same-interface traffic after an upgrade, the fix is a policy with the same interface as source and destination.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I set allow-traffic-redirect back to enable?
&lt;/h3&gt;

&lt;p&gt;Only as an emergency measure. It brings the traffic back quickly, but that traffic again passes without a policy check. An explicit same-interface policy is better, because it can be logged, inspected and owned by someone.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do two VLANs on a FortiGate need this policy too?
&lt;/h3&gt;

&lt;p&gt;Two VLAN interfaces are two different interfaces, so that traffic is not hairpin traffic and has always needed a normal policy between them. The setting matters when ingress and egress are the same interface, such as two subnets on one interface with a secondary IP address.&lt;/p&gt;

&lt;h3&gt;
  
  
  How can I find hairpin traffic before an upgrade if it is not logged?
&lt;/h3&gt;

&lt;p&gt;Use the sniffer on the shared interface with a filter on the two subnets, and start a fresh ping or session. Packets going in and back out on the same interface are hairpin traffic. Then check whether a policy with matching source and destination interfaces exists.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is the customer or the provider responsible for testing after a firewall upgrade?
&lt;/h3&gt;

&lt;p&gt;Whoever makes the change owns the proof that it worked. The customer knows which flows matter, so collect that list before the window and agree a named tester. A note asking the customer to verify, sent after the work, moves the risk without removing it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://quabin.com/wp-content/uploads/2026/11/quabin-fortigate-upgrade-lan-to-lan-implicit-deny.pdf" rel="noopener noreferrer"&gt;Download the PDF&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://quabin.com/fortigate-upgrade-lan-to-lan-implicit-deny/" rel="noopener noreferrer"&gt;quabin.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>networking</category>
      <category>security</category>
      <category>firewall</category>
      <category>devops</category>
    </item>
    <item>
      <title>Three Data Centres and No High Availability — Why a Second Site Without a Load Balancer Is Not Redundancy</title>
      <dc:creator>Daanial Farrukh</dc:creator>
      <pubDate>Wed, 04 Nov 2026 17:28:56 +0000</pubDate>
      <link>https://dev.to/daanial/three-data-centres-and-no-high-availability-why-a-second-site-without-a-load-balancer-is-not-4apg</link>
      <guid>https://dev.to/daanial/three-data-centres-and-no-high-availability-why-a-second-site-without-a-load-balancer-is-not-4apg</guid>
      <description>&lt;p&gt;&lt;em&gt;A presales war story about an infrastructure review where three data centres looked like the safest design in the room — until I asked which box the traffic passes through first — and the four-layer check I now run before I let anyone say the word "redundant".&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The slide had three buildings on it.&lt;/p&gt;

&lt;p&gt;"We run three data centres. If one goes down, the others carry the load. We are fully redundant."&lt;/p&gt;

&lt;p&gt;The audience nodded. It is a good slide. Three sites in different parts of the city, their own power, their own fibre, their own racks. An auditor had been happy with it. A customer's procurement team had been happy with it. Three is more than two, and two is already more than most.&lt;/p&gt;

&lt;p&gt;I asked one question.&lt;/p&gt;

&lt;p&gt;"When a visitor opens the portal, which device receives the packet first?"&lt;/p&gt;

&lt;p&gt;"The load balancer."&lt;/p&gt;

&lt;p&gt;"Where is the load balancer?"&lt;/p&gt;

&lt;p&gt;Silence.&lt;/p&gt;

&lt;p&gt;"Site A."&lt;/p&gt;

&lt;p&gt;"And if Site A is the one that goes dark?"&lt;/p&gt;

&lt;p&gt;Longer silence. Then the honest answer that every good engineer eventually gives.&lt;/p&gt;

&lt;p&gt;"Then nothing reaches Site B or Site C. The load balancer is the only way in."&lt;/p&gt;

&lt;p&gt;Three data centres. One front door. One key.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trap, Named
&lt;/h2&gt;

&lt;p&gt;That's the trap. A single point of failure does not show up as a single point of failure when it sits in front of a lot of redundancy. It hides in plain sight, because every diagram draws the load balancer as a small box at the top and the three big data centres underneath, and the eye goes to the big boxes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Redundancy is counted at the narrowest point of the path, not the widest.&lt;/strong&gt; Three sites behind one load balancer give you exactly the availability of that one load balancer. Not more.&lt;/p&gt;

&lt;h2&gt;
  
  
  What High Availability Actually Requires
&lt;/h2&gt;

&lt;p&gt;High availability is a property of a path, not of a building. For a request to survive the loss of a site, every hop it touches must have a living twin somewhere else, and the switch between the twins must happen without a human.&lt;/p&gt;

&lt;p&gt;Draw the path and the problem is obvious:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Visitor → DNS → Load balancer → Web / app tier → Database&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Each of those four layers has to span sites on its own terms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DNS&lt;/strong&gt; has to be able to point visitors somewhere else, or to point them at an address that is itself not tied to one site.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The load balancer&lt;/strong&gt; has to exist in more than one place, or be a service that lives outside all your sites.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The web and app tier&lt;/strong&gt; has to be running, warm and current in the second site, not "we can restore it from backup in four hours".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The database&lt;/strong&gt; has to be replicated to the second site with a defined way to promote the replica, or the app tier there has nothing to talk to.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Lose any one layer and the other three do not matter. In the review I described, the provider had done the hardest part — the buildings — and skipped the cheapest part, the front door.&lt;/p&gt;

&lt;p&gt;This pattern is common in three places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Providers and enterprises that grew from one site to two or three, and kept the original load balancer because "it works".&lt;/li&gt;
&lt;li&gt;Hardware load balancer deployments where a second appliance was quoted, judged expensive, and quietly dropped from the purchase order.&lt;/li&gt;
&lt;li&gt;Virtual load balancers that were deployed as one VM in one cluster and never moved to an HA pair because nobody owned that line of the design.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why Nobody Sees It in Testing
&lt;/h2&gt;

&lt;p&gt;Failover tests are almost always run from inside. Someone powers off a web server, watches the load balancer mark it down, and sees the site stay up. That proves the load balancer works. It proves nothing about what happens when the load balancer is the thing that is gone.&lt;/p&gt;

&lt;p&gt;The honest test — pull the uplink of the site that hosts the load balancer — is one almost nobody schedules, because it takes the whole platform down for the duration if the design is wrong. So the design is never proven wrong. It is simply believed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Absence of a failed test is not evidence of a passing one.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There is a second reason the gap stays hidden. Monitoring usually runs from the same site as the load balancer. When that site dies, the monitor dies with it, and the dashboard that would have shown red is itself unreachable. The first alert comes from a customer.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Actually Check This
&lt;/h2&gt;

&lt;p&gt;Before anyone promises redundancy, I run four read-only checks. None of them change anything. All of them fit in ten minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Find the single address everything resolves to.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short www.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the answer is one IP address, and that address is a physical or virtual device in one building, you have found the front door. Write down the address and the site it lives in. If the answer is a set of Cloudflare or other anycast addresses, the front door is already outside your sites, and the question moves to how the origins behind it are configured.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: Ask where that address is announced from.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;traceroute &lt;span class="nt"&gt;-n&lt;/span&gt; &amp;lt;that IP&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the last three hops. If they are routers in Site A every time, the address is reachable only through Site A. Some teams answer "we can re-announce it from Site B" — ask how long that takes and who does it at three in the morning. A manual re-announcement is a recovery procedure, not high availability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3: Count the load balancers.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;On a Linux load balancer pair, this one command tells you if a virtual IP is in play at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ip addr show | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A1&lt;/span&gt; &lt;span class="s2"&gt;"inet "&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the public-facing address appears as a secondary address on an interface and a &lt;code&gt;keepalived&lt;/code&gt; or &lt;code&gt;haproxy&lt;/code&gt; process is running, you are looking at one member of a pair. If the address is the only one on the box and there is no VRRP process, you are looking at a single device. Then check its twin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl status keepalived
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No such unit, no twin.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4: Ask the database the same question.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the layer teams forget. A second site with a load balancer and an app tier but a database that lives only in Site A is still a single site. Confirm replication is real and current:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mysql &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"SHOW REPLICA STATUS&lt;/span&gt;&lt;span class="se"&gt;\G&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"Replica_IO_Running|Replica_SQL_Running|Seconds_Behind_Source"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both &lt;code&gt;Running&lt;/code&gt; values must be &lt;code&gt;Yes&lt;/code&gt;, and the lag has to be a number you can live with. On older MySQL and MariaDB the command is &lt;code&gt;SHOW SLAVE STATUS\G&lt;/code&gt; and the fields are &lt;code&gt;Slave_IO_Running&lt;/code&gt; and &lt;code&gt;Seconds_Behind_Master&lt;/code&gt;. If this returns nothing, there is no replica, and no amount of load-balancer work will give you a second site.&lt;/p&gt;

&lt;p&gt;This step is where most tutorials stop and where mine starts. The checks above only tell you the shape of the problem. The fix is below.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Actually Fix the Front Door
&lt;/h2&gt;

&lt;p&gt;There are two honest answers. Which one is right depends on whether you already use Cloudflare.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option A — Move the load balancer outside your buildings (Cloudflare Load Balancing)
&lt;/h3&gt;

&lt;p&gt;If the site is already proxied through Cloudflare, this is the fastest path, because it adds no hardware and no new network design. The provider in my story was already on Cloudflare for WAF and SSL, which is exactly why I recommended it.&lt;/p&gt;

&lt;p&gt;The idea is simple. Cloudflare's edge becomes the load balancer. Each of your sites becomes an &lt;strong&gt;origin&lt;/strong&gt; inside a &lt;strong&gt;pool&lt;/strong&gt;. A &lt;strong&gt;health monitor&lt;/strong&gt; probes every origin from the outside, and when Site A stops answering, traffic is steered to Site B and Site C with no device of yours in the path.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Visitor → Cloudflare edge (LB + health checks) → Origin in Site A | Site B | Site C&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Give each site its own public origin address.&lt;/strong&gt; Each data centre needs an address that is reachable without passing through another data centre. If your three sites today share one address that lives in Site A, this step is the real work: you need a web or app entry point per site. It can be a small HAProxy or Nginx VM in each site, or a direct address on the app tier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: Create a health monitor.&lt;/strong&gt; Cloudflare → Traffic → Load Balancing → Manage Monitors → Create. Use an HTTPS probe to a path that touches the application, not just the web server — a &lt;code&gt;/health&lt;/code&gt; endpoint that opens a database connection is far more honest than &lt;code&gt;/&lt;/code&gt;. Set the expected code to 200.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3: Create a pool per site, attach the monitor.&lt;/strong&gt; Cloudflare → Traffic → Load Balancing → Manage Pools → Create. One pool per data centre, with that site's origin inside it. Attach the monitor and set a notification email, so a site going red tells a human even though failover is automatic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4: Create the load balancer on the hostname.&lt;/strong&gt; Cloudflare → Traffic → Load Balancing → Create Load Balancer. Hostname &lt;code&gt;www.example.com&lt;/code&gt;, pools in failover order, proxied on. Choose a steering policy — "Off" gives strict failover in pool order, which is the right starting point for an active–standby design. Save.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 5: Verify from outside — twice.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://www.example.com | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 200 and a &lt;code&gt;cf-ray&lt;/code&gt; header prove you are reaching the application through Cloudflare. Now the test that nobody ran before: block the health-check path on Site A's origin for two minutes, or simply stop its web service, and run the same &lt;code&gt;curl&lt;/code&gt; again. The response must still be 200, and Cloudflare → Traffic → Load Balancing must show Site A's pool as Unhealthy and the others Healthy. Bring Site A back and confirm the pool returns to Healthy.&lt;/p&gt;

&lt;p&gt;This error is not a bug. It is the verification. If the &lt;code&gt;curl&lt;/code&gt; fails while Site A is down, the second site is not yet an independent origin, and you have found the real work.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option B — Keep it on-premises (keepalived + virtual IP)
&lt;/h3&gt;

&lt;p&gt;If the traffic cannot leave your network — a private portal, a regulatory constraint, an internal API — the on-premises answer is a pair of load balancers sharing a &lt;strong&gt;virtual IP&lt;/strong&gt; with VRRP, implemented by &lt;code&gt;keepalived&lt;/code&gt;. Two HAProxy or Nginx nodes hold the public address between them. One is MASTER and answers; the other is BACKUP and takes the address within a few seconds when the master disappears.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Visitor → Virtual IP (VRRP) → HAProxy-1 (MASTER) | HAProxy-2 (BACKUP) → App tier&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Install on both nodes.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;keepalived haproxy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 2: Configure VRRP on the first node.&lt;/strong&gt; This is the entire file for a minimal pair. Mark this as a change — it adds an IP address to the box.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;vrrp_script&lt;/span&gt; &lt;span class="n"&gt;chk_haproxy&lt;/span&gt; {
    &lt;span class="n"&gt;script&lt;/span&gt; &lt;span class="s2"&gt;"/usr/bin/pgrep haproxy"&lt;/span&gt;
    &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
    &lt;span class="n"&gt;weight&lt;/span&gt; -&lt;span class="m"&gt;20&lt;/span&gt;
}

&lt;span class="n"&gt;vrrp_instance&lt;/span&gt; &lt;span class="n"&gt;VI_1&lt;/span&gt; {
    &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="n"&gt;MASTER&lt;/span&gt;
    &lt;span class="n"&gt;interface&lt;/span&gt; &lt;span class="n"&gt;eth0&lt;/span&gt;
    &lt;span class="n"&gt;virtual_router_id&lt;/span&gt; &lt;span class="m"&gt;51&lt;/span&gt;
    &lt;span class="n"&gt;priority&lt;/span&gt; &lt;span class="m"&gt;101&lt;/span&gt;
    &lt;span class="n"&gt;advert_int&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
    &lt;span class="n"&gt;authentication&lt;/span&gt; {
        &lt;span class="n"&gt;auth_type&lt;/span&gt; &lt;span class="n"&gt;PASS&lt;/span&gt;
        &lt;span class="n"&gt;auth_pass&lt;/span&gt; &amp;lt;&lt;span class="n"&gt;SHARED_SECRET&lt;/span&gt;&amp;gt;
    }
    &lt;span class="n"&gt;virtual_ipaddress&lt;/span&gt; {
        &lt;span class="m"&gt;203&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;113&lt;/span&gt;.&lt;span class="m"&gt;10&lt;/span&gt;/&lt;span class="m"&gt;24&lt;/span&gt;
    }
    &lt;span class="n"&gt;track_script&lt;/span&gt; {
        &lt;span class="n"&gt;chk_haproxy&lt;/span&gt;
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the second node, the same file with &lt;code&gt;state BACKUP&lt;/code&gt; and &lt;code&gt;priority 100&lt;/code&gt;. The &lt;code&gt;track_script&lt;/code&gt; block is the part people leave out: without it, keepalived only fails over when the whole node dies, not when HAProxy itself has crashed while the node is still up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3: Start and verify the address is held.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; keepalived
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ip addr show eth0 | &lt;span class="nb"&gt;grep &lt;/span&gt;203.0.113.10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The virtual IP must appear on the MASTER and not on the BACKUP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4: Fail it and watch.&lt;/strong&gt; Stop HAProxy on the master and run the same &lt;code&gt;ip addr show&lt;/code&gt; on the backup. The address must move within about three seconds. Start HAProxy again and it moves back. If it does not move, the track script is wrong or the two nodes cannot see each other's VRRP multicast — check that the switch and any firewall between them allow protocol 112.&lt;/p&gt;

&lt;p&gt;A word of honesty about Option B: a VRRP pair protects you against the loss of one load balancer. It does not by itself protect you against the loss of a whole site, because both nodes normally sit on the same subnet in the same building. To span sites with a virtual IP you need a stretched layer 2 network or a routed design with BGP, and at that point Option A is usually cheaper and safer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bigger Lesson — the Four-Layer Redundancy Checklist
&lt;/h2&gt;

&lt;p&gt;Before the word "redundant" goes on a slide, each of these must have an answer and a command that proves it.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;DNS.&lt;/strong&gt; Can visitors be steered away from a dead site without a human editing a record? Check: &lt;code&gt;dig +short www.example.com&lt;/code&gt; — is it a single site-bound address?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load balancer.&lt;/strong&gt; Is there a second one, somewhere else, that takes over on its own? Check: &lt;code&gt;systemctl status keepalived&lt;/code&gt; on-prem, or Cloudflare → Traffic → Load Balancing → pools listed as Healthy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;App tier.&lt;/strong&gt; Is the second site running the current release right now? Check: &lt;code&gt;curl -sI https://&amp;lt;site-B-origin&amp;gt;/health&lt;/code&gt; — it must be 200 today, not "after a restore".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database.&lt;/strong&gt; Is the replica current and is promotion documented? Check: &lt;code&gt;SHOW REPLICA STATUS\G&lt;/code&gt; — both threads &lt;code&gt;Yes&lt;/code&gt;, lag acceptable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitoring.&lt;/strong&gt; Does at least one probe run from outside all your sites? Check: where does the monitor that would page you actually live?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The real test.&lt;/strong&gt; Has the site that hosts the primary load balancer ever been taken offline on purpose, with customers watching? If not, the design is a belief.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Plain takeaway: &lt;strong&gt;three sites behind one door is one site with two very expensive spare rooms.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Tell Every Customer Now
&lt;/h2&gt;

&lt;p&gt;In the next infrastructure review, ask one question:&lt;/p&gt;

&lt;p&gt;"If the building with the load balancer loses power, what is the first thing a visitor sees — and who had to do anything to make that happen?"&lt;/p&gt;

&lt;p&gt;If the answer is "the same page as always, and nobody", they're in good shape. If the answer is "we re-announce the address from the other site" or "we have never tried", we're not done yet, and the fix is a health check and a pool away.&lt;/p&gt;

&lt;p&gt;I write about the questions I ask in real customer rooms — the ones that change architectures and surface the problems no one else noticed. If you found this useful, drop your email below and I'll send the next one when it's ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is a second data centre enough for high availability?
&lt;/h3&gt;

&lt;p&gt;No. A second data centre is a place where redundancy can live, not redundancy itself. The request path has four layers — DNS, load balancer, application tier and database — and each one has to exist and fail over in the second site on its own. If any one of them lives only in the first site, the second site cannot serve a single request when the first is down.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why is a single load balancer a single point of failure even with three data centres?
&lt;/h3&gt;

&lt;p&gt;Because every request has to pass through it before it reaches any of the three sites. Availability is set by the narrowest hop in the path, and a lone load balancer in one building is exactly as available as that building. Three sites behind it inherit its failure, not the other way round.&lt;/p&gt;

&lt;h3&gt;
  
  
  How does Cloudflare Load Balancing provide high availability across data centres?
&lt;/h3&gt;

&lt;p&gt;Cloudflare runs the load balancer at its own edge, outside all of your sites. You define each data centre as an origin in a pool, attach a health monitor that probes it from the internet, and set a steering policy. When a site stops passing the health check, Cloudflare routes new requests to the remaining healthy pools automatically, with no device of yours in the path and no DNS change by a human.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is keepalived and when should I use it instead of a cloud load balancer?
&lt;/h3&gt;

&lt;p&gt;keepalived implements VRRP on Linux so two load balancer nodes share one virtual IP address. The backup node takes over the address within seconds when the master or its HAProxy process fails. Use it when traffic must stay inside your own network. Remember that a standard VRRP pair sits on one subnet in one site, so it protects against a dead load balancer, not a dead building.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I test whether my setup really fails over?
&lt;/h3&gt;

&lt;p&gt;Take the primary load balancer, or the whole site hosting it, offline on purpose and run an external check at the same time. A plain &lt;code&gt;curl -sI https://www.example.com&lt;/code&gt; from outside your network must still return 200 while the primary is down. If it does not, you have not tested failover before; you have only tested a healthy system.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does the database need to be in both data centres too?
&lt;/h3&gt;

&lt;p&gt;Yes. An application tier in the second site with no database to talk to is a web page that returns errors. The database must be replicated to the second site, the replica must be current, and there must be a documented, preferably automated, way to promote it. Check with &lt;code&gt;SHOW REPLICA STATUS\G&lt;/code&gt; that both replication threads are running and the lag is acceptable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://quabin.com/wp-content/uploads/2026/10/quabin-three-data-centres-no-ha-load-balancer.pdf" rel="noopener noreferrer"&gt;Download the PDF&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://quabin.com/three-data-centres-no-ha-load-balancer/" rel="noopener noreferrer"&gt;quabin.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>highavailability</category>
      <category>cloudflare</category>
      <category>architecture</category>
      <category>devops</category>
    </item>
    <item>
      <title>RPO Zero Is a Network Question, Not a Backup Question — What 'No Data Loss' Really Costs Between Two Sites</title>
      <dc:creator>Daanial Farrukh</dc:creator>
      <pubDate>Wed, 04 Nov 2026 17:19:41 +0000</pubDate>
      <link>https://dev.to/daanial/rpo-zero-is-a-network-question-not-a-backup-question-what-no-data-loss-really-costs-between-fe0</link>
      <guid>https://dev.to/daanial/rpo-zero-is-a-network-question-not-a-backup-question-what-no-data-loss-really-costs-between-fe0</guid>
      <description>&lt;p&gt;&lt;em&gt;A presales war story about a revision request that read "RPO ≈ 0 and the lowest possible RTO" — the sentence everyone files under backup and almost nobody files under latency — and the four numbers I now collect before I promise any recovery point.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The first version of the proposal had been accepted in principle. Primary site in one city, a warm disaster-recovery site in another, the database replicated asynchronously, a recovery time of a few hours. Normal. Defensible. Then the revision request arrived.&lt;/p&gt;

&lt;p&gt;"We now need RPO of approximately zero and the lowest RTO you can commit to. Please update the technical and financial proposals."&lt;/p&gt;

&lt;p&gt;I joined the call with their database team already on it.&lt;/p&gt;

&lt;p&gt;"Zero means zero," said their lead DBA. "Our team will check the design. Do not write 'near zero' and hope."&lt;/p&gt;

&lt;p&gt;"Understood. Which component do you expect to deliver it?"&lt;/p&gt;

&lt;p&gt;"Your backup. You proposed a backup service. Make it continuous."&lt;/p&gt;

&lt;p&gt;"A backup cannot give you zero. It can give you a copy from a point in time. How far apart are your two sites, in milliseconds?"&lt;/p&gt;

&lt;p&gt;Silence.&lt;/p&gt;

&lt;p&gt;"And what is the peak redo rate of the database, in megabytes per second?"&lt;/p&gt;

&lt;p&gt;"Why does that matter for a backup?"&lt;/p&gt;

&lt;p&gt;That question — asked honestly, by a competent team — is the whole article.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trap, Named
&lt;/h2&gt;

&lt;p&gt;That's the trap. &lt;strong&gt;RPO&lt;/strong&gt; is a phrase from the backup world, so people hand it to the backup product. But a recovery point of zero cannot be produced by anything that copies data on a schedule, however short the schedule. It can only be produced by a system that refuses to confirm a write until a second site has it. That system is synchronous replication, and whether it is possible at all is decided by one network link — its round-trip time and its bandwidth against the database's peak write rate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RPO zero is not a feature you buy. It is a latency budget you either have or do not have.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What RPO Actually Means for Each Kind of Copy
&lt;/h2&gt;

&lt;p&gt;Here's the part that trips people up. Three different things get called "a copy of the database", and each one carries a different recovery point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A backup&lt;/strong&gt; writes a restore point at intervals. Nightly full, hourly incremental, whatever the policy says. Your RPO is the gap between restore points — hours, sometimes a day. In the design I was defending, the backup tier was a hardened repository with a 7-day immutability window. Its job is to survive ransomware and human error. Its job is not to be current.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Asynchronous replication&lt;/strong&gt; streams every change to the standby as fast as the link allows, but the primary commits first and ships later. The recovery point is the transport lag — normally seconds, sometimes a minute when the link is busy. The primary never waits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Synchronous replication&lt;/strong&gt; sends the change to the standby and waits for the acknowledgement before the primary tells the application "committed". The recovery point is zero. The cost is that every single commit now includes a round trip to the other city.&lt;/p&gt;

&lt;p&gt;In arrow form, the synchronous path for one transaction looks like this:&lt;/p&gt;

&lt;p&gt;Application commit → primary writes redo → redo crosses the link → standby writes it → acknowledgement crosses the link back → primary returns "committed" to the application&lt;/p&gt;

&lt;p&gt;Two trips across the link, inside every commit, for as long as the database runs. If the round-trip time between the sites is 20 milliseconds, every commit grows by roughly 20 milliseconds. A busy order-entry screen that used to commit in 2 milliseconds now commits in 22. Nobody changed the application. The requirement did it.&lt;/p&gt;

&lt;p&gt;Where this confusion is common:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tender requirement sheets, where "RPO" and "backup" sit in the same table row and get read as one thing.&lt;/li&gt;
&lt;li&gt;Marketing for "continuous data protection", which sounds like zero and usually means seconds.&lt;/li&gt;
&lt;li&gt;Database teams and infrastructure teams who each assume the other side owns the replication link.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why Nobody Sees It in Testing
&lt;/h2&gt;

&lt;p&gt;A disaster-recovery drill is the quietest moment in the database's year. It runs at night, on a weekend, with the application mostly idle. The redo rate is a trickle. The standby is "fully in sync" within a second of any change because there is almost nothing to send. Commit latency looks fine because almost nobody is committing.&lt;/p&gt;

&lt;p&gt;Then the design goes live, month-end arrives, the redo rate climbs to its real peak, and one of two things happens. In asynchronous mode the transport lag stretches — your "seconds" become "a minute or two" exactly when the data matters most. In synchronous mode the application slows down across the board, and if the link flaps, the primary stalls or drops back to asynchronous on its own. Either way, the drill never showed it, because the drill never carried load.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Absence of lag at 2 a.m. is not proof of RPO. It is proof that nothing was happening.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Replication Must Never Be Sold as Backup
&lt;/h2&gt;

&lt;p&gt;This is the second thing the revision request nearly flattened, and the customer's own tender had a section demanding the opposite — a clear separation between backup and disaster recovery. Their DBA team would have caught any blurring, and rightly so.&lt;/p&gt;

&lt;p&gt;Replication copies every change, including the bad ones. A dropped table arrives on the standby in seconds. An application bug that corrupts rows corrupts them in both cities. Ransomware that encrypts data through the application layer is replicated faithfully. The standby is a mirror, and a mirror reflects the damage.&lt;/p&gt;

&lt;p&gt;The backup is the thing that does not follow. It holds restore points from before the mistake, in a repository the production credentials cannot modify, for a fixed retention window. In the design that stayed, the database was backed up natively by the database's own tooling to that repository, and the replication product was listed under "disaster recovery" with its own column. Two products, two columns, two jobs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plain takeaway:&lt;/strong&gt; if a proposal has one line that claims to be both your backup and your zero-RPO replica, it is neither.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Actually Decide: The Four Numbers
&lt;/h2&gt;

&lt;p&gt;Before anyone writes "RPO 0" into a commitment, collect these four numbers. None of them come from the backup product.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Measure the round-trip time between the two sites.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is read-only and takes a minute. From a server at the primary site:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ping &lt;span class="nt"&gt;-c&lt;/span&gt; 100 203.0.113.20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the last line. The average round-trip time is the cost you will add to every synchronous commit. The maximum tells you what a bad moment looks like. If you want the path as well as the number:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mtr &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; 100 203.0.113.20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected output is a stable average with a small spread. A failure looks like an average that is fine and a maximum ten times higher — that spike is what a synchronous commit will wait for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: Measure the peak redo rate, not the average.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The database already knows how much change it generates. For Oracle, the archived redo gives you a per-hour history over the last days:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;TRUNC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;completion_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'HH24'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ROUND&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;blocks&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;block_size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;mb&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;archived_log&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;completion_time&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;SYSDATE&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;TRUNC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;completion_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'HH24'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Find the busiest hour, divide by 3,600, and you have peak redo in megabytes per second. That peak — not the daily average — is what the link must carry without the lag growing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3: Size the link against the peak, with headroom.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Oracle's own Data Guard guidance sizes the transport link by dividing the redo rate by 0.7 to leave room for protocol overhead, then converting to bits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Required Mbps = ( peak_redo_bytes_per_second / 0.7 ) x 8 / 1,000,000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the number comes out above the link you have, asynchronous lag will grow at peak and synchronous commits will queue. In the case that started this article, the peak redo rate was one of the three facts we had to request from the customer before the revision could be honest, together with the measured latency and the current link bandwidth. Nobody had them to hand. That is normal, and it is exactly why the question matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4: Choose the protection mode and name who presses the button.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;With the three numbers in front of you, the mode chooses itself. If the round-trip time is a few milliseconds and the link has headroom at peak, synchronous is affordable. If the sites are a city apart and the link is shared or thin, asynchronous with a committed lag ceiling is the honest answer. Read the current state from the database, not from the diagram:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;dataguard_stats&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'transport lag'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'apply lag'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Transport lag is your real RPO right now. Apply lag is how far behind the standby is in applying what it already has. Run it during the busiest hour you found in Step 2, not on a Sunday.&lt;/p&gt;

&lt;p&gt;Then write down who performs the failover. Automatic failover needs an observer process that watches both databases from a third location and makes the switch. If the customer's database team owns that observer — which is what we agreed, with the observer VM provided in a third data centre — then the recovery time splits into two owned parts: the provider's part (the standby platform is running and traffic is redirected) and the customer's part (the database role switch). Commit to the part you control. In our case that was 15 minutes for the platform and the traffic switch, and the commitment said so in plain words.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 5: Verify under load. This step is where most tutorials stop and where mine starts.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Run the drill with the application busy, or replay a representative load. Measure commit latency on the primary before and after switching the mode. Watch transport lag during the run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time_computed&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;dataguard_stats&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'transport lag'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the lag climbs steadily during the test and only recovers when load stops, the link is undersized for that peak. If commit latency doubled when you enabled synchronous transport, that is not a bug. It is the verification — the round trip you measured in Step 1, showing up exactly where the arithmetic said it would.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Committed Instead of "Zero"
&lt;/h2&gt;

&lt;p&gt;The honest version was shorter than the ambitious one, and the customer's database team accepted it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Replication stayed &lt;strong&gt;asynchronous&lt;/strong&gt; over one dedicated link between the two sites. No extra relay hop, no second link.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RPO committed as under one minute&lt;/strong&gt; — typically seconds, explicitly "not zero" — because that is what the measured path could promise without slowing production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RTO 15 minutes&lt;/strong&gt;, scoped to the provider's part: the disaster-recovery platform kept hot at full production size, and the traffic switch at the edge. The database failover belonged to the customer's observer.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;observer&lt;/strong&gt; ran on a VM in a third data centre, owned and configured by the customer's team, with the provider guiding.&lt;/li&gt;
&lt;li&gt;Backup stayed a &lt;strong&gt;separate line&lt;/strong&gt; — native database backups into a hardened, immutable repository — and was never described as part of the recovery-point story.&lt;/li&gt;
&lt;li&gt;One &lt;strong&gt;yearly drill&lt;/strong&gt;, under load, with the lag query above as the exit criterion.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nothing in that list is exotic. All of it is measurable. That is the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bigger Lesson: Six Checks Before You Promise a Recovery Point
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;You know the round-trip time between the sites.&lt;/strong&gt; &lt;code&gt;ping -c 100 &amp;lt;standby-ip&amp;gt;&lt;/code&gt; — the average is your synchronous commit tax.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You know the peak redo rate, by hour, for the last week.&lt;/strong&gt; The &lt;code&gt;v$archived_log&lt;/code&gt; query above — size the link to the peak.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The link has headroom at that peak.&lt;/strong&gt; Redo bytes per second ÷ 0.7 × 8 — if the result is above the link, lag will grow when it matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The protection mode is stated in words a DBA can verify.&lt;/strong&gt; &lt;code&gt;v$dataguard_stats&lt;/code&gt; during peak hour — transport lag is the real RPO.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failover has a named owner and a named observer location.&lt;/strong&gt; If the answer is "the platform handles it", ask which process, on which host, in which site.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backup and replication are separate lines with separate jobs.&lt;/strong&gt; One holds history behind immutability; the other mirrors the present. Never the same box.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What I Tell Every Customer Now
&lt;/h2&gt;

&lt;p&gt;When a requirement says "RPO zero", I ask one question before I touch the proposal:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"What is the round-trip time between your two sites, and what is your peak redo rate?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If they answer with two numbers, they are in good shape — the design is arithmetic from there. If the answer is "our backup vendor handles that", we're not done yet. The backup vendor was never asked that question either.&lt;/p&gt;

&lt;p&gt;I write about the questions I ask in real customer rooms — the ones that change architectures and surface the problems no one else noticed. If you found this useful, drop your email below and I'll send the next one when it's ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can a backup product deliver RPO zero?
&lt;/h3&gt;

&lt;p&gt;No. A backup writes restore points at intervals, so the recovery point is always the gap between the last restore point and the failure. Even "continuous" backup products capture changes with a small delay. Zero data loss needs synchronous replication, where the primary database waits for the standby to acknowledge each change before confirming the commit.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does synchronous replication cost in practice?
&lt;/h3&gt;

&lt;p&gt;Every commit waits for a round trip to the standby site. If the two sites are 20 milliseconds apart, every commit grows by roughly 20 milliseconds, all day, for every transaction. Measure the round-trip time with ping or mtr first; that number is the price of zero.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between transport lag and apply lag in Oracle Data Guard?
&lt;/h3&gt;

&lt;p&gt;Transport lag is how much redo the primary has generated that has not yet reached the standby — that is your real recovery point at this moment. Apply lag is how far the standby is behind in applying redo it already holds, which affects recovery time rather than data loss. Both come from the view v$dataguard_stats and should be read during peak hours.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is Oracle Data Guard a backup?
&lt;/h3&gt;

&lt;p&gt;No. Data Guard mirrors changes continuously, so a dropped table, a corrupting bug or ransomware acting through the application reaches the standby within seconds. A backup holds restore points from before the mistake, ideally in an immutable repository. Keep them as separate products with separate lines in any proposal or contract.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I size the network link for database replication?
&lt;/h3&gt;

&lt;p&gt;Find the peak redo rate — the busiest hour from v$archived_log divided by 3,600 — not the daily average. Oracle's guidance divides that rate by 0.7 for overhead and converts to bits per second. If the result exceeds the link, asynchronous lag grows at peak and synchronous commits queue.&lt;/p&gt;

&lt;h3&gt;
  
  
  Who should own the failover in a provider-hosted DR design?
&lt;/h3&gt;

&lt;p&gt;Name it explicitly. Automatic database failover needs an observer process in a third location; whoever configures and runs that observer owns the database part of the recovery time. The provider commits to its own part — the standby platform running and traffic redirected — and the agreement should state both parts and both owners.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://quabin.com/wp-content/uploads/2026/11/quabin-rpo-zero-network-question-not-backup.pdf" rel="noopener noreferrer"&gt;Download the PDF&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://quabin.com/rpo-zero-network-question-not-backup/" rel="noopener noreferrer"&gt;quabin.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>disasterrecovery</category>
      <category>oracle</category>
      <category>backup</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Locked Out of a Windows VM After a Cloud-Admin Restart — Cloudbase-Init and the Password Nobody Set</title>
      <dc:creator>Daanial Farrukh</dc:creator>
      <pubDate>Fri, 30 Oct 2026 17:18:54 +0000</pubDate>
      <link>https://dev.to/daanial/locked-out-of-a-windows-vm-after-a-cloud-admin-restart-cloudbase-init-and-the-password-nobody-set-1ijn</link>
      <guid>https://dev.to/daanial/locked-out-of-a-windows-vm-after-a-cloud-admin-restart-cloudbase-init-and-the-password-nobody-set-1ijn</guid>
      <description>&lt;p&gt;&lt;em&gt;A presales war story about an Administrator password that changed with "SYSTEM" as the subject, the OpenStack service almost nobody remembers is still installed, and the one log line I now read before anyone says "we were hacked".&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The call started with two groups of people certain they had done nothing.&lt;/p&gt;

&lt;p&gt;"We did not touch the password. We did not touch the network card. Your team did."&lt;/p&gt;

&lt;p&gt;That was the customer. Their application server, a Windows VM on a private OpenStack cloud, had been shut down the previous evening by their own administrator, from the console. The cloud team started it again a few minutes later. When the customer tried to log in the next morning, the Administrator password was wrong. The machine name had changed. The network adapter was disabled.&lt;/p&gt;

&lt;p&gt;"We only pressed Start. Nothing else," said the cloud team.&lt;/p&gt;

&lt;p&gt;I asked for one thing before anyone said the word "breach".&lt;/p&gt;

&lt;p&gt;"Can you export the Security log from the VM?"&lt;/p&gt;

&lt;p&gt;"It says SYSTEM did it. So it was not a person. So it was the platform."&lt;/p&gt;

&lt;p&gt;"SYSTEM is not a person. SYSTEM is a service. Which service?"&lt;/p&gt;

&lt;p&gt;Silence.&lt;/p&gt;

&lt;p&gt;Forty minutes later I had the answer, and it was neither group. It was a service that had been sitting on that VM since the day the image was built, doing nothing for months, waiting for a reason to run again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trap, Named
&lt;/h2&gt;

&lt;p&gt;That's the trap. When a Windows event says the subject was &lt;strong&gt;SYSTEM&lt;/strong&gt;, people read it as "no human was involved" and stop there. Some read it as proof of a hack. Some read it as proof the hypervisor did something. Almost nobody asks the next question: which process, running as SYSTEM, made that change at that second.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A SYSTEM-subject password reset is not the end of the investigation. It is the first clue about which service to go and read.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What Cloudbase-Init Actually Is
&lt;/h2&gt;

&lt;p&gt;Here's the part people miss. Every Windows image built for OpenStack carries a small service called &lt;strong&gt;Cloudbase-Init&lt;/strong&gt;. It is the Windows equivalent of cloud-init on Linux. On the first boot of a new instance it reads metadata from the cloud and does the things that make a fresh VM usable:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Cloud metadata → Cloudbase-Init service → set hostname → create user → set password → extend disk → run user-data → reboot&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Each of those is a plugin. The ones that matter in this story:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SetHostNamePlugin&lt;/strong&gt; renames Windows to the instance name the cloud knows, then schedules a reboot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CreateUserPlugin&lt;/strong&gt; makes sure the admin account exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SetUserPasswordPlugin&lt;/strong&gt; sets that account's password. If the cloud passed one in metadata, it uses it. If not, &lt;strong&gt;it generates a random one&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The design is correct for a brand-new instance. The random password is meant to be encrypted with the SSH public key the VM was launched with and posted back to the metadata service, so the owner can retrieve it from the cloud. If there is no key, there is nothing to encrypt with, and Cloudbase-Init says so in its log and moves on.&lt;/p&gt;

&lt;p&gt;Cloudbase-Init remembers which plugins have already run. It keeps a per-instance status in the Windows registry, under &lt;code&gt;HKLM\SOFTWARE\Cloudbase Solutions\Cloudbase-Init\&amp;lt;instance-id&amp;gt;\Plugins&lt;/code&gt;. On every later boot the service starts, checks the status, and for each plugin logs "execution already done, skipping". That is the normal life of the service: start, skip everything, stop. For months.&lt;/p&gt;

&lt;p&gt;Until one boot where it does not skip.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Nobody Sees It
&lt;/h2&gt;

&lt;p&gt;Three reasons.&lt;/p&gt;

&lt;p&gt;First, the service is invisible by design. It runs for a few seconds at boot, writes to a log under &lt;code&gt;C:\Program Files\Cloudbase Solutions\&lt;/code&gt;, and exits. Nobody opens that folder on a server that has been in production for a year.&lt;/p&gt;

&lt;p&gt;Second, the symptom looks exactly like an intruder. A password reset you did not do. A hostname you did not choose. A network card you did not disable. If you are the customer, that is a compromise. If you are the cloud team, that is the customer forgetting what they did. Both readings are emotionally satisfying and both are wrong.&lt;/p&gt;

&lt;p&gt;Third, the Security log is honest but incomplete. Event 4724, "An attempt was made to reset an account's password", records the subject as &lt;code&gt;SYSTEM&lt;/code&gt;. It does not record the process. The one log that names the process is the one nobody reads.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Absence of a human in the event log is not absence of a cause. It is a pointer to a service.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Actually Prove It
&lt;/h2&gt;

&lt;p&gt;Everything here is read-only. Run it from an elevated PowerShell on the affected VM, or against an exported log on another machine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Find the password resets and the exact second they happened.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Get-WinEvent&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-FilterHashtable&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;@{&lt;/span&gt;&lt;span class="nx"&gt;LogName&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'Security'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4724&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-MaxEvents&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;20&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Format-List&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;TimeCreated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Message&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In this case there were two 4724 events ten seconds apart, both with Subject Account Name &lt;code&gt;SYSTEM&lt;/code&gt;, both targeting &lt;code&gt;Administrator&lt;/code&gt;. Note the timestamps to the second. You will match them in the next step.&lt;/p&gt;

&lt;p&gt;Also pull 4723, "An attempt was made to change an account's password". That is the event you get when a logged-in user changes a password themselves. If you see 4723 with a named user, a person was at the keyboard. In this case there was one 4723, the next evening, from an interactive Administrator session, and that one was the customer's own recovery attempt. Keep the two event IDs apart in your head: &lt;strong&gt;4724 is a reset done to an account; 4723 is a change done by the account.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: Find out who rebooted the machine and why.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Get-WinEvent&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-FilterHashtable&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;@{&lt;/span&gt;&lt;span class="nx"&gt;LogName&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'System'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1074&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-MaxEvents&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;10&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Format-List&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;TimeCreated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Message&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Event 1074 names the process and the reason for every shutdown and restart. Here it showed two entries in the window. The first was the customer's own shutdown from the console the previous evening. The second, a minute after the cloud team pressed Start, read &lt;strong&gt;"Cloudbase-Init reboot"&lt;/strong&gt; in the reason field. That one line is the whole case. A service called Cloudbase-Init rebooted the server, and it only reboots after it has renamed the host.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3: Read the Cloudbase-Init log for the same seconds.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Select-String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Path&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"C:\Program Files\Cloudbase Solutions\Cloudbase-Init\log\cloudbase-init.log"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Pattern&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Generating a random user password|Password succesfully updated|No SSH public key|already done, skipping"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Select-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Last&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;40&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Yes, "succesfully" is spelled with one s in the log. Search for the misspelling or you will not find it.&lt;/p&gt;

&lt;p&gt;What you want to see is the contrast. In every earlier boot, line after line of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[-] Plugin 'cloudbaseinit.plugins.common.setuserpassword.SetUserPasswordPlugin' execution already done, skipping
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And then, in the boot after the restart, at the exact second of the first 4724 event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;setuserpassword [-] Generating a random user password
setuserpassword [-] No SSH public key available for password encryption
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it in words. The service decided to run its first-boot plugins again. It created or confirmed the admin user, generated a random password, found no key to encrypt it with, and set it anyway. The password now exists in exactly one place: the account itself. Nobody can retrieve it. Nobody did anything wrong at a keyboard. The machine is locked.&lt;/p&gt;

&lt;p&gt;When the timestamp of "Generating a random user password" matches the timestamp of the 4724 event with subject SYSTEM, the cause is proven. Not a theory. Proven.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4: Confirm the rename came from the same service.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Select-String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Path&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"C:\Program Files\Cloudbase Solutions\Cloudbase-Init\log\cloudbase-init.log"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Pattern&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sethostname|hostname"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Select-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Last&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;10&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hostname plugin logs the new name it applied and the reboot it requested. Match it to the 1074 "Cloudbase-Init reboot" event. Now you have one author for two of the three symptoms.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Part I Could Not Prove
&lt;/h2&gt;

&lt;p&gt;I want to be honest here, because this is where it is tempting to make the story tidier than it was.&lt;/p&gt;

&lt;p&gt;I proved the password reset. I proved the rename and the reboot. I did &lt;strong&gt;not&lt;/strong&gt; prove why the plugins ran again after months of skipping, and I did not prove that the disabled network adapter came from the same service.&lt;/p&gt;

&lt;p&gt;The documented mechanism is the per-instance plugin status in the registry. The status lives under a key named after the instance ID the service reads from metadata. If that key is not found, every plugin is new and runs. That can happen when the instance ID the service sees changes, or when the status key is gone. On this VM I could see that the plugins re-ran; I could not get the metadata history to say which of those it was. So in the report I wrote exactly that: cause of lockout proven, trigger of the re-run not proven, network card not attributed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A report that says "this part is proven and this part is not" is worth more than one that sounds complete.&lt;/strong&gt; The customer trusted the proven half because I did not stretch it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Actually Fix It
&lt;/h2&gt;

&lt;p&gt;Two jobs. Get back in, then make sure a restart can never do this again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Get back in.&lt;/strong&gt; This changes state.&lt;/p&gt;

&lt;p&gt;If the VM was launched with an SSH key pair and the cloud still has it, the random password is retrievable from the cloud side. On the OpenStack CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nova get-password &amp;lt;server&amp;gt; &amp;lt;private-key-file&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If, like here, there was no key, the cloud team resets the password from the platform instead. On OpenStack that is a password change through the compute service or the console. Any reset done this way is a new 4724 event. In our log, the following morning's resets were exactly that, and they did not appear in the Cloudbase-Init log at all, which is how you tell an admin's reset from the service's reset.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: Decide what Cloudbase-Init is allowed to do on a server that is already in production.&lt;/strong&gt; This changes state.&lt;/p&gt;

&lt;p&gt;Open the config file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;notepad "C:\Program Files\Cloudbase Solutions\Cloudbase-Init\conf\cloudbase-init.conf"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Find the &lt;code&gt;plugins=&lt;/code&gt; line. It is a comma-separated list of every plugin the service may run. On a VM that has been handed over to a customer, there is no reason for the service to ever rename the host or set the admin password again. Remove &lt;code&gt;SetHostNamePlugin&lt;/code&gt;, &lt;code&gt;CreateUserPlugin&lt;/code&gt; and &lt;code&gt;SetUserPasswordPlugin&lt;/code&gt; from the list and save. A restart is not required; the file is read at the next service start.&lt;/p&gt;

&lt;p&gt;Also check two settings that control the password behaviour:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Select-String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Path&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"C:\Program Files\Cloudbase Solutions\Cloudbase-Init\conf\cloudbase-init.conf"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Pattern&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"first_logon_behaviour|inject_user_password|plugins"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;inject_user_password&lt;/code&gt; is what posts the encrypted password back to the cloud; it only helps if the VM has a key. &lt;code&gt;first_logon_behaviour&lt;/code&gt; decides whether the user is forced to change the password at first logon. Neither of them stops the plugin from running. Only the &lt;code&gt;plugins=&lt;/code&gt; list does that.&lt;/p&gt;

&lt;p&gt;If the customer never wants cloud-side changes on this VM again, the stronger option is to disable the service entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Set-Service&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;cloudbase-init&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-StartupType&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Disabled&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I prefer trimming the plugin list over disabling the service, because the service also handles useful things like extending the disk after a resize. But both are legitimate. The point is to make a decision, not to leave the default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3: Keep a break-glass account.&lt;/strong&gt; This changes state.&lt;/p&gt;

&lt;p&gt;Create a second local administrator with a password stored in the customer's vault, not on the VM. Cloudbase-Init only manages the account named in its &lt;code&gt;username=&lt;/code&gt; setting. A second account is untouched by anything the service does. On this VM that one account would have turned a two-day outage into a two-minute login.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4: Verify. This step is where most tutorials stop and where mine starts.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Restart the VM once, on purpose, in a maintenance window. Then re-run the Step 1 and Step 3 checks from the proof section:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Get-WinEvent&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-FilterHashtable&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;@{&lt;/span&gt;&lt;span class="nx"&gt;LogName&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'Security'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4724&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-MaxEvents&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;5&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Format-List&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;TimeCreated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Message&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There must be &lt;strong&gt;no&lt;/strong&gt; new 4724 event at boot time. Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Select-String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Path&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"C:\Program Files\Cloudbase Solutions\Cloudbase-Init\log\cloudbase-init.log"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Pattern&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Generating a random user password"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Select-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Last&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;3&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last match must be the old one from the incident, not a fresh one from your test boot. If a new one appears, the plugin is still in the list, or the service is reading a different config file than the one you edited, and you go back to Step 2.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plain takeaway: a first-boot service you have not told to stop is a first-boot service that is waiting.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bigger Lesson — Four Questions Before Anyone Says "Breach"
&lt;/h2&gt;

&lt;p&gt;Whenever a Windows VM in a cloud comes back from a restart with something changed that "nobody did", run these in order.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;What did the 1074 events say?&lt;/strong&gt; The reason field often names the process. "Cloudbase-Init reboot" ends the argument in one line.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Who is the subject on the 4724 events?&lt;/strong&gt; A named user means a person. &lt;code&gt;SYSTEM&lt;/code&gt; means a service, and your next job is to find which one.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Does the Cloudbase-Init log have activity at the same second?&lt;/strong&gt; If yes, the service is the author. If no, the reset came from the hypervisor side or from a real intruder, and you escalate properly.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Is the first-boot service still allowed to run its first-boot plugins?&lt;/strong&gt; Check &lt;code&gt;plugins=&lt;/code&gt; in the config. On any VM older than its first week, the answer should be no for hostname and password.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the answers point at Cloudbase-Init, the fix is a config edit and a break-glass account. If they do not, you have a genuine incident and you have reached it in twenty minutes instead of two days.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Tell Every Customer Now
&lt;/h2&gt;

&lt;p&gt;In the next review, ask the team one question:&lt;/p&gt;

&lt;p&gt;"On your Windows cloud VMs, which Cloudbase-Init plugins are still enabled, and when did you last read its log?"&lt;/p&gt;

&lt;p&gt;If the answer is "we trimmed the list at handover and the log shows nothing but skips", they are in good shape. If the answer is "what is Cloudbase-Init?", we are not done yet.&lt;/p&gt;

&lt;p&gt;I write about the questions I ask in real customer rooms — the ones that change architectures and surface the problems no one else noticed. If you found this useful, drop your email below and I'll send the next one when it's ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why did my Windows VM's Administrator password change after a restart on OpenStack?
&lt;/h3&gt;

&lt;p&gt;Cloudbase-Init, the first-boot service installed in every OpenStack Windows image, re-ran its SetUserPasswordPlugin and generated a random password. The Security log shows this as event 4724 with subject SYSTEM. Confirm it by finding "Generating a random user password" in cloudbase-init.log at the same timestamp.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does "No SSH public key available for password encryption" mean in cloudbase-init.log?
&lt;/h3&gt;

&lt;p&gt;Cloudbase-Init wanted to encrypt the new random password with the key pair the VM was launched with, so the owner could retrieve it from the cloud. The VM had no key pair, so the password was set but never stored anywhere retrievable. That line is why the account is locked rather than just changed.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between Windows event 4724 and 4723?
&lt;/h3&gt;

&lt;p&gt;Event 4724 is a password reset performed on an account by someone or something else, such as an administrator or a service. Event 4723 is a password change made by the account itself from a logged-in session. A 4724 with subject SYSTEM points at a service; a 4723 with a user name points at a person at the keyboard.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I stop Cloudbase-Init from changing the hostname or password again?
&lt;/h3&gt;

&lt;p&gt;Edit cloudbase-init.conf under C:\Program Files\Cloudbase Solutions\Cloudbase-Init\conf and remove SetHostNamePlugin, CreateUserPlugin and SetUserPasswordPlugin from the plugins= list. Alternatively disable the cloudbase-init service. Then restart once in a maintenance window and confirm no new 4724 event appears at boot.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I recover the random password Cloudbase-Init generated?
&lt;/h3&gt;

&lt;p&gt;Only if the VM was launched with an SSH key pair and inject_user_password was enabled; then the encrypted password is in the metadata service and nova get-password decrypts it with the private key. Without a key pair the password cannot be recovered, and the cloud team must reset it from the platform.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is a SYSTEM-subject password reset proof that the server was hacked?
&lt;/h3&gt;

&lt;p&gt;No. SYSTEM means a process running as the local system account made the change, which includes legitimate services like Cloudbase-Init. Match the event's timestamp against service logs before treating it as an intrusion. If no service accounts for it, then escalate as a security incident.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Download the PDF:&lt;/strong&gt; &lt;a href="https://quabin.com/wp-content/uploads/2026/10/quabin-windows-vm-locked-out-cloudbase-init-random-password.pdf" rel="noopener noreferrer"&gt;https://quabin.com/wp-content/uploads/2026/10/quabin-windows-vm-locked-out-cloudbase-init-random-password.pdf&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://quabin.com/windows-vm-locked-out-cloudbase-init-random-password/" rel="noopener noreferrer"&gt;quabin.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>openstack</category>
      <category>windows</category>
      <category>devops</category>
      <category>security</category>
    </item>
    <item>
      <title>Webmail Returns 502 Only When People Are Actually Using It — The One Proxy Flag cPanel Forgot</title>
      <dc:creator>Daanial Farrukh</dc:creator>
      <pubDate>Thu, 29 Oct 2026 17:17:48 +0000</pubDate>
      <link>https://dev.to/daanial/webmail-returns-502-only-when-people-are-actually-using-it-the-one-proxy-flag-cpanel-forgot-5dh</link>
      <guid>https://dev.to/daanial/webmail-returns-502-only-when-people-are-actually-using-it-the-one-proxy-flag-cpanel-forgot-5dh</guid>
      <description>&lt;p&gt;&lt;em&gt;A presales war story about a webmail login page that worked for one tester and failed for forty staff, the Apache error almost nobody reads, and the single ProxyPass flag I now check on every cPanel server.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The ticket had been open for two weeks.&lt;/p&gt;

&lt;p&gt;"Webmail is unstable. Users get Bad Gateway. It comes and goes."&lt;/p&gt;

&lt;p&gt;The server was a customer's cPanel box, a few hundred mailboxes, staff logging in through &lt;code&gt;webmail.example.com&lt;/code&gt; in a browser. The hosting team had already done the usual rounds.&lt;/p&gt;

&lt;p&gt;"We added RAM. We restarted Apache twice. We even moved the site off Cloudflare for a day. Nothing changes."&lt;/p&gt;

&lt;p&gt;I asked one question.&lt;/p&gt;

&lt;p&gt;"When does it fail?"&lt;/p&gt;

&lt;p&gt;"Mornings. Nine, half past nine. By eleven it's fine."&lt;/p&gt;

&lt;p&gt;"And when you test it yourself?"&lt;/p&gt;

&lt;p&gt;"It always works when I test it."&lt;/p&gt;

&lt;p&gt;That was the whole case, in two answers. One person testing: fine. Forty people logging in within the same ten minutes: 502. Nobody had tested forty logins. Nobody had looked at what sits between Apache and the webmail service on a cPanel server.&lt;/p&gt;

&lt;p&gt;I asked for one line from one log, and the answer was there before the call ended.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trap, Named
&lt;/h2&gt;

&lt;p&gt;That's the trap. A 502 Bad Gateway from webmail does not look like a cPanel bug. It looks like a load problem. So the team feeds the server more memory, blames the CDN, blames the firewall, and tests with one browser tab, where it never fails.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The failure is not in the server's resources. It is in how Apache talks to cPanel's own webmail service on the loopback.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What cPanel Webmail Actually Is
&lt;/h2&gt;

&lt;p&gt;Here's the part people miss. On a cPanel server, Roundcube is not served by Apache the way a normal PHP site is. It is served by &lt;code&gt;cpsrvd&lt;/code&gt;, cPanel's own daemon, on ports 2095 and 2096.&lt;/p&gt;

&lt;p&gt;When a user types &lt;code&gt;webmail.example.com&lt;/code&gt;, they are hitting Apache on port 443. Apache then proxies the request over the loopback to cpsrvd:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Browser → Apache :443 (webmail.example.com) → ProxyPass → cpsrvd 127.0.0.1:2095 → Roundcube&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;ProxyPass&lt;/code&gt; is generated by cPanel from a template. In the stock configuration it looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="nc"&gt;ProxyPass&lt;/span&gt; / http://127.0.0.1:2095/ max=1 retry=0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two settings matter. &lt;code&gt;max=1&lt;/code&gt; means Apache keeps at most one pooled connection to cpsrvd per worker. And there is &lt;strong&gt;no &lt;code&gt;disablereuse=on&lt;/code&gt;&lt;/strong&gt;, which means Apache is allowed to reuse that one connection for the next request, from whoever it comes.&lt;/p&gt;

&lt;p&gt;That is exactly the design that breaks under load.&lt;/p&gt;

&lt;p&gt;The same proxy pattern is used on cPanel servers for all the service subdomains:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;webmail.example.com&lt;/code&gt; → 127.0.0.1:2095&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cpanel.example.com&lt;/code&gt; → 127.0.0.1:2082&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;whm.example.com&lt;/code&gt; and &lt;code&gt;webdisk.example.com&lt;/code&gt; → their own local ports&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Webmail is where it shows first, because webmail is where many different people log in at the same minute.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Shared Connection Breaks
&lt;/h2&gt;

&lt;p&gt;cpsrvd is strict about sessions. Each connection it accepts is tied to the authentication it saw on that connection's first request. The function doing this is named plainly in its own log: &lt;code&gt;ensure_authn_has_not_changed_from_initial_request&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Now put the two facts together.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Apache has one pooled connection to cpsrvd and is allowed to reuse it.&lt;/li&gt;
&lt;li&gt;User A's request goes through it. Then user B's request is sent down the same connection.&lt;/li&gt;
&lt;li&gt;cpsrvd sees a session that does not match the first request on that connection, and it resets the connection.&lt;/li&gt;
&lt;li&gt;Apache was mid-request. It never gets a status line back. It logs an error and sends the browser a 502.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;With one tester, user B never exists. The connection carries one session, cpsrvd is happy, and the page loads every time. With forty staff at nine in the morning, user B exists every few seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Absence of errors in testing is not health. It is a test that never created the condition.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Nobody Sees It
&lt;/h2&gt;

&lt;p&gt;Three reasons, and I have watched all three happen on the same ticket.&lt;/p&gt;

&lt;p&gt;First, the obvious dashboards are green. Memory is fine. Load average is fine. The 502 is not a resource problem, so no resource graph will show it.&lt;/p&gt;

&lt;p&gt;Second, the error is in a log almost nobody opens for a webmail complaint. It is in Apache's error log, as a proxy error, with a code that looks like noise: &lt;code&gt;AH01102&lt;/code&gt;. And the matching line on the other side is in cpsrvd's log under &lt;code&gt;/usr/local/cpanel/logs/&lt;/code&gt;, not under &lt;code&gt;/var/log&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Third, the direct URL works. If you tell a user to go to &lt;code&gt;https://example.com:2096&lt;/code&gt; instead of &lt;code&gt;webmail.example.com&lt;/code&gt;, the problem disappears. The team reads that as "the server is fine, the user's network is weird". It is actually the single most useful clue in the case: port 2096 bypasses the Apache proxy entirely. If the proxy path fails and the direct path works, the problem is the proxy.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Actually Prove It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Read the Apache error log for the proxy error.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is a read-only check.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep &lt;/span&gt;AH01102 /etc/apache2/logs/error_log | &lt;span class="nb"&gt;tail&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If this is your problem, you will see lines like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[proxy_http:error] [pid 12345] (104)Connection reset by peer: [client 203.0.113.10:51234] AH01102: error reading status line from remote server 127.0.0.1:2095
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it in words: Apache sent a request to cpsrvd on the loopback, and the connection was reset before a status line came back. The client IP is the user who saw the 502. The remote server is cpsrvd on 2095. Nothing else in the chain is involved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: Read cpsrvd's side of the same moment.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"Session does not match"&lt;/span&gt; /usr/local/cpanel/logs/error_log | &lt;span class="nb"&gt;tail&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The matching line is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;reset_connection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Session does not match first request&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is cpsrvd saying, in its own words, that it received a request for a different session on a connection that already belonged to someone else, and it closed the connection on purpose. When you see this line and the &lt;code&gt;AH01102&lt;/code&gt; line at the same timestamps, the cause is proven. Not a theory. Proven.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3: Confirm the generated ProxyPass is missing the flag.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"2095"&lt;/span&gt; /etc/apache2/conf/httpd.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the webmail &lt;code&gt;ProxyPass&lt;/code&gt; lines. If they say &lt;code&gt;max=1 retry=0&lt;/code&gt; and nothing about &lt;code&gt;disablereuse&lt;/code&gt;, you have the stock configuration that produces the fault.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4: Confirm the direct port works while the proxy fails.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Ask one affected user to log in at &lt;code&gt;https://example.com:2096&lt;/code&gt; during the failure window. If that works and &lt;code&gt;webmail.example.com&lt;/code&gt; fails, you have the behavioural proof as well as the log proof.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Actually Fix It
&lt;/h2&gt;

&lt;p&gt;The fix is one flag: &lt;code&gt;disablereuse=on&lt;/code&gt;. It tells Apache to close the backend connection after each request instead of pooling it. cpsrvd then sees a fresh connection for every request, and the session check never fails.&lt;/p&gt;

&lt;p&gt;There is a quick way and a permanent way. Do the quick way first if users are failing right now, then make it permanent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Quick fix, applied directly to the running config.&lt;/strong&gt; This changes state.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'s#ProxyPass / http://127.0.0.1:2095/ max=1 retry=0#ProxyPass / http://127.0.0.1:2095/ max=1 retry=0 disablereuse=on#'&lt;/span&gt; /etc/apache2/conf/httpd.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then restart Apache the cPanel way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/scripts/restartsrv_httpd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 502s stop immediately. But this edit lives in a generated file. The next time cPanel rebuilds &lt;code&gt;httpd.conf&lt;/code&gt;, which happens on EasyApache changes, account changes and some updates, your flag is gone and the 502s return. That is why the next step matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: Permanent fix, through the template cPanel actually uses.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The webmail &lt;code&gt;ProxyPass&lt;/code&gt; is generated from the main template, &lt;code&gt;ea4_main&lt;/code&gt;, not from the SSL vhost template many people edit first. cPanel's supported way to change a template is to copy &lt;code&gt;.default&lt;/code&gt; to &lt;code&gt;.local&lt;/code&gt; and edit the copy. This changes state.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; /var/cpanel/templates/apache2_4/ea4_main.default /var/cpanel/templates/apache2_4/ea4_main.local
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;code&gt;ea4_main.local&lt;/code&gt; and find the webmail &lt;code&gt;ProxyPass&lt;/code&gt; lines. There are two, one for the HTTP vhost and one for the HTTPS vhost, roughly 180 lines apart. Add &lt;code&gt;disablereuse=on&lt;/code&gt; to both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"2095"&lt;/span&gt; /var/cpanel/templates/apache2_4/ea4_main.local
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After editing, rebuild the config from the template and restart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/scripts/rebuildhttpdconf &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; /scripts/restartsrv_httpd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 3: Verify. This step is where most tutorials stop and where mine starts.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;First, prove the flag survived the rebuild:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"disablereuse=on"&lt;/span&gt; /etc/apache2/conf/httpd.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You want a count of at least two, one per webmail vhost. Zero means the rebuild overwrote your change, which means you edited the wrong template.&lt;/p&gt;

&lt;p&gt;Second, prove the fault is gone. Wait for the next busy window, or simulate it: have several people, or several browser sessions logged in as different mailboxes, hit &lt;code&gt;webmail.example.com&lt;/code&gt; within the same minute. Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; AH01102 /etc/apache2/logs/error_log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the number, wait through a busy period, run it again. If the count stops growing, the fix is in. If it keeps climbing with timestamps after your restart, the flag is not in effect on the vhost that is serving the traffic, and you go back to Step 2.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plain takeaway: a fix you cannot see in the log after a rebuild is not a fix.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Part That Is Not Your Fault
&lt;/h2&gt;

&lt;p&gt;I want to be fair to cPanel here, because this is not a misconfiguration by the hosting team.&lt;/p&gt;

&lt;p&gt;The stock template ships this way. I first saw the fault on a server running cPanel version 134, and it was reported widely after servers upgraded to 136.0.20. I checked the stock &lt;code&gt;ea4_main.default&lt;/code&gt; on 136.0.23 and it still generates &lt;code&gt;max=1 retry=0&lt;/code&gt; without &lt;code&gt;disablereuse=on&lt;/code&gt;. This is a regression in the default template, not something anyone on the customer side typed.&lt;/p&gt;

&lt;p&gt;That has two consequences.&lt;/p&gt;

&lt;p&gt;First, you will meet it on servers that have never been touched. I have now confirmed the identical fault on two unrelated cPanel servers at two unrelated customers. Same version range, same &lt;code&gt;AH01102&lt;/code&gt;, same cpsrvd reset line, same fix.&lt;/p&gt;

&lt;p&gt;Second, freezing &lt;code&gt;ea4_main.local&lt;/code&gt; has a cost. &lt;code&gt;ea4_main&lt;/code&gt; is a core template. Once you have a &lt;code&gt;.local&lt;/code&gt; copy, cPanel stops applying its own future changes to that template on your server. You will not get fixes or new directives that land in &lt;code&gt;ea4_main.default&lt;/code&gt; later. For a small server that is acceptable; for a fleet, it is a maintenance debt you have to track.&lt;/p&gt;

&lt;p&gt;The better long-term options are either a post-rebuild hook that re-applies the flag to the generated config, or a ticket with cPanel asking them to add &lt;code&gt;disablereuse=on&lt;/code&gt; to the default. I would open the ticket regardless. The more servers that report it, the sooner the default changes and the &lt;code&gt;.local&lt;/code&gt; copy can be deleted.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bigger Lesson — Four Checks Before You Add RAM
&lt;/h2&gt;

&lt;p&gt;Whenever a web service "fails under load but works in testing", run these before you touch resources.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Does the direct port work while the proxied name fails?&lt;/strong&gt; Try &lt;code&gt;https://example.com:2096&lt;/code&gt;. If yes, it is the proxy layer, not the application.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;What does the proxy log say, in its own words?&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"proxy"&lt;/span&gt; /etc/apache2/logs/error_log | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;What does the backend log say at the same timestamps?&lt;/strong&gt; For cpsrvd that is &lt;code&gt;/usr/local/cpanel/logs/error_log&lt;/code&gt;. Match the seconds.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Is a connection being shared between users?&lt;/strong&gt; Any &lt;code&gt;ProxyPass&lt;/code&gt; with a connection pool and no &lt;code&gt;disablereuse=on&lt;/code&gt;, in front of a backend that ties sessions to connections, will produce exactly this pattern.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If all four point at the proxy, more memory will not help, and a CDN change will not help, because the failing hop is a loopback connection inside the same machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Tell Every Customer Now
&lt;/h2&gt;

&lt;p&gt;In the next review, ask the hosting team one question:&lt;/p&gt;

&lt;p&gt;"When webmail returns a 502, what does the Apache error log say about 127.0.0.1:2095?"&lt;/p&gt;

&lt;p&gt;If the answer is "nothing, we've never seen a proxy error there", they are in good shape. If the answer is "AH01102, error reading status line", we are not done yet, and the fix is one flag away.&lt;/p&gt;

&lt;p&gt;I write about the questions I ask in real customer rooms — the ones that change architectures and surface the problems no one else noticed. If you found this useful, drop your email below and I'll send the next one when it's ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why does cPanel webmail give 502 Bad Gateway only sometimes?
&lt;/h3&gt;

&lt;p&gt;Because the fault needs two different users on the same Apache-to-cpsrvd connection within a short time. One tester never triggers it. A busy login window does. The intermittent pattern is the signature of a shared backend connection being reset, not of a server running out of resources.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does Apache error AH01102 mean on a cPanel server?
&lt;/h3&gt;

&lt;p&gt;AH01102 means Apache's proxy module sent a request to a backend and the connection was closed before a status line came back. On cPanel, when the remote server in the message is 127.0.0.1:2095 or 2096, the backend is cpsrvd serving webmail, and the reset is cpsrvd refusing a connection whose session changed.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does disablereuse=on do in Apache ProxyPass?
&lt;/h3&gt;

&lt;p&gt;It stops Apache from keeping the backend connection open for the next request. Each proxied request opens its own connection and closes it afterwards. That removes the chance of two users' requests sharing one connection, which is what cpsrvd's session check rejects.&lt;/p&gt;

&lt;h3&gt;
  
  
  Will my fix to httpd.conf survive a cPanel update?
&lt;/h3&gt;

&lt;p&gt;Not if you edited httpd.conf directly. cPanel regenerates that file from templates. To keep the change, copy ea4_main.default to ea4_main.local in /var/cpanel/templates/apache2_4, add the flag there, and run rebuildhttpdconf. Then confirm the flag is present in the rebuilt httpd.conf.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is this a cPanel bug or a configuration mistake?
&lt;/h3&gt;

&lt;p&gt;It is the stock template. Servers on cPanel 136.0.20 and later generate the webmail ProxyPass without disablereuse=on, and the default was still unchanged at 136.0.23. A hosting team that never touched the config will still see it. A support ticket to cPanel is worth opening so the default gets fixed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does the same problem affect cpanel.example.com and whm.example.com?
&lt;/h3&gt;

&lt;p&gt;Yes, the same ProxyPass pattern, max=1 and retry=0 without disablereuse=on, is used for the cpanel, whm and webdisk service subdomains. Webmail shows it first because many users log in at the same time there. If those other subdomains also return 502 under load, apply the same flag to their ProxyPass lines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Download the PDF:&lt;/strong&gt; &lt;a href="https://quabin.com/wp-content/uploads/2026/10/quabin-cpanel-webmail-502-bad-gateway-disablereuse.pdf" rel="noopener noreferrer"&gt;https://quabin.com/wp-content/uploads/2026/10/quabin-cpanel-webmail-502-bad-gateway-disablereuse.pdf&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://quabin.com/cpanel-webmail-502-bad-gateway-disablereuse/" rel="noopener noreferrer"&gt;quabin.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cpanel</category>
      <category>apache</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>"We've Been Hacked" — No, That Is Cloudflare: Reading Edge Traffic Before You Blame the VM</title>
      <dc:creator>Daanial Farrukh</dc:creator>
      <pubDate>Wed, 28 Oct 2026 17:13:40 +0000</pubDate>
      <link>https://dev.to/daanial/weve-been-hacked-no-that-is-cloudflare-reading-edge-traffic-before-you-blame-the-vm-1kff</link>
      <guid>https://dev.to/daanial/weve-been-hacked-no-that-is-cloudflare-reading-edge-traffic-before-you-blame-the-vm-1kff</guid>
      <description>&lt;p&gt;&lt;em&gt;A presales war story about a firewall full of "abnormal sessions", a team ready to block their own origin IP, and the three questions I now ask in Cloudflare before anyone calls a server compromised.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The call came on a weekend.&lt;/p&gt;

&lt;p&gt;"The VM is compromised. The firewall is showing abnormal sessions all over it."&lt;/p&gt;

&lt;p&gt;The server was a customer's cPanel box — WordPress sites and email on one VM, with Cloudflare in front of the websites on the free plan.&lt;/p&gt;

&lt;p&gt;The plan was already moving.&lt;/p&gt;

&lt;p&gt;"We're running an antivirus scan now. And we want to block the VM's IP on the firewall until it's clean."&lt;/p&gt;

&lt;p&gt;I asked one question.&lt;/p&gt;

&lt;p&gt;"Where are the sessions coming from?"&lt;/p&gt;

&lt;p&gt;Someone read a few source addresses off the firewall view. They all started with 172.64, 172.68, 162.158.&lt;/p&gt;

&lt;p&gt;"Those are Cloudflare."&lt;/p&gt;

&lt;p&gt;Silence.&lt;/p&gt;

&lt;p&gt;"So… Cloudflare is attacking us?"&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trap, Named
&lt;/h2&gt;

&lt;p&gt;That's the trap. &lt;strong&gt;Inbound traffic from Cloudflare to your origin is not an attack on your VM. It is your website working.&lt;/strong&gt; A busy, angry firewall view looks exactly like a breach to someone who does not know which way the arrows point.&lt;/p&gt;

&lt;p&gt;And blocking the origin IP does not stop anything. It just takes every site on the server offline — the attacker's job, done for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Are Actually Looking At
&lt;/h2&gt;

&lt;p&gt;When a site sits behind Cloudflare's proxy, nobody on the internet talks to your server directly. The path is:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Visitor → Cloudflare edge → Origin VM&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Every request your origin sees on port 443 arrives from a Cloudflare edge IP. Cloudflare publishes those ranges — &lt;code&gt;173.245.48.0/20&lt;/code&gt;, &lt;code&gt;103.21.244.0/22&lt;/code&gt;, &lt;code&gt;104.16.0.0/13&lt;/code&gt;, &lt;code&gt;172.64.0.0/13&lt;/code&gt;, &lt;code&gt;162.158.0.0/15&lt;/code&gt; and a handful more. So when a firewall lists thousands of inbound :443 sessions from &lt;code&gt;172.64.0.0/13&lt;/code&gt; and &lt;code&gt;162.158.0.0/15&lt;/code&gt;, it is showing you &lt;strong&gt;reverse-proxy traffic&lt;/strong&gt;, nothing else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A compromised VM is the source of traffic, not the destination.&lt;/strong&gt; The signs point outward:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the VM opening connections to addresses nobody configured — a command-and-control server, a mining pool&lt;/li&gt;
&lt;li&gt;spam or scans leaving the server from processes that should not send anything&lt;/li&gt;
&lt;li&gt;a login that succeeded from an address or a tool that should never have one&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Inbound sessions from Cloudflare are the opposite direction. They tell you about load, not about a breach.&lt;/p&gt;

&lt;p&gt;This mix-up is common in three places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;teams where the network side and the web side are run by different people&lt;/li&gt;
&lt;li&gt;monitoring that alerts on session count without knowing what sits behind the proxy&lt;/li&gt;
&lt;li&gt;any shop that has added Cloudflare recently and never updated its "normal" baseline&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  So What Was It?
&lt;/h2&gt;

&lt;p&gt;The sessions were real, and there were a lot of them. I opened Cloudflare's Security Analytics for the zone and read the last 24 hours.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;About 22.7 million requests&lt;/strong&gt; in 24 hours, for sites that normally see a tiny fraction of that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;About 22.7 million GET and about 7,200 POST.&lt;/strong&gt; So this was not a password attack on &lt;code&gt;wp-login.php&lt;/code&gt; or &lt;code&gt;xmlrpc.php&lt;/code&gt; — those are POSTs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two user-agent strings carried almost all of it&lt;/strong&gt;, roughly 50/50: one Firefox on Windows, one Firefox on iOS. Real Chrome — the browser most real visitors use — was about 0.25 million.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare mitigated about 1.18 million (~5%)&lt;/strong&gt; and passed &lt;strong&gt;about 20 million (~88%)&lt;/strong&gt; to the origin as dynamic, uncached requests.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is a distributed layer-7 GET flood from a botnet pretending to be two kinds of Firefox. Every one of those 20 million requests reached the VM through Cloudflare. That is what lit up the firewall.&lt;/p&gt;

&lt;p&gt;Here's the part that trips people up. &lt;strong&gt;Nothing on the VM was wrong.&lt;/strong&gt; The antivirus scan would find nothing because there was nothing to find. The VM was doing its job under a flood.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Usual Check Misses It
&lt;/h2&gt;

&lt;p&gt;The first place most people look in Cloudflare is &lt;strong&gt;Security → Events&lt;/strong&gt;. On this zone it was nearly empty. A few blocks, a few challenges — nothing that matched 22 million requests.&lt;/p&gt;

&lt;p&gt;That empty log is the second trap. On the free plan, a flood that looks like real browser traffic does not show up as &lt;code&gt;ddos_block&lt;/code&gt; or managed-challenge events. Cloudflare did not decide it was an attack, so it did not log an action. &lt;strong&gt;An empty events log means Cloudflare did not act. It does not mean nothing happened.&lt;/strong&gt; Ask a dashboard "was there a DDoS?" and it will look at the events log and say no.&lt;/p&gt;

&lt;p&gt;You judge a flood by &lt;strong&gt;volume and shape&lt;/strong&gt;, not by the events log:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;request count against the normal baseline&lt;/li&gt;
&lt;li&gt;the GET/POST split&lt;/li&gt;
&lt;li&gt;the user-agent mix&lt;/li&gt;
&lt;li&gt;the top source ASNs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All four live in &lt;strong&gt;Security → Analytics&lt;/strong&gt;, not in Events.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Actually Tell — and Fix It
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Confirm the sources are Cloudflare
&lt;/h3&gt;

&lt;p&gt;Before anything else, check the source addresses the firewall is worried about against Cloudflare's published list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://www.cloudflare.com/ips-v4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the "abnormal" sources sit inside those ranges, you are looking at proxied traffic. Stop the VM-blocking plan here.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Read the shape of the traffic in Cloudflare
&lt;/h3&gt;

&lt;p&gt;Cloudflare → your zone → &lt;strong&gt;Security → Analytics&lt;/strong&gt;. Set the window to the last 24 hours and read four panels in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Total requests&lt;/strong&gt; — compare with a normal day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP methods&lt;/strong&gt; — mostly GET means a page flood; a POST spike on login paths means a credential attack.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;User agents / browsers&lt;/strong&gt; — real traffic is spread across many browsers. One or two identical strings carrying most of it is a botnet template.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mitigated vs served by origin&lt;/strong&gt; — how much got through to you.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Plain takeaway: if requests are huge, mostly GET, and two user-agents carry the load, it is a flood. It is not a breach.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Turn on the free plan's strongest lever
&lt;/h3&gt;

&lt;p&gt;Cloudflare → &lt;strong&gt;Security → Settings&lt;/strong&gt; → Security Level → &lt;strong&gt;I'm Under Attack&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This puts a JavaScript challenge in front of every visitor. A real browser passes it in a few seconds without the user doing anything. A raw GET-flood script cannot run JavaScript, so it never reaches the origin. On the free plan, this single switch does more than anything else.&lt;/p&gt;

&lt;p&gt;Optionally, add a custom rule with &lt;strong&gt;Managed Challenge&lt;/strong&gt; for the exact user-agent strings the botnet is using, so you can dial the global level back down later without letting the template through.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Lock the origin to Cloudflare
&lt;/h3&gt;

&lt;p&gt;A flood that cannot pass Cloudflare will try to go around it. Allow port 443 on the origin only from Cloudflare's ranges, at the firewall in front of the VM or on the VM itself. Then the botnet has no direct path. This is the one change on the network side that actually helps — the opposite of blocking the origin IP.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Verify — this step is where most tutorials stop and where mine starts
&lt;/h3&gt;

&lt;p&gt;Back in &lt;strong&gt;Security → Analytics&lt;/strong&gt;, the "served by origin" line should fall to a small fraction of the total, and the challenged count should rise to match the flood. On the origin, the number of established sessions on :443 should drop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ss &lt;span class="nt"&gt;-Htn&lt;/span&gt; state established &lt;span class="s1"&gt;'( sport = :443 )'&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If origin traffic does not drop, something reaches the server without passing Cloudflare — a DNS-only record that points at the same IP, or port 443 still open to the world. That is not a failed fix. It is the verification telling you where the gap is.&lt;/p&gt;

&lt;p&gt;One more thing that confused the team: requests to &lt;code&gt;wp-cron.php&lt;/code&gt; coming &lt;strong&gt;from the origin's own IP&lt;/strong&gt;. That is WordPress calling its own scheduler through the public URL. It is the site talking to itself, not an attacker.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Real Compromise Looked Like — on the Same Server
&lt;/h2&gt;

&lt;p&gt;I want to be honest here: this same server did have a real compromise, found a few days later. It is the best proof that the two look nothing alike.&lt;/p&gt;

&lt;p&gt;First, there was probing. A scanner with an empty user-agent asked a backup subdomain for a list of common web-shell file names. Probing is not a breach. The question is whether any of those requests got a real answer. One grep on the access log decides it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-Ei&lt;/span&gt; &lt;span class="s2"&gt;"sh3ll|ah25|xwpg"&lt;/span&gt; access_log | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;" 200 "&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing means the scanner knocked and nobody answered. A &lt;code&gt;200&lt;/code&gt; means a file with that name existed and ran — then you investigate that file.&lt;/p&gt;

&lt;p&gt;The real entry, when it came, was quiet. &lt;strong&gt;One successful login to the cPanel account, from a script user-agent (&lt;code&gt;python-requests&lt;/code&gt;), followed by a password change on that account.&lt;/strong&gt; No flood, no firewall alarm. It was found in the cPanel login and access logs, not in Cloudflare and not on the firewall.&lt;/p&gt;

&lt;p&gt;And during the cleanup, two process names in &lt;code&gt;top&lt;/code&gt; looked like malware. Each one was checked before anyone called it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; /proc/&amp;lt;PID&amp;gt;/exe
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One pointed to a mail server's own helper binary — legitimate, just stuck at full CPU from brute-force load on the mailboxes, cleared by restarting the mail service. The other was an admin tool running from root's home directory. Neither was malware.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A breach is a quiet login and something leaving the server. A flood is loud traffic arriving through the proxy.&lt;/strong&gt; Mixing them up costs you both ways — you block your own sites for a flood, and you miss the one login that mattered.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bigger Lesson — Five Checks Before You Say "Compromised"
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Which direction is the traffic?&lt;/strong&gt; Inbound from Cloudflare ranges is your site. Outbound to unknown addresses is the alarm. &lt;code&gt;ss -tnp state established&lt;/code&gt; shows both, with the process.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is there a successful login you cannot explain?&lt;/strong&gt; For cPanel, read &lt;code&gt;/usr/local/cpanel/logs/login_log&lt;/code&gt;. For SSH, &lt;code&gt;grep Accepted /var/log/auth.log&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Did any probe get a 200?&lt;/strong&gt; Grep the access log for the probed paths and the status code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is the scary process really what its name says?&lt;/strong&gt; &lt;code&gt;ls -l /proc/&amp;lt;PID&amp;gt;/exe&lt;/code&gt; before you kill or report it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is the edge absorbing the load?&lt;/strong&gt; Security → Analytics: mitigated vs served by origin.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If all five are clean, you have a load problem, not a security problem. Fix it at the edge.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Tell Every Customer Now
&lt;/h2&gt;

&lt;p&gt;When a team says "the VM is hacked", I ask one question before anything is blocked or scanned:&lt;/p&gt;

&lt;p&gt;"Show me one connection that &lt;strong&gt;started&lt;/strong&gt; from the server."&lt;/p&gt;

&lt;p&gt;If the answer is "there isn't one, they're all coming in from Cloudflare", we are dealing with traffic, and the fix is in Cloudflare. If the answer is "yes, it's talking to an address we don't know", we're not done yet — and now we look at the VM.&lt;/p&gt;

&lt;p&gt;I write about the questions I ask in real customer rooms — the ones that change architectures and surface the problems no one else noticed. If you found this useful, drop your email below and I'll send the next one when it's ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why does my firewall show so many connections from Cloudflare IPs?
&lt;/h3&gt;

&lt;p&gt;Because your site is behind Cloudflare's proxy. Every visitor's request reaches your server from a Cloudflare edge address, not from the visitor. Thousands of inbound sessions from ranges like 172.64.0.0/13 or 162.158.0.0/15 are normal reverse-proxy traffic, and a spike means more requests, not a breach.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I know if my server is compromised or just under a DDoS?
&lt;/h3&gt;

&lt;p&gt;Look at direction. A compromised server starts connections outward — to a command server, a mining pool, or other mail servers with spam — or shows a login you cannot explain. A layer-7 flood is huge inbound request volume through the proxy. Check outbound connections and login logs on the server, and request volume in Cloudflare Security Analytics.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why is the Cloudflare Security Events log empty during an attack?
&lt;/h3&gt;

&lt;p&gt;Events only lists requests Cloudflare took an action on. On the free plan, a flood that imitates real browsers is often not classified as an attack, so it is passed to the origin without an event. Judge the attack by total requests, method split and user-agent mix in Security Analytics instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Cloudflare's I'm Under Attack mode stop a layer-7 GET flood?
&lt;/h3&gt;

&lt;p&gt;It stops most simple ones. It shows a JavaScript challenge that real browsers pass automatically and plain request scripts cannot. Combine it with a firewall rule that allows port 443 on the origin only from Cloudflare's ranges, so the flood cannot go around the proxy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I block my origin IP during an attack?
&lt;/h3&gt;

&lt;p&gt;No. Blocking the origin IP takes every site on the server offline, which is what the attacker wanted. Block or challenge at Cloudflare, and restrict the origin to Cloudflare's ranges so nothing reaches it directly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why do I see wp-cron.php requests from my own server's IP?
&lt;/h3&gt;

&lt;p&gt;That is WordPress triggering its own scheduled tasks by requesting wp-cron.php through the public URL. The source is your origin because the server is calling itself. It is expected and not an attack.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Download the PDF:&lt;/strong&gt; &lt;a href="https://quabin.com/wp-content/uploads/2026/10/quabin-cloudflare-traffic-not-vm-compromise.pdf" rel="noopener noreferrer"&gt;https://quabin.com/wp-content/uploads/2026/10/quabin-cloudflare-traffic-not-vm-compromise.pdf&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://quabin.com/cloudflare-traffic-not-vm-compromise/" rel="noopener noreferrer"&gt;quabin.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>security</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Root Password Changed at 10:17 and Nobody Logged In — The VM Console Path Your Firewall Never Sees</title>
      <dc:creator>Daanial Farrukh</dc:creator>
      <pubDate>Tue, 27 Oct 2026 17:18:33 +0000</pubDate>
      <link>https://dev.to/daanial/the-root-password-changed-at-1017-and-nobody-logged-in-the-vm-console-path-your-firewall-never-1mc8</link>
      <guid>https://dev.to/daanial/the-root-password-changed-at-1017-and-nobody-logged-in-the-vm-console-path-your-firewall-never-1mc8</guid>
      <description>&lt;p&gt;&lt;em&gt;A presales war story about a Linux VM whose root password changed with no login in any log — the swapped guest-tools agent behind it, the password stealer that came with it, and the checks I now run before anyone types a password on a suspect server.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The message came late one morning.&lt;/p&gt;

&lt;p&gt;"The root password doesn't work anymore. Nobody changed it."&lt;/p&gt;

&lt;p&gt;The server was a customer's Ubuntu 24.04 VM on a hosted private cloud. A web server with a handful of sites, SSH on a non-standard port, a firewall in front of it that had been reviewed twice that year.&lt;/p&gt;

&lt;p&gt;Their first theory came fast.&lt;/p&gt;

&lt;p&gt;"Someone brute-forced SSH. Check the firewall logs."&lt;/p&gt;

&lt;p&gt;I asked one question.&lt;/p&gt;

&lt;p&gt;"When was the last successful SSH login as root?"&lt;/p&gt;

&lt;p&gt;They looked. "Two days ago. Ours."&lt;/p&gt;

&lt;p&gt;"And the password changed this morning?"&lt;/p&gt;

&lt;p&gt;"10:17."&lt;/p&gt;

&lt;p&gt;A pause. "So how did they get in?"&lt;/p&gt;

&lt;p&gt;That is the question this whole article is about. Because nobody got in the way anyone in that room expected. Nobody got in through the network at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trap, Named
&lt;/h2&gt;

&lt;p&gt;That's the trap. When root changes under you, everyone stares at the network door — SSH, the firewall, the web application firewall, the panel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A VM has a second door that is not on the network at all: the hypervisor console.&lt;/strong&gt; It hides in plain sight because it is the door the cloud provider uses. Nobody treats it as an attack path, so nobody watches it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Happened — In Order
&lt;/h2&gt;

&lt;p&gt;It took two nights of log work to put the chain together. Here it is in one line:&lt;/p&gt;

&lt;p&gt;Cloud portal login → VM console opened → old build-time user logs in → sudo → three backdoors installed → guest agent replaced → "reset password" called from inside the VM → root changed at 10:17&lt;/p&gt;

&lt;p&gt;Now each link, with where the evidence was.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The portal login was not the customer's.&lt;/strong&gt; The cloud portal's audit showed a login to the customer's tenant from an address that was not the customer's office or their admin's home. Minutes later the web console to the VM was opened.&lt;/p&gt;

&lt;p&gt;Here is the part that trips people up. A console session leaves no trace in &lt;code&gt;auth.log&lt;/code&gt; by itself. But it does leave one in the kernel log. When the hypervisor attaches a console, the guest sees a virtual USB tablet — the console's mouse — plug in. That one kernel line dated the entry to the second.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root failed on the console. A forgotten user did not.&lt;/strong&gt; On tty1, two failed root logins. Then a login as a user created when the VM was first built, months earlier, whose password had never been changed. It worked. &lt;code&gt;sudo su&lt;/code&gt;. Root, in under three minutes, with a password that was probably in some old build document.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three persistence mechanisms in twenty minutes.&lt;/strong&gt; Once root, the attacker did not do one thing. They did three.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A fake service: a static binary at &lt;code&gt;/usr/sbin/kernel-agent&lt;/code&gt; with a systemd unit called &lt;code&gt;kernel-agent.service&lt;/code&gt;, running as root. No package owned it.&lt;/li&gt;
&lt;li&gt;A password stealer in &lt;code&gt;/etc/ld.so.preload&lt;/code&gt;: a shared object with a glibc-sounding name, loaded into every process on the system, hooking the functions that read passwords from a terminal.&lt;/li&gt;
&lt;li&gt;A PAM backdoor: one line added to &lt;code&gt;common-auth&lt;/code&gt;, &lt;code&gt;sshd&lt;/code&gt;, &lt;code&gt;su&lt;/code&gt; and &lt;code&gt;sudo&lt;/code&gt;, plus a tty variant in &lt;code&gt;login&lt;/code&gt;:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;auth&lt;/span&gt; &lt;span class="n"&gt;optional&lt;/span&gt; &lt;span class="n"&gt;pam_exec&lt;/span&gt;.&lt;span class="n"&gt;so&lt;/span&gt; &lt;span class="n"&gt;quiet&lt;/span&gt; &lt;span class="n"&gt;expose_authtok&lt;/span&gt; /&lt;span class="n"&gt;usr&lt;/span&gt;/&lt;span class="n"&gt;libexec&lt;/span&gt;/&lt;span class="n"&gt;glibc&lt;/span&gt;-&lt;span class="n"&gt;langpack&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;expose_authtok&lt;/code&gt; hands the plaintext password to that script on every authentication. The attacker left a marker comment in each file and saved the original files in a hidden folder under &lt;code&gt;/var/lib/private/&lt;/code&gt; — their own backup, so they could restore the files if they ever needed to look clean.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The guest-tools agent was swapped.&lt;/strong&gt; Every cloud platform ships a small agent inside the VM that the portal talks to over localhost: reset the password, run a script, report the IP. For about an hour the attacker probed that agent from inside the VM — the signature checks on its API kept failing. Then they replaced the binary with a modified copy and left a &lt;code&gt;.bak&lt;/code&gt; next to it. The running file's hash no longer matched the copy the vendor's package ships.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Then they cleaned up.&lt;/strong&gt; &lt;code&gt;/root/.bash_history&lt;/code&gt; emptied. Old records cut out of &lt;code&gt;/var/log/wtmp&lt;/code&gt;, so &lt;code&gt;last&lt;/code&gt; started two days late. The relevant lines deleted from &lt;code&gt;auth.log&lt;/code&gt;. They missed one place: journald still had every line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hours later, 10:17.&lt;/strong&gt; With no login of any kind — no SSH, no console, no panel — the journal shows calls to the agent's reset-password and run-script endpoints from localhost, and at 10:17:12 &lt;code&gt;chpasswd&lt;/code&gt; changed root. The attacker did not need to be logged in anymore. The swapped agent did it for them, on a schedule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Usual Checks Missed It
&lt;/h2&gt;

&lt;p&gt;The firewall was never in the path. The console goes hypervisor → VM, not network → VM. The distributed firewall, the port knocking, the custom SSH port — all irrelevant to that door.&lt;/p&gt;

&lt;p&gt;The logs people trust had been edited. &lt;code&gt;auth.log&lt;/code&gt; was missing lines. &lt;code&gt;wtmp&lt;/code&gt; had been truncated. &lt;code&gt;last&lt;/code&gt; and &lt;code&gt;who&lt;/code&gt; showed a quiet server, not because the server was quiet but because the file that would have said otherwise had been cut.&lt;/p&gt;

&lt;p&gt;The file timestamps lied. The attacker set the modification time of every file they touched back to an old date. &lt;code&gt;ls -l&lt;/code&gt; showed nothing recent. &lt;code&gt;find -mtime&lt;/code&gt; found nothing. That is why the PAM backdoor was found a full day after the first cleanup — it was caught by a search on change time, which a normal user cannot fake without changing the system clock.&lt;/p&gt;

&lt;p&gt;And the first-page checks were all green. No extra UID 0 user. Root's crontab untouched. No new SSH keys. Kernel not tainted. Everything a standard "am I hacked" checklist starts with was clean.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Absence of evidence in a log the attacker could edit is not evidence of absence.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Actually Find This
&lt;/h2&gt;

&lt;p&gt;Everything below is read-only. Run it from your own terminal, and — this matters — do not type a password on the suspect server until Step 3 comes back clean. If the stealer is loaded, every password typed is captured.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Ask the journal, not the file.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;journalctl &lt;span class="nt"&gt;--since&lt;/span&gt; &lt;span class="s2"&gt;"-3 days"&lt;/span&gt; &lt;span class="nv"&gt;_COMM&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;chpasswd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On this server it printed &lt;code&gt;password changed for root&lt;/code&gt; with a time nobody could explain. Then compare what the journal knows about logins with what the file knows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;journalctl &lt;span class="nt"&gt;--since&lt;/span&gt; &lt;span class="s2"&gt;"-3 days"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"session opened for user"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"session opened for user"&lt;/span&gt; /var/log/auth.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the journal has more lines than the file for the same period, the file was edited. On this server it had.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: Find console sessions in the kernel log.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;journalctl &lt;span class="nt"&gt;-k&lt;/span&gt; &lt;span class="nt"&gt;--since&lt;/span&gt; &lt;span class="s2"&gt;"-3 days"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"usb"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A virtual tablet or mouse attaching, with no physical change to the VM, is a console being opened from the hypervisor. This is how we knew the exact minute the attacker arrived — and that they arrived through the portal, not the network.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3: Check the two places that capture passwords.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /etc/ld.so.preload
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The file should not exist, or should be empty. Any library listed there is loaded into every process on the box. On this server it named one &lt;code&gt;.so&lt;/code&gt; in &lt;code&gt;/usr/libexec/&lt;/code&gt; that no package owned.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"pam_exec"&lt;/span&gt; /etc/pam.d/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;pam_exec.so&lt;/code&gt; line with &lt;code&gt;expose_authtok&lt;/code&gt; that you did not add is a password stealer. Full stop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This error is not a bug. It is the verification.&lt;/strong&gt; If both of those come back clean, passwords typed on the server are safe. If either is dirty, nothing typed on this server from the moment of compromise can be trusted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4: Search by change time, not modification time.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;find / &lt;span class="nt"&gt;-xdev&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="nt"&gt;-ctime&lt;/span&gt; &lt;span class="nt"&gt;-3&lt;/span&gt; &lt;span class="nt"&gt;-printf&lt;/span&gt; &lt;span class="s1"&gt;'%CY-%Cm-%Cd %CH:%CM %p\n'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Modification time is one &lt;code&gt;touch&lt;/code&gt; away from any date. Change time is set by the kernel when the inode changes and cannot be set from userspace. Sorted by ctime, the attacker's whole session appeared in order: the PAM files, the &lt;code&gt;.so&lt;/code&gt;, the agent, the hidden backup folder — all within one twenty-minute window, no matter what &lt;code&gt;ls -l&lt;/code&gt; said.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 5: Find services that no package owns.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl list-units &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;service &lt;span class="nt"&gt;--state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;running &lt;span class="nt"&gt;--no-pager&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For any name you do not recognise:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dpkg &lt;span class="nt"&gt;-S&lt;/span&gt; /usr/sbin/kernel-agent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;dpkg-query: no path found matching pattern&lt;/code&gt; means no package put that file there. A root-running service with a generic name and an unowned binary is yours to explain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 6: Hash the guest agent against its own package copy.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Vendor tools usually keep a pristine copy of the agent in an architecture folder next to the installed one. Hash both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sha256sum&lt;/span&gt; /usr/local/&amp;lt;vendor-tools&amp;gt;/agent /usr/local/&amp;lt;vendor-tools&amp;gt;/x64/agent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They must match. On this server they did not, and an &lt;code&gt;agent.bak&lt;/code&gt; sat beside them with the original hash. Also check the vendor's own update log: no update had been applied, so the only way the running hash could differ was a manual swap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 7: Find the user who should not exist.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight awk"&gt;&lt;code&gt;&lt;span class="nx"&gt;awk&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;F&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="nv"&gt;$3&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;=&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$7&lt;/span&gt;&lt;span class="o"&gt;!~&lt;/span&gt;&lt;span class="sr"&gt;/nologin|false/&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;etc&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;passwd&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then for each one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;chage &lt;span class="nt"&gt;-l&lt;/span&gt; &amp;lt;user&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Last password change" from the week the VM was built is the door. That user also sat in &lt;code&gt;sudo&lt;/code&gt;, &lt;code&gt;adm&lt;/code&gt; and &lt;code&gt;lxd&lt;/code&gt; — three groups, any one of which is root.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Actually Clean It — In This Order
&lt;/h2&gt;

&lt;p&gt;The order matters because of the stealer. &lt;strong&gt;Clear the password capture first, then rotate passwords, never the other way round.&lt;/strong&gt; Rotating first hands the attacker the new password.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Snapshot the VM from the portal.&lt;/strong&gt; That is your evidence. Everything after this changes state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Copy evidence out.&lt;/strong&gt; The unowned binaries, &lt;code&gt;/etc/pam.d/&lt;/code&gt;, the &lt;code&gt;.so&lt;/code&gt;, the agent and its &lt;code&gt;.bak&lt;/code&gt;, the journal export. Tar it, pull it off the server over your own key, hash the tarball.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;(changes state) Remove the preload.&lt;/strong&gt; &lt;code&gt;rm /etc/ld.so.preload&lt;/code&gt;, move the &lt;code&gt;.so&lt;/code&gt; to a quarantine folder. Nothing typed after this is captured by that component.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;(changes state) Restore PAM.&lt;/strong&gt; Diff every file in &lt;code&gt;/etc/pam.d/&lt;/code&gt; against the attacker's own backup copies or against the package versions, and put the originals back. The attacker's backup folder made this fast; &lt;code&gt;dpkg -V libpam-runtime&lt;/code&gt; confirms it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;(changes state) Remove the fake service.&lt;/strong&gt; &lt;code&gt;systemctl disable --now kernel-agent&lt;/code&gt;, then remove the unit and the binary to quarantine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;(changes state) Restore the guest agent.&lt;/strong&gt; Reinstall the vendor tools from the package, confirm the hash matches the package copy, restart the agent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;(changes state) Lock the entry user.&lt;/strong&gt; &lt;code&gt;usermod -L -e 1 -s /usr/sbin/nologin &amp;lt;user&amp;gt;&lt;/code&gt; and &lt;code&gt;deluser &amp;lt;user&amp;gt; sudo&lt;/code&gt; — and the same for &lt;code&gt;adm&lt;/code&gt; and &lt;code&gt;lxd&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Now rotate every password.&lt;/strong&gt; Root, every sudo user, the panel, the database. On this server the portal's own "reset password" failed, so root was set from the installer ISO: boot the Ubuntu installer, Help › Enter shell, mount the LVM root at &lt;code&gt;/mnt&lt;/code&gt;, &lt;code&gt;chroot /mnt passwd root&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-run Steps 1 to 7.&lt;/strong&gt; On this server a full second pass — unowned binaries, UID 0, cron, timers, kernel modules, webshells, authorized keys — came back clean.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plan the rebuild.&lt;/strong&gt; A cleaned server is a server you hope is clean. A rebuilt server is one you know is clean. Treat the cleanup as the bridge to a rebuild, not as the end.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two things I can say with certainty from the evidence, and one I cannot.&lt;/p&gt;

&lt;p&gt;The stealer stored what it captured in a hidden file under &lt;code&gt;/var/log&lt;/code&gt;, encrypted with a key kept beside it — it did not send anything out over the network by itself. It had two records, both from passwords that were changed anyway.&lt;/p&gt;

&lt;p&gt;The attacker's session proved outbound internet worked: a Python package install ran from the server during their window, with no login at that time. &lt;strong&gt;A compromised server with open egress is a staging post. Egress filtering is not a nice-to-have on a web server.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;What I cannot tell you is how the portal credential got into someone else's hands. The provider's own platform logs and the firewall records for that address were still being pulled when I wrote this. I will not guess at it here, because a guess presented as a cause is how the next incident gets missed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Second Server
&lt;/h2&gt;

&lt;p&gt;Within a day, a second VM at another customer on the same platform was reported with the same symptoms. Different OS family, same pattern: portal console, build-time user, the same three backdoors.&lt;/p&gt;

&lt;p&gt;We wrote the checks above into a read-only script and ran that first, before anyone touched the server. Then the snapshot, then the evidence, then the quarantine, then the agent, then the lock, and only then the passwords.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plain takeaway:&lt;/strong&gt; when the first server tells you the attacker's method, the second server is a checklist, not an investigation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bigger Lesson — Six Checks for Any Hosted VM
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The console is a login path. Treat portal accounts like root.&lt;/strong&gt; MFA on the cloud portal, named users only, and an alert on every console open. &lt;code&gt;journalctl -k | grep -i tablet&lt;/code&gt; tells you when one happened.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No build-time users survive go-live.&lt;/strong&gt; &lt;code&gt;awk -F: '$3&amp;gt;=1000' /etc/passwd&lt;/code&gt; and &lt;code&gt;chage -l&lt;/code&gt; for each. Expire or remove what nobody owns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/etc/ld.so.preload&lt;/code&gt; must not exist.&lt;/strong&gt; &lt;code&gt;test -s /etc/ld.so.preload &amp;amp;&amp;amp; echo ALERT&lt;/code&gt; in a daily job that mails you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PAM under change control.&lt;/strong&gt; &lt;code&gt;sha256sum /etc/pam.d/* &amp;gt; /root/pam.baseline&lt;/code&gt; on a clean day, then &lt;code&gt;sha256sum -c /root/pam.baseline&lt;/code&gt; on a schedule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hash the guest agent.&lt;/strong&gt; &lt;code&gt;sha256sum&lt;/code&gt; of the running binary against the package copy, on the same schedule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filter egress.&lt;/strong&gt; A web server needs to reach its update mirror and its DNS resolvers. It does not need to reach the whole internet. Prove it with &lt;code&gt;curl -m 5 https://pypi.org&lt;/code&gt; from the server — it should fail.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each one is a minute. Together they close the door this attacker walked through, and the two they would have used next.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Tell Every Customer Now
&lt;/h2&gt;

&lt;p&gt;When a customer shows me a VM on any hosted platform, I ask one question:&lt;/p&gt;

&lt;p&gt;"Who can open a console to this VM from the portal, and would you know within the hour if they did?"&lt;/p&gt;

&lt;p&gt;If the answer is "these three people, with MFA, and the console event alerts us", they're in good shape. If the answer is "the provider handles the portal", we're not done yet.&lt;/p&gt;

&lt;p&gt;I write about the questions I ask in real customer rooms — the ones that change architectures and surface the problems no one else noticed. If you found this useful, drop your email below and I'll send the next one when it's ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why did the root password change when nobody logged in?
&lt;/h3&gt;

&lt;p&gt;Because the change was made by a program, not a person. A tampered guest-tools agent inside the VM accepted a "reset password" call from localhost and ran chpasswd. Look for &lt;code&gt;journalctl _COMM=chpasswd&lt;/code&gt; to see the exact time, and then check whether the agent binary matches the vendor package.&lt;/p&gt;

&lt;h3&gt;
  
  
  How can someone access a VM without going through the firewall?
&lt;/h3&gt;

&lt;p&gt;Through the hypervisor console in the cloud portal. The console is attached by the hypervisor directly to the VM, like a keyboard and screen plugged into a physical server. It never crosses the VM's network interface, so no firewall, security group or SSH setting applies to it.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is ld.so.preload and why is it dangerous?
&lt;/h3&gt;

&lt;p&gt;It is a file that lists shared libraries the dynamic loader must inject into every program on the system. Legitimate systems almost never use it. An attacker can list a library there that hooks password-reading functions and captures every password typed, in every program, without any process looking unusual.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does pam_exec expose_authtok do?
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;pam_exec.so&lt;/code&gt; module runs an external program during authentication. With &lt;code&gt;expose_authtok&lt;/code&gt;, PAM passes the user's plaintext password to that program on standard input. Added to common-auth, sshd, su and sudo, it captures every password used on the server. Any such line you did not add yourself is a backdoor.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why search files by ctime instead of mtime?
&lt;/h3&gt;

&lt;p&gt;Modification time can be set to any value with &lt;code&gt;touch -d&lt;/code&gt;, and attackers do this to hide. Change time is updated by the kernel whenever the inode changes and cannot be set from userspace without changing the system clock. &lt;code&gt;find / -xdev -ctime -3&lt;/code&gt; shows what really changed, in order.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I clean a compromised server or rebuild it?
&lt;/h3&gt;

&lt;p&gt;Clean it first so you can collect evidence, stop password capture and get control back, then rebuild. A cleanup proves what you found. A rebuild from a known-good image is the only thing that proves nothing else is there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Download the PDF:&lt;/strong&gt; &lt;a href="https://quabin.com/wp-content/uploads/2026/10/quabin-root-password-changed-no-login-vm-console-backdoor.pdf" rel="noopener noreferrer"&gt;https://quabin.com/wp-content/uploads/2026/10/quabin-root-password-changed-no-login-vm-console-backdoor.pdf&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://quabin.com/root-password-changed-no-login-vm-console-backdoor/" rel="noopener noreferrer"&gt;quabin.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>linux</category>
      <category>devops</category>
      <category>sysadmin</category>
    </item>
    <item>
      <title>Mail Stopped Both Ways — "451 Temporary Local Problem" and the One DNS Resolver Nobody Checked</title>
      <dc:creator>Daanial Farrukh</dc:creator>
      <pubDate>Mon, 26 Oct 2026 17:15:18 +0000</pubDate>
      <link>https://dev.to/daanial/mail-stopped-both-ways-451-temporary-local-problem-and-the-one-dns-resolver-nobody-checked-45ol</link>
      <guid>https://dev.to/daanial/mail-stopped-both-ways-451-temporary-local-problem-and-the-one-dns-resolver-nobody-checked-45ol</guid>
      <description>&lt;p&gt;&lt;em&gt;A presales war story about a cPanel server that could neither send nor receive mail — no blacklist, no full disk, no broken config — and the single DNS line I now check before I open any mail log.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The call was short.&lt;/p&gt;

&lt;p&gt;"Email is down. Completely. We can't send, and nothing is coming in."&lt;/p&gt;

&lt;p&gt;The server was a customer's cPanel/WHM box on Rocky Linux 8. It relayed outbound mail through their provider's smart host. It had been quiet and boring for a long time, which is exactly what you want from a mail server.&lt;/p&gt;

&lt;p&gt;Users saw this in Roundcube every time they pressed Send:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SMTP Error (451): Failed to add recipient - Temporary local problem
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The customer's IT lead had a theory ready.&lt;/p&gt;

&lt;p&gt;"It must be the provider's relay. Or we got blacklisted again."&lt;/p&gt;

&lt;p&gt;I asked one question.&lt;/p&gt;

&lt;p&gt;"If it's the relay, why can't anyone send &lt;em&gt;to&lt;/em&gt; you either?"&lt;/p&gt;

&lt;p&gt;A pause. "...Both directions at the same time?"&lt;/p&gt;

&lt;p&gt;Both directions at the same time. A relay problem breaks outbound. A blacklist breaks one side for some senders. Something that breaks everything, in and out, at once, usually sits underneath both.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trap, Named
&lt;/h2&gt;

&lt;p&gt;That's the trap. "Temporary local problem" sounds like the mail server is sick. So people look at the mail server — the queue, the disk, the Exim config, the last cPanel update.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The mail server was healthy. It just could not ask a single DNS question.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And the reason was one line in a file that almost nobody reads during a mail outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "Temporary Local Problem" Actually Means Here
&lt;/h2&gt;

&lt;p&gt;Exim needs DNS for nearly every step of a mail transaction. Not only to find the recipient's MX. It needs DNS to find its own smart host, and it needs DNS to check that a sender is real before it accepts a message.&lt;/p&gt;

&lt;p&gt;Outbound looks like this:&lt;/p&gt;

&lt;p&gt;User → Roundcube → Exim → DNS lookup of the smart host → smart host → recipient&lt;/p&gt;

&lt;p&gt;Inbound with sender verification looks like this:&lt;/p&gt;

&lt;p&gt;Sender → Exim → DNS lookup of the sender's domain → callout to the sender's MX → accept or reject&lt;/p&gt;

&lt;p&gt;If DNS does not answer, Exim cannot finish either flow. It does not bounce the mail permanently, because a DNS timeout might fix itself. It answers with a &lt;strong&gt;temporary&lt;/strong&gt; &lt;code&gt;451&lt;/code&gt; and tries again later. Roundcube shows that as "Temporary local problem". The sending server on the other side sees a deferral and keeps retrying.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A &lt;code&gt;451&lt;/code&gt; with "local problem" often means the local server could not finish its own lookups — not that the message or the recipient is wrong.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The Exim log said it plainly once we looked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;temporarily rejected RCPT &amp;lt;user@example.com&amp;gt;: host lookup for smarthost.example.net did not complete (DNS timeout?)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And WHM › Email › Track Delivery was full of the same line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Could not complete sender verify
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both are the same failure seen from two sides. The smart host lookup breaks outbound. The sender verify lookup breaks inbound.&lt;/p&gt;

&lt;p&gt;This setup is common in three places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Servers built from a template where someone typed &lt;code&gt;8.8.8.8&lt;/code&gt; as the only resolver because "it always works".&lt;/li&gt;
&lt;li&gt;Networks where outbound DNS to the internet is filtered or routed differently from web traffic.&lt;/li&gt;
&lt;li&gt;Servers with one resolver line and no second one, so there is no fallback when that one goes away.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why Nobody Saw It
&lt;/h2&gt;

&lt;p&gt;From the outside, a server in this state looks fine. WHM loads. SSH works. Websites keep serving, because answering a web request does not need the server to resolve anything.&lt;/p&gt;

&lt;p&gt;The usual checks stay green too: disk, the Exim service, the last update. The queue grows, but a growing queue is a symptom, not a cause.&lt;/p&gt;

&lt;p&gt;Here is the part that trips people up. &lt;strong&gt;Resolver problems hide behind every other check.&lt;/strong&gt; A web request comes in, the server answers it, done — no outbound DNS needed. Mail is different. Mail is the one service on a cPanel box that makes outbound DNS lookups for every message.&lt;/p&gt;

&lt;p&gt;The file that mattered was this one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /etc/resolv.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It had exactly one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;nameserver&lt;/span&gt; &lt;span class="m"&gt;8&lt;/span&gt;.&lt;span class="m"&gt;8&lt;/span&gt;.&lt;span class="m"&gt;8&lt;/span&gt;.&lt;span class="m"&gt;8&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One resolver. No second line. And from that network, &lt;code&gt;8.8.8.8&lt;/code&gt; did not answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One dead resolver with no fallback stops all mail, in both directions, while everything else on the server keeps working.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Why &lt;code&gt;8.8.8.8&lt;/code&gt; was unreachable from that network is a question for the network team, and I won't guess at it here. What mattered on the day was proving that the resolver, and only the resolver, was the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Actually Fix This
&lt;/h2&gt;

&lt;p&gt;Everything below is read-only until Step 3, which I mark clearly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Read the Exim log for DNS timeouts.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"did not complete"&lt;/span&gt; /var/log/exim_mainlog | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you see &lt;code&gt;host lookup for ... did not complete (DNS timeout?)&lt;/code&gt;, stop reading other logs. You are looking at a DNS problem, not a mail problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: Prove it is the resolver, not DNS as a whole.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;First, ask the resolver the server is configured to use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;host google.com 8.8.8.8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On this server it hung and then failed with a timeout. Then ask a different public resolver the same question:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;host google.com 1.1.1.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one answered at once.&lt;/p&gt;

&lt;p&gt;Same server, same network, same name. One resolver answers, one does not. &lt;strong&gt;This error is not a bug. It is the verification.&lt;/strong&gt; It tells you the server's network and DNS in general are fine, and only the configured resolver is out of reach.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3 (changes state): Point the server at resolvers it can reach.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Back up the file first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; /etc/resolv.conf /etc/resolv.conf.bak
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then set two resolvers instead of one. On this server we used:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;nameserver&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;.&lt;span class="m"&gt;1&lt;/span&gt;.&lt;span class="m"&gt;1&lt;/span&gt;.&lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="n"&gt;nameserver&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two lines, not one. A second resolver is the fallback the server never had.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4 (changes state): Flush the queue.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The mail that piled up during the outage is still sitting in the queue. Check how much:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;exim &lt;span class="nt"&gt;-bpc&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then force a delivery run of everything in it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;exim &lt;span class="nt"&gt;-qff&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 5: Verify — in both directions.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This step is where most tutorials stop and where mine starts.&lt;/p&gt;

&lt;p&gt;Send from Roundcube to an outside mailbox. The &lt;code&gt;451&lt;/code&gt; must be gone. Then send from an outside mailbox into the server, and follow the log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /var/log/exim_mainlog
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You want to see the smart host resolve and the outbound message delivered, and the inbound message accepted without a sender verify failure. Run &lt;code&gt;exim -bpc&lt;/code&gt; again a few minutes later — the queue should be falling. In our case mail flowed in both directions as soon as the resolver changed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 6 (changes state): Make it survive a reboot.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the step almost everyone skips, and it is the one that brings the outage back after the next reboot.&lt;/p&gt;

&lt;p&gt;On Rocky Linux 8, &lt;code&gt;/etc/resolv.conf&lt;/code&gt; is written by NetworkManager. Edit it by hand and it works — until the next reboot or network restart, when NetworkManager writes &lt;code&gt;8.8.8.8&lt;/code&gt; straight back.&lt;/p&gt;

&lt;p&gt;Find the connection name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nmcli con show
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then set the resolvers on the connection itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nmcli con mod &lt;span class="s2"&gt;"&amp;lt;connection-name&amp;gt;"&lt;/span&gt; ipv4.dns &lt;span class="s2"&gt;"1.1.1.1 1.0.0.1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check that it is stored:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nmcli &lt;span class="nt"&gt;-g&lt;/span&gt; ipv4.dns con show &lt;span class="s2"&gt;"&amp;lt;connection-name&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It should print &lt;code&gt;1.1.1.1,1.0.0.1&lt;/code&gt;. Now the fix lives in the place that generates the file, not only in the file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plain takeaway:&lt;/strong&gt; if a tool generates a config file, fix it in the tool, not in the file.&lt;/p&gt;

&lt;h2&gt;
  
  
  One Thing the DNS Fix Did Not Fix
&lt;/h2&gt;

&lt;p&gt;One more failure was in the logs, and it had its own cause. Sender verify for the server's own no-reply address kept failing with "Mailbox is full / Blocks limit exceeded".&lt;/p&gt;

&lt;p&gt;That mailbox was over its quota. A verify check against a full mailbox fails no matter how healthy DNS is.&lt;/p&gt;

&lt;p&gt;I mention it because this is how outages hide a second problem. Once the big failure is fixed, look at what is still failing, and check whether it has its own cause. &lt;strong&gt;A fix that clears the big failure has not cleared the small ones — it has made them easier to see.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bigger Lesson — And a Caveat From My Last Article
&lt;/h2&gt;

&lt;p&gt;A mail server is a DNS client that also happens to deliver mail. Treat its resolver as part of the mail design, not as a network detail.&lt;/p&gt;

&lt;p&gt;There is one caveat I owe readers. In my last article on &lt;a href="https://quabin.com/spamhaus-open-resolver-all-mail-rejected/" rel="noopener noreferrer"&gt;Spamhaus and the open-resolver error&lt;/a&gt;, the lesson was that a mail server should &lt;strong&gt;not&lt;/strong&gt; send its blacklist lookups through big public resolvers, because Spamhaus refuses them. Switching to &lt;code&gt;1.1.1.1&lt;/code&gt; was the right move on this day, because it brought mail back within minutes. But if the server checks Spamhaus blocklists, a public resolver will break those lookups. The long-term design for that case is a local recursive resolver on the server, with the network allowing it to reach the internet on port 53.&lt;/p&gt;

&lt;p&gt;The two cases teach the same thing from opposite ends: &lt;strong&gt;know exactly which resolver your mail server asks, and know that it will answer.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here is the short checklist I now run on any mail server I inherit:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Count the resolvers.&lt;/strong&gt; &lt;code&gt;cat /etc/resolv.conf&lt;/code&gt; must show at least two &lt;code&gt;nameserver&lt;/code&gt; lines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test each one directly.&lt;/strong&gt; &lt;code&gt;host google.com &amp;lt;resolver-ip&amp;gt;&lt;/code&gt; for every line in the file. Each must answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search for DNS timeouts.&lt;/strong&gt; &lt;code&gt;grep -c "did not complete" /var/log/exim_mainlog&lt;/code&gt; should be zero or close to it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check who writes the file.&lt;/strong&gt; &lt;code&gt;nmcli -g ipv4.dns con show "&amp;lt;connection-name&amp;gt;"&lt;/code&gt; must match what is in resolv.conf.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch the queue.&lt;/strong&gt; &lt;code&gt;exim -bpc&lt;/code&gt; on a normal day tells you what "normal" is, so a growing queue stands out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check system mailbox quotas.&lt;/strong&gt; WHM › Account Information › List Accounts, then the account's Email Accounts page, for any no-reply or system address near its limit.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each item takes seconds. Together they turn "email is down" into a two-minute diagnosis.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Tell Every Customer Now
&lt;/h2&gt;

&lt;p&gt;When a customer shows me their mail server design, I ask one question:&lt;/p&gt;

&lt;p&gt;"If the DNS resolver in resolv.conf stopped answering right now, what would happen to your mail?"&lt;/p&gt;

&lt;p&gt;If the answer is "it would fall back to the second resolver, and we test both", they are in good shape. If the answer is "there is only one, and it's Google, so it never goes down", we're not done yet.&lt;/p&gt;

&lt;p&gt;I write about the questions I ask in real customer rooms — the ones that change architectures and surface the problems no one else noticed. If you found this useful, drop your email below and I'll send the next one when it's ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What does "SMTP Error (451) Failed to add recipient - Temporary local problem" mean?
&lt;/h3&gt;

&lt;p&gt;It means the mail server could not finish its own work for that recipient and deferred the message instead of rejecting it. On cPanel servers a common cause is a DNS lookup that timed out, for example the lookup of the smart host or of the sender's domain. Check the Exim log for "did not complete (DNS timeout?)".&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does a DNS problem stop both sending and receiving?
&lt;/h3&gt;

&lt;p&gt;Exim needs DNS to find the smart host or recipient MX for outbound mail. It also needs DNS for sender verification on inbound mail. If the only resolver does not answer, both flows fail at the same time, while websites on the same server keep working.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I test if my server's DNS resolver works?
&lt;/h3&gt;

&lt;p&gt;Run &lt;code&gt;host google.com &amp;lt;resolver-ip&amp;gt;&lt;/code&gt; for each nameserver listed in /etc/resolv.conf. Each one should answer at once. If one times out while another public resolver answers, the configured resolver is unreachable from that network.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why did my resolv.conf change back after a reboot?
&lt;/h3&gt;

&lt;p&gt;On Rocky Linux, AlmaLinux and RHEL 8, NetworkManager usually writes /etc/resolv.conf. A manual edit is overwritten on the next reboot or network restart. Set the DNS servers on the connection with &lt;code&gt;nmcli con mod "&amp;lt;connection-name&amp;gt;" ipv4.dns "..."&lt;/code&gt; so the change persists.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should a mail server use 8.8.8.8 or 1.1.1.1 as its resolver?
&lt;/h3&gt;

&lt;p&gt;A public resolver can work for basic mail flow, but always list at least two and test them. If the server checks Spamhaus blocklists, public resolvers will break those lookups, so a local recursive resolver is the better long-term design.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does "Could not complete sender verify" mean in Track Delivery?
&lt;/h3&gt;

&lt;p&gt;The server tried to confirm the sender's address before accepting the message and could not finish the check. A DNS timeout causes it for every sender at once. A full or missing mailbox causes it for one address only.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Download the PDF:&lt;/strong&gt; &lt;a href="https://quabin.com/wp-content/uploads/2026/10/quabin-exim-451-temporary-local-problem-dns-resolver.pdf" rel="noopener noreferrer"&gt;https://quabin.com/wp-content/uploads/2026/10/quabin-exim-451-temporary-local-problem-dns-resolver.pdf&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://quabin.com/exim-451-temporary-local-problem-dns-resolver/" rel="noopener noreferrer"&gt;quabin.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>email</category>
      <category>linux</category>
      <category>devops</category>
      <category>dns</category>
    </item>
    <item>
      <title>Every Email Rejected at Once — The Open-Resolver Mistake That Makes Spamhaus Answer "No"</title>
      <dc:creator>Daanial Farrukh</dc:creator>
      <pubDate>Mon, 26 Oct 2026 05:52:44 +0000</pubDate>
      <link>https://dev.to/daanial/every-email-rejected-at-once-the-open-resolver-mistake-that-makes-spamhaus-answer-no-1j5d</link>
      <guid>https://dev.to/daanial/every-email-rejected-at-once-the-open-resolver-mistake-that-makes-spamhaus-answer-no-1j5d</guid>
      <description>&lt;p&gt;&lt;em&gt;A presales war story about a mail server that suddenly rejected every sender on the internet — Gmail, Outlook, the bank — and the two DNS queries I now run on every mail server before anyone blames the blacklist.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The message came in early in the morning.&lt;/p&gt;

&lt;p&gt;"Nobody can email us. Gmail bounces. Outlook bounces. Even the bank statements are not arriving."&lt;/p&gt;

&lt;p&gt;The server was a customer's cPanel/WHM box on Ubuntu. It had been running mail for years without much drama. Nothing had been deployed. Nobody had touched the mail settings.&lt;/p&gt;

&lt;p&gt;The bounce text was the same for every sender:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;is in an RBL: Error: open resolver
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first reaction on the call was the natural one.&lt;/p&gt;

&lt;p&gt;"So Gmail is on a blacklist?"&lt;/p&gt;

&lt;p&gt;I asked one question back.&lt;/p&gt;

&lt;p&gt;"All of Gmail, all of Outlook and your bank — on the same blacklist, on the same morning?"&lt;/p&gt;

&lt;p&gt;Silence. Then: "That doesn't sound right, does it?"&lt;/p&gt;

&lt;p&gt;It didn't. When every sender on the internet is "listed" at once, the senders are not the problem. The thing doing the checking is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trap, Named
&lt;/h2&gt;

&lt;p&gt;That's the trap. An RBL rejection reads like a verdict on the sender. So people look at the sender. They open the blacklist websites, paste in Google's IP addresses, and find nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The error was not about the senders at all. It was about how this server asked the question.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And the answer to that was hiding in one file that nobody had looked at in years.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "Open Resolver" Actually Means Here
&lt;/h2&gt;

&lt;p&gt;A blacklist like Spamhaus ZEN is queried over DNS. When mail arrives from an address, the server reverses the IP and asks Spamhaus a DNS question about it. The flow looks like this:&lt;/p&gt;

&lt;p&gt;Sender connects → Exim takes the sender's IP → local resolver → Spamhaus DNS → answer → accept or reject&lt;/p&gt;

&lt;p&gt;If the answer is empty, the address is clean. If the answer is an address in &lt;code&gt;127.0.0.x&lt;/code&gt;, the address is listed and Exim rejects the mail.&lt;/p&gt;

&lt;p&gt;Here is the part that trips people up. &lt;strong&gt;Spamhaus does not answer queries that come through big public resolvers&lt;/strong&gt; such as &lt;code&gt;1.1.1.1&lt;/code&gt; or &lt;code&gt;8.8.8.8&lt;/code&gt;. Those resolvers carry traffic for millions of users, so Spamhaus blocks them. Instead of a real answer, it returns a special code: &lt;code&gt;127.255.255.254&lt;/code&gt;. That code means "you asked through a public resolver, I will not tell you".&lt;/p&gt;

&lt;p&gt;Now look at that code again. It starts with &lt;code&gt;127.&lt;/code&gt;. A mail server that treats any &lt;code&gt;127.x&lt;/code&gt; answer as "listed" now sees every single sender as listed. Gmail, Outlook, the bank, the customer's own staff at home — all rejected.&lt;/p&gt;

&lt;p&gt;That is what "open resolver" in the bounce means. Not "the sender is an open resolver". It means &lt;strong&gt;"the server asked me through a public resolver, so I refused to answer"&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This setup is common in three places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Servers where someone "fixed slow DNS" once by forwarding to public resolvers.&lt;/li&gt;
&lt;li&gt;Images or templates that ship a resolver config pointing at &lt;code&gt;1.1.1.1&lt;/code&gt; or &lt;code&gt;8.8.8.8&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Networks where outbound DNS was once blocked, and forwarding was the quick workaround.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why Nobody Saw It
&lt;/h2&gt;

&lt;p&gt;The obvious check is &lt;code&gt;/etc/resolv.conf&lt;/code&gt;. On this server it looked perfect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;nameserver&lt;/span&gt; &lt;span class="m"&gt;127&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That line says "use the local resolver". Most people stop there. Local resolver, so it must do its own recursion. Green tick.&lt;/p&gt;

&lt;p&gt;It didn't. The local resolver was Unbound, and Unbound has its own config. Inside &lt;code&gt;/etc/unbound/unbound.conf&lt;/code&gt; there was a block like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;forward-zone&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;."&lt;/span&gt;
    &lt;span class="na"&gt;forward-addr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1.1.1.1&lt;/span&gt;
    &lt;span class="na"&gt;forward-addr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;8.8.8.8&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;name: "."&lt;/code&gt; means "for every domain on the internet". So Unbound was not resolving anything itself. It took every question, including the Spamhaus ones, and passed it to Cloudflare and Google DNS. From Spamhaus's point of view, the question came from a public resolver.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;resolv.conf&lt;/code&gt; told the truth about the first hop. It said nothing about the second.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Why did it suddenly start that morning, and not years ago? I never proved the exact moment the block started to bite, so I won't pretend I know. What I can say is that the config was wrong all along, and the safe design does not depend on a third party's tolerance.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Actually Fix This
&lt;/h2&gt;

&lt;p&gt;Everything below is read-only until Step 3, which I mark clearly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Prove the RBL lookups are broken — not the senders.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Spamhaus keeps two test entries for exactly this. &lt;code&gt;127.0.0.2&lt;/code&gt; is always listed. &lt;code&gt;127.0.0.1&lt;/code&gt; is never listed. Ask the server's own resolver about both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short 2.0.0.127.zen.spamhaus.org
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short 1.0.0.127.zen.spamhaus.org
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A healthy server returns one or more &lt;code&gt;127.0.0.x&lt;/code&gt; addresses for the first query and nothing at all for the second.&lt;/p&gt;

&lt;p&gt;A broken server gives the same answer to both. If that answer is &lt;code&gt;127.255.255.254&lt;/code&gt;, you have your cause: Spamhaus sees your queries coming from a public resolver.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This error is not a bug. It is the verification.&lt;/strong&gt; The test entries exist so you can tell "my resolver is wrong" apart from "the sender is listed" in ten seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: Find where the forwarding lives.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;First confirm the first hop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /etc/resolv.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it points to &lt;code&gt;127.0.0.1&lt;/code&gt;, the local resolver is in charge. Now look inside it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"forward"&lt;/span&gt; /etc/unbound/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any &lt;code&gt;forward-zone:&lt;/code&gt; with &lt;code&gt;name: "."&lt;/code&gt; and public addresses is the problem. If the server uses a different local resolver, look for the same idea there: a forwarder that sends everything to &lt;code&gt;1.1.1.1&lt;/code&gt;, &lt;code&gt;8.8.8.8&lt;/code&gt; or your provider's shared resolvers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3 (changes state): Remove the forwarder.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Back up the file, then comment out the whole &lt;code&gt;forward-zone:&lt;/code&gt; block — all four lines, including &lt;code&gt;name: "."&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; /etc/unbound/unbound.conf /etc/unbound/unbound.conf.bak
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;#forward-zone:
#    name: "."
#    forward-addr: 1.1.1.1
#    forward-addr: 8.8.8.8
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With no forwarder, Unbound does full recursion itself, starting from the root servers. The queries to Spamhaus now come from the server's own IP, which is exactly what Spamhaus wants.&lt;/p&gt;

&lt;p&gt;Check the config before you restart, so a typo does not take DNS down for the whole box:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;unbound-checkconf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It must say &lt;code&gt;no errors&lt;/code&gt;. Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl restart unbound
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 4: Verify — again, with the same two queries.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This step is where most tutorials stop and where mine starts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short 2.0.0.127.zen.spamhaus.org
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short 1.0.0.127.zen.spamhaus.org
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First query: one or more &lt;code&gt;127.0.0.x&lt;/code&gt; answers. Second query: empty. Two different answers means the lookups are real again.&lt;/p&gt;

&lt;p&gt;Then watch real mail come in. Send a test from an outside mailbox and follow the log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /var/log/exim_mainlog
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You want to see the message accepted, not rejected with &lt;code&gt;in an RBL&lt;/code&gt;. In our case inbound mail from the big providers started flowing as soon as Unbound restarted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 5: Leave the whitelist alone if it is there on purpose.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This server also had a WHM RBL whitelist for two Google mail ranges, &lt;code&gt;209.85.0.0/16&lt;/code&gt; and &lt;code&gt;74.125.0.0/16&lt;/code&gt;. It was tempting to clean it up during the fix. We kept it. Another list, SpamCop, does sometimes list a real Gmail relay for a short time, and that whitelist keeps business mail flowing when it happens. It had nothing to do with this outage, so it stayed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plain takeaway:&lt;/strong&gt; fix the cause you proved, and don't tidy up things you have not proven wrong in the same change.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bigger Lesson — A Mail Server Must Do Its Own Recursion
&lt;/h2&gt;

&lt;p&gt;A web server can live happily behind a public resolver. A mail server cannot. Its spam defence depends on DNS questions that the blacklist operators only answer for resolvers they can trust to be one server, not millions of users.&lt;/p&gt;

&lt;p&gt;Here is the short checklist I now run on any mail server I inherit:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Test the RBL path with the test entries.&lt;/strong&gt; &lt;code&gt;dig +short 2.0.0.127.zen.spamhaus.org&lt;/code&gt; must answer, &lt;code&gt;dig +short 1.0.0.127.zen.spamhaus.org&lt;/code&gt; must not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the first hop.&lt;/strong&gt; &lt;code&gt;cat /etc/resolv.conf&lt;/code&gt; should point to a local resolver.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the second hop.&lt;/strong&gt; &lt;code&gt;grep -rn "forward" /etc/unbound/&lt;/code&gt; should find no &lt;code&gt;name: "."&lt;/code&gt; forwarder to public DNS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate before restart.&lt;/strong&gt; &lt;code&gt;unbound-checkconf&lt;/code&gt; must say &lt;code&gt;no errors&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search the logs for the symptom.&lt;/strong&gt; &lt;code&gt;grep -c "open resolver" /var/log/exim_mainlog&lt;/code&gt; should be zero.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know why every whitelist entry exists.&lt;/strong&gt; Look at WHM › Exim Configuration Manager and write down the reason next to each RBL whitelist range.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each item takes seconds. Together they turn "the whole internet is blacklisted" into a one-line fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Tell Every Customer Now
&lt;/h2&gt;

&lt;p&gt;When a customer shows me their mail server design, I ask one question:&lt;/p&gt;

&lt;p&gt;"When this server checks a blacklist, whose DNS resolver asks the question?"&lt;/p&gt;

&lt;p&gt;If the answer is "the server's own resolver, doing its own recursion", they are in good shape. If the answer is "it uses Google DNS, it's faster", we're not done yet.&lt;/p&gt;

&lt;p&gt;I write about the questions I ask in real customer rooms — the ones that change architectures and surface the problems no one else noticed. If you found this useful, drop your email below and I'll send the next one when it's ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What does "is in an RBL: Error: open resolver" mean?
&lt;/h3&gt;

&lt;p&gt;It means your mail server asked a blacklist like Spamhaus through a public DNS resolver, and Spamhaus refused to answer. The refusal code starts with 127, so the mail server reads it as "listed" and rejects the sender. The sender is usually not listed at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does Spamhaus block 1.1.1.1 and 8.8.8.8?
&lt;/h3&gt;

&lt;p&gt;Public resolvers carry queries for millions of users, so Spamhaus cannot tell who is asking or how much. It refuses those queries and returns 127.255.255.254 instead of a real answer. Mail servers should query from their own resolver and their own IP.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I test if my RBL lookups work?
&lt;/h3&gt;

&lt;p&gt;Run &lt;code&gt;dig +short 2.0.0.127.zen.spamhaus.org&lt;/code&gt; and &lt;code&gt;dig +short 1.0.0.127.zen.spamhaus.org&lt;/code&gt; on the mail server. The first must return 127.0.0.x and the second must return nothing. If both give the same answer, your lookups are broken.&lt;/p&gt;

&lt;h3&gt;
  
  
  My resolv.conf already says 127.0.0.1. Can I still have this problem?
&lt;/h3&gt;

&lt;p&gt;Yes. 127.0.0.1 only means a local resolver such as Unbound answers first. If that resolver has a forward-zone for "." pointing at public DNS, every query still leaves through the public resolver. Check the local resolver's own config.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is it safe to remove the forward-zone from Unbound?
&lt;/h3&gt;

&lt;p&gt;Yes, as long as the server can reach the internet on port 53. Unbound then resolves from the root servers itself. Back up the file, run unbound-checkconf, and restart the service, then repeat the two test queries.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I whitelist Google and Microsoft ranges to stop the rejections?
&lt;/h3&gt;

&lt;p&gt;No, not for this problem. A whitelist only hides the symptom for the ranges you add, and every other sender stays blocked. Fix the resolver. Keep a whitelist only where you have a proven reason for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Download the PDF:&lt;/strong&gt; &lt;a href="https://quabin.com/wp-content/uploads/2026/10/quabin-spamhaus-open-resolver-all-mail-rejected.pdf" rel="noopener noreferrer"&gt;https://quabin.com/wp-content/uploads/2026/10/quabin-spamhaus-open-resolver-all-mail-rejected.pdf&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://quabin.com/spamhaus-open-resolver-all-mail-rejected/" rel="noopener noreferrer"&gt;quabin.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>email</category>
      <category>linux</category>
      <category>devops</category>
      <category>dns</category>
    </item>
  </channel>
</rss>
