<?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>Acme on inetshell</title><link>https://inet.sh/en/tags/acme/</link><description>Recent content in Acme 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/acme/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>cert-manager: the HTTP-01 challenge returns 308 (missing ingressClassName in the solver) and other self-check failures</title><link>https://inet.sh/en/posts/kubernetes/cert-manager-http01-308-ingressclassname/</link><pubDate>Fri, 25 Sep 2026 00:00:00 +0000</pubDate><guid>https://inet.sh/en/posts/kubernetes/cert-manager-http01-308-ingressclassname/</guid><description>The cert-manager Challenge gets stuck on &amp;#39;wrong status code 308, expected 200&amp;#39;: your ingress controller does not serve the solver&amp;#39;s temporary Ingress and the request lands on the app&amp;#39;s Ingress with ssl-redirect. Fix with ingressClassName in the http01 solver (cert-manager 1.12+), how to diagnose with kubectl describe challenge, the other causes (404, hairpin NAT, TLS), and DNS-01 with Cloudflare as the alternative.</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></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Waiting for HTTP-01 challenge propagation: wrong status code &#39;308&#39;, expected &#39;200&#39;
</span></span></code></pre></td></tr></table>
</div>
</div><p>cert-manager creates a temporary Ingress (<code>cm-acme-http-solver-…</code>) to answer the challenge, but without a class your ingress controller does not adopt it; the request to <code>/.well-known/acme-challenge/…</code> gets answered by the application&rsquo;s Ingress, which redirects to HTTPS with <strong>308</strong>. Put the class in the solver:</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></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-prod</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-prod-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">http01</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">ingress</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">ingressClassName: nginx        # cert-manager &gt;= 1.12; before: class</span><span class="p">:</span><span class="w"> </span><span class="l">nginx</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><p>Apply and delete the stuck challenge so it retries:</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 delete challenge -n &lt;ns&gt; --all
</span></span></code></pre></td></tr></table>
</div>
</div><h2 id="diagnosis">Diagnosis</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><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">kubectl get certificate,certificaterequest,order,challenge -A
</span></span><span class="line"><span class="cl">kubectl describe challenge -n &lt;ns&gt; &lt;name&gt;              <span class="c1"># Status → Reason is the line you want</span>
</span></span><span class="line"><span class="cl">kubectl get ingress -n &lt;ns&gt;                            <span class="c1"># does cm-acme-http-solver-xxxx show CLASS &lt;none&gt;?</span>
</span></span><span class="line"><span class="cl">curl -i http://app.example.com/.well-known/acme-challenge/test   <span class="c1"># from outside and from a pod</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>Before asking Let&rsquo;s Encrypt to validate, cert-manager runs a <strong>self-check</strong>: it tries the challenge URL itself. The Challenge&rsquo;s <code>Reason</code> is the result of that self-check and almost always points at the cause.</p>
<table>
  <thead>
      <tr>
          <th><code>Reason</code></th>
          <th>Cause</th>
          <th>Fix</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>wrong status code '308'</code> (or 301/302)</td>
          <td>The solver Ingress has no class; the app&rsquo;s one answers with <code>ssl-redirect</code></td>
          <td><code>ingressClassName</code> in the solver</td>
      </tr>
      <tr>
          <td><code>wrong status code '404'</code></td>
          <td>The solver is not running, or the host&rsquo;s DNS points at another IP</td>
          <td><code>kubectl get pods -n &lt;ns&gt; | grep acme-solver</code>; <code>dig app.example.com</code></td>
      </tr>
      <tr>
          <td><code>dial tcp … i/o timeout</code> / <code>connection refused</code></td>
          <td>The public IP is unreachable from inside the cluster: NAT without hairpin, different DNS inside and outside</td>
          <td>NAT reflection or split DNS on the firewall (<a href="/en/posts/pfsense/nat-reflection-port-forward-from-lan/">pfSense</a>)</td>
      </tr>
      <tr>
          <td><code>tls: handshake failure</code></td>
          <td>The app&rsquo;s Ingress already has TLS with an empty secret</td>
          <td>Annotation <code>cert-manager.io/issue-temporary-certificate: &quot;true&quot;</code></td>
      </tr>
      <tr>
          <td><code>wrong status code '503'</code></td>
          <td>Several Ingresses with the same host and the controller does not merge them</td>
          <td><code>acme.cert-manager.io/http01-edit-in-place: &quot;true&quot;</code> on the Ingress</td>
      </tr>
  </tbody>
