blog: publish three posts — observe-first-deny-second, snat-ate-my-source, no-ssh

Heroes de-watermarked via the calibrated Jacobi diffuse (dewatermark.mjs now
parameterised src/out), diagrams authored in the house light-card style.
This commit is contained in:
2026-07-14 18:43:32 +10:00
parent a0d71388c4
commit 91b3cff67a
9 changed files with 506 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

+81
View File
@@ -0,0 +1,81 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1180" height="470" viewBox="0 0 1180 470" role="img" aria-label="A traditional server with many interactive doors accumulating drift, contrasted with a sealed Talos node whose only interface is a mutual-TLS API accepting a versioned machine-config document and answering typed reads.">
<rect width="1180" height="470" fill="#ffffff"></rect>
<!-- title -->
<text x="60" y="46" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="23" font-weight="700" fill="#0e1726">Many doors and drift, versus one API and a document</text>
<text x="61" y="70" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13.5" fill="#64748b">every interactive door is a place where reality and the record can quietly diverge</text>
<defs>
<marker id="arr3" markerWidth="10" markerHeight="8" refX="8" refY="4" orient="auto">
<path d="M0,0 L9,4 L0,8 z" fill="#64748b"></path>
</marker>
<marker id="arrm3" markerWidth="10" markerHeight="8" refX="8" refY="4" orient="auto">
<path d="M0,0 L9,4 L0,8 z" fill="#c026d3"></path>
</marker>
<marker id="arrc3" markerWidth="10" markerHeight="8" refX="8" refY="4" orient="auto">
<path d="M0,0 L9,4 L0,8 z" fill="#0c8fce"></path>
</marker>
</defs>
<!-- ===== left: the traditional box ===== -->
<rect x="60" y="110" width="440" height="290" rx="16" fill="#c026d3" fill-opacity="0.04" stroke="#c026d3" stroke-width="2.2" stroke-dasharray="7 6"></rect>
<text x="78" y="136" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13" font-weight="700" fill="#c026d3">a server that accepts hands</text>
<!-- the box itself -->
<rect x="150" y="200" width="260" height="150" rx="14" fill="#ffffff" stroke="#0e1726" stroke-width="1.6"></rect>
<text x="280" y="268" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="15" font-weight="700" fill="#0e1726">the machine</text>
<text x="280" y="292" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#c026d3">state: whatever the last pair</text>
<text x="280" y="308" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#c026d3">of hands left behind</text>
<!-- doors (arrows in) -->
<text x="88" y="188" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#0e1726">sshd :22</text>
<line x1="130" y1="184" x2="196" y2="216" stroke="#c026d3" stroke-width="1.8" stroke-dasharray="6 5" marker-end="url(#arrm3)"></line>
<text x="72" y="248" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#0e1726">console login</text>
<line x1="140" y1="252" x2="188" y2="262" stroke="#c026d3" stroke-width="1.8" stroke-dasharray="6 5" marker-end="url(#arrm3)"></line>
<text x="66" y="312" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#0e1726">package manager</text>
<line x1="140" y1="316" x2="188" y2="306" stroke="#c026d3" stroke-width="1.8" stroke-dasharray="6 5" marker-end="url(#arrm3)"></line>
<text x="110" y="382" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#0e1726">"just quickly fix it"</text>
<line x1="220" y1="374" x2="252" y2="354" stroke="#c026d3" stroke-width="1.8" stroke-dasharray="6 5" marker-end="url(#arrm3)"></line>
<!-- drift callout -->
<text x="424" y="228" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="12.5" font-weight="700" fill="#c026d3">drift:</text>
<text x="424" y="246" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">unrecorded &#183;</text>
<text x="424" y="262" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">load-bearing</text>
<text x="424" y="278" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">by next month</text>
<!-- ===== right: the sealed node ===== -->
<rect x="560" y="110" width="584" height="290" rx="16" fill="#0c8fce" fill-opacity="0.04" stroke="#0c8fce" stroke-width="2.2" stroke-dasharray="7 6"></rect>
<text x="578" y="136" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13" font-weight="700" fill="#0c8fce">a sealed node &#8212; the API is the only interface</text>
<!-- git document -->
<rect x="588" y="200" width="180" height="110" rx="14" fill="#ffffff" stroke="#7c3aed" stroke-width="1.8"></rect>
<text x="678" y="232" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#7c3aed">the document</text>
<text x="678" y="254" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">machine config in git</text>
<text x="678" y="270" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">disks &#183; NICs &#183; mirrors &#183; CNI</text>
<text x="678" y="286" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">secrets substituted at render</text>
<!-- API keyhole -->
<rect x="828" y="214" width="120" height="82" rx="14" fill="#ffffff" stroke="#0c8fce" stroke-width="1.8"></rect>
<text x="888" y="246" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#0c8fce">the API</text>
<text x="888" y="268" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">mTLS gRPC</text>
<text x="888" y="284" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">no shell behind it</text>
<!-- the node -->
<rect x="1008" y="200" width="112" height="110" rx="14" fill="#0e1726" stroke="#0e1726" stroke-width="1.6"></rect>
<text x="1064" y="252" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#ffffff">the node</text>
<text x="1064" y="274" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10" fill="#7dd3fc">state = document</text>
<!-- flows -->
<line x1="768" y1="240" x2="824" y2="240" stroke="#7c3aed" stroke-width="2" marker-end="url(#arr3)"></line>
<text x="796" y="228" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10" fill="#7c3aed">apply</text>
<line x1="948" y1="240" x2="1004" y2="240" stroke="#0c8fce" stroke-width="2" marker-end="url(#arrc3)"></line>
<!-- typed reads coming back -->
<path d="M 1008 290 L 954 290" fill="none" stroke="#0c8fce" stroke-width="1.8" stroke-dasharray="6 5" marker-end="url(#arrc3)"></path>
<text x="700" y="342" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">typed reads out through the same keyhole:</text>
<text x="700" y="360" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">/proc/uptime (prove the reboot) &#183; logs &#183; image pull (prove the credential)</text>
<text x="700" y="378" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">dry-run diff: what changes, and whether it costs a reboot</text>
<!-- footnote -->
<text x="60" y="442" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="12.5" fill="#64748b">Rebuild = reprint the document. Drift has nowhere to live, because nobody can log in to create it.</text>
</svg>

