<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Dns on inetshell</title><link>https://inet.sh/en/tags/dns/</link><description>Recent content in Dns on inetshell</description><generator>Hugo</generator><language>en</language><lastBuildDate>Sat, 26 Sep 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://inet.sh/en/tags/dns/index.xml" rel="self" type="application/rss+xml"/><item><title>acme-dns: DNS-01 without handing over the key to the whole zone (CNAME delegation of _acme-challenge) with certbot and cert-manager</title><link>https://inet.sh/en/posts/dns/acme-dns-dns-01-cname-delegation/</link><pubDate>Sat, 26 Sep 2026 00:00:00 +0000</pubDate><guid>https://inet.sh/en/posts/dns/acme-dns-dns-01-cname-delegation/</guid><description>A wildcard certificate needs the DNS-01 challenge, and that usually means storing on the server an API token with power over the whole DNS zone. acme-dns is a minimal DNS server that can only answer TXT records under its own subdomain: you delegate _acme-challenge.your-domain with a CNAME and every client gets credentials that only work for its own record. Server install, registration with allowfrom, certbot&amp;#39;s acme-dns-auth.py hook (first run with --debug-challenges), cert-manager&amp;#39;s acmeDNS solver with its JSON Secret, and the TTL and renewal traps.</description><content:encoded><![CDATA[<h2 id="tldr">TL;DR</h2>
<ol>
<li>
<p><strong>Delegate a small zone</strong> to the machine that will run acme-dns (records in your real DNS):</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">auth.example.com.   NS   acme.example.com.
</span></span><span class="line"><span class="cl">acme.example.com.   A    203.0.113.10
</span></span></code></pre></td></tr></table>
</div>
</div></li>
<li>
<p><strong>acme-dns</strong> on that machine (<code>/etc/acme-dns/config.cfg</code>), DNS on 53 and the API on 443:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span><span class="lnt">11
</span><span class="lnt">12
</span><span class="lnt">13
</span><span class="lnt">14
</span><span class="lnt">15
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="p">[</span><span class="nx">general</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="nx">listen</span> <span class="p">=</span> <span class="s2">&#34;0.0.0.0:53&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nx">protocol</span> <span class="p">=</span> <span class="s2">&#34;both&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nx">domain</span> <span class="p">=</span> <span class="s2">&#34;auth.example.com&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nx">nsname</span> <span class="p">=</span> <span class="s2">&#34;acme.example.com&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nx">nsadmin</span> <span class="p">=</span> <span class="s2">&#34;hostmaster.example.com&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nx">records</span> <span class="p">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;auth.example.com. A 203.0.113.10&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;auth.example.com. NS acme.example.com.&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="nx">api</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="nx">ip</span> <span class="p">=</span> <span class="s2">&#34;0.0.0.0&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nx">port</span> <span class="p">=</span> <span class="s2">&#34;443&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nx">tls</span> <span class="p">=</span> <span class="s2">&#34;letsencrypt&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nx">disable_registration</span> <span class="p">=</span> <span class="kc">false</span>
</span></span></code></pre></td></tr></table>
</div>
</div></li>
<li>
<p><strong>Register</strong> a client and create the CNAME:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">curl -s -X POST https://acme.example.com/register -H <span class="s1">&#39;Content-Type: application/json&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  -d <span class="s1">&#39;{&#34;allowfrom&#34;: [&#34;203.0.113.0/24&#34;]}&#39;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># {&#34;username&#34;:&#34;eabcdb41-…&#34;,&#34;password&#34;:&#34;pbAX…&#34;,&#34;fulldomain&#34;:&#34;d420c923-….auth.example.com&#34;,&#34;subdomain&#34;:&#34;d420c923-…&#34;,&#34;allowfrom&#34;:[&#34;203.0.113.0/24&#34;]}</span>
</span></span></code></pre></td></tr></table>
</div>
</div><div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">_acme-challenge.example.com.   CNAME   d420c923-….auth.example.com.
</span></span></code></pre></td></tr></table>
</div>
</div></li>
<li>
<p><strong>certbot</strong> with the hook:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span><span class="lnt">5
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">curl -fsSL -o /etc/letsencrypt/acme-dns-auth.py https://raw.githubusercontent.com/joohoi/acme-dns-certbot-joohoi/master/acme-dns-auth.py
</span></span><span class="line"><span class="cl">chmod +x /etc/letsencrypt/acme-dns-auth.py
</span></span><span class="line"><span class="cl">sed -i <span class="s1">&#39;s|^#!/usr/bin/env python$|#!/usr/bin/env python3|; s|^ACMEDNS_URL = .*|ACMEDNS_URL = &#34;https://acme.example.com&#34;|&#39;</span> /etc/letsencrypt/acme-dns-auth.py
</span></span><span class="line"><span class="cl">certbot certonly --manual --manual-auth-hook /etc/letsencrypt/acme-dns-auth.py <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  --preferred-challenges dns --debug-challenges -d <span class="s1">&#39;*.example.com&#39;</span> -d example.com
</span></span></code></pre></td></tr></table>
</div>
</div><p>The first time, the hook registers itself, stores the credentials in <code>/etc/letsencrypt/acmedns.json</code> and <strong>pauses</strong> so you can create the CNAME it prints; Enter, and it validates. Renewals (<code>certbot renew</code>) never ask again.</p>
</li>
</ol>
<h2 id="why">Why</h2>
<p>DNS-01 is the only challenge that gives <strong>wildcards</strong> and the only one that works for machines with no public port 80. The usual price is an API token from your DNS provider on every server that requests certificates; that token can typically change <strong>any record in the zone</strong>, and it sits in plain text under <code>/etc/letsencrypt</code>.</p>
<p>acme-dns turns that around: it is an authoritative DNS server for a zone of its own (<code>auth.example.com</code>) that only serves TXT records under random subdomains, with an API to update them. Since <code>_acme-challenge.example.com</code> is a <strong>CNAME</strong> to one of those subdomains, Let&rsquo;s Encrypt follows the chain and validates the TXT there. Each client&rsquo;s credentials only work for its subdomain; if stolen, the worst case is someone requesting a certificate for that name, not rewriting your zone.</p>
<h2 id="cert-manager">cert-manager</h2>
<p>The same scheme, with the credentials in a Secret:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">kubectl -n cert-manager create secret generic acme-dns --from-file<span class="o">=</span>acmedns.json<span class="o">=</span>./acmedns.json
</span></span></code></pre></td></tr></table>
</div>
</div><div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span><span class="lnt">5
</span><span class="lnt">6
</span><span class="lnt">7
</span><span class="lnt">8
</span><span class="lnt">9
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;example.com&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;username&#34;</span><span class="p">:</span> <span class="s2">&#34;eabcdb41-d89f-4580-826f-3e62e9755ef2&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;password&#34;</span><span class="p">:</span> <span class="s2">&#34;&lt;password from the registration&gt;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;fulldomain&#34;</span><span class="p">:</span> <span class="s2">&#34;d420c923-bbd7-4056-ab64-c3ca54c9b3cf.auth.example.com&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;subdomain&#34;</span><span class="p">:</span> <span class="s2">&#34;d420c923-bbd7-4056-ab64-c3ca54c9b3cf&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;allowfrom&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;203.0.113.0/24&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></td></tr></table>
</div>
</div><div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span><span class="lnt">11
</span><span class="lnt">12
</span><span class="lnt">13
</span><span class="lnt">14
</span><span class="lnt">15
</span><span class="lnt">16
</span><span class="lnt">17
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">cert-manager.io/v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">ClusterIssuer</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">letsencrypt-dns</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">acme</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">server</span><span class="p">:</span><span class="w"> </span><span class="l">https://acme-v02.api.letsencrypt.org/directory</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">email</span><span class="p">:</span><span class="w"> </span><span class="l">admin@example.com</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">privateKeySecretRef</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">letsencrypt-dns-account-key</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">solvers</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">dns01</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">acmeDNS</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">host</span><span class="p">:</span><span class="w"> </span><span class="l">https://acme.example.com</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">accountSecretRef</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">acme-dns</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">key</span><span class="p">:</span><span class="w"> </span><span class="l">acmedns.json</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><p>The JSON key is the certificate&rsquo;s domain; <code>*.example.com</code> and <code>example.com</code> share the same entry (the <code>_acme-challenge.example.com</code> CNAME covers both). Every distinct domain needs its own registration and CNAME.</p>
<h2 id="traps">Traps</h2>
<ul>
<li><strong><code>allowfrom</code> with the IP acme-dns sees</strong>: the public egress IP of the server or the cluster, not the pod&rsquo;s. If <code>/update</code> fails with 401, it is this or the password.</li>
<li><strong>CNAME TTL</strong>: keep it low (60–300 s) the first time; with a one-hour TTL, a mistake in the CNAME takes an hour to correct in Let&rsquo;s Encrypt&rsquo;s eyes. The TXT is served by acme-dns with TTL 1.</li>
<li><strong>A real port 53</strong>: acme-dns must be reachable over UDP and TCP from the Internet, and be the <strong>NS for <code>auth.example.com</code></strong> in your parent zone. Without the NS record there is no delegation and validation returns NXDOMAIN.</li>
<li><strong>One registration per domain</strong>, not per server: if three servers request certificates for <code>example.com</code>, they share the same registration and the same <code>acmedns.json</code>, or each registers its own and the CNAME can only point to one. Share the credentials.</li>
<li><strong><code>--manual</code> renews fine</strong> because the hook is stored in <code>/etc/letsencrypt/renewal/&lt;domain&gt;.conf</code>; what you cannot do is change the hook without running <code>certonly</code> again.</li>
<li><strong>Version</strong>: acme-dns 2.0.2 is from February 2025 and the project is still active; the certbot hook has not changed in years and works with Python 3 after fixing the shebang.</li>
</ul>
<p>References: <a href="https://github.com/joohoi/acme-dns">acme-dns</a>, <a href="https://github.com/joohoi/acme-dns-certbot-joohoi">acme-dns-certbot-joohoi</a> and <a href="https://cert-manager.io/docs/configuration/acme/dns01/acme-dns/">ACMEDNS in cert-manager</a>.</p>
<h2 id="see-also">See also</h2>
<ul>
<li><a href="/en/posts/kubernetes/cert-manager-http01-308-ingressclassname/">cert-manager: the HTTP-01 challenge returns 308</a> — when HTTP-01 is enough, and when it is not.</li>
<li><a href="/en/posts/pfsense/haproxy-acme-letsencrypt-cloudflare/">HAProxy on pfSense with ACME and Cloudflare</a> — DNS-01 with the provider&rsquo;s token, the case acme-dns avoids.</li>
<li><a href="/en/posts/dns/dkim-2048-txt-255-characters/">2048-bit DKIM in a split TXT</a> — another DNS record that breaks over details.</li>
</ul>
]]></content:encoded></item><item><title>Google-managed certificate that never gets issued: CAA (pki.goog, letsencrypt.org), FAILED_NOT_VISIBLE and the right order</title><link>https://inet.sh/en/posts/gcp/google-managed-certificate-caa/</link><pubDate>Sat, 26 Sep 2026 00:00:00 +0000</pubDate><guid>https://inet.sh/en/posts/gcp/google-managed-certificate-caa/</guid><description>The Google-managed certificate for an HTTPS load balancer sits in PROVISIONING for hours or drops to FAILED_NOT_VISIBLE or FAILED_CAA_FORBIDDEN. The three causes: DNS does not point to the load balancer IP yet, the certificate is not attached to a target proxy, or a CAA record in the zone does not authorize pki.goog or letsencrypt.org. How to check the status with gcloud, the correct CAA, and Certificate Manager with DNS authorization to issue before switching DNS.</description><content:encoded><![CDATA[<h2 id="tldr">TL;DR</h2>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">example.com.   CAA 0 issue &#34;pki.goog&#34;
</span></span><span class="line"><span class="cl">example.com.   CAA 0 issue &#34;letsencrypt.org&#34;
</span></span></code></pre></td></tr></table>
</div>
</div><div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span><span class="lnt">5
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">gcloud compute ssl-certificates describe web-cert --global --format<span class="o">=</span><span class="s1">&#39;yaml(managed)&#39;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># managed:</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   domainStatus:</span>
</span></span><span class="line"><span class="cl"><span class="c1">#     www.example.com: PROVISIONING | ACTIVE | FAILED_NOT_VISIBLE | FAILED_CAA_FORBIDDEN | FAILED_CAA_CHECKING | FAILED_RATE_LIMITED</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   status: PROVISIONING | ACTIVE</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>To reach <code>ACTIVE</code> three things must hold at once: the domain&rsquo;s <strong>A/AAAA</strong> points to the load balancer IP (and nothing else), the certificate is <strong>attached to a target proxy</strong> with its forwarding rule, and no <strong>CAA</strong> in the zone forbids Google&rsquo;s CAs. Then, up to <strong>60 minutes</strong>.</p>
<h2 id="why">Why</h2>
<p>Google issues managed certificates through <strong>Google Trust Services</strong> (<code>pki.goog</code>) or <strong>Let&rsquo;s Encrypt</strong>, and picks one or the other on each issuance or renewal. To validate the domain it runs an HTTP check against the IP the name resolves to, so until DNS points at the load balancer there is nothing to validate. And like any CA, it honors <strong>CAA</strong> records: if your zone says only <code>digicert.com</code> may issue (a typical corporate policy), Google cannot.</p>
<p>The statuses tell the story:</p>
<table>
  <thead>
      <tr>
          <th><code>domainStatus</code></th>
          <th>Cause</th>
          <th>What to do</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>PROVISIONING</code></td>
          <td>In progress</td>
          <td>Wait; past 60 minutes, check the items below</td>
      </tr>
      <tr>
          <td><code>FAILED_NOT_VISIBLE</code></td>
          <td>The name does not resolve to the load balancer IP, or the certificate is not attached to a target proxy</td>
          <td>Fix A/AAAA, attach the certificate, wait for propagation</td>
      </tr>
      <tr>
          <td><code>FAILED_CAA_CHECKING</code></td>
          <td>Google could not read the CAA records (unresponsive DNS, broken DNSSEC)</td>
          <td>Fix the zone</td>
      </tr>
      <tr>
          <td><code>FAILED_CAA_FORBIDDEN</code></td>
          <td>There is a CAA and it includes neither <code>pki.goog</code> nor <code>letsencrypt.org</code></td>
          <td>Add both records</td>
      </tr>
      <tr>
          <td><code>FAILED_RATE_LIMITED</code></td>
          <td>Too many issuance attempts</td>
          <td>Wait; do not recreate the certificate in a loop</td>
      </tr>
  </tbody>
