Add SSO+2FA and outbound-exposure posts with Excalidraw architecture diagrams; Diagram component serves outlined SVGs
build-and-deploy / build (push) Failing after 12m1s
build-and-deploy / build (push) Failing after 12m1s
This commit is contained in:
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 |
+20
-192
@@ -1,175 +1,48 @@
|
|||||||
---
|
---
|
||||||
// Hand-authored, theme-aware architecture diagrams rendered as inline SVG at build.
|
// Architecture diagrams as static SVG (authored to match the site style), shown on a
|
||||||
// No client JS, no headless browser, no inline <style> — CSP-clean and themeable
|
// constant light card so the fixed-colour art stays legible in BOTH light and dark themes.
|
||||||
// via CSS custom properties (colours follow the site theme).
|
// 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 {
|
interface Props {
|
||||||
name: string;
|
name: string;
|
||||||
caption?: string;
|
caption?: string;
|
||||||
}
|
}
|
||||||
const { name, caption } = Astro.props;
|
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> = {
|
const captions: Record<string, string> = {
|
||||||
"edge-ai": "Design → single-press pipeline → readiness-gated GPU inference at the edge",
|
"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",
|
"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",
|
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];
|
const cap = caption ?? captions[name];
|
||||||
---
|
---
|
||||||
|
|
||||||
{
|
<figure class="diagram" data-reveal>
|
||||||
spec && (
|
<img
|
||||||
<figure class="diagram" data-reveal>
|
class="diagram__svg"
|
||||||
<svg viewBox={spec.vb} role="img" aria-label={cap ?? `${name} architecture diagram`} class="diagram__svg">
|
src={`/diagrams/${name}.svg`}
|
||||||
<defs>
|
alt={cap ?? `${name} architecture diagram`}
|
||||||
<marker id="dg-arrow" viewBox="0 0 10 10" refX="8.5" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
loading="lazy"
|
||||||
<path d="M0 0 L10 5 L0 10 z" fill="currentColor" />
|
decoding="async"
|
||||||
</marker>
|
/>
|
||||||
</defs>
|
{cap && <figcaption class="diagram__caption mono">{cap}</figcaption>}
|
||||||
|
</figure>
|
||||||
<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)"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{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>
|
<style>
|
||||||
.diagram {
|
.diagram {
|
||||||
margin: var(--space-6) 0;
|
margin: var(--space-6) 0;
|
||||||
padding: var(--space-5);
|
padding: var(--space-5);
|
||||||
background: var(--bg-soft);
|
/* constant light surface so the fixed-colour SVG keeps contrast in dark mode */
|
||||||
border: 1px solid var(--border);
|
background: #f6f8fb;
|
||||||
|
border: 1px solid rgba(2, 6, 23, 0.1);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
.diagram__svg {
|
.diagram__svg {
|
||||||
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
overflow: visible;
|
|
||||||
}
|
}
|
||||||
.diagram__caption {
|
.diagram__caption {
|
||||||
margin-top: var(--space-4);
|
margin-top: var(--space-4);
|
||||||
@@ -177,49 +50,4 @@ const cap = caption ?? captions[name];
|
|||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
color: var(--text-faint);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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*.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -85,4 +85,16 @@ const { Content } = await render(entry);
|
|||||||
.post__body {
|
.post__body {
|
||||||
margin-top: var(--space-7);
|
margin-top: var(--space-7);
|
||||||
}
|
}
|
||||||
|
/* Diagram images (Markdown ``) 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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user