Add SSO+2FA and outbound-exposure posts with Excalidraw architecture diagrams; Diagram component serves outlined SVGs
build-and-deploy / build (push) Failing after 12m1s

This commit is contained in:
2026-06-22 16:26:15 +10:00
parent 1c87af560d
commit cbb82eb97b
9 changed files with 148 additions and 192 deletions
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 132 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 88 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 164 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 131 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 128 KiB

+16 -188
View File
@@ -1,175 +1,48 @@
---
// Hand-authored, theme-aware architecture diagrams rendered as inline SVG at build.
// No client JS, no headless browser, no inline <style> — CSP-clean and themeable
// via CSS custom properties (colours follow the site theme).
// Architecture diagrams as static SVG (authored to match the site style), shown on a
// constant light card so the fixed-colour art stays legible in BOTH light and dark themes.
// CSP-clean: a plain same-origin <img> (img-src 'self'); no inline script, no web fonts.
// SVG sources live in /public/diagrams/<name>.svg — the same files the blog posts embed.
interface Props {
name: string;
caption?: string;
}
const { name, caption } = Astro.props;
type Node = {
x: number;
y: number;
w: number;
h?: number;
label: string;
sub?: string;
accent?: boolean;
dashed?: boolean;
};
type Edge = {
x1?: number;
y1?: number;
x2?: number;
y2?: number;
d?: string; // explicit path (elbows)
dashed?: boolean;
label?: string;
lx?: number;
ly?: number;
};
type Spec = { vb: string; nodes: Node[]; edges: Edge[] };
const H = 64;
const diagrams: Record<string, Spec> = {
"edge-ai": {
vb: "0 0 860 175",
nodes: [
{ x: 12, y: 46, w: 150, label: "High-level design", sub: "the idea" },
{ x: 212, y: 46, w: 150, label: "CD pipeline", sub: "single press" },
{ x: 412, y: 46, w: 165, label: "Helm + manifests", sub: "end-state" },
{ x: 655, y: 46, w: 180, label: "Edge K8s", sub: "GPU inference", accent: true },
],
edges: [
{ x1: 162, y1: 78, x2: 212, y2: 78 },
{ x1: 362, y1: 78, x2: 412, y2: 78 },
{ x1: 577, y1: 78, x2: 655, y2: 78, dashed: true, label: "readiness-gated", lx: 616, ly: 30 },
],
},
"iac-fleet": {
vb: "0 0 620 232",
nodes: [
{ x: 12, y: 40, w: 150, label: "Git source of truth", sub: "vars + code" },
{ x: 212, y: 40, w: 160, label: "AWX / Ansible", sub: "single-touch" },
{ x: 432, y: 40, w: 170, label: "Edge fleet", sub: "templated per-store", accent: true },
{ x: 212, y: 150, w: 160, h: 58, label: "ACR / NVCR mirror", sub: "air-gapped images", dashed: true },
],
edges: [
{ x1: 162, y1: 72, x2: 212, y2: 72 },
{ x1: 372, y1: 72, x2: 432, y2: 72 },
{ x1: 292, y1: 150, x2: 292, y2: 106 },
],
},
homelab: {
vb: "0 0 700 280",
nodes: [
{ x: 12, y: 34, w: 158, h: 62, label: "Proxmox", sub: "PCIe passthrough" },
{ x: 212, y: 34, w: 178, h: 62, label: "Talos / OpenShift", sub: "single-node clusters" },
{ x: 432, y: 34, w: 160, h: 62, label: "ArgoCD", sub: "GitOps reconcile", accent: true },
{ x: 12, y: 182, w: 210, h: 62, label: "Local AI · DNS · Obs", sub: "LLM · Pi-hole · Prometheus" },
{ x: 282, y: 182, w: 172, h: 62, label: "Cloudflare Tunnel", sub: "outbound only", accent: true },
{ x: 512, y: 182, w: 150, h: 62, label: "Internet", sub: "www.bztmon.com" },
],
edges: [
{ x1: 170, y1: 65, x2: 212, y2: 65 },
{ x1: 390, y1: 65, x2: 432, y2: 65 },
{ d: "M512 96 L512 139 L117 139 L117 182", dashed: true, label: "deploys", lx: 300, ly: 132 },
{ x1: 222, y1: 213, x2: 282, y2: 213 },
{ x1: 454, y1: 213, x2: 512, y2: 213 },
],
},
};
const captions: Record<string, string> = {
"edge-ai": "Design → single-press pipeline → readiness-gated GPU inference at the edge",
"iac-fleet": "One source of truth → AWX/Ansible → identical edge nodes, even air-gapped",
homelab: "Bare metal → GitOps clusters → services, exposed outbound-only via a tunnel",
"authentik-sso": "One identity provider, 2FA at the flow; apps validate over an internal back channel",
"outbound-exposure": "No open ports — the origin dials out; the edge is the perimeter",
};
const spec = diagrams[name];
const cap = caption ?? captions[name];
---
{
spec && (
<figure class="diagram" data-reveal>
<svg viewBox={spec.vb} role="img" aria-label={cap ?? `${name} architecture diagram`} class="diagram__svg">
<defs>
<marker id="dg-arrow" viewBox="0 0 10 10" refX="8.5" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0 0 L10 5 L0 10 z" fill="currentColor" />
</marker>
</defs>
<g class="dg-edges">
{spec.edges.map((e) => (
<>
{e.d ? (
<path d={e.d} class:list={["dg-edge", e.dashed && "dg-edge--dashed"]} marker-end="url(#dg-arrow)" />
) : (
<line
x1={e.x1}
y1={e.y1}
x2={e.x2}
y2={e.y2}
class:list={["dg-edge", e.dashed && "dg-edge--dashed"]}
marker-end="url(#dg-arrow)"
<img
class="diagram__svg"
src={`/diagrams/${name}.svg`}
alt={cap ?? `${name} architecture diagram`}
loading="lazy"
decoding="async"
/>
)}
{e.label && (
<text x={e.lx} y={e.ly} class="dg-edge-label" text-anchor="middle">
{e.label}
</text>
)}
</>
))}
</g>
<g class="dg-nodes">
{spec.nodes.map((n) => {
const h = n.h ?? H;
const cx = n.x + n.w / 2;
return (
<g class:list={["dg-node", n.accent && "dg-node--accent"]}>
<rect
x={n.x}
y={n.y}
width={n.w}
height={h}
rx="10"
class:list={["dg-box", n.dashed && "dg-box--dashed"]}
/>
<text x={cx} y={n.sub ? n.y + h / 2 - 4 : n.y + h / 2 + 5} class="dg-label" text-anchor="middle">
{n.label}
</text>
{n.sub && (
<text x={cx} y={n.y + h / 2 + 16} class="dg-sub" text-anchor="middle">
{n.sub}
</text>
)}
</g>
);
})}
</g>
</svg>
{cap && <figcaption class="diagram__caption mono">{cap}</figcaption>}
</figure>
)
}
<style>
.diagram {
margin: var(--space-6) 0;
padding: var(--space-5);
background: var(--bg-soft);
border: 1px solid var(--border);
/* constant light surface so the fixed-colour SVG keeps contrast in dark mode */
background: #f6f8fb;
border: 1px solid rgba(2, 6, 23, 0.1);
border-radius: var(--radius);
}
.diagram__svg {
display: block;
width: 100%;
height: auto;
overflow: visible;
}
.diagram__caption {
margin-top: var(--space-4);
@@ -177,49 +50,4 @@ const cap = caption ?? captions[name];
font-size: 0.72rem;
color: var(--text-faint);
}
/* Edges */
.dg-edges {
color: var(--text-faint);
}
.dg-edge {
stroke: var(--text-faint);
stroke-width: 1.6;
fill: none;
}
.dg-edge--dashed {
stroke: var(--accent);
stroke-dasharray: 5 4;
}
.dg-edge-label {
fill: var(--accent);
font-family: var(--font-mono);
font-size: 11px;
}
/* Nodes */
.dg-box {
fill: var(--surface);
stroke: var(--border-strong);
stroke-width: 1.4;
}
.dg-box--dashed {
stroke-dasharray: 5 4;
fill: transparent;
}
.dg-node--accent .dg-box {
stroke: var(--accent);
fill: var(--accent-dim);
}
.dg-label {
fill: var(--text);
font-family: var(--font-mono);
font-size: 14px;
font-weight: 600;
}
.dg-sub {
fill: var(--text-dim);
font-family: var(--font-sans);
font-size: 11px;
}
</style>
+58
View File
@@ -0,0 +1,58 @@
---
title: "SSO is a perimeter decision, not a login box"
date: 2026-06-23
summary: "Putting single sign-on in front of a fleet of self-hosted apps isn't about a nicer login screen — it's about where your trust boundary lives, and how it fails."
tags: ["authentik", "oidc", "sso", "security", "kubernetes"]
draft: false
---
Most write-ups treat single sign-on as a feature you bolt on: stand up an identity provider, point the
apps at it, enjoy one login. That framing misses the actual decision. SSO *moves your trust boundary*
every app now trusts one issuer, and every account is one credential away from all of them. Get it right
and you've hardened the whole estate at once. Get it wrong and you've built a single, central thing to lose.
Here's how I think about it after wiring an OIDC provider in front of a self-hosted fleet.
![Authentik SSO flow — the browser authenticates against the identity provider over the front channel; each app validates tokens over an internal back channel](/diagrams/authentik-sso.svg)
## Enforce the second factor at the flow, not per app
The leverage is putting 2FA on the *authentication flow itself*, not on each application. Every app that
federates to the provider inherits it for free — you can't forget to enable 2FA on app number nine, because
app number nine never sees a password. Enrolment (TOTP / WebAuthn) is mandatory at the identity layer, and
the apps just receive an already-verified identity.
That's the whole point of centralising: you set the policy once, at the boundary, and everything downstream
gets it.
## Keep a key under the mat
The flip side of "one issuer for everything" is that when the issuer is down — or you fat-finger the auth
flow — you can lock yourself out of *everything*, including the tools you'd use to fix it. So every critical
app keeps a **local break-glass admin** that bypasses SSO, and the recovery codes for 2FA enrolment live
offline.
SSO is the front door. Break-glass is the fire exit. You don't ship a building with only one.
## The gotchas that actually cost time
Two traps ate real hours, and they're the same lesson wearing two hats:
- **A baked-in config file silently overrode environment variables.** I set the OIDC scopes via env, login
kept failing with *"missing fields: email"*, and the cause was a config file winning over the env. The
provider needs `openid email profile` explicitly — set where the app actually reads it, not where you
assume it does.
- **In-cluster back-channels can't use the public hostname.** The browser hits the public login URL fine,
but the app's *server-side* token exchange runs inside the cluster, where split-horizon DNS doesn't
resolve the public name. The fix: point the back channel at the internal service address while the
browser-facing URL stays public.
Both say the same thing: SSO has two channels — the one the user sees and the one the server uses — and they
don't live on the same network.
## The principle
Centralising identity is a force multiplier, but it concentrates risk *by design*. So design the
concentration: enforce the strong factor at the boundary, plan the failure mode before you need it, and
remember that OIDC authenticates a *user* — it doesn't, on its own, make a sensitive service safe to expose.
The login box is the easy part. The perimeter is the decision.
@@ -0,0 +1,53 @@
---
title: "The most secure inbound port is the one you never open"
date: 2026-06-23
summary: "Exposing self-hosted services to the internet with zero open ports, zero port-forwarding, and the origin's IP never leaving the building."
tags: ["cloudflare", "networking", "security", "zero-trust", "kubernetes"]
draft: false
---
The default way to put a home service on the internet is to forward a port: poke a hole in the router, map
it to the box, and hope the thing behind it never has a bad day. Every open port is a standing invitation
that's online whether or not anyone's knocking.
There's a better shape: don't open anything. Let the origin dial *out*.
![Outbound-only exposure — public traffic hits a Cloudflare edge with WAF and geo rules; the origin holds a single outbound tunnel; nothing is port-forwarded](/diagrams/outbound-exposure.svg)
## The tunnel dials out
A lightweight connector runs next to the service and opens a persistent **outbound** connection to the edge
network. Public traffic arrives at the edge, and the edge hands it back down that already-open tunnel. The
router has no inbound rule. The origin's public IP is never advertised. Port-scan the home connection and
there's nothing listening — because there isn't.
You've inverted the trust direction: instead of the internet reaching *in*, the box reaches *out*.
## Same name, two answers
The wrinkle is DNS. I run **split-horizon**: the internal domain resolves to in-cluster ingress for anyone
on the LAN, and the public domain resolves through the edge for the outside world. Same services, two names,
two answers depending on where you're standing — and the internal estate is never reachable through the
public path.
This bites in a non-obvious way too: a pod *inside* the cluster can't resolve the public hostname (it gets
the internal view), so any server-side callout has to target the internal service address directly.
Browser-facing stays public; machine-to-machine stays internal.
## The edge is the perimeter
Because every request now funnels through one edge before it ever touches home, that edge *is* the firewall.
So the controls live there, not on the origin:
- geo-fence to the regions you actually serve,
- rate-limit the authentication endpoints,
- put access control in front of anything administrative.
The origin's job shrinks to one thing: hold the tunnel open and serve. Everything hostile is filtered a
continent away.
## The principle
Exposure isn't binary, and it isn't a synonym for port-forwarding. Push the perimeter out to an edge you
don't host, make the origin speak only outbound, and the attack surface at home collapses to zero open
ports. The safest port is the one that was never listening.
+12
View File
@@ -85,4 +85,16 @@ const { Content } = await render(entry);
.post__body {
margin-top: var(--space-7);
}
/* Diagram images (Markdown `![](/diagrams/*.svg)`) sit on a constant light card so the
fixed-colour SVGs stay legible in dark mode — mirrors the Diagram.astro component. */
.post__body :global(img[src^="/diagrams/"]) {
display: block;
width: 100%;
height: auto;
margin: var(--space-6) auto;
padding: var(--space-5);
background: #f6f8fb;
border: 1px solid rgba(2, 6, 23, 0.1);
border-radius: var(--radius);
}
</style>