After

Width:  |  Height:  |  Size: 7.7 KiB

@@ -0,0 +1,65 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1180" height="430" viewBox="0 0 1180 430" role="img" aria-label="The observe-first rollout loop: watch real flows with Hubble, write the allowlist from that evidence, apply it out-of-band where rollback is one delete, verify it is enforcing with zero legitimate drops, and only then commit it to git where the reconciler defends it.">
<rect width="1180" height="430" fill="#ffffff"></rect>
<!-- title -->
<text x="60" y="46" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="23" font-weight="700" fill="#0e1726">Observe first, deny second: the loop</text>
<text x="61" y="70" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13.5" fill="#64748b">every rule points at a flow you watched happen &#8212; and the policy only reaches git after it has proven itself</text>
<!-- arrow marker defs -->
<defs>
<marker id="arr" markerWidth="10" markerHeight="8" refX="8" refY="4" orient="auto">
<path d="M0,0 L9,4 L0,8 z" fill="#64748b"></path>
</marker>
<marker id="arrc" markerWidth="10" markerHeight="8" refX="8" refY="4" orient="auto">
<path d="M0,0 L9,4 L0,8 z" fill="#0c8fce"></path>
</marker>
</defs>
<!-- stage 1: observe -->
<rect x="56" y="150" width="188" height="110" rx="14" fill="#ffffff" stroke="#0c8fce" stroke-width="1.8"></rect>
<text x="150" y="182" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="15" font-weight="700" fill="#0c8fce">observe</text>
<text x="150" y="206" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">Hubble &#183; real flows</text>
<text x="150" y="222" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">source &#183; destination &#183; port</text>
<text x="150" y="238" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">before any policy exists</text>
<!-- stage 2: write -->
<rect x="292" y="150" width="188" height="110" rx="14" fill="#ffffff" stroke="#7c3aed" stroke-width="1.8"></rect>
<text x="386" y="182" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="15" font-weight="700" fill="#7c3aed">write from evidence</text>
<text x="386" y="206" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">zero-peer default-deny</text>
<text x="386" y="222" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">+ allows for watched flows</text>
<text x="386" y="238" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">DNS by label &#183; host probes &#183; API</text>
<!-- stage 3: apply out-of-band (dashed boundary = temporary state) -->
<rect x="528" y="138" width="212" height="134" rx="16" fill="#c026d3" fill-opacity="0.04" stroke="#c026d3" stroke-width="2.2" stroke-dasharray="7 6"></rect>
<text x="634" y="176" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="15" font-weight="700" fill="#c026d3">apply out-of-band</text>
<text x="634" y="200" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">kubectl, not git</text>
<text x="634" y="216" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">the reconciler can't fight</text>
<text x="634" y="232" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">what it can't see</text>
<text x="634" y="256" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#c026d3">rollback = one delete</text>
<!-- stage 4: verify -->
<rect x="788" y="150" width="188" height="110" rx="14" fill="#ffffff" stroke="#0c8fce" stroke-width="1.8"></rect>
<text x="882" y="182" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="15" font-weight="700" fill="#0c8fce">verify both ways</text>
<text x="882" y="206" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">status: Valid=True</text>
<text x="882" y="222" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">forbidden flow drops</text>
<text x="882" y="238" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">zero legitimate drops</text>
<!-- stage 5: commit -->
<rect x="1024" y="150" width="120" height="110" rx="14" fill="#ffffff" stroke="#0e1726" stroke-width="1.6"></rect>
<text x="1084" y="188" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="15" font-weight="700" fill="#0e1726">commit</text>
<text x="1084" y="212" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">GitOps adopts</text>
<text x="1084" y="228" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">+ defends it</text>
<!-- forward arrows -->
<line x1="244" y1="205" x2="288" y2="205" stroke="#64748b" stroke-width="2" marker-end="url(#arr)"></line>
<line x1="480" y1="205" x2="524" y2="205" stroke="#64748b" stroke-width="2" marker-end="url(#arr)"></line>
<line x1="740" y1="205" x2="784" y2="205" stroke="#64748b" stroke-width="2" marker-end="url(#arr)"></line>
<line x1="976" y1="205" x2="1020" y2="205" stroke="#64748b" stroke-width="2" marker-end="url(#arr)"></line>
<!-- loop-back: verify failure returns to observe -->
<path d="M 882 264 L 882 330 L 150 330 L 150 268" fill="none" stroke="#0c8fce" stroke-width="1.8" stroke-dasharray="6 5" marker-end="url(#arrc)"></path>
<text x="516" y="322" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#0c8fce">a legitimate drop found? back to watching &#8212; the fence was wrong, not the animal</text>
<!-- footnote -->
<text x="60" y="396" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="12.5" fill="#64748b">After the commit, rollback changes shape: git revert &#8212; self-heal now restores the policy against any hand-delete, exactly as designed.</text>
</svg>

