<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://michaeloleary.net/feed.xml" rel="self" type="application/atom+xml" /><link href="https://michaeloleary.net/" rel="alternate" type="text/html" /><updated>2026-09-30T23:24:49+00:00</updated><id>https://michaeloleary.net/feed.xml</id><title type="html">Michael’s tech blog</title><subtitle>Basic tech blog and notes.</subtitle><author><name>Michael O&apos;Leary</name></author><entry><title type="html">Second Brain, part 2</title><link href="https://michaeloleary.net/ai/second-brain-2/" rel="alternate" type="text/html" title="Second Brain, part 2" /><published>2026-09-30T00:00:00+00:00</published><updated>2026-09-30T00:00:00+00:00</updated><id>https://michaeloleary.net/ai/second-brain-2</id><content type="html" xml:base="https://michaeloleary.net/ai/second-brain-2/"><![CDATA[<p><a href="/ai/second-brain-1/">Part 1</a> was my first iteration of an <a href="https://fdicostanzo.com/posts/2026/20260427_karpathy_llm_second">AI Second Brain</a>. Two days in and I have some changes to document.</p>

<figure>
    <a href="/assets/second-brain/ai-brain.jpg"><img src="/assets/second-brain/ai-brain.jpg" /></a>
    <figcaption>AI Second Brain</figcaption>
</figure>

<p>The short version: I swapped Antigravity for Claude Code via VS Code, switched LLM from Gemini to Claude, cleaned up the instruction architecture, and added a few plugins to Obsidian.</p>

<hr />

<h3 id="why-i-swapped-tools">Why I Swapped Tools</h3>

<p>Part 1 used <strong>Antigravity</strong> from Google. This is a decent agent with a good UI, but two annoying things got me to migrate to Claude Code:</p>
<ul>
  <li>it was constantly prompting me for permission to do anything and I couldn’t just blanket allow operations on local files</li>
  <li>recently I’ve been getting “model unavailable” errors with retries in a fall-back fashion. Super annoying and basically unuseable.</li>
</ul>

<p><strong>Claude Code</strong> — Anthropic’s agentic CLI — is built for exactly this. It runs an autonomous loop, issues its own file reads and writes, and doesn’t drift mid-task. The VS Code extension puts a Claude Code terminal in the same window as your vault. For a system that manages files as its primary output, this is the right tool. Also, many colleagues use it so I have people to go to.</p>

<hr />

<h3 id="model-switch-gemini--claude-sonnet">Model switch: Gemini → Claude Sonnet</h3>

<p>Antigravity used Gemini by default. Claude Code lets you pin a specific model in <code class="language-plaintext highlighter-rouge">settings.json</code>:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
</pre></td><td class="rouge-code"><pre><span class="w">	</span><span class="nl">"claudeCode.environmentVariables"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"ANTHROPIC_AUTH_TOKEN"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"value"</span><span class="p">:</span><span class="w"> </span><span class="s2">"xxxxx"</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"ANTHROPIC_BASE_URL"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"value"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://&lt;intranet_url&gt;/anthropic"</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
            </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"ANTHROPIC_MODEL"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"value"</span><span class="p">:</span><span class="w"> </span><span class="s2">"claude-sonnet-4-6"</span><span class="w">
    </span><span class="p">}</span><span class="w">
</span><span class="p">]</span><span class="w">
</span></pre></td></tr></tbody></table></code></pre></div></div>

<p>I’m using Claude Sonnet 4.6. For a second brain, the job is following complex multi-step instructions reliably — not reasoning over hard math problems or generating creative writing. Sonnet hits the right balance of speed, cost, and instruction-following for this use case. Opus is more capable but noticeably slower for what is essentially document management.</p>

<hr />

<h3 id="updating-the-scaffolding">Updating the scaffolding</h3>

<p>Part 1’s setup wasn’t terrible but there was some overlap:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">CLAUDE.md</code></strong> — Claude Code’s instruction file</li>
  <li><strong><code class="language-plaintext highlighter-rouge">AGENTS.md</code></strong> — the universal agent rulebook for any AI tool</li>
  <li><strong><code class="language-plaintext highlighter-rouge">.agent/rules/leadership-rules.md</code></strong> — a condensed copy for tools that load rules from that path</li>
</ul>

<p><strong>The fix:</strong> <code class="language-plaintext highlighter-rouge">CLAUDE.md</code> is now 25 lines. It delegates everything to <code class="language-plaintext highlighter-rouge">AGENTS.md</code> and only adds Claude Code-specific configuration — which skills directory to use, where memory lives, file naming conventions. <code class="language-plaintext highlighter-rouge">AGENTS.md</code> is the single source of truth. <code class="language-plaintext highlighter-rouge">.agent/rules/leadership-rules.md</code> now just says “read AGENTS.md.”</p>

<p><code class="language-plaintext highlighter-rouge">AGENTS.md</code> also gained five new sections: a daily notes standard, inbox routing rules, staleness thresholds (14-day follow-up flag, 21-day 1:1 flag, 60-day radar archive recommendation), a <code class="language-plaintext highlighter-rouge">LOG.md</code> spec, and definitions for the new slash commands.</p>

<hr />

<h3 id="five-new-slash-commands">Five New Slash Commands</h3>

<table>
  <thead>
    <tr>
      <th>Command</th>
      <th>What It Does</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/morning</code></td>
      <td>Reads open follow-ups, flags overdue items, suggests top 3 priorities for the day</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/capture &lt;note&gt;</code></td>
      <td>Drops a timestamped note into <code class="language-plaintext highlighter-rouge">inbox/</code> without context-switching</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/process-inbox</code></td>
      <td>Routes every file in <code class="language-plaintext highlighter-rouge">inbox/</code> to its correct vault location</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/weekly-review</code></td>
      <td>Digest from the week’s daily notes plus staleness check</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/lint</code></td>
      <td>Health check: broken links, stale follow-ups, orphaned pages</td>
    </tr>
  </tbody>
</table>

<p><code class="language-plaintext highlighter-rouge">/morning</code> is the one I expect to use daily. It’s read-only, takes about ten seconds, and gives me a clear starting point without having to open the master index manually.</p>

<p><code class="language-plaintext highlighter-rouge">/lint</code> is the one I’m most curious about long-term. The staleness problem — notes that quietly become outdated — killed every previous note system I tried. Having an automated health check changes the maintenance model from “trust yourself to remember” to “trust the agent to flag it.”</p>

<hr />

<h3 id="people-profile-frontmatter">People Profile Frontmatter</h3>

<p>Every person ledger in <code class="language-plaintext highlighter-rouge">people/</code> now has YAML frontmatter:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
</pre></td><td class="rouge-code"><pre><span class="nn">---</span>
<span class="na">name</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">Name</span><span class="pi">]</span>
<span class="na">role</span><span class="pi">:</span> <span class="s">Senior Solutions Architect</span>
<span class="na">status</span><span class="pi">:</span> <span class="s">active</span>
<span class="na">last_updated</span><span class="pi">:</span> <span class="s2">"</span><span class="s">2026-09-30"</span>
<span class="na">last_1on1</span><span class="pi">:</span> <span class="s2">"</span><span class="s">—"</span>
<span class="nn">---</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>This is the piece that enables everything else. Dataview queries read from frontmatter. <code class="language-plaintext highlighter-rouge">/lint</code> compares <code class="language-plaintext highlighter-rouge">last_1on1</code> against today’s date. The <code class="language-plaintext highlighter-rouge">/morning</code> brief surfaces anyone with an overdue cadence. Without frontmatter, all of this requires the agent to parse prose — fragile and slow. With it, the data is structured and queryable.</p>

<p>The agent is also required to update these fields on every write. The dates stay accurate automatically.</p>

<hr />

<h3 id="obsidian-extensions-dataview--omnisearch">Obsidian extensions: Dataview + Omnisearch</h3>

<p>Two community plugins:</p>

<p><strong>Dataview</strong> turns Obsidian into a live database over your vault. The master index used to be a manually-maintained Markdown table. It now renders from a live query:</p>

<pre><code class="language-dataview">TABLE WITHOUT ID
  link(file.path, name) AS "Name",
  role AS "Role",
  last_1on1 AS "Last 1:1",
  last_updated AS "Last Updated"
FROM "people"
WHERE status = "active" AND role != "Manager (skip-level)"
SORT last_1on1 ASC
</code></pre>

<p>The table sorts ascending by last 1:1 date, so the people I most need to check in with are always at the top. When the agent updates frontmatter after a debrief, the table updates automatically. No manual editing.</p>

<p>I also created <code class="language-plaintext highlighter-rouge">dashboard.md</code> — a new file with six live views: 1:1s never formally logged, the full team sorted by last 1:1, active initiatives, radar items, recent daily notes, and inbox status. It’s the first file I open each day.</p>

<p><strong>Omnisearch</strong> replaces Obsidian’s built-in search with a fuzzy full-text engine that actually works. Searching for a customer or topic across dozens of daily notes returns results instantly. Install it, forget Obsidian has a native search.</p>

<hr />

<h3 id="logmd-the-audit-trail">LOG.md: The Audit Trail</h3>

<p>Every file mutation the agent makes now gets one appended line in <code class="language-plaintext highlighter-rouge">LOG.md</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>- 2026-09-30 `/1on1-debrief Dave`: updated `people/dave-remington.md` (1:1 history, follow-ups, frontmatter), `INDEX.md`
</pre></td></tr></tbody></table></code></pre></div></div>

<p>This matters for two reasons. First, debugging: when the agent does something unexpected, there’s a record of what it changed and when. Second, trust: I was more comfortable giving the agent write access to my vault once I knew I could audit its actions without reading every file.</p>

<p><code class="language-plaintext highlighter-rouge">LOG.md</code> is append-only by instruction. Neither I nor the agent edits prior entries.</p>

<hr />

<h3 id="whats-next---maybe">What’s Next - maybe</h3>

<ul>
  <li><strong>Templater + Periodic Notes:</strong> daily notes are still created manually. Templater auto-creates <code class="language-plaintext highlighter-rouge">daily/YYYY-MM-DD.md</code> on schedule with the right structure. This week’s task.</li>
  <li><strong>Claude Desktop + MCP:</strong> the Obsidian MCP plugin exposes your vault to Claude Desktop, so you can query it from the Claude.ai interface without opening VS Code. Useful for mobile and quick lookups.</li>
  <li><strong>Terminal plugin:</strong> runs a shell inside Obsidian’s sidebar. Means I can type <code class="language-plaintext highlighter-rouge">/morning</code> without switching apps.</li>
  <li><strong>Canvas team map:</strong> a visual node graph of the team, color-coded by specialty and development path. Good for skip-level prep and org conversations with leadership.</li>
</ul>

<p>The core principle from Part 1 holds: the agent is the programmer, the vault is the codebase. What changed is the quality of the tooling. Better agentic loop, cleaner instructions, structured data. The system is more reliable because it makes fewer demands on me to keep it consistent.</p>

<hr />

<p><strong>Summary:</strong> Switched from Antigravity to <strong>Claude Code</strong> for its agentic file loop. Pinned <strong>Claude Sonnet 4.6</strong> in <code class="language-plaintext highlighter-rouge">settings.json</code>. Reduced <code class="language-plaintext highlighter-rouge">CLAUDE.md</code> to a thin delegation layer, made <code class="language-plaintext highlighter-rouge">AGENTS.md</code> the single source of truth, and added five new slash commands. Added YAML frontmatter to every people profile. Installed <strong>Dataview</strong> and <strong>Omnisearch</strong> in Obsidian, replaced the static master index table with a live query, and created <code class="language-plaintext highlighter-rouge">dashboard.md</code> as the default home view. Added <strong>LOG.md</strong> as an append-only audit trail for all agent mutations.</p>]]></content><author><name>Michael O&apos;Leary</name></author><category term="ai" /><category term="second-brain" /><category term="obsidian" /><category term="claude" /><category term="vscode" /><category term="llm" /><category term="productivity" /><category term="people-management" /><summary type="html"><![CDATA[Upgrading from Antigravity to Claude Code, switching models, cleaning up the instruction architecture, and wiring Obsidian's Dataview plugin into the corpus.]]></summary></entry><entry><title type="html">Second brain, part 1</title><link href="https://michaeloleary.net/ai/second-brain-1/" rel="alternate" type="text/html" title="Second brain, part 1" /><published>2026-09-28T00:00:00+00:00</published><updated>2026-09-28T00:00:00+00:00</updated><id>https://michaeloleary.net/ai/second-brain-1</id><content type="html" xml:base="https://michaeloleary.net/ai/second-brain-1/"><![CDATA[<p>For years, Microsoft OneNote was my default operational brain. As an individual contributor I could track most of my work here. More recently, as a people leader, each of my direct reports had a shared notebook in OneDrive where we tracked shared work. I also tracked about a dozen active conversations and ephemeral issues that came and went, as well as other stuff.</p>

<figure>
    <a href="/assets/second-brain/one-note.jpg"><img src="/assets/second-brain/one-note.jpg" /></a>
    <figcaption>I grabbed this screenshot from a blog post from 2016. Looks like of old-fashioned now in 2026, doesn't it? My personal OneNote is much busier than this image.</figcaption>
</figure>

<p>Here is how I (mostly) replaced OneNote with a local, agent-operated markdown Second Brain that stays private, backs up to OneDrive, and avoids lock-in to any single AI vendor.</p>

<hr />
<h3 id="whats-with-onenote">What’s with OneNote?</h3>
<p>Manual use of OneNote works, but feels outdated in the age of AI. As a people leader, you share info with your team, but you also track dozens of micro-initiatives, proposals, and sometimes things that are private and should not be shared.</p>

<p>OneNote is breaking under that weight:</p>
<ul>
  <li><strong>Filing friction:</strong> Forcing you to decide where a fleeting thought belongs upfront means half your notes get lost on generic pages.</li>
  <li><strong>Sluggish search:</strong> Grepping across years of tabs and sections to recall a specific technical escalation is painful.</li>
  <li><strong>Stenographer syndrome:</strong> Typing during 1:1s in 2026 kills active listening and eye contact.</li>
</ul>

<p>However, OneNote is at least a manual, local tool. I don’t have to worry (much) about corporate spyware, and I know that only I have access to Notebooks that are not shared. So there is certainly a place for it.</p>

<hr />
<h3 id="the-architecture-decoupling-storage-viewer-and-intelligence">The Architecture: Decoupling Storage, Viewer, and Intelligence</h3>
<p>I see proprietary AI note tools (Notion, Mem, Roam) as a trap. For one, I don’t know if I can control where my data resides or which model my data may transit. If corporate IT blocks them or your company mandates a new platform, your data is trapped.</p>

<p>A resilient second brain decouples three layers:</p>
<ol>
  <li><strong>Storage (OneDrive):</strong> Plain text Markdown (<code class="language-plaintext highlighter-rouge">.md</code>) files stored in a synced OneDrive folder. It stays local to your machine, backs up automatically, and complies with enterprise DLP rules.</li>
  <li><strong>Viewer (Obsidian):</strong> Opens the OneDrive folder directly as an interactive wiki with clickable links (<code class="language-plaintext highlighter-rouge">[[people/alice]]</code>) and instant fuzzy search. There is <strong>no local web server to start up</strong> (<code class="language-plaintext highlighter-rouge">localhost:8080</code>), no build step, and zero database lock-in.</li>
  <li><strong>Intelligence (The AI Agent):</strong> Antigravity, Claude Code, or Cursor. The agent acts as a filing clerk—ingesting brain dumps, cross-linking files, updating status tables, and pulling pre-meeting context.</li>
</ol>

<hr />
<h3 id="how-agents-actually-work-with-your-corpus-token-economics">How Agents Actually Work With Your Corpus (Token Economics)</h3>
<p>A common misconception is that an AI agent sends your entire folder to the model with every prompt. 
If you have 50 files totaling 80,000 words, sending that entire corpus on every prompt would be slow, expensive, and drown the LLM in irrelevant context. 
Modern agentic assistants interact with your files using <strong>selective tool execution</strong>:</p>
<ol>
  <li><strong>Workspace Discovery:</strong> The agent inspects <code class="language-plaintext highlighter-rouge">AGENTS.md</code> and discovers specialized skills (like <code class="language-plaintext highlighter-rouge">/1on1-debrief</code>).</li>
  <li><strong>Targeted Reading:</strong> When you say <em>“Debrief with Alice”</em>, the agent checks <code class="language-plaintext highlighter-rouge">INDEX.md</code> to resolve the filename, reads <strong>only</strong> <code class="language-plaintext highlighter-rouge">people/alice.md</code>, and ignores the rest of your team’s files.</li>
  <li><strong>Intent Disambiguation:</strong> A well-instructed agent separates general knowledge from private data. If you ask <em>“Where is Bank of America headquartered?”</em>, it answers from its base training without touching your files. If you ask <em>“What did Alice say about the BoA migration?”</em>, it reads the relevant customer and personal files.</li>
  <li><strong>State Mutation:</strong> It generates the user-facing summary in chat, appends private coaching notes to <code class="language-plaintext highlighter-rouge">people/alice.md</code>, and updates the master tracking table in <code class="language-plaintext highlighter-rouge">INDEX.md</code>.</li>
</ol>

<hr />
<h3 id="the-anti-lock-in-rule-switching-models-and-agents-in-60-seconds">The Anti-Lock-In Rule: Switching Models and Agents in 60 Seconds</h3>
<p>I use Antigravity today, but corporate tooling shifts. I refuse to build a system I can’t migrate in a minute.
We achieve portability through open standards:</p>
<ul>
  <li><strong>Universal Instructions (<code class="language-plaintext highlighter-rouge">AGENTS.md</code>):</strong> All operational rules—confidentiality, intent separation, and formatting templates—live in a root <code class="language-plaintext highlighter-rouge">AGENTS.md</code> file. If I switch to Claude Code, <code class="language-plaintext highlighter-rouge">CLAUDE.md</code> points to it. If I use Cursor, <code class="language-plaintext highlighter-rouge">.cursorrules</code> points to it.</li>
  <li><strong>Model Agnostic:</strong> Standard Markdown and YAML frontmatter mean the prompt setup works with any model—Gemini, Claude, or open-source.</li>
</ul>

<hr />
<h3 id="key-file-best-practices">Key File Best Practices</h3>
<p>A clean directory structure keeps the agent fast and token usage low:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
</pre></td><td class="rouge-code"><pre>second-brain/
├── HOWTO.md          # Human reference manual read in Obsidian
├── AGENTS.md         # Universal operating rules read by AI agents
├── INDEX.md          # Master register of people, initiatives, and follow-ups
├── people/           # Confidential direct report ledgers
├── initiatives/      # Project cloud (active, radar, archive)
└── daily/            # Chronological daily timeline logs
</pre></td></tr></tbody></table></code></pre></div></div>

<ul>
  <li><strong>HOWTO.md</strong>: The human cheat sheet visible in Obsidian. Lists command shortcuts, voice dictation tips, and natural language query recipes.</li>
  <li><strong>INDEX.md</strong>: The agent’s compass. Maintaining a single table of active initiatives, team members, and open commitments lets the agent locate files without traversing the whole folder tree.</li>
  <li><strong>people/[name].md</strong>: Separates public from confidential. Shared 1:1 bullet points live in one section; private coaching notes or career goals live under a Manager Eyes Only section that the agent is strictly forbidden from sharing. (To be clear, I don’t put anything sensitive in this second brain. I will manually take notes or sometimes keep conversations verbal. I do not trust AI models with personal details, even corporate-approved models. I’m certainly willing to do this work manually.)</li>
  <li><strong>initiatives/</strong>: Broken into active/ (in-flight projects) and radar/ (low-touch signals or rumors). If a radar item heats up, the agent promotes it; if it goes stale for 60 days, it moves to archive/.</li>
</ul>

<h3 id="the-11-workflow-in-practice-voice--custom-skills">The 1:1 Workflow in Practice: Voice + Custom Skills</h3>
<p>My team is used to our standard 5-header format in their shared OneNote pages. I didn’t want to disrupt their habits.</p>

<ol>
  <li>
    <p><strong>Zero Typing During the Call</strong>
During the 1:1, I stay fully present. No typing, no distraction.</p>
  </li>
  <li>
    <p><strong>The 60-Second Voice Debrief (Win + H)</strong>
Immediately after the meeting, I open Antigravity, click into chat, hit Win + H for Windows voice dictation, and speak naturally:</p>
  </li>
</ol>

<blockquote>
  <p><code class="language-plaintext highlighter-rouge">/1on1-debrief Alice</code>. Customer activity: Spent two days assisting Acme Corp on an architecture review. SA initiatives: Started building an automated lab script for team enablement. Other F5 things: Attended all-hands. Personal: Taking Friday off. Follow-ups: I will send her the lab credentials by Tuesday, and Alice will share her draft doc by Thursday. Private reflection: She seemed confident and energized about the new project.</p>
</blockquote>

<ol start="3">
  <li><strong>Deterministic Output &amp; Vault Sync</strong>
The /1on1-debrief skill executes in seconds and outputs the exact copy-pasteable block:</li>
</ol>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
</pre></td><td class="rouge-code"><pre>#Customer Activity
- Acme Corp: Assisted with architecture review.

#SA initiatives / enablement
- Automated Lab Script: Developing deployment script for team enablement.

#Other F5 things
- Attended quarterly all-hands.

#Personal
- PTO: Taking Friday off.

#Follow-ups
- [ ] Mike: Send lab credentials by Tuesday.
- [ ] Alice: Share draft documentation by Thursday.
</pre></td></tr></tbody></table></code></pre></div></div>

<p>I copy that block into Alice’s shared OneNote page in two seconds.</p>

<p>Meanwhile, Antigravity silently logs the private reflection (“<em>Seemed confident and energized…</em>”) into <code class="language-plaintext highlighter-rouge">people/alice.md</code>, registers my follow-up commitment in <code class="language-plaintext highlighter-rouge">INDEX.md</code>, and updates her last meeting date.</p>

<p>Next week, asking <code class="language-plaintext highlighter-rouge">/prep-1on1 Alice</code> automatically surfaces my open commitments and previous discussion points before we jump on the call.</p>

<h3 id="to-do">To Do</h3>
<p>Now that I’ve written this post there’s a few things I want to do:</p>
<ol>
  <li>Switch from Antigravity to Claude Code. I am not loving Antigravity.</li>
  <li>Find a way to make my Wiki a bit prettier. Now that I’ve read similar blog posts from people that know more than me, I have room for improvement here.</li>
  <li>I’ll switch from Gemini (current LLM for corporate reasons) to a different model, also for corporate reasons.</li>
</ol>

<h3 id="summary">Summary</h3>

<p>Tools and AI vendors will rotate. By keeping your second brain in <strong>local Markdown</strong>, syncing through <strong>OneDrive</strong>, viewing it in <strong>Obsidian</strong>, and instructing your AI assistant via <strong>AGENTS.md</strong>, your system remains private, responsive, and completely under your control.</p>]]></content><author><name>Michael O&apos;Leary</name></author><category term="ai" /><category term="ai" /><category term="antigravity" /><summary type="html"><![CDATA[My first attempt at a LLM Wiki / Second Brain.]]></summary></entry><entry><title type="html">Faucer washer replacement</title><link href="https://michaeloleary.net/home%20projects/washer-fix/" rel="alternate" type="text/html" title="Faucer washer replacement" /><published>2026-09-20T00:00:00+00:00</published><updated>2026-09-20T00:00:00+00:00</updated><id>https://michaeloleary.net/home%20projects/washer-fix</id><content type="html" xml:base="https://michaeloleary.net/home%20projects/washer-fix/"><![CDATA[<p>Simple washer change needed for my upstairs bathroom faucet, but worth a post since I learned a couple things.</p>

