<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Posts on Casey Robb</title><link>https://neozeon.io/posts/</link><description>Recent content in Posts on Casey Robb</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Sun, 18 Oct 2026 19:30:00 -0600</lastBuildDate><atom:link href="https://neozeon.io/posts/index.xml" rel="self" type="application/rss+xml"/><item><title>Thirty Apps, One Repo: Anatomy of a GitOps Homelab Cluster</title><link>https://neozeon.io/posts/chaos-cluster-anatomy/</link><pubDate>Sun, 18 Oct 2026 19:30:00 -0600</pubDate><guid>https://neozeon.io/posts/chaos-cluster-anatomy/</guid><description>&lt;h2 id="thirty-apps-one-repo-anatomy-of-a-gitops-homelab-cluster"&gt;Thirty Apps, One Repo: Anatomy of a GitOps Homelab Cluster&lt;/h2&gt;
&lt;p&gt;Somewhere between running my first app on Kubernetes and running my thirtieth,
the problem changed shape. It stopped being &amp;ldquo;how do I deploy this&amp;rdquo; and became
&amp;ldquo;how do I know what my cluster &lt;em&gt;is&lt;/em&gt;, and how do I change it without
nuking &amp;amp; paving.&amp;rdquo; My answer is a single GitLab repo &lt;a href="https://gitlab.com/caseyrobb/iac"&gt;gitlab.com/caseyrobb/iac&lt;/a&gt;: one ApplicationSet
generator, one allowlist per cluster, one thin wrapper chart per app, and a
handful of infrastructure operators doing the rest. The production cluster,
&lt;code&gt;chaos&lt;/code&gt; (k0s, six nodes on mini PCs at home — no, the name is not
prophetic; mostly), runs thirty-two apps out of that repo and has never had a
&lt;code&gt;kubectl apply&lt;/code&gt; from a human.&lt;/p&gt;</description><content type="html"><![CDATA[<h2 id="thirty-apps-one-repo-anatomy-of-a-gitops-homelab-cluster">Thirty Apps, One Repo: Anatomy of a GitOps Homelab Cluster</h2>
<p>Somewhere between running my first app on Kubernetes and running my thirtieth,
the problem changed shape. It stopped being &ldquo;how do I deploy this&rdquo; and became
&ldquo;how do I know what my cluster <em>is</em>, and how do I change it without
nuking &amp; paving.&rdquo; My answer is a single GitLab repo <a href="https://gitlab.com/caseyrobb/iac">gitlab.com/caseyrobb/iac</a>: one ApplicationSet
generator, one allowlist per cluster, one thin wrapper chart per app, and a
handful of infrastructure operators doing the rest. The production cluster,
<code>chaos</code> (k0s, six nodes on mini PCs at home — no, the name is not
prophetic; mostly), runs thirty-two apps out of that repo and has never had a
<code>kubectl apply</code> from a human.</p>
<p>This post is a tour of the five design choices that make that work and a few
scars, because every ignoreDifferences rule in my repo is the fossil of a
fight ArgoCD and I had.</p>
<h2 id="1-the-monorepo-and-an-applicationset-that-treats-absence-as-policy">1. The monorepo, and an ApplicationSet that treats absence as policy</h2>
<p>Everything (ArgoCD&rsquo;s own bootstrap, all chart wrappers, all per-cluster
values) lives in one repo. The tempting alternative is a chart repo per app or
a &ldquo;platform&rdquo; repo plus an &ldquo;apps&rdquo; repo, and I tried fragments of both. The
monorepo won for one reason: a change and its consequences get reviewed in one
diff. Adding an app means a new directory <em>and</em> its allowlist entry in the same
merge request; CI fails if either half is missing.</p>
<p>The entire fleet is generated by one ApplicationSet, whose generator is a
matrix of <em>clusters</em> × <em>that cluster&rsquo;s app list</em>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">generators</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">matrix</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">generators</span>:
</span></span><span style="display:flex;"><span>        - <span style="color:#f92672">list</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">elements</span>:
</span></span><span style="display:flex;"><span>              - <span style="color:#f92672">clusterName</span>: <span style="color:#ae81ff">PATCH_ME</span> <span style="color:#75715e"># bootstrap patches in the real name</span>
</span></span><span style="display:flex;"><span>        - <span style="color:#f92672">git</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">repoURL</span>: <span style="color:#ae81ff">https://gitlab.com/caseyrobb/iac.git</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">revision</span>: <span style="color:#ae81ff">HEAD</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">files</span>:
</span></span><span style="display:flex;"><span>              - <span style="color:#f92672">path</span>: <span style="color:#e6db74">&#34;clusters/{{ .clusterName }}/apps.yaml&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">template</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">spec</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">source</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">path</span>: <span style="color:#e6db74">&#34;apps/{{ .app }}&#34;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">helm</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">valueFiles</span>:
</span></span><span style="display:flex;"><span>          - <span style="color:#ae81ff">values.yaml</span>
</span></span><span style="display:flex;"><span>          - <span style="color:#e6db74">&#34;../../clusters/{{ .clusterName }}/{{ .app }}.yaml&#34;</span>
</span></span></code></pre></div><p>The file that generator reads is the most important opinion in the repo:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#75715e"># clusters/chaos/apps.yaml — app deploys ONLY if listed here.</span>
</span></span><span style="display:flex;"><span>- <span style="color:#f92672">app</span>: <span style="color:#ae81ff">argocd</span>
</span></span><span style="display:flex;"><span>- <span style="color:#f92672">app</span>: <span style="color:#ae81ff">cilium</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">namespaceOverride</span>: <span style="color:#ae81ff">kube-system</span>
</span></span><span style="display:flex;"><span>- <span style="color:#f92672">app</span>: <span style="color:#ae81ff">immich</span>
</span></span><span style="display:flex;"><span>- <span style="color:#f92672">app</span>: <span style="color:#ae81ff">keycloak</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">namespaceOverride</span>: <span style="color:#ae81ff">keycloak</span>
</span></span></code></pre></div><p><strong>The allowlist is the contract.</strong> Presence deploys; absence is a <em>deliberate
decision</em>, not an oversight, and CI enforces the inverse direction too.
Every chart under <code>apps/</code> must be claimed by at least one cluster, so dead
apps can&rsquo;t accrete. The sandbox cluster is the same generator pointed at a
different file, which means staging an app in prod is one line in a merge
request, reviewed like everything else.</p>
<p>Values layering follows the same philosophy: each wrapper chart carries its
<code>values.yaml</code> (defaults for all clusters) and an optional per-cluster overlay.
<code>ignoreMissingValueFiles: true</code> means overlays exist only where they earn their
place.</p>
<p>One honesty corner, because every GitOps blog pretends ignoreDifferences is
for cowards: I have exactly three, and each one is a documented truce. Two
Secrets whose <code>data</code> is written by the External Secrets controller (which
ArgoCD can never observe as Synced, so the alternative is a permanent red
badge and selfHeal ping-pong), and ServiceMonitors, where the
prometheus-operator defaulting webhook rewrites fields the chart never sent,
a perpetual OutOfSync that SSA applies and the webhook un-applies, forever.
Each rule states its reason in a comment. IgnoreDifferences without a written
reason is just drift with extra steps.</p>
<p>And because syncPolicy is <code>automated + selfHeal</code>, there&rsquo;s one operational rule
the whole cluster lives by: <strong>nothing is hot-patched.</strong> The fix for anything is
a revert-MR to master. The emergency lever is <code>git revert</code>, and it&rsquo;s the same
lever you use on a normal Tuesday.</p>
<h2 id="2-cni-is-the-platform-cilium-gateway-api-and-one-door">2. CNI is the platform: Cilium, Gateway API, and one door</h2>
<p>Cilium does far more than pod networking on this cluster: kube-proxy
replacement, native routing (which makes the BGP setup a post of its own,
see the <a href="/posts/k0s-cilium-bgpd/">OpenBGPD writeup</a>), Hubble for observability, Envoy as the L7
data-path, and the Gateway API implementation. It installs via the appset like
any app, into <code>kube-system</code> via <code>namespaceOverride</code>, from a wrapper chart that
just pins the upstream release.</p>
<p>Every public service on the cluster passes through <strong>one <code>Gateway</code></strong> and its
own one-screen <code>HTTPRoute</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">apiVersion</span>: <span style="color:#ae81ff">gateway.networking.k8s.io/v1</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">kind</span>: <span style="color:#ae81ff">Gateway</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">metadata</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">name</span>: <span style="color:#ae81ff">neozeon-gateway</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">namespace</span>: <span style="color:#ae81ff">kube-system</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">annotations</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">cert-manager.io/cluster-issuer</span>: <span style="color:#ae81ff">letsencrypt-prod</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">spec</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">gatewayClassName</span>: <span style="color:#ae81ff">cilium</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">listeners</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">https</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">protocol</span>: <span style="color:#ae81ff">HTTPS</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">port</span>: <span style="color:#ae81ff">443</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">hostname</span>: <span style="color:#e6db74">&#34;*.neozeon.ca&#34;</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">allowedRoutes</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">namespaces</span>: { <span style="color:#f92672">from</span>: <span style="color:#ae81ff">All }</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">tls</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">mode</span>: <span style="color:#ae81ff">Terminate</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">certificateRefs</span>:
</span></span><span style="display:flex;"><span>          - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">wildcard-neozeon-tls-secret</span>
</span></span></code></pre></div><p>Adding an app&rsquo;s public face is then pure declaration, an HTTPRoute in the
wrapper chart mapping <code>hostname + backend service</code> to the shared gateway.
No Ingress annotations zoo, no per-app TLS config, no load-balancer
provisioning: the gateway&rsquo;s <code>LoadBalancer</code> IP already exists (it&rsquo;s one of the
addresses Cilium advertises over BGP), and cert-manager already owns the
wildcard cert. The route is the only per-app decision left, which is exactly
how it should be.</p>
<h2 id="3-secrets-vault-as-the-source-of-truth-that-never-touches-git">3. Secrets: Vault as the source of truth that never touches git</h2>
<p>The secret-management posture fits in one sentence, and that&rsquo;s the point: <strong>no
secret material is ever committed to git or posted to chat.</strong> What lives in
git is <em>references</em>.</p>
<p>The chain: a HashiCorp Vault (one node, it&rsquo;s a homelab :p)
runs in-cluster on persistent storage; every credential lives under a <code>chaos/</code>
KV v2 mount, <code>chaos/rustfs/rustfs-secret</code>, <code>chaos/keycloak/operator-client</code>,
and so on. External Secrets Operator syncs Vault into Secrets through a single
<code>ClusterSecretStore</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">provider</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">vault</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">server</span>: <span style="color:#ae81ff">https://vault.neozeon.ca</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">path</span>: <span style="color:#ae81ff">chaos</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">version</span>: <span style="color:#ae81ff">v2</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">auth</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">kubernetes</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">mountPath</span>: <span style="color:#ae81ff">kubernetes</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">role</span>: <span style="color:#ae81ff">external-secrets</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">serviceAccountRef</span>:
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">name</span>: <span style="color:#ae81ff">external-secrets</span>
</span></span></code></pre></div><p>That <code>auth.kubernetes</code> block is the quiet hero. ESO authenticates to Vault by
exchanging the controller&rsquo;s own projected service-account token. No static
token in git, no static token in a Secret, no rotation ceremony. When I want
a new credential in the cluster, the flow is: put it in Vault, chart&rsquo;s
<code>ExternalSecret</code> template names the path, ArgoCD syncs. The cluster learns
secrets the same way it learns everything else: from a merge request.</p>
<p>The hygiene rules that grew around this are boring and absolute. Secrets that
ESO owns get <code>/data</code> ignored in ArgoCD (above), so selfHeal and ESO never
fight. Backups (CNPG&rsquo;s <code>ScheduledBackup</code> to an in-cluster RustFS S3 bucket)
read per-namespace <code>ExternalSecret</code>s for their S3 credentials, so the backup
path and the application path share one Vault source. One rotation, both
fixed. Finally, the credential for the <em>notification</em> system, <a href="https://pushover.net">pushover</a>, follows the same discipline living in Vault.</p>
<h2 id="4-storage-zfs-on-the-nas-iscsi-into-the-cluster-and-the-longhorn-war-i-retired">4. Storage: ZFS on the NAS, iSCSI into the cluster, and the longhorn war I retired</h2>
<p>Storage is the least fashionable infrastructure decision and the one that
decides whether you keep the cluster. Mine is deliberately old-fashioned: a
Debian 13 NAS with a raidz2 ZFS pool exports volumes over iSCSI, and
<strong>democratic-csi</strong> in the cluster converts iSCSI targets into PersistentVolumes
with a StorageClass <code>nas-iscsi</code> (<code>zfs-generic-iscsi</code> under the hood)
that everything uses:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">driver</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">existingConfigSecret</span>: <span style="color:#ae81ff">democratic-csi-iscsi</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">config</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">driver</span>: <span style="color:#ae81ff">zfs-generic-iscsi</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">storageClasses</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">nas-iscsi</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">reclaimPolicy</span>: <span style="color:#ae81ff">Delete</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">allowVolumeExpansion</span>: <span style="color:#66d9ef">true</span>
</span></span></code></pre></div><p>The cluster previously ran Longhorn, the Kubernetes-native block storage product with the lovely UI and
the replica-rebalancing opinions. On homelab hardware, it was a second
distributed system to babysit <em>in order to run</em> the other distributed systems:
upgrade windows for the storage layer, replica anti-affinity drama whenever a
node rebooted for kernel patches, and a recovery procedure that required the
cluster to be healthy to help the cluster be healthy. I
decommissioned it, migrated the volumes, and the config above is what
replaced it: ZFS snapshots and sends on the NAS, iSCSI to the nodes, plain
TCP over the switch (homelab-fast enough), and one k0s quirk: the
<code>node.kubeletHostPath</code> has to point at <code>/var/lib/k0s/kubelet</code>, because k0s
doesn&rsquo;t pretend to be kubeadm.</p>
<p>The trade is honest: no replicated volumes, so the NAS is a single point. I
accepted it the same way I accepted a single gateway router, with the same
upgrade path in my back pocket: a second NAS and DRBD or just very good
snapshots. What I got back is that storage now fails in ways I understand in
one layer instead of three. RustFS (an S3-compatible server, itself running
on these very volumes) then serves as the backup target for the Postgres
clusters (CloudNativePG writes Barman-format base backups and WAL straight
into it nightly), which means the storage stack is: ZFS → iSCSI → PVs →
apps <em>and</em> app-backups, with each hop boring enough to debug at 23:00.</p>
<h2 id="5-certificates-and-dns-the-wildcard-that-makes-the-edges-line-up">5. Certificates and DNS: the wildcard that makes the edges line up</h2>
<p>Two small operators finish the perimeter, and they&rsquo;re best explained together
because they solve one problem from both ends: <em>an app should become
reachable by writing a route</em>, not by filing tickets with yourself.</p>
<p><strong>cert-manager</strong> holds the ACME relationship with Let&rsquo;s Encrypt via a
<code>ClusterIssuer</code> in the wrapper chart, using the DNS-01 challenge through
Cloudflare, which is what makes the <code>*.neozeon.ca</code> wildcard certificate in
section 2 renewable forever without exposing an HTTP path:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">solvers</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">dns01</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">cloudflare</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">apiTokenSecretRef</span>:
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">name</span>: <span style="color:#ae81ff">cloudflare-api-token-secret</span> <span style="color:#75715e"># from Vault, via ESO</span>
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">key</span>: <span style="color:#ae81ff">api-token</span>
</span></span></code></pre></div><p>Yes: even cert-manager&rsquo;s Cloudflare API token is an ExternalSecret. There is
no bootstrap exception, which is why this section is short.</p>
<p><strong>external-dns</strong> reads Gateway API objects: <code>gateway-httproute</code>, <code>gateway-tlsroute</code>, <code>gateway-tcproute</code>, policy <code>sync</code>
and maintains the Cloudflare DNS records that mirror them. Create a route in
git, get a DNS record through the same tunnel the app uses; delete the route,
the record goes away. The cluster&rsquo;s public surface and its DNS are the same
list, which is the kind of statement you should distrust in any blog post,
including this one.</p>
<p>The asterisk: the Cloudflare zone deliberately has <em>no wildcard record</em>.
Every hostname is a real DNS entry (external-dns&rsquo;s doing) plus a manual
Cloudflare Tunnel route (mine). That manual step allows me to selectively expose
an app over the internet versus just being available via a RFC1918
address on my LAN.</p>
<h3 id="wrapping-up"><em>Wrapping Up</em></h3>
<p>Step back and the architecture is almost aggressively boring: one repo that a
generator matrix fans out into thirty-plus apps, one CNI wearing four hats, one
Gateway, one Vault, one NAS, one wildcard certificate. The interesting parts
aren&rsquo;t components, they&rsquo;re the contracts: the allowlist that makes absence
mean something, the wrapper chart that pins one version while upstream drifts,
the ExternalSecret that makes a credential deployable by ArgoCD, and the
selfHeal that makes every operator habit (like hot-patching) impossible to
form.</p>
<p>Boring is the goal. Boring is what &ldquo;production&rdquo; costs when you run it on
mini-PCs in my basement and want the weekend back.</p>
]]></content></item><item><title>Serving OpenShift VIPs from OpenBSD: relayd, pf, and unbound as Your Cluster's Load Balancer</title><link>https://neozeon.io/posts/ocp-relayd-unbound-pf/</link><pubDate>Sat, 17 Oct 2026 18:45:00 -0600</pubDate><guid>https://neozeon.io/posts/ocp-relayd-unbound-pf/</guid><description>&lt;h2 id="serving-openshift-vips-from-openbsd-relayd-pf-and-unbound"&gt;Serving OpenShift VIPs from OpenBSD: relayd, pf, and unbound&lt;/h2&gt;
&lt;p&gt;OpenShift is unusual among Kubernetes distros in how much it demands from your
network before it will install itself: three virtual IPs (API, internal API, and
wildcard ingress), DNS that resolves all three &lt;em&gt;before the first node boots&lt;/em&gt;,
and a healthcheck contract for your load balancer that is spelled out almost
to the millisecond in the install docs. In the cloud this is someone else&amp;rsquo;s
problem, on bare metal it is yours.&lt;/p&gt;</description><content type="html"><![CDATA[<h2 id="serving-openshift-vips-from-openbsd-relayd-pf-and-unbound">Serving OpenShift VIPs from OpenBSD: relayd, pf, and unbound</h2>
<p>OpenShift is unusual among Kubernetes distros in how much it demands from your
network before it will install itself: three virtual IPs (API, internal API, and
wildcard ingress), DNS that resolves all three <em>before the first node boots</em>,
and a healthcheck contract for your load balancer that is spelled out almost
to the millisecond in the install docs. In the cloud this is someone else&rsquo;s
problem, on bare metal it is yours.</p>
<p>In this post I&rsquo;ll build the entire gateway side of an OpenShift compact
cluster: the pf rules, a validated <code>relayd.conf</code> that satisfies OpenShift&rsquo;s
health-check spec exactly, a tiny shell probe for the ingress routers, and the
unbound DNS entries the installer will nag you about. The cluster deployment
itself (ignition, bootstrap, all that) is out of scope, this is the part that
lives on your router forever.</p>
<p>My platform is a Protectli v1210 running OpenBSD 8.0, the same little box that
already routes my LAN (<code>igc1</code>, 172.16.0.0/22) and <a href="/posts/k0s-cilium-bgpd/">peers BGP</a> with my other
clusters.</p>
<h2 id="the-architecture">The Architecture</h2>
<p>OpenShift needs three addresses fronting a three-node compact cluster:</p>
<ul>
<li><strong>API VIP</strong> (<code>172.16.0.70</code>) — fronts port 6443 (kube API), 22623 (Machine
Config Server / Ignition), and 22624 (MCS internal)</li>
<li><strong>Ingress VIP</strong> (<code>172.16.0.75</code>) — fronts ports 80/443 for every
<code>*.apps.ocp.redcomet.ca</code> route</li>
<li><strong>The three nodes</strong> (<code>ocp1</code>–<code>ocp3</code>, <code>172.16.0.71</code>–<code>.73</code>) — masters that also
run workloads</li>
</ul>
<pre tabindex="0"><code> clients ──6443/22623──►  ┌────────────────────────────────┐
                          │ OpenBSD gateway (Protectli)    │
 clients ────80/443───►   │  pf (rdr) + relayd + unbound   │
                          │  VIPs aliased on igc1          │
                          └──────────┬─────────────────────┘
                          ┌──────────┴──────────┐
                       ocp1 .71 ── ocp2 .72 ── ocp3 .73
</code></pre><p>The key decision: relayd runs as an <strong>L4 redirect</strong>, not an L7 proxy. In
<code>redirect</code> mode relayd arranges a pf <code>rdr-to</code>, meaning packets are <em>redirected</em>,
not terminated. The client&rsquo;s source IP arrives intact at the router pods, and
TLS passes through untouched. relayd never sees a key, and your apps never need
PROXY protocol or <code>X-Forwarded-For</code> gymnastics just because a load balancer
exists.</p>
<h2 id="1-aliases-and-pf">1. Aliases and pf</h2>
<p>First the VIPs must exist on the wire. They&rsquo;re aliases on the LAN interface,
persisted in <code>/etc/hostname.igc1</code>:</p>
<pre tabindex="0"><code>inet 172.16.0.1 255.255.252.0 172.16.3.255
inet 172.16.0.70 alias
inet 172.16.0.75 alias
</code></pre><p>Then pf. relayd injects its own <code>rdr-to</code> rules into an anchor, so the anchor
line is the one requirement; everything else is ordinary filter policy:</p>
<pre tabindex="0"><code class="language-pf" data-lang="pf"># --- OpenShift external LB (gateway side) ---
ocp_if     = &#34;igc1&#34;
ocp_nodes  = &#34;{ 172.16.0.71, 172.16.0.72, 172.16.0.73 }&#34;
ocp_apivip = &#34;172.16.0.70&#34;
ocp_ingvip = &#34;172.16.0.75&#34;

# relayd&#39;s redirect rules land here automatically
anchor &#34;relayd/*&#34;

# Explicit pass rules so ordering stays sane in a deny-by-default ruleset
pass in quick on $ocp_if inet proto tcp from any to $ocp_apivip port { 6443 22623 22624 }
pass in quick on $ocp_if inet proto tcp from any to $ocp_ingvip port { 80 443 }

# ... and never, ever forward 1936 (router stats/metrics)
</code></pre><p>Two details worth calling out. The <code>pass</code> rules are technically redundant, a
relayd redirect passes the traffic it redirects but being explicit keeps your
rule ordering auditable when the ruleset grows. And the <code>1936</code> comment deserves
emphasis: that&rsquo;s the HAProxy stats port on every router pod, and if it&rsquo;s
reachable from outside the cluster you&rsquo;re leaking your whole routing table as a
metric. The health check for it runs <em>from the gateway itself</em>, so no forward
rule is ever needed.</p>
<h2 id="2-relayd-openshifts-health-check-contract-in-60-lines">2. relayd: OpenShift&rsquo;s health-check contract, in 60 lines</h2>
<p>OpenShift documents its load balancer expectations precisely: probe the kube API
with a TLS <code>GET /readyz</code> expecting 200, the MCS with a TLS <code>GET /healthz</code>,
probe every 5–10 seconds, and a failed node must leave the pool within 30
seconds. Long-lived connections (<code>oc exec</code>, <code>oc port-forward</code>, watches,
websockets) must also survive. relayd can satisfy every clause. Here&rsquo;s the
config:</p>
<pre tabindex="0"><code class="language-relayd.conf" data-lang="relayd.conf"># /etc/relayd.conf — OpenShift external LB

apivip=&#34;172.16.0.70&#34;
ingvip=&#34;172.16.0.75&#34;

interval 10           # spec: probe every 5-10s
timeout 3000          # check timeout ms; generous for TLS handshakes
log state changes

table &lt;ocp_api&gt;   { 172.16.0.71 retry 2, 172.16.0.72 retry 2, 172.16.0.73 retry 2 }
table &lt;ocp_mcs&gt;   { 172.16.0.71 retry 2, 172.16.0.72 retry 2, 172.16.0.73 retry 2 }
table &lt;ocp_mcs24&gt; { 172.16.0.71 retry 2, 172.16.0.72 retry 2, 172.16.0.73 retry 2 }
table &lt;ocp_http&gt;  { 172.16.0.71 retry 2, 172.16.0.72 retry 2, 172.16.0.73 retry 2 }
table &lt;ocp_https&gt; { 172.16.0.71 retry 2, 172.16.0.72 retry 2, 172.16.0.73 retry 2 }

# kube API: TLS GET /readyz -&gt; 200 (docs-compliant unauthenticated readiness)
redirect &#34;ocp-api&#34; {
 listen on $apivip port 6443
 forward to &lt;ocp_api&gt; port 6443 timeout 1500 \
  check send &#34;GET /readyz HTTP/1.0\r\n\r\n&#34; expect &#34;HTTP/1.[01] 200*&#34; tls
 session timeout 3600
}

# Machine Config Server / Ignition: TLS GET /healthz -&gt; 200
redirect &#34;ocp-mcs&#34; {
 listen on $apivip port 22623
 forward to &lt;ocp_mcs&gt; port 22623 timeout 1500 \
  check send &#34;GET /healthz HTTP/1.0\r\n\r\n&#34; expect &#34;HTTP/1.[01] 200*&#34; tls
 session timeout 3600
}