After

Width:  |  Height:  |  Size: 6.6 KiB

+87
View File
@@ -0,0 +1,87 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1180" height="470" viewBox="0 0 1180 470" role="img" aria-label="Two paths for the same client packet: with externalTrafficPolicy Cluster the node SNATs the connection so policy sees the un-restrictable world identity; with externalTrafficPolicy Local the client's real address survives to policy evaluation and a precise allow rule can match it.">
<rect width="1180" height="470" fill="#ffffff"></rect>
<!-- title -->
<text x="60" y="46" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="23" font-weight="700" fill="#0e1726">Policies bind to what arrives, not what was sent</text>
<text x="61" y="70" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13.5" fill="#64748b">the same client, the same service &#8212; one traffic-policy line decides whether the policy ever meets the sender</text>
<defs>
<marker id="arr2" markerWidth="10" markerHeight="8" refX="8" refY="4" orient="auto">
<path d="M0,0 L9,4 L0,8 z" fill="#64748b"></path>
</marker>
<marker id="arrm" markerWidth="10" markerHeight="8" refX="8" refY="4" orient="auto">
<path d="M0,0 L9,4 L0,8 z" fill="#c026d3"></path>
</marker>
<marker id="arrc2" markerWidth="10" markerHeight="8" refX="8" refY="4" orient="auto">
<path d="M0,0 L9,4 L0,8 z" fill="#0c8fce"></path>
</marker>
</defs>
<!-- ===== top lane: ETP Cluster ===== -->
<rect x="36" y="100" width="1108" height="150" rx="16" fill="#c026d3" fill-opacity="0.04" stroke="#c026d3" stroke-width="2.2" stroke-dasharray="7 6"></rect>
<text x="54" y="124" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13" font-weight="700" fill="#c026d3">externalTrafficPolicy: Cluster &#8212; the default, and the depot stamp</text>
<!-- client -->
<rect x="66" y="146" width="170" height="84" rx="14" fill="#ffffff" stroke="#0e1726" stroke-width="1.6"></rect>
<text x="151" y="178" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#0e1726">client</text>
<text x="151" y="200" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#0c8fce">src 10.0.11.42</text>
<text x="151" y="217" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">an honest return address</text>
<!-- LB VIP -->
<rect x="306" y="146" width="150" height="84" rx="14" fill="#ffffff" stroke="#7c3aed" stroke-width="1.8"></rect>
<text x="381" y="184" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#7c3aed">LB VIP</text>
<text x="381" y="206" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">any node accepts</text>
<!-- SNAT -->
<rect x="526" y="146" width="200" height="84" rx="14" fill="#ffffff" stroke="#c026d3" stroke-width="1.8"></rect>
<text x="626" y="176" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#c026d3">node SNAT</text>
<text x="626" y="198" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#0e1726">src &#8594; the node's own</text>
<text x="626" y="215" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">stamped over in transit</text>
<!-- policy sees world -->
<rect x="796" y="146" width="200" height="84" rx="14" fill="#ffffff" stroke="#c026d3" stroke-width="1.8"></rect>
<text x="896" y="176" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#c026d3">policy evaluates</text>
<text x="896" y="198" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#c026d3">identity: world</text>
<text x="896" y="215" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">the sender is gone</text>
<text x="1052" y="182" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13" font-weight="700" fill="#c026d3">no rule can</text>
<text x="1052" y="200" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13" font-weight="700" fill="#c026d3">name the client</text>
<line x1="236" y1="188" x2="302" y2="188" stroke="#64748b" stroke-width="2" marker-end="url(#arr2)"></line>
<line x1="456" y1="188" x2="522" y2="188" stroke="#64748b" stroke-width="2" marker-end="url(#arr2)"></line>
<line x1="726" y1="188" x2="792" y2="188" stroke="#c026d3" stroke-width="2" marker-end="url(#arrm)"></line>
<!-- ===== bottom lane: ETP Local ===== -->
<rect x="36" y="272" width="1108" height="150" rx="16" fill="#0c8fce" fill-opacity="0.04" stroke="#0c8fce" stroke-width="2.2" stroke-dasharray="7 6"></rect>
<text x="54" y="296" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="13" font-weight="700" fill="#0c8fce">externalTrafficPolicy: Local &#8212; the sender survives (commit it, or self-heal undoes the fix)</text>
<!-- client -->
<rect x="66" y="318" width="170" height="84" rx="14" fill="#ffffff" stroke="#0e1726" stroke-width="1.6"></rect>
<text x="151" y="350" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#0e1726">client</text>
<text x="151" y="372" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#0c8fce">src 10.0.11.42</text>
<!-- LB VIP backend-local -->
<rect x="306" y="318" width="200" height="84" rx="14" fill="#ffffff" stroke="#7c3aed" stroke-width="1.8"></rect>
<text x="406" y="348" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#7c3aed">LB VIP &#8212; backend node only</text>
<text x="406" y="370" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">no cross-node forward</text>
<text x="406" y="387" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">no SNAT needed</text>
<!-- policy sees real client -->
<rect x="576" y="318" width="220" height="84" rx="14" fill="#ffffff" stroke="#0c8fce" stroke-width="1.8"></rect>
<text x="686" y="348" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#0c8fce">policy evaluates</text>
<text x="686" y="370" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="11" fill="#0c8fce">identity: 10.0.11.42</text>
<text x="686" y="387" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">the envelope kept its address</text>
<!-- precise rule -->
<rect x="866" y="318" width="200" height="84" rx="14" fill="#ffffff" stroke="#0e1726" stroke-width="1.6"></rect>
<text x="966" y="348" text-anchor="middle" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="14" font-weight="700" fill="#0e1726">a precise allow</text>
<text x="966" y="370" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#0e1726">fromCIDR the LAN &#183; a /32 peer</text>
<text x="966" y="387" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Menlo, monospace" font-size="10.5" fill="#64748b">least privilege, possible again</text>
<line x1="236" y1="360" x2="302" y2="360" stroke="#64748b" stroke-width="2" marker-end="url(#arr2)"></line>
<line x1="506" y1="360" x2="572" y2="360" stroke="#64748b" stroke-width="2" marker-end="url(#arr2)"></line>
<line x1="796" y1="360" x2="862" y2="360" stroke="#0c8fce" stroke-width="2" marker-end="url(#arrc2)"></line>
<!-- footnote -->
<text x="60" y="446" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="12.5" fill="#64748b">Caveats that keep it honest: overlay/subnet routers still masquerade regardless &#8212; and swapping the LB plumbing itself (kube-proxy &#8594; eBPF) changes what arrives under an unchanged policy. Observe before you allow.</text>
</svg>