<figure>
    <a href="/assets/faucets/header.jpg"><img src="/assets/faucets/header.jpg" /></a>
    <figcaption>The vintage porcelain faucet setup in our 1930s bathroom.</figcaption>
</figure>
<hr />

<h3 id="problem-1-hot-water-would-not-turn-off">Problem #1: Hot water would not turn off</h3>

<p>I’m not sure what my kid did, but he yelled for help as he constantly turned the hot water faucet clockwise but the water would not stop flowing. I tried to quickly trace a water line from our water heater to somewhere near the bathroom but couldn’t find a shut off, so I shut off the main water to the whole house. Then I unscrewed the faucet and stem by turning it counter-clockwise. The whole stem came out and I saw there was no washer left on the end of the stem.</p>

<figure>
    <a href="/assets/faucets/stem-no-washer.jpg"><img src="/assets/faucets/stem-no-washer.jpg" /></a>
    <figcaption>Notice there is no washer left at all.</figcaption>
</figure>

<p>In retrospect, the fact that I didn’t have to use a wrench at all, and simply turning the faucet counter-clockwise removed the entire stem assembly, probably means the stem body threads were loose or altogether unscrewed after being loose for a while.</p>

<p>Also, I found the half-disintegrated washer stuck in the pipe in the wall. I don’t know what to call that “pipe”, but Gemini suggests I called it the the faucet body, or fixture body.</p>

<figure class="third ">
  
    
      <a href="/assets/faucets/stem-washer.jpg">
          <img src="/assets/faucets/stem-washer.jpg" alt="" />
      </a>
    
  
    
      <a href="/assets/faucets/thread-diagram.jpg">
          <img src="/assets/faucets/thread-diagram.jpg" alt="" />
      </a>
    
  
    
      <a href="/assets/faucets/fixture-body.jpg">
          <img src="/assets/faucets/fixture-body.jpg" alt="" />
      </a>
    
  
  
    <figcaption>Different threads and washer found
</figcaption>
  
</figure>

<p>At this point I thought it would be a simple replacement of that rubber washer.</p>

<h3 id="problem-2-a-sheared-brass-stub">Problem #2: A Sheared Brass Stub</h3>

<p>Look closely at the photo of the stem with no washer: the head of the brass bibb screw holding the old rubber washer had completely sheared off. How am I supposed to replace that washer when there’s no screw to hold it down? There was only a tiny stub of the old screw left in there, but no way to grip it to screw it out.</p>

<h4 id="trial-error-and-crumbling-brass">Trial, Error, and Crumbling Brass</h4>

<p>My first instinct was to grip the stub with pliers — no luck. So I sprayed WD-40 around that stub and went to bed, hoping it would loosen the headless screw overnight and the pliers would work in the morning - still, no luck.</p>

<p>I clamped the stem into a vise and bought mini hacksaw with a blade with 24 TPI (teeth per inch), intending to cut a clean slot across the stub face to back it out with a flathead screwdriver. I have no picture of this, but unfortunately, 100-year-old brass is deceptively soft. As soon as I applied counterclockwise pressure with the screwdriver, the notched brass simply crumbled apart, leaving me with a tiny, rounded nub and no surface left to grip.</p>

<h4 id="the-precision-drilling-operation">The Precision Drilling Operation</h4>

<p>With no material left to grab, drilling was the only option left. Because both the screw stub and the surrounding stem body were made of soft brass, any drill bit drift would instantly ruin the internal threads. The drill would have to perfectly be aligned. I kind of eye-balled it and got lucky.</p>

<p>I started with a tiny pilot bit, keeping the drill perfectly perpendicular to the face.</p>

<p>In the photo below, the pilot hole is drilled and you can see the first drill bit was thinner than the screw itself. I took this picture with my bibb screws in hand because I uploaded it to Gemini, asking AI which of the two sizes of bibb screw it thought I would be replacing this with.</p>

<figure>
    <a href="/assets/faucets/pilot-drilled.jpg"><img src="/assets/faucets/pilot-drilled.jpg" /></a>
    <figcaption>The pilot hole is drilled and you can see the first drill bit was thinner than the screw itself.</figcaption>
</figure>

<p>From there, the key was stepping up incrementally without overshooting. I expanded the hole to 1/8”, not quite to 9/64”, which is the exact root diameter for a standard #8 bibb screw. Going slow paid off: I don’t remember if it was the 1/8” or the 9/64” bit, but one of them suddenly made a noise like something loosened. I stopped immediately and pulled out my drill.</p>

<p>It had hollowed out the screw core, leaving behind a paper-thin spiral shell of old brass threads that peeled right out on the bit tip. Check out the photos!</p>

<figure class="third ">
  
    
      <a href="/assets/faucets/old-screw-on-drill-bit-1.jpg">
          <img src="/assets/faucets/old-screw-on-drill-bit-1.jpg" alt="" />
      </a>
    
  
    
      <a href="/assets/faucets/old-screw-on-drill-bit-2.jpg">
          <img src="/assets/faucets/old-screw-on-drill-bit-2.jpg" alt="" />
      </a>
    
  
    
      <a href="/assets/faucets/old-screw-on-drill-bit-3.jpg">
          <img src="/assets/faucets/old-screw-on-drill-bit-3.jpg" alt="" />
      </a>
    
  
  
    <figcaption>The old screw came out and the inner threads were left it tact! (As far as I can tell)
</figcaption>
  
</figure>

<hr />

<h3 id="washer-12-flat">Washer: 1/2 flat</h3>

<p>Mostly for the sake of a note so I don’t forget, I used 1/2 inch flat washers (not bevelled) because the screw wasn’t long enough to hold a bevelled washer, which is thicker. A replacement bibb screw holds this in nicely.</p>

<figure class="half ">
  
    
      <a href="/assets/faucets/replacement-bibb-screw.jpg">
          <img src="/assets/faucets/replacement-bibb-screw.jpg" alt="" />
      </a>
    
  
    
      <a href="/assets/faucets/replacement-washer.jpg">
          <img src="/assets/faucets/replacement-washer.jpg" alt="" />
      </a>
    
  
  
    <figcaption>Photos below show replacement bibb screw and washer. I ended up using flat, not bevelled, washer. I think the photo shows a bevelled washer
</figcaption>
  
</figure>

<h3 id="another-note-packing-nut">Another note: packing nut</h3>

<p>The packing nut is the smaller, front-most hex nut—the one closest to the splined shaft where your handle attaches. Tightening this nut squeezes the internal packing seal against the shaft to stop leaks coming out along the stem.</p>

<figure>
    <a href="/assets/faucets/packing-nut.jpg"><img src="/assets/faucets/packing-nut.jpg" /></a>
    <figcaption>I had to turn this one quarter turn clockwise to slightly tighten, in order to stop a very small leak coming down the stem and out by the faucet.</figcaption>
</figure>

<h3 id="another-note-slow-drain">Another note: slow drain</h3>

<p>Lastly, this bathtub has never drained very fast. It’s not too slow, but I thought I could speed it up. Long story short, I was able to get some hair and scum from the drain with a thin plastic snake, but it hasnt’ improved much. I was able to confirm the slowness is in the horizontal drain pipe from underneath the bath to the vertical drain pipe right behind the bath in the wall. When I removed the barrel plunger from the cylinder (picture below), I couldnt find any hair or blockage to clear.</p>

<figure>
    <a href="/assets/faucets/barrel-plunger.jpg"><img src="/assets/faucets/barrel-plunger.jpg" /></a>
    <figcaption></figcaption>
</figure>

<hr />

<h3 id="finishing-up">Finishing up</h3>

<p>Reinstalled the stem with new washer, turned on mains, and faucet works nicely! I did the cold water faucet proactively. The old washer on the cold stem turned out to be a flat washer (looking a hair wider than my standard 1/2” pack, likely an old 1/2L size) that had turned completely stiff since it’s last replacement.</p>

<p>Now, shutting off both hot and cold side feels soft and smooth! Of course, showing my kids how the stem is screwed into the wall was the most satisfying part for me.</p>]]></content><author><name>Michael O&apos;Leary</name></author><category term="home projects" /><category term="home projects" /><summary type="html"><![CDATA[A few quick photos to remind myself what to do next time a faucet leaks.]]></summary></entry><entry><title type="html">Mulesoft + BIG-IP with OpenShift</title><link href="https://michaeloleary.net/openshift/mulesoft-on-openshift-behind-bigip/" rel="alternate" type="text/html" title="Mulesoft + BIG-IP with OpenShift" /><published>2026-07-28T00:00:00+00:00</published><updated>2026-07-28T00:00:00+00:00</updated><id>https://michaeloleary.net/openshift/mulesoft-on-openshift-behind-bigip</id><content type="html" xml:base="https://michaeloleary.net/openshift/mulesoft-on-openshift-behind-bigip/"><![CDATA[<figure>
    <a href="/assets/mulesoft-f5-demo/mulesoft-proxy-big-ip.png"><img src="/assets/mulesoft-f5-demo/mulesoft-proxy-big-ip.png" /></a>
    <figcaption>Traffic flow from outside -&gt; inside the cluster.</figcaption>
</figure>

<p><strong>Same edge pattern, a very different gateway — and a trail of real-world friction worth documenting</strong></p>

<p>In an <a href="/openshift/kong-on-openshift-behind-bigip/">earlier post</a> I put open-source Kong behind an F5 BIG-IP on OpenShift and showed the two are complementary: the ADC owns the edge (TLS, WAF, pod-direct load balancing), the gateway owns API management (routing, rate-limiting, auth). The whole point is that the pattern is <strong>modular</strong> — the edge layer and API gateway layer can be switched for alternatives.</p>

<p>I’ll show this again. This time the in-cluster gateway is <strong>MuleSoft Omni Gateway</strong> — the product formerly (and still, in most logs and docs) called <strong>Flex Gateway</strong>. Same BIG-IP, same CIS, same OpenShift cluster, same “client → BIG-IP → gateway → backends” architecture. Only the middle layer changed.</p>

<p>But where the Kong write-up was a clean “here’s the architecture” piece, this one is a <strong>field report</strong>. Omni Gateway on Kubernetes had some troubleshooting - at least for me, with a trial license, learning by doing. If you’re a platform engineer about to try this, the goal here is to save you that day.</p>

<p>The end state:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>client ──HTTPS──▶ F5 BIG-IP ──HTTP──▶ Omni Gateway ──▶ backend apps
                  (TLS + LB via CIS)   (routing + rate-limit + JWT)
</pre></td></tr></tbody></table></code></pre></div></div>

<p>I stood up <code class="language-plaintext highlighter-rouge">https://mulesoft.my-f5.com/</code>, edge TLS on the BIG-IP, CIS configured with OpenShift Route, Omni Gateway enforcing path routing, rate limiting, and JWT auth against two backends on <code class="language-plaintext highlighter-rouge">/a</code> and <code class="language-plaintext highlighter-rouge">/b</code>.</p>

<blockquote class="notice--info">
  <p><strong>A note on branding.</strong> “Omni Gateway” is the current name; “Flex Gateway” is what you’ll find in almost all documentation, the Helm chart (<code class="language-plaintext highlighter-rouge">flex-gateway</code>), the container image (<code class="language-plaintext highlighter-rouge">mulesoft/flex-gateway:1.13.3</code>), and every log line (<code class="language-plaintext highlighter-rouge">flex-gateway-agent</code>, <code class="language-plaintext highlighter-rouge">flex-gateway-envoy</code>). If you’re searching for docs, search for <strong>Flex Gateway</strong>. I use “Omni Gateway” for the product and “Flex Gateway” wherever the software actually says so.</p>
</blockquote>

<hr />

<h3 id="part-1--the-setup-journey">Part 1 — The setup journey</h3>

<h4 id="the-trap---connectedtrue">The trap: <code class="language-plaintext highlighter-rouge">--connected=true</code></h4>

<p>I created a trial login on https://anypoint.mulesoft.com/, logged into Anypoint platform, and clicked <code class="language-plaintext highlighter-rouge">Runtimes &gt; Runtime Manager &gt; Omni Gateways &gt; Self-Managed Gateways &gt; Add Self-Managed Omni Gateway</code>.</p>

<p>This gives me instructions to deploy a Gateway on Openshift. First step is to run a docker command that will generate a registration.yaml file to be used when deploying the gateway via helm. Token and Org GUID’s are populated - I’ve blurred my screenshots but you will have your own in your instructions.</p>

<figure class="half ">
  
    
      <a href="/assets/mulesoft-f5-demo/anypoint-screenshot-1.png" title="Screenshot 1: adding a gateway">
          <img src="/assets/mulesoft-f5-demo/anypoint-screenshot-1.png" alt="Screenshot 1: adding a gateway" />
      </a>
    
  
    
      <a href="/assets/mulesoft-f5-demo/anypoint-screenshot-2.png" title="Screenshot 2: adding a gateway">
          <img src="/assets/mulesoft-f5-demo/anypoint-screenshot-2.png" alt="Screenshot 2: adding a gateway" />
      </a>
    
  
  
    <figcaption>Screenshots from AnyPoint Runtime Manager
</figcaption>
  
</figure>

<p>Note this line from the instructions in the screenshot:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>--connected=true
</pre></td></tr></tbody></table></code></pre></div></div>

<p>I pasted it, registered, deployed the Helm chart, and the pod <strong>never went Ready</strong> in K8s. Not crash-looping — just a readiness probe that never passed. And the maddening part: the logs showed <em>no errors</em>. The container was up. The process was running. It simply wasn’t serving.</p>

<p>I did the whole checklist:</p>

<ul>
  <li><strong>SCC?</strong> Checked (it was a problem too — see below — but not <em>this</em> problem).</li>
  <li><strong>kubectl describe</strong> Fine.</li>
  <li><strong>Pod logs</strong> Nothing obvious I could see.</li>
  <li><strong>DNS / service resolution?</strong> Clean.</li>
</ul>

<p>The breakthrough came from reading the <em>full</em> startup log rather than grepping for <code class="language-plaintext highlighter-rouge">error</code>. In <strong>connected mode</strong>, the gateway expects its configuration to come down from the Anypoint control plane — API Manager contracts, Runtime Manager config. It is <strong>not</strong> looking at Kubernetes-native resources. So my <code class="language-plaintext highlighter-rouge">ApiInstance</code> and <code class="language-plaintext highlighter-rouge">PolicyBinding</code> CRDs were simply ignored, no listener was ever configured for them, and readiness — which depends on a configured listener — never flipped to true. No error, because from the gateway’s point of view nothing was wrong. It was patiently waiting for a control plane that was never going to talk to it.</p>

<p><strong>The fix</strong> is to register in <em>local</em> mode instead:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
</pre></td><td class="rouge-code"><pre><span class="c"># Registration is immutable per (name, org). You cannot flip an existing</span>
<span class="c"># registration from connected to local — you must register a NEW name.</span>
docker run <span class="nt">--entrypoint</span> flexctl <span class="nt">-u</span> <span class="nv">$UID</span>   <span class="nt">-v</span> <span class="s2">"</span><span class="si">$(</span><span class="nb">pwd</span><span class="si">)</span><span class="s2">"</span>:/registration mulesoft/flex-gateway <span class="se">\ </span>
  registration create <span class="nt">--organization</span><span class="o">=</span>xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx <span class="se">\</span>
  <span class="nt">--token</span><span class="o">=</span>xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx   <span class="se">\</span>
  <span class="nt">--output-directory</span><span class="o">=</span>/registration   <span class="se">\</span>
  <span class="nt">--connected</span><span class="o">=</span><span class="nb">false</span> <span class="se">\ </span><span class="c">## this line is key when creating your registration.yaml file.</span>
  my-gateway3  
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Then deploy with the chart in local mode:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>helm <span class="nt">-n</span> gateway upgrade <span class="nt">-i</span> <span class="nt">--create-namespace</span> ingress flex-gateway/flex-gateway   <span class="nt">--set-file</span> registration.content<span class="o">=</span>registration.yaml
</pre></td></tr></tbody></table></code></pre></div></div>

<p><strong>The learning:</strong> registration mode is the <em>first</em> architectural decision, and the most consequential one. <code class="language-plaintext highlighter-rouge">connected</code> vs <code class="language-plaintext highlighter-rouge">local</code> is not a fallback you can toggle later — it determines whether your gateway is configured by Anypoint contracts or by Kubernetes CRDs. Pick wrong and you get a silent readiness failure.</p>

<p><strong>Bonus UX papercut — the vocabulary problem.</strong> This one setting has <em>four</em> names depending on where you look:</p>

<table>
  <thead>
    <tr>
      <th>Where</th>
      <th>Term</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Wizard / CLI flag</td>
      <td><code class="language-plaintext highlighter-rouge">--connected=true/false</code></td>
    </tr>
    <tr>
      <td>Helm value</td>
      <td><code class="language-plaintext highlighter-rouge">gateway.mode=local</code></td>
    </tr>
    <tr>
      <td>Older docs / deprecated flags</td>
      <td><code class="language-plaintext highlighter-rouge">disconnected</code></td>
    </tr>
    <tr>
      <td>Runtime logs</td>
      <td><code class="language-plaintext highlighter-rouge">Mode=offline</code></td>
    </tr>
  </tbody>
</table>

<p><code class="language-plaintext highlighter-rouge">connected=false</code> = <code class="language-plaintext highlighter-rouge">mode=local</code> = <code class="language-plaintext highlighter-rouge">disconnected</code> = <code class="language-plaintext highlighter-rouge">offline</code>. Same thing, four words. Worth knowing when you’re grepping logs.</p>

<h4 id="the-scc-storm">The SCC storm</h4>

<p>Once registration mode was fixed, the pod got <em>further</em> and then hit OpenShift’s Security Context Constraints — twice, in quick succession.</p>

<p><strong>1. UID range.</strong> The gateway image wants to run as a specific built-in user. OpenShift’s default <code class="language-plaintext highlighter-rouge">restricted-v2</code> SCC forces a randomized UID from the namespace’s allocated range and rejects the image’s requested UID.</p>

<p><strong>2. Low-port binding.</strong> The gateway wants to bind privileged ports (&lt; 1024). Doing that as a non-root user requires the <code class="language-plaintext highlighter-rouge">NET_BIND_SERVICE</code> Linux capability, and OpenShift’s default <code class="language-plaintext highlighter-rouge">seccomp</code>/capability posture drops it.</p>

<p>The fast way through — and what I did first to get unblocked — is to bind broad SCCs to the gateway’s ServiceAccount:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>oc adm policy add-scc-to-user anyuid     <span class="nt">-z</span> ingress <span class="nt">-n</span> gateway
oc adm policy add-scc-to-user privileged <span class="nt">-z</span> ingress <span class="nt">-n</span> gateway
</pre></td></tr></tbody></table></code></pre></div></div>