</table>
<h2 id="the-right-caa">The right CAA</h2>
<p>With no CAA record at all, any CA may issue and Google works. If you have CAA (or your organization requires it), add <strong>both</strong> CAs:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">example.com.   CAA 0 issue &#34;pki.goog&#34;
</span></span><span class="line"><span class="cl">example.com.   CAA 0 issue &#34;letsencrypt.org&#34;
</span></span></code></pre></td></tr></table>
</div>
</div><p>Listing only one forces Google to always use that one; the documentation advises against it because a renewal may come out through the other. CAA records inherit downwards: a CAA on <code>example.com</code> covers <code>www.example.com</code> if the subdomain has none of its own.</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">dig +short CAA example.com
</span></span></code></pre></td></tr></table>
</div>
</div><h2 id="certificate-manager-issue-before-switching-dns">Certificate Manager: issue before switching DNS</h2>
<p>With classic certificates (<code>compute ssl-certificates</code>) the domain must already point at the load balancer, which during a migration means a few minutes of broken HTTPS. <strong>Certificate Manager</strong> solves that with <strong>DNS authorization</strong>: you publish a validation CNAME and Google issues the certificate while traffic still flows to the old site.</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">gcloud certificate-manager dns-authorizations create www-auth --domain<span class="o">=</span>www.example.com
</span></span><span class="line"><span class="cl">gcloud certificate-manager dns-authorizations describe www-auth --format<span class="o">=</span><span class="s1">&#39;yaml(dnsResourceRecord)&#39;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># dnsResourceRecord: name: _acme-challenge.www.example.com. type: CNAME data: xxxx.authorize.certificatemanager.goog.</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># publish that CNAME in your zone and then:</span>
</span></span><span class="line"><span class="cl">gcloud certificate-manager certificates create www-cert <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  --domains<span class="o">=</span>www.example.com --dns-authorizations<span class="o">=</span>www-auth
</span></span><span class="line"><span class="cl">gcloud certificate-manager maps create web-map
</span></span><span class="line"><span class="cl">gcloud certificate-manager maps entries create www --map<span class="o">=</span>web-map --certificate<span class="o">=</span>www-cert --hostname<span class="o">=</span>www.example.com
</span></span><span class="line"><span class="cl">gcloud compute target-https-proxies update web-proxy --certificate-map<span class="o">=</span>web-map
</span></span></code></pre></td></tr></table>
</div>
</div><p>Once the certificate is <code>ACTIVE</code>, you flip the A/AAAA and the cutover is zero. CAA applies the same way.</p>
<h2 id="traps">Traps</h2>
<ul>
<li><strong>Cloudflare or another proxy in front</strong> of the load balancer: the A record points at the proxy, not at Google, and validation fails with <code>FAILED_NOT_VISIBLE</code>. Grey cloud during issuance, or Certificate Manager with DNS authorization.</li>
<li><strong>A managed certificate attached to nothing never gets issued.</strong> Creating it and waiting does nothing; attach it to the target proxy first.</li>
<li><strong>Changing the domain list</strong> creates a new certificate: attach the new one, wait for <code>ACTIVE</code>, and only then remove the old one from the proxy, or you get a gap.</li>
<li><strong>Wildcards</strong> only with Certificate Manager and DNS authorization; classic managed certificates do not support them.</li>
<li><strong>Badly signed DNSSEC</strong> breaks CAA lookups: <code>FAILED_CAA_CHECKING</code> even when you have no CAA at all.</li>
</ul>
<p>Reference: <a href="https://docs.cloud.google.com/load-balancing/docs/ssl-certificates/google-managed-certs">Google-managed SSL certificates</a> and <a href="https://docs.cloud.google.com/certificate-manager/docs/overview">Certificate Manager</a> in the Google Cloud documentation.</p>
]]></content:encoded></item><item><title>2048-bit DKIM: the key does not fit in a 255-character TXT</title><link>https://inet.sh/en/posts/dns/dkim-2048-txt-255-characters/</link><pubDate>Fri, 25 Sep 2026 00:00:00 +0000</pubDate><guid>https://inet.sh/en/posts/dns/dkim-2048-txt-255-characters/</guid><description>How to publish a 2048-bit DKIM key (about 410 characters) when the DNS provider rejects the TXT for exceeding 255: split it into several quoted strings inside the same record. Commands for Route 53 (CharacterStringTooLong), Google Cloud DNS, BIND and Terraform, and verification with dig and openssl.</description><content:encoded><![CDATA[<h2 id="tldr">TL;DR</h2>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl"><span class="nv">PUB</span><span class="o">=</span><span class="k">$(</span>openssl rsa -in dkim.key -pubout -outform DER 2&gt;/dev/null <span class="p">|</span> base64 <span class="p">|</span> tr -d <span class="s1">&#39;\n&#39;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;v=DKIM1; k=rsa; p=</span><span class="nv">$PUB</span><span class="s2">&#34;</span> <span class="p">|</span> fold -w <span class="m">255</span> <span class="p">|</span> sed <span class="s1">&#39;s/.*/&#34;&amp;&#34;/&#39;</span> <span class="p">|</span> paste -sd<span class="s1">&#39; &#39;</span> -
</span></span></code></pre></td></tr></table>
</div>
</div><p>That prints the value ready to paste into the DNS provider, two quoted strings separated by a space:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">&#34;v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvs7EROOYh35C/1s7M//pIaRvu1XWXzdexgBR2dO7eq1oDw+X08BNJUVUEte3z7os5CNeP7YfZrAqyDJvA59wMK1qul6oHyy7z5P1aB4zNczvO7SZ20HogWjHjzYJ0qwmSUUtjlKvECQzoTCbS/F0gB9rdUZN02MJopDs8YWcohy8oqNLnrflHdxWm6jAb3sSa&#34; &#34;EQReu6iRKKPiXTGUISSetY+iXLo2j/2c5g5ppiIYkUTq58asamMNiCzB7BeF4WNkjz2P+9IJmmtkkJ0M/U4oThUMvBBtQ/XUV4i2N4PGB359G5d/PIMiCj4go2axA4p8ul7Qafbtv0fyPwOIGSojwIDAQAB&#34;
</span></span></code></pre></td></tr></table>
</div>
</div><p>Both go into <strong>the same TXT record</strong>. Whoever verifies DKIM concatenates them with no spaces. If Google Workspace or Microsoft 365 handed you the text and you do not have the private key, it is the same without the <code>openssl</code>: <code>echo &quot;&lt;value&gt;&quot; | fold -w 255 | sed 's/.*/&quot;&amp;&quot;/' | paste -sd' ' -</code>.</p>
<h2 id="the-problem">The problem</h2>
<p>A TXT record is made of one or more <em>character-strings</em>, and each one is capped at <strong>255 bytes</strong> (RFC 1035). The whole record can be much longer; what it cannot have is a single 410-character string.</p>
<table>
  <thead>
      <tr>
          <th>RSA key</th>
          <th>Length of <code>p=</code></th>
          <th>With <code>v=DKIM1; k=rsa; p=</code></th>
          <th>Fits in one string?</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1024 bits</td>
          <td>216</td>
          <td>234</td>
          <td>Yes</td>
      </tr>
      <tr>
          <td>2048 bits</td>
          <td>392</td>
          <td>410</td>
          <td>No: two are needed</td>
      </tr>
      <tr>
          <td>4096 bits</td>
          <td>736</td>
          <td>754</td>
          <td>No: three</td>
      </tr>
  </tbody>