After

Width:  |  Height:  |  Size: 8.5 KiB

+80
View File
@@ -0,0 +1,80 @@
---
title: "My servers don't have SSH, and that's the feature"
date: 2026-07-14
summary: "Every box in this fleet runs an OS with no shell, no package manager, and no SSH daemon — the entire machine is an API with a declarative config. It sounds like giving up control. It's the opposite: you can't drift what you can't touch."
tags: ["talos", "kubernetes", "immutable-infrastructure", "security", "homelab"]
draft: false
hero: "/blog/no-ssh.webp"
heroAlt: "A sealed obsidian machine with a single structured API port of light"
---
The first thing everyone asks about Talos Linux: how do you get in? You don't. There is no SSH daemon to
connect to, no shell waiting behind it, no package manager if you got there, and no login even at the
physical console. The operating system boots, runs Kubernetes, and answers exactly one thing: a mutual-TLS
gRPC API. Six machines in this fleet, and not one of them is a place I can *visit*.
That sounds like a limitation you'd tolerate for the security. It took about a week of running it to
realise it's the point.
## A server you can't visit is a server you can't drift
Every hand-run command on a traditional box is an unrecorded change: a config edited to test something, a
package installed during an incident, a daemon restarted with a flag nobody wrote down. None of it is in
git. All of it is load-bearing by next month. Configuration drift isn't a failure of discipline — it's the
*inevitable* product of machines that accept hands.
Talos removes the hands. The entire machine — disks, network interfaces pinned by MAC, kernel modules,
registry mirrors, even the manifests the cluster boots with — is one declarative document, applied through
the API, versioned in git. There's no side door through which an undocumented change can arrive, because
there's no door. The machine config isn't documentation *of* the machine. It **is** the machine.
<!-- DIAGRAM: left — a traditional server as a house with many doors (ssh, console, package manager), arrows of drift entering; right — a sealed Talos block with one structured API port, a signed config document flowing in -->
![Many doors and drift, versus one API and a document](/diagrams/no-ssh.svg)
## Operating through the keyhole
Day-two work changes shape. Logs, service status, process lists, even packet capture arrive through typed
API calls, not a terminal session — and every mutation is an *apply*: edit the document, run a dry-run diff
that says precisely what will change and whether it costs a reboot, then commit it. The dry-run gate is the
quiet superpower — on a machine you can't shell into, "what exactly will this do?" stops being a guess and
becomes an answer the API owes you *before* anything moves.
This week put that shape under load. Three of the six nodes had their network datapath swapped — kube-proxy
out, Cilium's eBPF replacement in — and their registry credentials rotated, one reboot each, entirely
through the API. Nobody logged into anything, because there is nothing to log into. The credentials never
touched a live machine either: they're substituted into the document at render time from an encrypted file
and arrive as configuration, not keystrokes — there is no terminal to mistype them into.
Two habits from that work show what keyhole operation feels like. After an apply that costs a reboot, the
API answers again *before* the machine has cycled — the door reopening proves nothing. So you read
`/proc/uptime` through the API and demand a number seconds old; the check has caught a node still showing
413,223 seconds of uptime with the apply long returned. You don't trust the door. You read the building's
own clock through the keyhole. And before calling a node good, you have it pull a container image through
that same API — the machine exercises its new registry credential end-to-end while you watch from outside.
When something's genuinely wrong, the recovery isn't archaeology on a mutated filesystem — it's
reconciliation: re-apply the known-good document, or in the worst case rebuild the node from it in minutes.
The machine has no state worth rescuing *because nothing was ever hand-placed on it*. Pets die of unknown
illnesses; documents get reprinted.
## The honest trade
The console is gone as a *control* surface, not as a *truth* surface — and that distinction still bites. A
node once dropped off the network in a way that looked, from every remote signal, like a total lockup. The
physical screen told a different story: the box was alive and healthy, only its network path had wedged.
The lesson isn't "you need a shell" — a shell would have shown the same thing more slowly. It's that
firsthand evidence still outranks remote inference, and an API-only fleet needs its operators to remember
the difference on the day it matters. The same lesson came back this week when a node went dark at layer 2:
the fix was a screwdriver, not a session — no SSH daemon answers on a dead NIC either.
## The principle
Control isn't the ability to touch a machine — it's the guarantee that the machine matches a document you
trust. Every interactive door a server offers is a place where reality and the record can quietly diverge,
and everything that makes fleets miserable lives in that gap. Seal the doors, apply the document, keep the
diff. The machine you can't log into is the only one whose state you truly know.
*Live across a six-node Talos fleet: machine configs generated and versioned in git, applied over mTLS with
dry-run gates, nodes rebuilt from documents when hardware moves. Three of the six had their network datapath
swapped and their registry credentials rotated this week — one reboot each, entirely through the API. No SSH
key to any of them exists, because there is nothing for it to open.*
@@ -0,0 +1,96 @@
---
title: "Observe first, deny second"
date: 2026-07-14
summary: "Everyone writes network policy from the architecture diagram, and the diagram is always wrong. The only allowlist that survives contact with production is one written from the flows you actually watched — applied out-of-band, proven enforcing, and only then handed to GitOps."
tags: ["security", "networking", "cilium", "hubble", "kubernetes", "gitops"]
draft: false
hero: "/blog/observe-first-deny-second.webp"
heroAlt: "A dark reserve at night, instrument beams tracing animal paths before any fence exists"
---
Here's the uncomfortable default: when it's time to lock a namespace down, almost everyone opens the
architecture diagram and starts writing allow rules from it. This talks to that, that talks to the database,
done. The diagram is confident, tidy — and wrong. It omits the probe traffic, the DNS hop, the controller's
back-channel to the API server, the one call some library makes that nobody documented.
Policy written from a diagram *feels* like engineering. It's fiction with enforcement attached — and
enforcement doesn't care that the fiction was well-intentioned. It drops the flow anyway, in production,
at whatever hour the flow next happens.
I made the case for the default-deny floor itself in [the trust-nothing post](/blog/trust-nothing); this is
the other half: how you roll it across a live fleet, namespace by namespace, without an outage. The inversion is
simple. Don't fence first and see what starves. **Survey the reserve, then build the fence.**
<!-- DIAGRAM: the loop — Hubble observe → write allowlist from evidence → apply out-of-band → verify enforcing + zero drops → commit to git -->
![The observe → deny → verify → commit loop](/diagrams/observe-first-deny-second.svg)
## Survey before you fence
A ranger doesn't fence a reserve from a map. They track the actual animals for a season — where they drink,
which paths they take at night — because the fence has to leave every real path open and close everything else.
Hubble is that season of tracking. Before a single policy exists, watch the namespace's real flows — every
connection, source and destination identity, port. That record *is* the allowlist. Not the docs, not your
memory of the architecture, not the diagram. Every rule you write should point at a flow you watched happen.
Start with the lowest-risk namespaces and leave the control plane for last: mis-fence a leaf app and one thing
breaks; mis-fence the plane that runs deploys and everything does.
## Build the fence where you can tear it down
Apply the policy trio — the default-deny plus its allows — **out-of-band** with `kubectl`, not through git.
This is deliberate, and it's the step people skip. The GitOps reconciler doesn't know the policy exists, so
it can't fight you over it, and rollback is one `kubectl delete` — instant, no commit, no sync wait. Push the
policy through git first and you've inverted your own safety: selfHeal means the reconciler restores whatever
you delete, in seconds, while you stand there believing you rolled back.
Out-of-band is a temporary state, not a destination. It's the fence held up with clamps while you check the
gates.
## Prove it bites, prove nothing bleeds
Two verifications, and both are mandatory because each one lies without the other.
First, prove the policy enforces at all. On Cilium 1.19 a policy can be **accepted but inert** — it passes a
server-side dry-run, sits in the cluster looking correct, and enforces nothing. A policy that looks live and
isn't is worse than no policy: it changes what you believe without changing what the network does. Read the
live object's status conditions and demand `Valid=True`, then watch a forbidden connection get dropped. "It
applied cleanly" and "it's enforcing" are different sentences.
Second, prove nothing legitimate is bleeding. Back to Hubble — zero drops on real flows, and the real consumer
path exercised end-to-end, not just a curl from your own shell.
Only when both hold does the policy go to git. The reconciler adopts it, out-of-band ends, and the rollback
story changes shape: from here, undo means `git revert` — because selfHeal now defends the policy as hard as
it would have fought your rollback.
The sternest test so far wasn't a rollout at all: the fleet's datapath was swapped out underneath the
policies — kube-proxy replaced by Cilium's eBPF kube-proxy-replacement on the last three clusters, one reboot
each — and afterwards every namespace's floor re-verified enforcing: 30/30, 32/32, 27/27 `Valid=True`, zero
legitimate drops. The fence held while the ground under it was replaced.
## The paths nobody draws
Three flows exist in every namespace and appear on no diagram. Omit any one and you pay:
- **DNS — allowed by label, never by IP.** Pin the resolver's ClusterIP into a rule and it never matches at
all — the address is rewritten to a backend pod before policy judges the flow. Select `k8s-app: kube-dns`
and it holds forever.
- **The kubelet's probes.** Health checks arrive from the **host** entity on *every* workload. Forget the
allow and your pods go NotReady the moment the deny lands — the fence starving the animals it was meant
to protect.
- **API-server egress.** Every controller and operator talks to the Kubernetes API constantly. A controller
that "manages X" usually manages it *through* the API server, so this one allow often covers its whole
lifecycle.
## The principle
The method generalises past network policy. Any control that turns assumption into enforcement — firewall,
RBAC, admission — is only as honest as the evidence it was written from. Observation first, out-of-band while
unproven, permanent only after it's earned it.
Policy written from observation is engineering. Policy written from a diagram is fiction — and production is
where fiction gets fact-checked.
*Rolled out across all six Talos clusters on Cilium 1.19 — every namespace surveyed with Hubble before its
deny landed, the control plane last, zero outages; the floor re-verified enforcing after this week's
kube-proxy→eBPF datapath swap.*
+97
View File
@@ -0,0 +1,97 @@
---
title: "SNAT ate my source IP"
date: 2026-07-14
summary: "A LoadBalancer service with the default traffic policy rewrites every incoming packet's source to the node's own address — so by the time a network policy sees it, the real client is gone. You cannot allowlist a sender the network has already erased."
tags: ["security", "networking", "cilium", "kubernetes", "load-balancing"]
draft: false
hero: "/blog/snat-ate-my-source.webp"
heroAlt: "A glowing envelope passing through a dark sorting machine that stamps over its return address"
---
Here's the uncomfortable default: give a Kubernetes service a LoadBalancer IP, leave
`externalTrafficPolicy` at its default of `Cluster`, and **every packet that arrives gets its source
address rewritten to the node's own**. Not by an attacker. By the load balancer, on purpose, as
routine plumbing.
I found out the way you always find out — by writing a network policy for it. LAN clients live on
`10.0.11.0/24`, so I wrote a `fromCIDR 10.0.11.0/24` allow. Applied cleanly, showed `Valid=True`,
dropped every real user. Hubble told me why: the traffic wasn't arriving from the LAN at all. Cilium
tagged the source `world` — the identity of *anywhere*, the one you can't restrict without
restricting everything.
It's a letter that reaches you carrying the sorting office's return address instead of the sender's.
The sorting office isn't lying to you; it's how the machinery forwards mail. But you can't write a
"letters from Alice only" rule when every envelope on your doormat says it came from the depot.
<!-- DIAGRAM: client → LB VIP → SNAT at the node (source rewritten) → policy sees `world`, vs ETP:Local preserving the client address -->
![SNAT rewriting the client source before policy evaluation, and ETP:Local preserving it](/diagrams/snat-ate-my-source.svg)
## Why the depot stamps over the sender
The rewrite has a reason. With `externalTrafficPolicy: Cluster`, any node can accept traffic for the
service and forward it to a backend pod on a *different* node. For the reply to route back through
the node that forwarded it, that node SNATs the connection to itself. Balanced spreading, bought by
destroying the source.
The consequence lands exactly where you can't see it coming: **policies bind to what arrives, not to
what was sent.** The client's packet left home with an honest return address; the depot stamped over
it in transit; your policy — evaluated after the rewrite — never meets the client at all. There is no
rule you can write for an identity the network erased one hop earlier.
## Preserve the sender, then commit it
The fix is one line: `externalTrafficPolicy: Local`. Only nodes running a backend pod accept
the traffic, no cross-node forwarding happens, no SNAT is needed — the client's real address survives
to policy evaluation. Now `fromCIDR 10.0.11.0/24` matches LAN clients, and a cross-cluster peer shows
up as its node IP — the peer *node*, not the peer pod, because separate clusters masquerade pod
traffic on the way out — pin-downable with a `/32`. That's how the LLM gateway's ingress rule got to
be a single line: one legitimate consumer, the agent cluster next door, exactly one `/32` allowed in.
Here's the one that costs a 2am. I made that change with `kubectl patch` — quick, out-of-band, worked
immediately. The service was GitOps-managed with self-heal on. Within seconds ArgoCD noticed the live
object differed from git and put it back the way the repo said, which re-enabled the SNAT, which
re-broke the policy, which took DNS down with it. **The change must be committed**, or the platform
will politely undo your fix while you sleep.
One caveat that keeps it honest: some paths still rewrite. A client arriving through a
tailnet subnet router gets masqueraded at the routing node even with `Local` set — it lands as
`world` regardless. Some envelopes pass through a second depot you don't control.
## When the depot closes, every envelope changes
This week the fleet finished swapping kube-proxy for Cilium's eBPF replacement. Same services, same
addresses, same policies — and a traefik that had answered LAN probes for months went dark, under a
policy nobody touched.
It was never supposed to answer. That traefik fronts zero routes, and the tightest floor guards it —
a policy admitting `host`, because kubelet probes need it, and nothing else. But kube-proxy's SNAT
had been dressing LAN probes as `host` — a different depot, a different stamp, the same disease. The
replacement closed the depot. Every envelope arrived carrying its real return address, the probes
landed as `world`, and the policy dropped them — exactly as written. Nothing broke. The policy
finally saw the truth, and the truth matched the original intent: answer no one.
But if you didn't know *why* it used to answer, this is the morning you file an outage ticket and
"fix" a policy that was never wrong. The rewriting machinery is itself a moving part. Change the
plumbing and every identity changes with it — which is why only observed flows stay true, and
remembered ones quietly expire.
## Read the envelope before you write the rule
The real lesson isn't the one-line fix. It's that I wrote a policy for the traffic I *imagined*
clients on the LAN, arriving as themselves — instead of the traffic that *arrived*. The packet's
story gets rewritten at every hop: SNAT here, DNAT there, a masquerade at a routing boundary. The
sender's truth and the receiver's truth are different documents.
So the discipline, before any allow rule exists: **observe first.** Open Hubble, watch the real
flows, and note the identity the traffic actually carries when it reaches the endpoint — `world`,
`host`, `cluster`, a pod label, a CIDR. Then write the rule for *that*. A policy written from the
architecture diagram is a guess; a policy written from observed flows is a fact.
## The principle
A network policy is a doorman checking return addresses, and the postal system rewrites return
addresses as a matter of course. You don't secure what was sent — nobody ever sees what was sent.
You secure what arrives. Go and look at the envelope first.
*Bitten and fixed on a six-cluster Talos fleet running Cilium 1.19 — kube-proxy replaced by the eBPF
datapath fleet-wide, MetalLB retired, and a standing rule: Hubble before policy, every time.*