<p>That works, but <code class="language-plaintext highlighter-rouge">privileged</code> is a sledgehammer and no security reviewer will sign off on it for production. <strong>The cleaner landing spot</strong> — which is what the running demo actually uses now — avoids <code class="language-plaintext highlighter-rouge">privileged</code> entirely by (a) running as <code class="language-plaintext highlighter-rouge">nobody</code>, (b) adding <em>only</em> the one capability that’s needed, and (c) using a sysctl to let a non-root process bind low ports:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
</pre></td><td class="rouge-code"><pre><span class="c1"># pod securityContext</span>
<span class="na">securityContext</span><span class="pi">:</span>
  <span class="na">runAsNonRoot</span><span class="pi">:</span> <span class="no">true</span>
  <span class="na">runAsUser</span><span class="pi">:</span> <span class="m">65534</span>                 <span class="c1"># nobody</span>
  <span class="na">seccompProfile</span><span class="pi">:</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">RuntimeDefault</span>
  <span class="na">sysctls</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">net.ipv4.ip_unprivileged_port_start</span>
      <span class="na">value</span><span class="pi">:</span> <span class="s2">"</span><span class="s">0"</span>                   <span class="c1"># allow unprivileged bind to low ports</span>
<span class="nn">---</span>
<span class="c1"># container securityContext</span>
<span class="na">securityContext</span><span class="pi">:</span>
  <span class="na">allowPrivilegeEscalation</span><span class="pi">:</span> <span class="no">false</span>
  <span class="na">capabilities</span><span class="pi">:</span>
    <span class="na">add</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">NET_BIND_SERVICE"</span><span class="pi">]</span>
    <span class="na">drop</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">ALL"</span><span class="pi">]</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The even simpler move — and the one I’d recommend if you control the values file — is to sidestep the whole low-port fight by <strong>not using low ports at all</strong> (see the <code class="language-plaintext highlighter-rouge">:8081</code> decision in Part 3). Then you don’t need <code class="language-plaintext highlighter-rouge">NET_BIND_SERVICE</code>, the sysctl, or <code class="language-plaintext highlighter-rouge">privileged</code>.</p>

<p><strong>Why this matters:</strong> SCCs and Pod Security Standards are <em>constraints, not misconfigurations</em>. They’ll bite you in any hardened Kubernetes environment, not just OpenShift. Budget time for them as a setup prerequisite, and reach for a minimal custom SCC (or a high port) before you reach for <code class="language-plaintext highlighter-rouge">privileged</code>.</p>

<hr />

<h3 id="part-2--the-architecture">Part 2 — The architecture</h3>

<p>Three layers, each independently testable, each ignorant of the others’ internals.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
</pre></td><td class="rouge-code"><pre>┌───────────────────────────────────────────────────────────────────┐
│ Layer 1 — Network / edge  (F5 BIG-IP via CIS)                       │
│   • TLS termination (self-signed cert for this demo)                │
│   • Pod-direct load balancing to the gateway (pool = pod IPs:8081)  │
│   • WAF attach point (present via Policy CR; not exercised here)     │
└───────────────────────────────────────────────────────────────────┘
                                 │  HTTP :8081
                                 ▼
┌───────────────────────────────────────────────────────────────────┐
│ Layer 2 — API gateway  (MuleSoft Omni Gateway v1.13.3, local mode)  │
│   • Path routing:  /a → app-a,  /b → app-b                          │
│   • Rate limiting: 5 requests / 10 s, keyed on client IP            │
│   • JWT validation: HS256 bearer token, offline (no API Manager)    │
└───────────────────────────────────────────────────────────────────┘
                                 │  HTTP :8080
                                 ▼
┌───────────────────────────────────────────────────────────────────┐
│ Layer 3 — Backends  (namespace mulesoft-demo, 2 replicas each)      │
│   • app-a: traefik/whoami           (echoes request/pod metadata)   │
│   • app-b: mendhak/http-https-echo  (echoes headers + body)         │
└───────────────────────────────────────────────────────────────────┘
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The gateway lives in namespace <code class="language-plaintext highlighter-rouge">gateway</code>; the backends live in <code class="language-plaintext highlighter-rouge">mulesoft-demo</code>. BIG-IP routes on hostname to the Mulesoft gateway.</p>

<p>The BIG-IP doesn’t know what’s behind the gateway. The gateway doesn’t know a BIG-IP exists.</p>

<p>Here’s the running environment:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
</pre></td><td class="rouge-code"><pre><span class="gp">$</span><span class="w"> </span>oc get pods <span class="nt">-n</span> gateway <span class="nt">-o</span> wide
<span class="go">NAME                      READY   STATUS    RESTARTS   AGE     IP            NODE
ingress-f88b6c598-txxlk   1/1     Running   1          4d21h   10.129.0.19   ip-10-0-2-184...

</span><span class="gp">$</span><span class="w"> </span>oc get pods <span class="nt">-n</span> mulesoft-demo <span class="nt">-o</span> wide
<span class="go">NAME                     READY   STATUS    RESTARTS   AGE     IP            NODE
app-a-6fb74c6c6c-9qvj9   1/1     Running   1          4d19h   10.129.0.26   ip-10-0-2-184...
app-a-6fb74c6c6c-nxhvh   1/1     Running   1          4d19h   10.129.0.24   ip-10-0-2-184...
app-b-5ff456446f-txr29   1/1     Running   1          4d19h   10.129.0.27   ip-10-0-2-184...
app-b-5ff456446f-w87kj   1/1     Running   1          4d19h   10.129.0.28   ip-10-0-2-184...

</span><span class="gp">$</span><span class="w"> </span>oc get svc <span class="nt">-n</span> gateway
<span class="go">NAME           TYPE        CLUSTER-IP       PORT(S)
ingress        ClusterIP   172.30.194.53    80/TCP,443/TCP
</span><span class="gp">ingress-8081   ClusterIP   172.30.137.137   8081/TCP        #</span><span class="w"> </span>&lt;- the demo listener
<span class="gp">ingress-hl     ClusterIP   None             &lt;none&gt;</span><span class="w">
</span></pre></td></tr></tbody></table></code></pre></div></div>

<blockquote class="notice">
  <p>The pod is named <code class="language-plaintext highlighter-rouge">ingress</code> because that’s the Helm release name for the Flex/Omni gateway chart — don’t let it fool you into thinking it’s the OpenShift ingress router. The single container is named <code class="language-plaintext highlighter-rouge">app</code>, and the workload’s label selector is <code class="language-plaintext highlighter-rouge">app=ingress</code> (not <code class="language-plaintext highlighter-rouge">app.kubernetes.io/name=flex-gateway</code>, which some docs imply — worth checking with <code class="language-plaintext highlighter-rouge">oc get svc &lt;svc&gt; -o jsonpath='{.spec.selector}'</code> before you write a NetworkPolicy or a CIS selector against it).</p>
</blockquote>

<h3 id="part-3--mulesoft-omni-gateway-configuration-deep-dive">Part 3 — MuleSoft Omni Gateway configuration deep dive</h3>

<p>Everything here is done the Kubernetes-native way: <code class="language-plaintext highlighter-rouge">ApiInstance</code> for the listener + routing, <code class="language-plaintext highlighter-rouge">PolicyBinding</code> for policy. That’s exactly why local mode matters — in connected mode these CRDs are ignored.</p>

<h4 id="getting-routing-right-the-apiinstance">Getting routing right: the <code class="language-plaintext highlighter-rouge">ApiInstance</code></h4>

<p>First friction point: <code class="language-plaintext highlighter-rouge">kubectl explain</code> wasn’t helpful. The published examples I found show one structure but I had to do slightly differently than what I found. Here’s the structure that works on v1.13.3:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
</pre></td><td class="rouge-code"><pre><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">gateway.mulesoft.com/v1alpha1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">ApiInstance</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">demo-api</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">gateway</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">address</span><span class="pi">:</span> <span class="s">http://0.0.0.0:8081</span>        <span class="c1"># listen on :8081 — see the port note below</span>
  <span class="na">services</span><span class="pi">:</span>
    <span class="na">backend-a</span><span class="pi">:</span>
      <span class="na">address</span><span class="pi">:</span> <span class="s">http://app-a.mulesoft-demo.svc.cluster.local:8080</span>
      <span class="na">routes</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="na">rules</span><span class="pi">:</span>
            <span class="pi">-</span> <span class="na">path</span><span class="pi">:</span> <span class="s">/a</span>
    <span class="na">backend-b</span><span class="pi">:</span>
      <span class="na">address</span><span class="pi">:</span> <span class="s">http://app-b.mulesoft-demo.svc.cluster.local:8080</span>
      <span class="na">routes</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="na">rules</span><span class="pi">:</span>
            <span class="pi">-</span> <span class="na">path</span><span class="pi">:</span> <span class="s">/b</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<blockquote class="notice">
  <p><strong>Why <code class="language-plaintext highlighter-rouge">:8081</code>?</strong> The Helm chart already stands up listeners on <code class="language-plaintext highlighter-rouge">:80</code> and <code class="language-plaintext highlighter-rouge">:443</code> (you’ll see <code class="language-plaintext highlighter-rouge">ApiInstance/ingress-http</code> and <code class="language-plaintext highlighter-rouge">ingress-https</code> in the namespace). Rather than fight those — and rather than deal with the low-port SCC drama from Part 1 — I gave the demo API its own listener on a high port. Cleaner, and it sidesteps <code class="language-plaintext highlighter-rouge">NET_BIND_SERVICE</code> entirely.</p>
</blockquote>

<p>Prove routing works <strong>before</strong> you add a single policy. Port-forward to the listener and hit each path:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
</pre></td><td class="rouge-code"><pre>oc port-forward <span class="nt">-n</span> gateway svc/ingress-8081 18081:8081 &amp;

curl <span class="nt">-s</span> http://localhost:18081/a   <span class="c"># -&gt; app-a (traefik/whoami)</span>
curl <span class="nt">-s</span> http://localhost:18081/b   <span class="c"># -&gt; app-b (mendhak/http-https-echo)</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">/a</code> returns a <code class="language-plaintext highlighter-rouge">whoami</code> dump (hostname, pod IPs, request headers); <code class="language-plaintext highlighter-rouge">/b</code> returns the echo server’s JSON. Once that’s solid, layer policy on top.</p>

<h4 id="the-schema-mismatch-extension-definitions-vs-policybinding-reality">The schema mismatch: Extension definitions vs. PolicyBinding reality</h4>

<p>Here’s the one that will cost you the most time if you’re not warned. I could not find a way to learn the expected schema or Mulesoft CRD’s. Check this out:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
</pre></td><td class="rouge-code"><pre>ubuntu@ubuntu-Virtual-Machine:~/mulesoft/cis-resources<span class="nv">$ </span>kubectl get crd policybindings.gateway.mulesoft.com <span class="nt">-o</span> yaml
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  creationTimestamp: <span class="s2">"2026-07-22T20:38:50Z"</span>
  generation: 1
  name: policybindings.gateway.mulesoft.com
  resourceVersion: <span class="s2">"829989"</span>
  uid: c3fffbe6-9644-477f-bf3f-3ee953f12bf2
spec:
  conversion:
    strategy: None
  group: gateway.mulesoft.com
  names:
    kind: PolicyBinding
    listKind: PolicyBindingList
    plural: policybindings
    singular: policybinding
  scope: Namespaced
  versions:
  - additionalPrinterColumns:
    - description: The target to apply the Policy
      jsonPath: .spec.targetRef.name
      name: Target
      <span class="nb">type</span>: string
    - description: The Policy to apply
      jsonPath: .spec.policyRef.name
      name: Policy
      <span class="nb">type</span>: string
    name: v1alpha1
    schema:
      openAPIV3Schema:
        properties:
          spec:
            x-kubernetes-preserve-unknown-fields: <span class="nb">true
        type</span>: object
    served: <span class="nb">true
    </span>storage: <span class="nb">true</span>
......[redacted status fields].....
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The key section is below.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
</pre></td><td class="rouge-code"><pre>    <span class="na">schema</span><span class="pi">:</span>
      <span class="na">openAPIV3Schema</span><span class="pi">:</span>
        <span class="na">properties</span><span class="pi">:</span>
          <span class="na">spec</span><span class="pi">:</span>
            <span class="na">x-kubernetes-preserve-unknown-fields</span><span class="pi">:</span> <span class="no">true</span>
        <span class="na">type</span><span class="pi">:</span> <span class="s">object</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>This means that you can put anything in the CRD and it’s treated as valid by kubectl. That’s bad - I had no real way of troubleshooting my CRD’s. Perhaps these are just the CRD’s installed by Helm, but this is not helpful.</p>

<p>The only reliable method I found was to switch to a different AI model that I had helping me :)</p>

<p>But AI’s suggestion as I’m writing this blog post is now: <strong>pull the definition off the pod, then build the binding empirically against a minimal working example.</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
</pre></td><td class="rouge-code"><pre><span class="nv">POD</span><span class="o">=</span><span class="si">$(</span>oc get po <span class="nt">-n</span> gateway <span class="nt">-l</span> <span class="nv">app</span><span class="o">=</span>ingress <span class="nt">-o</span> name | <span class="nb">head</span> <span class="nt">-1</span><span class="si">)</span>
oc <span class="nb">exec</span> <span class="nt">-n</span> gateway <span class="nv">$POD</span> <span class="nt">--</span> <span class="se">\</span>
  <span class="nb">cat</span> /etc/mulesoft/flex-gateway/conf.d/policies/definitions/rate_limit_definition.yaml
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Read it for the property <em>meanings</em>, not as gospel for field names and nesting. Then test.</p>

<h4 id="rate-limiting--the-complete-working-binding">Rate limiting — the complete working binding</h4>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
</pre></td><td class="rouge-code"><pre><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">gateway.mulesoft.com/v1alpha1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">PolicyBinding</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">demo-rate-limit</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">gateway</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">policyRef</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">rate-limiting-flex</span>        <span class="c1"># NOT "rate-limit"</span>
  <span class="na">targetRef</span><span class="pi">:</span>
    <span class="na">kind</span><span class="pi">:</span> <span class="s">ApiInstance</span>               <span class="c1"># required — not optional</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">demo-api</span>
  <span class="na">config</span><span class="pi">:</span>
    <span class="na">rateLimits</span><span class="pi">:</span>                     <span class="c1"># keySelector/exposeHeaders/clusterizable</span>
      <span class="pi">-</span> <span class="na">maximumRequests</span><span class="pi">:</span> <span class="m">5</span>          <span class="c1"># live INSIDE this array item</span>
        <span class="na">timePeriodInMilliseconds</span><span class="pi">:</span> <span class="m">10000</span>
        <span class="na">exposeHeaders</span><span class="pi">:</span> <span class="no">true</span>
        <span class="na">clusterizable</span><span class="pi">:</span> <span class="no">false</span>        <span class="c1"># false for this single-replica demo; true in prod</span>
        <span class="na">keySelector</span><span class="pi">:</span> <span class="s2">"</span><span class="s">#[attributes.clientAddress.ip]"</span>   <span class="c1"># rate-limit per client IP</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Test it — five in, then rejections:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
</pre></td><td class="rouge-code"><pre><span class="gp">$</span><span class="w"> </span><span class="k">for </span>i <span class="k">in</span> <span class="si">$(</span><span class="nb">seq </span>1 10<span class="si">)</span><span class="p">;</span> <span class="k">do</span>
<span class="gp">    curl -s -o /dev/null -w "req $</span>i -&gt; %<span class="o">{</span>http_code<span class="o">}</span><span class="se">\n</span><span class="s2">" localhost:18081/a
</span><span class="go">  done
</span><span class="gp">req 1 -&gt;</span><span class="w"> </span>200
<span class="gp">req 2 -&gt;</span><span class="w"> </span>200
<span class="gp">req 3 -&gt;</span><span class="w"> </span>200
<span class="gp">req 4 -&gt;</span><span class="w"> </span>200
<span class="gp">req 5 -&gt;</span><span class="w"> </span>200
<span class="gp">req 6 -&gt;</span><span class="w"> </span>429
<span class="gp">req 7 -&gt;</span><span class="w"> </span>429
<span class="gp">req 8 -&gt;</span><span class="w"> </span>429
<span class="gp">req 9 -&gt;</span><span class="w"> </span>429
<span class="gp">req 10 -&gt;</span><span class="w"> </span>429
<span class="go">
</span><span class="gp">$</span><span class="w"> </span>curl <span class="nt">-s</span> localhost:18081/a          <span class="c"># body once the budget is spent</span>
<span class="go">{"error":"Too Many Requests"}
</span></pre></td></tr></tbody></table></code></pre></div></div>

<p><strong>Gotchas that actually cost me time:</strong></p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">keySelector</code> is effectively required.</strong> Leave it off and the policy loads happily but the enforcement behavior is not what you expect. Set it explicitly.</li>
  <li><strong>It’s <code class="language-plaintext highlighter-rouge">attributes.clientAddress.ip</code></strong>, not <code class="language-plaintext highlighter-rouge">attributes.request.ip</code>. Wrong expression → no useful key.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">targetRef.kind: ApiInstance</code> is mandatory.</strong> Omit it and the binding won’t attach.</li>
  <li><strong>Honesty check on <code class="language-plaintext highlighter-rouge">exposeHeaders</code>.</strong> I set <code class="language-plaintext highlighter-rouge">exposeHeaders: true</code> expecting <code class="language-plaintext highlighter-rouge">RateLimit-Limit</code> / <code class="language-plaintext highlighter-rouge">RateLimit-Remaining</code> / <code class="language-plaintext highlighter-rouge">RateLimit-Reset</code> headers on allowed responses. On v1.13.3 in this local-mode setup I did <strong>not</strong> see them emitted on the 200s — the enforcement (200→429) is rock solid, but the informational headers didn’t surface for me. If your clients depend on <code class="language-plaintext highlighter-rouge">RateLimit-*</code> headers to self-throttle, validate that behavior on your version before you promise it downstream.</li>
</ul>

<h4 id="jwt-validation">JWT validation</h4>

<p>This is where I’ll <strong>correct my own initial notes</strong>. If you follow blog posts (including drafts of this one) that show <code class="language-plaintext highlighter-rouge">jwtSigningMethod</code>, <code class="language-plaintext highlighter-rouge">jwtSigningKeyLength</code>, and <code class="language-plaintext highlighter-rouge">jwtKey</code>, the binding will not behave. The <strong>actual working field names</strong> on v1.13.3 are <code class="language-plaintext highlighter-rouge">signingMethod</code>, <code class="language-plaintext highlighter-rouge">signingKeyLength</code>, and <code class="language-plaintext highlighter-rouge">textKey</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
</pre></td><td class="rouge-code"><pre><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">gateway.mulesoft.com/v1alpha1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">PolicyBinding</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">demo-jwt-auth</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">gateway</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">policyRef</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">jwt-validation-flex</span>       <span class="c1"># NOT "jwt-validation"</span>
  <span class="na">targetRef</span><span class="pi">:</span>
    <span class="na">kind</span><span class="pi">:</span> <span class="s">ApiInstance</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">demo-api</span>
  <span class="na">config</span><span class="pi">:</span>
    <span class="na">signingMethod</span><span class="pi">:</span> <span class="s">hmac</span>             <span class="c1"># not "jwtSigningMethod"</span>
    <span class="na">signingKeyLength</span><span class="pi">:</span> <span class="m">256</span>           <span class="c1"># not "jwtSigningKeyLength"</span>
    <span class="na">jwtKeyOrigin</span><span class="pi">:</span> <span class="s">text</span>
    <span class="na">textKey</span><span class="pi">:</span> <span class="s2">"</span><span class="s">12345678901234567890123456789012"</span>   <span class="c1"># not "jwtKey"; plain text, NOT base64</span>
    <span class="na">jwtOrigin</span><span class="pi">:</span> <span class="s">httpBearerAuthenticationHeader</span>
    <span class="na">validateAudClaim</span><span class="pi">:</span> <span class="no">false</span>
    <span class="na">mandatoryExpClaim</span><span class="pi">:</span> <span class="no">true</span>
    <span class="na">skipClientIdValidation</span><span class="pi">:</span> <span class="no">true</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Generate a token (standard PyJWT):</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
</pre></td><td class="rouge-code"><pre><span class="kn">import</span> <span class="nn">time</span><span class="p">,</span> <span class="n">jwt</span>
<span class="n">now</span> <span class="o">=</span> <span class="nb">int</span><span class="p">(</span><span class="n">time</span><span class="p">.</span><span class="n">time</span><span class="p">())</span>
<span class="n">payload</span> <span class="o">=</span> <span class="p">{</span><span class="s">"sub"</span><span class="p">:</span> <span class="s">"test"</span><span class="p">,</span> <span class="s">"iat"</span><span class="p">:</span> <span class="n">now</span><span class="p">,</span> <span class="s">"nbf"</span><span class="p">:</span> <span class="n">now</span><span class="p">,</span> <span class="s">"exp"</span><span class="p">:</span> <span class="n">now</span> <span class="o">+</span> <span class="mi">3600</span><span class="p">}</span>  <span class="c1"># 1-hour expiry
</span><span class="k">print</span><span class="p">(</span><span class="n">jwt</span><span class="p">.</span><span class="n">encode</span><span class="p">(</span><span class="n">payload</span><span class="p">,</span> <span class="s">"12345678901234567890123456789012"</span><span class="p">,</span> <span class="n">algorithm</span><span class="o">=</span><span class="s">"HS256"</span><span class="p">))</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Test the three cases:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
</pre></td><td class="rouge-code"><pre><span class="gp">$</span><span class="w"> </span>curl <span class="nt">-s</span> <span class="nt">-o</span> /dev/null <span class="nt">-w</span> <span class="s2">"%{http_code}</span><span class="se">\n</span><span class="s2">"</span> localhost:18081/a
<span class="gp">400                                   #</span><span class="w"> </span>no token
<span class="gp">$</span><span class="w"> </span>curl <span class="nt">-s</span> localhost:18081/a
<span class="go">{"error":"JWT Token is required."}

