<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Debian on Lessons learned</title><link>https://eghuro.cz/tags/debian/</link><description>Recent content in Debian on Lessons learned</description><generator>Hugo</generator><language>en-us</language><copyright>© Alexandr Mansurov · Licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)</copyright><lastBuildDate>Wed, 21 Oct 2026 13:13:00 +0200</lastBuildDate><atom:link href="https://eghuro.cz/tags/debian/index.xml" rel="self" type="application/rss+xml"/><item><title>Unprivileged LXC containers share the host's per-user kernel limits: 'Too many open files' and 'disk quota exceeded'</title><link>https://eghuro.cz/posts/lxc-containers-share-host-kernel-limits/</link><pubDate>Wed, 21 Oct 2026 13:13:00 +0200</pubDate><guid>https://eghuro.cz/posts/lxc-containers-share-host-kernel-limits/</guid><description>&lt;p&gt;One day, two containers on the same host failed with errors that seemed unrelated. systemd in one of them:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;systemd[4573]: Failed to create control group inotify object: Too many open files&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;systemd[4573]: Failed to allocate manager object: Too many open files&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;systemd[1]: Failed to start user@1000.service - User Manager for UID 1000.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Docker in another, where every container sat at &lt;code&gt;Exited (128)&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;OCI runtime create failed: runc create failed: unable to start container process: error during container init: unable to join session keyring: unable to create session key: disk quota exceeded: unknown&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;No file descriptors had run out, and no disk was full. Both errors come from per-user kernel quotas counted on the host, and every unprivileged container there was drawing from the same two.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Debian 13, kernel 6.12, LXC 6.0.4&lt;/li&gt;&#10;&lt;li&gt;More unprivileged containers than the hardware comfortably runs, all with the same idmap: container root is host UID 1000000&lt;/li&gt;&#10;&lt;li&gt;systemd in every container, Docker nested in some&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="too-many-open-files-inotify-instances"&gt;Too many open files: inotify instances &lt;/h2&gt;&#10;&lt;p&gt;systemd opens inotify instances to watch cgroups and units, and &lt;code&gt;inotify_init1()&lt;/code&gt; fails with &lt;code&gt;EMFILE&lt;/code&gt; when the user&amp;rsquo;s instance limit is reached. strerror turns that into &amp;ldquo;Too many open files&amp;rdquo;. The limit is &lt;code&gt;fs.inotify.max_user_instances&lt;/code&gt;, 128 by default.&lt;/p&gt;&#10;&lt;p&gt;Count the instances per user on the host:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo find /proc/&lt;span class="o"&gt;[&lt;/span&gt;0-9&lt;span class="o"&gt;]&lt;/span&gt;*/fd -lname &lt;span class="s1"&gt;&amp;#39;anon_inode:inotify&amp;#39;&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;|&lt;/span&gt; cut -d/ -f3 &lt;span class="p"&gt;|&lt;/span&gt; xargs -I&lt;span class="o"&gt;{}&lt;/span&gt; ps -o &lt;span class="nv"&gt;user&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; -p &lt;span class="o"&gt;{}&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; sort &lt;span class="p"&gt;|&lt;/span&gt; uniq -c &lt;span class="p"&gt;|&lt;/span&gt; sort -rn&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cat /proc/sys/fs/inotify/max_user_instances&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Host root held 23 instances, and uids in the container range (1000000 and up) held 104 between them: 127 of 128. On the first sighting, my coding agent got that far, then dropped it as a side effect of having started too many containers at once. That was half right, since each container&amp;rsquo;s systemd adds about five instances. A day later it came back.&lt;/p&gt;&#10;&lt;p&gt;The &lt;code&gt;lxc&lt;/code&gt; package already ships a higher limit, and it wasn&amp;rsquo;t in effect:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ cat /etc/sysctl.d/30-lxc-inotify.conf | grep -v &amp;#39;^#&amp;#39;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;fs.inotify.max_user_instances = 1024&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ cat /proc/sys/fs/inotify/max_user_instances&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;128&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The file was dated three days after the last boot. &lt;code&gt;/etc/sysctl.d&lt;/code&gt; is applied by &lt;code&gt;systemd-sysctl&lt;/code&gt; at boot, and the package&amp;rsquo;s install hadn&amp;rsquo;t applied it, so the setting sat on disk doing nothing.&lt;/p&gt;&#10;&#10;&lt;h2 id="disk-quota-exceeded-kernel-keys"&gt;Disk quota exceeded: kernel keys &lt;/h2&gt;&#10;&lt;p&gt;runc gives each container it starts a new session keyring. &lt;code&gt;add_key()&lt;/code&gt; fails with &lt;code&gt;EDQUOT&lt;/code&gt; when the user&amp;rsquo;s key quota is full, and strerror calls that &amp;ldquo;Disk quota exceeded&amp;rdquo;. The quota is &lt;code&gt;kernel.keys.maxkeys&lt;/code&gt;, 200 keys for any uid other than root. The host shows usage per UID:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ cat /proc/key-users&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 0: 127 126/126 107/1000000 2283/25000000&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 1000: 12 12/12 12/200 86/20000&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;1000000: 198 198/198 198/200 4189/20000&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;198/200&lt;/code&gt; are keys used against the quota. UID 1000000 is root in every container, so all the containers shared those 200 keys, and Docker&amp;rsquo;s restart attempts had nothing left.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-fix"&gt;The fix &lt;/h2&gt;&#10;&lt;p&gt;Raise the limits and apply them right away, not at the next boot. Incus recommends 2000 keys and 2 MB in its &lt;a&#10; href="https://linuxcontainers.org/incus/docs/main/reference/server_settings"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;server settings&lt;/a&gt;&#10;; 1024 inotify instances matches the &lt;code&gt;lxc&lt;/code&gt; package.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo tee /etc/sysctl.d/60-lxc-limits.conf &lt;span class="s"&gt;&amp;lt;&amp;lt;&amp;#39;EOF&amp;#39;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;fs.inotify.max_user_instances = 1024&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;kernel.keys.maxkeys = 2000&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;kernel.keys.maxbytes = 2000000&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s"&gt;EOF&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo sysctl -p /etc/sysctl.d/60-lxc-limits.conf&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;With Ansible, &lt;code&gt;ansible.posix.sysctl&lt;/code&gt; with &lt;code&gt;sysctl_set: true&lt;/code&gt; writes the file and sets the live value in one task.&lt;/p&gt;&#10;&lt;p&gt;Then run &lt;code&gt;systemctl reset-failed&lt;/code&gt; in the affected container, and &lt;code&gt;docker start&lt;/code&gt; for the dead Docker containers. Right after, uid 1000000 held 226 keys, already over the old limit.&lt;/p&gt;&#10;&#10;&lt;h2 id="why-the-two-quotas-are-counted-differently"&gt;Why the two quotas are counted differently &lt;/h2&gt;&#10;&lt;p&gt;The two quotas look similar, but separate idmaps would fix only one.&lt;/p&gt;&#10;&lt;p&gt;Keys are counted per host UID, against global sysctls. Containers that share an idmap share one quota. A separate idmap per container (&lt;code&gt;security.idmap.isolated&lt;/code&gt; in LXD and Incus) would give each its own.&lt;/p&gt;&#10;&lt;p&gt;inotify instances are counted per user namespace and charged up the chain of parent namespaces, at each level against the uid that created the child namespace. A new namespace&amp;rsquo;s own limit is &lt;code&gt;INT_MAX&lt;/code&gt;, so the limit that bites is the host&amp;rsquo;s, charged to whoever started the containers. LXC started as root charges host root, which is why the 23 instances of host root&amp;rsquo;s own daemons counted toward the same 128. Separate idmaps don&amp;rsquo;t change that.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;On a container host, &amp;ldquo;Too many open files&amp;rdquo; and &amp;ldquo;disk quota exceeded&amp;rdquo; can mean that every container together hit a per-user kernel quota on the host. Check the host&amp;rsquo;s counters before you debug the container that complained.&lt;/p&gt;&#10;&lt;p&gt;Run the &lt;code&gt;find&lt;/code&gt; count and &lt;code&gt;cat /proc/key-users&lt;/code&gt; on your own container host and compare them with the limits before a container restart finds out for you.&lt;/p&gt;&#10;</description></item><item><title>The rollback that saved the host, and killed it 24 hours later</title><link>https://eghuro.cz/posts/systemd-run-rollback-killed-dhclient/</link><pubDate>Sat, 17 Oct 2026 21:51:00 +0200</pubDate><guid>https://eghuro.cz/posts/systemd-run-rollback-killed-dhclient/</guid><description>&lt;p&gt;The rollback timer fired, the remote host got its address back, and the journal said all was well:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;dhclient[1796120]: bound to 192.168.0.2 -- renewal in 34318 seconds.&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;systemd[1]: lxc-uplink-rollback.service: Deactivated successfully.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Exactly 24 hours later, to the second, the host dropped off the network and took SSH, the port forwards, and the VPN with it.&lt;/p&gt;&#10;&lt;p&gt;When a transient &lt;code&gt;systemd-run&lt;/code&gt; unit finishes, systemd kills everything its command left running. If your rollback, or any other command run that way, starts a daemon, the daemon dies with the unit. Here it was &lt;code&gt;dhclient&lt;/code&gt;, and the damage only showed when the DHCP lease expired.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Debian 13, systemd 257, ifupdown with ISC dhclient 4.4.3-P1&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;allow-hotplug enp0s25&lt;/code&gt; / &lt;code&gt;iface enp0s25 inet dhcp&lt;/code&gt;, with a 24-hour lease from the router&lt;/li&gt;&#10;&lt;li&gt;The only way in is SSH through a port forward to the host&amp;rsquo;s address; the console is days away&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&#10;&lt;h2 id="the-dead-mans-switch-worked"&gt;The dead man&amp;rsquo;s switch worked &lt;/h2&gt;&#10;&lt;p&gt;Before a risky network change, an Ansible play arms a timer that undoes it, applies the change, and disarms the timer only if a fresh SSH login succeeds:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- &lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Arm rollback timer&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;ansible.builtin.command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;&amp;gt;-&lt;/span&gt;&lt;span class="sd"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt; systemd-run --collect --unit=lxc-uplink-rollback --on-active=5min&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt; /bin/sh -c &amp;#39;ifdown -a --exclude=lo; ip link del br0;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt; cp /etc/network/interfaces.rollback /etc/network/interfaces;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt; ifup -a; ifup -a --allow=hotplug&amp;#39;&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# ... apply the change ...&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- &lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Drop the cached SSH connection&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;ansible.builtin.meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;reset_connection&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- &lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Prove a fresh SSH login still works&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;ansible.builtin.wait_for_connection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;420&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- &lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Disarm rollback timer&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;ansible.builtin.command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;systemctl stop lxc-uplink-rollback.timer&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Without &lt;code&gt;meta: reset_connection&lt;/code&gt;, Ansible reuses its open SSH connection and a broken change looks fine.&lt;/p&gt;&#10;&lt;p&gt;The change moved the NIC into a bridge, &lt;code&gt;br0&lt;/code&gt;. The bridge got the old address back, but forwarded SSH from the internet stopped reaching the host. (I never found out why, and it isn&amp;rsquo;t the point here.) Five minutes later the timer restored the old config, the host came back, and I moved on.&lt;/p&gt;&#10;&#10;&lt;h2 id="what-went-wrong"&gt;What went wrong &lt;/h2&gt;&#10;&lt;p&gt;Look at the rollback unit again. Its main process is &lt;code&gt;/bin/sh&lt;/code&gt;. &lt;code&gt;ifup&lt;/code&gt; starts &lt;code&gt;dhclient&lt;/code&gt;, which forks into the background, and then &lt;code&gt;sh&lt;/code&gt; exits. The unit is now done, and a finished service gets its cgroup cleaned up: by default (&lt;code&gt;KillMode=control-group&lt;/code&gt;), systemd kills every process still in it. That includes the &lt;code&gt;dhclient&lt;/code&gt; that had just been &amp;ldquo;bound&amp;rdquo;.&lt;/p&gt;&#10;&lt;p&gt;A killed &lt;code&gt;dhclient&lt;/code&gt; doesn&amp;rsquo;t release the lease, so the address stays on the interface with a lifetime of 86400 seconds. Nothing renews it. When the lifetime runs out, the kernel removes the address, and avahi is the first to notice:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Oct 17 21:08:42 avahi-daemon[936]: Withdrawing address record for 192.168.0.2 on enp0s25.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The clue was in the journal on day one. When the rollback started, its &lt;code&gt;dhclient&lt;/code&gt; logged:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;dhclient[1796009]: Removed stale PID file&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That stale PID file belonged to the &lt;code&gt;dhclient&lt;/code&gt; on &lt;code&gt;br0&lt;/code&gt;. Ansible had started it five minutes earlier from another &lt;code&gt;systemd-run&lt;/code&gt; unit, and systemd had killed it the same way, but the rollback replaced it before anyone noticed.&lt;/p&gt;&#10;&lt;p&gt;At boot, &lt;code&gt;ifup@enp0s25.service&lt;/code&gt; starts the same &lt;code&gt;dhclient&lt;/code&gt;. That unit stays &lt;code&gt;active (exited)&lt;/code&gt;, so its cgroup is never cleaned up, which is how the boot-time &lt;code&gt;dhclient&lt;/code&gt; had kept renewing for about six weeks.&lt;/p&gt;&#10;&lt;p&gt;You can see the behavior in two lines:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo systemd-run --unit&lt;span class="o"&gt;=&lt;/span&gt;demo /bin/sh -c &lt;span class="s1"&gt;&amp;#39;sleep 600 &amp;amp;&amp;#39;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sleep 1&lt;span class="p"&gt;;&lt;/span&gt; pgrep -a sleep &lt;span class="c1"&gt;# nothing: systemd killed it when sh exited&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="the-fix"&gt;The fix &lt;/h2&gt;&#10;&lt;p&gt;To get back in, I went through a VM that had its own macvlan leg on the same NIC and ran &lt;code&gt;sudo dhclient enp0s25&lt;/code&gt;. With &lt;code&gt;ssh -J&lt;/code&gt;, &lt;code&gt;-i&lt;/code&gt; applies only to the last hop, so give the jump host its key through &lt;code&gt;ProxyCommand&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ssh -i ~/.ssh/key -o &lt;span class="nv"&gt;IdentitiesOnly&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;yes &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -o &lt;span class="nv"&gt;ProxyCommand&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;ssh -i ~/.ssh/key -o IdentitiesOnly=yes -W %h:%p user@jump.example.com&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; user@target.example.com&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The real fix was to stop depending on a long-running DHCP client. The router reserves the address for the host anyway, so the host now has a static stanza. Ansible applies it without restarting networking, because that would cut off the run and the only way in:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ip address replace 192.168.0.2/24 dev enp0s25 valid_lft forever preferred_lft forever&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ip route replace default via 192.168.0.1 dev enp0s25&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;pkill --signal KILL -f &lt;span class="s1"&gt;&amp;#39;(^|/)dhclient .*enp0s25$&amp;#39;&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;SIGKILL&lt;/code&gt; is deliberate: a &lt;code&gt;dhclient&lt;/code&gt; that stops cleanly removes the address it holds, the one this very run is connected through.&lt;/p&gt;&#10;&lt;p&gt;If the host has to stay on DHCP, keep daemons out of transient units. Start the daemon through the unit that owns it at boot (here &lt;code&gt;ifup@enp0s25.service&lt;/code&gt;), or let the transient unit leave its children alone with &lt;code&gt;systemd-run -p KillMode=process&lt;/code&gt;. I didn&amp;rsquo;t retry the bridge, so I haven&amp;rsquo;t tested either variant in this rollback.&lt;/p&gt;&#10;&lt;p&gt;To check which unit a daemon really belongs to:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cat /proc/&lt;span class="k"&gt;$(&lt;/span&gt;pgrep -o dhclient&lt;span class="k"&gt;)&lt;/span&gt;/cgroup&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;Deactivated successfully&lt;/code&gt; only says the unit finished; whatever it started may have died with it. A killed DHCP client fails a whole lease later, long after you have stopped looking.&lt;/p&gt;&#10;&lt;p&gt;Search your playbooks and scripts for &lt;code&gt;systemd-run&lt;/code&gt; calls that run &lt;code&gt;ifup&lt;/code&gt;, &lt;code&gt;dhclient&lt;/code&gt; or anything else that forks into the background. Then check which cgroup that process ends up in.&lt;/p&gt;&#10;</description></item><item><title>fail2ban that never banned anyone: '/bin/sh: 1: nft: not found'</title><link>https://eghuro.cz/posts/fail2ban-nft-not-found/</link><pubDate>Fri, 16 Oct 2026 15:33:00 +0200</pubDate><guid>https://eghuro.cz/posts/fail2ban-nft-not-found/</guid><description>&lt;p&gt;fail2ban said the address was banned:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ sudo fail2ban-client status nginx-limit-req&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;...&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; `- Banned IP list:&#9;203.0.113.10&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;From that address, right after:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ curl -s -o /dev/null -w &amp;#39;%{http_code}\n&amp;#39; https://example.com/&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;302&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The reason was in &lt;code&gt;/var/log/fail2ban.log&lt;/code&gt; all along:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;2026-03-14 10:21:05,205 fail2ban.utils [1293]: ERROR 7f5e8fbb0d30 -- exec: nft add table inet f2b-table&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;2026-03-14 10:21:05,206 fail2ban.utils [1293]: ERROR 7f5e8fbb0d30 -- stderr: &amp;#39;/bin/sh: 1: nft: not found&amp;#39;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;On Debian, fail2ban bans through the &lt;code&gt;nft&lt;/code&gt; command. A host that runs firewalld can easily lack it. fail2ban then counts failures, lists bans, and blocks nothing, including the sshd jail.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Debian 13, fail2ban 1.1.0-8, firewalld 2.3.1 with &lt;code&gt;FirewallBackend=nftables&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;nginx as a reverse proxy in front of a few web apps, SSH forwarded from the router&lt;/li&gt;&#10;&lt;li&gt;fail2ban set up by hand, with the stock sshd jail&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Debian&amp;rsquo;s &lt;code&gt;/etc/fail2ban/jail.d/defaults-debian.conf&lt;/code&gt; picks the ban action:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-ini" data-lang="ini"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;[DEFAULT]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;banaction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;nftables&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="na"&gt;banaction_allports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;nftables[type=allports]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="how-it-showed-up"&gt;How it showed up &lt;/h2&gt;&#10;&lt;p&gt;I was adding jails for the web logins, so I tested them from a VPS outside every whitelisted network. Testing from home proves nothing when home is in &lt;code&gt;ignoreip&lt;/code&gt;. Twenty-five quick POSTs to a login endpoint:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;for&lt;/span&gt; i in &lt;span class="k"&gt;$(&lt;/span&gt;seq 25&lt;span class="k"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; curl -s -o /dev/null -w &lt;span class="s1"&gt;&amp;#39;%{http_code} &amp;#39;&lt;/span&gt; -d &lt;span class="nv"&gt;client_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;probe https://example.com/login&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;done&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The first 11 got 400 from the app, the rest 429 from nginx&amp;rsquo;s rate limit, and the &lt;code&gt;nginx-limit-req&lt;/code&gt; jail banned the VPS as it should. Then the next request got a 302, as if nothing had happened.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;sudo nft list table inet f2b-table&lt;/code&gt; answered &lt;code&gt;sudo: nft: command not found&lt;/code&gt;. The log held ten lines like the ones above. Each failed ban left two ERROR lines in a log nobody reads, while &lt;code&gt;fail2ban-client status&lt;/code&gt; still listed the bans.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-fix"&gt;The fix &lt;/h2&gt;&#10;&lt;p&gt;Install the &lt;code&gt;nft&lt;/code&gt; command:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo apt install nftables&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo systemctl restart fail2ban&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Leave &lt;code&gt;nftables.service&lt;/code&gt; disabled, as the package installs it. Debian&amp;rsquo;s &lt;code&gt;/etc/nftables.conf&lt;/code&gt; starts with &lt;code&gt;flush ruleset&lt;/code&gt;, so enabling the service would wipe firewalld&amp;rsquo;s rules at the next boot. fail2ban doesn&amp;rsquo;t need the service, only the binary. It keeps its bans in a table of its own, &lt;code&gt;inet f2b-table&lt;/code&gt;, next to firewalld&amp;rsquo;s &lt;code&gt;inet firewalld&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;On restart, fail2ban restores the bans it has on record, so the ones that had silently failed now take effect:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;fail2ban.actions [1649343]: NOTICE [nginx-limit-req] Restore Ban 203.0.113.10&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Give it a few seconds before you check. Right after the restart, &lt;code&gt;nft list table inet f2b-table&lt;/code&gt; still said &lt;code&gt;No such file or directory&lt;/code&gt;. A moment later, it showed the set and the rule:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;table inet f2b-table {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;set addr-set-nginx-limit-req {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;type ipv4_addr&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;elements = { 203.0.113.10 }&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;chain f2b-chain {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;type filter hook input priority filter - 1; policy accept;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;tcp dport { 80, 443 } ip saddr @addr-set-nginx-limit-req reject with icmp port-unreachable&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This time, curl on the VPS couldn&amp;rsquo;t connect at all (exit code 7). If you manage the host with Ansible or similar, add &lt;code&gt;nftables&lt;/code&gt; to the package list alongside &lt;code&gt;fail2ban&lt;/code&gt; so a rebuilt host doesn&amp;rsquo;t lose it again.&lt;/p&gt;&#10;&#10;&lt;h2 id="why-the-binary-was-missing"&gt;Why the binary was missing &lt;/h2&gt;&#10;&lt;p&gt;firewalld talks to the kernel through the &lt;code&gt;python3-nftables&lt;/code&gt; bindings, not the &lt;code&gt;nft&lt;/code&gt; command, so it doesn&amp;rsquo;t pull in the &lt;code&gt;nftables&lt;/code&gt; package. fail2ban only recommends it as an alternative:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ apt-cache show fail2ban | grep ^Recommends&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Recommends: nftables | iptables, whois, python3-pyinotify, python3-setuptools&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;iptables&lt;/code&gt; was already installed (firewalld recommends it), so apt considered the recommendation met. The ban action still called &lt;code&gt;nft&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;The same thing can happen with any ban action whose command isn&amp;rsquo;t on the host: the action fails, and the address stays on the ban list anyway.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;Banned IP list&lt;/code&gt; shows what fail2ban decided, not what the firewall does. The only proof that a ban works is a blocked connection from the banned address, and &lt;code&gt;grep ERROR /var/log/fail2ban.log&lt;/code&gt; tells you why one isn&amp;rsquo;t.&lt;/p&gt;&#10;&lt;p&gt;Get one of your own hosts to ban a machine outside its whitelist, then try to connect from that machine.&lt;/p&gt;&#10;</description></item><item><title>No DHCP for an LXC container while iptables counts the packets as ACCEPT: firewalld rejects them in its own nftables table</title><link>https://eghuro.cz/posts/lxc-dhcp-iptables-accept-firewalld-reject/</link><pubDate>Tue, 13 Oct 2026 22:56:00 +0200</pubDate><guid>https://eghuro.cz/posts/lxc-dhcp-iptables-accept-firewalld-reject/</guid><description>&lt;p&gt;A new LXC container came up with an IPv6 address and no IPv4 address. According to &lt;code&gt;iptables&lt;/code&gt;, the host allowed DHCP from the container bridge, and the counter on that rule kept growing:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ sudo iptables -L INPUT -v -n | grep dpt:67&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 0 0 ACCEPT tcp -- lxcbr0 * 0.0.0.0/0 0.0.0.0/0 tcp dpt:67&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 26 7956 ACCEPT udp -- lxcbr0 * 0.0.0.0/0 0.0.0.0/0 udp dpt:67&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Twenty-six packets accepted, and dnsmasq, listening on that bridge, never logged a single DHCPDISCOVER.&lt;/p&gt;&#10;&lt;p&gt;With nftables, &lt;code&gt;accept&lt;/code&gt; only ends the chain it sits in. Any other base chain on the same hook still gets the packet. On this host, the next one belonged to firewalld, and firewalld rejected everything arriving on an interface that wasn&amp;rsquo;t in any of its zones.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-setup"&gt;The setup &lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Debian 13 host with the stock &lt;code&gt;lxc-net&lt;/code&gt; bridge: &lt;code&gt;lxcbr0&lt;/code&gt;, 10.0.3.0/24, dnsmasq at 10.0.3.1&lt;/li&gt;&#10;&lt;li&gt;firewalld 2.3.1 with &lt;code&gt;FirewallBackend=nftables&lt;/code&gt;, default zone &lt;code&gt;public&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;iptables 1.8.11, the nf_tables flavor Debian installs as &lt;code&gt;iptables&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;an unprivileged Debian 13 container running systemd-networkd with &lt;code&gt;DHCP=true&lt;/code&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;When lxc-net brings up the bridge, it adds its own iptables rules: ACCEPT for DNS and DHCP on &lt;code&gt;lxcbr0&lt;/code&gt;, ACCEPT for forwarding, and a MASQUERADE for the subnet. It knows nothing about firewalld.&lt;/p&gt;&#10;&#10;&lt;h2 id="following-the-packet"&gt;Following the packet &lt;/h2&gt;&#10;&lt;p&gt;The first visible symptom was the container&amp;rsquo;s bootstrap script, which spent nine minutes waiting on DNS lookups that timed out. From there, each check moved the problem one step along the path:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;The container sent requests. With debug logging, systemd-networkd showed &lt;code&gt;DHCPv4 client: DISCOVER&lt;/code&gt; in the &lt;code&gt;selecting&lt;/code&gt; state.&lt;/li&gt;&#10;&lt;li&gt;The host received them. The ACCEPT counter went from 20 to 26 to 29 while I made the client retry with &lt;code&gt;networkctl reconfigure eth0&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;dnsmasq had the right socket open: &lt;code&gt;0.0.0.0%lxcbr0:67&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;dnsmasq received nothing. &lt;code&gt;strace&lt;/code&gt; showed it sitting in &lt;code&gt;poll()&lt;/code&gt;. During a retry, it read only an IPv6 router solicitation, which it answered. That is where the IPv6 address came from.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;AppArmor (no denials), reverse-path filtering (loose on the bridge), &lt;code&gt;br_netfilter&lt;/code&gt; (not loaded), leftover iptables-legacy tables (empty), and socket buffer drops (&lt;code&gt;RcvbufErrors&lt;/code&gt; 0) all came up clean. The host had no &lt;code&gt;tcpdump&lt;/code&gt;, and no &lt;code&gt;nft&lt;/code&gt; either:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ sudo nft list ruleset&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo: nft: command not found&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That left &lt;code&gt;iptables&lt;/code&gt; as the only view of the firewall, and &lt;code&gt;iptables&lt;/code&gt; shows only the tables it created. So the packet passed an ACCEPT at the input hook and never reached the socket. Listing the running services found a second firewall:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;firewalld.service loaded active running firewalld - dynamic firewall daemon&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ sudo firewall-cmd --get-zone-of-interface=lxcbr0&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;no zone&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&#10;&lt;h2 id="two-tables-on-one-hook"&gt;Two tables on one hook &lt;/h2&gt;&#10;&lt;p&gt;After &lt;code&gt;apt install nftables&lt;/code&gt; (the binary is enough; leave &lt;code&gt;nftables.service&lt;/code&gt; disabled), you can see both chains on the input hook. Trimmed:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ sudo nft list chains&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;table ip filter {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;chain INPUT {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;type filter hook input priority filter; policy accept;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;table inet firewalld {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;chain filter_INPUT {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;&#9;type filter hook input priority filter + 10; policy accept;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;ip filter&lt;/code&gt; holds lxc-net&amp;rsquo;s rules, written through iptables-nft. &lt;code&gt;inet firewalld&lt;/code&gt; runs right after it, and an interface that no zone claims falls through to the default zone:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;chain filter_INPUT_POLICIES {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;...&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;jump filter_IN_policy_allow-host-ipv6&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;jump filter_IN_public&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#9;reject with icmpx admin-prohibited&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;public&lt;/code&gt; allowed SSH and the DHCPv6 client, but no DHCP server and no DNS, so the DISCOVER hit the final &lt;code&gt;reject&lt;/code&gt;. The zone chain also ends with &lt;code&gt;meta l4proto { icmp, ipv6-icmp } accept&lt;/code&gt;, which is why the router solicitation got through.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-fix"&gt;The fix &lt;/h2&gt;&#10;&lt;p&gt;Put the bridge in a firewalld zone. &lt;code&gt;trusted&lt;/code&gt; accepts everything, including forwarded traffic, which the containers need for internet access:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo firewall-cmd --zone&lt;span class="o"&gt;=&lt;/span&gt;trusted --add-interface&lt;span class="o"&gt;=&lt;/span&gt;lxcbr0&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo firewall-cmd --permanent --zone&lt;span class="o"&gt;=&lt;/span&gt;trusted --add-interface&lt;span class="o"&gt;=&lt;/span&gt;lxcbr0&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Or with Ansible:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- &lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Trust the container bridge in firewalld&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;ansible.posix.firewalld&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;zone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;trusted&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;interface&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;lxcbr0&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;permanent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;immediate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;enabled&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The container got its lease on the client&amp;rsquo;s next retry, without restarting anything. &lt;code&gt;trusted&lt;/code&gt; also lets containers reach every port on the host. If that&amp;rsquo;s too much, give the bridge a zone of its own that allows only DHCP, DNS, and forwarding. The same applies to any interface that another tool creates, such as a VPN tunnel.&lt;/p&gt;&#10;&#10;&lt;h2 id="why-the-counter-lied"&gt;Why the counter lied &lt;/h2&gt;&#10;&lt;p&gt;The &lt;a&#10; href="https://wiki.nftables.org/wiki-nftables/index.php/Configuring_chains#Base_chain_priority"&#10; &#10; target="_blank" rel="noopener"&#10; &#10; &#10;&gt;nftables wiki&lt;/a&gt;&#10; says that &amp;ldquo;an accept verdict &amp;hellip; isn&amp;rsquo;t necessarily final&amp;rdquo;, while &amp;ldquo;drops take immediate effect, with no further rules or chains being evaluated.&amp;rdquo; Base chains on one hook run in priority order. An accept ends one chain, and a drop or reject in any chain wins. When two firewalls share a host, the stricter one decides, and the permissive one keeps counting packets that never arrive.&lt;/p&gt;&#10;&lt;p&gt;Most of the time you won&amp;rsquo;t see the second table. &lt;code&gt;iptables -L&lt;/code&gt; lists only what iptables-nft created, and a host running firewalld may not have the &lt;code&gt;nft&lt;/code&gt; command at all. (That missing binary &lt;a&#10; href="https://eghuro.cz/posts/fail2ban-nft-not-found"&#10; &#10; data-rel="prefetch"&#10; &#10; &#10;&gt;also leaves fail2ban unable to ban anyone&lt;/a&gt;&#10;.)&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;A counter on an ACCEPT rule only proves that the packet reached that rule. When an accepted packet still doesn&amp;rsquo;t arrive, list every base chain on the hook and find the later one that drops it.&lt;/p&gt;&#10;&lt;p&gt;If your host runs firewalld alongside anything that writes its own iptables rules, run &lt;code&gt;sudo nft list chains | grep hook&lt;/code&gt; and count the chains on &lt;code&gt;input&lt;/code&gt; and &lt;code&gt;forward&lt;/code&gt;.&lt;/p&gt;&#10;</description></item><item><title>unattended-upgrades on Debian that installs nothing: Ubuntu-style Allowed-Origins</title><link>https://eghuro.cz/posts/unattended-upgrades-debian-allowed-origins/</link><pubDate>Sat, 26 Sep 2026 19:35:00 +0200</pubDate><guid>https://eghuro.cz/posts/unattended-upgrades-debian-allowed-origins/</guid><description>&lt;p&gt;A Debian 13 server had automatic security updates. Ansible had installed unattended-upgrades, enabled it in &lt;code&gt;20auto-upgrades&lt;/code&gt;, and &lt;code&gt;apt-daily-upgrade.timer&lt;/code&gt; had run that morning. Then a routine check:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ apt list --upgradable 2&amp;gt;/dev/null | grep -c security&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;61&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Nothing had failed, and nothing had warned. unattended-upgrades had never found anything it was allowed to install.&lt;/p&gt;&#10;&lt;p&gt;If your &lt;code&gt;50unattended-upgrades&lt;/code&gt; on Debian has &lt;code&gt;Allowed-Origins&lt;/code&gt; with &lt;code&gt;${distro_codename}-security&lt;/code&gt;, yours does the same.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-config"&gt;The config &lt;/h2&gt;&#10;&lt;p&gt;This is what the Ansible role wrote to &lt;code&gt;/etc/apt/apt.conf.d/50unattended-upgrades&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Unattended-Upgrade::Allowed-Origins {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;${distro_id}:${distro_codename}&amp;#34;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;${distro_id}:${distro_codename}-security&amp;#34;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;${distro_id}ESMApps:${distro_codename}-apps-security&amp;#34;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;${distro_id}ESM:${distro_codename}-infra-security&amp;#34;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;};&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The ESM lines give it away: Extended Security Maintenance is Ubuntu Pro. This is Ubuntu&amp;rsquo;s stock file, copied into a Debian role by hand or by an LLM. On Debian it expands to &amp;ldquo;Debian:trixie&amp;rdquo; and &amp;ldquo;Debian:trixie-security&amp;rdquo;, which looks right and matches nothing.&lt;/p&gt;&#10;&#10;&lt;h2 id="why-it-matches-nothing"&gt;Why it matches nothing &lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;Allowed-Origins&lt;/code&gt; is the legacy syntax. unattended-upgrades (2.12 on Debian 13) converts each entry to the newer pattern format in &lt;code&gt;get_allowed_origins_legacy()&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;allowed_origins&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;o=&lt;/span&gt;&lt;span class="si"&gt;%s&lt;/span&gt;&lt;span class="s2"&gt;,a=&lt;/span&gt;&lt;span class="si"&gt;%s&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;substitute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;distro_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;substitute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;distro_codename&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The part after the colon becomes &lt;code&gt;a=&lt;/code&gt;, and the matcher compares &lt;code&gt;a&lt;/code&gt; with the archive, which is the &lt;code&gt;Suite:&lt;/code&gt; field of the Release file:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;what&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;a&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;suite&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;archive&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fnmatch&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fnmatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;origin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;archive&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;On Ubuntu, suite and codename are the same string:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;# security.ubuntu.com/ubuntu/dists/noble-security/InRelease&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Suite: noble-security&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Codename: noble&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;On Debian, they are not:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;# security.debian.org/debian-security/dists/trixie-security/InRelease&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Origin: Debian&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Label: Debian-Security&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Suite: stable-security&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Codename: trixie-security&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;apt-cache policy&lt;/code&gt; shows both, as &lt;code&gt;a=&lt;/code&gt; and &lt;code&gt;n=&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;release v=13,o=Debian,a=stable-security,n=trixie-security,l=Debian-Security,c=main,b=amd64&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;So &lt;code&gt;a=trixie-security&lt;/code&gt; matches nothing, and neither does &lt;code&gt;a=trixie&lt;/code&gt;, because the main archive&amp;rsquo;s suite is &lt;code&gt;stable&lt;/code&gt;. Run through unattended-upgrades&amp;rsquo; own matcher against a trixie host&amp;rsquo;s package lists, the legacy entries come up empty, and Debian&amp;rsquo;s patterns each hit their archive:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&amp;#39;o=Debian,a=trixie&amp;#39; -&amp;gt; []&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&amp;#39;o=Debian,a=trixie-security&amp;#39; -&amp;gt; []&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&amp;#39;origin=Debian,codename=${distro_codename},label=Debian&amp;#39; -&amp;gt; [&amp;#39;stable/trixie&amp;#39;]&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&amp;#39;origin=Debian,codename=${distro_codename}-security,label=Debian-Security&amp;#39; -&amp;gt; [&amp;#39;stable-security/trixie-security&amp;#39;]&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;With no allowed origin, there is nothing to upgrade, and that&amp;rsquo;s not an error. Each run logs &lt;code&gt;No packages found that can be upgraded unattended and no pending auto-removals&lt;/code&gt; and exits cleanly.&lt;/p&gt;&#10;&#10;&lt;h2 id="the-fix"&gt;The fix &lt;/h2&gt;&#10;&lt;p&gt;Use &lt;code&gt;Origins-Pattern&lt;/code&gt; with &lt;code&gt;codename=&lt;/code&gt;, as Debian&amp;rsquo;s own file does:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Unattended-Upgrade::Origins-Pattern {&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;origin=Debian,codename=${distro_codename},label=Debian&amp;#34;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;origin=Debian,codename=${distro_codename}-updates,label=Debian&amp;#34;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;origin=Debian,codename=${distro_codename},label=Debian-Security&amp;#34;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;origin=Debian,codename=${distro_codename}-security,label=Debian-Security&amp;#34;;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;};&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Debian&amp;rsquo;s stock file has the &lt;code&gt;-updates&lt;/code&gt; line commented out. Keep it or drop it as you like.&lt;/p&gt;&#10;&lt;p&gt;&lt;code&gt;a=stable-security&lt;/code&gt; would match too, but don&amp;rsquo;t use it. On release day, &lt;code&gt;stable&lt;/code&gt; moves to the next Debian release, and a suite-based pattern would follow it. A codename stays with the release you installed.&lt;/p&gt;&#10;&lt;p&gt;Then check what unattended-upgrades actually picks:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sudo unattended-upgrade --dry-run -d&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The &lt;code&gt;Allowed origins are:&lt;/code&gt; line shows the patterns after conversion. Below it, the security packages from &lt;code&gt;apt list --upgradable&lt;/code&gt; should show up as upgrade candidates. If the run ends with &amp;ldquo;No packages found&amp;rdquo; while &lt;code&gt;apt list --upgradable&lt;/code&gt; lists &lt;code&gt;stable-security&lt;/code&gt; packages, the origins are still wrong.&lt;/p&gt;&#10;&lt;p&gt;With &lt;code&gt;Automatic-Reboot &amp;quot;false&amp;quot;&lt;/code&gt;, a new kernel or libc still waits for you once updates flow again, so check for &lt;code&gt;/var/run/reboot-required&lt;/code&gt;.&lt;/p&gt;&#10;&#10;&lt;h2 id="takeaway"&gt;Takeaway &lt;/h2&gt;&#10;&lt;p&gt;On Debian, &lt;code&gt;Allowed-Origins &amp;quot;${distro_id}:${distro_codename}-security&amp;quot;&lt;/code&gt; is a no-op that looks like a security policy, because it matches the suite (&lt;code&gt;stable-security&lt;/code&gt;), not the codename. Use &lt;code&gt;Origins-Pattern&lt;/code&gt; with &lt;code&gt;codename=&lt;/code&gt;, and confirm it with &lt;code&gt;--dry-run -d&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;Run &lt;code&gt;grep -rn Allowed-Origins /etc/apt/apt.conf.d/&lt;/code&gt; on your Debian hosts, and run the same grep over your configuration management repos, where the copy is most likely to live.&lt;/p&gt;&#10;</description></item></channel></rss>