# MCS internal (kubelet config-driver): TCP only
redirect &#34;ocp-mcs24&#34; {
 listen on $apivip port 22624
 forward to &lt;ocp_mcs24&gt; port 22624 timeout 1500 check tcp
 session timeout 3600
}

# Ingress: L4 passthrough on 80/443, readiness probed on router stats port 1936
redirect &#34;ocp-http&#34; {
 listen on $ingvip port 80
 forward to &lt;ocp_http&gt; port 80 timeout 1500 \
  check script &#34;/usr/local/bin/ocp_router_check&#34;
 session timeout 3600
}

redirect &#34;ocp-https&#34; {
 listen on $ingvip port 443
 forward to &lt;ocp_https&gt; port 443 timeout 1500 \
  check script &#34;/usr/local/bin/ocp_router_check&#34;
 session timeout 3600
}
</code></pre><p>Reading the highlights:</p>
<ul>
<li><strong><code>check send/expect ... tls</code></strong> speaks just enough HTTP over TLS to ask the
API server if it&rsquo;s ready. No certificates, no client auth, no dependencies.
Interval 10 with per-host <code>retry 2</code> puts a dead node out of the pool in
roughly 30 seconds: exactly the documented maximum, no tighter.</li>
<li><strong><code>session timeout 3600</code></strong> beats relayd&rsquo;s 600-second default so an
<code>oc rsh</code> into a debugging pod doesn&rsquo;t evaporate mid-sentence.</li>
<li><strong>Five tables, not one</strong> — the ingress table membership should differ from
the API table the moment you add dedicated infra nodes; starting with five
identical tables means adding a node later is one line, not a redesign.</li>
</ul>
<h3 id="the-ingress-check-where-relayd-gets-weird">The ingress check: where relayd gets weird</h3>
<p>The router readiness probe lives on port <strong>1936</strong> (<code>GET /healthz/ready</code> on the
HAProxy stats interface) but relayd health checks always target the
<em>forwarded</em> port, and there&rsquo;s no check-port override (haproxy has one; relayd
doesn&rsquo;t). The escape hatch is <code>check script</code>, which also comes with the single
most counterintuitive rule in <code>relayd.conf(5)</code>:</p>
<blockquote>
<p>the script &ldquo;expects a positive return value on success and zero on failure&rdquo;</p>
</blockquote>
<p><strong>Exit nonzero = healthy.</strong> Exit 0 = down. It&rsquo;s inverted from every monitoring
convention you know, and no syntax error will warn you. Misread it and every
node reports down forever. Here&rsquo;s the probe, including two more traps it
dodges:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-sh" data-lang="sh"><span style="display:flex;"><span><span style="color:#75715e">#!/bin/sh
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"># /usr/local/bin/ocp_router_check &lt;ip&gt;</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># relayd &#39;check script&#39; probe: router readiness on stats port 1936</span>
</span></span><span style="display:flex;"><span>h<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;</span>$1<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">[</span> -n <span style="color:#e6db74">&#34;</span>$h<span style="color:#e6db74">&#34;</span> <span style="color:#f92672">]</span> <span style="color:#f92672">||</span> exit <span style="color:#ae81ff">0</span>
</span></span><span style="display:flex;"><span>r<span style="color:#f92672">=</span><span style="color:#66d9ef">$(</span> <span style="color:#f92672">(</span>printf <span style="color:#e6db74">&#39;GET /healthz/ready HTTP/1.0\r\n\r\n&#39;</span>; sleep 0.2<span style="color:#66d9ef">)</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>     | /usr/bin/nc -w <span style="color:#ae81ff">1</span> <span style="color:#e6db74">&#34;</span>$h<span style="color:#e6db74">&#34;</span> <span style="color:#ae81ff">1936</span> 2&gt;/dev/null | /usr/bin/head -n1 <span style="color:#f92672">)</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">case</span> <span style="color:#e6db74">&#34;</span>$r<span style="color:#e6db74">&#34;</span> in
</span></span><span style="display:flex;"><span>  *<span style="color:#e6db74">&#34; 200&#34;</span>*<span style="color:#f92672">)</span> exit <span style="color:#ae81ff">1</span> ;;   <span style="color:#75715e"># positive -&gt; relayd marks host UP</span>
</span></span><span style="display:flex;"><span>  *<span style="color:#f92672">)</span>        exit <span style="color:#ae81ff">0</span> ;;   <span style="color:#75715e"># zero    -&gt; relayd marks host DOWN</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">esac</span>
</span></span></code></pre></div><ul>
<li>The script runs as <code>_relayd</code> with a minimal PATH.</li>
<li>It requests a plain path because httpd&rsquo;s <code>GET /healthz/ready</code> on a <em>directory</em>
returns a 301 redirect, and this probe doesn&rsquo;t follow redirects.</li>
</ul>
<p>Install both files, then <code>rcctl enable relayd &amp;&amp; rcctl restart relayd</code>, and
watch it think:</p>
<pre tabindex="0"><code># relayctl show hosts
172.16.0.71   100%  6/6   up    (3.12s)
172.16.0.72   100%  6/6   up    (2.87s)
172.16.0.73   100%  6/6   up    (3.01s)
</code></pre><h2 id="3-unbound-the-dns-the-installer-will-hold-you-hostage-over">3. unbound: the DNS the installer will hold you hostage over</h2>
<p>OpenShift resolves <code>api.&lt;cluster-domain&gt;</code> and <code>api-int.&lt;cluster-domain&gt;</code> from
<em>inside</em> the cluster. MCO and etcd will not tolerate <code>api-int</code> failing to
resolve and every application route hangs off a wildcard for the apps domain.
The three records that matter, in <code>/var/unbound/etc/unbound.conf</code>:</p>
<pre tabindex="0"><code>server:
    # ... usual homelab server config ...

    local-zone: &#34;ocp.redcomet.ca.&#34; redirect
    local-data: &#34;api.ocp.redcomet.ca.       3600 IN A 172.16.0.70&#34;
    local-data: &#34;api-int.ocp.redcomet.ca.   3600 IN A 172.16.0.70&#34;
    local-data: &#34;*.apps.ocp.redcomet.ca.    3600 IN A 172.16.0.75&#34;
</code></pre><p>Plus the boring-but-vital node records (<code>ocp1</code>–<code>ocp3</code> → <code>.71</code>–<code>.73</code>) in the
regular LAN zone so the hostnames, the DHCP leases, and <code>relayctl</code> output all
agree.</p>
<p>The wildcard is the one everyone forgets: without <code>*.apps</code> pointing at the
ingress VIP, your first <code>oc expose</code> &ldquo;works&rdquo; perfectly and then times out, and
the error message will not mention DNS. Note that <code>redirect</code> is what makes the
wildcard legal, unbound only synthesizes wildcard answers inside a declared
redirect zone. If the records silently don&rsquo;t resolve, that&rsquo;s usually why.</p>
<h2 id="verifying-the-whole-path-before-openshift-exists">Verifying the whole path before OpenShift exists</h2>
<p>The beauty of doing the LB first is that you can <em>prove</em> it before a single
RHCOS disk is written:</p>
<pre tabindex="0"><code># relayctl show hosts        # all nodes up in all five tables
# relayctl show redirects    # all five redirects &#34;active&#34;
$ curl -k https://172.16.0.70:6443/readyz      # mock 200 (on the node!)
$ dig +short api.ocp.redcomet.ca               # 172.16.0.70
$ dig +short foo.apps.ocp.redcomet.ca          # 172.16.0.75
</code></pre><p>The failover test is the confidence-builder: pull one node&rsquo;s IP off the wire
and watch <code>relayctl show hosts</code> drop it to <code>down</code> in <strong>all five tables</strong> within 30 seconds while the
redirects stay active with two hosts. Then re-add it and watch the check
counts climb back.</p>
<p>One OpenShift-specific dance remains, and it&rsquo;s easy to trip over on install
day: the <strong>bootstrap host</strong> serves Ignition on port 22623 to the masters. If
your MCS table only contains the three nodes, the masters boot, can&rsquo;t fetch
their ignition, and everyone stares at a timeout. Add the bootstrap host&rsquo;s IP
to <code>&lt;ocp_api&gt;</code> and <code>&lt;ocp_mcs&gt;</code> before you start (<code>relayctl</code> or edit and
reload), and remove it when the install completes.</p>
<h3 id="wrapping-up"><em>Wrapping Up</em></h3>
<p>Three small pieces on one unassuming x86 box: pf aliases and rules, a 60-line
relayd config with one inverted-exit-code shell probe, and three unbound
records. An OpenShift installer that normally assumes it&rsquo;s running in a cloud now finds
a compliant enterprise-style load balancer waiting on the LAN. relayd&rsquo;s
<code>redirect</code> mode means OpenShift never learns a proxy exists: real source IPs,
untouched TLS, no application changes.</p>
<p>Is the single gateway a load-balancer SPOF? Yes, and for a homelab that&rsquo;s a
fine trade with a clean upgrade path: a second OpenBSD, <code>carp(4)</code> VIPs,
relayd on both if I ever choose to go down that path.</p>
]]></content></item><item><title>Using OpenBSD's OpenBGPD and Cilium for k0s Networking</title><link>https://neozeon.io/posts/k0s-cilium-bgpd/</link><pubDate>Fri, 16 Oct 2026 13:31:00 -0700</pubDate><guid>https://neozeon.io/posts/k0s-cilium-bgpd/</guid><description>&lt;h2 id="using-openbsds-openbgpd-and-cilium-for-k0s-networking"&gt;Using OpenBSD&amp;rsquo;s OpenBGPD and Cilium for k0s Networking&lt;/h2&gt;
&lt;p&gt;If you&amp;rsquo;re running a bare-metal or on-premises Kubernetes cluster, one of the biggest challenges is making your services accessible to the rest of your network. While NodePorts and traditional reverse proxies work, running BGP (Border Gateway Protocol) natively gives you a much more elegant, scalable, and true-to-cloud experience.&lt;/p&gt;
&lt;p&gt;In this post, we&amp;rsquo;ll walk through how to configure an OpenBSD router running &lt;strong&gt;OpenBGPD&lt;/strong&gt; to peer with a &lt;strong&gt;k0s&lt;/strong&gt; Kubernetes cluster using &lt;strong&gt;Cilium&lt;/strong&gt; as the CNI (Container Network Interface). This allows Cilium to announce LoadBalancer IP pools and Pod CIDRs directly to your network&amp;rsquo;s core router.&lt;/p&gt;</description><content type="html"><![CDATA[<h2 id="using-openbsds-openbgpd-and-cilium-for-k0s-networking">Using OpenBSD&rsquo;s OpenBGPD and Cilium for k0s Networking</h2>
<p>If you&rsquo;re running a bare-metal or on-premises Kubernetes cluster, one of the biggest challenges is making your services accessible to the rest of your network. While NodePorts and traditional reverse proxies work, running BGP (Border Gateway Protocol) natively gives you a much more elegant, scalable, and true-to-cloud experience.</p>
<p>In this post, we&rsquo;ll walk through how to configure an OpenBSD router running <strong>OpenBGPD</strong> to peer with a <strong>k0s</strong> Kubernetes cluster using <strong>Cilium</strong> as the CNI (Container Network Interface). This allows Cilium to announce LoadBalancer IP pools and Pod CIDRs directly to your network&rsquo;s core router.</p>
<h2 id="the-architecture">The Architecture</h2>
<p>Our lab setup uses the following topology:</p>
<ul>
<li><strong>Gateway / Router:</strong> OpenBSD 8.0 on a Protectli v1210 (pf, OpenBGPD, unbound and isc-dhcpd all on one box)</li>
<li><strong>Kubernetes Distro:</strong> k0s v1.36.4 on Minisforum MS-A2 mini PCs (three control plane nodes, also running workloads, plus three workers), CentOS Stream 10</li>
<li><strong>CNI:</strong> Cilium 1.20.3 (kube-proxy replacement, BGP control plane enabled)</li>
<li><strong>k0s Subnet:</strong> <code>10.30.0.0/24</code> (VLAN 30)</li>
<li><strong>OpenBSD VLAN30 Interface IP:</strong> <code>10.30.0.1</code></li>
<li><strong>OpenBSD ASN:</strong> <code>65000</code></li>
<li><strong>Cilium Nodes ASN:</strong> <code>65030</code></li>
</ul>
<p>Each node is dual-homed: it has an address on the LAN (<code>172.16.0.0/22</code>) where
kubectl&rsquo;s <code>InternalIP</code> comes from, and an address on the Kubernetes VLAN where
BGP peering and LoadBalancer IPs live. The VLAN octets deliberately mirror the
LAN: control plane is <code>10.30.0.41</code>-<code>10.30.0.43</code>, workers are <code>10.30.0.31</code>–<code>10.30.0.33</code>
which makes debugging on either side much easier.</p>
<p>Let&rsquo;s break down the OpenBSD configurations needed to make this routing magic happen.</p>
<hr>
<h2 id="1-setting-up-the-dhcp-scope-dhcpdconf">1. Setting Up the DHCP Scope (<code>dhcpd.conf</code>)</h2>
<p>First, we need to ensure our k0s nodes have stable IP addresses within our designated Kubernetes VLAN (VLAN 30). We define the subnet in <code>/etc/dhcpd.conf</code>, assigning the OpenBSD box (<code>10.30.0.1</code>) as both the default gateway and the DNS server.</p>
<pre tabindex="0"><code class="language-dhcp.conf" data-lang="dhcp.conf"># VLAN 30 - k0s
subnet 10.30.0.0 netmask 255.255.255.192 {
    option routers 10.30.0.1;
    option domain-name-servers 10.30.0.1;
    range 10.30.0.2 10.30.0.62;

    host k0s-w01 {
      hardware ethernet fc:aa:14:00:11:22;
      fixed-address 172.16.0.31;
      option host-name &#34;k0s-w01&#34;;
    }
    ...
}
</code></pre><p>This allocates a generous /26 block for our cluster nodes, providing plenty of room for horizontal scaling.</p>
<hr>
<h2 id="2-firewall-and-routing-policies-pfconf">2. Firewall and Routing Policies (<code>pf.conf</code>)</h2>
<p>OpenBSD&rsquo;s PF (Packet Filter) is incredibly powerful, but to get BGP working, we need to explicitly allow the BGP protocol (TCP port 179) to pass between the k0s nodes and the router.</p>
<pre tabindex="0"><code class="language-pf.conf" data-lang="pf.conf"># Interfaces and Networks
k8s_if    = &#34;vlan30&#34;
k8s_net   = &#34;10.30.0.0/24&#34;

# k0s Node IP Definitions (control plane + workers — the CP peers too!)
k8s_nodes = &#34;{ 10.30.0.31, 10.30.0.32, 10.30.0.33, 10.30.0.41, 10.30.0.42, 10.30.0.43 }&#34;

# Tables
table &lt;k8s&gt; const { $k8s_net }
table &lt;puffy&gt; const { 10.30.0.1 }

### NAT ###
# Ensure outbound traffic from internal networks can route correctly
match out on $k8s_if from 172.16.0.0/22 to any nat-to 10.30.0.1

### Allow BGP Peering ###
# Pass BGP (TCP 179) traffic between k0s nodes and the OpenBSD router
pass in log on $k8s_if proto tcp from $k8s_nodes to 10.30.0.1 port 179
pass out log on $k8s_if proto tcp from 10.30.0.1 to $k8s_nodes port 179

# General Node Connectivity
pass out log on $k8s_if from 10.30.0.1 to any
pass in log on $k8s_if from $k8s_nodes to any

### DNS Redirect to Unbound ###
# Transparently redirect cluster DNS queries to local unbound instance
pass in log quick on $k8s_if proto { tcp udp } from &lt;k8s&gt; to &lt;puffy&gt; port domain rdr-to 127.0.0.1
pass in log quick on $k8s_if proto tcp from &lt;k8s&gt; to &lt;puffy&gt; port 853 rdr-to 127.0.0.1
</code></pre><p><strong>Key Takeaways from the PF config:</strong></p>
<ol>
<li><strong>BGP Rules</strong>: We explicitly pass <code>TCP port 179</code> in both directions. Without this, OpenBGPD will never establish a neighbor relationship with Cilium.</li>
<li><strong>DNS Redirection</strong>: Nodes pointing to the gateway for DNS are seamlessly routed to the local <code>unbound</code> resolver on OpenBSD.</li>
</ol>
<hr>
<h2 id="3-configuring-openbgpd-bgpdconf">3. Configuring OpenBGPD (<code>bgpd.conf</code>)</h2>
<p>Now for the star of the show. We need to configure OpenBGPD to listen on the Kubernetes interface and establish peering sessions with the Cilium daemon running on each k0s node.</p>
<p>Since the OpenBSD router and the Cilium nodes have different ASNs (<code>65000</code> vs <code>65030</code>), this will be an eBGP (External BGP) peering setup.</p>
<pre tabindex="0"><code class="language-bgpd.conf" data-lang="bgpd.conf"># /etc/bgpd.conf

AS 65000
router-id 10.30.0.1
listen on 10.30.0.1
network 10.30.0.0/24

# Define our k0s cluster workers
group &#34;cilium-nodes&#34; {
    remote-as 65030
    neighbor 10.30.0.41 { descr &#34;k0s-m01&#34; }
    neighbor 10.30.0.42 { descr &#34;k0s-m02&#34; }
    neighbor 10.30.0.43 { descr &#34;k0s-m03&#34; }
    neighbor 10.30.0.31 { descr &#34;k0s-w01&#34; }
    neighbor 10.30.0.32 { descr &#34;k0s-w02&#34; }
    neighbor 10.30.0.33 { descr &#34;k0s-w03&#34; }
}

# Filtering: Accept the Cilium LoadBalancer IP Pool
allow from group &#34;cilium-nodes&#34;
allow to group &#34;cilium-nodes&#34;
allow from AS 65030
</code></pre><p><strong>What&rsquo;s happening here?</strong></p>
<ul>
<li><code>listen on 10.30.0.1</code>: Instructs the BGP daemon to listen for connections on the router&rsquo;s VLAN 30 IP.</li>
<li><code>group &quot;cilium-nodes&quot;</code>: Groups all the k0s nodes together. Setting <code>remote-as 65030</code> ensures the router expects the incoming BGP requests from Cilium to identify as AS 65030.</li>
<li><code>allow from AS 65030</code>: This filter is crucial. It tells OpenBSD to accept the route advertisements that Cilium pushes (e.g., your Kubernetes LoadBalancer IP addresses).</li>
</ul>
<h2 id="4-the-cilium-side-the-part-that-actually-advertises">4. The Cilium side (the part that actually advertises)</h2>
<p>The current Cilium BGP
control plane (enabled with <code>bgpControlPlane.enabled: true</code> in helm values)
speaks in four cooperating CRDs.</p>
<p><strong>The IP pool.</strong> Cilium hands out <code>LoadBalancer</code> addresses from an explicit
pool — pick a block inside your VLAN range that DHCP won&rsquo;t hand out, and clear
of the router and node addresses:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">apiVersion</span>: <span style="color:#ae81ff">cilium.io/v2</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">kind</span>: <span style="color:#ae81ff">CiliumLoadBalancerIPPool</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">metadata</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">name</span>: <span style="color:#ae81ff">k0s-lb-pool</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">spec</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">blocks</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">cidr</span>: <span style="color:#ae81ff">10.30.0.128</span><span style="color:#ae81ff">/26</span> <span style="color:#75715e"># range .129 - .190</span>
</span></span></code></pre></div><p><strong>What gets advertised.</strong> A <code>CiliumBGPAdvertisement</code> says which route classes
leave the cluster — I announce both Pod CIDRs (which pairs nicely with Cilium&rsquo;s
native routing mode) and every Service <code>LoadBalancerIP</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">apiVersion</span>: <span style="color:#ae81ff">cilium.io/v2</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">kind</span>: <span style="color:#ae81ff">CiliumBGPAdvertisement</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">metadata</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">name</span>: <span style="color:#ae81ff">bgp-advertisement</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">labels</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">advertise</span>: <span style="color:#ae81ff">bgp-openbsd</span> <span style="color:#75715e"># peer configs select on this label</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">spec</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">advertisements</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">advertisementType</span>: <span style="color:#ae81ff">PodCIDR</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">advertisementType</span>: <span style="color:#ae81ff">Service</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">service</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">addresses</span>:
</span></span><span style="display:flex;"><span>          - <span style="color:#ae81ff">LoadBalancerIP</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">selector</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">matchLabels</span>: {} <span style="color:#75715e"># every Service, no filter</span>
</span></span></code></pre></div><p><strong>Session behavior.</strong> Timers and graceful restart live in a <code>CiliumBGPPeerConfig</code>,
and the label selector above binds it to the advertisement:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">apiVersion</span>: <span style="color:#ae81ff">cilium.io/v2</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">kind</span>: <span style="color:#ae81ff">CiliumBGPPeerConfig</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">metadata</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">name</span>: <span style="color:#ae81ff">openbsd-peer-config</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">spec</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">timers</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">holdTimeSeconds</span>: <span style="color:#ae81ff">90</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">keepAliveTimeSeconds</span>: <span style="color:#ae81ff">30</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">gracefulRestart</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">enabled</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">restartTimeSeconds</span>: <span style="color:#ae81ff">120</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">families</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">afi</span>: <span style="color:#ae81ff">ipv4</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">safi</span>: <span style="color:#ae81ff">unicast</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">advertisements</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">matchLabels</span>:
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">advertise</span>: <span style="color:#e6db74">&#34;bgp-openbsd&#34;</span>
</span></span></code></pre></div><p><strong>The peering itself</strong> is a <code>CiliumBGPClusterConfig</code>: my ASN, a node selector,
and one peer pointing back at the router from section 3. Note how it closes
the loop with the gateway&rsquo;s <code>bgpd.conf</code> (ASNs in mirror image, and every node
in the gateway&rsquo;s <code>neighbor</code> list must also match this <code>nodeSelector</code>):</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">apiVersion</span>: <span style="color:#ae81ff">cilium.io/v2</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">kind</span>: <span style="color:#ae81ff">CiliumBGPClusterConfig</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">metadata</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">name</span>: <span style="color:#ae81ff">cilium-bgp</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">spec</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">nodeSelector</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">matchLabels</span>:
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">kubernetes.io/os</span>: <span style="color:#ae81ff">linux</span> <span style="color:#75715e"># every node — including the control plane</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">bgpInstances</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">cilium-bgp</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">localASN</span>: <span style="color:#ae81ff">65030</span>
</span></span><span style="display:flex;"><span>      <span style="color:#f92672">peers</span>:
</span></span><span style="display:flex;"><span>        - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">puffy</span> <span style="color:#75715e"># OpenBSD&#39;s mascot earns its place</span>
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">peerAddress</span>: <span style="color:#ae81ff">10.30.0.1</span>
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">peerASN</span>: <span style="color:#ae81ff">65000</span>
</span></span><span style="display:flex;"><span>          <span style="color:#f92672">peerConfigRef</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">name</span>: <span style="color:#ae81ff">openbsd-peer-config</span>
</span></span></code></pre></div><p>One gotcha worth knowing up front: these CRDs ship with <strong>schema defaults</strong>
(<code>ebgpMultihop</code>, <code>connectRetryTimeSeconds</code>, and the timer/graceful-restart
values). The API server injects them into your live objects, so <code>kubectl get -o yaml</code> shows fields you never wrote.</p>
<h3 id="seeing-it-work">Seeing it work</h3>
<p>After applying, the router tells the whole story:</p>
<pre tabindex="0"><code># doas bgpctl show
Neighbor                   AS    MsgRcvd    MsgSent  OutQ Up/Down  State/PrfRcvd
k0s-m01                 65030        169        173     0 01:23:00      4
k0s-m02                 65030        170        176     0 01:23:06      4
k0s-m03                 65030        170        179     0 01:23:07      4
k0s-w01                 65030        170        173     0 01:23:02      4
k0s-w02                 65030        170        179     0 01:23:10      4
k0s-w03                 65030          0          0     0 01:24:50 Connect
</code></pre><p>Six established sessions (my control plane peers too, see the <code>k8s_nodes</code> macro above), each with a fistful of prefixes
received from Cilium. On the cluster side:</p>
<pre tabindex="0"><code># kubectl -n kube-system exec &lt;cilium-pod&gt; -c cilium-agent -- cilium bgp peers
Local AS   Peer AS   Peer Address    Session       Uptime     Family         Received   Advertised
65030      65000     10.30.0.1:179   established   1h26m13s   ipv4/unicast   8          4
</code></pre><p>And in the pool, the addresses my <code>LoadBalancer</code> Services actually pulled:
<code>.129</code> for the shared-mode ingress, <code>.130</code> for the Gateway API entrypoint that
serves every <code>*.neozeon.ca</code> hostname, <code>.131</code> for the pf-dashboard stack. Create
a new <code>LoadBalancer</code> Service and a new IP appears in the pool, a route appears
in <code>bgpctl show rib</code>. And because the gateway&rsquo;s routes cover the whole /24, a
<code>curl</code> from any machine on the network works as a smoke-test. No port forwarding, no
reverse-proxy hop just to reach a cluster service from the LAN.</p>
<h3 id="further-reading">Further reading</h3>
<ul>
<li>Cilium&rsquo;s <a href="https://docs.cilium.io/en/stable/network/bgp-control-plane/bgp-control-plane/">BGP control plane guide</a> and <a href="https://docs.cilium.io/en/stable/network/lb-ipam/">LB IPAM docs</a> — the canonical reference for the four CRDs above (and the ECMP rabbit hole you&rsquo;ll want before advertising Service IPs from every node).</li>
<li>OpenBSD&rsquo;s <a href="https://man.openbsd.org/bgpd.conf.5">bgpd.conf(5)</a> and <a href="https://man.openbsd.org/bgpd.8">bgpd(8)</a> (which covers the <code>bgpctl</code> client) — the filtering language deserves a read even for a setup this small.</li>
<li>The <a href="https://man.openbsd.org/pf.conf.5">pf docs</a> if you want to understand why those BGP <code>pass</code> rules are written the way they are.</li>
<li><a href="https://docs.k0sproject.io/stable/">k0s documentation</a> for the distro itself — BGP lives entirely in the CNI, so any conformant cluster works.</li>
</ul>
<h3 id="wrapping-up"><em>Wrapping Up</em></h3>
<p>The complete chain is: pf passes TCP 179, OpenBGPD accepts the sessions and
the advertised prefixes, and four small CRDs make every Cilium node a tiny
router that knows exactly which IPs it owns. OpenBSD&rsquo;s minimalist,
secure-by-default philosophy combined with Cilium&rsquo;s eBPF datapath makes
homelab networking behave like a cloud VPC and every piece of it fits in
about a page of Git.</p>
]]></content></item></channel></rss>