</table>
<h2 id="why-ingressclassname">Why <code>ingressClassName</code></h2>
<p>Since Kubernetes 1.18 an Ingress&rsquo;s class goes in <code>spec.ingressClassName</code>; the <code>kubernetes.io/ingress.class</code> annotation is deprecated and modern controllers (ingress-nginx 1.x, Traefik) may ignore it. In cert-manager, the solver&rsquo;s <code>class</code> field still writes the annotation; <code>ingressClassName</code> (since 1.12) writes the field. Use one of the two, and in 2026 the right one is <code>ingressClassName</code>.</p>
<p>Without a class, the temporary Ingress exists but nobody serves it. The controller routes the request by host, finds the app&rsquo;s Ingress, and applies its rules: <code>ssl-redirect</code> → 308. That is why the error looks like the app&rsquo;s and not cert-manager&rsquo;s.</p>
<h2 id="alternative-dns-01-with-cloudflare">Alternative: DNS-01 with Cloudflare</h2>
<p>When the cluster is not reachable from the Internet on port 80, or you want a wildcard, the challenge goes through DNS. Token in Cloudflare with <strong>Zone → DNS → Edit</strong> and <strong>Zone → Zone → Read</strong> on the zone:</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><span class="lnt">16
</span><span class="lnt">17
</span><span class="lnt">18
</span><span class="lnt">19
</span><span class="lnt">20
</span><span class="lnt">21
</span><span class="lnt">22
</span><span class="lnt">23
</span><span class="lnt">24
</span><span class="lnt">25
</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">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">Secret</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">cloudflare-api-token</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">namespace</span><span class="p">:</span><span class="w"> </span><span class="l">cert-manager</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="nt">type</span><span class="p">:</span><span class="w"> </span><span class="l">Opaque</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="nt">stringData</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">api-token</span><span class="p">:</span><span class="w"> </span><span class="l">&lt;token&gt;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="nn">---</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><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">cloudflare</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">apiTokenSecretRef</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">cloudflare-api-token</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">api-token</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><p>With DNS-01 there is no temporary Ingress, no HTTP self-check, no hairpin; the self-check queries the TXT record, and if the cluster&rsquo;s DNS resolves against an internal server with the zone cached, the <code>Reason</code> will be <code>DNS record for … not yet propagated</code>: patience, or <code>--dns01-recursive-nameservers=1.1.1.1:53</code> on cert-manager.</p>
<h2 id="traps">Traps</h2>
<ul>
<li><strong>Staging first.</strong> <code>https://acme-staging-v02.api.letsencrypt.org/directory</code> has no issuance limits; production blocks 5 issuances per name per week, and every failed Challenge that reaches Let&rsquo;s Encrypt counts as an attempt.</li>
<li><strong>Delete the Challenge, not the Certificate.</strong> Deleting the <code>Order</code> or the <code>Challenge</code> forces a retry with the new configuration; deleting the <code>Certificate</code> also deletes the secret and leaves the app without TLS meanwhile.</li>
<li><strong><code>pathType: Exact</code></strong> on the solver Ingress (recent cert-manager) clashes with some old controllers; if you see 503 on OpenShift, that is it.</li>
<li><strong>Issuer per namespace vs ClusterIssuer</strong>: the Ingress must annotate the one that exists (<code>cert-manager.io/cluster-issuer</code> or <code>cert-manager.io/issuer</code>); annotating the one that does not leaves the Certificate in <code>Issuing</code> forever with no Challenge to describe.</li>
</ul>
<p>References: <a href="https://cert-manager.io/docs/configuration/acme/http01/">HTTP01 solver</a>, <a href="https://cert-manager.io/docs/configuration/acme/dns01/cloudflare/">Cloudflare DNS01</a>, and <a href="https://cert-manager.io/docs/troubleshooting/acme/">Troubleshooting ACME</a> in the cert-manager documentation.</p>
]]></content:encoded></item><item><title>pfSense: Let's Encrypt certificate with ACME and Cloudflare DNS, then using it in HAProxy</title><link>https://inet.sh/en/posts/pfsense/haproxy-acme-letsencrypt-cloudflare/</link><pubDate>Fri, 25 Sep 2026 00:00:00 +0000</pubDate><guid>https://inet.sh/en/posts/pfsense/haproxy-acme-letsencrypt-cloudflare/</guid><description>Issue a Let&amp;#39;s Encrypt certificate on pfSense with the ACME package and DNS-01 validation on Cloudflare (API token, Account ID and Zone ID only; no Global API Key), wildcard included, and hang it off the HAProxy frontend with an automatic restart on every renewal. With the token IP filter and DNS Sleep traps.</description><content:encoded><![CDATA[<h2 id="tldr">TL;DR</h2>
<ol>
<li><strong>Cloudflare</strong> → My Profile → API Tokens → <em>Create Token</em> → <em>Edit zone DNS</em> template: permissions <strong>Zone → Zone → Read</strong> and <strong>Zone → DNS → Edit</strong>, <em>Zone Resources</em> limited to your zone. Copy the token; the <strong>Account ID</strong> and <strong>Zone ID</strong> are on the zone&rsquo;s <em>Overview</em> page.</li>
<li><strong>pfSense</strong> → System → Package Manager → install <strong>acme</strong> and <strong>haproxy</strong>.</li>
<li>Services → ACME → <em>Account keys</em> → Add: <em>ACME Server</em> <strong>Let&rsquo;s Encrypt Production ACME v2</strong> (or <em>Staging</em> to test) → <em>Create new account key</em> → <em>Register</em> → Save.</li>
<li>Services → ACME → <em>Certificates</em> → Add: name, the account, <em>Domain SAN list</em>: <code>app.example.com</code> (and <code>*.example.com</code> if you want a wildcard) with method <strong>DNS-Cloudflare</strong>, and in its fields only <strong>Token</strong>, <strong>Account ID</strong> and <strong>Zone ID</strong>. <em>Actions list</em>: <strong>Restart Local Service</strong> → <code>haproxy</code>. Save → <strong>Issue</strong>.</li>
<li>Services → HAProxy → <em>Frontend</em> → edit → <em>SSL Offloading</em> ✔ on the listen address → <em>Certificate</em>: the one ACME just created → Save → <em>Apply Changes</em>.</li>
</ol>
<p>Automatic renewal (the package&rsquo;s daily cron) and HAProxy restarted on every renewal.</p>
<h2 id="cloudflare-the-token">Cloudflare: the token</h2>
<p>The <em>Global API Key</em> old guides ask for gives access to the whole account; an <strong>API token</strong> with minimal permissions is the right thing and is what the package&rsquo;s <em>DNS-Cloudflare</em> method uses when you fill in <code>Token</code>:</p>
<table>
  <thead>
      <tr>
          <th>Package field</th>
          <th>Value</th>
          <th>Where</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Token</td>
          <td>The API token</td>
          <td>My Profile → API Tokens</td>
      </tr>
      <tr>
          <td>Account ID</td>
          <td>Account ID</td>
          <td>Zone → Overview → <em>API</em> (right column)</td>
      </tr>
      <tr>
          <td>Zone ID</td>
          <td>Zone ID</td>
          <td>Same place</td>
      </tr>
      <tr>
          <td>Key / Email</td>
          <td>Empty</td>
          <td>Only for the Global API Key (do not use it)</td>
      </tr>
  </tbody>