</table>
<p>Google Workspace and Microsoft 365 generate 2048-bit keys by default, and rightly so: 1024 has been discouraged since RFC 8301. So almost any new DKIM setup today runs into this, and each provider reacts differently:</p>
<table>
  <thead>
      <tr>
          <th>Provider</th>
          <th>What happens with 410 characters in a row</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Route 53</td>
          <td>Rejects it with <code>CharacterStringTooLong</code>, in the console and the CLI</td>
      </tr>
      <tr>
          <td>Google Cloud DNS</td>
          <td>Rejects the value; you have to type the quoted strings yourself</td>
      </tr>
      <tr>
          <td>Cloudflare</td>
          <td>Accepts it and splits it into 255-character chunks on its own</td>
      </tr>
      <tr>
          <td>BIND and most self-hosted servers</td>
          <td>The zone file does not load until you use several strings</td>
      </tr>
  </tbody>
</table>
<p>RFC 6376 (§3.6.2.2) requires verifiers to concatenate the strings before using them, so splitting the key is not a trick: it is the intended format.</p>
<h2 id="per-provider">Per provider</h2>
<h3 id="bind--zone-file">BIND / zone file</h3>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">selector._domainkey  300  IN  TXT  ( &#34;v=DKIM1; k=rsa; p=MIIBIjANBgkq...first part&#34;
</span></span><span class="line"><span class="cl">                                     &#34;second part...IDAQAB&#34; )
</span></span></code></pre></td></tr></table>
</div>
</div><h3 id="route-53">Route 53</h3>
<p>In the console, paste both quoted strings into <strong>Value</strong> separated by a space or a line break. From the CLI the value is a single string with escaped quotes:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span><span class="lnt">5
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">aws route53 change-resource-record-sets --hosted-zone-id Z0123456789EXAMPLE --change-batch <span class="s1">&#39;{
</span></span></span><span class="line"><span class="cl"><span class="s1">  &#34;Changes&#34;: [{&#34;Action&#34;: &#34;UPSERT&#34;, &#34;ResourceRecordSet&#34;: {
</span></span></span><span class="line"><span class="cl"><span class="s1">    &#34;Name&#34;: &#34;selector._domainkey.example.com&#34;, &#34;Type&#34;: &#34;TXT&#34;, &#34;TTL&#34;: 300,
</span></span></span><span class="line"><span class="cl"><span class="s1">    &#34;ResourceRecords&#34;: [{&#34;Value&#34;: &#34;\&#34;v=DKIM1; k=rsa; p=...first part\&#34; \&#34;second part...\&#34;&#34;}]
</span></span></span><span class="line"><span class="cl"><span class="s1">  }}]}&#39;</span>
</span></span></code></pre></td></tr></table>
</div>
</div><h3 id="google-cloud-dns">Google Cloud DNS</h3>
<p>In the console, same format in the data field. With <code>gcloud</code>:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">gcloud dns record-sets create selector._domainkey.example.com. --zone<span class="o">=</span>example-zone <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  --type<span class="o">=</span>TXT --ttl<span class="o">=</span><span class="m">300</span> <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  --rrdatas<span class="o">=</span><span class="s1">&#39;&#34;v=DKIM1; k=rsa; p=...first part&#34; &#34;second part...&#34;&#39;</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>With Terraform (<code>google_dns_record_set</code>), a single <code>rrdatas</code> element with escaped quotes:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="cl"><span class="n">rrdatas</span> <span class="o">=</span><span class="n"> [&#34;\&#34;v</span><span class="o">=</span><span class="n">DKIM1; k</span><span class="o">=</span><span class="n">rsa; p</span><span class="o">=</span><span class="p">...</span><span class="k">first</span> <span class="k">part</span><span class="err">\</span><span class="s2">&#34; \&#34;second part...\&#34;&#34;</span><span class="p">]</span>
</span></span></code></pre></td></tr></table>
</div>
</div><h3 id="cloudflare">Cloudflare</h3>
<p>Paste the full value into <strong>Content</strong>; the dashboard splits it on save. When you query it with <code>dig</code> you will see two strings even though you entered one: that is normal.</p>
<h2 id="verifying">Verifying</h2>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">dig +short TXT selector._domainkey.example.com
</span></span></code></pre></td></tr></table>
</div>
</div><p>Expected: <code>&quot;v=DKIM1; k=rsa; p=MIIB...&quot; &quot;...IDAQAB&quot;</code>, two strings, one record. Then check that the key decodes to 2048 bits, which confirms no character was lost at the cut:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">dig +short TXT selector._domainkey.example.com <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  <span class="p">|</span> tr -d <span class="s1">&#39;&#34; \n&#39;</span> <span class="p">|</span> sed -E <span class="s1">&#39;s/.*p=([A-Za-z0-9+/=]+).*/\1/&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  <span class="p">|</span> base64 -d <span class="p">|</span> openssl pkey -pubin -inform DER -noout -text <span class="p">|</span> head -1
</span></span><span class="line"><span class="cl"><span class="c1"># Public-Key: (2048 bit)</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>If <code>base64</code> complains, a space slipped inside the key or characters are missing. Finish with a test email and <code>Authentication-Results: ... dkim=pass</code> in the headers; for a closer look, <a href="https://dmarcian.com/dkim-inspector/">dmarcian&rsquo;s DKIM Inspector</a>.</p>
<h2 id="traps">Traps</h2>
<ul>
<li><strong>The cut can go anywhere</strong>, even mid-base64, but <strong>never put spaces inside the key</strong>. The space between the two strings separates strings; it is not data.</li>
<li><strong>Do not double the quotes.</strong> If the panel adds them for you and you paste <code>&quot;...&quot;</code>, <code>dig</code> returns <code>&quot;\&quot;v=DKIM1...\&quot;&quot;</code> and the verifier fails.</li>
<li><strong>One record, not two.</strong> Two separate TXT records with the same name are two independent values, and neither is a valid key.</li>
<li><strong>2048 is the sweet spot.</strong> 4096 means three strings and a DNS answer close to the UDP limit; it works, but gains nothing for DKIM.</li>
<li><strong>Rotation</strong>: publish the new key under a new selector, switch the signer, and delete the old selector after the TTL. Never edit the selector in use.</li>
<li><strong>SPF has the same limit</strong>, but an SPF that does not fit in 255 is usually a sign of too many <code>include:</code>; there the fix is shortening it, not splitting it.</li>
</ul>
<p>References: <a href="https://knowledge.workspace.google.com/admin/security/troubleshoot-dkim-issues">Google Workspace — troubleshoot DKIM issues</a>, <a href="https://repost.aws/knowledge-center/route53-resolve-dkim-text-record-error">AWS re:Post — CharacterStringTooLong</a>, and the <a href="https://www.mailhardener.com/tools/dns-record-splitter">Mailhardener DNS record splitter</a> if you would rather not use <code>fold</code>.</p>
]]></content:encoded></item><item><title>pfSense and OPNsense: the port forward does not work from the LAN itself (NAT reflection and split DNS)</title><link>https://inet.sh/en/posts/pfsense/nat-reflection-port-forward-from-lan/</link><pubDate>Fri, 25 Sep 2026 00:00:00 +0000</pubDate><guid>https://inet.sh/en/posts/pfsense/nat-reflection-port-forward-from-lan/</guid><description>From outside the port forward works, but from the LAN the public IP or the domain lands on the firewall&amp;#39;s GUI or a certificate error. Two fixes: split DNS with host overrides in the DNS Resolver (the recommended one) or NAT reflection in Pure NAT mode with automatic outbound NAT. Includes the OPNsense equivalent and the DNS rebinding trap.</description><content:encoded><![CDATA[<h2 id="tldr">TL;DR</h2>
<p><strong>Clean option (split DNS):</strong> Services → DNS Resolver → <em>Host Overrides</em> → <code>app.example.com</code> → the server&rsquo;s internal IP (<code>192.168.1.50</code>). LAN clients must use the firewall as their DNS.</p>
<p><strong>NAT reflection option:</strong> System → Advanced → <em>Firewall &amp; NAT</em>:</p>
<ul>
<li><em>NAT Reflection mode for port forwards</em>: <strong>Pure NAT</strong></li>
<li><em>Enable NAT Reflection for 1:1 NAT</em>: ✔</li>
<li><em>Enable automatic outbound NAT for Reflection</em>: ✔</li>
</ul>
<p>Save and test from a LAN client against the public IP.</p>
<h2 id="the-problem">The problem</h2>
<p>The port forward <code>WAN:443 → 192.168.1.50:443</code> works from the Internet. From a machine on the same LAN, <code>https://app.example.com</code> (which resolves to the public IP) answers with the pfSense GUI, a certificate error, or <code>Potential DNS Rebind attack detected</code>.</p>
<p>The packet leaves the client towards the public IP, reaches the firewall through the LAN interface, and stops there: the port forward rule listens on the WAN, not the LAN, and the destination IP is one of the firewall&rsquo;s own addresses. It never &ldquo;goes out and comes back in&rdquo;.</p>
<h2 id="option-1-split-dns">Option 1: split DNS</h2>
<p>This is what Netgate recommends: the name resolves to the internal IP inside and to the public one outside. Traffic goes direct, the server sees the client&rsquo;s real IP, and the firewall touches nothing.</p>
<ol>
<li>Services → DNS Resolver → <em>Host Overrides</em> → <strong>Add</strong>: host <code>app</code>, domain <code>example.com</code>, IP <code>192.168.1.50</code>. If the service has several names, add <em>Additional Names</em>.</li>
<li>Clients must use the firewall as DNS (the norm if DHCP hands it out). A client with <code>8.8.8.8</code> hardcoded will keep going to the public IP.</li>
<li><code>nslookup app.example.com</code> from the LAN → <code>192.168.1.50</code>.</li>
</ol>
<p>Requirement: the internal and external ports must be the same (if outside you forward <code>8443 → 443</code>, the internal client will have to use <code>443</code> directly).</p>
<p>If you run the DNS Forwarder (dnsmasq) instead of the Resolver, the equivalent setting is under Services → DNS Forwarder → <em>Host Overrides</em>.</p>
<h2 id="option-2-nat-reflection">Option 2: NAT reflection</h2>
<p>When you do not control the clients&rsquo; DNS, or there are lots of ports, the firewall can &ldquo;reflect&rdquo; the connection: it rewrites the destination to the internal IP and, with automatic outbound NAT, the source too, so the reply comes back through the firewall.</p>
<p>System → Advanced → <em>Firewall &amp; NAT</em>, <em>Network Address Translation</em> section:</p>
<table>
  <thead>
      <tr>
          <th>Setting</th>
          <th>Value</th>
          <th>Why</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>NAT Reflection mode for port forwards</td>
          <td><strong>Pure NAT</strong></td>
          <td>PF rules only: every protocol, no port limit. <em>NAT + Proxy</em> uses a daemon, TCP only and at most 500 ports per rule</td>
      </tr>
      <tr>
          <td>Enable NAT Reflection for 1:1 NAT</td>
          <td>✔</td>
          <td>If you have 1:1 NAT besides port forwards</td>
      </tr>
      <tr>
          <td>Enable automatic outbound NAT for Reflection</td>
          <td>✔</td>
          <td>Without it, when client and server share a subnet the server replies straight to the client, which does not recognize the connection and drops it</td>
      </tr>
      <tr>
          <td>Reflection Timeout</td>
          <td>(NAT + Proxy only)</td>
          <td>Ignore it in Pure NAT</td>
      </tr>
  </tbody>