</span><span class="gp">$</span><span class="w"> </span>curl <span class="nt">-s</span> <span class="nt">-o</span> /dev/null <span class="nt">-w</span> <span class="s2">"%{http_code}</span><span class="se">\n</span><span class="s2">"</span> <span class="se">\</span>
<span class="gp">    -H "Authorization: Bearer $</span>TOKEN<span class="s2">" localhost:18081/a
</span><span class="gp">200                                   #</span><span class="w"> </span><span class="s2">valid token → whoami
</span><span class="go">
</span><span class="gp">$</span><span class="w"> </span>curl <span class="nt">-s</span> <span class="nt">-o</span> /dev/null <span class="nt">-w</span> <span class="s2">"%{http_code}</span><span class="se">\n</span><span class="s2">"</span> <span class="se">\</span>
<span class="go">    -H "Authorization: Bearer not.a.real.token" localhost:18081/a
</span><span class="gp">401                                   #</span><span class="w"> </span>bad token
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Note the status semantics: <strong>missing</strong> token is <code class="language-plaintext highlighter-rouge">400</code> (malformed request), <strong>present-but-invalid</strong> is <code class="language-plaintext highlighter-rouge">401</code> (unauthorized). Some clients treat those very differently.</p>

<h4 id="why-plain-text-textkey-not-base64">Why plain-text <code class="language-plaintext highlighter-rouge">textKey</code>, not base64</h4>

<p>The <code class="language-plaintext highlighter-rouge">jwtKeyOrigin</code> field selects how the key is interpreted:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">text</code> — the key is a literal string (correct for HMAC / HS256).</li>
  <li><code class="language-plaintext highlighter-rouge">jwks</code> — the key is a URL to a JWKS endpoint (for RS/ES asymmetric keys).</li>
  <li><code class="language-plaintext highlighter-rouge">customExpression</code> — the key comes from a DataWeave expression.</li>
</ul>

<p>For HS256 use <code class="language-plaintext highlighter-rouge">text</code> and supply the secret <strong>as-is, in plain text</strong>. If you base64-encode it out of habit, the policy tries to interpret the decoded bytes as a <strong>PEM</strong> key and fails with:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>Error generating PEM InvalidData(InvalidByte(4, 45))
</pre></td></tr></tbody></table></code></pre></div></div>

<p>That error is a red herring the first time you see it — it looks like a certificate problem, but the real cause is “you gave me base64 and <code class="language-plaintext highlighter-rouge">text</code> origin.” Keep the secret plain.</p>

<h4 id="the-32-bit-wasm-timestamp-gotcha">The 32-bit WASM timestamp gotcha</h4>

<p>Flex/Omni Gateway runs each policy as a <strong>WebAssembly module</strong> (<code class="language-plaintext highlighter-rouge">wasm32-unknown-unknown</code>) inside Envoy. The <code class="language-plaintext highlighter-rouge">jwt-validation</code> policy is Rust compiled to that 32-bit WASM target, and its claim-parsing code calls <code class="language-plaintext highlighter-rouge">.unwrap()</code> on a timestamp conversion. Feed it an <code class="language-plaintext highlighter-rouge">exp</code> that’s large enough to overflow the conversion and the <code class="language-plaintext highlighter-rouge">.unwrap()</code> panics on <code class="language-plaintext highlighter-rouge">None</code>, which traps the WASM module on an <code class="language-plaintext highlighter-rouge">unreachable</code> instruction — and the gateway returns <strong><code class="language-plaintext highlighter-rouge">503</code></strong> for that request.</p>

<p>I mapped the boundary empirically on v1.13.3 by walking <code class="language-plaintext highlighter-rouge">exp</code> upward:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
</pre></td><td class="rouge-code"><pre><span class="gp">exp = 2147483647   (2^31-1, "2038")  -&gt;</span><span class="w"> </span>HTTP 200   <span class="c"># accepted</span>
<span class="gp">exp = 2147483648                      -&gt;</span><span class="w"> </span>HTTP 200
<span class="gp">exp = 4294967295   (2^32-1)           -&gt;</span><span class="w"> </span>HTTP 200
<span class="gp">exp = 4294967296   (2^32)             -&gt;</span><span class="w"> </span>HTTP 200
<span class="gp">exp = 5000000000                      -&gt;</span><span class="w"> </span>HTTP 200
<span class="gp">exp = 9999999999                      -&gt;</span><span class="w"> </span>HTTP 503   <span class="c"># panic</span>
<span class="gp">exp = 99999999999                     -&gt;</span><span class="w"> </span>HTTP 503
</pre></td></tr></tbody></table></code></pre></div></div>

<p>So — and this corrects the common “it breaks at 2038 / 2³¹” claim — on this version the wall sits <strong>above 2³²</strong>, somewhere between 5×10⁹ and ~10¹⁰, not at the signed-32-bit 2038 boundary. Wherever exactly the threshold is, the point stands: unrealistically large <code class="language-plaintext highlighter-rouge">exp</code> values crash the request.</p>

<p>The smoking gun, straight from <code class="language-plaintext highlighter-rouge">oc logs deploy/ingress</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
</pre></td><td class="rouge-code"><pre>[flex-gateway-envoy][critical] wasm log demo-jwt-auth ...
  panicked at .../jwt-lib/src/model/claims.rs:340:65:
  called `Option::unwrap()` on a `None` value
[flex-gateway-envoy][error] Function: proxy_on_request_headers failed:
  ... jwt_validation.wasm!..extract_datetime_value ...
      jwt_validation.wasm!..JWTClaims::new ...
Caused by:
  wasm trap: wasm `unreachable` instruction executed
</pre></td></tr></tbody></table></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">extract_datetime_value</code> → <code class="language-plaintext highlighter-rouge">.unwrap()</code> on <code class="language-plaintext highlighter-rouge">None</code> → <code class="language-plaintext highlighter-rouge">abort</code> → <code class="language-plaintext highlighter-rouge">unreachable</code>. That’s a shipping robustness bug: the policy should clamp or reject an out-of-range timestamp gracefully, not abort the WASM VM and 503 the caller.</p>

<p><strong>The fix is boring and correct:</strong> use realistic Unix timestamps. <code class="language-plaintext highlighter-rouge">now + 3600</code> for a one-hour token is fine. You only hit this if you (or a buggy token minter) emit absurd expiries — but it’s exactly the kind of thing a fuzzer or a copy-pasted “never expires” hack will trigger in the wild, so it’s worth knowing the failure mode is a <code class="language-plaintext highlighter-rouge">503</code>, not a clean <code class="language-plaintext highlighter-rouge">401</code>.</p>

<hr />

<h3 id="part-4--f5-cis-integration">Part 4 — F5 CIS integration</h3>

<p>The reader here is assumed to know F5, so this is deliberately brief — the CIS side is nearly identical to the Kong post. What changed is that this cluster drives BIG-IP config from an <strong>OpenShift Route</strong> plus an <strong>extended-spec ConfigMap</strong>, rather than a <code class="language-plaintext highlighter-rouge">VirtualServer</code> CRD.</p>

<p>CIS runs in <code class="language-plaintext highlighter-rouge">kube-system</code>, watching the relevant namespaces, in OpenShift controller mode with cluster (pod-direct) pool members:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
</pre></td><td class="rouge-code"><pre><span class="gp">$</span><span class="w"> </span>oc get deploy f5cis-f5-bigip-ctlr <span class="nt">-n</span> kube-system <span class="se">\</span>
<span class="go">    -o jsonpath='{.spec.template.spec.containers[0].args}'
--bigip-partition=openshift
--bigip-url=10.0.4.11
--controller-mode=openshift
</span><span class="gp">--pool-member-type=cluster        #</span><span class="w"> </span>pool members <span class="o">=</span> pod IPs, not NodePorts
<span class="go">--orchestration-cni=ovn-k8s
--static-routing-mode=true
--namespace=kube-system --namespace=gateway --namespace=mulesoft-demo
</span></pre></td></tr></tbody></table></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">pool-member-type=cluster</code> with <code class="language-plaintext highlighter-rouge">orchestration-cni=ovn-k8s</code> and <code class="language-plaintext highlighter-rouge">static-routing-mode=true</code> is what gives us <strong>pod-direct load balancing</strong> on OVN-Kubernetes: BIG-IP balances straight across the gateway’s pod IPs, no NodePort or router hop, and pool membership tracks pods automatically.</p>

<p>The Route (edge TLS decryption, HTTP→HTTPS redirect, targeting the <code class="language-plaintext highlighter-rouge">:8081</code> Service):</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
</pre></td><td class="rouge-code"><pre><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">route.openshift.io/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Route</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">mulesoft-route</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">gateway</span>
  <span class="na">labels</span><span class="pi">:</span>
    <span class="na">f5type</span><span class="pi">:</span> <span class="s">systest</span>                 <span class="c1"># picked up by CIS</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">host</span><span class="pi">:</span> <span class="s">mulesoft.my-f5.com</span>
  <span class="na">path</span><span class="pi">:</span> <span class="s">/</span>
  <span class="na">port</span><span class="pi">:</span>
    <span class="na">targetPort</span><span class="pi">:</span> <span class="m">8081</span>
  <span class="na">tls</span><span class="pi">:</span>
    <span class="na">termination</span><span class="pi">:</span> <span class="s">edge</span>
    <span class="na">insecureEdgeTerminationPolicy</span><span class="pi">:</span> <span class="s">Redirect</span>
  <span class="na">to</span><span class="pi">:</span>
    <span class="na">kind</span><span class="pi">:</span> <span class="s">Service</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">ingress-8081</span>
    <span class="na">weight</span><span class="pi">:</span> <span class="m">100</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The extended-spec ConfigMap pins the VIP, partition, and a Policy CR (TCP profiles + SNAT; the WAF attach point lives here too):</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
</pre></td><td class="rouge-code"><pre><span class="na">extendedRouteSpec</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">namespace</span><span class="pi">:</span> <span class="s">gateway</span>
    <span class="na">vserverAddr</span><span class="pi">:</span> <span class="s">10.0.0.101</span>           <span class="c1"># the BIG-IP VIP</span>
    <span class="na">vserverName</span><span class="pi">:</span> <span class="s">mulesoft-demo-routes</span>
    <span class="na">bigIpPartition</span><span class="pi">:</span> <span class="s">mulesoft-demo</span>
    <span class="na">policyCR</span><span class="pi">:</span> <span class="s">kube-system/security-policy</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>A quick confirmation that BIG-IP actually admitted the Route — note it’s admitted by <strong>two</strong> routers, the OpenShift default <em>and</em> <code class="language-plaintext highlighter-rouge">F5 BIG-IP</code> (CIS):</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
</pre></td><td class="rouge-code"><pre><span class="gp">$</span><span class="w"> </span>oc get route mulesoft-route <span class="nt">-n</span> gateway <span class="nt">-o</span> <span class="nv">jsonpath</span><span class="o">=</span><span class="s1">'{range .status.ingress[*]}{.routerName}{"\n"}{end}'</span>
<span class="go">default
F5 BIG-IP
</span></pre></td></tr></tbody></table></code></pre></div></div>

<p>Put together, the flow is:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
</pre></td><td class="rouge-code"><pre>Internet ─▶ BIG-IP VIP  mulesoft.my-f5.com : 443
         ─▶ TLS decrypt (edge policy on the Route)
         ─▶ CIS pod-direct routing to gateway pod IP : 8081
         ─▶ Omni Gateway  (JWT + rate-limit)
         ─▶ backend  /a (app-a)  or  /b (app-b)
</pre></td></tr></tbody></table></code></pre></div></div>

<blockquote>
  <p><strong>Cert caveat.</strong> This demo terminates TLS with the BIG-IP’s default <strong>self-signed</strong> cert (<code class="language-plaintext highlighter-rouge">CN=localhost.localdomain</code>), which is why every <code class="language-plaintext highlighter-rouge">curl</code> below uses <code class="language-plaintext highlighter-rouge">-k</code>. Use a real, trusted certificate in production.</p>
</blockquote>

<hr />

<h3 id="part-5--end-to-end-testing-layered-so-failures-are-localized">Part 5 — End-to-end testing (layered, so failures are localized)</h3>

<p>The value of the three-layer design is that you can test each layer in isolation. If layer <em>N</em> misbehaves, the bug is in <em>N</em> — not upstream.</p>

<p><strong>Layer 1 — gateway routing only</strong> (port-forward, no edge):</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>curl <span class="nt">-s</span> localhost:18081/a   <span class="c"># app-a</span>
curl <span class="nt">-s</span> localhost:18081/b   <span class="c"># app-b</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p><strong>Layer 2 — rate limiting</strong> (port-forward):</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre><span class="gp">$</span><span class="w"> </span><span class="k">for </span>i <span class="k">in</span> <span class="si">$(</span><span class="nb">seq </span>1 10<span class="si">)</span><span class="p">;</span> <span class="k">do </span>curl <span class="nt">-s</span> <span class="nt">-o</span> /dev/null <span class="nt">-w</span> <span class="s2">"%{http_code} "</span> localhost:18081/a<span class="p">;</span> <span class="k">done</span>
<span class="go">200 200 200 200 200 429 429 429 429 429
</span></pre></td></tr></tbody></table></code></pre></div></div>

<p><strong>Layer 3 — JWT</strong> (port-forward):</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
</pre></td><td class="rouge-code"><pre><span class="gp">$</span><span class="w"> </span>curl <span class="nt">-s</span> <span class="nt">-o</span> /dev/null <span class="nt">-w</span> <span class="s2">"%{http_code}</span><span class="se">\n</span><span class="s2">"</span> localhost:18081/a                              <span class="c"># 400 (no token)</span>
<span class="gp">$</span><span class="w"> </span>curl <span class="nt">-s</span> <span class="nt">-o</span> /dev/null <span class="nt">-w</span> <span class="s2">"%{http_code}</span><span class="se">\n</span><span class="s2">"</span> <span class="nt">-H</span> <span class="s2">"Authorization: Bearer </span><span class="nv">$TOKEN</span><span class="s2">"</span>   localhost:18081/a   <span class="c"># 200</span>
<span class="gp">$</span><span class="w"> </span>curl <span class="nt">-s</span> <span class="nt">-o</span> /dev/null <span class="nt">-w</span> <span class="s2">"%{http_code}</span><span class="se">\n</span><span class="s2">"</span> <span class="nt">-H</span> <span class="s2">"Authorization: Bearer garbage"</span>  localhost:18081/a   <span class="c"># 401</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p><strong>Layer 4 — the whole stack through BIG-IP</strong> (<code class="language-plaintext highlighter-rouge">https://mulesoft.my-f5.com</code>, self-signed → <code class="language-plaintext highlighter-rouge">-k</code>):</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
</pre></td><td class="rouge-code"><pre><span class="gp">#</span><span class="w"> </span>no token
<span class="gp">$</span><span class="w"> </span>curl <span class="nt">-sk</span> <span class="nt">-o</span> /dev/null <span class="nt">-w</span> <span class="s2">"%{http_code}</span><span class="se">\n</span><span class="s2">"</span> https://mulesoft.my-f5.com/a
<span class="go">400
</span><span class="gp">$</span><span class="w"> </span>curl <span class="nt">-sk</span> https://mulesoft.my-f5.com/a
<span class="go">{"error":"JWT Token is required."}

</span><span class="gp">#</span><span class="w"> </span>invalid token
<span class="gp">$</span><span class="w"> </span>curl <span class="nt">-sk</span> <span class="nt">-o</span> /dev/null <span class="nt">-w</span> <span class="s2">"%{http_code}</span><span class="se">\n</span><span class="s2">"</span> <span class="nt">-H</span> <span class="s2">"Authorization: Bearer bad"</span> https://mulesoft.my-f5.com/a
<span class="go">401
</span><span class="gp">$</span><span class="w"> </span>curl <span class="nt">-sk</span> <span class="nt">-H</span> <span class="s2">"Authorization: Bearer bad"</span> https://mulesoft.my-f5.com/a
<span class="go">{"error":"Invalid token."}

</span><span class="gp">#</span><span class="w"> </span>valid token → routed to app-a <span class="o">(</span><span class="nb">whoami</span><span class="o">)</span>
<span class="gp">$</span><span class="w"> </span>curl <span class="nt">-sk</span> <span class="nt">-H</span> <span class="s2">"Authorization: Bearer </span><span class="nv">$TOKEN</span><span class="s2">"</span> https://mulesoft.my-f5.com/a
<span class="go">Hostname: app-a-6fb74c6c6c-nxhvh
IP: 10.129.0.24
GET /a HTTP/1.1
Host: app-a.mulesoft-demo.svc.cluster.local
</span><span class="c">...
</span><span class="go">
</span><span class="gp">#</span><span class="w"> </span>valid token → routed to app-b <span class="o">(</span><span class="nb">echo</span><span class="o">)</span>
<span class="gp">$</span><span class="w"> </span>curl <span class="nt">-sk</span> <span class="nt">-H</span> <span class="s2">"Authorization: Bearer </span><span class="nv">$TOKEN</span><span class="s2">"</span> https://mulesoft.my-f5.com/b
<span class="go">{ "path": "/b", "headers": { "host": "app-b.mulesoft-demo.svc.cluster.local", ... } }

</span><span class="gp">#</span><span class="w"> </span>HTTP is redirected to HTTPS at the edge
<span class="gp">$</span><span class="w"> </span>curl <span class="nt">-sk</span> <span class="nt">-o</span> /dev/null <span class="nt">-w</span> <span class="s2">"%{http_code} -&gt; %{redirect_url}</span><span class="se">\n</span><span class="s2">"</span> http://mulesoft.my-f5.com/a
<span class="gp">302 -&gt;</span><span class="w"> </span>https://mulesoft.my-f5.com:443/a
<span class="go">
</span><span class="gp">#</span><span class="w"> </span>rate limiting still enforced end-to-end <span class="o">(</span>window mid-cycle here<span class="o">)</span>
<span class="gp">$</span><span class="w"> </span><span class="k">for </span>i <span class="k">in</span> <span class="si">$(</span><span class="nb">seq </span>1 10<span class="si">)</span><span class="p">;</span> <span class="k">do</span>
<span class="gp">    curl -sk -o /dev/null -w "%{http_code} " -H "Authorization: Bearer $</span>TOKEN<span class="s2">" https://mulesoft.my-f5.com/a
</span><span class="go">  done
200 200 200 429 429 429 429 429 429 429
</span></pre></td></tr></tbody></table></code></pre></div></div>

<p>Same behavior through the edge as at the gateway — which is exactly what “the layers are decoupled” is supposed to mean.</p>

<hr />

<h3 id="part-6--key-learnings">Part 6 — Key learnings</h3>

<p><strong>The three teaching moments</strong></p>

<ol>
  <li>
    <p><strong>Registration mode is architectural, and it fails silently.</strong> <code class="language-plaintext highlighter-rouge">--connected=true</code> (the wizard default) makes the gateway ignore your Kubernetes CRDs and wait for Anypoint contracts — readiness never passes, and there’s no error to grep for. Register <code class="language-plaintext highlighter-rouge">--connected=false</code> / <code class="language-plaintext highlighter-rouge">gateway.mode=local</code>. Registration is immutable per name, so budget for throwaway registrations while you experiment.</p>
  </li>
  <li>
    <p><strong>Extension definitions describe the policy, not the binding schema.</strong> Policy names differ (<code class="language-plaintext highlighter-rouge">rate-limit</code> → <code class="language-plaintext highlighter-rouge">rate-limiting-flex</code>), field names differ (<code class="language-plaintext highlighter-rouge">signingMethod</code>/<code class="language-plaintext highlighter-rouge">textKey</code>, not <code class="language-plaintext highlighter-rouge">jwtSigningMethod</code>/<code class="language-plaintext highlighter-rouge">jwtKey</code>), and nesting differs (rate-limit knobs live inside the <code class="language-plaintext highlighter-rouge">rateLimits</code> array item). Pull the definition off the pod for <em>meaning</em>, then build the binding empirically against a minimal working example — and test.</p>
  </li>
  <li>
    <p><strong>The JWT policy runs in 32-bit WASM and will <code class="language-plaintext highlighter-rouge">503</code> on absurd <code class="language-plaintext highlighter-rouge">exp</code> values.</strong> The panic is a real <code class="language-plaintext highlighter-rouge">.unwrap()</code> on <code class="language-plaintext highlighter-rouge">None</code> in <code class="language-plaintext highlighter-rouge">claims.rs</code>, trapping the WASM VM. On v1.13.3 the boundary is above 2³² (not the “2038”/2³¹ myth), but the takeaway is the same: use realistic expiries, and know that the failure mode is a <code class="language-plaintext highlighter-rouge">503</code>, not a clean <code class="language-plaintext highlighter-rouge">401</code>. Related: use <strong>plain-text</strong> <code class="language-plaintext highlighter-rouge">textKey</code> for HMAC — base64 makes the policy try to parse PEM and fail.</p>
  </li>
