diff --git a/src/components/Hero.astro b/src/components/Hero.astro index 6d057b4c..717d9d9c 100644 --- a/src/components/Hero.astro +++ b/src/components/Hero.astro @@ -42,4 +42,10 @@ Email + + + Projects + Journey + Homelab + diff --git a/src/components/Nav.astro b/src/components/Nav.astro index b6b5399b..20a57454 100644 --- a/src/components/Nav.astro +++ b/src/components/Nav.astro @@ -1,50 +1,17 @@ ---- -const pathname = Astro.url.pathname; - -const links = [ - { href: "/", label: "tyler" }, - { href: "/homelab/", label: "homelab" }, - { href: "/projects/", label: "projects" }, -]; ---- - - - {links.map(({ href, label }) => { - const active = pathname === href || pathname === href.replace(/\/$/, ""); - return ( - - - {label} - - - ); - })} - + Tyler Koenig - - sans - / - serif - / - mono - + projects + journey + homelab + data-theme-toggle + aria-label="Switch to light mode" + class="text-[var(--color-text-label)] hover:text-[var(--color-text)] cursor-pointer" + > light @@ -71,31 +38,4 @@ const links = [ }); updateTheme(); - - const tfBtns = document.querySelectorAll("[data-typeface-btn]"); - - function updateTypeface() { - const current = document.documentElement.dataset.typeface || "sans"; - tfBtns.forEach((b) => { - const val = b.getAttribute("data-typeface-btn"); - if (val === current) { - b.classList.add("font-bold", "text-[var(--color-text)]"); - b.classList.remove("text-[var(--color-text-dim)]"); - } else { - b.classList.remove("font-bold", "text-[var(--color-text)]"); - b.classList.add("text-[var(--color-text-dim)]"); - } - }); - } - - tfBtns.forEach((b) => { - b.addEventListener("click", () => { - const val = b.getAttribute("data-typeface-btn")!; - document.documentElement.dataset.typeface = val; - localStorage.setItem("lerko96-typeface", val); - updateTypeface(); - }); - }); - - updateTypeface(); diff --git a/src/components/Timeline.astro b/src/components/Timeline.astro index a04f5f47..ba663309 100644 --- a/src/components/Timeline.astro +++ b/src/components/Timeline.astro @@ -14,29 +14,24 @@ const typeLabel: Record = { --- - + {timeline.map((entry) => ( - - + + {isDate(entry.date) ? {entry.date} : {entry.date} } - · - {typeLabel[entry.type]} - - - {entry.title} - - - {entry.description} - - - {entry.tags && entry.tags.length > 0 && ( - - {entry.tags.join(" · ")} + + + + {entry.title} + · {typeLabel[entry.type]} + + + {entry.description} - )} + ))} diff --git a/src/data/services.ts b/src/data/services.ts index 9e8d9677..59a140aa 100644 --- a/src/data/services.ts +++ b/src/data/services.ts @@ -2,6 +2,7 @@ export type Service = { name: string; description: string; category: "infrastructure" | "security" | "monitoring" | "productivity" | "media"; + hidden?: boolean; }; export const services: Service[] = [ @@ -11,7 +12,7 @@ export const services: Service[] = [ { name: "Pi-hole", description: "Network-wide DNS + ad blocking", category: "infrastructure" }, { name: "WireGuard", description: "VPN — full LAN access for remote clients", category: "infrastructure" }, { name: "mail relay", description: "Outbound SMTP relay for self-hosted service notifications", category: "infrastructure" }, - { name: "gluetun", description: "VPN container routing download client traffic", category: "infrastructure" }, + { name: "gluetun", description: "VPN container routing download client traffic", category: "infrastructure", hidden: true }, { name: "Home Assistant", description: "Smart home automation and device management", category: "infrastructure" }, // Security / Auth @@ -23,34 +24,36 @@ export const services: Service[] = [ { name: "Grafana", description: "Dashboards and alerting across all hosts and services", category: "monitoring" }, { name: "Beszel", description: "Lightweight container and host monitoring", category: "monitoring" }, { name: "ntfy", description: "Self-hosted push notifications", category: "monitoring" }, + { name: "Uptime Kuma", description: "Self-hosted monitoring tool (GUI)", category: "monitoring" }, // Productivity { name: "Gitea", description: "Personal Git server", category: "productivity" }, { name: "Outline", description: "Team wiki and knowledge base", category: "productivity" }, - { name: "Vikunja", description: "Task management", category: "productivity" }, { name: "Actual Budget", description: "Personal budgeting", category: "productivity" }, - { name: "Ghostfolio", description: "Investment portfolio tracking", category: "productivity" }, - { name: "Hoarder", description: "Bookmark manager with tagging", category: "productivity" }, - { name: "FreshRSS", description: "RSS reader", category: "productivity" }, - { name: "Memos", description: "Quick notes and journal", category: "productivity" }, - { name: "Traggo", description: "Time tracking", category: "productivity" }, { name: "Baikal", description: "CalDAV / CardDAV server", category: "productivity" }, { name: "Grist", description: "Spreadsheets and structured data", category: "productivity" }, { name: "Glance", description: "Self-hosted start page with feeds and service status", category: "productivity" }, - { name: "Filebrowser", description: "Web-based file manager", category: "productivity" }, - + { name: "Hoarder", description: "Bookmark manager with tagging", category: "productivity", hidden: true }, + { name: "FreshRSS", description: "RSS reader", category: "productivity", hidden: true }, + { name: "Memos", description: "Quick notes and journal", category: "productivity", hidden: true }, + { name: "Ghostfolio", description: "Investment portfolio tracking", category: "productivity", hidden: true }, + { name: "Vikunja", description: "Task management", category: "productivity", hidden: true }, + { name: "Traggo", description: "Time tracking", category: "productivity", hidden: true }, + { name: "Filebrowser", description: "Web-based file manager", category: "productivity", hidden: true }, + // Media - { name: "Plex", description: "Media streaming — movies, TV, music", category: "media" }, - { name: "Jellyfin", description: "Open-source media streaming", category: "media" }, - { name: "Sonarr", description: "Automated TV show management", category: "media" }, - { name: "Radarr", description: "Automated movie management", category: "media" }, - { name: "Lidarr", description: "Automated music management", category: "media" }, - { name: "Prowlarr", description: "Indexer manager and proxy for the *arr stack", category: "media" }, - { name: "Bazarr", description: "Automatic subtitle download and management", category: "media" }, - { name: "nzbget", description: "Usenet downloader", category: "media" }, - { name: "qBittorrent", description: "Torrent client with web UI", category: "media" }, + { name: "Immich", description: "Open-source photo library", category: "media"}, { name: "Kavita", description: "Self-hosted manga and book reader", category: "media" }, - { name: "Openshelf", description: "Book library with auto-ingest", category: "media" }, + { name: "Jellyfin", description: "Open-source media streaming", category: "media" }, + { name: "Prowlarr", description: "Indexer manager and proxy for the *arr stack", category: "media" }, + { name: "*Arr stack", description: "Automated media management for streaming", category: "media" }, + { name: "Bazarr", description: "Automatic subtitle download and management", category: "media" }, + { name: "Lidarr", description: "Automated music management", category: "media", hidden: true }, + { name: "Radarr", description: "Automated movie management", category: "media", hidden: true }, + { name: "Plex", description: "Media streaming — movies, TV, music", category: "media", hidden: true }, + { name: "nzbget", description: "Usenet downloader", category: "media", hidden: true }, + { name: "qBittorrent", description: "Torrent client with web UI", category: "media", hidden: true }, + { name: "Openshelf", description: "Book library with auto-ingest", category: "media", hidden: true }, ]; export const categoryOrder: Service["category"][] = [ diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index c615eb4d..f06ffc04 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -25,12 +25,6 @@ const { if (dark) document.documentElement.classList.add("dark"); })(); - - - + + Redirecting... - This page moved. /projects/ + This page moved. /#projects diff --git a/src/pages/homelab.astro b/src/pages/homelab.astro index d28aef6c..8dcb1bc5 100644 --- a/src/pages/homelab.astro +++ b/src/pages/homelab.astro @@ -1,202 +1,12 @@ ---- -import Base from "@/layouts/Base.astro"; -import Nav from "@/components/Nav.astro"; -import Footer from "@/components/Footer.astro"; -import Widget from "@/components/Widget.astro"; -import { services, categoryOrder, categoryLabels } from "@/data/services"; - -const glanceStats = [ - { label: "Hypervisor", value: "Proxmox VE" }, - { label: "Firewall", value: "pfSense (Netgate 1100)" }, - { label: "Switching", value: "TP-Link Omada (managed)" }, - { label: "ISP", value: "AT&T Fiber 1 Gbps" }, - { label: "VPN", value: "WireGuard (pfSense)" }, - { label: "Reverse Proxy", value: "Caddy + Cloudflare DNS-01" }, - { label: "Auth", value: "Authentik SSO" }, - { label: "DNS", value: "Pi-hole → Unbound → Cloudflare" }, - { label: "Containers", value: "9 LXC + 2 VMs" }, -]; - -const vlans = [ - { id: "MGMT", name: "MGMT", purpose: "Network equipment only" }, - { id: "LAN", name: "LAN", purpose: "Trusted personal devices" }, - { id: "Lab", name: "Homelab", purpose: "All self-hosted services" }, - { id: "Guest", name: "Guests", purpose: "Internet only, RFC1918 blocked" }, - { id: "IoT", name: "IoT", purpose: "Smart home, isolated" }, - { id: "WFH", name: "WFH", purpose: "Work devices, no personal access" }, - { id: "DMZ", name: "DMZ", purpose: "Public-facing, hard-blocked internally" }, - { id: "VPN", name: "VPN", purpose: "WireGuard clients, LAN-equivalent access" }, -]; - -const adrs = [ - { - title: "ISP gateway: passthrough mode", - decision: - "ISP gateway stays in-line in passthrough mode, pfSense gets the public IP directly. Gateway WiFi disabled.", - why: "Carrier locks 802.1X auth to their own gateway hardware, and bypassing it is brittle — breaks on firmware updates and only saves a millisecond or two. True bridge mode isn't supported. Passthrough is the cleanest option that keeps pfSense as the actual perimeter.", - }, - { - title: "Caddy over NGINX Proxy Manager", - decision: - "Caddy with DNS-01 challenge via Cloudflare API. All subdomains resolve to Caddy internally via Pi-hole. Caddy terminates TLS and proxies to backends.", - why: "Single Caddyfile, automatic certs without ever needing to expose internal services to the internet for an HTTP-01 challenge. NPM has more UI overhead for the same outcome. Traefik is more complex for no benefit at this scale.", - }, - { - title: "WireGuard over OpenVPN", - decision: - "WireGuard on pfSense as the only remote-access path. Clients get the access tier documented in the access model — same as LAN, plus the admin surfaces that aren't reachable any other way.", - why: "Faster, simpler config, better battery life on mobile. Throughput on the firewall hardware comfortably exceeds the WAN link. OpenVPN has no advantage here. Tailscale would add an external relay dependency for a problem WireGuard already solves.", - }, - { - title: "Pi-hole in Homelab VLAN, not MGMT", - decision: - "Pi-hole runs in the Homelab VLAN. Firewall allows port 53 inbound from VLANs that need local resolution. MGMT uses pfSense Unbound as its primary resolver instead.", - why: "Putting Pi-hole in MGMT would mean opening MGMT to all the VLANs that need DNS — much bigger attack surface for the most sensitive tier. DNS traffic crossing into the Homelab VLAN is the lesser risk, and Homelab is already where service traffic terminates anyway.", - }, - { - title: "Netgate 1100 for pfSense", - decision: - "Netgate 1100 (Marvell ARMADA 3720, dual-core ARM) as the firewall appliance. ~6W idle, line-rate NAT at 1 Gbps, WireGuard at ~100–150 Mbps.", - why: "Purpose-built for pfSense. Right-sized for 1 Gbps fiber — NAT saturates the link, WireGuard is fast enough for remote access. A full rack server wastes power for this role. Configs and version tracked in private repo.", - }, - { - title: "Shared Postgres + Redis in apps LXC", - decision: - "One Postgres instance hosting multiple databases. One Redis instance. A single init script provisions schemas on first run.", - why: "Avoids ~15 separate DB containers. Big RAM savings. Productivity apps colocate in one LXC anyway, so a shared backing store there is the natural shape.", - }, - { - title: "Gitea CI/CD: self-hosted runner, internal pipeline, static deploy", - decision: - "Self-hosted Gitea Actions runner builds the portfolio on push, then deploys pre-built static files to the public-facing host. Build runs in an isolated container so the runner host stays clean. Public host serves static files only — no build toolchain on it.", - why: "Keeps the whole pipeline internal. No external runners, no GitHub Actions. The build/serve split means the public-facing host has the smallest possible footprint — static file server, nothing more.", - }, - { - title: "Authentik over Authelia", - decision: "Authentik as the SSO provider across all self-hosted services.", - why: "Full OIDC provider + forward auth in one. Lets services like Outline, Gitea, and Vikunja use real SSO rather than just a login gate. Authelia is forward-auth only — no OIDC provider capability.", - }, -]; ---- - - - - - - Homelab - - Personal infrastructure environment for learning, self-hosting, and - operational practice. Running 24/7 on production-grade hardware with - real network segmentation, SSO, monitoring, and IaC-style - documentation. - - - - - - {glanceStats.map(({ label, value }) => ( - - {label} - {value} - - ))} - - - - - - - - - - Segment - - - Name - - - Purpose - - - - - {vlans.map((v) => ( - - {v.id} - {v.name} - {v.purpose} - - ))} - - - - - - - - {categoryOrder.map((cat) => { - const catServices = services.filter((s) => s.category === cat); - return ( - - - {categoryLabels[cat]} - - - {catServices.map((svc) => ( - - {svc.name} - — {svc.description} - - ))} - - - ); - })} - - - - - - {adrs.map((adr) => ( - - {adr.title} - - Decision: {adr.decision} - - - Why: {adr.why} - - - ))} - - - - - Docs - - VLAN maps, runbooks, service registry, config exports, and setup guides. - - - gitea.lerkolabs.com/lerko/homelab - - - - - + + + + + + + Redirecting... + + + This page moved. /#homelab + + diff --git a/src/pages/index.astro b/src/pages/index.astro index c0ca2ca0..3a688a28 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -4,11 +4,217 @@ import Nav from "@/components/Nav.astro"; import Footer from "@/components/Footer.astro"; import Hero from "@/components/Hero.astro"; import Timeline from "@/components/Timeline.astro"; +import Widget from "@/components/Widget.astro"; +import ProjectCard from "@/components/ProjectCard.astro"; +import { featuredProjects } from "@/data/projects"; +import { services, categoryOrder, categoryLabels } from "@/data/services"; + +const glanceStats = [ + { label: "Hypervisor", value: "Proxmox VE" }, + { label: "Firewall", value: "pfSense (Netgate 1100)" }, + { label: "Switching", value: "TP-Link Omada (managed)" }, + { label: "ISP", value: "AT&T Fiber 1 Gbps" }, + { label: "VPN", value: "WireGuard (pfSense)" }, + { label: "Reverse Proxy", value: "Caddy + Cloudflare DNS-01" }, + { label: "Auth", value: "Authentik SSO" }, + { label: "DNS", value: "Pi-hole → Unbound → Cloudflare" }, + { label: "Containers", value: "9 LXC + 2 VMs" }, +]; + +const vlans = [ + { id: "MGMT", name: "MGMT", purpose: "Network equipment only" }, + { id: "LAN", name: "LAN", purpose: "Trusted personal devices" }, + { id: "Lab", name: "Homelab", purpose: "All self-hosted services" }, + { id: "Guest", name: "Guests", purpose: "Internet only, RFC1918 blocked" }, + { id: "IoT", name: "IoT", purpose: "Smart home, isolated" }, + { id: "WFH", name: "WFH", purpose: "Work devices, no personal access" }, + { id: "DMZ", name: "DMZ", purpose: "Public-facing, hard-blocked internally" }, + { id: "VPN", name: "VPN", purpose: "WireGuard clients, LAN-equivalent access" }, +]; + +const adrs = [ + { + title: "ISP gateway: passthrough mode", + decision: + "ISP gateway stays in-line in passthrough mode, pfSense gets the public IP directly. Gateway WiFi disabled.", + why: "Carrier locks 802.1X auth to their own gateway hardware, and bypassing it is brittle — breaks on firmware updates and only saves a millisecond or two. True bridge mode isn't supported. Passthrough is the cleanest option that keeps pfSense as the actual perimeter.", + }, + { + title: "Caddy over NGINX Proxy Manager", + decision: + "Caddy with DNS-01 challenge via Cloudflare API. All subdomains resolve to Caddy internally via Pi-hole. Caddy terminates TLS and proxies to backends.", + why: "Single Caddyfile, automatic certs without ever needing to expose internal services to the internet for an HTTP-01 challenge. NPM has more UI overhead for the same outcome. Traefik is more complex for no benefit at this scale.", + }, + { + title: "WireGuard over OpenVPN", + decision: + "WireGuard on pfSense as the only remote-access path. Clients get the access tier documented in the access model — same as LAN, plus the admin surfaces that aren't reachable any other way.", + why: "Faster, simpler config, better battery life on mobile. Throughput on the firewall hardware comfortably exceeds the WAN link. OpenVPN has no advantage here. Tailscale would add an external relay dependency for a problem WireGuard already solves.", + }, + { + title: "Pi-hole in Homelab VLAN, not MGMT", + decision: + "Pi-hole runs in the Homelab VLAN. Firewall allows port 53 inbound from VLANs that need local resolution. MGMT uses pfSense Unbound as its primary resolver instead.", + why: "Putting Pi-hole in MGMT would mean opening MGMT to all the VLANs that need DNS — much bigger attack surface for the most sensitive tier. DNS traffic crossing into the Homelab VLAN is the lesser risk, and Homelab is already where service traffic terminates anyway.", + }, + { + title: "Netgate 1100 for pfSense", + decision: + "Netgate 1100 (Marvell ARMADA 3720, dual-core ARM) as the firewall appliance. ~6W idle, line-rate NAT at 1 Gbps, WireGuard at ~100–150 Mbps.", + why: "Purpose-built for pfSense. Right-sized for 1 Gbps fiber — NAT saturates the link, WireGuard is fast enough for remote access. A full rack server wastes power for this role. Configs and version tracked in private repo.", + }, + { + title: "Shared Postgres + Redis in apps LXC", + decision: + "One Postgres instance hosting multiple databases. One Redis instance. A single init script provisions schemas on first run.", + why: "Avoids ~15 separate DB containers. Big RAM savings. Productivity apps colocate in one LXC anyway, so a shared backing store there is the natural shape.", + }, + { + title: "Gitea CI/CD: self-hosted runner, internal pipeline, static deploy", + decision: + "Self-hosted Gitea Actions runner builds the portfolio on push, then deploys pre-built static files to the public-facing host. Build runs in an isolated container so the runner host stays clean. Public host serves static files only — no build toolchain on it.", + why: "Keeps the whole pipeline internal. No external runners, no GitHub Actions. The build/serve split means the public-facing host has the smallest possible footprint — static file server, nothing more.", + }, + { + title: "Authentik over Authelia", + decision: "Authentik as the SSO provider across all self-hosted services.", + why: "Full OIDC provider + forward auth in one. Lets services like Outline, Gitea, and Vikunja use real SSO rather than just a login gate. Authelia is forward-auth only — no OIDC provider capability.", + }, +]; --- + - + + + + + {featuredProjects.map((project) => ( + + ))} + + + + + + + + + + + Homelab + + Personal infrastructure environment for learning, self-hosting, and + operational practice. Running 24/7 on production-grade hardware with + real network segmentation, SSO, monitoring, and IaC-style + documentation. + + + + + + {glanceStats.map(({ label, value }) => ( + + {label} + {value} + + ))} + + + + + + + + + + Segment + + + Name + + + Purpose + + + + + {vlans.map((v) => ( + + {v.id} + {v.name} + {v.purpose} + + ))} + + + + + + + + {categoryOrder.map((cat) => { + const catServices = services.filter((s) => s.category === cat && !s.hidden); + return ( + + + {categoryLabels[cat]} + + + {catServices.map((svc) => ( + + {svc.name} + — {svc.description} + + ))} + + + ); + })} + + + + + + {adrs.map((adr) => ( + + {adr.title} + + Decision: {adr.decision} + + + Why: {adr.why} + + + ))} + + + + + Docs + + VLAN maps, runbooks, service registry, config exports, and setup guides. + + + gitea.lerkolabs.com/lerko/homelab + + + + diff --git a/src/pages/projects.astro b/src/pages/projects.astro index e1f043d6..285e4ea0 100644 --- a/src/pages/projects.astro +++ b/src/pages/projects.astro @@ -1,64 +1,12 @@ ---- -import Base from "@/layouts/Base.astro"; -import Nav from "@/components/Nav.astro"; -import Footer from "@/components/Footer.astro"; -import Widget from "@/components/Widget.astro"; -import ProjectCard from "@/components/ProjectCard.astro"; -import { featuredProjects, archiveProjects } from "@/data/projects"; ---- - - - - - - Projects - - Featured work first. Earlier experiments, browser extensions, and bootcamp projects below — kept for context. - - - - - - {featuredProjects.map((project) => ( - - ))} - - - - - - {archiveProjects.map((project) => ( - - - {project.year && ( - - {project.year} - - )} - - - {project.title} - - - - - {project.description} - - - {project.tags.join(" · ")} - - - ))} - - - - - + + + + + + + Redirecting... + + + This page moved. /#projects + + diff --git a/src/styles/globals.css b/src/styles/globals.css index 8f4e6eec..8e7c8bf6 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -17,8 +17,6 @@ --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; --font-serif: Charter, "Bitstream Charter", "Sitka Text", Cambria, serif; --font-mono: "Source Code Pro", ui-monospace, monospace; - --font-body: var(--font-sans); - /* Breakpoints */ --breakpoint-xs: 576px; @@ -44,14 +42,9 @@ html { line-height: 1.5; background-color: var(--color-bg); color: var(--color-text); - font-family: var(--font-body); + font-family: var(--font-sans); } -/* Typeface picker overrides */ -html[data-typeface="sans"] { --font-body: var(--font-sans); } -html[data-typeface="serif"] { --font-body: var(--font-serif); line-height: 1.6; } -html[data-typeface="mono"] { --font-body: var(--font-mono); font-size: 15px; letter-spacing: -0.01em; } - @layer base { * { box-sizing: border-box; @@ -82,11 +75,23 @@ a:hover { text-decoration-color: currentColor; } +/* Focus states */ +a:focus-visible { + text-decoration-color: currentColor; + outline: 2px solid var(--color-border-bright); + outline-offset: 2px; +} +button:focus-visible { + outline: 2px solid var(--color-border-bright); + outline-offset: 2px; +} + /* Default transitions */ a, button { transition: color 120ms linear, border-color 120ms linear, - opacity 120ms linear, text-decoration-color 120ms linear; + opacity 120ms linear, text-decoration-color 120ms linear, + outline-color 120ms linear; } @media (prefers-reduced-motion: reduce) {
+
- {entry.description} -
- {entry.tags.join(" · ")} +
+ {entry.description}
This page moved. /projects/
This page moved. /#projects
- Personal infrastructure environment for learning, self-hosting, and - operational practice. Running 24/7 on production-grade hardware with - real network segmentation, SSO, monitoring, and IaC-style - documentation. -
- Decision: {adr.decision} -
- Why: {adr.why} -
- VLAN maps, runbooks, service registry, config exports, and setup guides. -
This page moved. /#homelab
+ Personal infrastructure environment for learning, self-hosting, and + operational practice. Running 24/7 on production-grade hardware with + real network segmentation, SSO, monitoring, and IaC-style + documentation. +
+ Decision: {adr.decision} +
+ Why: {adr.why} +
+ VLAN maps, runbooks, service registry, config exports, and setup guides. +
- Featured work first. Earlier experiments, browser extensions, and bootcamp projects below — kept for context. -
- {project.description} -
- {project.tags.join(" · ")} -