<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Servers and databases - Matteo Cisilino</title>
	<atom:link href="https://www.cisilino.com/en/category/servers-databases/feed/" rel="self" type="application/rss+xml" />
	<link>https://www.cisilino.com/en/</link>
	<description>Guide su WordPress, server e sicurezza</description>
	<lastBuildDate>Sat, 31 Oct 2026 10:03:49 +0000</lastBuildDate>
	<language>en-GB</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1.4</generator>
	<item>
		<title>Database credential rotation after an ISPConfig breach</title>
		<link>https://www.cisilino.com/en/2026/10/31/database-credential-rotation-ispconfig-breach/</link>
					<comments>https://www.cisilino.com/en/2026/10/31/database-credential-rotation-ispconfig-breach/#respond</comments>
		
		<dc:creator><![CDATA[matteocisilino]]></dc:creator>
		<pubDate>Sat, 31 Oct 2026 10:03:56 +0000</pubDate>
				<category><![CDATA[Servers and databases]]></category>
		<category><![CDATA[bash script]]></category>
		<category><![CDATA[credential rotation]]></category>
		<category><![CDATA[incident response]]></category>
		<category><![CDATA[ISPConfig]]></category>
		<category><![CDATA[MySQL]]></category>
		<category><![CDATA[server management]]></category>
		<guid isPermaLink="false">https://www.cisilino.com/?p=1412</guid>

					<description><![CDATA[<p>I rotate credentials the moment I suspect a breach. Not after I&#8217;ve confirmed one. Database credential rotation on an ISPConfig server sounds like routine maintenance. It stops being routine the day you find a web shell on a hosted site. Or a configuration file you can&#8217;t explain, sitting in a public backup. At that point, [&#8230;]</p>
<p>The post <a href="https://www.cisilino.com/en/2026/10/31/database-credential-rotation-ispconfig-breach/">Database credential rotation after an ISPConfig breach</a> first appeared on <a href="https://www.cisilino.com/en/">Matteo Cisilino</a>.</p>]]></description>
										<content:encoded><![CDATA[<p class="wp-block-paragraph">I rotate credentials the moment I suspect a breach. Not after I&#8217;ve confirmed one. Database credential rotation on an ISPConfig server sounds like routine maintenance. It stops being routine the day you find a web shell on a hosted site. Or a configuration file you can&#8217;t explain, sitting in a public backup. At that point, every password tied to that database stack needs to be new. Verified. Documented. Not just assumed to be fine.</p>



<figure class="wp-block-image size-large"><img fetchpriority="high" decoding="async" src="https://www.cisilino.com/wp-content/uploads/2026/10/database-credential-rotation-ispconfig.webp" alt="database credential rotation lock and key rotating above an ISPConfig database" width="1200" height="630"/></figure>



<h2 class="wp-block-heading">When does rotation stop being optional?</h2>



<p class="wp-block-paragraph">A few scenarios bring this up often enough that I keep a rotation plan ready for them. I&#8217;d rather not improvise one under pressure.</p>



<p class="wp-block-paragraph">A plugin or CMS on a hosted site gets compromised. I wrote about one real case on this kind of server in <a href="https://www.cisilino.com/en/2026/10/29/php2shell-cve-2026-109618-server-mitigation/">PHP2Shell: how I protected my servers while waiting for the patch</a>, a PHP vulnerability that let an attacker drop a web shell before a patch existed. Once a web shell runs under a hosting user, it can often read that site&#8217;s own config.inc.php. On a shared ISPConfig box, that file sits next to credentials for the control panel&#8217;s own database. Not only the one credential the attacker was actually after.</p>



<p class="wp-block-paragraph">A configuration file ends up somewhere it shouldn&#8217;t. A backup archive left in a public folder. A repository pushed with a stray file in it. A log that&#8217;s more readable than anyone intended. config.inc.php and mysql_clientdb.conf have no business in any of those places. Mistakes happen anyway, including mine.</p>



<p class="wp-block-paragraph">A system administrator leaves the team. However amicable the departure, shared database credentials they once held are credentials I no longer fully trust. Trust doesn&#8217;t transfer automatically when someone walks out the door.</p>



<p class="wp-block-paragraph">A suspected intrusion, even an unconfirmed one. This case is hardest to explain to anyone who hasn&#8217;t run a server under suspicion of attack. You don&#8217;t wait for proof before acting. You assume compromise and rotate. An unnecessary rotation costs an afternoon. A skipped one, after a real breach, can cost every site the server hosts.</p>



<h2 class="wp-block-heading">Why manual database credential rotation is so error-prone</h2>



<p class="wp-block-paragraph">The first time I rotated ISPConfig&#8217;s database credentials by hand, I underestimated how many files were involved. They all have to change together, or something quietly breaks a few hours later.</p>



<p class="wp-block-paragraph">There&#8217;s config.inc.php on the server side, and a second copy on the interface side. Both need the same new values. There&#8217;s mysql_clientdb.conf, which ISPConfig reads to manage every hosted site&#8217;s own database, not just its own housekeeping. Dovecot, Pure-FTPd and Postfix each keep their own copy of some of the same credentials. Mail and FTP can stop authenticating silently if you update the database user and forget just one of those three.</p>



<p class="wp-block-paragraph">Then there&#8217;s the Master/Slave topology some of my setups use. A Slave node authenticates to its Master&#8217;s database over the network, under a separate password. That password has to be rotated on the Master itself, not only locally. Get the sequence wrong, and a Slave can lock itself out of the very Master it&#8217;s supposed to be replicating from.</p>



<p class="wp-block-paragraph">None of that is conceptually difficult. It&#8217;s a lot of files. Several services to restart, in a sensible order. Almost no tolerance for a typo, especially while you&#8217;re doing it under pressure, right after finding something you didn&#8217;t want to find. That combination is exactly why database credential rotation kept feeling riskier by hand than the task itself should be. It&#8217;s why I wrote reset_DB_password.sh, after living through the problem myself more than once.</p>



<h2 class="wp-block-heading">What reset_DB_password.sh actually does</h2>



<p class="wp-block-paragraph">reset_DB_password.sh detects whether the node is Standalone, Master or Slave. It then runs only the phases that role needs.</p>



<p class="wp-block-paragraph">Phase A rotates the local MySQL root password. That&#8217;s the password ISPConfig itself uses to manage every hosted site&#8217;s database. The script updates mysql_clientdb.conf to match the new value.</p>



<p class="wp-block-paragraph">Phase B rotates the ispconfig control user&#8217;s password. It updates config.inc.php on both the server and interface side. Then it updates the matching credentials inside Dovecot, Pure-FTPd and Postfix, so mail and FTP keep authenticating without a separate manual pass through three more config files.</p>



<p class="wp-block-paragraph">Phase C runs only on a Slave. It connects to the Master&#8217;s database remotely. It rotates the Slave user&#8217;s password there, then updates dbmaster_password locally, so replication keeps authenticating correctly against the new value.</p>



<p class="wp-block-paragraph">After Phase B, the script offers to restart Dovecot, Pure-FTPd and Postfix for you. I almost always say yes. A credential rotation that leaves a mail service running on the old password isn&#8217;t finished, it&#8217;s just quiet about being broken. Restarting in the same run, right after the config files change, keeps the whole thing inside one short maintenance window instead of three separate ones discovered later.</p>



<h2 class="wp-block-heading">Backups before anything changes</h2>



<p class="wp-block-paragraph">Before touching a single file, the script takes a full rsync copy of /usr/local/ispconfig/ into a timestamped folder. It also attempts a non-blocking mysqldump of the mysql and dbispconfig databases. Non-blocking on purpose: I&#8217;d rather finish a credential rotation than sit waiting on a dump that&#8217;s stuck for some unrelated reason. Every configuration file it edits gets its own .bak copy first, before any string inside it gets replaced.</p>



<p class="wp-block-paragraph">None of that replaces an independent backup strategy. I still take a separate snapshot, or a full backup, before running this on any server that actually matters. The script&#8217;s own disclaimer says the same thing, more bluntly than I just did.</p>



<h2 class="wp-block-heading">Dry run first, every time</h2>



<p class="wp-block-paragraph">Every phase asks for confirmation before changing anything. &#8211;dry-run simulates an entire run without touching a single file. I use dry-run first on every server, every time. Even on ones I&#8217;ve rotated credentials on before, and think I know well. &#8211;skip-local-root skips Phase A, for when only the control user&#8217;s password needs rotating. &#8211;role=master or &#8211;role=slave overrides the automatic detection, for the rare case where it doesn&#8217;t read a server&#8217;s topology correctly on its own.</p>



<p class="wp-block-paragraph">The passwords it generates are 32 characters, letters and digits only. Long enough to resist guessing. Plain enough that MySQL&#8217;s and PHP&#8217;s own parsers never trip over a stray symbol in a connection string. I&#8217;ve tested the script on ISPConfig 3.3.1p1. I haven&#8217;t run it against older or newer branches. I wouldn&#8217;t assume identical behaviour there, without checking first myself.</p>



<p class="wp-block-paragraph">A node&#8217;s role isn&#8217;t always obvious at a glance, especially on a server you inherited rather than built yourself. That&#8217;s why &#8211;role exists, and why the dry-run output is worth reading line by line the first time, not skimmed. I&#8217;d rather spend two minutes confirming a Slave is really a Slave than find out the hard way mid-rotation.</p>



<h2 class="wp-block-heading">What I&#8217;d tell you before you run it</h2>



<p class="wp-block-paragraph"><strong>Disclaimer</strong>: the script is provided as-is, for informational purposes. You use it entirely at your own risk: I accept no responsibility for data loss, downtime, or any error caused by incorrect use or by running it in a context different from the one I use it in myself. Always keep an independent backup or VM snapshot before running it in production, regardless of what the script&#8217;s own backup step already captures for you.</p>



<p class="wp-block-paragraph">It&#8217;s free software. You can redistribute and modify it, with attribution to me as the original author. The source for reset_DB_password.sh, and the rest of the ispconfig-fast-tools collection, is on GitHub at <a href="https://github.com/matteocisilino/ispconfig-fast-tools" target="_blank" rel="noopener">github.com/matteocisilino/ispconfig-fast-tools</a>.</p>



<h2 class="wp-block-heading">Database credential rotation is a habit, not a cleanup job</h2>



<p class="wp-block-paragraph">Database credential rotation after a confirmed breach is just competent incident response. Most administrators would do it without being told twice. The harder habit is doing it after a merely suspected one, before anyone has proof. That&#8217;s the only version of the plan that actually limits the damage, while it&#8217;s still limitable. A script that runs the right phases for a server&#8217;s role, takes its own backups first, and lets me dry-run the whole thing before touching anything real. That&#8217;s what makes the habit practical on a Tuesday night, instead of theoretical in a blog post.</p>



<p class="wp-block-paragraph">ISPConfig, MySQL, PHP, Dovecot, Pure-FTPd, Postfix and the other names mentioned are trademarks of their respective owners. They are used only to identify the products.</p><p>The post <a href="https://www.cisilino.com/en/2026/10/31/database-credential-rotation-ispconfig-breach/">Database credential rotation after an ISPConfig breach</a> first appeared on <a href="https://www.cisilino.com/en/">Matteo Cisilino</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://www.cisilino.com/en/2026/10/31/database-credential-rotation-ispconfig-breach/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Linux Kernel Vulnerability: a Use-After-Free in TCP IPv4</title>
		<link>https://www.cisilino.com/en/2026/10/31/linux-kernel-vulnerability-tcp-ipv4-cve-2026-98432/</link>
					<comments>https://www.cisilino.com/en/2026/10/31/linux-kernel-vulnerability-tcp-ipv4-cve-2026-98432/#respond</comments>
		
		<dc:creator><![CDATA[matteocisilino]]></dc:creator>
		<pubDate>Sat, 31 Oct 2026 09:19:23 +0000</pubDate>
				<category><![CDATA[Servers and databases]]></category>
		<category><![CDATA[CVE]]></category>
		<category><![CDATA[Debian]]></category>
		<category><![CDATA[Linux kernel]]></category>
		<category><![CDATA[server security]]></category>
		<guid isPermaLink="false">https://www.cisilino.com/?p=1403</guid>

					<description><![CDATA[<p>A Linux kernel vulnerability (CVE-2026-98432) causes a use-after-free in TCP IPv4. Debian is already patched; RHEL and Ubuntu are still waiting for a fix.</p>
<p>The post <a href="https://www.cisilino.com/en/2026/10/31/linux-kernel-vulnerability-tcp-ipv4-cve-2026-98432/">Linux Kernel Vulnerability: a Use-After-Free in TCP IPv4</a> first appeared on <a href="https://www.cisilino.com/en/">Matteo Cisilino</a>.</p>]]></description>
										<content:encoded><![CDATA[<p class="wp-block-paragraph">I&#8217;ve been running Debian servers in production for years. Every time a kernel CVE comes out, I stop to read the details. Then I decide whether to patch right away or wait. This time the story is more tangled than usual: there&#8217;s a <strong>Linux kernel vulnerability</strong> in TCP IPv4, a CVSS score that vendors can&#8217;t agree on, and even a reported root exploit built by an AI model. Let&#8217;s start with a question that looks like a side note, but isn&#8217;t: what is MPTCP, and why does it matter even if you don&#8217;t use it.</p>



<figure class="wp-block-image size-large"><img decoding="async" src="https://www.cisilino.com/wp-content/uploads/2026/10/vulnerabilita-kernel-linux-tcp-cve-2026-98432.webp" alt="Linux kernel vulnerability in TCP IPv4, stylised penguin with a patched Debian shield" width="1200" height="630"/></figure>



<h2 class="wp-block-heading">What MPTCP is, and why it matters even if you don&#8217;t use it</h2>



<p class="wp-block-paragraph">Multipath TCP (MPTCP) is the RFC 8684 standard. It extends classic TCP so a single connection can travel over several network paths at once. A phone can use WiFi and mobile data at the same time, for the same connection. A server with several interfaces can do the same, for redundancy or for combined bandwidth. Apple has used it in production since iOS 7, back in 2013, for Siri: it&#8217;s the best-known real-world case. Some mobile carriers, for example in South Korea, use it for WiFi/LTE bonding. Several data centres adopt it for multi-homing across different uplinks. It&#8217;s been in the mainline Linux kernel since 5.6.</p>



<p class="wp-block-paragraph">The point that made me raise an eyebrow is a different one. From version 1.24 onwards, programs written in Go require MPTCP by default for listening sockets. The Go Security Team confirmed this on 29 October. Along with it, they gave an uncomfortable warning: the bug we&#8217;re talking about today isn&#8217;t MPTCP-specific. A system with no active MPTCP listeners can still be exposed to similar attacks. The vulnerable code sits in the generic IPv4 TCP path, not in an optional module. You can disable MPTCP per program, with the <code>GODEBUG=multipathtcp=0</code> runtime variable. But the Go Security Team calls this mitigation &#8220;fragile&#8221;: a single MPTCP listener left exposed is enough to undo it. Nobody has shown that every working exploit actually depends on MPTCP. That&#8217;s why the MPTCP code stays an attack surface, even if you never consciously enable it in your stack: it just needs to be compiled into the kernel and reachable.</p>



<p class="wp-block-paragraph">The MPTCP project itself states, on its own page, that the main current Linux distributions ship the kernel with MPTCP support compiled in by default (<code>CONFIG_MPTCP=y</code>). It explicitly names Debian, Ubuntu, RHEL and Fedora. That&#8217;s a statement from the project itself, not an independent distro-by-distro check, but the underlying point still holds: support is switched on at the kernel level, not &#8220;opt-in&#8221; at the operating system level. Only the per-connection negotiation is left to the application.</p>



<h2 class="wp-block-heading">CVE-2026-98432, the Linux kernel vulnerability, in brief</h2>



<p class="wp-block-paragraph">The Linux kernel CVE team published CVE-2026-98432 on 15 October 2026. The upstream fix had already landed ten days earlier, on 5 October. It often happens that publication lags behind the commit. The CVSS score of 9.8 (critical) only arrived on 24 October, nine days after the CVE was published.</p>



<p class="wp-block-paragraph">The problem lies in TCP handshake handling for IPv4, specifically in the <code>tcp_v4_syn_recv_sock()</code> function. When a connection is created, the IP options from the SYN packet are passed from the pending request socket to the new socket. For example Record Route or Timestamp, if the client includes them in a crafted SYN. The problem is that they&#8217;re passed by reference, not by copy, and without the listener&#8217;s lock. According to the technical analysis of the patch, the structures involved are called <code>inet_opt</code> and <code>ireq_opt</code>. A second CPU can free that buffer with <code>kfree()</code>, called from <code>inet_sock_destruct()</code> or from <code>tcp_v4_reqsk_destructor()</code>. Meanwhile a first CPU is still reading it in <code>ip_options_build()</code>, to build the SYNACK. It&#8217;s a classic use-after-free caused by a race condition. The equivalent IPv6 path, <code>tcp_v6_syn_recv_sock()</code>, copies the options instead of passing them by reference: it isn&#8217;t vulnerable to the main race. There&#8217;s also a second attack route, through request-socket migration: <code>net.ipv4.tcp_migrate_req=1</code> or an eBPF <code>BPF_SK_REUSEPORT_SELECT_OR_MIGRATE</code> program. Both are disabled by default.</p>



<p class="wp-block-paragraph">The bug is remote and needs no authentication or user interaction, according to the kernel team&#8217;s assessment. It&#8217;s been present since kernel version 4.4. The fix is signed off by Eric Dumazet, reported by Xinyang Ge of Anthropic. It moves the memory release to after an RCU grace period, with <code>kfree_rcu()</code>: that way no CPU can read a buffer that&#8217;s already been freed. The fixed stable versions are 5.10.272, 5.15.223, 6.1.190, 6.6.159, 6.12.113, 6.18.56, 7.2.10 and mainline from 7.3-rc7.</p>



<h2 class="wp-block-heading">Conflicting CVSS scores: not every vendor agrees</h2>



<p class="wp-block-paragraph">Here&#8217;s where the story gets complicated. The kernel CVE team and Ubuntu rate attack complexity as low, and give it 9.8 (critical). Oracle and SUSE instead give 8.1 (important): same CVSS vector, but high attack complexity. Red Hat, oddly, has two assessments that don&#8217;t match each other. The public CVE page shows &#8220;Moderate, 5.9&#8221;. The machine-readable advisory file, updated on 29 October, shows instead &#8220;Important, 8.1&#8221;, with high impact on confidentiality and integrity. The same day, Red Hat&#8217;s internal bug tracker raised the severity from medium to high. Amazon, for its part, assigns 7.0. At the time of writing, the NVD hasn&#8217;t yet published a score of its own.</p>



<p class="wp-block-paragraph">It&#8217;s worth saying this plainly. This internal inconsistency at Red Hat isn&#8217;t a detail I worked out myself: it&#8217;s reported that way by the original article from The Hacker News, my main source for this data. It&#8217;s not the first time a vendor has published two different numbers for the same bug, on different channels. But here the jump from &#8220;moderate&#8221; to &#8220;important&#8221; on the same day says a lot. Even for the people who write the code, reliably estimating the real exploitability of a kernel race condition is hard.</p>



<h2 class="wp-block-heading">Patch status: Debian already fixed, others not yet</h2>



<p class="wp-block-paragraph">This is the point that affects me most directly: the hosting servers I manage run Debian stable. And here the facts speak for themselves, no need to add emphasis: <strong>Debian has already fixed this Linux kernel vulnerability</strong>, on both trixie and bookworm. Debian trixie (stable) was fixed on 16 October 2026, with package <code>linux 6.12.113-1</code>, advisory DSA-6568-1. Debian 12 (bookworm) was fixed in two steps: on 17 and 22 October 2026, with <code>linux 6.1.190-1</code> (advisory DLA-4841-1) and <code>linux-6.12 6.12.113-1~deb12u1</code> (advisory DLA-4850-1). In its own tracking table, Debian doesn&#8217;t list a separate CVSS score for this CVE. It simply marks the fix status for each release: that&#8217;s the information that actually matters, for anyone deciding whether to reboot a server.</p>



<p class="wp-block-paragraph">Comparing this with the status of other widely used distributions helps put it in perspective. As of 30 October, Red Hat has updated the status for RHEL 8, 9, 10 and OpenShift 4. It used to be &#8220;fix deferred&#8221; (marked on 29 October), now it&#8217;s &#8220;affected&#8221;. No release date has been given. Ubuntu, across all supported versions (22.04, 24.04, 26.04, 26.10), is still listed as &#8220;in progress&#8221;, with no date. Ubuntu 16.04, 18.04 and 20.04 are listed as vulnerable, with no fix listed at all. SUSE Linux Enterprise Server, from SP4 through SP7 plus 16.0, is in the same situation. Oracle Linux (UEK), openSUSE Leap 16.0, SUSE Linux Micro 6.2 and Amazon Linux 2023 are, instead, already fixed. RHEL 7 isn&#8217;t affected: the vulnerable code path was never shipped on that release.</p>



<p class="wp-block-paragraph">I&#8217;m not saying this to score points against Ubuntu or RHEL. They remain serious distributions, with solid security processes. I&#8217;m saying it because, on this specific bug, in this time window, the facts are these: anyone relying on hosting built on Debian stable already has the fix installed. On other very widely used distributions, the server stays exposed for an indefinite period. It&#8217;s a concrete, verifiable advantage, just by looking at the dates. It isn&#8217;t an opinion about the Debian project in general.</p>



<h2 class="wp-block-heading">Anthropic&#8217;s exploit (reported, not independently verified)</h2>



<p class="wp-block-paragraph">The most unusual part of this story involves Anthropic, the company that first reported the bug through Xinyang Ge. According to industry reports, one of its internal AI models, not released publicly, built a working root exploit entirely on its own. Starting from nothing but the patch and the report as a prompt. The two-stage attack reportedly works like this. In the first stage, the race makes the kernel&#8217;s response leak extra bytes to the attacker. From there the attacker recovers a kernel address, defeating KASLR. In the second stage, the same bug reportedly causes a buffer overflow. It overwrites a callback pointer with the address of a payload the attacker has already placed in kernel memory. When the kernel calls that pointer, the payload runs an arbitrary shell command as root. A lost race would only fail that single connection attempt, without crashing the system: so repeated attempts would be possible. In the test described by Anthropic: 48 minutes to steal the kernel address, another 5 minutes to reach command execution.</p>



<p class="wp-block-paragraph">Red Hat says a remote attacker reaching an exposed TCP service can generally trigger the race. The root exploit described by Anthropic, though, reportedly targeted specifically servers with an active multipath TCP listener. Anthropic&#8217;s post doesn&#8217;t clarify whether a server without MPTCP is exploitable in the same way. Neither the CVE record nor the upstream fix mention MPTCP anywhere. It&#8217;s trade press, not a primary source, reporting this link: so I&#8217;m writing it with the scepticism it deserves, not as a hundred-percent established fact. Anthropic hasn&#8217;t published the exploit code, the exact kernel version used, or the target server&#8217;s configuration. Another company, Innora, has published a root-cause analysis, with a QEMU reproduction environment. It stated it won&#8217;t release a weaponised exploit in the short term. At the moment there&#8217;s no evidence of real-world exploitation by vendors or CERTs. The bug isn&#8217;t in the CISA KEV catalogue, according to the copy maintained by Luxembourg&#8217;s CIRCL, updated as of 28 October.</p>



<h2 class="wp-block-heading">What to do on your servers right now</h2>



<p class="wp-block-paragraph">No vendor has published an officially sanctioned workaround for this <strong>Linux kernel vulnerability</strong>. Red Hat states explicitly that none of the measures circulating so far meet its standard for ease of deployment and stability. The primary mitigation remains applying the fixed kernel and rebooting, once the patch is available for your distribution. On Debian it already is. On RHEL and Ubuntu, not yet, at the time of writing.</p>



<p class="wp-block-paragraph">As defence in depth, not as a definitive fix, a few things are worth doing. Check that <code>net.ipv4.tcp_migrate_req</code> is set to 0. Check that no <code>BPF_SK_REUSEPORT_SELECT_OR_MIGRATE</code> eBPF programs are loaded. Consider filtering incoming IPv4 packets that carry Record Route or Timestamp options. Monitor for any crashes mentioning <code>ip_options_build</code>, <code>ip_build_and_send_pkt</code> or <code>skb_under_panic</code>. Take stock of any exposed MPTCP listeners, and avoid leaving them reachable without a reason. If you also manage defences against more common application-level attacks, <a href="https://www.cisilino.com/en/2026/10/29/php2shell-cve-2026-109618-server-mitigation/">how I protected my servers while waiting for the PHP2Shell patch</a> takes the same approach: mitigate what you can while waiting for the definitive fix, without promising protections that don&#8217;t exist.</p>



<p class="wp-block-paragraph">For anyone asking me whether to patch now: if you run Debian stable, the fixed kernel is already in the security repositories. The update is straightforward, and the exposure window is already closed. If you run other distributions, there&#8217;s no patch to install yet today. It&#8217;s worth following your vendor&#8217;s advisories closely over the coming days: the situation is changing quickly.</p>



<p class="wp-block-paragraph">Sources: <a href="https://thehackernews.com/2026/10/linux-kernel-tcp-flaw-with-98-cvss.html" target="_blank" rel="nofollow noopener">The Hacker News</a>, the <a href="https://security-tracker.debian.org/tracker/CVE-2026-98432" target="_blank" rel="nofollow noopener">Debian Security Tracker</a> and the <a href="https://github.com/torvalds/linux/commit/e3f33b0a1d89d738e2b913dab519226eaf76e15b" target="_blank" rel="noopener">fix commit</a>. Debian, Red Hat, Ubuntu and the other names mentioned are trademarks of their respective owners.</p><p>The post <a href="https://www.cisilino.com/en/2026/10/31/linux-kernel-vulnerability-tcp-ipv4-cve-2026-98432/">Linux Kernel Vulnerability: a Use-After-Free in TCP IPv4</a> first appeared on <a href="https://www.cisilino.com/en/">Matteo Cisilino</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://www.cisilino.com/en/2026/10/31/linux-kernel-vulnerability-tcp-ipv4-cve-2026-98432/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>WordPress email log: why I check it with FluentSMTP</title>
		<link>https://www.cisilino.com/en/2026/10/31/wordpress-email-log-why-i-check-it-fluentsmtp/</link>
					<comments>https://www.cisilino.com/en/2026/10/31/wordpress-email-log-why-i-check-it-fluentsmtp/#respond</comments>
		
		<dc:creator><![CDATA[matteocisilino]]></dc:creator>
		<pubDate>Sat, 31 Oct 2026 08:00:00 +0000</pubDate>
				<category><![CDATA[Servers and databases]]></category>
		<category><![CDATA[email]]></category>
		<category><![CDATA[FluentSMTP]]></category>
		<category><![CDATA[SMTP]]></category>
		<category><![CDATA[WordPress plugin]]></category>
		<guid isPermaLink="false">https://www.cisilino.com/?p=1312</guid>

					<description><![CDATA[<p>Why I check the WordPress email log in FluentSMTP before server logs: failed sends, manual resending, encrypted SMTP credentials, backup connections, and the log's honest limits.</p>
<p>The post <a href="https://www.cisilino.com/en/2026/10/31/wordpress-email-log-why-i-check-it-fluentsmtp/">WordPress email log: why I check it with FluentSMTP</a> first appeared on <a href="https://www.cisilino.com/en/">Matteo Cisilino</a>.</p>]]></description>
										<content:encoded><![CDATA[<p class="wp-block-paragraph">I manage several WordPress sites. On each one, more emails go out than anyone would guess: contact notifications, password resets, order confirmations, alerts to site managers. The WordPress email log in FluentSMTP is the tool I check as soon as a client writes &#8220;I never got anything.&#8221; I used to look at the SMTP server logs from the command line. I had to match grep output and timestamps by hand. Now I open a page in the WordPress dashboard and it&#8217;s all right there.</p>



<figure class="wp-block-image size-large"><img decoding="async" src="https://www.cisilino.com/wp-content/uploads/2026/10/fluent-smtp-log-invii-email-wordpress.webp" alt="WordPress email log shown in the FluentSMTP dashboard" width="1200" height="630"/></figure>



<h2 class="wp-block-heading">Why I check the WordPress email log before the server logs</h2>



<p class="wp-block-paragraph">The typical case is always the same. A contact form that &#8220;isn&#8217;t arriving&#8221;. A client who never gets the password reset email. An order left without a confirmation. Without a log at hand, the first guess is the spam folder. Then the SMTP server logs. Then maybe the firewall. With FluentSMTP I skip those steps. I can see straight away whether WordPress actually tried to send that email. To whom, when, and with what response from the sending server.</p>



<p class="wp-block-paragraph">FluentSMTP logs every call to <code>wp_mail()</code>. Not just emails from third-party plugins, but also the ones generated by core: new-user notifications, update reminders, error alerts. For every send, the log shows the sender, recipient, subject, full headers, and message body. And the response from the SMTP provider or API used. If an email failed, that server response is right there. I don&#8217;t have to guess it.</p>



<h2 class="wp-block-heading">What I actually find in the sending log</h2>



<p class="wp-block-paragraph">The plugin supports dozens of connectors: Amazon SES, SendGrid, Mailgun, Brevo, Postmark, SparkPost. Also Google Workspace and Microsoft 365 via OAuth, as well as plain authenticated SMTP. On the sites I manage I almost always use authenticated SMTP with the client&#8217;s own domain. Sometimes a dedicated API, when the email volume grows. Either way, the WordPress email log doesn&#8217;t change. Every row has a status, sent or failed. There&#8217;s a button to review the content or resend the message, without the user having to repeat the action that triggered it.</p>



<p class="wp-block-paragraph">Manual resending is the feature I use most. A form filled in twice because &#8220;it never arrived&#8221; becomes one click in the log. Not a fresh complaint from the client. For repeated failures I&#8217;ve set up notifications on Telegram. As soon as a connection stops working, I know before anyone writes to tell me. Instead of finding out from a support ticket.</p>



<p class="wp-block-paragraph">The log isn&#8217;t only about notifications aimed at me or the client who manages the site. Every outgoing email the site generates towards third parties ends up there too: the order confirmation an online shop sends to its customer, a payment receipt, a shipping notice. If that confirmation doesn&#8217;t arrive, I see it in the WordPress email log exactly like an internal notification, with the same button to resend it to the right recipient without making them repeat the purchase.</p>



<h2 class="wp-block-heading">How I set up FluentSMTP on the sites I manage</h2>



<p class="wp-block-paragraph">After connecting the right connector, I change the log retention. By default it stays on for a long time. I set it to 30-60 days, depending on the site&#8217;s traffic. That&#8217;s enough to trace back a problem reported late. Without filling the database with rows I&#8217;ll never look at. I then turn on at least one notification channel for failures, Telegram or Slack depending on the client. The log alone doesn&#8217;t alert anyone: it has to be opened to be read.</p>



<p class="wp-block-paragraph">On sites with contact forms I also use <a href="https://www.cisilino.com/en/2026/10/29/wordpress-security-plugin/">Cisilino Site Defense Network</a> to block bots. Less incoming spam means fewer emails generated by the forms. The log stays easier to read, with less noise to filter through when I&#8217;m looking for the right message.</p>



<h2 class="wp-block-heading">SMTP credentials don&#8217;t stay in plain text in the database</h2>



<p class="wp-block-paragraph">One detail I always check before saving a connection: where the credentials end up. FluentSMTP encrypts them by default with WordPress&#8217;s own SALT keys, the ones already present in <code>wp-config.php</code> for session security. Encryption can be switched off, but the plugin itself flags that as not recommended. On the sites I manage I prefer another option: saving the API or SMTP password directly in <code>wp-config.php</code>, outside the database, by defining the constants before the plugin reads them. That way, even a database dump or unauthorised access to the dashboard doesn&#8217;t expose the sending provider&#8217;s key. It&#8217;s the same principle I apply elsewhere: fewer secrets in the database, the better.</p>



<h2 class="wp-block-heading">Several connections together, not just one at a time</h2>



<p class="wp-block-paragraph">FluentSMTP lets you configure several connections together, not just one at a time. On one site I set a default connection and add a second one as a backup: if the first provider doesn&#8217;t respond, sending switches automatically to the backup connection, without me having to step in. Which connection gets used depends on the sender address: each connection has its own &#8220;From&#8221;, everything else goes through the default one, with the backup kicking in only if the default one really fails (a provider error, not an email that ended up in spam). It&#8217;s one of the reasons I prefer it to a single SMTP setup with no alternative: if SendGrid or Amazon SES has a problem for a few hours, the site keeps sending anyway, and I see it straight away in the WordPress email log. All included in the free version, with no paid pro version: you only pay the sending provider you choose, not the plugin.</p>



<h2 class="wp-block-heading">A daily health check, not just when something breaks</h2>



<p class="wp-block-paragraph">Besides the row-by-row log, FluentSMTP runs a connection health check every day, automatically. If the provider stops responding, the alert arrives before a client writes to tell me &#8220;the site isn&#8217;t sending emails any more&#8221;. The same dashboard shows a chart with daily totals of successful and failed sends: useful for seeing at a glance whether a drop is normal or a sign that something is breaking, without having to open the WordPress email log row by row every time. There&#8217;s also a periodic email summary, with the week&#8217;s totals: it isn&#8217;t a failure alert, it&#8217;s a bulletin I skim through to know whether the sending volume is within the norm.</p>



<p class="wp-block-paragraph">For anyone who, like me, manages several sites from the command line, FluentSMTP also exposes WP-CLI commands: a test send, a connection health check, statistics, and cleanup of old logs. I use them in the same scripts that already run on the servers for other maintenance checks, without having to open every site&#8217;s dashboard one by one.</p>



<h2 class="wp-block-heading">The honest limits of the WordPress email log</h2>



<p class="wp-block-paragraph">It isn&#8217;t a magic solution. Retention is based on days, not on a maximum number of rows. On a site with a lot of transactional traffic the log table can grow quickly. It&#8217;s worth keeping an eye on it, or setting a shorter retention period. Failure notifications only arrive on the chat channels you&#8217;ve configured, not by email. That makes sense: an email warning about a failed email risks failing in turn too. But it&#8217;s worth knowing before relying on it.</p>



<p class="wp-block-paragraph">Finally, the log records what happened on the WordPress side and the provider&#8217;s side. It doesn&#8217;t guarantee delivery into the recipient&#8217;s inbox. If an email is marked as sent but ends up in spam, that&#8217;s a domain reputation or SPF/DKIM configuration issue. Not something a log can fix on its own. It&#8217;s still the first place I look, before any other check.</p>



<p class="wp-block-paragraph">The plugin is free and open source. Official page on <a href="https://wordpress.org/plugins/fluent-smtp/" target="_blank" rel="nofollow noopener">wordpress.org</a>, with no features locked behind a pro version. For anyone managing even a single site with a contact form, it&#8217;s worth installing just for the WordPress email log. The first time it turns &#8220;I never got the email&#8221; into a one-minute fix instead of half an hour, it&#8217;s paid for itself.</p><p>The post <a href="https://www.cisilino.com/en/2026/10/31/wordpress-email-log-why-i-check-it-fluentsmtp/">WordPress email log: why I check it with FluentSMTP</a> first appeared on <a href="https://www.cisilino.com/en/">Matteo Cisilino</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://www.cisilino.com/en/2026/10/31/wordpress-email-log-why-i-check-it-fluentsmtp/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>PHP2Shell: how I protected my servers while waiting for the patch</title>
		<link>https://www.cisilino.com/en/2026/10/29/php2shell-cve-2026-109618-server-mitigation/</link>
					<comments>https://www.cisilino.com/en/2026/10/29/php2shell-cve-2026-109618-server-mitigation/#respond</comments>
		
		<dc:creator><![CDATA[matteocisilino]]></dc:creator>
		<pubDate>Thu, 29 Oct 2026 18:53:44 +0000</pubDate>
				<category><![CDATA[Servers and databases]]></category>
		<category><![CDATA[CVE-2026-109618]]></category>
		<category><![CDATA[CVE-2026-109619]]></category>
		<category><![CDATA[Debian]]></category>
		<category><![CDATA[ISPConfig]]></category>
		<category><![CDATA[mitigation]]></category>
		<category><![CDATA[PHP]]></category>
		<category><![CDATA[php2shell]]></category>
		<category><![CDATA[server management]]></category>
		<category><![CDATA[server security]]></category>
		<guid isPermaLink="false">https://www.cisilino.com/?p=848</guid>

					<description><![CDATA[<p>Two serious flaws in the PHP core (CVE-2026-109618 and CVE-2026-109619): what they are, which versions are hit, the mitigation script and how I updated my servers.</p>
<p>The post <a href="https://www.cisilino.com/en/2026/10/29/php2shell-cve-2026-109618-server-mitigation/">PHP2Shell: how I protected my servers while waiting for the patch</a> first appeared on <a href="https://www.cisilino.com/en/">Matteo Cisilino</a>.</p>]]></description>
										<content:encoded><![CDATA[<p>On 20 October 2026, two serious flaws in the PHP core were made public. The common name is <strong>PHP2Shell</strong> and the identifiers are CVE-2026-109618 and CVE-2026-109619. The CVSS score is 9.2. They allow code to run on the server without authentication, and attacks have already been seen.</p>
<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" src="https://www.cisilino.com/wp-content/uploads/2026/10/php2shell-diagram.webp" alt="PHP2Shell diagram: five circles linked to a box with the terminal symbol" width="1200" height="630"/></figure>
<p>In this article I explain what they are and which PHP versions are involved. I describe how I secured the servers I manage while there was no patch, and how I got to the final update. You will also find the script I used and the commands to run it.</p>
<h2 class="wp-block-heading">What PHP2Shell is</h2>
<p>The problem lies in how PHP reads <code>multipart/form-data</code> requests. This is the format the browser uses to send files. PHP parses it before any script starts.</p>
<p>So you do not need an upload form. A single request to any PHP file on the site may be enough.</p>
<p>The two flaws, in short:</p>
<ul class="wp-block-list">
<li><strong>CVE-2026-109618.</strong> If memory runs out while PHP is reading the request, some data gets corrupted. The corruption shows up in the final clean-up of the request and can lead to code execution.</li>
<li><strong>CVE-2026-109619.</strong> Part of a request&#8217;s state stays in memory. A later request on the same process reuses it.</li>
</ul>
<p>These technical details come from the <a href="https://github.com/NCSC-NL/php2shell/wiki" target="_blank" rel="noopener">analysis by the Dutch NCSC</a> and from other public sources. I have not tried to exploit the flaws on my own servers.</p>
<h2 class="wp-block-heading">Which PHP versions are affected</h2>
<p>The fixed versions came out on 21 October 2026: <strong>PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35</strong>. Every version before these is affected.</p>
<p>Branches 8.1 and older have no official fixed release, because they are out of support. PHP has only published minimal source backports for 7.4, 8.0 and 8.1. For even older branches, nothing has appeared.</p>
<p>The risk covers every way of running PHP. The most exposed is mod_php. PHP-FPM, php-cgi, FrankenPHP and LiteSpeed count too. It also affects every application that runs on PHP: WordPress, Joomla, Drupal, Nextcloud, phpMyAdmin, Roundcube and the ISPConfig panel.</p>
<p>On Debian there is a detail worth knowing. Debian applies fixes as backports and does not change the PHP version number. A server can show a low number and already be fixed. That is why the script does not compare versions. It checks whether the changelog of the installed package mentions the two CVEs.</p>
<h2 class="wp-block-heading">The mitigation, while waiting for the patch</h2>
<p>When the flaws became public, the patches were not yet available for the distributions. I did not want to leave the servers exposed, so I reduced the risk with a script.</p>
<p><strong>The mitigation is not a patch.</strong> Turning off <code>session.upload_progress</code> closes only one of the known paths for CVE-2026-109619 and does nothing against CVE-2026-109618. It was meant to narrow the attack surface until the update, not to replace it.</p>
<h3 class="wp-block-heading">What the script does</h3>
<p>The script is designed for Debian 12 and 13. It works with Apache (mod_php, FastCGI, suPHP, PHP-FPM) and with Nginx (PHP-FPM). It recognises servers managed with ISPConfig, both Apache and Nginx.</p>
<p>The options:</p>
<ul class="wp-block-list">
<li>With no options it only checks. For each PHP package it verifies that the changelog mentions the two CVEs. It flags processes that still use an old binary, and reads the active settings and the listening web ports.</li>
<li><code>--update</code> refreshes the package index first.</li>
<li><code>--logs</code> looks for suspicious signs: recent PHP files in upload or cache folders, typical webshell patterns, references to php2shell in the logs. It is a heuristic and does not prove that the system has been compromised.</li>
<li><code>--harden</code> applies the hardening described below.</li>
<li><code>--dry-run</code>, together with <code>--harden</code>, shows what it would do without changing anything.</li>
<li><code>--skip-disable-functions</code>, together with <code>--harden</code>, leaves <code>disable_functions</code> alone.</li>
<li><code>--rollback</code> removes what <code>--harden</code> applied.</li>
</ul>
<h3 class="wp-block-heading">What &#8211;harden changes</h3>
<p>For each PHP version found in <code>/etc/php</code>, the script creates the file <code>mods-available/cve-hardening.ini</code>. It enables it with <code>phpenmod</code> for the fpm, apache2 and cgi SAPIs. This way the protection applies to all sites at once.</p>
<p>The original <code>php.ini</code> files stay untouched. The CLI is not touched either, so composer, artisan and wp-cli keep working.</p>
<p>The file sets three things:</p>
<ul class="wp-block-list">
<li><code>session.upload_progress.enabled = Off</code></li>
<li><code>expose_php = Off</code></li>
<li><code>disable_functions</code> with exec, passthru, shell_exec, system, proc_open, popen and pcntl_exec, added to those already present.</li>
</ul>
<p>Then the script tests the configuration and reloads PHP-FPM and Apache only if the test passes. Before writing anything, it makes a backup in <code>/var/backups/php-cve-hardening/</code>.</p>
<h3 class="wp-block-heading">The limits to know about</h3>
<ul class="wp-block-list">
<li><code>disable_functions</code> can break some applications, for example WordPress with certain plugins, or Magento. If that happens, use <code>--rollback</code> or run it again with <code>--skip-disable-functions</code>.</li>
<li>The <code>php_admin_value</code> directives in PHP-FPM pools take precedence over the global file. The script flags them but does not change them.</li>
<li>With ISPConfig, the files in <code>pool.d</code> are regenerated by the panel. Overrides must be changed from the panel (Sites &gt; Website &gt; Options &gt; Custom php.ini settings), not by hand.</li>
<li>Per-site <code>php.ini</code> files (ISPConfig FastCGI or suPHP mode) and PHP versions in <code>/opt</code> are not handled. They need to be checked separately.</li>
</ul>
<h2 class="wp-block-heading">How to run the script</h2>
<p>The script is here: <a href="https://cisilino.com/mscripts/php20261.sh">https://cisilino.com/mscripts/php20261.sh</a></p>
<p>Before running anything from the internet, read it. You can download it and open it:</p>
<pre class="wp-block-code"><code>wget -qO php20261.sh https://cisilino.com/mscripts/php20261.sh
less php20261.sh</code></pre>
<p>If you want to run it directly from the remote source, you need root privileges. These are the commands, in the order I recommend.</p>
<p>1. Check only, with a refresh of the package index:</p>
<pre class="wp-block-code"><code>wget -qO- https://cisilino.com/mscripts/php20261.sh | sudo bash -s -- --update</code></pre>
<p>2. Hardening simulation, with no changes:</p>
<pre class="wp-block-code"><code>wget -qO- https://cisilino.com/mscripts/php20261.sh | sudo bash -s -- --harden --dry-run</code></pre>
<p>3. Applying it:</p>
<pre class="wp-block-code"><code>wget -qO- https://cisilino.com/mscripts/php20261.sh | sudo bash -s -- --harden</code></pre>
<p>4. Removal, if something breaks:</p>
<pre class="wp-block-code"><code>wget -qO- https://cisilino.com/mscripts/php20261.sh | sudo bash -s -- --rollback</code></pre>
<p>After <code>--harden</code> and after <code>--rollback</code> you do not need to restart anything by hand: the script reloads PHP-FPM and Apache itself, after testing the configuration. A restart is needed, however, after updating the PHP package, because the running processes keep the old binary in memory:</p>
<pre class="wp-block-code"><code>sudo systemctl restart 'php*-fpm' apache2</code></pre>
<p>The quotes around <code>php*-fpm</code> are needed. Without them, the shell expands the asterisk, not systemctl. If the server uses only Nginx, drop <code>apache2</code>.</p>
<p>The script also reports its outcome as an exit code: 0 if all is well, 1 if there are warnings, 2 if the system is vulnerable or action is needed.</p>
<h2 class="wp-block-heading">On ISPConfig servers: ispconfig-multipart-guard</h2>
<p><a href="https://www.ispconfig.org/blog/php2shell-critical-php-vulnerabilities-cve-2026-109618-cve-2026-109619/" target="_blank" rel="nofollow noopener"><img loading="lazy" decoding="async" src="https://www.cisilino.com/wp-content/uploads/2026/10/ispconfig-logo.png" alt="ISPConfig logo" width="239" height="90"></a></p>
<p>If you use ISPConfig, you have an extra tool. The project has released the <strong>ispconfig-multipart-guard</strong> script, described in <a href="https://www.ispconfig.org/blog/php2shell-critical-php-vulnerabilities-cve-2026-109618-cve-2026-109619/" target="_blank" rel="nofollow noopener">the official ISPConfig advisory</a>.</p>
<p>The principle is simple. If the problem is in multipart requests, the web server stops them before they reach PHP:</p>
<ul class="wp-block-list">
<li>it blocks <code>Content-Type: multipart/...</code> requests for all sites, for some chosen sites or for the control panel;</li>
<li>with Nginx it answers 415 to PHP requests on blocked sites, and static files do not change. With Apache it answers 403, static files included;</li>
<li>by default it blocks only the ISPConfig panel. Sites must be blocked with commands such as <code>block all</code> or <code>block esempio.it</code>;</li>
<li>it creates a cron job that restores its hooks every 5 minutes, in case ISPConfig rewrites the configuration;</li>
<li>it has a list of authorised IPs that can still upload files.</li>
</ul>
<p>It works with Debian and Ubuntu, with ISPConfig 3.2 or 3.3 (and also without ISPConfig), with Nginx or with Apache 2.4 and later.</p>
<p>The price is that uploads do not work on blocked sites. That includes CMS media, form attachments, webmail attachments and phpMyAdmin imports. Normal pages and urlencoded forms keep working. This too is a temporary measure and not a fix.</p>
<p>This script and mine are not alternatives. Mine acts on the PHP configuration, the ISPConfig one on the web server. I have not compared them in the field and I cannot tell you how they behave together on every setup.</p>
<h2 class="wp-block-heading">The final update, in two steps</h2>
<p>The mitigations were meant to last a short time. I updated the servers as soon as the updates were available, in two steps:</p>
<ol class="wp-block-list">
<li><strong>Direct build.</strong> While waiting for the distribution used on each server to release its package, I built the fixed PHP versions from source.</li>
<li><strong>Official package.</strong> When the distribution&#8217;s official package was released for that server, I replaced the build with the package.</li>
</ol>
<p>The second step matters. A hand-built PHP does not update itself with the rest of the system. The distribution package does, and the script can verify it by reading the changelog.</p>
<h2 class="wp-block-heading">What to do now</h2>
<ol class="wp-block-list">
<li>Update PHP to 8.5.12, 8.4.27, 8.3.36 or 8.2.35. On Debian you can also use the distribution package when the changelog mentions the two CVEs.</li>
<li>Restart PHP-FPM and Apache, then run the script again with no options. It should no longer report problems.</li>
<li>If you use PHP 8.1 or earlier, plan a move to a supported branch. There is no official fixed version for those branches.</li>
<li>Keep <code>session.upload_progress</code> turned off until the next round of fixes, if your application does not use it.</li>
<li>Check the logs and recent files, with <code>--logs</code> or by hand, looking for PHP files that have appeared in upload folders.</li>
<li>Do not forget PHP inside containers, images and third-party appliances. They need updating separately.</li>
<li>If your site uses WordPress, the <a href="https://www.cisilino.com/en/2026/10/29/wordpress-security-plugin/">Cisilino Site Defense Network plugin</a> does not solve PHP2Shell. It does protect the login and keeps an activity log, which helps you understand what is happening on the site.</li>
</ol>
<h2 class="wp-block-heading">Sources</h2>
<ul class="wp-block-list">
<li><a href="https://www.ispconfig.org/blog/php2shell-critical-php-vulnerabilities-cve-2026-109618-cve-2026-109619/" target="_blank" rel="nofollow noopener">ISPConfig: PHP2Shell, critical PHP vulnerabilities (CVE-2026-109618, CVE-2026-109619)</a></li>
<li><a href="https://github.com/NCSC-NL/php2shell/wiki" target="_blank" rel="noopener">NCSC-NL: PHP2Shell wiki</a></li>
</ul>
<p>ISPConfig, PHP, Debian and the other names mentioned are trademarks of their respective owners. They are used only to identify the products.</p><p>The post <a href="https://www.cisilino.com/en/2026/10/29/php2shell-cve-2026-109618-server-mitigation/">PHP2Shell: how I protected my servers while waiting for the patch</a> first appeared on <a href="https://www.cisilino.com/en/">Matteo Cisilino</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://www.cisilino.com/en/2026/10/29/php2shell-cve-2026-109618-server-mitigation/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Which database is using up your disk space? A script for MySQL and PostgreSQL</title>
		<link>https://www.cisilino.com/en/2026/10/20/database-disk-space-script-mysql-postgresql/</link>
					<comments>https://www.cisilino.com/en/2026/10/20/database-disk-space-script-mysql-postgresql/#respond</comments>
		
		<dc:creator><![CDATA[matteocisilino]]></dc:creator>
		<pubDate>Tue, 20 Oct 2026 07:53:00 +0000</pubDate>
				<category><![CDATA[Servers and databases]]></category>
		<category><![CDATA[bash script]]></category>
		<category><![CDATA[disk space]]></category>
		<category><![CDATA[MySQL]]></category>
		<category><![CDATA[PostgreSQL]]></category>
		<guid isPermaLink="false">https://www.cisilino.com/?p=256</guid>

					<description><![CDATA[<p>The server disk is filling up and you don't know which database is to blame? A script to measure the space used by MySQL and PostgreSQL and compare it over time.</p>
<p>The post <a href="https://www.cisilino.com/en/2026/10/20/database-disk-space-script-mysql-postgresql/">Which database is using up your disk space? A script for MySQL and PostgreSQL</a> first appeared on <a href="https://www.cisilino.com/en/">Matteo Cisilino</a>.</p>]]></description>
										<content:encoded><![CDATA[<p>It happens to everyone sooner or later. The server disk fills up, the alarm goes off and the question is always the same. <strong>Which database is taking up all this space?</strong> Measuring database disk space properly turns out to be harder than it looks.</p>
<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" src="https://www.cisilino.com/wp-content/uploads/2026/10/mysql-postgresql-database-disk-usage-script.webp" alt="Database disk space by engine: ring chart split into four coloured segments, with three legend bars on the right" width="1200" height="630"/></figure>
<p>I ran into it after an optimisation. I had run <code>OPTIMIZE TABLE</code> hoping to reclaim space, but the filesystem didn&#8217;t shrink as expected. Without a &#8220;before&#8221; to compare with the &#8220;after&#8221;, it was impossible to tell whether the optimisation had worked. Nor could I tell which database was really responsible, or whether the missing space was somewhere else entirely.</p>
<p>The tools I had answered only half of the problem:</p>
<ul class="wp-block-list">
<li><code>du</code> shows the files on disk, but doesn&#8217;t say which table they belong to;</li>
<li><code>information_schema</code> (or <code>pg_class</code> on PostgreSQL) gives the logical sizes, but doesn&#8217;t see binlogs, <code>ibdata1</code>, undo logs or WAL;</li>
<li>both photograph a single instant: comparing over time remains manual work, done with copy and paste and a spreadsheet.</li>
</ul>
<p>I needed something simple: <strong>take a snapshot of the state of all databases at a given moment, take another later and have it tell me what changed</strong>, sorting the differences from the largest to the smallest. That way the culprit jumps out immediately.</p>
<p>The result is a single Bash script. It has no dependencies beyond the database client and the standard Linux tools (<code>awk</code>, <code>du</code>, <code>df</code>). It works with <strong>MySQL/MariaDB</strong> and with <strong>PostgreSQL</strong>. I&#8217;m sharing it here in the hope it saves someone the same afternoon of frustration.</p>
<h2 class="wp-block-heading">What the database disk space script does</h2>
<p>The script has four commands:</p>
<ul class="wp-block-list">
<li><code>snapshot</code> saves the current state (databases, tables, files in the data directory, filesystem space);</li>
<li><code>compare</code> compares two snapshots and shows the differences, the largest on top;</li>
<li><code>report</code> shows the largest databases and tables in a single snapshot;</li>
<li><code>list</code> lists the saved snapshots.</li>
</ul>
<p>The comparison is split into four sections, so as not to leave any blind spots:</p>
<ul class="wp-block-list">
<li><strong>size of each database</strong> (data + indexes);</li>
<li><strong>free or reclaimable space</strong> (<code>data_free</code> on MySQL, an estimate of dead tuples on PostgreSQL);</li>
<li><strong>size of each table</strong>;</li>
<li><strong>actual files in the data directory</strong>: database folders, <code>ibdata1</code>, binlogs, undo, <code>pg_wal</code>.</li>
</ul>
<p>The last section is often the one that solves the mystery. Many times the &#8220;anomalous&#8221; space doesn&#8217;t belong to any database, but to the binary logs or to InnoDB&#8217;s shared files.</p>
<h2 class="wp-block-heading">Requirements</h2>
<ul class="wp-block-list">
<li>Linux with Bash 4 or later, <code>awk</code>, <code>du</code>, <code>df</code> (all standard);</li>
<li>the <code>mysql</code> or <code>psql</code> client, with a user who can read the metadata of all databases;</li>
<li>root permissions (or those of the <code>mysql</code>/<code>postgres</code> user) if you also want to measure the files on disk; without them, that section is skipped with a warning.</li>
</ul>
<h2 class="wp-block-heading">Installation</h2>
<p>Save the script (you will find it at the end of the article) to a file and make it executable:</p>
<pre class="wp-block-code"><code>nano db_space_compare.sh
chmod +x db_space_compare.sh</code></pre>
<h3 class="wp-block-heading">Credentials</h3>
<p>For MySQL/MariaDB the cleanest way is a <code>~/.my.cnf</code> file with the credentials, so they don&#8217;t appear in the command history:</p>
<pre class="wp-block-code"><code>&#91;client]
user=root
password=LA_TUA_PASSWORD</code></pre>
<p>Alternatively you can pass the options through an environment variable:</p>
<pre class="wp-block-code"><code>export MYSQL_OPTS="-u root -pPASSWORD -h 127.0.0.1"</code></pre>
<p>For PostgreSQL, <code>~/.pgpass</code>, the <code>PG*</code> variables or <code>PG_OPTS</code> work:</p>
<pre class="wp-block-code"><code>export PG_OPTS="-U postgres -h localhost"</code></pre>
<h2 class="wp-block-heading">How to use it</h2>
<p>The typical flow is <em>before, optimisation, after, comparison</em>:</p>
<pre class="wp-block-code"><code># 1. fotografia prima dell'ottimizzazione
sudo ./db_space_compare.sh snapshot prima

# 2. esegui l'ottimizzazione (OPTIMIZE TABLE, VACUUM FULL, ...)

# 3. fotografia dopo
sudo ./db_space_compare.sh snapshot dopo

# 4. confronto
sudo ./db_space_compare.sh compare prima dopo</code></pre>
<p>For PostgreSQL just add the <code>-e postgres</code> option:</p>
<pre class="wp-block-code"><code>sudo ./db_space_compare.sh -e postgres snapshot prima
sudo ./db_space_compare.sh -e postgres snapshot dopo
sudo ./db_space_compare.sh -e postgres compare prima dopo</code></pre>
<p>The available options are:</p>
<ul class="wp-block-list">
<li><code>-e mysql|postgres</code> chooses the engine (default: <code>mysql</code>);</li>
<li><code>-d DIR</code> changes the folder where snapshots are saved (default: <code>./db_snapshots</code>);</li>
<li><code>-n N</code> sets how many rows to show per section (default: 20).</li>
</ul>
<p>If you don&#8217;t give a label, the snapshot is named after the date and time. This is handy for monitoring growth over time.</p>
<h2 class="wp-block-heading">Example output</h2>
<p>Here is a comparison on test data, built on purpose to show a typical case. The <code>shop</code> database shrinks after the optimisation, but the <code>log</code> database grows and the binlogs double.</p>
<pre class="wp-block-code"><code>Confronto 'prima' (2026-10-11 20:00:00)  -&gt;  'dopo' (2026-10-11 22:00:00)
Filesystem /var: usato 46.6 GiB -&gt; 48.4 GiB (+1.9 GiB)

=== Database - dimensione totale ===
  NOME                                                      PRIMA         DOPO    DIFFERENZA  NOTE
  log                                                    21.4 GiB     27.5 GiB      +6.1 GiB  +28.3%
  shop                                                    5.6 GiB      3.6 GiB      -2.0 GiB  -35.0%

=== Tabelle - dimensione (dati + indici) ===
  NOME                                                      PRIMA         DOPO    DIFFERENZA  NOTE
  log.eventi                                             21.4 GiB     27.5 GiB      +6.1 GiB  +28.3%
  shop.ordini                                             5.6 GiB      3.6 GiB      -2.0 GiB  -35.0%
  log.nuova                                                   0 B      1.0 KiB      +1.0 KiB  NUOVO

=== File nella data directory (cartelle DB, ibdata1, binlog, undo, WAL) ===
  NOME                                                      PRIMA         DOPO    DIFFERENZA  NOTE
  log                                                    21.4 GiB     27.5 GiB      +6.1 GiB  +28.3%
  mysql-bin.*                                             3.7 GiB      8.4 GiB      +4.7 GiB  +125.0%
  shop                                                    5.7 GiB      3.6 GiB      -2.0 GiB  -36.1%
  ibdata1                                                11.2 GiB     11.2 GiB           0 B</code></pre>
<p>Here the culprit is obvious: the <code>log.eventi</code> table has grown by more than 6 GiB. The <code>mysql-bin.*</code> files went from 3.7 to 8.4 GiB. The optimisation on <code>shop</code> worked, but the gain was eaten up by the rest.</p>
<h2 class="wp-block-heading">How to read the database disk space results</h2>
<ul class="wp-block-list">
<li>At the top of each section are the items that changed the most. A database or table that <strong>doesn&#8217;t shrink</strong> (or even grows) after the optimisation is the first suspect.</li>
<li>If the tables shrink but the filesystem doesn&#8217;t, the space is elsewhere. Look at the section on files in the data directory (binlogs, <code>ibdata1</code>, undo, <code>pg_wal</code>).</li>
<li><strong>MySQL/MariaDB:</strong> <code>ibdata1</code> never shrinks, not even with <code>OPTIMIZE TABLE</code>. With <code>innodb_file_per_table=OFF</code> the value of <code>data_free</code> is shared and not very informative.</li>
<li><strong>PostgreSQL:</strong> a plain <code>VACUUM</code> does not give space back to the operating system, you need <code>VACUUM FULL</code> (or <code>pg_repack</code>). The &#8220;reclaimable&#8221; space shown is an estimate based on dead tuples. The PostgreSQL documentation on <a href="https://www.postgresql.org/docs/current/diskusage.html" target="_blank" rel="noopener">monitoring disk usage</a> covers the underlying functions.</li>
</ul>
<h2 class="wp-block-heading">Monitoring database disk space over time</h2>
<p>If the problem isn&#8217;t tied to a single optimisation but to gradual growth, it is worth automating snapshots with cron, one an hour, and then comparing any two moments:</p>
<pre class="wp-block-code"><code># crontab -e (come root)
0 * * * * /usr/local/bin/db_space_compare.sh -d /var/lib/db_snapshots snapshot &gt;/dev/null 2&gt;&amp;1</code></pre>
<p>Without a label, every snapshot is named after the date and time (for example <code>20261011-140000</code>). With <code>list</code> you see the available ones and with <code>compare</code> you compare two. Within a few days it becomes clear which database is growing abnormally.</p>
<pre class="wp-block-code"><code>./db_space_compare.sh -d /var/lib/db_snapshots list
./db_space_compare.sh -d /var/lib/db_snapshots compare 20261011-140000 20261012-140000</code></pre>
<h2 class="wp-block-heading">The complete script</h2>
<p>Here is the code. Copy it into a file called <code>db_space_compare.sh</code>, make it executable and you are ready.</p>
<pre class="wp-block-code"><code>#!/usr/bin/env bash
#
# db_space_compare.sh
# Fotografa l'occupazione di spazio di tutti i database (MySQL/MariaDB o
# PostgreSQL) e confronta due fotografie (es. prima/dopo l'ottimizzazione)
# per capire quale database, tabella o file occupa spazio in modo anomalo.
#
# Uso:
#   db_space_compare.sh &#91;opzioni] snapshot &#91;etichetta]   (default: data-ora)
#   db_space_compare.sh &#91;opzioni] compare  &lt;prima&gt; &lt;dopo&gt;
#   db_space_compare.sh &#91;opzioni] report   &lt;etichetta&gt;
#   db_space_compare.sh &#91;opzioni] list
#
# Opzioni:
#   -e mysql|postgres   motore del database (default: mysql)
#   -d DIR              cartella delle fotografie (default: ./db_snapshots)
#   -n N                righe mostrate per sezione (default: 20)
#
# Credenziali:
#   MySQL/MariaDB: ~/.my.cnf oppure MYSQL_OPTS="-u root -pPASSWORD -h 127.0.0.1"
#   PostgreSQL:    ~/.pgpass, variabili PG* oppure PG_OPTS="-U postgres -h localhost"
#                  (non mettere -d in PG_OPTS: lo script si collega a ogni database)
#
# Per misurare anche i file su disco (cartelle dei DB, ibdata1, binlog, undo,
# pg_wal) va eseguito come root o come utente mysql/postgres; altrimenti
# quella parte viene saltata con un avviso.
#
# Esempio:
#   sudo ./db_space_compare.sh snapshot prima
#   ... ottimizzazione (OPTIMIZE TABLE / VACUUM FULL / ...) ...
#   sudo ./db_space_compare.sh snapshot dopo
#   sudo ./db_space_compare.sh compare prima dopo

set -euo pipefail
export LC_ALL=C

ENGINE=mysql
OUTDIR=./db_snapshots
TOPN=20
SNAP_IN_PROGRESS=""

die()  { echo "ERRORE: $*" &gt;&amp;2; exit 1; }
warn() { echo "ATTENZIONE: $*" &gt;&amp;2; }
usage() { sed -n '2,/^$/{s/^# \{0,1\}//;p}' "$0"; exit "${1:-0}"; }

cleanup() {
  if &#91;&#91; -n $SNAP_IN_PROGRESS ]]; then
    rm -rf -- "$SNAP_IN_PROGRESS"
    echo "Fotografia incompleta rimossa: $SNAP_IN_PROGRESS" &gt;&amp;2
  fi
}
trap cleanup EXIT

while getopts ":e:d:n:h" opt; do
  case $opt in
    e) ENGINE=$OPTARG ;;
    d) OUTDIR=$OPTARG ;;
    n) TOPN=$OPTARG ;;
    h) usage 0 ;;
    *) usage 1 ;;
  esac
done
shift $((OPTIND - 1))

&#91;&#91; $ENGINE == mysql || $ENGINE == postgres ]] || die "motore non valido: '$ENGINE' (usa mysql o postgres)"
&#91;&#91; $TOPN =~ ^&#91;0-9]+$ ]] || die "-n deve essere un numero"

read -r -a MYSQL_ARGS &lt;&lt;&lt; "${MYSQL_OPTS:-}"
read -r -a PG_ARGS    &lt;&lt;&lt; "${PG_OPTS:-}"

my() { mysql "${MYSQL_ARGS&#91;@]+"${MYSQL_ARGS&#91;@]}"}" --batch --skip-column-names "$@"; }
pg() { psql  "${PG_ARGS&#91;@]+"${PG_ARGS&#91;@]}"}" -X -q -A -t -F $'\t' -v ON_ERROR_STOP=1 "$@"; }

# Funzioni awk comuni: formattazione in KiB/MiB/GiB, chiave e valore di riga.
#   nk = numero di colonne che formano il nome (1 = database, 2 = db.tabella)
#   vc = colonne da sommare per il valore (es. "4,5" = dati + indici)
AWK_LIB='
function h(b,   a, i, u) {
  split("B KiB MiB GiB TiB PiB", u, " ")
  a = (b &lt; 0) ? -b : b; i = 1
  while (a &gt;= 1024 &amp;&amp; i &lt; 6) { a /= 1024; i++ }
  if (i == 1) return sprintf("%s%d B", (b &lt; 0 ? "-" : ""), a)
  return sprintf("%s%.1f %s", (b &lt; 0 ? "-" : ""), a, u&#91;i])
}
function hs(b) { return (b &gt; 0 ? "+" : "") h(b) }
function key(   k, i) { k = $1; for (i = 2; i &lt;= nk; i++) k = k "." $i; return k }
function val(   n, c, i, s) { n = split(vc, c, ","); s = 0; for (i = 1; i &lt;= n; i++) s += $(c&#91;i]); return s }
'

meta_get() { awk -F= -v k="$2" '$1 == k { sub(/^&#91;^=]*=/, ""); print; exit }' "$1/meta.txt"; }

# ---------------------------------------------------------------- raccolta dati

# Occupazione reale su disco delle voci di primo livello della data directory:
# cartelle dei database, ibdata1, binlog, undo, redo log, pg_wal, base, ...
# I file numerati (mysql-bin.000123, relay-log.000045, ...) sono sommati in una voce.
collect_disk() {
  local d=$1 out=$2
  : &gt; "$out"
  if &#91;&#91; -z $d || ! -r $d || ! -x $d ]]; then
    warn "data directory '${d:-?}' non leggibile: salto i file su disco (esegui come root per includerli)"
    return 0
  fi
  { du -sb -- "$d"/* 2&gt;/dev/null || true; } |
    awk -F'\t' -v p="$d/" '
      { n = $2
        if (index(n, p) == 1) n = substr(n, length(p) + 1)
        if (n ~ /\.&#91;0-9]&#91;0-9]&#91;0-9]&#91;0-9]&#91;0-9]&#91;0-9]$/) sub(/&#91;0-9]+$/, "*", n)
        s&#91;n] += $1 }
      END { for (n in s) printf "%s\t%.0f\n", n, s&#91;n] }' &gt; "$out"
}

write_meta() {
  local dir=$1 datadir=$2 version=$3
  {
    echo "timestamp=$(date '+%Y-%m-%d %H:%M:%S')"
    echo "engine=$ENGINE"
    echo "host=$(hostname)"
    echo "version=$version"
    echo "datadir=$datadir"
    if &#91;&#91; -n $datadir &amp;&amp; -e $datadir ]]; then
      { df -P -B1 -- "$datadir" 2&gt;/dev/null || true; } |
        awk 'NR == 2 { print "fs_mount=" $6; print "fs_used=" $3; print "fs_avail=" $4 }'
    fi
  } &gt; "$dir/meta.txt"
}

# File prodotti da ogni fotografia (separati da TAB):
#   tables.tsv     db, tabella, engine, dati, indici, libero/recuperabile, righe
#   databases.tsv  db, dati, indici, libero/recuperabile, totale
#   disk.tsv       voce della data directory, byte su disco
#   meta.txt       data, host, versione, filesystem

snapshot_mysql() {
  local dir=$1 pre="" datadir version
  command -v mysql &gt;/dev/null 2&gt;&amp;1 || die "client 'mysql' non trovato"
  my -e "SELECT 1" &gt;/dev/null || die "connessione a MySQL/MariaDB fallita (controlla ~/.my.cnf o MYSQL_OPTS)"

  # MySQL 8 tiene in cache le statistiche di information_schema fino a 24 ore:
  # senza questo, i valori "dopo" l'ottimizzazione potrebbero essere quelli vecchi.
  if &#91;&#91; -n $(my -e "SHOW VARIABLES LIKE 'information_schema_stats_expiry'") ]]; then
    pre="SET SESSION information_schema_stats_expiry = 0;"
  fi

  my -e "$pre
    SELECT table_schema, table_name, IFNULL(engine, '-'),
           IFNULL(data_length, 0), IFNULL(index_length, 0), IFNULL(data_free, 0), IFNULL(table_rows, 0)
      FROM information_schema.TABLES
     WHERE table_type IN ('BASE TABLE', 'SYSTEM VERSIONED')
       AND table_schema NOT IN ('information_schema', 'performance_schema', 'sys')" &gt; "$dir/tables.tsv"

  my -e "$pre
    SELECT s.schema_name,
           IFNULL(SUM(t.data_length), 0), IFNULL(SUM(t.index_length), 0), IFNULL(SUM(t.data_free), 0),
           IFNULL(SUM(t.data_length), 0) + IFNULL(SUM(t.index_length), 0)
      FROM information_schema.SCHEMATA s
      LEFT JOIN information_schema.TABLES t
             ON t.table_schema = s.schema_name
            AND t.table_type IN ('BASE TABLE', 'SYSTEM VERSIONED')
     WHERE s.schema_name NOT IN ('information_schema', 'performance_schema', 'sys')
     GROUP BY s.schema_name" &gt; "$dir/databases.tsv"

  datadir=$(my -e "SELECT @@datadir"); datadir=${datadir%/}
  version=$(my -e "SELECT VERSION()")
  collect_disk "$datadir" "$dir/disk.tsv"
  write_meta "$dir" "$datadir" "$version"
}

snapshot_postgres() {
  local dir=$1 maindb=${PGDATABASE:-postgres} datadir version db
  command -v psql &gt;/dev/null 2&gt;&amp;1 || die "client 'psql' non trovato"
  pg -d "$maindb" -c "SELECT 1" &gt;/dev/null || die "connessione a PostgreSQL fallita (controlla PG_OPTS, PGUSER, ~/.pgpass)"

  pg -d "$maindb" -c "SELECT datname FROM pg_database WHERE datallowconn AND NOT datistemplate ORDER BY 1" &gt; "$dir/.dblist"

  # Per ogni database: dimensione di ogni tabella (catalogo incluso, es. pg_largeobject)
  # e stima dello spazio occupato da tuple morte (bloat recuperabile con VACUUM FULL).
  : &gt; "$dir/tables.tsv"
  while IFS= read -r db; do
    &#91;&#91; -n $db ]] || continue
    pg -d "$db" -c "
      SELECT current_database(), s.schemaname || '.' || s.relname, 'table',
             pg_table_size(s.relid), pg_indexes_size(s.relid),
             CASE WHEN s.n_live_tup + s.n_dead_tup &gt; 0
                  THEN (pg_table_size(s.relid)::numeric * s.n_dead_tup / (s.n_live_tup + s.n_dead_tup))::bigint
                  ELSE 0 END,
             s.n_live_tup
        FROM pg_stat_all_tables s
       WHERE s.schemaname &lt;&gt; 'information_schema'
         AND s.schemaname NOT LIKE 'pg_toast%'" &lt; /dev/null &gt;&gt; "$dir/tables.tsv" \
      || warn "impossibile leggere le tabelle del database '$db'"
  done &lt; "$dir/.dblist"

  pg -d "$maindb" -c "SELECT datname, pg_database_size(oid) FROM pg_database WHERE datallowconn AND NOT datistemplate" &gt; "$dir/.dbsize"
  awk -F'\t' '
    FILENAME == ARGV&#91;1] { d&#91;$1] += $4; i&#91;$1] += $5; f&#91;$1] += $6; next }
    { printf "%s\t%.0f\t%.0f\t%.0f\t%s\n", $1, d&#91;$1], i&#91;$1], f&#91;$1], $2 }' \
    "$dir/tables.tsv" "$dir/.dbsize" &gt; "$dir/databases.tsv"

  datadir=$(pg -d "$maindb" -c "SHOW data_directory" 2&gt;/dev/null || true)
  version=$(pg -d "$maindb" -c "SHOW server_version")
  collect_disk "$datadir" "$dir/disk.tsv"

  # Dettaglio di base/&lt;oid&gt; con il nome del database al posto dell'oid
  if &#91;&#91; -n $datadir &amp;&amp; -r $datadir/base &amp;&amp; -x $datadir/base ]]; then
    pg -d "$maindb" -c "SELECT oid, datname FROM pg_database" &gt; "$dir/.oidmap"
    { du -sb -- "$datadir"/base/* 2&gt;/dev/null || true; } |
      awk -F'\t' '
        FILENAME == ARGV&#91;1] { name&#91;$1] = $2; next }
        { o = $2; sub(/.*\//, "", o); printf "base/%s\t%s\n", ((o in name) ? name&#91;o] : o), $1 }' \
        "$dir/.oidmap" - &gt;&gt; "$dir/disk.tsv"
  fi
  write_meta "$dir" "$datadir" "$version"
}

# --------------------------------------------------------------- presentazione

print_fs() {
  local d=$1 used avail mount
  used=$(meta_get "$d" fs_used); avail=$(meta_get "$d" fs_avail); mount=$(meta_get "$d" fs_mount)
  &#91;&#91; -n $used ]] || return 0
  awk -v u="$used" -v a="$avail" -v m="$mount" "$AWK_LIB"'
    BEGIN { printf "Filesystem %s: usato %s, libero %s\n", m, h(u), h(a) }'
}

top_section() {
  local f=$1 nk=$2 vc=$3 title=$4
  echo
  echo "=== $title ==="
  if &#91;&#91; ! -s $f ]]; then echo "  (nessun dato)"; return 0; fi
  awk -F'\t' -v nk="$nk" -v vc="$vc" "$AWK_LIB"'
    { v = val(); printf "%.0f\t%s\t%s\n", v, key(), h(v) }' "$f" |
    sort -t $'\t' -k1,1nr -k2,2 |
    awk -F'\t' -v top="$TOPN" 'NR &lt;= top &amp;&amp; $1 &gt; 0 { printf "  %-60s %12s\n", $2, $3 }'
}

compare_section() {
  local fa=$1 fb=$2 nk=$3 vc=$4 title=$5
  &#91;&#91; -f $fa ]] || fa=/dev/null
  &#91;&#91; -f $fb ]] || fb=/dev/null
  echo
  echo "=== $title ==="
  if &#91;&#91; ! -s $fa &amp;&amp; ! -s $fb ]]; then echo "  (nessun dato)"; return 0; fi
  printf '  %-50s %12s %12s %13s  %s\n' "NOME" "PRIMA" "DOPO" "DIFFERENZA" "NOTE"
  awk -F'\t' -v nk="$nk" -v vc="$vc" "$AWK_LIB"'
    FILENAME == ARGV&#91;1] { k = key(); A&#91;k] = val(); S&#91;k] = 1; next }
                        { k = key(); B&#91;k] = val(); S&#91;k] = 1 }
    END {
      for (k in S) {
        inA = (k in A); inB = (k in B)
        a = inA ? A&#91;k] : 0; b = inB ? B&#91;k] : 0; d = b - a
        if (!inA)                 note = "NUOVO"
        else if (!inB)            note = "RIMOSSO"
        else if (a &gt; 0 &amp;&amp; d != 0) note = sprintf("%+.1f%%", 100 * d / a)
        else                      note = ""
        printf "%.0f\t%s\t%s\t%s\t%s\t%s\n", (d &lt; 0 ? -d : d), k, h(a), h(b), hs(d), note
      }
    }' "$fa" "$fb" |
    sort -t $'\t' -k1,1nr -k2,2 |
    awk -F'\t' -v top="$TOPN" 'NR &lt;= top { printf "  %-50s %12s %12s %13s  %s\n", $2, $3, $4, $5, $6 }'
}

# -------------------------------------------------------------------- comandi

cmd_snapshot() {
  local label=${1:-$(date '+%Y%m%d-%H%M%S')}
  &#91;&#91; $label =~ ^&#91;A-Za-z0-9]&#91;A-Za-z0-9._-]*$ ]] || die "etichetta non valida: usa solo lettere, numeri, . _ -"
  local dir=$OUTDIR/$label
  &#91;&#91; ! -e $dir ]] || die "la fotografia '$label' esiste già ($dir)"
  mkdir -p -- "$dir"
  SNAP_IN_PROGRESS=$dir
  echo "Fotografia '$label' ($ENGINE) in corso..."
  "snapshot_$ENGINE" "$dir"
  rm -f -- "$dir/.dblist" "$dir/.dbsize" "$dir/.oidmap"
  SNAP_IN_PROGRESS=""
  echo "Salvata in $dir ($(wc -l &lt; "$dir/tables.tsv") tabelle)"
  print_fs "$dir"
  top_section "$dir/databases.tsv" 1 5 "Database più grandi"
}

cmd_report() {
  local label=${1:-}
  &#91;&#91; -n $label ]] || die "uso: report &lt;etichetta&gt;"
  local d=$OUTDIR/$label
  &#91;&#91; -f $d/meta.txt ]] || die "fotografia '$label' non trovata in $OUTDIR"
  echo "Fotografia '$label' del $(meta_get "$d" timestamp) - $(meta_get "$d" engine) $(meta_get "$d" version) su $(meta_get "$d" host)"
  print_fs "$d"
  top_section "$d/databases.tsv" 1 5   "Database più grandi (dati + indici)"
  top_section "$d/tables.tsv"    2 4,5 "Tabelle più grandi (dati + indici)"
  top_section "$d/tables.tsv"    2 6   "Tabelle con più spazio recuperabile (data_free / tuple morte stimate)"
  top_section "$d/disk.tsv"      1 2   "Voci più grandi nella data directory"
}

cmd_compare() {
  local a=${1:-} b=${2:-}
  &#91;&#91; -n $a &amp;&amp; -n $b ]] || die "uso: compare &lt;etichetta_prima&gt; &lt;etichetta_dopo&gt;"
  &#91;&#91; $a != "$b" ]] || die "le due etichette sono uguali"
  local da=$OUTDIR/$a db=$OUTDIR/$b
  &#91;&#91; -f $da/meta.txt ]] || die "fotografia '$a' non trovata in $OUTDIR"
  &#91;&#91; -f $db/meta.txt ]] || die "fotografia '$b' non trovata in $OUTDIR"
  &#91;&#91; $(meta_get "$da" engine) == "$(meta_get "$db" engine)" ]] || warn "le due fotografie sono di motori diversi"

  echo "Confronto '$a' ($(meta_get "$da" timestamp))  -&gt;  '$b' ($(meta_get "$db" timestamp))"
  local ua ub m
  ua=$(meta_get "$da" fs_used); ub=$(meta_get "$db" fs_used); m=$(meta_get "$db" fs_mount)
  if &#91;&#91; -n $ua &amp;&amp; -n $ub ]]; then
    awk -v a="$ua" -v b="$ub" -v m="$m" "$AWK_LIB"'
      BEGIN { printf "Filesystem %s: usato %s -&gt; %s (%s)\n", m, h(a), h(b), hs(b - a) }'
  fi

  compare_section "$da/databases.tsv" "$db/databases.tsv" 1 5   "Database - dimensione totale"
  compare_section "$da/databases.tsv" "$db/databases.tsv" 1 4   "Database - spazio libero/recuperabile"
  compare_section "$da/tables.tsv"    "$db/tables.tsv"    2 4,5 "Tabelle - dimensione (dati + indici)"
  compare_section "$da/disk.tsv"      "$db/disk.tsv"      1 2   "File nella data directory (cartelle DB, ibdata1, binlog, undo, WAL)"

  cat &lt;&lt;'EOF'

Come leggere il risultato:
  - In cima a ogni sezione ci sono le voci cambiate di più: un database o una
    tabella che dopo l'ottimizzazione non cala, o addirittura cresce, è il
    primo sospetto.
  - Se le tabelle calano ma il filesystem no, lo spazio sta altrove: guarda la
    sezione "File nella data directory" (binlog, ibdata1, undo, pg_wal).
  - MySQL/MariaDB: ibdata1 non si restringe mai, nemmeno con OPTIMIZE; con
    innodb_file_per_table=OFF il data_free è condiviso e poco indicativo.
  - PostgreSQL: VACUUM semplice non restituisce spazio al disco, serve
    VACUUM FULL (o pg_repack); lo spazio "recuperabile" è una stima.
EOF
}

cmd_list() {
  if &#91;&#91; ! -d $OUTDIR ]]; then echo "Nessuna fotografia in $OUTDIR"; return 0; fi
  local d found=0
  for d in "$OUTDIR"/*/; do
    &#91;&#91; -f $d/meta.txt ]] || continue
    found=1
    printf '%-24s %s  %-8s  %s\n' "$(basename "$d")" "$(meta_get "$d" timestamp)" \
      "$(meta_get "$d" engine)" "$(meta_get "$d" host)"
  done
  &#91;&#91; $found -eq 1 ]] || echo "Nessuna fotografia in $OUTDIR"
}

cmd=${1:-}
shift || true
case $cmd in
  snapshot) cmd_snapshot "$@" ;;
  compare)  cmd_compare "$@" ;;
  report)   cmd_report "$@" ;;
  list)     cmd_list ;;
  ""|help)  usage 0 ;;
  *)        die "comando sconosciuto: '$cmd' (usa snapshot, compare, report, list)" ;;
esac</code></pre>
<h2 class="wp-block-heading">Final notes on database disk space</h2>
<ul class="wp-block-list">
<li>The script is <strong>read-only</strong> on the databases: it only runs <code>SELECT</code> on the metadata. It writes only to the snapshots folder.</li>
<li>On MySQL 8 it sets <code>information_schema_stats_expiry = 0</code> in the session. Otherwise the cached statistics (up to 24 hours) could show &#8220;old&#8221; values even after the optimisation.</li>
<li>If a snapshot fails halfway, it is removed automatically so as not to leave incomplete data.</li>
<li>Before using it in production, try it in a test environment and check the database user&#8217;s permissions.</li>
</ul>
<p>If you use it and find a case it doesn&#8217;t cover, write to me in the comments. The same goes for ideas to extend it (other engines, CSV export, email alerts). If the problem is a MySQL that won&#8217;t start rather than a full disk, see <a href="https://www.cisilino.com/en/2026/10/13/mysql-wont-start-debian-12/">MySQL won&#8217;t start after a Debian 12 upgrade</a>.</p><p>The post <a href="https://www.cisilino.com/en/2026/10/20/database-disk-space-script-mysql-postgresql/">Which database is using up your disk space? A script for MySQL and PostgreSQL</a> first appeared on <a href="https://www.cisilino.com/en/">Matteo Cisilino</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://www.cisilino.com/en/2026/10/20/database-disk-space-script-mysql-postgresql/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>MySQL 5.7 in Docker protected with nftables</title>
		<link>https://www.cisilino.com/en/2026/10/17/mysql-5-7-docker-nftables-firewall/</link>
					<comments>https://www.cisilino.com/en/2026/10/17/mysql-5-7-docker-nftables-firewall/#respond</comments>
		
		<dc:creator><![CDATA[matteocisilino]]></dc:creator>
		<pubDate>Sat, 17 Oct 2026 08:10:45 +0000</pubDate>
				<category><![CDATA[Servers and databases]]></category>
		<category><![CDATA[Docker]]></category>
		<category><![CDATA[firewall]]></category>
		<category><![CDATA[MySQL]]></category>
		<category><![CDATA[nftables]]></category>
		<guid isPermaLink="false">https://www.cisilino.com/?p=271</guid>

					<description><![CDATA[<p>How to run MySQL 5.7 in Docker on a modern server without exposing it to the internet: docker-compose, a closed port and an nftables firewall.</p>
<p>The post <a href="https://www.cisilino.com/en/2026/10/17/mysql-5-7-docker-nftables-firewall/">MySQL 5.7 in Docker protected with nftables</a> first appeared on <a href="https://www.cisilino.com/en/">Matteo Cisilino</a>.</p>]]></description>
										<content:encoded><![CDATA[<h2 class="wp-block-heading">Background</h2>
<p>We needed <strong>MySQL 5.7 in Docker</strong>, a legacy database on a server that otherwise runs a recent distribution. Some legacy applications are not compatible with MySQL 8. The reasons: a change of default authentication plugin, stricter SQL modes, new reserved words. Migrating them quickly was not an option.</p>
<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" src="https://www.cisilino.com/wp-content/uploads/2026/10/mysql-5-7-docker-nftables-firewall.webp" alt="MySQL 5.7 in Docker: a database inside a box, surrounded by an orange dashed border, with a circle and a bar in front" width="1200" height="630"/></figure>
<p>Installing such an old version directly on the host brings several problems. The official repositories of current distributions no longer offer it. Third-party packages risk conflicting with the rest of the system. An OS upgrade could break it.</p>
<p>So we decided to <strong>isolate it in a Docker container</strong>: the host stays clean and up to date. The database version is fixed and reproducible. Data and configuration live in a folder that is easy to back up and move.</p>
<p>This article tells how we set it up and, above all, the obstacles we met along the way. First the unintentional exposure of port 3306, then an iptables error at reboot. Last, writing a firewall with <strong>nftables</strong> that lets through only authorised IPs.</p>
<blockquote class="wp-block-quote">
<p><strong>Note:</strong> MySQL 5.7 reached end of life (EOL) in October 2023 and no longer receives security patches. It is a stopgap: the goal remains to migrate to MySQL 8.x or MariaDB. That is exactly why it is even more important not to leave it exposed.</p>
</blockquote>
<hr class="wp-block-separator has-alpha-channel-opacity"/>
<h2 class="wp-block-heading">1. Folder structure</h2>
<pre class="wp-block-code"><code>/opt/mysql57/
├── docker-compose.yml
├── .env
├── conf.d/        # configurazioni personalizzate (my.cnf aggiuntivi)
├── data/          # dati del database (volume persistente)
└── logs/
</code></pre>
<p>The <code>.env</code> file holds the credentials, so they don&#8217;t end up in the compose file:</p>
<pre class="wp-block-code"><code>TZ=Europe/Rome
MYSQL_ROOT_PASSWORD=una_password_molto_lunga_e_casuale
MYSQL_DATABASE=nome_db
MYSQL_USER=nome_utente
MYSQL_PASSWORD=altra_password_robusta
</code></pre>
<p>Protect it with <code>chmod 600 .env</code>.</p>
<h2 class="wp-block-heading">2. MySQL 5.7 in Docker: the docker-compose.yml</h2>
<pre class="wp-block-code"><code>services:
  mysql57:
    image: mysql:5.7
    container_name: mysql57
    restart: unless-stopped
    env_file:
      - .env
    ports:
      - "3306:3306"
    environment:
      TZ: "${TZ:-Europe/Rome}"
      MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD}"
      MYSQL_DATABASE: "${MYSQL_DATABASE}"
      MYSQL_USER: "${MYSQL_USER}"
      MYSQL_PASSWORD: "${MYSQL_PASSWORD}"
    command:
      - --default-authentication-plugin=mysql_native_password
    volumes:
      - ./data:/var/lib/mysql
      - ./conf.d:/etc/mysql/conf.d:ro
      - ./logs:/var/log/mysql
    healthcheck:
      test: &#91;"CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$$MYSQL_ROOT_PASSWORD --silent"]
      interval: 20s
      timeout: 5s
      retries: 10
      start_period: 45s
    ulimits:
      nofile:
        soft: 65535
        hard: 65535
</code></pre>
<p>A few choices worth noting:</p>
<ul class="wp-block-list">
<li><strong><code>restart: unless-stopped</code></strong>: the database restarts by itself after a server reboot.</li>
<li><strong>Bind-mount volumes</strong>: data, configuration and logs are on the host, so backups and migrations are simple folder copies.</li>
<li><strong>Healthcheck</strong>: lets other services wait until MySQL is really ready (<code>$$</code> makes the variable expand inside the container and not in Compose).</li>
<li><strong><code>ulimits nofile</code></strong>: avoids the &#8220;too many open files&#8221; error under load.</li>
</ul>
<h2 class="wp-block-heading">3. First problem: the port is open to the whole world</h2>
<p>The line <code>- "3306:3306"</code> is equivalent to <code>0.0.0.0:3306:3306</code>. Docker publishes the port on <strong>all interfaces</strong> of the host, including the public one.</p>
<p>The most insidious part is that Docker writes its own rules straight into the kernel firewall. These are evaluated before those of UFW or firewalld. Having a firewall running is <strong>not enough</strong>: the port stays reachable. Docker&#8217;s own documentation on <a href="https://docs.docker.com/engine/network/packet-filtering-firewalls/" target="_blank" rel="noopener">packet filtering and firewalls</a> describes this behaviour.</p>
<p>The possible approaches are:</p>
<ol class="wp-block-list">
<li><strong>Localhost only</strong>: <code>"127.0.0.1:3306:3306"</code>, if the database is only used by the host or through an SSH tunnel.</li>
<li><strong>No published port</strong>: if the clients are other containers, a shared Docker network is enough.</li>
<li><strong>Specific IP</strong>: publish only on an address of a private network/VPN.</li>
<li><strong>Public port with IP filtering</strong>: our case, because an external client (a phpMyAdmin instance on another server) has to be able to connect.</li>
</ol>
<p>For case 4 you need a firewall that correctly filters the traffic of the ports published by Docker.</p>
<h2 class="wp-block-heading">4. Second problem: an iptables error at startup</h2>
<p>After touching the firewall, <code>docker compose up</code> returned:</p>
<pre class="wp-block-code"><code>failed to create network mysql57_default: Error response from daemon:
Failed to Setup IP tables: Unable to enable ACCEPT OUTGOING rule:
(iptables failed: iptables --wait -t filter -A DOCKER-FORWARD -i br-... -j ACCEPT:
iptables: No chain/target/match by that name.
</code></pre>
<p>The error doesn&#8217;t depend on the compose file: the <code>DOCKER-FORWARD</code> chain (introduced in recent Docker versions) no longer existed. It had been deleted by something that cleaned up the rules <strong>after</strong> the daemon started. Restarting Docker (<code>systemctl restart docker</code>) recreates it, but the problem comes back until the real culprit is removed.</p>
<h3 class="wp-block-heading">The culprit: <code>flush ruleset</code></h3>
<p>Our <code>/etc/nftables.conf</code> began with:</p>
<pre class="wp-block-code"><code>flush ruleset
</code></pre>
<p>This command deletes <strong>all</strong> nftables tables, including those created by Docker. On top of that the file contained a static copy of Docker&#8217;s rules. The bridge name (<code>br-278550235c3f</code>) and the container IP (<code>172.18.0.2</code>) were written by hand. But the bridge name changes every time the Compose network is recreated, so the copy immediately became obsolete. The <code>ip nat</code> table also clashed with the one managed by Docker.</p>
<p><strong>Golden rule:</strong> with Docker, in the nftables file you don&#8217;t use <code>flush ruleset</code> and you don&#8217;t copy its rules. You manage only your own firewall, in dedicated tables.</p>
<h2 class="wp-block-heading">5. The nftables firewall for MySQL 5.7 in Docker</h2>
<p>The principle is this: the ports published by Docker are rewritten with DNAT and then forwarded by the host to the container, so they go through the <strong><code>forward</code></strong> chain, not <code>input</code>. A <code>drop</code> rule in a chain of ours, <code>forward</code>, with a slightly lower priority (so evaluated first). It drops the packet regardless of Docker&#8217;s <code>accept</code> rules.</p>
<p>To avoid touching other people&#8217;s tables we use the <code>table ...</code> + <code>delete table ...</code> pattern: it makes the file re-runnable without errors and without <code>flush</code>.</p>
<p><code>/etc/nftables.conf</code>:</p>
<pre class="wp-block-code"><code>#!/usr/sbin/nft -f

# NIENTE "flush ruleset": cancellerebbe anche le tabelle di Docker

# ---------------- firewall host ----------------
table inet filter
delete table inet filter

table inet filter {

    chain input {
        type filter hook input priority filter; policy drop;
        ct state established,related accept
        iifname "lo" accept
        ip6 nexthdr icmpv6 accept
        iifname "enp7s0" accept                       # rete privata
        ip saddr 203.0.113.20 tcp dport 22 accept     # SSH da host fidato
        ip saddr 203.0.113.30 tcp dport 22 accept     # SSH da VPN
        ip saddr 203.0.113.20 icmp type echo-request accept
        tcp dport { 80, 443 } accept
    }

    chain output {
        type filter hook output priority filter; policy accept;
    }
}

# ---------------- guardia MySQL (porta pubblicata da Docker) ----------------
table ip mysql_guard
delete table ip mysql_guard

table ip mysql_guard {
    set allowed {
        type ipv4_addr
        flags interval
        elements = {
            203.0.113.10        # phpMyAdmin
        }
    }

    chain forward {
        type filter hook forward priority filter - 5; policy accept;
        iifname "enp7s0" accept
        ct status dnat tcp dport 3306 ip saddr @allowed accept
        ct status dnat tcp dport 3306 counter drop
    }
}
</code></pre>
<p>How the guard works:</p>
<ul class="wp-block-list">
<li><code>ct status dnat</code> selects only packets that went through DNAT, that is those headed to the ports published by Docker.</li>
<li>The first rule accepts whoever is in the <code>allowed</code> set; the second drops everyone else and counts them (<code>counter</code>).</li>
<li>Local traffic (<code>127.0.0.1</code>) and traffic between containers on the same network doesn&#8217;t go through the router&#8217;s forward. So it isn&#8217;t touched.</li>
</ul>
<blockquote class="wp-block-quote">
<p><strong>Compatibility:</strong> in a first version we used <code>ct original proto-dst 3306</code>, but some versions of <code>nft</code> answer with <em>&#8220;Can&#8217;t parse symbolic invalid expressions&#8221;</em>. In the <code>forward</code> hook the DNAT has already happened and the container port matches the published one (<code>3306:3306</code>). So <code>tcp dport 3306</code> combined with <code>ct status dnat</code> is equivalent and works everywhere. If you change the mapping (e.g. <code>3307:3306</code>), the rule must be adapted to the internal port.</p>
</blockquote>
<h3 class="wp-block-heading">Applying the changes without locking yourself out</h3>
<p>Keep a spare SSH session open, then:</p>
<pre class="wp-block-code"><code>nft -c -f /etc/nftables.conf     # solo verifica della sintassi
nft delete table ip nat          # una tantum: elimina la vecchia copia statica
nft -f /etc/nftables.conf
systemctl restart docker         # Docker ricrea le sue tabelle pulite
cd /opt/mysql57 &amp;&amp; docker compose up -d
</code></pre>
<p>For loading at boot, make sure the service is enabled (<code>systemctl enable --now nftables</code>) and that <code>/etc/nftables.conf</code> does not contain <code>flush ruleset</code>.</p>
<h3 class="wp-block-heading">Managing IPs at runtime</h3>
<pre class="wp-block-code"><code>nft add element ip mysql_guard allowed { 203.0.113.50 }
nft delete element ip mysql_guard allowed { 203.0.113.50 }
</code></pre>
<p>To make it permanent, also update the set in the file.</p>
<h2 class="wp-block-heading">6. Verification</h2>
<p>From the authorised server:</p>
<pre class="wp-block-code"><code>nc -zv IP_PUBBLICO_DB 3306        # deve rispondere "succeeded"
</code></pre>
<p>From any other IP:</p>
<pre class="wp-block-code"><code>nmap -Pn -p 3306 IP_PUBBLICO_DB   # deve risultare "filtered" / timeout
</code></pre>
<p>On the server, the drop rule&#8217;s counter must grow with every unauthorised attempt:</p>
<pre class="wp-block-code"><code>nft list chain ip mysql_guard forward
ss -tlnp | grep 3306
</code></pre>
<h2 class="wp-block-heading">7. Second barrier: the MySQL users</h2>
<p>The firewall doesn&#8217;t replace database permissions. Create the user with the host limited to the client&#8217;s IP:</p>
<pre class="wp-block-code"><code>CREATE USER 'pma_user'@'203.0.113.10' IDENTIFIED BY 'password_robusta';
GRANT ALL ON nome_db.* TO 'pma_user'@'203.0.113.10';
FLUSH PRIVILEGES;
</code></pre>
<p>If the TCP connection succeeds but MySQL answers <code>Access denied</code>, the problem is here and not in the firewall.</p>
<h2 class="wp-block-heading">8. Things to remember about MySQL 5.7 in Docker</h2>
<ul class="wp-block-list">
<li><strong>Docker and the host firewall don&#8217;t ignore each other</strong>. Understanding which chain the traffic goes through (<code>input</code> or <code>forward</code>) is the key to everything.</li>
<li><strong>Never <code>flush ruleset</code></strong> on a host with Docker; use dedicated tables.</li>
<li><strong>Don&#8217;t copy the rules generated by Docker</strong> into your own files: bridge names and IPs change.</li>
<li>If you have already exposed the port, check the logs in <code>./logs</code> and consider <strong>changing the passwords</strong>, especially root&#8217;s.</li>
<li>Where possible, prefer a <strong>VPN (WireGuard) or SSH tunnel</strong> to exposing a database publicly.</li>
<li>Plan the <strong>migration</strong> from MySQL 5.7 to a supported version.</li>
</ul>
<p>Do you have questions or a different solution to the same problem? Write it in the comments. If you run MySQL on Debian, see also <a href="https://www.cisilino.com/en/2026/10/13/mysql-wont-start-debian-12/">MySQL won&#8217;t start after a Debian 12 upgrade</a>.</p><p>The post <a href="https://www.cisilino.com/en/2026/10/17/mysql-5-7-docker-nftables-firewall/">MySQL 5.7 in Docker protected with nftables</a> first appeared on <a href="https://www.cisilino.com/en/">Matteo Cisilino</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://www.cisilino.com/en/2026/10/17/mysql-5-7-docker-nftables-firewall/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>MySQL won&#8217;t start after a Debian 12 upgrade: cause and fix</title>
		<link>https://www.cisilino.com/en/2026/10/13/mysql-wont-start-debian-12/</link>
					<comments>https://www.cisilino.com/en/2026/10/13/mysql-wont-start-debian-12/#respond</comments>
		
		<dc:creator><![CDATA[matteocisilino]]></dc:creator>
		<pubDate>Tue, 13 Oct 2026 07:50:06 +0000</pubDate>
				<category><![CDATA[Servers and databases]]></category>
		<category><![CDATA[Debian 12]]></category>
		<category><![CDATA[MariaDB]]></category>
		<category><![CDATA[MySQL]]></category>
		<category><![CDATA[upgrade]]></category>
		<guid isPermaLink="false">https://www.cisilino.com/?p=255</guid>

					<description><![CDATA[<p>After a Debian 12 upgrade MySQL won't start and the logs mention mariadb.conf.d. How to identify the cause and fix it without touching your data.</p>
<p>The post <a href="https://www.cisilino.com/en/2026/10/13/mysql-wont-start-debian-12/">MySQL won’t start after a Debian 12 upgrade: cause and fix</a> first appeared on <a href="https://www.cisilino.com/en/">Matteo Cisilino</a>.</p>]]></description>
										<content:encoded><![CDATA[<h2 class="wp-block-heading">The symptom: mysql.service in a failed state</h2>
<p>MySQL won&#8217;t start: the service shows as failed and the log reports the error <code>Can't read dir of '/etc/mysql/mariadb.conf.d/'</code>:</p>
<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" src="https://www.cisilino.com/wp-content/uploads/2026/10/mysql-wont-start-debian-12-upgrade.webp" alt="MySQL won't start: a cylinder representing a database with an orange warning circle beside it" width="1200" height="630"/></figure>
<pre class="wp-block-code"><code># service mysql status
× mysql.service - MySQL Community Server
     Loaded: loaded (/lib/systemd/system/mysql.service; enabled; preset: enabled)
     Active: failed (Result: exit-code)
     Status: "Server startup in progress"

mysqld: Can't read dir of '/etc/mysql/mariadb.conf.d/' (OS errno 2 - No such file or directory)
mysqld: &#91;ERROR] Stopped processing the 'includedir' directive in file /etc/mysql/my.cnf at line 29.
mysqld: &#91;ERROR] Fatal error in defaults handling. Program aborted!
systemd&#91;1]: Failed to start mysql.service - MySQL Community Server.</code></pre>
<p>Checking the installed packages, MySQL is there as expected (<code>mysql-community-server</code> 8.0 packages from the official Oracle repository):</p>
<pre class="wp-block-code"><code>dpkg -l | grep mysql</code></pre>
<h2 class="wp-block-heading">The cause: the my.cnf alternative points to mariadb.cnf</h2>
<p>On Debian the file <code>/etc/mysql/my.cnf</code> is not a real file but a link managed by <strong>update-alternatives</strong>. Several packages can register their own version and, in automatic mode, the one with the highest priority wins.</p>
<p>During the upgrade the <strong>mariadb-common</strong> package was installed (and later removed), which registers <code>/etc/mysql/mariadb.cnf</code> with <strong>priority 500</strong>, higher than <code>/etc/mysql/mysql.cnf</code> (priority 300). The package was removed but not fully purged (state <code>rc</code>), so its configuration stayed active. On line 29 of that file there is an include of <code>/etc/mysql/mariadb.conf.d/</code>, a directory that does not exist. MySQL reads the configuration, cannot find the directory and stops, which is why MySQL won&#8217;t start.</p>
<p><strong>Good news:</strong> the data in <code>/var/lib/mysql</code> has not been touched. It is only a configuration problem.</p>
<h2 class="wp-block-heading">Diagnosis: check where my.cnf points</h2>
<pre class="wp-block-code"><code>ls -l /etc/mysql/my.cnf /etc/alternatives/my.cnf
update-alternatives --display my.cnf
sed -n '25,32p' /etc/mysql/my.cnf
dpkg -l | grep -i mariadb</code></pre>
<p>If this is the problem, the output will look like this:</p>
<pre class="wp-block-code"><code>/etc/alternatives/my.cnf -&gt; /etc/mysql/mariadb.cnf
/etc/mysql/my.cnf -&gt; /etc/alternatives/my.cnf

my.cnf - modalità automatica
  la versione migliore del collegamento è /etc/mysql/mariadb.cnf
  il collegamento attualmente punta a /etc/mysql/mariadb.cnf
/etc/mysql/mariadb.cnf - priorità 500
/etc/mysql/my.cnf.fallback - priorità 100
/etc/mysql/mysql.cnf - priorità 300

!includedir /etc/mysql/conf.d/
!includedir /etc/mysql/mariadb.conf.d/

rc  mariadb-common   1:10.11.18-0+deb12u1   all   MariaDB common configuration files</code></pre>
<h2 class="wp-block-heading">Step-by-step fix</h2>
<h3 class="wp-block-heading">1. Back up the configuration</h3>
<pre class="wp-block-code"><code>cp -a /etc/mysql /root/etc-mysql-backup-$(date +%F)</code></pre>
<h3 class="wp-block-heading">2. Remove the mariadb-common leftovers</h3>
<p>The purge removes <code>mariadb.cnf</code> and its entry in update-alternatives:</p>
<pre class="wp-block-code"><code>apt purge mariadb-common</code></pre>
<h3 class="wp-block-heading">3. Point my.cnf back to mysql.cnf</h3>
<pre class="wp-block-code"><code>update-alternatives --remove my.cnf /etc/mysql/mariadb.cnf 2&gt;/dev/null
update-alternatives --set my.cnf /etc/mysql/mysql.cnf</code></pre>
<h3 class="wp-block-heading">4. Check the configuration</h3>
<pre class="wp-block-code"><code>update-alternatives --display my.cnf
cat /etc/mysql/my.cnf
my_print_defaults mysqld</code></pre>
<p>Now <code>my.cnf</code> must point to <code>/etc/mysql/mysql.cnf</code> and include <code>/etc/mysql/conf.d/</code> and <code>/etc/mysql/mysql.conf.d/</code>. If <code>my_print_defaults mysqld</code> prints the parameters without errors, the configuration is correct.</p>
<h3 class="wp-block-heading">5. Start MySQL</h3>
<pre class="wp-block-code"><code>systemctl start mysql
systemctl status mysql
journalctl -u mysql -n 50 --no-pager</code></pre>
<h2 class="wp-block-heading">If MySQL won&#8217;t start and my.cnf is not a symbolic link</h2>
<p>If <code>/etc/mysql/my.cnf</code> is a regular file or <code>/etc/mysql/mysql.cnf</code> does not exist, you can recreate the configuration by hand:</p>
<pre class="wp-block-code"><code>mv /etc/mysql/my.cnf /etc/mysql/my.cnf.mariadb.bak
cat &gt; /etc/mysql/my.cnf &lt;&lt;'EOF'
!includedir /etc/mysql/conf.d/
!includedir /etc/mysql/mysql.conf.d/
EOF
mkdir -p /etc/mysql/conf.d
systemctl start mysql</code></pre>
<h2 class="wp-block-heading">Quick temporary fix when MySQL won&#8217;t start</h2>
<p>If you need to bring the database back online right away, just create the missing directory. It is only a stopgap, though. MySQL would keep reading the configuration meant for MariaDB. Better to apply the complete fix as soon as possible.</p>
<pre class="wp-block-code"><code>mkdir /etc/mysql/mariadb.conf.d &amp;&amp; systemctl start mysql</code></pre>
<h2 class="wp-block-heading">How to stop it happening again</h2>
<p>It is worth finding out which upgrade installed MariaDB, by looking at the apt history:</p>
<pre class="wp-block-code"><code>grep -i -B3 -A3 mariadb /var/log/apt/history.log*</code></pre>
<p>To stop <code>mariadb-common</code> being reinstalled in the future you can block it with an APT pin at a negative priority:</p>
<pre class="wp-block-code"><code>cat &gt; /etc/apt/preferences.d/no-mariadb &lt;&lt;'EOF'
Package: mariadb-*
Pin: release *
Pin-Priority: -1
EOF</code></pre>
<p>After every major upgrade it is also good practice to check the state of the alternative with <code>update-alternatives --display my.cnf</code>. Before the next upgrade, read the <a href="https://www.debian.org/releases/bookworm/amd64/release-notes/" target="_blank" rel="noopener">Debian 12 release notes</a> for known issues. A related case is <a href="https://www.cisilino.com/en/2026/10/17/mysql-5-7-docker-nftables-firewall/">MySQL 5.7 in Docker protected with nftables</a>.</p>
<h2 class="wp-block-heading">Frequently asked questions</h2>
<h3 class="wp-block-heading">Will I lose my database data?</h3>
<p>No. The problem only concerns the configuration files in <code>/etc/mysql</code>; the data in <code>/var/lib/mysql</code> stays intact.</p>
<h3 class="wp-block-heading">Why did Debian choose mariadb.cnf?</h3>
<p>Because the <code>my.cnf</code> alternative was in automatic mode and <code>mariadb.cnf</code> has priority 500, higher than the 300 of <code>mysql.cnf</code>. With <code>update-alternatives --set</code> the choice becomes manual and no longer changes by itself.</p>
<h3 class="wp-block-heading">Does it apply to Ubuntu too?</h3>
<p>Yes, the <code>update-alternatives</code> mechanism for <code>my.cnf</code> is the same on Ubuntu and on the other Debian-derived distributions.</p>
<h2 class="wp-block-heading">Conclusion</h2>
<p>The error <strong>&#8220;Can&#8217;t read dir of /etc/mysql/mariadb.conf.d/&#8221;</strong> after a Debian upgrade does not mean MySQL is damaged. It means a leftover MariaDB configuration has stayed active through update-alternatives. By removing <code>mariadb-common</code> with <code>apt purge</code> and setting <code>my.cnf</code> back to <code>mysql.cnf</code>, MySQL works normally again. If MySQL won&#8217;t start after an upgrade, check update-alternatives first.</p></p><p>The post <a href="https://www.cisilino.com/en/2026/10/13/mysql-wont-start-debian-12/">MySQL won’t start after a Debian 12 upgrade: cause and fix</a> first appeared on <a href="https://www.cisilino.com/en/">Matteo Cisilino</a>.</p>]]></content:encoded>
					
					<wfw:commentRss>https://www.cisilino.com/en/2026/10/13/mysql-wont-start-debian-12/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
	</channel>
</rss>