</ol>

<p><strong>Operational insights</strong></p>

<ul>
  <li><strong>Namespace isolation pays off.</strong> Gateway in <code class="language-plaintext highlighter-rouge">gateway</code>, apps in <code class="language-plaintext highlighter-rouge">mulesoft-demo</code>, CIS scoped to just the namespaces it needs. Clean blast radius, no cross-talk.</li>
  <li><strong>Use a high port for demo listeners.</strong> <code class="language-plaintext highlighter-rouge">:8081</code> dodges the chart’s <code class="language-plaintext highlighter-rouge">:80/:443</code> listeners <em>and</em> the entire low-port SCC saga. If you must bind low ports as non-root, prefer <code class="language-plaintext highlighter-rouge">NET_BIND_SERVICE</code> + the <code class="language-plaintext highlighter-rouge">ip_unprivileged_port_start</code> sysctl over <code class="language-plaintext highlighter-rouge">privileged</code>.</li>
  <li><strong>Logs are the tool.</strong> <code class="language-plaintext highlighter-rouge">oc logs -f deploy/ingress -n gateway</code> shows policy load/apply events and the WASM panics in real time. When the readiness probe is silent, the <em>full</em> startup log is where the truth is.</li>
  <li><strong>SCC/PSP are constraints, not suggestions.</strong> Plan for them in any hardened cluster.</li>
</ul>

<hr />

<h3 id="wrapping-up">Wrapping up</h3>

<p>Two gateways, one edge pattern. Kong or MuleSoft Gateway, the BIG-IP side barely changed: TLS and pod-direct load balancing at the edge via CIS, API routing/rate-limiting/auth inside the cluster. That’s the payoff of clean layering — <strong>the gateway is a swappable component.</strong></p>]]></content><author><name>Michael O&apos;Leary</name></author><category term="openshift" /><category term="openshift" /><category term="f5" /><category term="cis" /><category term="kubernetes" /><category term="mulesoft" /><summary type="html"><![CDATA[Another reference architecture where the API gateway and the ADC each do what they do best.]]></summary></entry><entry><title type="html">Alpine jump host, part 2: WinSCP, outbound internet, and forwarding other ports</title><link href="https://michaeloleary.net/hyper-v/alpine-jumphost-part2/" rel="alternate" type="text/html" title="Alpine jump host, part 2: WinSCP, outbound internet, and forwarding other ports" /><published>2026-07-27T00:00:00+00:00</published><updated>2026-07-27T00:00:00+00:00</updated><id>https://michaeloleary.net/hyper-v/alpine-jumphost-part2</id><content type="html" xml:base="https://michaeloleary.net/hyper-v/alpine-jumphost-part2/"><![CDATA[<figure>
    <a href="/assets/alpine-jumphost/network-routing2.jpg"><img src="/assets/alpine-jumphost/network-routing2.jpg" /></a>
    <figcaption>Updates on workarounds that multiple NIC's allow</figcaption>
</figure>

<h3 id="summary">Summary</h3>

<p>After <a href="https://michaeloleary.net/hyper-v/reaching-hyperv-internal-network-on-vpn/">getting the Alpine jump host working</a> for SSH access to my internal Hyper-V VM while on VPN, a few real-world gaps showed up once I started actually using it day to day. This post covers three follow-up issues and how I fixed each one.</p>

<h3 id="1-winscp-doesnt-read-my-ssh-config-file">1. WinSCP doesn’t read my SSH config file</h3>

<p>VSCode’s Remote-SSH extension shells out to the real <code class="language-plaintext highlighter-rouge">ssh.exe</code>, so it picked up my <code class="language-plaintext highlighter-rouge">~/.ssh/config</code> <code class="language-plaintext highlighter-rouge">ProxyJump</code> entry automatically, with zero extra config. WinSCP does not — it has its own SSH implementation and never reads that file, so <code class="language-plaintext highlighter-rouge">ProxyJump</code> is invisible to it.</p>

<p>The fix is to configure the jump manually as a <strong>Tunnel</strong> inside WinSCP itself:</p>

<ol>
  <li>Session settings → <strong>Advanced</strong> → <strong>Connection → Tunnel</strong></li>
  <li>Check <strong>Connect through SSH tunnel</strong></li>
  <li>Tunnel host name: the jump host’s external IP</li>
  <li>Tunnel user name + private key (converted to <code class="language-plaintext highlighter-rouge">.ppk</code> via WinSCP’s bundled PuTTYgen if needed)</li>
  <li>Leave the main session’s host name as the target VM’s internal IP</li>
</ol>

<p>Once that’s set, WinSCP opens the tunnel to the jump host first, then connects on to the target VM through it — same end result as <code class="language-plaintext highlighter-rouge">ProxyJump</code>, just configured in WinSCP’s own UI instead of inherited from a shared config file.</p>

<h3 id="2-outbound-internet-from-the-internal-vm-stopped-working">2. Outbound internet from the internal VM stopped working</h3>

<p>Separately from the SSH/inbound story, I noticed my internal Ubuntu VM couldn’t reach the internet at all while on VPN (<code class="language-plaintext highlighter-rouge">apt-get update</code> and friends). This is a different traffic direction than the jump host was originally built for, so it needed its own fix.</p>

<p>I pointed the Ubuntu VM’s default gateway at the Alpine jump host’s internal IP, and added <code class="language-plaintext highlighter-rouge">FORWARD</code> rules plus <code class="language-plaintext highlighter-rouge">MASQUERADE</code> for outbound traffic — the same shape of rule as the SSH forwarding, just for return-path traffic heading out to the internet instead of into the internal switch.</p>

<p>The rules alone didn’t get it working immediately, though. Troubleshooting turned up two separate issues:</p>

<ul>
  <li><strong>Strict reverse-path filtering (<code class="language-plaintext highlighter-rouge">rp_filter</code>).</strong> With <code class="language-plaintext highlighter-rouge">rp_filter=1</code> (strict) on both interfaces, the kernel was silently dropping forwarded packets before they reached <code class="language-plaintext highlighter-rouge">eth0</code>, even though the routes and FORWARD rules were correct. Disabling it (<code class="language-plaintext highlighter-rouge">rp_filter=0</code> on both interfaces, plus <code class="language-plaintext highlighter-rouge">all</code>/<code class="language-plaintext highlighter-rouge">default</code>) cleared that up:
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
</pre></td><td class="rouge-code"><pre><span class="nb">echo </span>0 <span class="o">&gt;</span> /proc/sys/net/ipv4/conf/eth0/rp_filter
<span class="nb">echo </span>0 <span class="o">&gt;</span> /proc/sys/net/ipv4/conf/eth1/rp_filter
<span class="nb">echo </span>0 <span class="o">&gt;</span> /proc/sys/net/ipv4/conf/all/rp_filter
<span class="nb">echo </span>0 <span class="o">&gt;</span> /proc/sys/net/ipv4/conf/default/rp_filter
</pre></td></tr></tbody></table></code></pre></div>    </div>
  </li>
  <li><strong><code class="language-plaintext highlighter-rouge">net.ipv4.ip_forward</code> was actually still <code class="language-plaintext highlighter-rouge">0</code>.</strong> This was the real root cause — my earlier <code class="language-plaintext highlighter-rouge">sysctl -p</code> hadn’t actually applied it (or something reset it), and I didn’t catch it until I checked <code class="language-plaintext highlighter-rouge">sysctl net.ipv4.ip_forward</code> directly rather than assuming the config file entry meant it was live. With forwarding disabled at the kernel level, packets arriving on the internal NIC were dropped before ever reaching the FORWARD chain — which is why the FORWARD counters stayed at zero no matter what rules I added.</li>
</ul>

<p>Once <code class="language-plaintext highlighter-rouge">ip_forward</code> was actually confirmed as <code class="language-plaintext highlighter-rouge">1</code>, outbound internet access from the internal VM started working immediately, routed through Alpine’s own MASQUERADE’d IP on the way out.</p>

<p><strong>Lesson:</strong> when packets aren’t reaching a chain at all (0 packets/0 bytes on every rule), check the more fundamental kernel switches first (<code class="language-plaintext highlighter-rouge">ip_forward</code>, <code class="language-plaintext highlighter-rouge">rp_filter</code>) before troubleshooting the iptables rules themselves — a correct ruleset won’t matter if forwarding is off at the kernel level.</p>

<h3 id="3-forwarding-other-ports-besides-ssh">3. Forwarding other ports besides SSH</h3>

<p>The original setup only handled SSH (port 22) via <code class="language-plaintext highlighter-rouge">ProxyJump</code>/tunneling, which works because it’s a client-initiated hop through an existing SSH session. But I also wanted to reach a plain HTTP service running on the internal VM (port 4000) directly by hitting the jump host’s external IP — no SSH involved at all.</p>

<p>That needs actual <strong>DNAT (destination NAT / port forwarding)</strong>, not just the FORWARD-and-MASQUERADE setup used for outbound traffic:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>iptables <span class="nt">-t</span> nat <span class="nt">-A</span> PREROUTING <span class="nt">-i</span> eth0 <span class="nt">-p</span> tcp <span class="nt">--dport</span> 4000 <span class="nt">-j</span> DNAT <span class="nt">--to-destination</span> 172.20.100.101:4000
iptables <span class="nt">-A</span> FORWARD <span class="nt">-i</span> eth0 <span class="nt">-o</span> eth1 <span class="nt">-p</span> tcp <span class="nt">-d</span> 172.20.100.101 <span class="nt">--dport</span> 4000 <span class="nt">-m</span> state <span class="nt">--state</span> NEW,ESTABLISHED,RELATED <span class="nt">-j</span> ACCEPT
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The second rule mattered as much as the first: my existing <code class="language-plaintext highlighter-rouge">eth0 → eth1</code> FORWARD rule only permitted <code class="language-plaintext highlighter-rouge">RELATED,ESTABLISHED</code> traffic (since it was written for SSH <em>return</em> traffic, not fresh inbound connections). A brand-new inbound connection to port 4000 doesn’t match either of those states, so without explicitly allowing <code class="language-plaintext highlighter-rouge">NEW</code> traffic for that specific port, the DNAT rule alone wasn’t enough — the first packet still got dropped at FORWARD.</p>

<p>With both rules in place, <code class="language-plaintext highlighter-rouge">http://&lt;jump-host-external-ip&gt;:4000</code> now reaches the internal VM’s service directly.</p>

<h3 id="where-things-stand-now">Where things stand now</h3>

<ul>
  <li>SSH/SFTP via CLI and VSCode: works transparently through <code class="language-plaintext highlighter-rouge">~/.ssh/config</code>, on or off VPN.</li>
  <li>WinSCP: works, via a manually configured tunnel (not automatic).</li>
  <li>Outbound internet from the internal VM: works, routed through Alpine.</li>
  <li>Other TCP services on the internal VM: reachable via explicit DNAT rules per port, as needed.</li>
</ul>

<p>Bit by bit this jump host has turned into the actual gateway for my internal switch, in both directions — worth keeping this post as the reference for which rule handles which direction next time I need to add another port.</p>]]></content><author><name>Michael O&apos;Leary</name></author><category term="hyper-v" /><category term="hyper-v" /><category term="networking" /><category term="vpn" /><category term="alpine" /><summary type="html"><![CDATA[A few follow-up fixes after putting my Hyper-V jump host into daily use: WinSCP needs its own tunnel config, outbound internet from the internal VM needed a kernel setting, and forwarding non-SSH traffic needed real DNAT rules.]]></summary></entry><entry><title type="html">Reaching my Hyper-V internal network while connected to VPN</title><link href="https://michaeloleary.net/hyper-v/alpine-jumphost/" rel="alternate" type="text/html" title="Reaching my Hyper-V internal network while connected to VPN" /><published>2026-07-26T00:00:00+00:00</published><updated>2026-07-26T00:00:00+00:00</updated><id>https://michaeloleary.net/hyper-v/alpine-jumphost</id><content type="html" xml:base="https://michaeloleary.net/hyper-v/alpine-jumphost/"><![CDATA[<figure>
    <a href="/assets/alpine-jumphost/network-routing.jpg"><img src="/assets/alpine-jumphost/network-routing.jpg" /></a>
    <figcaption>Multiple NICs allow for many things</figcaption>
</figure>

<h3 id="summary">Summary</h3>

<p>I run my daily-use Ubuntu VM on a custom Hyper-V Internal virtual switch (see my <a href="https://michaeloleary.net/hyper-v/HyperV-internal-virtual-switch-static-IP/">previous post</a> on giving it a persistent static IP). That setup works great — until I connect to my corporate VPN (F5 Access / BIG-IP APM), at which point the VM becomes completely unreachable from my laptop. The fix wasn’t a routing tweak, it was a small Alpine Linux VM acting as a jump host with one leg on my physical LAN and one leg on the internal switch.</p>

<h3 id="the-problem">The problem</h3>

<p>Connecting to VPN broke SSH/WinSCP/VSCode access to my internal VMs. My first assumption was routing: maybe the VPN client was pushing a full-tunnel default route that was winning over my internal subnet.</p>

<p>Turned out to be wrong on both counts, but it took some diagnosis to get there.</p>

<h4 id="checking-the-routing-table">Checking the routing table</h4>

<p>Before/after connecting to VPN, I compared <code class="language-plaintext highlighter-rouge">route print</code> output. The route to my internal switch’s subnet (<code class="language-plaintext highlighter-rouge">172.20.100.0/24</code>) was actually still present and correctly installed as a persistent route:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>Network Destination        Netmask          Gateway       Interface  Metric
172.20.100.0     255.255.255.0   On-link      172.20.100.1     16
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Since this is a more specific prefix than the VPN’s <code class="language-plaintext highlighter-rouge">0.0.0.0/0</code> default route, longest-prefix-match rules mean it should always win regardless of metric. So the routing table wasn’t lying to me — the route was there, on-link, correct.</p>

<p>And yet, ping to my VM and even to my own internal switch’s gateway (<code class="language-plaintext highlighter-rouge">172.20.100.1</code>) went completely silent while on VPN. No “destination unreachable,” just nothing. That silence is the tell: a route being present but traffic still not passing means something below the routing layer is filtering it.</p>

<h4 id="its-not-routing--its-the-vpn-clients-packet-filter">It’s not routing — it’s the VPN client’s packet filter</h4>

<p>F5 Access (like most enterprise VPN clients doing split-tunnel enforcement) installs a <strong>Windows Filtering Platform (WFP)</strong> callout driver that blocks traffic to non-tunnel destinations at the kernel level, independent of the routing table. This is deliberate anti-split-tunnel policy enforcement, controlled server-side by the BIG-IP APM Access Policy (a “Local Subnet Access” setting, or a split-tunnel exclude list) — not something fixable from the client side or with local admin rights.</p>

<p>I confirmed the scope of the block with a couple of pings while connected to VPN:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">ping 172.20.100.1</code> (my own internal switch gateway) — <strong>failed</strong></li>
  <li><code class="language-plaintext highlighter-rouge">ping 192.168.1.1</code> (my physical LAN gateway) — <strong>succeeded</strong></li>
</ul>

<p>So the block is scoped specifically to the Hyper-V internal-switch adapter, not a blanket “nothing but the tunnel” policy. That distinction is what made a workaround possible at all.</p>

<h3 id="what-i-considered-that-didnt-work">What I considered that didn’t work</h3>

<p>For completeness, here’s what I ruled out before landing on the jump host:</p>

<ul>
  <li><strong>Bumping the interface metric</strong> on the internal vEthernet adapter — didn’t matter, since the route was never the problem.</li>
  <li><strong>Adding a persistent static route</strong> with admin rights (<code class="language-plaintext highlighter-rouge">route -p add ...</code>) — the route survived the VPN connection just fine, but traffic still didn’t pass. This is what confirmed it was a filter, not a route.</li>
  <li><strong>Trying to remove/override the WFP filter directly</strong> — technically possible with admin rights, but a bad idea in practice: the F5 client service re-asserts its policy on a timer or reconnect, so any local override would likely get stomped anyway. More importantly, this is my own company’s security policy working as designed so I didn’t touch. Also, I’m out of my depth here, guided with AI tools but am not confident here anyway.</li>
</ul>

<p>The actual fix for the root cause is a server-side APM policy change (Local Subnet Access, or excluding <code class="language-plaintext highlighter-rouge">172.20.100.0/24</code> from the tunnel) — that’s a conversation with whoever owns the BIG-IP policy, not something to solve locally. In the meantime, I wanted a workaround.</p>

<h3 id="the-workaround-a-jump-host-vm">The workaround: a jump host VM</h3>

<p>The idea: a small VM with one NIC on my physical LAN (External switch) and one NIC on my internal switch, sitting between my laptop and my other VMs. Since traffic to my physical LAN gateway survives the VPN’s filter, SSH-ing to the jump host’s LAN-facing IP works fine even on VPN — and from there, it’s a local hop to the internal switch, which the VPN never touches.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>Host (VPN on) → SSH to jump host on 192.168.1.x → forwards to target VM on 172.20.100.x
</pre></td></tr></tbody></table></code></pre></div></div>

<h4 id="vm-vs-container">VM vs. container</h4>

<p>I considered a Windows container with the <code class="language-plaintext highlighter-rouge">transparent</code> network driver instead of a full VM, mainly to save on footprint. Decided against it:</p>

<ul>
  <li>Docker Desktop’s default WSL2 backend shares one NAT’d network view across containers — no clean way to attach a container directly to two different Hyper-V virtual switches.</li>
  <li>The <code class="language-plaintext highlighter-rouge">transparent</code> driver <em>can</em> attach a container to a vSwitch directly, but that requires Windows Server Core/Nano containers, not Linux containers, and is a much less common, less documented setup.</li>
  <li>A bare Alpine VM ends up with a smaller footprint anyway than Docker Desktop’s backing WSL2 VM, so the “container to save memory” reasoning didn’t actually hold up once I compared the numbers.</li>
</ul>

<p>A VM with two vNICs attached to two switches is just how Hyper-V networking normally works — nothing exotic, nothing to fight.</p>

<h4 id="building-the-jump-host">Building the jump host</h4>

<ul>
  <li><strong>OS</strong>: Alpine Linux, <code class="language-plaintext highlighter-rouge">alpine-virt</code> edition (the ISO built for VM use) — smallest realistic option with normal package management, <code class="language-plaintext highlighter-rouge">iptables</code>, and OpenSSH support.</li>
  <li><strong>VM generation</strong>: started on Generation 2, but hit a boot failure — the VM would fall through to PXE boot even with Hard Drive first in the firmware boot order and Secure Boot disabled. Switched to <strong>Generation 1</strong> (legacy BIOS/MBR boot) and the install was reliable. For a small headless VM with no need for Gen2-specific features, Gen1 is the simpler choice anyway.</li>
  <li><strong>NICs</strong>: one attached to my External virtual switch (physical LAN), one attached to my existing Internal virtual switch (<code class="language-plaintext highlighter-rouge">172.20.100.0/24</code>).</li>
  <li><strong>IP forwarding + NAT</strong>:
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
</pre></td><td class="rouge-code"><pre><span class="nb">echo</span> <span class="s2">"net.ipv4.ip_forward=1"</span> <span class="o">&gt;&gt;</span> /etc/sysctl.conf
sysctl <span class="nt">-p</span>
apk add iptables iptables-openrc
iptables <span class="nt">-t</span> nat <span class="nt">-A</span> POSTROUTING <span class="nt">-o</span> eth0 <span class="nt">-j</span> MASQUERADE
rc-update add iptables
/etc/init.d/iptables save
</pre></td></tr></tbody></table></code></pre></div>    </div>
  </li>
  <li><strong>SSH</strong>: created a non-root user, added my public key to <code class="language-plaintext highlighter-rouge">~/.ssh/authorized_keys</code>, enabled and started <code class="language-plaintext highlighter-rouge">sshd</code>. Had to explicitly set <code class="language-plaintext highlighter-rouge">AllowTcpForwarding yes</code> in <code class="language-plaintext highlighter-rouge">sshd_config</code> — it was blocking the <code class="language-plaintext highlighter-rouge">ProxyJump</code> hop with an “administratively prohibited” error until I turned it on.</li>
</ul>

<h4 id="making-it-transparent-from-windows">Making it transparent from Windows</h4>

<p>A couple of entries in <code class="language-plaintext highlighter-rouge">~/.ssh/config</code> on my laptop:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
</pre></td><td class="rouge-code"><pre>Host jumphost
    HostName 192.168.1.x
    User jumpuser

Host devvm
    HostName 172.20.100.101
    User &lt;my-vm-user&gt;
    ProxyJump jumphost
</pre></td></tr></tbody></table></code></pre></div></div>

<p>With that in place, <code class="language-plaintext highlighter-rouge">ssh devvm</code> (or <code class="language-plaintext highlighter-rouge">ssh 172.20.100.101</code> directly, since the config matches on hostname) transparently hops through the jump host. No change needed in WinSCP or VSCode Remote-SSH either, since both understand <code class="language-plaintext highlighter-rouge">ProxyJump</code>.</p>