</table>
<p>It can also be enabled per rule: Firewall → NAT → Port Forward → edit → <em>NAT reflection</em> → <em>Enable (Pure NAT)</em>, leaving the global setting on <em>Disable</em>.</p>
<p>Costs: the server sees the firewall&rsquo;s LAN IP as the source (not the client&rsquo;s), and all that traffic crosses the firewall even when client and server sit on the same switch.</p>
<h2 id="opnsense">OPNsense</h2>
<p>Same idea, other menus:</p>
<ul>
<li>Split DNS: Services → Unbound DNS → <em>Overrides</em> → Host Overrides.</li>
<li>Reflection: Firewall → Settings → <em>Advanced</em> → <strong>Reflection for port forwards</strong>, <strong>Reflection for 1:1</strong>, <strong>Automatic outbound NAT for Reflection</strong>.</li>
</ul>
<h2 id="traps">Traps</h2>
<ul>
<li><strong>DNS rebinding.</strong> If instead of a host override you point the Resolver at an external DNS that returns private IPs (an internal DNS at another site, a provider with views), Unbound drops those answers as rebinding protection. Do not untick <em>Disable DNS Rebinding Checks</em> under System → Advanced → Admin Access: add <code>private-domain: &quot;example.com&quot;</code> under Services → DNS Resolver → <em>Custom options</em> and only that domain is exempt.</li>
<li><strong>A client with hardcoded DNS</strong> bypasses split DNS. Either force it through DHCP, catch it with a port 53 NAT rule, or use reflection.</li>
<li><strong>Pure NAT needs to know the interface and gateway</strong> when rules load; with dynamic WANs that change IP, a <em>Filter Reload</em> after the IP change fixes it.</li>
<li><strong>Different ports inside and outside</strong> only work with reflection; split DNS does not translate ports.</li>
<li><strong>Certificates</strong>: with split DNS the browser still sees <code>app.example.com</code>, so the server&rsquo;s certificate works as is. When reflecting through the public IP, too. The certificate error at the start was the firewall GUI&rsquo;s, not the server&rsquo;s.</li>
</ul>
<p>References: <a href="https://docs.netgate.com/pfsense/en/latest/recipes/port-forwards-from-local-networks.html">Port Forwards from Local Networks</a> and <a href="https://docs.netgate.com/pfsense/en/latest/nat/reflection.html">NAT Reflection</a> in the Netgate documentation.</p>
]]></content:encoded></item></channel></rss>