diff --git a/.gitignore b/.gitignore index 433a8ee..ca85306 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,6 @@ sbom.json # Nothing under drafts/ may ever be committed; a draft becomes public only by # being moved into src/content/blog/ at publish time. drafts/ + +# Publish-workflow archives stay local for the same reason. +published/ diff --git a/README.md b/README.md index e33f8b7..f3186bd 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,19 @@ # bztmon-site -The source for **[www.bztmon.com](https://www.bztmon.com)** — Jonathon Wright's -portfolio / résumé site. A fast, animated, security-hardened static site for a -platform / infrastructure engineer. +Source for [www.bztmon.com](https://www.bztmon.com) - portfolio and blog for a +platform / infrastructure engineer. Static output, no server runtime, hardened +by default. -> This repo is **public**. It lives on a self-hosted public Gitea (`git.bztmon.com`), -> isolated from the private homelab GitOps. **Never commit secrets** — the static -> site needs none. +> This repo is public and lives on a self-hosted Gitea (`git.bztmon.com`), +> isolated from the private GitOps repos. The static site needs no secrets; +> none are committed. ## Stack -- **Astro** (static output) + **TypeScript** + **Tailwind v4** -- Zero JS by default; tiny islands for the theme toggle + scroll reveals -- Content & config are data-driven (`src/data/`) — adding a project never touches a component +- Astro (static output), TypeScript, Tailwind v4 +- Zero JS by default; small islands for the theme toggle and scroll reveals +- Content and config are data-driven (`src/data/`, `src/content/`) - adding a + post or project never touches a component ## Develop @@ -20,72 +21,59 @@ platform / infrastructure engineer. npm install npm run dev # http://localhost:4321 npm run check # astro check (types + diagnostics) -npm run build # static build → dist/ +npm run build # static build -> dist/ npm run preview # serve the build locally npm run gen:og # regenerate the social-preview image (public/og.png) ``` -## Project layout +## Layout ``` src/ - data/ site.ts, socials.ts, skills.ts, projects.ts, experience.ts - components/ Hero, Nav, ThemeToggle, ProjectCard, SkillGroup, ... + content/blog/ posts (Markdown, zod-validated frontmatter) + content/projects/ project dossiers + data/ site.ts, series.ts, skills.ts, projects.ts, experience.ts + components/ Hero, Nav, PostList, ProjectCard, ... layouts/ Layout.astro (SEO/OG, theme bootstrap) - pages/ index.astro, projects/, 404.astro + pages/ index, blog/, projects/, 404 styles/ tokens.css (theme), global.css - lib/ build-time helpers (cv detection) -scripts/ gen-og.mjs, build-image.sh +scripts/ gen-og.mjs, build-image.sh, new-post.mjs +public/ heroes (blog/), diagrams (diagrams/), og.png, cv nginx/ default.conf (security headers, caching) baked into the image -Dockerfile Debian build stage → nginx-unprivileged runtime +Dockerfile Debian build stage -> nginx-unprivileged runtime ``` -## Content TODOs (Jonathon) +## Posts -- Drop a real CV at `public/cv.pdf` — the **Download CV** button appears automatically. -- Fill the `TODO(Jonathon)` markers in `src/data/experience.ts`, `projects.ts`, `socials.ts` - (employer names, dates, GitHub/LinkedIn handles). - -## Publishing a post - -A post is just a Markdown file in `src/content/blog/`. Write one by hand, or generate -a schema-valid one with the publish helper (this is the seam an IaC/CI step calls): - -```bash -# from flags -node scripts/new-post.mjs --title "My post" --summary "One line" \ - --tags "kubernetes,gpu" [--draft] [--bodyFile notes.md] - -# from a JSON event (e.g. an Ansible/AWX deploy summary) -echo '{"title":"...","summary":"...","tags":["x"],"body":"## Hi\n..."}' \ - | node scripts/new-post.mjs --stdin -``` - -Commit the file to `main` → CI rebuilds and ships. A malformed post **fails the build** -(frontmatter is zod-validated), so a bad pipeline event never reaches production. +A post is a Markdown file in `src/content/blog/` with zod-validated +frontmatter; a malformed post fails the build, so a bad file never reaches +production. `scripts/new-post.mjs` scaffolds one from flags or a JSON event on +stdin - the seam a CI step can call. Each post belongs to one numbered series +(`src/data/series.ts`), which drives the badge on the index cards. ## CI/CD -`.gitea/workflows/deploy.yml` runs on a self-hosted runner (a dedicated unprivileged -user on the bastion): +`.gitea/workflows/deploy.yml` runs on a self-hosted runner (a dedicated +unprivileged user on a utility host): ``` -npm ci → astro check → audit-ci (high/critical gate) → build → scan dist → -SBOM (CycloneDX) → buildah build+push → open a digest-bump PR to home-ops +npm ci -> astro check -> audit-ci (high/critical gate) -> build -> scan dist -> +SBOM (CycloneDX) -> buildah build+push -> open a digest-bump PR to home-ops ``` -The PR is **never auto-merged** — `home-ops` `main` is branch-protected; merging it is -what triggers the ArgoCD rollout. The runner holds only least-privilege creds (a -`home-ops`-scoped deploy key + a PR token + a registry push token). +The PR is never auto-merged - the deploy repo's `main` is branch-protected, and +merging the PR is what triggers the ArgoCD rollout. The runner holds +least-privilege credentials only: a scoped deploy key, a PR token, and a +registry push token. -- `npm run scan` — build-time gate: no secrets, no inline scripts, no third-party origins. -- `.audit-ci.json` — fails on high/critical advisories. One allowlisted: `GHSA-gv7w-rqvm-qjhr` - (esbuild install-integrity; build-time only, mitigated by the committed lockfile + trusted registry). -- `renovate.json` — keeps npm deps and the digest-pinned base images current. +- `npm run scan` - build-time gate: no secrets, no inline scripts, no + third-party origins in `dist/` +- `.audit-ci.json` - fails the build on high/critical advisories +- `renovate.json` - keeps npm deps and the digest-pinned base images current ## Deploy -Built into a container image, served by nginx-unprivileged on a homelab Kubernetes -cluster, exposed via Cloudflare Tunnel. The image is pinned by digest in the private -`home-ops` repo and rolled out by ArgoCD. Manual/bootstrap build: `scripts/build-image.sh push`. -See `SECURITY.md` for the full security posture. +Built into a container image, served by nginx-unprivileged on Kubernetes, +exposed outbound-only via Cloudflare Tunnel. The image is pinned by digest in +the private deploy repo and rolled out by ArgoCD. Manual bootstrap build: +`scripts/build-image.sh push`. Full posture in `SECURITY.md`. diff --git a/SECURITY.md b/SECURITY.md index 1e8645e..9ed7f14 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -5,17 +5,17 @@ as acceptance criteria, not polish. ## Attack surface -- **Static output** — no server runtime, no database, no user input, no forms. +- **Static output** - no server runtime, no database, no user input, no forms. - Served by `nginx-unprivileged` (uid 101, read-only root filesystem, all Linux capabilities dropped, no service-account token) on Kubernetes. -- Exposed **outbound-only via a Cloudflare Tunnel** — no open inbound ports, a single +- Exposed **outbound-only via a Cloudflare Tunnel** - no open inbound ports, a single public hostname, no catch-all. ## Headers Split by where they belong: -**Origin (nginx, ships in the image — `nginx/security-headers.conf`)** +**Origin (nginx, ships in the image - `nginx/security-headers.conf`)** | Header | Value | |---|---| @@ -23,11 +23,11 @@ Split by where they belong: | X-Content-Type-Options | `nosniff` | | X-Frame-Options | `DENY` | | Referrer-Policy | `strict-origin-when-cross-origin` | -| Permissions-Policy | camera/mic/geo/payment/usb… all denied | +| Permissions-Policy | camera/mic/geo/payment/usb... all denied | | Cross-Origin-Opener-Policy | `same-origin` | | Cross-Origin-Resource-Policy | `same-origin` | -**Edge (Cloudflare dashboard)** — HSTS, HTTP→HTTPS redirect, SSL Full, Bot Fight, +**Edge (Cloudflare dashboard)** - HSTS, HTTP->HTTPS redirect, SSL Full, Bot Fight, rate-limiting. HSTS ships **without** `includeSubDomains`/`preload` initially because `*.bztmon.com` resolves to the WAN and the subdomain form would brick non-public hosts. @@ -36,7 +36,7 @@ rate-limiting. HSTS ships **without** `includeSubDomains`/`preload` initially be - **`script-src 'self'` with zero inline scripts.** All JS (pre-paint theme, toggle, scroll reveal) lives in one external `/site.js`. No `unsafe-inline`, no `unsafe-eval`, no hashes to maintain. -- **`style-src` allows `'unsafe-inline'`** — the one conscious exception. Shiki's +- **`style-src` allows `'unsafe-inline'`** - the one conscious exception. Shiki's dual-theme syntax highlighting emits per-token CSS custom-properties as inline `style` attributes; hashing them is impractical (they vary per page). The security-critical directive (`script-src`) stays strict. Everything else is self-hosted: fonts are system @@ -55,9 +55,9 @@ Runs in CI before the image is built. ## Targets (verified post-deploy) -- securityheaders.com → A+ -- Mozilla Observatory → A/A+ (small deduction expected for `style-src 'unsafe-inline'`) -- Lighthouse ≥ 95 across Performance / Accessibility / Best Practices / SEO +- securityheaders.com -> A+ +- Mozilla Observatory -> A/A+ (small deduction expected for `style-src 'unsafe-inline'`) +- Lighthouse >= 95 across Performance / Accessibility / Best Practices / SEO - CSP: zero console violations in a real browser (incl. code blocks + diagrams) ## Reporting