<p>I don’t actually work from a PowerShell or CMD prompt day to day — I live in VSCode. Since VSCode’s Remote-SSH extension reads the exact same <code class="language-plaintext highlighter-rouge">~/.ssh/config</code> file the <code class="language-plaintext highlighter-rouge">ssh</code> CLI does, this fix applied with zero changes on my end there. I opened VSCode on VPN, connected to the same host entry I always use, and it just worked — routed through the jump host without me touching any settings.</p>

<h3 id="the-final-result">The final result</h3>

<ul>
  <li>Off VPN: my Ubuntu dev VM is reachable directly on <code class="language-plaintext highlighter-rouge">172.20.100.101</code>, same as always.</li>
  <li>On VPN: SSH to <code class="language-plaintext highlighter-rouge">172.20.100.101</code> is automatically routed via the jump host at <code class="language-plaintext highlighter-rouge">192.168.1.205</code>, and it works — no change to my daily workflow, no manual jump-host step to remember.</li>
</ul>

<h3 id="feedback-and-summary">Feedback and summary</h3>

<p>The lesson that took the longest to land: a correct, present route in <code class="language-plaintext highlighter-rouge">route print</code> doesn’t mean traffic will actually pass — WFP-level filtering happens below routing and won’t show up there at all. Ping tests to a couple of different gateways (internal switch vs. physical LAN) were what actually isolated the scope of the block and pointed me at a workaround instead of a dead end.</p>

<p>The proper long-term fix is still a policy change on the APM side, and I’ll follow up on that separately. This jump host is a workaround, not a fix — but it’s a workaround that survives reboots, VPN reconnects, and switching between Ethernet and WiFi, so it’s good enough for now.</p>]]></content><author><name>Michael O&apos;Leary</name></author><category term="hyper-v" /><category term="hyper-v" /><category term="networking" /><category term="vpn" /><category term="alpine" /><summary type="html"><![CDATA[My corporate VPN client blocks access to my Hyper-V internal switch. Here's how I worked around it with a tiny Alpine jump host.]]></summary></entry><entry><title type="html">Kong + BIG-IP on OpenShift</title><link href="https://michaeloleary.net/openshift/kong-on-openshift-behind-bigip/" rel="alternate" type="text/html" title="Kong + BIG-IP on OpenShift" /><published>2026-07-20T00:00:00+00:00</published><updated>2026-07-20T00:00:00+00:00</updated><id>https://michaeloleary.net/openshift/kong-on-openshift-behind-bigip</id><content type="html" xml:base="https://michaeloleary.net/openshift/kong-on-openshift-behind-bigip/"><![CDATA[<figure>
    <a href="/assets/kong-f5-demo/kong-proxy-big-ip.png"><img src="/assets/kong-f5-demo/kong-proxy-big-ip.png" /></a>
    <figcaption>Traffic flow from outside -&gt; inside the cluster.</figcaption>
</figure>

<p><strong>A reference architecture where the API gateway and the ADC each do what they do best</strong></p>

<p>Often, an API gateway and F5 BIG-IP are <strong>complementary</strong>, not competing. They live at different layers of the stack and solve different problems. Put them together and you get a clean separation of concerns: the ADC owns the edge, the gateway owns API management.</p>

<p>For this article, I have chosen specific products to show configuration examples:</p>
<ul>
  <li>For my <strong>Kubernetes</strong> (K8s) cluster, I’m using <strong>OpenShift</strong></li>
  <li>For my <strong>API Gateway</strong> I’m using open source <strong>Kong</strong></li>
  <li>For my <strong>ADC</strong> I’m using <strong>F5 BIG-IP</strong></li>
</ul>

<p>Of course, I could use a different K8s distribution (Rancher, AKS, GKE, etc) and a different API Gateway (NGINX, Gloo, Apigee, etc), and even a different ADC. The pattern is identical: F5 BIG-IP fronts the gateway, and the gateway fronts the services.</p>

<h3 id="the-split-of-responsibilities">The split of responsibilities</h3>

<p>The whole idea fits on one line:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>client ──HTTPS──▶ F5 BIG-IP (TLS + WAF + LB) ──HTTP──▶ Kong (routing + rate-limit + auth) ──▶ backend apps
                      
</pre></td></tr></tbody></table></code></pre></div></div>

<h4 id="f5-big-ip--the-edge--entry-point">F5 BIG-IP — the edge / entry point:</h4>

<ul>
  <li><strong>TLS termination.</strong> BIG-IP presents the TLS certificate, decrypts, and forwards as HTTP to Kong inside the cluster.</li>
  <li><strong>Advanced WAF.</strong> L7 protection (SQLi, XSS, etc.) is enforced at the edge, before traffic reaches Kong.</li>
  <li><strong>Load balancing straight to pods.</strong> F5 Container Ingress Services (CIS) runs in cluster mode, so the BIG-IP pool members are the Kong proxy <strong>pod IPs</strong>.</li>
</ul>

<h4 id="kong--behind-f5-inside-openshift">Kong — behind F5, inside OpenShift:</h4>

<ul>
  <li><strong>Request routing</strong> to backend services by path.</li>
  <li><strong>Rate limiting</strong> per route.</li>
  <li><strong>Authentication</strong> (here, API key / key-auth).</li>
</ul>

<p>Notice there is no overlap. BIG-IP is not handling API keys; Kong is not performing WAF. Each can be operated by the team that owns it — NetOps/SecOps at the edge, the platform/API team inside the cluster.</p>

<h3 id="the-environment">The environment</h3>

<p><strong>Kong Gateway (open source)</strong> runs as a single Deployment <code class="language-plaintext highlighter-rouge">kong-kong</code> with replicas=1, installed via helm in namespace <code class="language-plaintext highlighter-rouge">kong-f5-demo</code>. The proxy Service is <strong>ClusterIP</strong> on purpose: the external entry point is BIG-IP, not the OpenShift router.</p>

<p>Two sample backends sit behind Kong:</p>

<ul>
  <li><strong>httpbin</strong> (<code class="language-plaintext highlighter-rouge">mccutchen/go-httpbin</code>) exposed at <code class="language-plaintext highlighter-rouge">/httpbin</code></li>
  <li><strong>app2</strong> (<code class="language-plaintext highlighter-rouge">nginx</code> serving a static page) exposed at <code class="language-plaintext highlighter-rouge">/app2</code></li>
</ul>

<p><strong>BIG-IP integration</strong> is via <strong>CIS</strong> running inside the cluster watching custom resources. A WAF policy <code class="language-plaintext highlighter-rouge">/Common/kong_demo_waf_policy</code> already exists on the BIG-IP.</p>

<p>Here is the running namespace <code class="language-plaintext highlighter-rouge">kong-f5-demo</code> shown with CLI output and UI screenshots:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
</pre></td><td class="rouge-code"><pre>$ oc get pods -n kong-f5-demo -o wide
NAME                         READY   STATUS    RESTARTS   AGE     IP            NODE
app2-64bcb6968b-plfp4        1/1     Running   0          6h24m   10.129.0.49   ip-10-0-2-184...
httpbin-67f5ffcb5f-b7b85     1/1     Running   0          6h22m   10.129.0.50   ip-10-0-2-184...
kong-kong-54b7bf4765-78f9j   2/2     Running   0          6h24m   10.129.0.47   ip-10-0-2-184...

$ oc get svc -n kong-f5-demo
NAME              TYPE        CLUSTER-IP       PORT(S)
app2              ClusterIP   172.30.4.204     80/TCP
httpbin           ClusterIP   172.30.29.73     80/TCP
kong-kong-admin   ClusterIP   172.30.27.62     8001/TCP,8444/TCP
kong-kong-proxy   ClusterIP   172.30.130.33    80/TCP,443/TCP
</pre></td></tr></tbody></table></code></pre></div></div>

<figure class="half ">
  
    
      <a href="/assets/kong-f5-demo/shot-01-openshift-pods.png" title="Pod running in OpenShift">
          <img src="/assets/kong-f5-demo/shot-01-openshift-pods.png" alt="Pod running in OpenShift" />
      </a>
    
  
    
      <a href="/assets/kong-f5-demo/shot-03-openshift-services.png" title="Services in OpenShift">
          <img src="/assets/kong-f5-demo/shot-03-openshift-services.png" alt="Services in OpenShift" />
      </a>
    
  
  
    <figcaption>OpenShift screenshots showing Pods and Services.
</figcaption>
  
</figure>

<h3 id="the-configuration">The configuration</h3>

<h4 id="big-ip-one-virtualserver-one-tlsprofile">BIG-IP: one VirtualServer, one TLSProfile</h4>

<p>BIG-IP is configured by CIS, which watches K8s custom resources. Two resources describe the BIG-IP config:</p>

<p><strong>TLSProfile</strong> — TLS edge termination, referencing a K8s TLS secret that holds the cert for <code class="language-plaintext highlighter-rouge">kong-demo.my-f5.com</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
</pre></td><td class="rouge-code"><pre><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">cis.f5.com/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">TLSProfile</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">kong-f5-demo-tls</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">kong-f5-demo</span>
  <span class="na">labels</span><span class="pi">:</span>
    <span class="na">f5cr</span><span class="pi">:</span> <span class="s2">"</span><span class="s">true"</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">hosts</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">kong-demo.my-f5.com</span>
  <span class="na">tls</span><span class="pi">:</span>
    <span class="na">termination</span><span class="pi">:</span> <span class="s">edge</span>
    <span class="na">reference</span><span class="pi">:</span> <span class="s">secret</span>
    <span class="na">clientSSL</span><span class="pi">:</span> <span class="s">kong-f5-demo-tls</span>   <span class="c1"># K8s TLS secret</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p><strong>VirtualServer</strong> — Note the three F5 responsibilities in one object: the TLS profile, the WAF policy reference, and a pool that points straight at the Kong proxy Service (which CIS translates to pod IPs):</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
</pre></td><td class="rouge-code"><pre><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">cis.f5.com/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">VirtualServer</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">kong-proxy-vs</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">kong-f5-demo</span>
  <span class="na">labels</span><span class="pi">:</span> 
    <span class="na">f5cr</span><span class="pi">:</span> <span class="s2">"</span><span class="s">true"</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">host</span><span class="pi">:</span> <span class="s">kong-demo.my-f5.com</span>
  <span class="na">virtualServerAddress</span><span class="pi">:</span> <span class="s">10.0.0.101</span> 
  <span class="na">tlsProfileName</span><span class="pi">:</span> <span class="s">kong-f5-demo-tls</span> <span class="c1"># reference to TLSProfile object</span>
  <span class="na">waf</span><span class="pi">:</span> <span class="s">/Common/kong_demo_waf_policy</span> <span class="c1"># reference to existing policy</span>
  <span class="na">pools</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">path</span><span class="pi">:</span> <span class="s">/</span>
      <span class="na">service</span><span class="pi">:</span> <span class="s">kong-kong-proxy</span> <span class="c1">#pod IP's of this service become pool members</span>
      <span class="na">servicePort</span><span class="pi">:</span> <span class="s">80</span>   
</pre></td></tr></tbody></table></code></pre></div></div>

<p>That <code class="language-plaintext highlighter-rouge">servicePort: 80</code> shows that BIG-IP VIP is listening on tcp/443 (HTTPS), decrypting, and forwarding to the pool on tcp/80 (HTTP).</p>

<figure class="third ">
  
    
      <a href="/assets/kong-f5-demo/bigip1.png" title="BIG-IP screenshot">
          <img src="/assets/kong-f5-demo/bigip1.png" alt="BIG-IP screenshot" />
      </a>
    
  
    
      <a href="/assets/kong-f5-demo/bigip2.png" title="BIG-IP screenshot">
          <img src="/assets/kong-f5-demo/bigip2.png" alt="BIG-IP screenshot" />
      </a>
    
  
    
      <a href="/assets/kong-f5-demo/bigip3.png" title="BIG-IP screenshot">
          <img src="/assets/kong-f5-demo/bigip3.png" alt="BIG-IP screenshot" />
      </a>
    
  
  
    <figcaption>BIG-IP screenshots show the Virtual Server created, the pool created, and the existing WAF policy
</figcaption>
  
</figure>

<h4 id="kong-ingresses-and-plugins">Kong: Ingresses and Plugins</h4>

<p>Kong is also configured the K8s-native way. Each backend gets an <code class="language-plaintext highlighter-rouge">Ingress</code> with <code class="language-plaintext highlighter-rouge">ingressClassName: kong</code> and <code class="language-plaintext highlighter-rouge">konghq.com/strip-path: "true"</code> (so <code class="language-plaintext highlighter-rouge">/httpbin/get</code> reaches the backend as <code class="language-plaintext highlighter-rouge">/get</code>). Policy is defined as <code class="language-plaintext highlighter-rouge">KongPlugin</code> objects and attached to routes with the <code class="language-plaintext highlighter-rouge">konghq.com/plugins</code> annotation.</p>

<p><strong>Routing</strong> — one Ingress per backend:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
</pre></td><td class="rouge-code"><pre><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">networking.k8s.io/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Ingress</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">httpbin</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">kong-f5-demo</span>
  <span class="na">annotations</span><span class="pi">:</span>
    <span class="na">konghq.com/strip-path</span><span class="pi">:</span> <span class="s2">"</span><span class="s">true"</span>
    <span class="na">konghq.com/plugins</span><span class="pi">:</span> <span class="s">rate-limiting-demo</span>   <span class="c1"># policy attached here</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">ingressClassName</span><span class="pi">:</span> <span class="s">kong</span>
  <span class="na">rules</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">http</span><span class="pi">:</span>
        <span class="na">paths</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="na">path</span><span class="pi">:</span> <span class="s">/httpbin</span>
            <span class="na">pathType</span><span class="pi">:</span> <span class="s">ImplementationSpecific</span>
            <span class="na">backend</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">service</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">name</span><span class="pi">:</span> <span class="nv">httpbin</span><span class="pi">,</span> <span class="nv">port</span><span class="pi">:</span> <span class="pi">{</span> <span class="nv">number</span><span class="pi">:</span> <span class="nv">80</span> <span class="pi">}</span> <span class="pi">}</span> <span class="pi">}</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">app2</code> Ingress is identical in shape but points at the <code class="language-plaintext highlighter-rouge">app2</code> Service on <code class="language-plaintext highlighter-rouge">/app2</code> and attaches <code class="language-plaintext highlighter-rouge">key-auth-demo</code> instead.</p>

<p><strong>Rate limiting</strong> — 10 requests/minute, local counter:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
</pre></td><td class="rouge-code"><pre><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">configuration.konghq.com/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">KongPlugin</span>
<span class="na">metadata</span><span class="pi">:</span> 
  <span class="na">name</span><span class="pi">:</span> <span class="s">rate-limiting-demo</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">kong-f5-demo</span>
<span class="na">plugin</span><span class="pi">:</span> <span class="s">rate-limiting</span>
<span class="na">config</span><span class="pi">:</span>
  <span class="na">minute</span><span class="pi">:</span> <span class="m">10</span>
  <span class="na">policy</span><span class="pi">:</span> <span class="s">local</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p><strong>Authentication</strong> — API key carried in the <code class="language-plaintext highlighter-rouge">apikey</code> header, plus a consumer identity:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
</pre></td><td class="rouge-code"><pre><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">configuration.konghq.com/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">KongPlugin</span>
<span class="na">metadata</span><span class="pi">:</span> 
  <span class="na">name</span><span class="pi">:</span> <span class="s">key-auth-demo</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">kong-f5-demo</span>
<span class="na">plugin</span><span class="pi">:</span> <span class="s">key-auth</span>
<span class="na">config</span><span class="pi">:</span>
  <span class="na">key_names</span><span class="pi">:</span> <span class="pi">[</span> <span class="nv">apikey</span> <span class="pi">]</span>
<span class="nn">---</span>
<span class="na">apiVersion</span><span class="pi">:</span> <span class="s">configuration.konghq.com/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">KongConsumer</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">demo-consumer</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">kong-f5-demo</span>
  <span class="na">annotations</span><span class="pi">:</span>
    <span class="na">kubernetes.io/ingress.class</span><span class="pi">:</span> <span class="s">kong</span>
<span class="na">username</span><span class="pi">:</span> <span class="s">demo-consumer</span>
<span class="na">credentials</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">demo-apikey</span>     <span class="c1"># Secret holding the key value</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Again — this is Kong syntax, but the <em>concepts</em> (routes, a rate-limit policy, a key-auth policy, a consumer) map onto every API gateway on the market.</p>

<hr />

<h3 id="proving-it-works">Proving it works</h3>

<p>All requests below go to <code class="language-plaintext highlighter-rouge">https://kong-demo.my-f5.com</code>, which resolves to the BIG-IP VIP <code class="language-plaintext highlighter-rouge">10.0.0.101</code>.</p>

<h4 id="1-tls-is-terminated-on-the-big-ip">1. TLS is terminated on the BIG-IP</h4>

<p>The served certificate is the BIG-IP’s, with <code class="language-plaintext highlighter-rouge">CN=kong-demo.my-f5.com</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
</pre></td><td class="rouge-code"><pre>$ curl -kv https://kong-demo.my-f5.com/httpbin/get 2&gt;&amp;1 | grep -E 'subject:|issuer:'
* SSL connection using TLSv1.2 / ECDHE-ECDSA-AES128-GCM-SHA256
*   subject: CN=kong-demo.my-f5.com
*   issuer:  C=US; O=Let's Encrypt; CN=YE2
</pre></td></tr></tbody></table></code></pre></div></div>

<h4 id="2-the-waf-blocks-attacks-before-kong-sees-them">2. The WAF blocks attacks <em>before</em> Kong sees them</h4>

<p>A classic SQL-injection probe is stopped at the edge — HTTP 403 with the F5 block page:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
</pre></td><td class="rouge-code"><pre>$ curl -k -i "https://kong-demo.my-f5.com/httpbin/get?id=1' OR '1'='1'--"
HTTP/1.1 403 Forbidden
Content-Type: text/html; charset=utf-8
...
&lt;html&gt;&lt;head&gt;&lt;title&gt;Blocked by F5 BIG-IP WAF&lt;/title&gt;&lt;/head&gt;
&lt;body&gt;&lt;h1&gt;403 Forbidden&lt;/h1&gt;
&lt;p&gt;Request blocked by F5 Advanced WAF before reaching Kong.&lt;/p&gt;
&lt;p&gt;Support ID: 1487496205675004032&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;
</pre></td></tr></tbody></table></code></pre></div></div>

<p>An XSS attempt gets the same treatment:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
</pre></td><td class="rouge-code"><pre>$ curl -k -i "https://kong-demo.my-f5.com/httpbin/anything?q=&lt;script&gt;alert(1)&lt;/script&gt;"
HTTP/1.1 403 Forbidden
...
&lt;p&gt;Request blocked by F5 Advanced WAF before reaching Kong.&lt;/p&gt;
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Rendered in a browser, the block page looks like this:</p>

<figure>
    <a href="/assets/kong-f5-demo/shot-02-f5-waf-block-page.png"><img src="/assets/kong-f5-demo/shot-02-f5-waf-block-page.png" /></a>
    <figcaption>The custom 403 block page served by BIG-IP Advanced WAF.</figcaption>
</figure>

<p>The key architectural point: Kong and the backends never process the malicious payload. The attack surface is reduced <em>before</em> the API-management layer.</p>

<h4 id="3-kong-routes-clean-traffic-to-the-backends">3. Kong routes clean traffic to the backends</h4>

<p>Legitimate requests sail through F5 and Kong routes them by path. <code class="language-plaintext highlighter-rouge">/httpbin/get</code> (strip-path turns it into <code class="language-plaintext highlighter-rouge">/get</code>):</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
</pre></td><td class="rouge-code"><pre>$ curl -k https://kong-demo.my-f5.com/httpbin/get
{
  "args": {},
  "headers": {
    "Host": [ "kong-demo.my-f5.com" ],
    "User-Agent": [ "curl/8.18.0" ]
  },
  ...
}
</pre></td></tr></tbody></table></code></pre></div></div>

<h4 id="4-kong-enforces-rate-limiting">4. Kong enforces rate limiting</h4>

<p>The <code class="language-plaintext highlighter-rouge">rate-limiting</code> plugin caps <code class="language-plaintext highlighter-rouge">/httpbin</code> at 10 requests/minute. On every allowed request Kong returns the standard rate-limit headers, and once the budget is spent it returns <strong>HTTP 429</strong> with a JSON body:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
</pre></td><td class="rouge-code"><pre>### RateLimit headers on a normal request:
RateLimit-Limit: 10
RateLimit-Remaining: 9
RateLimit-Reset: 26

### 12 requests inside one minute:
req  1 -&gt; 200
req  2 -&gt; 200
 ...
req 10 -&gt; 200
req 11 -&gt; 429
req 12 -&gt; 429

### Body once the limit is exceeded:
{ "message": "API rate limit exceeded", "request_id": "d75e6d49..." }
</pre></td></tr></tbody></table></code></pre></div></div>

