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 |
+16
-188
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user