</table>
<p>With <code>Token</code> one of the two IDs is enough; setting both does no harm. Token permissions: <strong>Zone:Read</strong> and <strong>DNS:Edit</strong> on the specific zone (<em>Include → Specific zone</em>).</p>
<p>If you enable <em>Client IP Address Filtering</em> on the token, it must include the firewall&rsquo;s public <strong>IPv4 and also IPv6</strong>: pfSense goes out through whichever it has, and a token limited to v4 fails as soon as the API call goes over v6.</p>
<h2 id="acme-account-and-certificate">ACME: account and certificate</h2>
<p><strong>Account.</strong> Create two: one against <em>Staging</em> to test and one against <em>Production</em>. Staging has no issuance limits but its certificates are not trusted; once everything works, switch the certificate&rsquo;s account to production and issue again.</p>
<p><strong>Certificate.</strong> Under Services → ACME → <em>Certificates</em> → Add:</p>
<ul>
<li><em>Domain SAN list</em>: one entry per name. For a <code>*.example.com</code> wildcard the validation has to be DNS-01 (HTTP-01 does not allow it). A wildcard does not cover the apex: add <code>example.com</code> as a second entry.</li>
<li><em>Method</em>: <strong>DNS-Cloudflare</strong> on each entry, with the three fields.</li>
<li><em>DNS Sleep</em>: Cloudflare propagates in seconds; the default is plenty. If issuance fails with <code>TXT not found</code>, raise it to <code>120</code>.</li>
<li><em>Key</em>: RSA 2048 or ECDSA 256; both work fine with HAProxy.</li>
<li><em>Actions list</em>: <strong>Restart Local Service</strong> → <strong>haproxy</strong>. Without it, HAProxy keeps serving the old certificate until someone restarts it, even though ACME has already renewed.</li>
</ul>
<p><strong>Issue</strong> from the certificate list. The result shows up under System → Certificates with the name you gave it; that is where HAProxy will see it.</p>
<h2 id="haproxy-using-the-certificate">HAProxy: using the certificate</h2>
<p>Services → HAProxy:</p>
<ol>
<li><em>Settings</em>: enable HAProxy, and leave <em>Max SSL Diffie-Hellman size</em> at 2048 or more.</li>
<li><em>Frontend</em> → your HTTPS frontend → <em>External address</em>: port <code>443</code>, <strong>SSL Offloading</strong> ✔ → <em>SSL Offloading</em> section → <strong>Certificate</strong>: the ACME one. For several domains on the same frontend, <em>Additional certificates</em> with the rest; HAProxy picks by SNI.</li>
<li>Save → <strong>Apply Changes</strong>.</li>
</ol>
<p>Test from outside:</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-shell" data-lang="shell"><span class="line"><span class="cl">openssl s_client -connect app.example.com:443 -servername app.example.com &lt;/dev/null 2&gt;/dev/null <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  <span class="p">|</span> openssl x509 -noout -issuer -dates
</span></span></code></pre></td></tr></table>
</div>
</div><p><code>issuer=C = US, O = Let's Encrypt</code> and <code>notAfter</code> 90 days out.</p>
<p>As a bonus, the pfSense GUI with the same certificate: System → Advanced → <em>Admin Access</em> → <em>SSL/TLS Certificate</em>, and a second action on the ACME certificate: <strong>Shell Command</strong> <code>/etc/rc.restart_webgui</code>.</p>
<h2 id="traps">Traps</h2>
<ul>
<li><strong>Cloudflare in proxy mode (orange cloud)</strong> does not affect DNS-01, but it does affect how traffic arrives: if the <code>A</code> record is proxied, HAProxy sees Cloudflare&rsquo;s IP as the source. For an internal service behind pfSense, grey cloud.</li>
<li><strong><code>Issue</code> fails with <code>Invalid request headers</code> or <code>Authentication error</code></strong>: token without permission on that zone, token IP filter, or the Zone ID of another zone.</li>
<li><strong>Production rate limits</strong>: five certificates per set of names per week. Test on staging.</li>
<li><strong>The certificate renews; HAProxy does not reload it by itself.</strong> The restart action is mandatory. Check under Services → ACME that the last renewal has a date and in <code>openssl s_client</code> that <code>notAfter</code> moved.</li>
<li><strong>ACME certificates renew at 2/3 of their lifetime</strong> (about 60 days). If you see the cron running and nothing changes before then, that is normal.</li>
</ul>
<p>Reference: <a href="https://docs.netgate.com/pfsense/en/latest/packages/acme/index.html">ACME package</a> in the Netgate documentation and acme.sh&rsquo;s <a href="https://github.com/acmesh-official/acme.sh/wiki/dnsapi">DNS provider list</a> (<code>dns_cf</code> section).</p>
]]></content:encoded></item></channel></rss>