<p><em>Note:</em> This example is hitting Kong proxy directly to show 429 responses. My WAF will re-classify Kong’s 429 response and serve its own 403 block page to the client. It’s a good illustration of the layering: the <em>gateway</em> decides “too many requests, send a 429” the <em>edge</em> decides “I see a 429 response but I’ll forward a 403 to the client”.</p>

<h4 id="5-kong-enforces-authentication">5. Kong enforces authentication</h4>

<p>The <code class="language-plaintext highlighter-rouge">/app2</code> route requires an API key.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
</pre></td><td class="rouge-code"><pre><span class="c"># No key → Kong returns 401</span>

curl <span class="nt">-k</span> <span class="nt">-i</span> https://kong-demo.my-f5.com/app2/
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Key
Content-Type: application/json<span class="p">;</span> <span class="nv">charset</span><span class="o">=</span>utf-8

<span class="o">{</span><span class="s2">"message"</span>:<span class="s2">"No API key found in request"</span><span class="o">}</span>

<span class="c"># Key in `apikey` header → 200 and the app2 page:</span>

curl <span class="nt">-k</span> <span class="nt">-i</span> <span class="nt">-H</span> <span class="s2">"apikey: demo-secret-key-12345"</span> https://kong-demo.my-f5.com/app2/
HTTP/1.1 200 OK
Content-Type: text/html<span class="p">;</span> <span class="nv">charset</span><span class="o">=</span>UTF-8
</pre></td></tr></tbody></table></code></pre></div></div>
<h3 id="why-this-layering-is-worth-it">Why this layering is worth it</h3>

<p>Walk the request path one more time and note who owns each decision:</p>

<table>
  <thead>
    <tr>
      <th>Concern</th>
      <th>Owned by</th>
      <th>Where it runs</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>TLS termination / certs</td>
      <td>F5 BIG-IP</td>
      <td>Edge VIP <code class="language-plaintext highlighter-rouge">10.0.0.101:443</code></td>
    </tr>
    <tr>
      <td>L7 attack protection (WAF)</td>
      <td>F5 BIG-IP</td>
      <td>Edge, before the cluster</td>
    </tr>
    <tr>
      <td>Load balancing to pods</td>
      <td>F5 BIG-IP</td>
      <td>Pool = Kong pod IPs</td>
    </tr>
    <tr>
      <td>Request routing</td>
      <td>API gateway (Kong)</td>
      <td>In-cluster</td>
    </tr>
    <tr>
      <td>Rate limiting</td>
      <td>API gateway (Kong)</td>
      <td>In-cluster, per route</td>
    </tr>
    <tr>
      <td>Authentication</td>
      <td>API gateway (Kong)</td>
      <td>In-cluster, per route</td>
    </tr>
  </tbody>
</table>

<p>A few architectural advantages:</p>

<ul>
  <li><strong>Clean ownership boundaries.</strong> NetOps/SecOps manage the edge (certs, WAF policy, VIPs) via BIG-IP; the API/platform team manages routes and API policy via K8s objects. Neither steps on the other.</li>
  <li><strong>Blast-radius reduction.</strong> SQLi/XSS/other L7 attacks are dropped at the edge.</li>
  <li><strong>Direct-to-pod load balancing.</strong> CIS cluster mode means the BIG-IP balances across Kong pod IPs — no extra NodePort/router hop, and pool membership tracks pods automatically.</li>
  <li><strong>Portability.</strong> Everything is configured using K8s resource, for both Kong and BIG-IP. The gateway is replaceable — the edge pattern doesn’t change.</li>
</ul>

<p><strong>Kong here is just the example.</strong> The architecture here is: let the ADC do TLS, WAF, and pod-level load balancing at the edge, and let your API gateway do routing, rate limiting, and auth. They’re better together.</p>

<hr />

<h3 id="appendix--asset-files">Appendix — asset files</h3>

<p>Raw CLI captures used above are saved alongside this article under <code class="language-plaintext highlighter-rouge">blog-assets/</code>:</p>

<ul>
  <li><a href="/assets/kong-f5-demo/cli-01-openshift-resources.txt"><code class="language-plaintext highlighter-rouge">cli-01-openshift-resources.txt</code></a> — pods / services / ingress / plugins / CRs</li>
  <li><a href="/assets/kong-f5-demo/cli-02-tls-termination.txt"><code class="language-plaintext highlighter-rouge">cli-02-tls-termination.txt</code></a> — served-cert subject/issuer</li>
  <li><a href="/assets/kong-f5-demo/cli-03-waf-block.txt"><code class="language-plaintext highlighter-rouge">cli-03-waf-block.txt</code></a> — SQLi + XSS 403 responses</li>
  <li><a href="/assets/kong-f5-demo/cli-04-routing.txt"><code class="language-plaintext highlighter-rouge">cli-04-routing.txt</code></a> — httpbin + app2 200 responses</li>
  <li><a href="/assets/kong-f5-demo/cli-05-ratelimit.txt"><code class="language-plaintext highlighter-rouge">cli-05-ratelimit.txt</code></a> — rate-limit headers + 200→429 progression + 429 body</li>
  <li><a href="/assets/kong-f5-demo/cli-06-keyauth.txt"><code class="language-plaintext highlighter-rouge">cli-06-keyauth.txt</code></a> — 401 without key, 200 with key</li>
  <li><a href="/assets/kong-f5-demo/cli-07-manifests.txt"><code class="language-plaintext highlighter-rouge">cli-07-manifests.txt</code></a> — full VirtualServer / TLSProfile / Ingress / KongPlugin / KongConsumer manifests</li>
</ul>]]></content><author><name>Michael O&apos;Leary</name></author><category term="openshift" /><category term="openshift" /><category term="f5" /><category term="cis" /><category term="kubernetes" /><category term="kong" /><summary type="html"><![CDATA[A reference architecture where the API gateway and the ADC each do what they do best.]]></summary></entry><entry><title type="html">Building a Kids Closet Built-In: Shelving, Drawers, and Lessons Learned</title><link href="https://michaeloleary.net/home/projects/kids-closet-built-in/" rel="alternate" type="text/html" title="Building a Kids Closet Built-In: Shelving, Drawers, and Lessons Learned" /><published>2026-07-10T00:00:00+00:00</published><updated>2026-07-10T00:00:00+00:00</updated><id>https://michaeloleary.net/home/projects/kids-closet-built-in</id><content type="html" xml:base="https://michaeloleary.net/home/projects/kids-closet-built-in/"><![CDATA[<h3 id="background">Background</h3>

<p>My kid’s closet was a disaster. Two fabric hanging organizers crammed with clothes, shoes piled on the floor, and absolutely no system. The standard single rod and one high shelf weren’t cutting it.</p>

<p>I decided to build a custom built-in: two flanking shelving towers, a center hanging zone, and four shallow drawers below the hang area. Total interior width was 50.5 inches. I wanted everything white, clean, and furniture-quality.</p>

<p>This is a record of what I did, what I learned, and what I’d do differently.</p>

<!-- Gallery 1: Before photos -->

<figure class="half ">
  
    
      <a href="/assets/closet-shelves/IMG_6238.JPEG" title="closet before photo">
          <img src="/assets/closet-shelves/IMG_6238.JPEG" alt="closet before photo" />
      </a>
    
  
    
      <a href="/assets/closet-shelves/IMG_6664.JPEG" title="closet after photo">
          <img src="/assets/closet-shelves/IMG_6664.JPEG" alt="closet after photo" />
      </a>
    
  
  
    <figcaption>Before and after: chaos and order.
</figcaption>
  
</figure>

<hr />

<h3 id="design-decisions">Design decisions</h3>

<p>The closet is 50.5 inches wide with a 30-inch door opening and 10-inch recesses on each side behind the door frame. The depth is 26 inches total, but with clearance for reaching in, the shelving depth is 12 inches.</p>

<p>Key constraints:</p>
<ul>
  <li>An existing top shelf support at 67 inches height (kept it — no reason to remove it)</li>
  <li>An existing mid-height wall support at 34.5 inches (used it as a natural shelf rest)</li>
  <li>Baseboard 0.75 inches thick and 5 inches tall, running along all three walls</li>
  <li>No reliable stud locations (old house, unreliable stud finder)</li>
</ul>

<p>The final layout:</p>
<ul>
  <li><strong>Two inner divider panels</strong> — 12 inches deep, 67 inches tall, notched at the back to clear the baseboard, mid support, and top support</li>
  <li><strong>Left and right shelving towers</strong> — 14.5 inches interior width each, 5 shelves per tower at 12.5”, 23.5”, 34.5”, 45.5”, and 56.5” from the floor</li>
  <li><strong>Center zone</strong> — 20 inches wide: hanging rod above 34.5”, four shallow drawers below</li>
  <li><strong>Full-width top shelf</strong> at 67 inches resting on the existing wall support</li>
</ul>

<hr />
<h3 id="tools-and-materials">Tools and materials</h3>

<p><strong>Tools:</strong></p>
<ul>
  <li>main tools: drill, impact driver, jigsaw, miter saw, circular saw, sander</li>
  <li>other: Clamps, big and small spirit levels, square ruler, pocket hole jig</li>
</ul>

<p><strong>Materials:</strong></p>
<ul>
  <li>3/4 inch birch plywood (shelving and dividers)</li>
  <li>1/2 inch birch plywood (drawer box sides, fronts, backs) and 1/4 inch (bottoms)</li>
  <li>white primer and white paint</li>
  <li>other: drawer slides, screws, wood glue</li>
</ul>

<hr />

<h3 id="build-process">Build process</h3>
<p>The store cut my plywood into 12-inch strips on their panel saw, which made transport easy and reduced my circular saw work significantly.</p>

<p><strong>Divider panels first.</strong> Each panel needed three notches cut from the back edge to clear the wall supports: baseboard (0.75 deep × 5 tall), mid support (0.75 deep × 3.75 tall, starting at 30.75 inches), and top support (0.75 deep × 3.5 tall, from the top). I did a terrible job with a cheap jigsaw but it will work.</p>

<p><strong>Dry fit everything before committing.</strong> I assembled the full structure with one temporary screw per divider panel before any glue or painting. This confirmed the notches cleared correctly, the top shelf sat level, and the proportions looked right in person. A few minor adjustments.</p>

<p><strong>Cleats for shelf support.</strong> I cut simple 11-inch wooden cleats from offcuts (3/4 × 1 inch strips) and glued and screwed them to the wall faces and divider panel faces at each shelf height. At shelf positions that coincide with the existing wall supports, the support itself carries that side and only the divider-side cleat is needed.</p>

<figure class="third ">
  
    
      <a href="/assets/closet-shelves/IMG_6487.JPEG" title="Notches. Not perfect but will work. ">
          <img src="/assets/closet-shelves/IMG_6487.JPEG" alt="Notches. Not perfect but will work. " />
      </a>
    
  
    
      <a href="/assets/closet-shelves/IMG_6485.JPEG" title="dry fit">
          <img src="/assets/closet-shelves/IMG_6485.JPEG" alt="dry fit" />
      </a>
    
  
    
      <a href="/assets/closet-shelves/IMG_6518.JPEG" title="dry fit">
          <img src="/assets/closet-shelves/IMG_6518.JPEG" alt="dry fit" />
      </a>
    
  
  
    <figcaption>Dry fit. The notch is ugly but it works.
</figcaption>
  
</figure>

<p><strong>Paint before final assembly.</strong> I sanded everything to 220 grit outdoors, primed one coat, lightly re-sanded, then applied two coats of matte white. Should have done semi-gloss but bought the wrong paint and will leave with it.</p>

<figure class="third ">
  
    
      <a href="/assets/closet-shelves/IMG_6581.JPEG" title="Primer coat">
          <img src="/assets/closet-shelves/IMG_6581.JPEG" alt="Primer coat" />
      </a>
    
  
    
      <a href="/assets/closet-shelves/IMG_6582.JPEG" title="Primer coat">
          <img src="/assets/closet-shelves/IMG_6582.JPEG" alt="Primer coat" />
      </a>
    
  
    
      <a href="/assets/closet-shelves/IMG_6592.JPEG" title="Shelving done">
          <img src="/assets/closet-shelves/IMG_6592.JPEG" alt="Shelving done" />
      </a>
    
  
  
    <figcaption>Painting.
</figcaption>
  
</figure>

<p><strong>Drawers built from scratch.</strong> Each drawer box: front and back at 19.25 × 7 inches, sides at 10 × 7 inches (10 inches to account for the front and back panel thickness), and a 1/4 inch plywood bottom at 19.25 × 11 inches. Assembled with wood glue screws.</p>

<p>Slide heights from the floor (with 1/8 inch offset per Everbilt instructions):</p>
<ul>
  <li>Drawer 1: 5 and 1/8 inches</li>
  <li>Drawer 2: 12 and 5/8 inches</li>
  <li>Drawer 3: 20 and 1/8 inches</li>
  <li>Drawer 4: 27 and 5/8 inches</li>
</ul>

<p>Drawer faces cut at 21 × 7.5 inches (full overlay, covering the 20.25-inch opening with slight overlap onto the divider panel faces). Positioned using a US quarter as a consistent 1/16-inch spacer between faces, starting from the bottom drawer and working up. This was not 100% perfect, but they work.</p>

<!-- Gallery 2: In-progress build photos -->

<figure class="half ">
  
    
      <a href="/assets/closet-shelves/IMG_6615.JPEG" title="building drawers">
          <img src="/assets/closet-shelves/IMG_6615.JPEG" alt="building drawers" />
      </a>
    
  
    
      <a href="/assets/closet-shelves/IMG_6616.JPEG" title="building drawers">
          <img src="/assets/closet-shelves/IMG_6616.JPEG" alt="building drawers" />
      </a>
    
  
  
    <figcaption>Building drawers.
</figcaption>
  
</figure>

<figure class="third ">
  
    
      <a href="/assets/closet-shelves/IMG_6618.JPEG" title="Drawer building">
          <img src="/assets/closet-shelves/IMG_6618.JPEG" alt="Drawer building" />
      </a>
    
  
    
      <a href="/assets/closet-shelves/IMG_6632.JPEG" title="Drawer building">
          <img src="/assets/closet-shelves/IMG_6632.JPEG" alt="Drawer building" />
      </a>
    
  
    
      <a href="/assets/closet-shelves/IMG_6635.JPEG" title="Primer coat">
          <img src="/assets/closet-shelves/IMG_6635.JPEG" alt="Primer coat" />
      </a>
    
  
  
    <figcaption>Installing drawers and drawer faces.
</figcaption>
  
</figure>

<hr />

<h3 id="lessons-learned">Lessons learned</h3>

<p><strong>Use the store’s panel saw.</strong> Getting the store to rip your sheet goods into 12-inch strips saves enormous time and effort.</p>

<p><strong>Dry-fit before painting, label before disassembly.</strong> Mark every piece with a pencil before you take it apart.</p>

<p><strong>Wooden cleats are underrated.</strong> I considered shelf pin strips, L-brackets, and other hardware, but simple plywood offcut cleats glued and screwed in place are strong, cheap, and I think they look nice.</p>

<p><strong>Pocket screws need the right length.</strong> 1-1/4 inch Kreg screws are for 3/4 inch material. For 1/2 inch drawer box material I had to switch to standard wood screws — the Kreg jig isn’t set up for thin stock without a different collar setting and shorter screws. Plan ahead.</p>

<p><strong>Measure slide heights from the actual installed base, not the theoretical one.</strong> My baseboard height was 4.5 inches, not 5 inches as I’d initially thought. Small difference, but it cascades through all four slide heights if you get it wrong.</p>

<p><strong>Drawer face positioning: don’t use a coin as a spacer.</strong> This was annoying in the end. Next time I’ll plan ahead and have something better as a spacer.</p>

<hr />

<h3 id="what-id-do-differently">What I’d do differently</h3>

<ul>
  <li><strong>Face panels.</strong> Seeing 3/4 inch plywood edges makes this look like a dad’s DIY project (which it is), not a professional job. Next time I might plan for something more.</li>
  <li><strong>Plan better.</strong> If I did this again I’d spend more time drawing this out.</li>
  <li><strong>Invest in a better stud finder earlier.</strong> I’ve done this now.</li>
  <li><strong>Double-check dimensions if Home Depot does the cuts</strong>. After getting 1x 48-inch piece cut into 4 pieces, I had 3x 12-inch widths, and 1x ~11 inch. I forgot to account for minor variances.</li>
  <li><strong>Semi-gloss paint for the shelving.</strong> I used matte, which looks clean, but semi-gloss would be more durable and wipeable for a kids’ room.</li>
</ul>

<!-- Gallery 6: Finished product -->

<figure class="third ">
  
    
      <a href="/assets/closet-shelves/IMG_6637.JPEG" title="Done">
          <img src="/assets/closet-shelves/IMG_6637.JPEG" alt="Done" />
      </a>
    
  
    
      <a href="/assets/closet-shelves/IMG_6666.JPEG" title="Done">
          <img src="/assets/closet-shelves/IMG_6666.JPEG" alt="Done" />
      </a>
    
  
    
      <a href="/assets/closet-shelves/IMG_6665.JPEG" title="Done">
          <img src="/assets/closet-shelves/IMG_6665.JPEG" alt="Done" />
      </a>
    
  
  
    <figcaption>Finished: painted, drawers in, clothes organized.
</figcaption>
  
</figure>]]></content><author><name>Michael O&apos;Leary</name></author><category term="home" /><category term="projects" /><category term="home" /><category term="projects" /><summary type="html"><![CDATA[How I designed and built a custom closet organizer with flanking shelving towers, a hanging zone, and four shallow drawers — start to finish.]]></summary></entry><entry><title type="html">CIS: OpenShift Operator + Service type LB</title><link href="https://michaeloleary.net/openshift/cis-operator-load-balancer/" rel="alternate" type="text/html" title="CIS: OpenShift Operator + Service type LB" /><published>2026-06-25T00:00:00+00:00</published><updated>2026-06-25T00:00:00+00:00</updated><id>https://michaeloleary.net/openshift/cis-operator-load-balancer</id><content type="html" xml:base="https://michaeloleary.net/openshift/cis-operator-load-balancer/"><![CDATA[<figure>
    <a href="/assets/cis-operator-lb/operator-header2.jpg"><img src="/assets/cis-operator-lb/operator-header-2.jpg" /></a>
    <figcaption>Don't forget to drink 8 glasses of water today.</figcaption>
</figure>

<p>In <a href="https://michaeloleary.net/openshift/cis-operator-cli/">my last post</a> I installed the F5 CIS operator from the CLI and brought up the <code class="language-plaintext highlighter-rouge">F5BigIpCtlr</code> operand. This post picks up right after that: the operator is running, CIS is healthy, and now a customer wants to expose an application with a plain <code class="language-plaintext highlighter-rouge">Service</code> of <code class="language-plaintext highlighter-rouge">type: LoadBalancer</code> instead of an <code class="language-plaintext highlighter-rouge">Ingress</code>, a <code class="language-plaintext highlighter-rouge">Route</code>, or a <code class="language-plaintext highlighter-rouge">VirtualServer</code> custom resource.</p>

<h3 id="why-service-type-lb">Why Service Type LB?</h3>
<p>Some teams don’t want to manage a CIS-specific CRD for every app - they just want the same <code class="language-plaintext highlighter-rouge">type: LoadBalancer</code> experience they’d get from a cloud provider’s native load balancer, and they want BIG-IP to be the thing that satisfies it on-prem.</p>

<p>The short version: CIS doesn’t watch <code class="language-plaintext highlighter-rouge">Service</code> objects for <code class="language-plaintext highlighter-rouge">LoadBalancer</code> behavior out of the box. Two operator-level <code class="language-plaintext highlighter-rouge">args</code> need to be set before CIS will even look at the Service, and then the Service itself needs an annotation telling CIS what IP to use.</p>

<h3 id="why-a-service-alone-isnt-enough">Why a Service alone isn’t enough</h3>

<p>If you just create a <code class="language-plaintext highlighter-rouge">Service</code> with <code class="language-plaintext highlighter-rouge">type: LoadBalancer</code> against a default CIS install, nothing happens. <code class="language-plaintext highlighter-rouge">EXTERNAL-IP</code> sits at <code class="language-plaintext highlighter-rouge">&lt;pending&gt;</code> forever, because there’s no cloud controller manager assigning it and CIS, by default, isn’t watching for this resource type at all.</p>

<p>CIS has to be put into a mode where it watches Custom Resources, and on OpenShift, it needs to know which CNI plugin is in play so it can correctly read pod and node networking information. Both of these are operator-level settings, not something you can fix from the Service manifest alone.</p>

<h3 id="notable-parameters">Notable parameters</h3>
<p>Notice these parameters were all included when we defined our operand in our last post.</p>

<table>
  <thead>
    <tr>
      <th>Parameter</th>
      <th>What it does</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">custom_resource_mode</code></td>
      <td>Switches CIS into CRD mode. This is also the mode that gives CIS native support for <code class="language-plaintext highlighter-rouge">Service</code> objects of <code class="language-plaintext highlighter-rouge">type: LoadBalancer</code> - without it, CIS only processes Routes or ConfigMaps, never Services.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">orchestration_cni</code></td>
      <td>Tells CIS which CNI plugin the cluster runs, so it can correctly read pod CIDR and node IP information for that CNI. For an OpenShift cluster running OVN-Kubernetes (the default since 4.x), this is <code class="language-plaintext highlighter-rouge">ovn-k8s</code>.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">static_routing_mode</code></td>
      <td>Adds Static Routes on the BIGIP so that traffic can be directly route to the pods. (Without tunnels)</td>
    </tr>
  </tbody>
