<?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>Security &#8211; Bizr.net</title>
	<atom:link href="https://bizr.net/category/homelab/security/feed/" rel="self" type="application/rss+xml" />
	<link>https://bizr.net</link>
	<description>Lessons from the lab, the stack, and the team.</description>
	<lastBuildDate>Wed, 24 Jun 2026 23:32:07 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.0</generator>

<image>
	<url>https://bizr.net/wp-content/uploads/2026/05/favicon-1.png</url>
	<title>Security &#8211; Bizr.net</title>
	<link>https://bizr.net</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>Locking Down Authelia: TOTP, Real SMTP, and the Hostname-vs-IP TLS Trap</title>
		<link>https://bizr.net/locking-down-authelia-totp-real-smtp-and-the-hostname-vs-ip-tls-trap/</link>
					<comments>https://bizr.net/locking-down-authelia-totp-real-smtp-and-the-hostname-vs-ip-tls-trap/#respond</comments>
		
		<dc:creator><![CDATA[Bizr]]></dc:creator>
		<pubDate>Fri, 19 Jun 2026 12:16:49 +0000</pubDate>
				<category><![CDATA[Security]]></category>
		<category><![CDATA[authelia]]></category>
		<category><![CDATA[certificate-validation]]></category>
		<category><![CDATA[dmz-stack]]></category>
		<category><![CDATA[email]]></category>
		<category><![CDATA[Homelab]]></category>
		<category><![CDATA[identity-management]]></category>
		<category><![CDATA[network-security]]></category>
		<category><![CDATA[self-hosted]]></category>
		<category><![CDATA[smtp]]></category>
		<category><![CDATA[TLS]]></category>
		<category><![CDATA[totp]]></category>
		<category><![CDATA[two-factor-authentication]]></category>
		<guid isPermaLink="false">https://bizr.net/?p=2214</guid>

					<description><![CDATA[Claude responded: Post excerptPost excerptA login page that renders isn't the same thing as authentication that's actually done. Here's how I turned on real TOTP two-factor for Authelia, bootstrapped the very first registration without a working mailbox using a filesystem notifier trick, and then tripped over a TLS certificate trap that taught me hostnames aren't an implementation detail you can swap for a raw IP — not once certificate validation enters the picture.]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">There&#8217;s a particular kind of false confidence that comes from standing up Authelia in front of your apps and seeing the login page render. It looks done. You type a username and password, you get challenged, you get in. Box checked, right?</p>



<p class="wp-block-paragraph">Except a username-and-password-only Authelia instance is just a fancier login form sitting in front of the exact same single point of failure every other login form has: whatever password you picked. The entire reason to run a centralized auth layer in the first place is to get to do hard things — like enforcing real two-factor authentication — once, centrally, instead of bolting it onto every app individually. If you skip that part, you&#8217;ve built infrastructure for a feature you&#8217;re not actually using.</p>



<p class="wp-block-paragraph">So this post is about closing that gap: turning on real TOTP-based two-factor, and wiring up the notification system Authelia needs to actually deliver codes, links, and alerts — which sounds like a footnote but turned into its own small adventure once I hit a TLS certificate quirk that&#8217;s worth flagging for anyone else who runs into it.</p>



<h2 class="wp-block-heading">Why two-factor isn&#8217;t optional once you centralize auth</h2>



<p class="wp-block-paragraph">Once Authelia is gating <a href="https://bizr.net/one-login-to-rule-them-all-wiring-authelia-as-sso-for-open-webui-and-nextcloud/">Nextcloud, Open WebUI</a>, and whatever else lives behind it, that one login becomes the single key to the entire DMZ. That&#8217;s the whole point — but it cuts both ways. A compromised password used to mean a compromised app. Now it means a compromised everything, all at once, through the front door I built specifically to make access more convenient. Centralizing authentication without centralizing strong authentication is just centralizing risk — and it&#8217;s the same reason the DMZ network itself is <a href="https://bizr.net/beyond-the-tunnel-adding-haproxy-acme-and-vlan-isolation-behind-my-opnsense-wireguard-setup/">VLAN-isolated from the rest of the homelab</a> in the first place: a single compromised layer shouldn&#8217;t mean a compromised everything else too. </p>



<p class="wp-block-paragraph">Authelia supports TOTP out of the box, and enabling it for an <code>access_control</code> policy is a one-line change in <code>configuration.yml</code>:</p>



<p class="wp-block-paragraph">yaml</p>



<pre class="wp-block-code has-ast-global-color-5-color has-text-color has-link-color wp-elements-67d97775ddc2813cacba26d1dff0d2d5"><code>access_control:
  rules:
    - domain: openwebui.yourdomain.com
      policy: two_factor
    - domain: nextcloud.yourdomain.com
      policy: two_factor</code></pre>



<p class="wp-block-paragraph">That&#8217;s it on the policy side — flip <code>one_factor</code> to <code>two_factor</code> for any domain you want gated, restart Authelia, and the access control layer now requires a second factor before granting a session. I deliberately left <code>auth.yourdomain.com</code> itself — Authelia&#8217;s own login portal — on a <code>bypass</code> policy, since that&#8217;s the page doing the authenticating in the first place; there&#8217;s nothing to gate behind itself.</p>



<p class="wp-block-paragraph">The part that actually takes thought isn&#8217;t the policy line, though — it&#8217;s how the person setting up TOTP for the first time actually receives the QR code and registration link. Authelia&#8217;s identity-verification flow for first-time 2FA registration works by emailing you a link to confirm it&#8217;s really you before letting you scan a QR code and bind an authenticator app. No working notification channel, no registration. The login page rendering correctly was never actually proof the auth system was complete; it was proof exactly one feature — first-factor password checking — was complete.</p>



<h2 class="wp-block-heading">Bootstrapping without email: the filesystem notifier</h2>



<p class="wp-block-paragraph">Here&#8217;s a chicken-and-egg problem I didn&#8217;t expect going in: you generally need a working SMTP setup to register 2FA, but I hadn&#8217;t actually wired up SMTP yet when I wanted to register my own TOTP for the first time. Authelia&#8217;s answer to this is a notifier mode that doesn&#8217;t try to send anything anywhere — it just writes the notification to a file on disk:</p>



<p class="wp-block-paragraph">yaml</p>



<pre class="wp-block-code has-ast-global-color-5-color has-text-color has-link-color wp-elements-c922a1bede1d89177f90905c1ebb5dc7"><code>notifier:
  filesystem:
    filename: /config/notification.txt</code></pre>



<p class="wp-block-paragraph">With this in place, instead of an email landing in an inbox, the identity-verification link gets written straight into a text file inside the Authelia container. Reading it is just:</p>



<p class="wp-block-paragraph">bash</p>



<pre class="wp-block-code has-ast-global-color-5-color has-text-color has-link-color wp-elements-82cfe32d36fdef6c6b8e0cc2ef1bd198"><code>docker exec authelia cat /data/notification.txt</code></pre>



<p class="wp-block-paragraph">Copy the link out, paste it into a browser, and the registration flow proceeds exactly as if a real email had arrived. This is a genuinely useful bootstrapping trick worth knowing about independent of whatever your final SMTP setup ends up being — any time Authelia needs to send something and you don&#8217;t yet trust (or haven&#8217;t yet built) the real delivery path, dropping back to the filesystem notifier lets you keep moving without blocking on infrastructure that isn&#8217;t ready yet.</p>



<h2 class="wp-block-heading">Wiring up real SMTP, and the trap waiting inside it</h2>



<p class="wp-block-paragraph">Bootstrapping aside, a filesystem notifier obviously isn&#8217;t a long-term answer — it only works because I have shell access to the box, which defeats the entire purpose of a notification system the moment any other person needs to register their own 2FA. The real fix is pointing Authelia at an actual mailbox.</p>



<p class="wp-block-paragraph">The notifier config for SMTP looks innocent enough:</p>



<p class="wp-block-paragraph">yaml</p>



<pre class="wp-block-code has-ast-global-color-5-color has-text-color has-link-color wp-elements-093e6e690cd6a2a63c319c63e57963be"><code>notifier:
  smtp:
    address: 'submissions://mailserver.domain.com:465'
    username: 'mymail@domain.com'
    password: 'REDACTED'
    sender: "Authelia &lt;mymail@maildomain.com&gt;"
    identifier: 'yourdomain.com'
    startup_check_address: 'mymail@maildomain.com'
    tls:
      skip_verify: <strong>false</strong>
      minimum_version: 'TLS1.2'</code></pre>



<p class="wp-block-paragraph">The <code>submissions://</code> scheme tells Authelia this is an implicit-TLS SMTP submission connection on port 465, rather than the older plaintext-then-STARTTLS dance on port 587. That part&#8217;s fine and fairly standard.</p>



<p class="wp-block-paragraph">What actually tripped me up was something that, in hindsight, is an obvious consequence of how TLS works, but is exactly the kind of thing you don&#8217;t think about until it bites you: my first instinct was to point the <code>address</code> field at the mail server&#8217;s raw IP rather than its hostname, since I already had the IP handy and it felt like one less moving part — no DNS resolution step, no dependency on DNS staying healthy. Authelia started up, attempted the connection, and immediately failed with <code>x509: cannot validate certificate for &lt;IP&gt; because it doesn't contain any IP SANs</code>.</p>



<p class="wp-block-paragraph">That error message, once you actually read it instead of just reflexively googling it, explains the whole problem. The mail server&#8217;s TLS certificate was issued for the hostname <code>mailserver.domain.com</code>. TLS certificates assert an identity — &#8220;I am this specific name&#8221; — and the client connecting is supposed to check that the name it asked to connect to matches the name on the certificate it received back. When I connected by raw IP, there was no hostname in the conversation for the certificate to match against; the certificate says &#8220;I&#8217;m <code>mailserver.domain.com</code>,&#8221; and the connection was nominally to <code>203.0.113.45</code> or whatever the IP happened to be, and those two things have no relationship as far as the TLS handshake is concerned, unless the certificate specifically lists that IP as an additional valid identity (an IP SAN, which this one didn&#8217;t have, because almost no certificates bother to).</p>



<p class="wp-block-paragraph">This is a small, almost embarrassing thing to get wrong, and also a genuinely common trap, because using a raw IP feels like it should be strictly safer or simpler — fewer dependencies, no DNS lookup that could fail or get hijacked. For plaintext or non-validated connections, sure. The moment TLS certificate validation enters the picture, the hostname isn&#8217;t an implementation detail you can route around — it&#8217;s load-bearing. The certificate&#8217;s whole job is binding a cryptographic identity to a name, and if you don&#8217;t connect using that name, there&#8217;s no name for the certificate to authenticate against, full stop. (The same hostname-vs-certificate confusion shows up again, in a slightly different shape, when <a href="https://bizr.net/ditch-google-self-hosting-searxng-behind-authelia-for-desktop-and-open-webui/">verifying a wildcard cert covers a new subdomain</a> — worth a read if this kind of TLS mismatch interests you beyond just SMTP.) </p>



<p class="wp-block-paragraph">The fix was simply using the hostname Authelia was always meant to use:</p>



<p class="wp-block-paragraph">yaml</p>



<pre class="wp-block-code has-ast-global-color-5-color has-text-color has-link-color wp-elements-f45e993b6f7ef0e41d18633d64daddb7"><code>address: 'submissions://mailserver.domain.com:465'</code></pre>



<p class="wp-block-paragraph">Once that one piece changed, the connection succeeded cleanly, the startup check passed, and Authelia logged a clean <code>Startup complete</code> with no certificate complaints.</p>



<h2 class="wp-block-heading">Confirming it actually works, end to end</h2>



<p class="wp-block-paragraph">Configuration that merely starts without errors and configuration that actually works are two different claims, and I&#8217;ve been burned often enough by the first one masquerading as the second that I always insist on testing the real flow before calling something done. With SMTP wired up correctly, I went back through the TOTP registration process one more time, this time as a genuine end-to-end test rather than the filesystem-notifier bootstrap — triggered the identity verification flow, and watched a real email land in the actual inbox a few seconds later, with a working confirmation link.</p>



<p class="wp-block-paragraph">From there, registering TOTP itself is almost anticlimactic after all that setup: scan the QR code with an authenticator app, enter the six-digit code it generates to confirm the binding succeeded, and from that point forward, every login to a <code>two_factor</code>-policy domain prompts for that code after the password. The login page, from the outside, looks exactly like it did before any of this work — username, password, and now an extra six digits. The difference is everything behind that screen actually does what centralizing authentication was supposed to buy in the first place.</p>



<h2 class="wp-block-heading">The actual lesson here</h2>



<p class="wp-block-paragraph">None of this was hard, exactly — every individual piece was a short config block and a restart. What made it worth writing up is how each step quietly assumed the previous one actually worked, and how easy it would have been to stop at &#8220;the login page renders&#8221; and call it done. Two-factor without a working notification channel isn&#8217;t two-factor, it&#8217;s a setting nobody can finish enabling. A notification channel that starts without errors isn&#8217;t necessarily a notification channel that delivers anything, if a hostname-vs-IP TLS mismatch is quietly swallowing every send attempt. The only way to know any of it actually works is to walk the whole path a real, first-time user would take, end to end, and watch what actually lands in an actual inbox.</p>



<p class="wp-block-paragraph">This same 2FA-gated <code>access_control</code> pattern gets reused directly in the next build — <a href="https://bizr.net/ditch-google-self-hosting-searxng-behind-authelia-for-desktop-and-open-webui/">Ditch Google</a>, which gates a self-hosted SearXNG instance behind exactly this TOTP setup, plus a network-scoped bypass rule for Open WebUI&#8217;s API calls. And if locking yourself out of your own auth layer is a worry — it should be, once you&#8217;ve made Authelia the single key to everything — <a href="https://bizr.net/never-lock-yourself-out-building-a-self-updating-anti-lockout-rule-for-opnsense/">Never Lock Yourself Out</a> covers the equivalent safety net on the OPNsense side. </p>
]]></content:encoded>
					
					<wfw:commentRss>https://bizr.net/locking-down-authelia-totp-real-smtp-and-the-hostname-vs-ip-tls-trap/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Never Lock Yourself Out: Building a Self-Updating Anti-Lockout Rule for OPNsense</title>
		<link>https://bizr.net/never-lock-yourself-out-building-a-self-updating-anti-lockout-rule-for-opnsense/</link>
					<comments>https://bizr.net/never-lock-yourself-out-building-a-self-updating-anti-lockout-rule-for-opnsense/#respond</comments>
		
		<dc:creator><![CDATA[Bizr]]></dc:creator>
		<pubDate>Thu, 18 Jun 2026 12:44:46 +0000</pubDate>
				<category><![CDATA[Security]]></category>
		<category><![CDATA[anti-lockout]]></category>
		<category><![CDATA[CGNAT]]></category>
		<category><![CDATA[cloudflare]]></category>
		<category><![CDATA[dynamic dns]]></category>
		<category><![CDATA[Firewall]]></category>
		<category><![CDATA[Homelab]]></category>
		<category><![CDATA[Network Security]]></category>
		<category><![CDATA[OPNsense]]></category>
		<guid isPermaLink="false">https://bizr.net/?p=2211</guid>

					<description><![CDATA[Two firewalls, one home connection behind CGNAT, and a problem I kept putting off: how do you build an anti-lockout rule for a remote firewall when your home IP changes and isn't even fully under your control? Here's the self-updating fix.]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">I run two OPNsense boxes. One sits at home, quietly routing my homelab traffic and occasionally getting yelled at when I push a config change that breaks DNS resolution for the fourth time this month. The other lives in a data center, fronting the actual production stack behind bizr.net, cookinary.com, and numiscurio.com — Apache, PHP-FPM, the works, with <a href="https://bizr.net/hardening-your-homelabs-public-edge-geoip-blocking-crowdsec-and-syncing-fail2ban-with-opnsense/">GeoIP blocking, CrowdSec, and a fail2ban-to-OPNsense sync</a> already doing the perimeter hardening on that side. </p>



<p class="wp-block-paragraph">Two firewalls, two very different jobs. But they share a problem that took me longer than I&#8217;d like to admit to solve properly: how do you restrict management access on the remote box to &#8220;just me, at home&#8221; when your home connection doesn&#8217;t have a fixed IP — and worse, doesn&#8217;t even have a <em>real</em> IP from the internet&#8217;s point of view?</p>



<p class="wp-block-paragraph">This post walks through what I built: a self-updating allowlist that tracks my home IP automatically, even through CGNAT, without me touching either firewall again. If you&#8217;re running a similar two-site setup — homelab plus colo, or homelab plus VPS — this is the kind of plumbing that&#8217;s easy to skip and annoying to retrofit later.</p>



<h2 class="wp-block-heading">Why Bother Locking This Down At All</h2>



<p class="wp-block-paragraph">The webhosting firewall has a management GUI and SSH, like every OPNsense box does. In an ideal world, neither would ever touch the WAN interface — everything would go over VPN, full stop. In practice, there are legitimate reasons to want a direct fallback path: VPN daemons crash, tunnels flap, and at 2am when something&#8217;s on fire, you want a door that doesn&#8217;t depend on the thing that might be broken.</p>



<p class="wp-block-paragraph">The compromise I landed on is the same one most of us reach for eventually: keep the door, but restrict who can knock. An allow rule scoped to &#8220;traffic from my home IP only&#8221; on top of a default-deny stance shrinks the attack surface from &#8220;anyone on the internet&#8221; to &#8220;anyone who happens to be sitting at my house, or who happens to share my ISP&#8217;s NAT pool at this exact moment&#8221; — which, as I&#8217;ll get into, isn&#8217;t quite as airtight as it sounds, but it&#8217;s a meaningful improvement over leaving the GUI wide open.</p>



<p class="wp-block-paragraph">This is also just good practice independent of the CGNAT wrinkle. If you&#8217;ve got a static IP at home, the IP-allowlist part of this is trivial — one alias, one rule, done. The interesting part of my setup, and the reason this post exists, is what happens when your home connection doesn&#8217;t give you a stable address to allowlist in the first place.</p>



<h2 class="wp-block-heading">The CGNAT Catch</h2>



<p class="wp-block-paragraph">My home ISP, like most residential providers these days, doesn&#8217;t hand out a unique public IPv4 address per customer. Instead, I sit behind Carrier-Grade NAT: a whole pool of subscribers shares a smaller set of public IPs, and the address my OPNsense&#8217;s WAN interface reports is a private one in the 100.64.0.0/10 range — not my actual internet-facing address at all.</p>



<p class="wp-block-paragraph">This is the same CGNAT wrinkle that made <a href="https://bizr.net/bypassing-cgnat-with-an-opnsense-wireguard-tunnel/">getting a WireGuard tunnel through to this homelab in the first place</a> its own separate problem — there, the issue was inbound traffic having nowhere to land; here, it&#8217;s an outbound-facing allowlist having nothing stable to allowlist. Same root cause, two different symptoms.</p>



<p class="wp-block-paragraph">This matters a lot for dynamic DNS. The naive approach — point a DDNS client at your WAN interface and have it push that address to a DNS record — completely falls apart under CGNAT. You&#8217;d faithfully update your DNS record every few minutes with an address nobody on the internet can route to. The fix isn&#8217;t complicated once you know it&#8217;s needed, but it&#8217;s a &#8220;you don&#8217;t know what you don&#8217;t know&#8221; trap: the DDNS client has to ask an <em>external</em> service what your IP looks like from the outside, rather than trusting what your own interface thinks it is.</p>



<h2 class="wp-block-heading">Building a Self-Updating Allowlist</h2>



<p class="wp-block-paragraph">With that constraint in mind, the chain I built has three links:</p>



<ol class="wp-block-list">
<li>A DNS record on Cloudflare that always points at my real home IP.</li>



<li>A DDNS client on the home OPNsense box that keeps that record current — sourcing the IP from an external lookup service instead of the WAN interface, specifically because of the CGNAT issue above.</li>



<li>A firewall alias on the webhosting OPNsense box that resolves that same hostname on a timer and feeds the result straight into the pf table, with a rule referencing the alias instead of a hardcoded address.</li>
</ol>



<p class="wp-block-paragraph">The elegant part is that none of this needs glue scripts or cron jobs duct-taped together. OPNsense&#8217;s alias system natively understands FQDNs in Host-type aliases — it resolves them on a configurable interval (300 seconds by default) and keeps the underlying table in sync on its own. Once the chain is wired up, it just runs.</p>



<h3 class="wp-block-heading">Step 1 — A DNS Record and a Properly Scoped API Token</h3>



<p class="wp-block-paragraph">On the Cloudflare side, I added a plain A record — <code>home.yourdomain.com</code> — with the proxy turned off (grey cloud, &#8220;DNS only&#8221;). That last bit matters more than it looks: if the orange-cloud proxy is on, anything resolving that hostname gets back one of Cloudflare&#8217;s edge IPs instead of my home address, which would quietly break the whole scheme in a way that&#8217;s annoying to debug.</p>



<p class="wp-block-paragraph">For authentication, I skipped the Global API Key — it grants account-wide access, which is a lot of blast radius for something that only needs to touch one DNS record — in favor of a scoped API token limited to <code>Zone / DNS / Edit</code> permissions on the specific zone. Good habit generally: minimum necessary privilege, even for the small stuff.</p>



<h3 class="wp-block-heading">Step 2 — DDNS on the Home Firewall</h3>



<p class="wp-block-paragraph">OPNsense doesn&#8217;t ship dynamic DNS support out of the box; it&#8217;s a plugin (<code>os-ddclient</code>). Once installed, the configuration lives under Services → Dynamic DNS, and the account setup is where the CGNAT-specific decision actually gets made.</p>



<p class="wp-block-paragraph">The provider is Cloudflare, the token goes in the password field (with a slightly odd quirk — the username field has to literally contain the word &#8220;token&#8221; rather than an email address, which is how the plugin distinguishes API-token auth from the legacy global-key auth). The zone is the bare domain, the hostname is the full subdomain I want updated.</p>



<p class="wp-block-paragraph">The setting that actually solves the CGNAT problem is &#8220;Check IP method.&#8221; Left on &#8220;Interface,&#8221; the client would read the WAN interface&#8217;s address — the private CGNAT one — and dutifully push garbage upstream. Pointed instead at a web-based IP-checking service, the client asks an external host what address the request arrived from, which is the real, routable, internet-facing IP, regardless of how many layers of NAT sit between my router and the actual internet.</p>



<p class="wp-block-paragraph">It&#8217;s a small dropdown selection with a big practical consequence, and it&#8217;s the kind of detail that&#8217;s invisible until you&#8217;ve had a DDNS setup silently failing for a week because nobody told you the interface address wasn&#8217;t the address that mattered.</p>



<h3 class="wp-block-heading">Step 3 — Alias and Rule on the Webhosting Firewall</h3>



<p class="wp-block-paragraph">On the colo box, the configuration is almost anticlimactic by comparison. A new Host-type alias with the DDNS hostname as its content, and OPNsense takes care of the rest — resolving it on its own schedule and populating the pf table transparently. Any rule referencing that alias automatically tracks whatever the hostname currently resolves to.</p>



<p class="wp-block-paragraph">The rule itself is a single allow entry, sourced from the alias, scoped to the specific port the management interface listens on, sitting above the default-deny rule that covers everything else. From the rule&#8217;s perspective, it looks exactly like a static IP allowlist. The dynamic part is entirely hidden inside the alias. (This same idea — trusting a request because of where it&#8217;s coming from, rather than what it claims to be — is exactly what the <a href="https://bizr.net/ditch-google-self-hosting-searxng-behind-authelia-for-desktop-and-open-webui/">network-scoped Authelia bypass rule for Open WebUI&#8217;s search calls</a> does at the application layer instead of the firewall layer.) </p>



<h2 class="wp-block-heading">What Could Still Go Wrong</h2>



<p class="wp-block-paragraph">I want to be honest about the limits of this setup, because IP-based access control always sounds more solid than it actually is.</p>



<p class="wp-block-paragraph">First, there&#8217;s a convergence gap. If my home IP changes, there&#8217;s a window — typically a few minutes — between the DDNS client noticing and pushing the update, and the remote firewall&#8217;s alias re-resolving the hostname. During that window, the old IP is still allowed and the new one isn&#8217;t yet. If the only path into the box were this rule, a badly timed IP rotation could lock me out at the worst possible moment.</p>



<p class="wp-block-paragraph">Second, and more fundamentally: CGNAT means the public IP I&#8217;m allowlisting isn&#8217;t uniquely mine. It&#8217;s shared across a pool of subscribers on the same ISP infrastructure. Anyone else sitting behind the same NAT pool at the same moment would also satisfy the source-IP check. That doesn&#8217;t make the rule useless — it still meaningfully narrows things down from &#8220;the entire internet&#8221; to &#8220;people on my ISP&#8217;s network right now&#8221; — but it&#8217;s not the same security guarantee a unique static IP would give you, and I don&#8217;t want to oversell it as one.</p>



<p class="wp-block-paragraph">Because of both of these, this rule isn&#8217;t my primary path into the box. I already run <a href="https://bizr.net/bypassing-cgnat-with-an-opnsense-wireguard-tunnel/">a WireGuard tunnel between the two sites</a>, with <a href="https://bizr.net/beyond-the-tunnel-adding-haproxy-acme-and-vlan-isolation-behind-my-opnsense-wireguard-setup/">HAProxy, ACME, and VLAN isolation</a> deciding what&#8217;s actually reachable on the other end of it, and that&#8217;s where day-to-day management traffic goes. This dynamic allowlist exists as the fallback — the door I can still use if the tunnel itself is down — not as the front line. And I&#8217;ve kept a separate, non-network access path (console/out-of-band) available too, specifically so that a mistimed IP change during a real outage can&#8217;t compound into a full lockout.</p>



<h2 class="wp-block-heading">The Bigger Lesson</h2>



<p class="wp-block-paragraph">None of this is exotic infrastructure — a DNS record, a small plugin, two firewall objects. But it&#8217;s a good small-scale example of something that shows up constantly in the day job too: a control that looks complete in isolation often has a hidden dependency that only surfaces under specific conditions. &#8220;Just allowlist the IP&#8221; is a perfectly fine instruction right up until someone&#8217;s home connection sits behind carrier-grade NAT, at which point the naive implementation fails silently instead of loudly.</p>



<p class="wp-block-paragraph">The fix, in both the homelab and the enterprise version of this problem, is the same: don&#8217;t treat any single control as sufficient on its own. Layer the dynamic allowlist behind the VPN, keep an out-of-band path alive, and build in the assumption that any one piece — DNS propagation, a NAT pool reassignment, a tunnel daemon crashing — will eventually fail at an inconvenient time. The goal isn&#8217;t a system that never breaks. It&#8217;s one where a single break doesn&#8217;t turn into a 2am lockout. The same defense-in-depth instinct shows up again later in <a href="https://bizr.net/locking-down-authelia-totp-real-smtp-and-the-hostname-vs-ip-tls-trap/">locking down Authelia&#8217;s own 2FA setup</a> — never trusting that one layer rendering correctly means the whole chain behind it actually works. </p>



<p class="wp-block-paragraph">That&#8217;s the version of &#8220;secure by default&#8221; I actually trust — not because it&#8217;s clever, but because it assumes its own cleverness will eventually be wrong about something.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://bizr.net/never-lock-yourself-out-building-a-self-updating-anti-lockout-rule-for-opnsense/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>From App Logs to Firewall Blocks: Building a fail2ban-to-OPNsense Ban Pipeline</title>
		<link>https://bizr.net/from-app-logs-to-firewall-blocks-building-a-fail2ban-to-opnsense-ban-pipeline/</link>
					<comments>https://bizr.net/from-app-logs-to-firewall-blocks-building-a-fail2ban-to-opnsense-ban-pipeline/#respond</comments>
		
		<dc:creator><![CDATA[Bizr]]></dc:creator>
		<pubDate>Thu, 18 Jun 2026 11:45:59 +0000</pubDate>
				<category><![CDATA[Security]]></category>
		<category><![CDATA[automation]]></category>
		<category><![CDATA[caddy]]></category>
		<category><![CDATA[dmz-stack]]></category>
		<category><![CDATA[Fail2Ban]]></category>
		<category><![CDATA[Firewall]]></category>
		<category><![CDATA[Homelab]]></category>
		<category><![CDATA[intrusion-prevention]]></category>
		<category><![CDATA[network-security]]></category>
		<category><![CDATA[OPNsense]]></category>
		<category><![CDATA[self-hosted]]></category>
		<category><![CDATA[WireGuard]]></category>
		<guid isPermaLink="false">https://bizr.net/?p=2208</guid>

					<description><![CDATA[fail2ban catches the bad guys, but if it's only banning them at the backend, every attacker still gets a free trip through OPNsense and HAProxy before being told no. Here's how to close that gap: a script that exports every jail's banned IPs into a flat file, a tightly locked-down Caddy route to serve it, and an OPNsense URL Table alias that turns it into a live WAN block rule — so the next retry never makes it past the front gate.]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph"><em>art of the ongoing series on hardening the DMZ stack. This one assumes you already have fail2ban watching your app logs and banning offenders locally — the question this post answers is: why stop at the app server when you could stop them at the front gate?</em></p>



<p class="wp-block-paragraph">For a long time, my fail2ban setup did exactly what fail2ban is supposed to do, and I was perfectly happy with it. Watch the logs, count the failures, ban the IP at the host firewall level via nftables. WordPress brute-forcers, badbots scraping for <code>wp-login.php</code>, the usual cast of characters — all of them got banned, all on the backend server itself.</p>



<p class="wp-block-paragraph">The problem with that picture only became obvious once I actually thought about where the ban was happening. My backend, the box running Apache and ISPConfig and all the actual application logic, sits behind <a href="https://bizr.net/beyond-the-tunnel-adding-haproxy-acme-and-vlan-isolation-behind-my-opnsense-wireguard-setup/">OPNsense and HAProxy</a>, with <a href="https://bizr.net/bypassing-cgnat-with-an-opnsense-wireguard-tunnel/">a WireGuard tunnel</a> carrying the traffic between the two boxes in the first place. Every single request that gets banned by fail2ban has already traveled all the way from the internet, through OPNsense, through HAProxy, before fail2ban even gets a chance to say no. The ban was real, but it was the last possible line of defense, not the first. Every blocked request still cost me a hop through the entire stack before being rejected.</p>



<p class="wp-block-paragraph">That&#8217;s the itch this post scratches: take the bans fail2ban is already correctly deciding on, and push them out to the actual edge — OPNsense itself — so the next attempt from that IP never even reaches HAProxy, let alone the backend. The attacker gets stopped at the door instead of in the living room.</p>



<h2 class="wp-block-heading">Why this isn&#8217;t a five-minute job</h2>



<p class="wp-block-paragraph">The annoying part of this problem is that fail2ban and OPNsense don&#8217;t speak the same language. fail2ban lives on a Linux box and bans IPs using nftables or iptables rules, scoped entirely to that machine. OPNsense is a separate firewall appliance running pf, with its own rule engine and its own way of thinking about IP lists, called aliases. There&#8217;s no built-in bridge between the two. fail2ban has no idea OPNsense exists, and OPNsense has no idea fail2ban exists. If you want one to inform the other, you have to build that bridge yourself.</p>



<p class="wp-block-paragraph">OPNsense, fortunately, gives you a reasonable on-ramp: a <strong>URL Table (IPs)</strong> alias type, found under <strong>Firewall → Aliases</strong>. Instead of manually typing a static list of IPs into an alias, you point this alias type at a URL, and OPNsense periodically fetches that URL, expecting a plain text file with one IP per line, and uses the contents as a dynamically updating block list. That&#8217;s the missing piece — if I can get fail2ban to maintain a text file with all its currently-banned IPs, and serve that file somewhere OPNsense can reach, the rest is just OPNsense doing what it already knows how to do.</p>



<p class="wp-block-paragraph">So the pipeline has three parts: a script that asks fail2ban &#8220;who&#8217;s currently banned, across every jail you&#8217;re running&#8221; and writes that to a file; a way to serve that file so OPNsense can fetch it; and an OPNsense-side alias and firewall rule that actually uses the list to block traffic at the WAN edge.</p>



<h2 class="wp-block-heading">Part one: asking fail2ban what it knows</h2>



<p class="wp-block-paragraph">fail2ban already tracks every jail&#8217;s banned IPs internally — you can query any individual jail with <code>fail2ban-client status &lt;jailname&gt;</code>, and it&#8217;ll show you a <code>Banned IP list</code> line. The annoying bit is that if you&#8217;re running several jails (and most reasonably hardened setups are — separate jails for WordPress login attempts, badbots, 404 floods, and so on), you need to loop over all of them and merge the results, deduplicated, into one list.</p>



<p class="wp-block-paragraph">bash</p>



<pre class="wp-block-code"><code><strong>#!/bin/bash</strong>
OUTPUT_FILE="/home/user/dmz-stack/caddy/banlist/banned.txt"
TMP_FILE="$(mktemp)"

for jail in $(fail2ban-client status | grep "Jail list" | sed 's/^.*://;s/,//g'); do
    fail2ban-client status "$jail" | grep "Banned IP list" | sed 's/^.*://' | tr ' ' '\n'
done | grep -v '^$' | sort -u &gt; "$TMP_FILE"

mv "$TMP_FILE" "$OUTPUT_FILE"
chmod 644 "$OUTPUT_FILE"</code></pre>



<p class="wp-block-paragraph">This is deliberately unfancy. <code>fail2ban-client status</code> with no jail name lists every active jail; the loop asks each one for its banned IPs, strips the label text fail2ban prepends, and splits the space-separated list onto individual lines. Sorting and deduplicating means it doesn&#8217;t matter if the same IP shows up in two jails — it lands in the output file exactly once. The <code>mktemp</code>-then-<code>mv</code> pattern matters more than it looks like it should: writing directly to the final file risks OPNsense fetching a half-written file mid-update, while writing to a temp file and atomically moving it into place guarantees whoever reads <code>banned.txt</code> always sees a complete, consistent snapshot.</p>



<p class="wp-block-paragraph">Drop this in <code>/usr/local/bin/export-fail2ban-bans.sh</code>, make it executable, and you&#8217;ve got the first link in the chain. Run it manually once and <code>cat</code> the output file to sanity-check it&#8217;s actually populated with real-looking IPs before moving on.</p>



<h2 class="wp-block-heading">Part two: serving the file without opening a new can of worms</h2>



<p class="wp-block-paragraph">Now OPNsense needs to fetch this file over HTTP. The lazy option is to drop it somewhere your existing web server already serves — but I didn&#8217;t want a list of banned IPs sitting in a publicly browsable directory, even an obscure one, so I gave it its own dedicated, narrowly scoped route instead.</p>



<p class="wp-block-paragraph">Since I already run Caddy as my reverse proxy in front of everything else, adding one more tightly restricted route was the path of least resistance:</p>



<p class="wp-block-paragraph">caddyfile</p>



<pre class="wp-block-code"><code>:8090 {
  @opnsense remote_ip 172.18.32.1
  handle @opnsense {
    root * /srv/banlist
    file_server
  }
  handle {
    abort
  }
}</code></pre>



<p class="wp-block-paragraph">That <code>remote_ip</code> matcher is doing the actual security work here — only requests originating from OPNsense&#8217;s own WireGuard IP get served anything; everyone else hits <code>abort</code>, which just drops the connection outright rather than politely saying &#8220;no&#8221; with an HTTP error (politely saying no still tells a port-scanner something is listening). Putting this on its own port, <code>8090</code>, separate from your normal <code>80</code>/<code>443</code> traffic, also means a casual look at access logs for your real sites never gets cluttered with OPNsense&#8217;s periodic polling requests. Mount the directory containing <code>banned.txt</code> into the container, expose the port in your compose file, and this half is done.</p>



<h2 class="wp-block-heading">Part three: getting OPNsense to actually act on it</h2>



<p class="wp-block-paragraph">Inside OPNsense&#8217;s web UI, go to <strong>Firewall → Aliases</strong> and create a new alias. Set the type to <strong>URL Table (IPs)</strong>, point the content field at the internal address of wherever you&#8217;re serving the file from — in my case <code>http://172.18.32.11:8090/banned.txt</code>, reachable over WireGuard — and set a refresh frequency. I run mine on a 5-minute cadence, which, combined with fail2ban&#8217;s own cron job re-running the export script every five minutes too, means worst-case latency between a ban happening and OPNsense enforcing it at the edge is comfortably under ten minutes. That&#8217;s not instant, but it&#8217;s a different category of protection than &#8220;eventually gets blocked after wasting a hop through my whole stack every single time.&#8221;</p>



<p class="wp-block-paragraph">Once the alias exists and OPNsense has successfully fetched it at least once (you can confirm this under the alias&#8217;s own details view, which shows the current resolved IP count), the last step is actually using it. Go to <strong>Firewall → Rules → WAN</strong>, add a new rule with action <strong>Block</strong>, source set to your new alias, and — this part actually matters — drag the rule to the very top of the WAN rule list. Firewall rules are evaluated top-to-bottom, first match wins, and if some broader &#8220;allow&#8221; rule sits above your block rule, the block never gets a chance to fire.</p>



<h2 class="wp-block-heading">Testing it without waiting for a real attacker</h2>



<p class="wp-block-paragraph">I didn&#8217;t want to wait around for an actual bot to get banned just to confirm the whole pipeline worked end to end, so I manually banned a couple of made-up test IPs directly through fail2ban (<code>fail2ban-client set &lt;jail&gt; banip 203.0.113.99</code>), ran the export script by hand, and watched the alias refresh in the OPNsense UI to confirm the count ticked up and the test IP appeared in the resolved list. Unbanning the same way and re-running the export confirmed it cleared correctly too. This kind of synthetic round-trip test is worth doing deliberately rather than trusting the pipeline blind — it&#8217;s much easier to debug a test IP that you know should or shouldn&#8217;t be there than to debug a real attacker&#8217;s IP weeks later while also wondering whether the pipeline ever worked at all.</p>



<h2 class="wp-block-heading">What this actually buys you</h2>



<p class="wp-block-paragraph">It&#8217;s tempting to think of this as a marginal optimization — fail2ban was already blocking these IPs, so who cares if the block happens one hop earlier? But the difference compounds in ways that aren&#8217;t obvious until you&#8217;ve lived with it. Every blocked request that used to traverse OPNsense, HAProxy, and reach the application layer before being rejected was still consuming connection slots, CPU cycles, and log lines at every layer along the way. Pushing the block to the WAN edge means a banned attacker&#8217;s retry traffic gets silently dropped before it ever touches HAProxy&#8217;s connection table, which matters more than you&#8217;d expect once you&#8217;re also running Suricata and CrowdSec at that same edge — fewer packets reaching the inspection layers means cleaner signal in those tools too, since they&#8217;re not wading through traffic that&#8217;s already a known, settled case. <a href="https://bizr.net/hardening-your-homelabs-public-edge-geoip-blocking-crowdsec-and-syncing-fail2ban-with-opnsense/">Hardening Your Homelab&#8217;s Public Edge</a> covers that GeoIP/CrowdSec layer in full — worth noting that post describes a <em>different</em> fail2ban-to-OPNsense sync method (a direct API push from fail2ban itself) than the pull-based, flat-file approach built here, so treat them as two alternative implementations rather than the same pipeline described twice. </p>



<p class="wp-block-paragraph">There&#8217;s also a quieter benefit: once this pipeline exists, it becomes the natural place to plug in anything else that produces a list of bad IPs. CrowdSec&#8217;s own community blocklist already feeds OPNsense through a similar mechanism; Suricata&#8217;s alert-driven blocks could, in principle, feed the same kind of alias. The pattern — local detection, exported as a flat list, consumed by the edge firewall as a dynamic alias — turns out to be a reusable shape for tying together security tools that were never designed to talk to each other directly. fail2ban doesn&#8217;t need to know OPNsense exists, and OPNsense doesn&#8217;t need to know fail2ban exists. They just both need to agree on the shape of a text file, and that turns out to be enough.</p>



<p class="wp-block-paragraph">This same &#8220;trust based on network origin, not credentials&#8221; idea reappears in two later posts: the <a href="https://bizr.net/ditch-google-self-hosting-searxng-behind-authelia-for-desktop-and-open-webui/">network-scoped Authelia bypass</a> for Open WebUI&#8217;s search calls, and the <a href="https://bizr.net/never-lock-yourself-out-building-a-self-updating-anti-lockout-rule-for-opnsense/">self-updating anti-lockout alias</a> for remote firewall management — both are the same alias-driven, dynamically-resolved trust pattern used here, just applied to allowing traffic instead of blocking it.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://bizr.net/from-app-logs-to-firewall-blocks-building-a-fail2ban-to-opnsense-ban-pipeline/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>One Login to Rule Them All: Wiring Authelia as SSO for Open WebUI and Nextcloud</title>
		<link>https://bizr.net/one-login-to-rule-them-all-wiring-authelia-as-sso-for-open-webui-and-nextcloud/</link>
					<comments>https://bizr.net/one-login-to-rule-them-all-wiring-authelia-as-sso-for-open-webui-and-nextcloud/#respond</comments>
		
		<dc:creator><![CDATA[Bizr]]></dc:creator>
		<pubDate>Thu, 18 Jun 2026 11:30:17 +0000</pubDate>
				<category><![CDATA[Security]]></category>
		<category><![CDATA[authelia]]></category>
		<category><![CDATA[caddy]]></category>
		<category><![CDATA[dmz-stack]]></category>
		<category><![CDATA[forward-auth]]></category>
		<category><![CDATA[Homelab]]></category>
		<category><![CDATA[identity-management]]></category>
		<category><![CDATA[nextcloud]]></category>
		<category><![CDATA[oidc]]></category>
		<category><![CDATA[open-webui]]></category>
		<category><![CDATA[reverse-proxy]]></category>
		<category><![CDATA[self-hosted]]></category>
		<category><![CDATA[sso]]></category>
		<guid isPermaLink="false">https://bizr.net/?p=2204</guid>

					<description><![CDATA[Authelia gives you one login screen, but underneath it you're actually running two completely different integration patterns. Open WebUI never knows Authelia exists — Caddy vets every request at the edge and hands the app a verdict. Nextcloud's user_oidc plugin talks to Authelia directly, parsing signed tokens and making its own decisions about identity. Same login box, two very different trust relationships — and the difference matters more than it looks like it should.]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Here&#8217;s the thing nobody tells you when you start adding Authelia to a homelab: &#8220;SSO&#8221; is not one integration pattern. It&#8217;s two, and they don&#8217;t look anything alike under the hood, even though from the browser they feel identical — type your username, tap your TOTP code, land on the app. Same login screen, same muscle memory, same warm feeling of having Solved Authentication Forever. (If you haven&#8217;t actually turned on real TOTP yet and are still running password-only, <a href="https://bizr.net/locking-down-authelia-totp-real-smtp-and-the-hostname-vs-ip-tls-trap/">Locking Down Authelia</a></p>



<p class="wp-block-paragraph">Under the hood, one of my apps has no idea Authelia exists. The other one talks to it directly, as a first-class citizen, using a protocol with its own handshake, its own tokens, its own opinions about what a &#8220;user&#8221; even is. Get this distinction wrong — or worse, not realize it&#8217;s a distinction at all — and you&#8217;ll spend an evening debugging a problem that looks like a bug but is actually a category error. I know this because I did exactly that a few weeks later, but that&#8217;s a story for a different post.</p>



<p class="wp-block-paragraph">For today: how do you actually wire Authelia in front of Open WebUI and Nextcloud, and why are these two setups fundamentally different shapes, not just different flavors of the same thing?</p>



<h2 class="wp-block-heading">The app that doesn&#8217;t know it&#8217;s protected</h2>



<p class="wp-block-paragraph">Open WebUI, like a lot of self-hosted tools, was never built with an external identity provider in mind. It has its own login form, its own user table, its own idea of &#8220;logged in.&#8221; If I want Authelia sitting in front of it, I&#8217;m not integrating with Open WebUI&#8217;s authentication — I&#8217;m overriding it from the outside, before any of its own login logic ever runs.</p>



<p class="wp-block-paragraph">This is forward-auth, and the mental model is simpler than the name suggests: Caddy intercepts every request to <code>openwebui.yourdomain.com</code> and, before it reverse-proxies anything to Open WebUI, makes a side-channel call to Authelia asking one question — &#8220;is this person allowed in?&#8221; If Authelia says yes, Caddy lets the request through to Open WebUI exactly as if nothing happened. If Authelia says no, the visitor gets redirected to a login page, and Open WebUI never even sees the request.</p>



<p class="wp-block-paragraph">The forward-auth block in my Caddyfile looks roughly like this:</p>



<p class="wp-block-paragraph">caddyfile</p>



<pre class="wp-block-code"><code>(forward_auth_block) {
  forward_auth authelia:9091 {
    uri /api/verify?rd=https://authelia.yourdomain.com
    copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
  }
}

openwebui.yourdomain.com:80 {
  import forward_auth_block
  reverse_proxy openwebui:8080
}</code></pre>



<p class="wp-block-paragraph">Two things matter here that are easy to skim past. First, <code>copy_headers</code> — once Authelia approves the request, it hands back a set of headers identifying who just logged in, and Caddy stitches those onto the request before it reaches Open WebUI. Second, and this is the part that actually matters for day-to-day reliability: Open WebUI still has no idea any of this happened. It sees a request arrive, optionally with some <code>Remote-User</code>-style headers it may or may not even read, and that&#8217;s it. As far as Open WebUI&#8217;s own code is concerned, Authelia doesn&#8217;t exist.</p>



<p class="wp-block-paragraph">That&#8217;s the whole trick of forward-auth. It&#8217;s a bouncer standing in front of a door, not a doorman the building hired. The building&#8217;s own systems are completely unaware there&#8217;s anyone checking IDs outside. This is also exactly why it works for literally any app — Open WebUI, a dashboard, an internal wiki, whatever — regardless of whether that app has ever heard of OIDC, SAML, or any authentication standard at all. If it speaks HTTP, you can put a forward-auth gate in front of it. (This is the exact same <code>forward_auth_block</code> reused later, almost verbatim, to gate <a href="https://bizr.net/ditch-google-self-hosting-searxng-behind-authelia-for-desktop-and-open-webui/">a self-hosted SearXNG instance</a> behind Authelia too.)</p>



<p class="wp-block-paragraph">The tradeoff is just as structural. Because the protected app doesn&#8217;t know about Authelia, it also doesn&#8217;t <em>really</em> know who&#8217;s logged in beyond whatever you choose to pass through those headers. There&#8217;s no rich user object, no group claims baked into a token the app can inspect on its own, no session the app controls. Everything the app &#8220;knows&#8221; about the user is whatever you handed it in a header, and if that header mapping is wrong or missing, the app just falls back to whatever its own default behavior is — which, for Open WebUI, meant new accounts landing in a &#8220;pending&#8221; status that I had to manually promote the first time around. The app isn&#8217;t broken; it&#8217;s just genuinely in the dark about the bigger picture.</p>



<h2 class="wp-block-heading">The app that wants to know you personally</h2>



<p class="wp-block-paragraph">Nextcloud is a different animal entirely, because <code>user_oidc</code> isn&#8217;t a gate in front of Nextcloud — it&#8217;s a plugin running <em>inside</em> Nextcloud that speaks OpenID Connect directly to Authelia. There&#8217;s no bouncer here. Nextcloud itself walks up to Authelia, introduces itself with a client ID and secret, and asks for a proper, structured answer: who is this person, what&#8217;s their email, what groups are they in, and here&#8217;s a signed token to prove you&#8217;re not lying to me.</p>



<p class="wp-block-paragraph">This is OIDC done the way the spec actually intends it, and it shows in how much more Nextcloud knows once the dance is over. Setting it up means configuring an actual client relationship between the two systems, not just a header-passing convention:</p>



<p class="wp-block-paragraph">bash</p>



<pre class="wp-block-code"><code>docker exec -u www-data nextcloud php occ user_oidc:provider authelia \
  --clientid="nextcloud" \
  --clientsecret="..." \
  --discoveryuri="https://authelia.yourdomain.com/.well-known/openid-configuration"</code></pre>



<p class="wp-block-paragraph">That discovery URI is doing a lot of quiet work. Nextcloud fetches it once, learns where Authelia&#8217;s authorization endpoint, token endpoint, and signing keys all live, and from then on the two systems are talking a real protocol with real cryptographic guarantees — not just trusting whatever headers happen to show up. When I log in through Nextcloud&#8217;s web UI, I&#8217;m redirected to Authelia, I authenticate there (TOTP and all), and Authelia hands back a signed JWT containing claims: <code>email</code>, <code>groups</code>, <code>name</code>, <code>sub</code>. Nextcloud reads that token directly and uses it to provision or update my account.</p>



<p class="wp-block-paragraph">This is the crucial difference. Open WebUI never sees a token — it sees headers Caddy chose to forward. Nextcloud sees an actual cryptographically signed identity document and parses it itself. That gives Nextcloud a much richer, more reliable picture of who&#8217;s logging in — group membership for access control, email for notifications, display name for the UI — all sourced directly from the identity provider rather than relayed secondhand through a proxy&#8217;s header rewriting.</p>



<p class="wp-block-paragraph">It also means Nextcloud has opinions about <em>how</em> it builds a user from that token, and those opinions matter more than they look like they should. By default, <code>user_oidc</code> uses the <code>sub</code> claim — a stable, unique identifier Authelia generates — as the internal Nextcloud account ID. That&#8217;s the technically correct choice for uniqueness, and it&#8217;s also a UUID, not anything resembling a username. I didn&#8217;t think about this at all when I set it up, because why would I — the login worked, I could see my files, case closed.</p>



<p class="wp-block-paragraph">It came back to bite me weeks later in a completely different context (ask me about the desktop sync client some other time), but the shape of the problem is worth flagging here: with forward-auth, the protected app never had to make a decision about identity at all, because it was never given the opportunity. With native OIDC, the protected app is making real decisions — how to map claims to accounts, what becomes the username, whether to provision automatically or require a pre-existing account — and every one of those decisions is a place a default can surprise you.</p>



<h2 class="wp-block-heading">Same login screen, two different trust relationships</h2>



<p class="wp-block-paragraph">Step back far enough and the distinction is really about where the trust boundary sits. With forward-auth, the trust boundary is at the edge — Caddy and Authelia have a private conversation, and the app behind them is simply handed a verdict. The app trusts Caddy to have done its job; it has no independent way to verify anything, and it doesn&#8217;t need one, because it was never designed to.</p>



<p class="wp-block-paragraph">With native OIDC, the trust boundary moves inside the app itself. Nextcloud doesn&#8217;t trust Caddy&#8217;s opinion about who&#8217;s logged in — Caddy, in fact, is barely involved in the authentication conversation at all beyond passing bytes back and forth. Nextcloud independently verifies a cryptographic token against keys it fetched from Authelia&#8217;s discovery document. It&#8217;s a direct relationship between the two systems, with the reverse proxy reduced to plumbing.</p>



<p class="wp-block-paragraph">Neither pattern is strictly better — they solve different problems. Forward-auth is the right tool when you want to blanket-protect something that was never built with SSO in mind, and you&#8217;re willing to accept that the protected app will only ever know what you choose to tell it. Native OIDC is the right tool when the app actually needs to reason about identity — assign permissions by group, send mail to the right address, distinguish &#8220;logged in as Marco&#8221; from &#8220;logged in as some anonymous header value&#8221; — and you&#8217;re willing to take on the deeper integration work and the protocol-level decisions that come with it.</p>



<p class="wp-block-paragraph">What I&#8217;d tell past-me, a few weeks before the desktop sync client incident: when you wire up native OIDC, go look at exactly what claim becomes the account ID, on purpose, before you start using the account for anything real. It&#8217;s a five-minute check during setup. It&#8217;s a multi-hour debugging session if you skip it and only find out later, indirectly, through a symptom that looks like it&#8217;s about something else entirely.</p>



<p class="wp-block-paragraph">Same login screen. Two completely different conversations happening behind it. Worth knowing which one you&#8217;re having before you need to debug it.</p>



<p class="wp-block-paragraph">This same forward-auth-versus-native-protocol distinction is worth keeping in mind for <a href="https://bizr.net/ditch-google-self-hosting-searxng-behind-authelia-for-desktop-and-open-webui/">the network-scoped Authelia bypass</a> too — Open WebUI&#8217;s web-search calls to SearXNG hit an API directly, with no forward-auth bouncer able to intercept them the normal way, which is exactly why that post needed a different kind of trust rule instead.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://bizr.net/one-login-to-rule-them-all-wiring-authelia-as-sso-for-open-webui-and-nextcloud/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Hardening Your Homelab&#8217;s Public Edge: GeoIP Blocking, CrowdSec, and Syncing Fail2Ban with OPNsense</title>
		<link>https://bizr.net/hardening-your-homelabs-public-edge-geoip-blocking-crowdsec-and-syncing-fail2ban-with-opnsense/</link>
					<comments>https://bizr.net/hardening-your-homelabs-public-edge-geoip-blocking-crowdsec-and-syncing-fail2ban-with-opnsense/#respond</comments>
		
		<dc:creator><![CDATA[Bizr]]></dc:creator>
		<pubDate>Mon, 25 May 2026 22:32:44 +0000</pubDate>
				<category><![CDATA[Security]]></category>
		<category><![CDATA[CrowdSec]]></category>
		<category><![CDATA[Defense in Depth]]></category>
		<category><![CDATA[Fail2Ban]]></category>
		<category><![CDATA[Firewall]]></category>
		<category><![CDATA[GeoIP]]></category>
		<category><![CDATA[HAProxy]]></category>
		<category><![CDATA[Network Security]]></category>
		<category><![CDATA[OPNsense]]></category>
		<category><![CDATA[Self-Hosting]]></category>
		<category><![CDATA[Threat Intelligence]]></category>
		<guid isPermaLink="false">https://bizr.net/?p=2149</guid>

					<description><![CDATA[Exposing services to the internet means accepting a constant flood of automated attacks. Here's how I cut that noise dramatically with GeoIP blocking, CrowdSec, and a Fail2Ban-to-OPNsense sync that turns one webserver's detection into a network-wide block.]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Part of the ongoing series on running a self-hosted edge behind a CGNAT-bypassing WireGuard tunnel. If you haven&#8217;t read the <a href="https://bizr.net/bypassing-cgnat-with-an-opnsense-wireguard-tunnel/">WireGuard tunnel post</a> or the <a href="https://bizr.net/beyond-the-tunnel-adding-haproxy-acme-and-vlan-isolation-behind-my-opnsense-wireguard-setup/">HAProxy follow-up</a>, start there — this post builds on that architecture.</p>



<p class="wp-block-paragraph">Once you have public services running behind OPNsense and HAProxy, you quickly notice something: <strong>the internet is noisy.</strong> Within hours of exposing port 443, the logs start filling with probes for <code>/wp-login.php</code>, <code>/.env</code>, random PHP shells, and credential stuffing attempts against anything that looks like a login page. Most of it is automated. Almost none of it is your real users.</p>



<p class="wp-block-paragraph">This post is about the three layers I use to keep that noise out — and to make sure that when something <em>does</em> slip through, it doesn&#8217;t get a second try:</p>



<ol class="wp-block-list">
<li><strong>GeoIP blocking at the firewall</strong>, to drop traffic from regions I have no business serving.</li>



<li><strong>CrowdSec</strong>, to share threat intelligence and block known bad actors automatically.</li>



<li><strong>Syncing Fail2Ban logs from my webservers back to OPNsense</strong>, so that an attack on a single host turns into a network-wide block.</li>
</ol>



<p class="wp-block-paragraph">Each layer is simple on its own. Together, they cut the attack surface dramatically.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">The Mental Model</h3>



<p class="wp-block-paragraph">Before the config, the philosophy:</p>



<ul class="wp-block-list">
<li><strong>The firewall should do as much filtering as possible, as early as possible.</strong> A packet that&#8217;s dropped at OPNsense never reaches HAProxy, never reaches the webserver, and never burns CPU on your application.</li>



<li><strong>The webserver knows things the firewall doesn&#8217;t.</strong> It sees failed logins, application-level abuse, and patterns the firewall can&#8217;t infer from packet headers alone. That knowledge needs to flow <em>back</em> to the firewall.</li>



<li><strong>Reputation beats reaction.</strong> Blocking an attacker after they&#8217;ve hit you is fine. Blocking them before they&#8217;ve hit you — because someone else already reported them — is better.</li>
</ul>



<p class="wp-block-paragraph">GeoIP handles bulk noise. CrowdSec handles shared reputation. Fail2Ban-to-OPNsense sync handles what only your own services can see. The three together form a feedback loop.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">Layer 1: GeoIP Blocking in OPNsense</h3>



<p class="wp-block-paragraph">GeoIP isn&#8217;t a security solution by itself — a determined attacker will use a VPN or a cloud instance in a country you allow. But it&#8217;s an extraordinarily cheap way to drop the <em>volume</em> of automated attacks, which mostly originate from a predictable handful of regions.</p>



<h4 class="wp-block-heading">Setup</h4>



<p class="wp-block-paragraph">OPNsense uses <strong>MaxMind&#8217;s GeoLite2</strong> database via aliases. The high-level steps:</p>



<ol class="wp-block-list">
<li><strong>Get a free MaxMind license key.</strong> Register at maxmind.com and generate a license key under your account.</li>



<li><strong>Configure the GeoIP source in OPNsense.</strong> Under <em>Firewall → Aliases → GeoIP settings</em>, paste the MaxMind download URL with your license key. OPNsense will pull the database on a schedule.</li>



<li><strong>Create GeoIP aliases.</strong> Under <em>Firewall → Aliases</em>, create a new alias of type <strong>GeoIP</strong>, and pick the countries you want to group together. I keep two aliases:
<ul class="wp-block-list">
<li><code>GeoIP_Allow</code> — countries where my actual users live.</li>



<li><code>GeoIP_HighRisk</code> — countries that show up disproportionately in my logs as sources of automated attacks.</li>
</ul>
</li>



<li><strong>Apply the alias in your WAN-facing rules.</strong> On the firewall rule that allows inbound traffic to HAProxy (port 443 from the WireGuard tunnel), add a <strong>block rule above it</strong> that drops traffic from <code>GeoIP_HighRisk</code>. Or, if you want to be stricter, use a default-deny model: only allow traffic from <code>GeoIP_Allow</code>, and let everything else fall to a deny rule.</li>
</ol>



<h4 class="wp-block-heading">A Word of Caution</h4>



<p class="wp-block-paragraph">GeoIP blocking is a blunt tool. Two things to watch for:</p>



<ul class="wp-block-list">
<li><strong>Legitimate users on VPNs or mobile carriers</strong> sometimes appear from unexpected countries. If you serve a global audience, default-deny will hurt you. Default-allow with a high-risk blocklist is usually the better compromise.</li>



<li><strong>CDN and monitoring traffic</strong> (uptime checks, search engine crawlers) may originate from regions you&#8217;d otherwise block. Whitelist their IP ranges explicitly <em>above</em> the GeoIP rule.</li>
</ul>



<p class="wp-block-paragraph">I treat GeoIP as <strong>layer one of noise reduction</strong>, not as security. It&#8217;s the bouncer who turns away the obvious troublemakers before they get to the door.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">Layer 2: CrowdSec</h3>



<p class="wp-block-paragraph"><a href="https://www.crowdsec.net/" target="_blank" rel="noopener">CrowdSec</a> is the layer I&#8217;m most enthusiastic about. The short pitch: it&#8217;s a modern, open-source alternative to Fail2Ban that not only detects local attacks but also <strong>shares anonymized signals with a community blocklist</strong>. If a thousand other CrowdSec users are being hit by a particular IP, you benefit from that knowledge automatically.</p>



<h4 class="wp-block-heading">Architecture</h4>



<p class="wp-block-paragraph">There are two pieces:</p>



<ul class="wp-block-list">
<li><strong>The CrowdSec agent</strong> — reads logs, detects malicious behavior, and reports it.</li>



<li><strong>The bouncer</strong> — enforces decisions. There&#8217;s an official <strong>OPNsense plugin</strong> that acts as a firewall bouncer, pulling the blocklist and applying it as a pf table.</li>
</ul>



<p class="wp-block-paragraph">You can run the agent on OPNsense itself, on your webservers, or both. I run agents on the webservers (where the interesting logs live) and the bouncer on OPNsense (where blocks are most effective).</p>



<h4 class="wp-block-heading">Setup on OPNsense</h4>



<ol class="wp-block-list">
<li><strong>Install the OPNsense CrowdSec plugin</strong> from <em>System → Firmware → Plugins</em> (<code>os-crowdsec</code>).</li>



<li><strong>Enroll the instance with the CrowdSec Console</strong> (free tier is fine for a homelab). Enrollment lets you manage decisions across multiple agents from one dashboard.</li>



<li><strong>Enable the firewall bouncer.</strong> It will create a pf table that&#8217;s automatically populated with malicious IPs from both your local detections and the community blocklist.</li>



<li><strong>Add a block rule</strong> referencing the CrowdSec table, placed near the top of your WAN/tunnel-facing rules — <em>above</em> GeoIP and any allow rules.</li>
</ol>



<h4 class="wp-block-heading">Setup on the Webservers</h4>



<ol class="wp-block-list">
<li><strong>Install the CrowdSec agent</strong> on each webserver (Debian/Ubuntu packages are straightforward).</li>



<li><strong>Enable the relevant parsers and scenarios</strong> — <code>nginx</code>, <code>apache2</code>, <code>sshd</code>, <code>http-cve</code>, <code>base-http-scenarios</code>, etc. CrowdSec ships with a hub of community-maintained detection rules.</li>



<li><strong>Forward decisions to OPNsense.</strong> There are two ways:
<ul class="wp-block-list">
<li>Run a <strong>local bouncer</strong> on each webserver (e.g., the iptables or nginx bouncer) so blocks happen at the host level too.</li>



<li>More importantly, configure the webserver agent to <strong>share decisions with the central OPNsense agent</strong> via the CrowdSec API. This is where one webserver&#8217;s detection becomes a firewall-level block for everything.</li>
</ul>
</li>
</ol>



<h4 class="wp-block-heading">Why I Like It</h4>



<p class="wp-block-paragraph">CrowdSec gets you three things Fail2Ban alone doesn&#8217;t:</p>



<ul class="wp-block-list">
<li><strong>Community blocklist.</strong> You&#8217;re blocking known-bad IPs before they ever touch your services.</li>



<li><strong>Centralized decisions.</strong> Multiple agents, one source of truth, one bouncer enforcing at the firewall.</li>



<li><strong>Better scenarios.</strong> Detection rules are written and maintained by a community, not by you at 2 a.m. after an incident.</li>
</ul>



<p class="wp-block-paragraph">It doesn&#8217;t replace Fail2Ban entirely in my setup — but it carries most of the load.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">Layer 3: Syncing Fail2Ban Logs from Webservers to OPNsense</h3>



<p class="wp-block-paragraph">Some things Fail2Ban catches that CrowdSec scenarios might not — custom application logs, niche services, anything you&#8217;ve written a bespoke filter for. I still want those bans enforced at the firewall, not just on the host that detected them.</p>



<p class="wp-block-paragraph">The goal: <strong>when Fail2Ban bans an IP on any webserver, that IP gets blocked at OPNsense for everyone.</strong></p>



<p class="wp-block-paragraph">There are a few ways to do this. Here&#8217;s the approach I use, in order of complexity. <em>(Note: this is the API-push method. <a href="https://bizr.net/from-app-logs-to-firewall-blocks-building-a-fail2ban-to-opnsense-ban-pipeline/">From App Logs to Firewall Blocks</a> documents a different, pull-based method for the same goal — exporting bans to a flat file that OPNsense fetches on a timer via a URL Table alias, rather than pushing them via API call on every ban. Worth treating as two alternative approaches rather than the same pipeline twice.)</em> </p>



<h4 class="wp-block-heading">Option A: Fail2Ban → OPNsense Alias via API (Cleanest)</h4>



<p class="wp-block-paragraph">OPNsense exposes a REST API that can manipulate aliases. The flow:</p>



<ol class="wp-block-list">
<li><strong>Create a host-type alias on OPNsense</strong> called something like <code>Fail2Ban_Sync</code>. This alias will hold the IPs to block.</li>



<li><strong>Add a firewall rule</strong> that drops traffic from <code>Fail2Ban_Sync</code>, placed at the top of your WAN/tunnel rules.</li>



<li><strong>On each webserver, configure a Fail2Ban action</strong> that, on ban, calls the OPNsense API to add the IP to the alias, and on unban, removes it.</li>
</ol>



<p class="wp-block-paragraph">The action script lives in <code>/etc/fail2ban/action.d/opnsense-alias.conf</code> and uses <code>curl</code> to hit the OPNsense API. You&#8217;ll need an API key with permission to modify aliases — create a dedicated user with the minimum scope, and store the credentials in a file readable only by root.</p>



<p class="wp-block-paragraph">A simplified outline of the action:</p>



<p class="wp-block-paragraph">ini</p>



<pre class="wp-block-code has-ast-global-color-5-color has-text-color has-link-color wp-elements-125a08921b228109c89eb07636bb598f"><code>&#91;Definition]
actionban   = curl -s -u "$API_KEY:$API_SECRET" \
              -X POST "https://opnsense.lan/api/firewall/alias_util/add/Fail2Ban_Sync" \
              -H "Content-Type: application/json" \
              -d '{"address":"&lt;ip&gt;"}'

actionunban = curl -s -u "$API_KEY:$API_SECRET" \
              -X POST "https://opnsense.lan/api/firewall/alias_util/delete/Fail2Ban_Sync" \
              -H "Content-Type: application/json" \
              -d '{"address":"&lt;ip&gt;"}'</code></pre>



<p class="wp-block-paragraph">Reference that action in your <code>jail.local</code>:</p>



<p class="wp-block-paragraph">ini</p>



<pre class="wp-block-code has-ast-global-color-5-color has-text-color has-link-color wp-elements-0e8f2fdc72c8e718204f4cf6e97d07d7"><code>&#91;nginx-http-auth]
enabled  = true
action   = opnsense-alias
filter   = nginx-http-auth
logpath  = /var/log/nginx/error.log
maxretry = 3
bantime  = 86400</code></pre>



<p class="wp-block-paragraph">Now every ban on the webserver is mirrored to OPNsense within seconds.</p>



<h4 class="wp-block-heading">Option B: Shipping Logs Centrally and Letting CrowdSec Handle It</h4>



<p class="wp-block-paragraph">If you&#8217;re already running CrowdSec, you can also ship webserver logs to a central CrowdSec agent (or use the CrowdSec multi-server setup), and let CrowdSec handle the detection-to-block pipeline. This is cleaner long-term but means moving away from Fail2Ban for those specific filters.</p>



<p class="wp-block-paragraph">I run a hybrid: CrowdSec for the well-known patterns, Fail2Ban-with-API-sync for the bespoke ones I haven&#8217;t ported yet.</p>



<h4 class="wp-block-heading">Option C: Syslog Forwarding</h4>



<p class="wp-block-paragraph">You can also forward Fail2Ban logs to OPNsense via syslog and parse them there. This is more fragile (parsing logs you didn&#8217;t generate is always a tax) and I&#8217;d recommend the API approach instead.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">Putting It Together: Rule Order Matters</h3>



<p class="wp-block-paragraph">The order of firewall rules on the WAN-facing / tunnel-facing interface matters a lot. Mine looks roughly like this, top to bottom:</p>



<ol class="wp-block-list">
<li><strong>Allow</strong> known-good monitoring and CDN IP ranges (explicit whitelist).</li>



<li><strong>Block</strong> CrowdSec table.</li>



<li><strong>Block</strong> Fail2Ban_Sync alias.</li>



<li><strong>Block</strong> GeoIP_HighRisk alias.</li>



<li><strong>Allow</strong> HTTPS to HAProxy from the tunnel.</li>



<li><strong>Default deny</strong> (implicit).</li>
</ol>



<p class="wp-block-paragraph">The principle: <strong>cheap, high-confidence blocks first; expensive or broader rules later.</strong> A packet from a known-bad IP gets dropped before GeoIP even has to look at it.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">Operational Notes</h3>



<p class="wp-block-paragraph">A few things I&#8217;ve learned running this setup:</p>



<ul class="wp-block-list">
<li><strong>Whitelist yourself.</strong> Add your own admin IPs (and your monitoring) to a <code>Whitelist</code> alias that&#8217;s referenced <em>above</em> all block rules. The day you lock yourself out because Fail2Ban banned your home IP is the day you build this safeguard — or better, build it before that day, the way <a href="https://bizr.net/never-lock-yourself-out-building-a-self-updating-anti-lockout-rule-for-opnsense/">Never Lock Yourself Out</a> describes for a dynamic, CGNAT-aware home IP rather than a static one. </li>



<li><strong>Set sensible bantimes.</strong> Permanent bans grow your alias forever and eventually cause performance issues. I use 24 hours for most jails, 7 days for repeat offenders, and a separate manual alias for permanent blocks.</li>



<li><strong>Monitor the size of your aliases.</strong> If your <code>Fail2Ban_Sync</code> alias is growing by thousands of entries a day, something is misconfigured — probably a filter that&#8217;s too aggressive.</li>



<li><strong>Test from outside.</strong> Use a VPN or a phone on cellular to verify that blocks actually work from the public side, not just from your LAN.</li>



<li><strong>Keep an audit trail.</strong> Log every API call to OPNsense from your webservers. If you ever wonder why an IP is blocked, you want to be able to answer that question.</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">The Bigger Picture</h3>



<p class="wp-block-paragraph">What I like about this setup isn&#8217;t any one tool — it&#8217;s that <strong>each layer has a clear job</strong> and the layers compose cleanly:</p>



<ul class="wp-block-list">
<li><strong>GeoIP</strong> removes bulk noise based on geography.</li>



<li><strong>CrowdSec</strong> removes traffic from IPs that other people have already flagged.</li>



<li><strong>Fail2Ban sync</strong> turns local detections into network-wide blocks.</li>



<li><strong>HAProxy</strong> (<a href="https://bizr.net/beyond-the-tunnel-adding-haproxy-acme-and-vlan-isolation-behind-my-opnsense-wireguard-setup/">from the previous post</a>) decides what gets routed where. </li>



<li><strong>VLAN segmentation</strong> (<a href="https://bizr.net/beyond-the-tunnel-adding-haproxy-acme-and-vlan-isolation-behind-my-opnsense-wireguard-setup/">also from the previous post</a>) ensures that even successful intrusions have a tiny blast radius. </li>
</ul>



<p class="wp-block-paragraph">No single layer is doing all the work. If one fails or is misconfigured, the others still hold. That&#8217;s the part worth taking away — not the specific commands, but the layered, composable design. It&#8217;s the same principle that applies to almost any system worth running: small, focused pieces that each do their job well, and that fail in ways the rest of the system can absorb.</p>



<p class="wp-block-paragraph">The same composable, defense-in-depth instinct shows up again in <a href="https://bizr.net/locking-down-authelia-totp-real-smtp-and-the-hostname-vs-ip-tls-trap/">Locking Down Authelia</a> — never trusting that one layer rendering correctly (a login page) means the whole chain behind it (2FA, real SMTP delivery, TLS validation) actually works. </p>



<p class="wp-block-paragraph">If you&#8217;re running a similar setup or solving the same problem differently, drop a note. The best part of the homelab community is comparing notes — and the best part of layered security is that everyone&#8217;s stack teaches you something new.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>
]]></content:encoded>
					
					<wfw:commentRss>https://bizr.net/hardening-your-homelabs-public-edge-geoip-blocking-crowdsec-and-syncing-fail2ban-with-opnsense/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
	</channel>
</rss>