</table>

<p>Worth calling out: <code class="language-plaintext highlighter-rouge">custom_resource_mode</code> is the parameter that’s actually mandatory for <code class="language-plaintext highlighter-rouge">Service</code> type <code class="language-plaintext highlighter-rouge">LoadBalancer</code> support. <code class="language-plaintext highlighter-rouge">static_routing_mode</code> and <code class="language-plaintext highlighter-rouge">orchestration_cni</code> enable routes to be created on F5 BIG-IP so that the BIG-IP can know which Pod IP address blocks belong to which OpenShift nodes.</p>

<h3 id="the-f5bigipctlr-operand">The F5BigIpCtlr operand</h3>

<p>This is the same operand I had in the last blog post.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
</pre></td><td class="rouge-code"><pre><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">cis.f5.com/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">F5BigIpCtlr</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">cis1</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">kube-system</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">kube-system</span>
  <span class="na">args</span><span class="pi">:</span>
    <span class="na">bigip_url</span><span class="pi">:</span> <span class="s">10.0.4.11</span>
    <span class="na">bigip_partition</span><span class="pi">:</span> <span class="s">openshift</span>
    <span class="na">log_level</span><span class="pi">:</span> <span class="s">DEBUG</span>
    <span class="na">insecure</span><span class="pi">:</span> <span class="no">true</span>
    <span class="na">pool_member_type</span><span class="pi">:</span> <span class="s">cluster</span>
    <span class="na">manage_routes</span><span class="pi">:</span> <span class="no">false</span>
    <span class="na">custom_resource_mode</span><span class="pi">:</span> <span class="no">true</span>
    <span class="na">static_routing_mode</span><span class="pi">:</span> <span class="no">true</span>
    <span class="na">orchestration_cni</span><span class="pi">:</span> <span class="s">ovn-k8s</span>
  <span class="na">bigip_login_secret</span><span class="pi">:</span> <span class="s">bigip-login</span>
  <span class="na">image</span><span class="pi">:</span>
    <span class="na">repo</span><span class="pi">:</span> <span class="s">f5networks/cntr-ingress-svcs</span>
    <span class="na">user</span><span class="pi">:</span> <span class="s">registry.connect.redhat.com</span>
    <span class="na">pullPolicy</span><span class="pi">:</span> <span class="s">Always</span>
  <span class="na">version</span><span class="pi">:</span> <span class="s">2.20.4-ubi10</span>
  <span class="na">ingressClass</span><span class="pi">:</span>
    <span class="na">create</span><span class="pi">:</span> <span class="no">false</span>
    <span class="na">defaultController</span><span class="pi">:</span> <span class="no">false</span>
    <span class="na">ingressClassName</span><span class="pi">:</span> <span class="s">f5</span>
  <span class="na">rbac</span><span class="pi">:</span>
    <span class="na">create</span><span class="pi">:</span> <span class="no">true</span>
    <span class="na">namespaced</span><span class="pi">:</span> <span class="no">false</span>
  <span class="na">serviceAccount</span><span class="pi">:</span>
    <span class="na">create</span><span class="pi">:</span> <span class="no">true</span>

</pre></td></tr></tbody></table></code></pre></div></div>

<h3 id="exposing-the-application">Exposing the application</h3>

<p>With CIS configured, the customer’s application Service just needs <code class="language-plaintext highlighter-rouge">type: LoadBalancer</code> plus one CIS-specific annotation that tells CIS which IP to configure on BIG-IP for the virtual server.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
</pre></td><td class="rouge-code"><pre><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Service</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">annotations</span><span class="pi">:</span>
    <span class="na">cis.f5.com/ip</span><span class="pi">:</span> <span class="s">10.8.3.1</span> <span class="c1">#the VIP gets created with this IP</span>
  <span class="na">labels</span><span class="pi">:</span>
    <span class="na">app</span><span class="pi">:</span> <span class="s">hello-world</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">hello-world-svc-1</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">hello-world</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">ports</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">port8080</span>
      <span class="na">port</span><span class="pi">:</span> <span class="m">8080</span>
      <span class="na">protocol</span><span class="pi">:</span> <span class="s">TCP</span>
      <span class="na">targetPort</span><span class="pi">:</span> <span class="m">8080</span>
  <span class="na">selector</span><span class="pi">:</span>
    <span class="na">app</span><span class="pi">:</span> <span class="s">hello-world</span>
  <span class="na">type</span><span class="pi">:</span> <span class="s">LoadBalancer</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Check that the IP landed on the Service:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>oc get svc hello-world-svc-1 <span class="nt">-n</span> hello-world 
</pre></td></tr></tbody></table></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">EXTERNAL-IP</code> should now show <code class="language-plaintext highlighter-rouge">10.8.3.1</code> instead of <code class="language-plaintext highlighter-rouge">&lt;pending&gt;</code>, and CIS will have created a corresponding LTM virtual server on BIG-IP at that address.</p>

<h3 id="a-note-on-the-ipam-alternative">A note on the IPAM alternative</h3>
<p><code class="language-plaintext highlighter-rouge">cis.f5.com/ip</code> is the hardcoded path - good for a customer who already owns a static IP and just wants CIS to honor it, which is the case I’m documenting here. F5 also ships an <a href="https://clouddocs.f5.com/containers/latest/userguide/ipam/">F5 IPAM Controller</a> that can assign the address automatically from a pre-defined range, using a <code class="language-plaintext highlighter-rouge">cis.f5.com/ipamLabel</code> annotation instead of <code class="language-plaintext highlighter-rouge">cis.f5.com/ip</code>. That path needs the additional <code class="language-plaintext highlighter-rouge">ipam: true</code> arg on the CIS operand and an <code class="language-plaintext highlighter-rouge">F5IPAM</code> resource to define the range.</p>

<p>If both annotations happen to be present on the same Service, <code class="language-plaintext highlighter-rouge">cis.f5.com/ip</code> wins - CIS treats the hardcoded IP as the more specific instruction. For most customer environments I’ve seen, where IP assignment is already governed by an IPAM process outside the cluster, hardcoding via <code class="language-plaintext highlighter-rouge">cis.f5.com/ip</code> is the simpler and more predictable choice. IPAM earns its keep once you have many Services and don’t want to hand-manage individual addresses.</p>

<h3 id="final-thoughts">Final thoughts</h3>

<p>Simple blog post intended for a copy/paste option for a customer that has installed CIS via OpenShift operator and wants to expose services of type <code class="language-plaintext highlighter-rouge">LoadBalancer</code>.</p>

<p>If you’re working through this for a real customer deployment, feel free to reach out!</p>]]></content><author><name>Michael O&apos;Leary</name></author><category term="openshift" /><category term="openshift" /><category term="f5" /><category term="cis" /><category term="kubernetes" /><summary type="html"><![CDATA[A customer has CIS installed via the operator and a Service of type LoadBalancer. Here's the operator-level config, and the Service annotation example.]]></summary></entry><entry><title type="html">Installing the F5 CIS OpenShift Operator programmatically</title><link href="https://michaeloleary.net/openshift/cis-operator-cli/" rel="alternate" type="text/html" title="Installing the F5 CIS OpenShift Operator programmatically" /><published>2026-06-22T00:00:00+00:00</published><updated>2026-06-22T00:00:00+00:00</updated><id>https://michaeloleary.net/openshift/cis-operator-cli</id><content type="html" xml:base="https://michaeloleary.net/openshift/cis-operator-cli/"><![CDATA[<figure>
    <a href="/assets/cis-operator-cli/operator-header.jpg"><img src="/assets/cis-operator-cli/operator-header.jpg" /></a>
    <figcaption>Telephone Operators. Not the same thing.</figcaption>
</figure>

<p>I’ve installed F5 Container Ingress Services (CIS) into OpenShift a  different ways over the years - raw manifests, Helm, and the OperatorHub tile. This week I wanted to install it via the OperatorHub operator, but skip the console entirely and do it from the CLI instead. The goal is to replicate, step by step, what a customer’s GitOps pipeline does when it subscribes to an operator, so I understand exactly what objects get created and in what order.</p>

<p>This post walks through:</p>

<ol>
  <li>A quick reminder of what’s actually happening when you click “Install” in OperatorHub</li>
  <li>Doing the same install manually through the console, for reference</li>
  <li>Doing it again from the CLI, object by object</li>
  <li>Deploying the CIS operand (<code class="language-plaintext highlighter-rouge">F5BigIpCtlr</code>) with a plain YAML manifest once the operator is running</li>
</ol>

<p>Cluster is OpenShift 4.21, freshly built, nothing else installed yet.</p>

<h3 id="why-bother-with-the-cli-when-the-gui-already-works">Why bother with the CLI when the GUI already works</h3>

<p>A few reasons this was worth the time:</p>

<ul>
  <li><strong>GitOps.</strong> If a customer manages their cluster with ArgoCD or Flux, the operator subscription is just another manifest in a repo. Understanding the underlying objects means I can hand them exactly what they need instead of “go click around in OperatorHub.”</li>
  <li><strong>Repeatability.</strong> I rebuild lab clusters constantly. <code class="language-plaintext highlighter-rouge">oc apply -f</code> three times beats re-navigating the console every time.</li>
  <li><strong>It demystifies OLM.</strong> The console hides a sequence of object creations behind a single “Install” button. Once you’ve created the <code class="language-plaintext highlighter-rouge">Namespace</code>, <code class="language-plaintext highlighter-rouge">OperatorGroup</code>, and <code class="language-plaintext highlighter-rouge">Subscription</code> yourself, the rest of Operator Lifecycle Manager (OLM) stops being a black box.
None of this is news if you’ve installed operators via CLI before, but it’s the first time I’ve done it specifically to mirror a customer’s workflow with the F5 CIS operator, so I wanted to write it down.</li>
</ul>

<h3 id="what-olm-actually-creates">What OLM actually creates</h3>

<p>OperatorHub installs, whether from the console or the CLI, boil down to the same three objects:</p>

<table>
  <thead>
    <tr>
      <th>Object</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Namespace</code></td>
      <td>Where the operator (and usually the operand) will live</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">OperatorGroup</code></td>
      <td>Tells OLM which namespace(s) the operator should watch</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Subscription</code></td>
      <td>Declares the package, channel, catalog source, and approval strategy; this is what triggers OLM to generate an <code class="language-plaintext highlighter-rouge">InstallPlan</code></td>
    </tr>
  </tbody>
</table>

<p>The <code class="language-plaintext highlighter-rouge">InstallPlan</code> and the resulting <code class="language-plaintext highlighter-rouge">ClusterServiceVersion</code> (CSV) are generated automatically by OLM in response to the <code class="language-plaintext highlighter-rouge">Subscription</code> - you never author those two yourself. Red Hat’s official walkthrough of this is in <a href="https://docs.redhat.com/en/documentation/openshift_container_platform/4.18/html/operators/administrator-tasks">Administrator tasks for Operators in OpenShift Container Platform</a>, specifically the “Installing from OperatorHub using the CLI” section. (If you’re on 4.21 docs and the link above 404s, just bump the version number in the URL - Red Hat’s doc structure is stable across releases.)</p>

<figure>
    <a href="/assets/cis-operator-cli/operatorhub-search.png"><img src="/assets/cis-operator-cli/operatorhub-search.png" /></a>
    <figcaption>OperatorHub search results filtered to "F5," showing the F5 Container Ingress Services tile</figcaption>
</figure>

<h2 id="part-1-installing-via-the-console-for-reference">Part 1: Installing via the console (for reference)</h2>

<p>I’m not going to dwell on this part since it’s not the point of the post, but for completeness:</p>

<ol>
  <li><strong>Operators → OperatorHub</strong>, search for <code class="language-plaintext highlighter-rouge">F5</code></li>
  <li>Select <strong>F5 Container Ingress Services</strong>, click <strong>Install</strong></li>
  <li>Choose an installation mode (<strong>kube-system</strong> namespace, in my case), an update channel, and an approval strategy</li>
  <li>Click <strong>Install</strong> again and wait for the CSV to reach <code class="language-plaintext highlighter-rouge">Succeeded</code>
That’s the entire console flow. Behind the scenes it’s creating the same <code class="language-plaintext highlighter-rouge">Namespace</code>/<code class="language-plaintext highlighter-rouge">OperatorGroup</code>/<code class="language-plaintext highlighter-rouge">Subscription</code> trio described above - the console just bundles the choices into a wizard.</li>
</ol>

<figure>
    <a href="/assets/cis-operator-cli/operator-install-screen.png"><img src="/assets/cis-operator-cli/operator-install-screen.png" /></a>
    <figcaption>The "Install Operator" form for F5 Container Ingress Services</figcaption>
</figure>

<h2 id="part-2-installing-via-the-cli">Part 2: Installing via the CLI</h2>

<p>Prerequisites from the F5 documentation:</p>
<ol>
  <li>Create BIG-IP secret: <code class="language-plaintext highlighter-rouge">oc create secret generic &lt;SECRET-NAME&gt; -n kube-system --from-literal=username=&lt;USERNAME&gt; --from-literal=password=&lt;PASSWORD&gt;</code></li>
  <li>Install CRD’s: <code class="language-plaintext highlighter-rouge">oc create -f https://raw.githubusercontent.com/F5Networks/k8s-bigip-ctlr/${CIS_VERSION}/docs/config_examples/customResourceDefinitions/customresourcedefinitions.yml</code></li>
</ol>

<h3 id="step-1-find-the-exact-package-name-and-channel">Step 1: Find the exact package name and channel</h3>

<p>Don’t guess at the package name. Query the catalog directly:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>oc get packagemanifests -n openshift-marketplace | grep -i f5
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Then get the details for the one you want:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>oc describe packagemanifest f5-bigip-ctlr-operator -n openshift-marketplace
</pre></td></tr></tbody></table></code></pre></div></div>

<p>This output tells you three things you need for the <code class="language-plaintext highlighter-rouge">Subscription</code>: the available <strong>channels</strong>, the <strong>catalog source</strong> name (e.g. <code class="language-plaintext highlighter-rouge">certified-operators</code>), and the <strong>default channel</strong>.</p>

<h3 id="step-2-create-the-namespace">Step 2: Create the Namespace</h3>

<p>(Since we are going to install the Operator into the kube-system namespace which already exists, we won’t worry about this)</p>

<h3 id="step-3-create-the-operatorgroup">Step 3: Create the OperatorGroup</h3>

<p>The <code class="language-plaintext highlighter-rouge">OperatorGroup</code> scopes which namespace(s) the operator watches. Since I only need CIS watching one namespace, this is a single-namespace install:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
</pre></td><td class="rouge-code"><pre><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">operators.coreos.com/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">OperatorGroup</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">f5-cis-og</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">kube-system</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">targetNamespaces</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">kube-system</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>oc apply -f operatorgroup.yaml
</pre></td></tr></tbody></table></code></pre></div></div>

<h3 id="step-4-create-the-subscription">Step 4: Create the Subscription</h3>

<p>This is the object that actually kicks OLM into action.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
</pre></td><td class="rouge-code"><pre><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">operators.coreos.com/v1alpha1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Subscription</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">f5-cis-subscription</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">kube-system</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">channel</span><span class="pi">:</span> <span class="s">stable</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">f5-bigip-ctlr-operator</span>
  <span class="na">source</span><span class="pi">:</span> <span class="s">certified-operators</span>
  <span class="na">sourceNamespace</span><span class="pi">:</span> <span class="s">openshift-marketplace</span>
  <span class="na">installPlanApproval</span><span class="pi">:</span> <span class="s">Automatic</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>A couple of fields worth calling out:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">channel</code>, <code class="language-plaintext highlighter-rouge">name</code>, and <code class="language-plaintext highlighter-rouge">source</code> come straight from the <code class="language-plaintext highlighter-rouge">packagemanifest describe</code> output in Step 1 — don’t hardcode mine without checking yours
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>oc apply -f subscription.yaml
</pre></td></tr></tbody></table></code></pre></div>    </div>
  </li>
</ul>

<h3 id="step-5-watch-it-converge">Step 5: Watch it converge</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
</pre></td><td class="rouge-code"><pre>oc get subscription f5-cis-subscription -n kube-system -o yaml
oc get installplan -n kube-system
oc get csv -n kube-system
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Once the CSV’s <code class="language-plaintext highlighter-rouge">PHASE</code> reads <code class="language-plaintext highlighter-rouge">Succeeded</code>, the operator pod is up and the <code class="language-plaintext highlighter-rouge">F5BigIpCtlr</code> CRD is registered on the cluster.</p>

<figure>
    <a href="/assets/cis-operator-cli/csv-succeeded.png"><img src="/assets/cis-operator-cli/csv-succeeded.png" /></a>
    <figcaption>`oc get csv -n kube-system` output with PHASE = Succeeded</figcaption>
</figure>

<h2 id="part-3-deploying-the-operand-with-a-yaml-manifest">Part 3: Deploying the operand with a YAML manifest</h2>

<p>The operator itself doesn’t do anything until you create an instance of its CRD. For the F5 CIS operator, that CRD is <code class="language-plaintext highlighter-rouge">F5BigIpCtlr</code> under the <code class="language-plaintext highlighter-rouge">cis.f5.com/v1</code> API group. Since this is a Helm-based operator, the spec fields map directly onto the underlying Helm chart’s <code class="language-plaintext highlighter-rouge">values.yaml</code> keys - which is the whole reason this operator is “just” a packaging layer on top of Helm.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
</pre></td><td class="rouge-code"><pre><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">cis.f5.com/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">F5BigIpCtlr</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">cis1</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">kube-system</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">kube-system</span>
  <span class="na">args</span><span class="pi">:</span>
    <span class="na">bigip_url</span><span class="pi">:</span> <span class="s">10.0.4.11</span>
    <span class="na">bigip_partition</span><span class="pi">:</span> <span class="s">openshift</span>
    <span class="na">log_level</span><span class="pi">:</span> <span class="s">DEBUG</span>
    <span class="na">insecure</span><span class="pi">:</span> <span class="no">true</span>
    <span class="na">pool_member_type</span><span class="pi">:</span> <span class="s">cluster</span>
    <span class="na">manage_routes</span><span class="pi">:</span> <span class="no">false</span>
    <span class="na">custom_resource_mode</span><span class="pi">:</span> <span class="no">true</span>
    <span class="na">static_routing_mode</span><span class="pi">:</span> <span class="no">true</span>
    <span class="na">orchestration_cni</span><span class="pi">:</span> <span class="s">ovn-k8s</span>
  <span class="na">bigip_login_secret</span><span class="pi">:</span> <span class="s">bigip-login</span>
  <span class="na">image</span><span class="pi">:</span>
    <span class="na">repo</span><span class="pi">:</span> <span class="s">f5networks/cntr-ingress-svcs</span>
    <span class="na">user</span><span class="pi">:</span> <span class="s">registry.connect.redhat.com</span>
    <span class="na">pullPolicy</span><span class="pi">:</span> <span class="s">Always</span>
  <span class="na">version</span><span class="pi">:</span> <span class="s">2.20.4-ubi10</span>
  <span class="na">ingressClass</span><span class="pi">:</span>
    <span class="na">create</span><span class="pi">:</span> <span class="no">false</span>
    <span class="na">defaultController</span><span class="pi">:</span> <span class="no">false</span>
    <span class="na">ingressClassName</span><span class="pi">:</span> <span class="s">f5</span>
  <span class="na">rbac</span><span class="pi">:</span>
    <span class="na">create</span><span class="pi">:</span> <span class="no">true</span>
    <span class="na">namespaced</span><span class="pi">:</span> <span class="no">false</span>
  <span class="na">serviceAccount</span><span class="pi">:</span>
    <span class="na">create</span><span class="pi">:</span> <span class="no">true</span>

</pre></td></tr></tbody></table></code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>oc apply -f f5bigipctlr.yaml
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Confirm the controller pod comes up:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre>oc get pods -n kube-system
oc logs deploy/cis1-f5-bigip-ctlr -n kube-system
</pre></td></tr></tbody></table></code></pre></div></div>

<h2 id="final-thoughts">Final thoughts</h2>

<p>None of the individual steps here are complicated. What’s useful about going through them by hand is seeing exactly which three objects OLM needs (<code class="language-plaintext highlighter-rouge">Namespace</code>, <code class="language-plaintext highlighter-rouge">OperatorGroup</code>, <code class="language-plaintext highlighter-rouge">Subscription</code>) versus which two it generates for you (<code class="language-plaintext highlighter-rouge">InstallPlan</code>, <code class="language-plaintext highlighter-rouge">CSV</code>). For my purposes - matching a customer’s deployment workflow rather than just getting CIS running - that distinction matters more than the YAML itself.</p>

<p>If you’re doing this for real rather than as an exercise, feel free to reach out!</p>]]></content><author><name>Michael O&apos;Leary</name></author><category term="openshift" /><category term="openshift" /><category term="f5" /><summary type="html"><![CDATA[Installing F5 CIS from OperatorHub, both by hand and with oc, then deploying the operand with a plain YAML manifest]]></summary></entry></feed>