Lots of changes to the website

This commit is contained in:
Blake Ridgway
2026-03-27 07:57:13 -05:00
parent 617624c179
commit 7e7480ecf9
33 changed files with 1539 additions and 184 deletions

View File

@@ -5,7 +5,7 @@ page, and an admin panel for managing content.
## Why Go?
- Compiles to a single static binary. Easy to deploy on OpenBSD with no runtime dependencies.
- Compiles to a single static binary. Easy to deploy on FreeBSD with no runtime dependencies.
- Standard library handles HTTP, HTML templates, and file serving without pulling in a framework.
- Fast startup, low memory footprint. Fits comfortably behind relayd on the SuperMicro 1U.
- The tool I know best for this kind of thing.
@@ -168,7 +168,7 @@ EOF
mv "$TMPFILE" "$STATUS_FILE"
```
## Deploying on OpenBSD
## Deploying on FreeBSD
### 1. Build

View File

@@ -69,6 +69,7 @@ func main() {
mux.HandleFunc("POST /hire", h.HirePost)
mux.HandleFunc("GET /resume", h.Resume)
mux.HandleFunc("POST /newsletter", h.NewsletterPost)
mux.HandleFunc("GET /changelog", h.Changelog)
mux.HandleFunc("GET /sitemap.xml", h.Sitemap)
// Admin routes (auth handled per-handler)

View File

@@ -0,0 +1,86 @@
---
title: "Why I Moved fw01 from OpenBSD to OPNsense"
date: 2026-03-17
tags: [openbsd, opnsense, networking, homelab]
slug: openbsd-to-opnsense
description: "I love OpenBSD. I still moved my firewall to OPNsense. Here's the honest account of why."
draft: false
---
I wrote a post not long ago about why I chose FreeBSD for this homelab. I could write a
nearly identical one about OpenBSD on the firewall. The man pages, the security posture, pf —
OpenBSD is one of the most coherent operating systems I've ever used, and fw01 ran it well.
So why did I replace it with OPNsense?
I wrestled with this for longer than I probably should have. Changing your firewall feels like
a statement. I've written about pf. I've defended the "just learn the config file" approach.
Switching to a web UI felt like a betrayal of something.
But pragmatism won.
## The Actual Problem
My ISP bumped me to a 2 Gb/s connection. Theoretically great. In practice, I needed my
firewall to actually push that throughput across the NICs I had available.
OPNsense gave me more flexibility in how those interfaces were handled — driver support,
offloading options, tuning knobs exposed through the UI. Getting the same result on OpenBSD
would have meant more digging, more testing, more time spent on the firewall instead of
everything the firewall is supposed to protect.
I didn't want to spend a weekend tuning network drivers. I wanted 2 Gb/s to work.
## Why Not pfSense
Before this homelab, I ran a Netgate 4200 with pfSense. It worked fine, but the UI felt
like it hadn't been touched since 2012. Cluttered, inconsistent, hard to navigate. Every
time I needed to do something non-obvious I was digging through three menus wondering if I
was in the right place.
OPNsense is a different experience. The interface is clean, the layout makes sense, and it
moves at a pace that feels like a maintained project. It's also based on FreeBSD — so under
the hood, it's still pf, still the networking stack I trust.
Choosing OPNsense wasn't a hard call once pfSense was off the table.
## The Migration
I expected this to be painful. It wasn't. My pf rules translated cleanly. VLAN configuration
that I'd built up over time moved over without drama. The concepts are identical because
the underlying system is the same — OPNsense just wraps it.
If you're coming from OpenBSD's pf, OPNsense's firewall rules section will feel familiar.
The mental model is the same. You're still thinking in terms of interfaces, states, and
explicit allows. The GUI is just a different way of expressing those rules.
## The Part I Didn't Expect to Care About
Here's the thing I didn't anticipate valuing: if something goes wrong with the firewall
while I'm not home, someone in my family can actually do something about it.
With a text config and an SSH session, the answer to "the internet is down" is "call me and
I'll walk you through it." With OPNsense, it's "open a browser, log in, click here, click
there." That's a meaningful difference in a home environment.
I'm not designing a data center. I'm running a homelab that also happens to be the internet
connection for my household. Resilience includes other humans being able to use it.
## What I Gave Up
I won't pretend there's no loss here. OpenBSD's simplicity is real. The config file is
auditable in a way no web UI ever fully is. There's a directness to `pfctl -sr` that no
amount of GUI polish replicates.
But I still have pf. I still have the BSD networking stack. The firewall is still doing
exactly what I'd configure it to do manually — I'm just configuring it differently.
## The Honest Takeaway
Sometimes the right tool isn't the purist choice.
I still believe in OpenBSD. I still think pf is the best firewall I've used. None of that
changed. What changed was an honest accounting of what I actually needed from this specific
machine — throughput, flexibility, and something my household can survive without me.
OPNsense delivered that. The ideology didn't need to.

View File

@@ -25,7 +25,7 @@ Five VLANs, each on a different subnet:
| game | 30 | 10.0.30.0/24 | Game clients and VMs |
| iot | 40 | 10.0.40.0/24 | Untrusted / IoT / Guest |
The physical layout: one NIC on fw01 is trunked to the main switch. OpenBSD VLAN interfaces
The physical layout: one NIC on fw01 is trunked to the main switch. OPNsense VLAN interfaces
(`vlan10`, `vlan20`, etc.) are configured on top of it. Each VLAN interface gets an IP address
in its respective subnet and acts as the default gateway for devices in that VLAN.

View File

@@ -1,20 +1,20 @@
---
title: "ridgwaysystems.org is live"
date: 2026-03-11
tags: [meta, go, openbsd]
tags: [meta, go, freebsd]
slug: site-is-live
description: "The site is up. A single Go binary on OpenBSD, serving blog posts, a status page, a hire page, and an admin panel — no database, no Docker, no external dependencies."
description: "The site is up. A single Go binary on FreeBSD, serving blog posts, a status page, a hire page, and an admin panel — no database, no Docker, no external dependencies."
draft: false
---
It's up.
ridgwaysystems.org is now running on a Vultr VPS — OpenBSD, relayd for TLS termination, a single Go binary handling everything behind it. No database. No Docker. No framework. Flat Markdown files on disk, templates compiled into the binary at startup, HMAC-signed sessions, and a background goroutine that checks service health every few minutes.
ridgwaysystems.org is now running on a Vultr VPS — FreeBSD, nginx for TLS termination, a single Go binary handling everything behind it. No database. No Docker. No framework. Flat Markdown files on disk, templates compiled into the binary at startup, HMAC-signed sessions, and a background goroutine that checks service health every few minutes.
The stack:
- **Go** — stdlib `net/http` with 1.22 pattern routing. One binary, one deploy, done.
- **OpenBSD** — relayd as the reverse proxy, acme-client for TLS certs, rc.d for service management.
- **FreeBSD** — nginx as the reverse proxy, certbot for TLS certs, rc.d for service management.
- **Flat files** — posts are `.md` files in `content/posts/`. The status page reads from `data/status.json`. Newsletter subscribers live in `data/subscribers.json`.
- **No build step** — CSS is hand-written, no preprocessor. JS is a single file for the admin editor.
@@ -22,4 +22,4 @@ Features that made it in before launch: blog with next/prev navigation, a status
The source is at [git.ridgwaysystems.org](https://git.ridgwaysystems.org).
More build posts to follow — the relayd config alone is worth documenting.
More build posts to follow — the nginx config alone is worth documenting.

View File

@@ -13,10 +13,10 @@ actually in the rack, why those machines, and what each one does.
## The Firewall: SuperMicro 1U
The firewall is a SuperMicro 1U server with a Xeon E3-1230v2 and 16GB ECC RAM. This runs
OpenBSD and handles everything at the network edge:
OPNsense (FreeBSD-based) and handles everything at the network edge:
- **pf** — stateful packet filtering, VLAN routing
- **relayd** — reverse proxy, TLS termination for external services
- **nginx** — reverse proxy, TLS termination for external services
- **WireGuard** — VPN for remote access
- **unbound** — recursive DNS resolver for internal clients
- **dhcpd** — DHCP for all VLANs
@@ -32,7 +32,7 @@ network segment.
The R720 is the workhorse. Dual Xeon E5-2600 series processors, 64GB ECC RAM, a few SSDs
for the OS and data volumes.
This runs OpenBSD and hosts:
This runs FreeBSD and hosts:
- **httpd** — web server for this site
- **Gitea** — self-hosted git
@@ -51,18 +51,18 @@ on a home circuit without thinking about it.
## The Secondary Server: Dell R710
The R710 is older — Xeon 5500/5600 series — but has more RAM slots, currently at 48GB.
It runs a mix of OpenBSD base with some Linux VMs managed by `vmm(4)`.
It runs FreeBSD with Linux VMs managed by `bhyve(8)`.
Primary roles:
- **nsd** — authoritative DNS for ridgwaysystems.org zones
- **Linux VMs** — game servers (Minecraft, Valheim, etc.), running in `vmm(4)`
- **Linux VMs** — game servers (Minecraft, Valheim, etc.), running in `bhyve(8)`
- **Jellyfin** — media server
- **Backup target** — receiving rsync backups from srv01
- **Backup target** — receiving ZFS send/recv and rsync backups from srv01
The R710 is even louder than the R720 under load. Old server hardware wasn't designed with
home environments in mind. ILO (Dell iDRAC) makes remote management workable — I rarely
need to touch it physically.
home environments in mind. iDRAC makes remote management workable — I rarely need to touch
it physically.
## The Desktop: Daily Driver and Ansible Control Node
@@ -95,4 +95,4 @@ data. ECC doesn't eliminate all failure modes, but it eliminates the commonest o
Next up: the pf configuration and VLAN setup. This is where most of the interesting work
happens — separating untrusted IoT devices from servers, routing WireGuard traffic, and
setting up relayd to proxy external services.
setting up the reverse proxy to forward external services.

View File

@@ -1,35 +1,40 @@
---
title: "Why OpenBSD for a Homelab"
title: "Why FreeBSD for a Homelab"
date: 2026-03-01
tags: [openbsd, homelab]
slug: why-openbsd
description: "The case for running OpenBSD as the foundation of a homelab — security model, pf, clean base system, and the value of good documentation."
tags: [freebsd, homelab]
slug: why-freebsd
description: "The case for running FreeBSD as the foundation of a homelab — ZFS, jails, pf, clean base system, and the value of good documentation."
draft: false
---
A few people have asked why I chose OpenBSD for this homelab instead of the usual suspects —
A few people have asked why I chose FreeBSD for this homelab instead of the usual suspects —
Proxmox, TrueNAS, some flavor of Linux. The short answer: I wanted to actually understand what
my infrastructure is doing, and OpenBSD forces that in a way nothing else does.
my infrastructure is doing, and FreeBSD forces that in a way nothing else does.
## The Security Model Is Different
## ZFS Is the Right Filesystem
Most operating systems bolt security on. OpenBSD builds it in.
FreeBSD ships ZFS in the base system and it's a first-class citizen. Copy-on-write,
checksumming, snapshots, send/receive for replication, compression, deduplication. For a
homelab running storage workloads, ZFS means you actually trust your data is what you think it is.
`pledge(2)` and `unveil(2)` are the clearest expression of this. Programs declare up front exactly
what syscalls they'll use and which filesystem paths they'll touch. The kernel enforces it. If a
daemon gets compromised, the blast radius is bounded by what it pledged. This isn't theoretical —
the base system uses these everywhere.
Compare that to the ext4/LVM dance on Linux — it works, but you're stitching together multiple
tools to get what ZFS gives you out of the box.
Compare that to a typical Linux daemon running as root (or even a non-root user) with access to
the full filesystem. The attack surface is enormous by default.
## Jails Are the Right Way to Isolate Services
FreeBSD jails give you lightweight OS-level isolation without the overhead or complexity of VMs,
and without the moving parts of Docker. A jail is just a restricted process tree with its own
filesystem view. Predictable, auditable, and you control exactly what it can see.
The security model is sound. A compromised service in a jail can't reach the host or other jails
without explicit configuration. Compare this to a typical Linux container where the security
boundary depends on which kernel namespaces you've set up correctly.
## pf Is the Best Firewall I've Used
I've spent time with iptables, nftables, and a few others. `pf(4)` is in a different category.
The rule syntax reads like English. State tracking is intelligent by default. `relayd(8)` handles
reverse proxying and TLS termination in a way that integrates naturally with pf. The whole
networking stack hangs together coherently.
FreeBSD ships pf — the same packet filter that made BSD firewalls famous. The rule syntax reads
like English. State tracking is intelligent by default. The whole networking stack hangs together
coherently.
Here's a minimal pf.conf to give a sense of the syntax:
@@ -47,37 +52,32 @@ That's readable. You can audit that in two minutes.
## The Base System Is Clean
OpenBSD ships a complete, minimal base system. No package manager drama, no systemd, no
six-hundred-dependency init system. The base is coherent. Everything in `/usr/bin` and
`/sbin` has been reviewed and fits together.
FreeBSD ships a complete, coherent base system — separate from ports and packages. No systemd.
Everything in the base has been considered deliberately and fits together.
When I install a service, I know exactly what I'm adding on top of a well-understood foundation.
On a typical Linux distro, I'm never quite sure what's lurking in the base.
## The Documentation Is Accurate
This is underrated. OpenBSD man pages are written by the people who wrote the code. They are
current. They are correct. When the man page says `pledge(2)` takes these promises in this
order, that is exactly what happens.
The FreeBSD Handbook is one of the best pieces of technical documentation in open source. The
man pages are maintained by the people who wrote the code. When the Handbook describes how to
set up ZFS or configure pf, it describes what actually happens.
How many times have you followed a Linux man page only to find it describes behavior from four
kernel versions ago? Or read documentation that's accurate for one distro but not another?
With OpenBSD, the man page is the specification. This matters enormously when you're learning
a new tool or debugging an obscure issue at 2am.
With FreeBSD, the documentation and the system agree with each other. This matters enormously
when you're learning a new tool or debugging an obscure issue at 2am.
## The Tradeoffs
OpenBSD isn't the right choice for everything. ZFS isn't in the base (use FreeBSD for that).
The package ecosystem is smaller than Linux. Some software just doesn't run on OpenBSD, or
runs with limitations.
FreeBSD isn't the right choice for everything. The package ecosystem is smaller than Linux.
Some software just doesn't run on FreeBSD, or requires extra effort to get working.
For a homelab where the goal is to *understand* the infrastructure rather than just consume
services, these tradeoffs are worth it. The constraint of a smaller, more deliberate ecosystem
means you end up with a leaner, more auditable system.
If you want to run Kubernetes on your homelab, OpenBSD is the wrong choice. If you want to
actually know what your firewall is doing and why, it's worth the investment.
If you want a point-and-click hypervisor, use Proxmox. If you want to actually know what your
firewall is doing and why, FreeBSD is worth the investment.
## What's Next

1
data/changelog.json Normal file
View File

@@ -0,0 +1 @@
{"entries": []}

View File

@@ -1,5 +1,5 @@
{
"last_checked": "2026-03-11T21:01:46.808088132Z",
"last_checked": "2026-03-27T12:53:37.268148673Z",
"services": [
{
"name": "Web (httpd)",
@@ -47,8 +47,7 @@
{
"name": "Matrix (Conduit)",
"description": "Self-hosted chat",
"status": "degraded",
"note": "Migrating to new server — intermittent"
"status": "up"
},
{
"name": "Jellyfin",
@@ -58,8 +57,7 @@
{
"name": "Game Servers",
"description": "Minecraft, Valheim VMs on srv02",
"status": "down",
"note": "Offline for maintenance"
"status": "up"
}
]
}

114
data/uptime.json Normal file
View File

@@ -0,0 +1,114 @@
[
{
"time": "2026-03-19T21:00:00Z",
"statuses": {
"DNS (nsd)": "down",
"DNS (unbound)": "down",
"Email (OpenSMTPD)": "down",
"Game Servers": "down",
"Gitea": "down",
"Grafana": "unknown",
"Jellyfin": "down",
"Matrix (Conduit)": "down",
"Monitoring (Prometheus)": "down",
"VPN (WireGuard)": "down",
"Web (httpd)": "up"
}
},
{
"time": "2026-03-19T22:00:00Z",
"statuses": {
"DNS (nsd)": "up",
"DNS (unbound)": "up",
"Email (OpenSMTPD)": "up",
"Game Servers": "up",
"Gitea": "down",
"Grafana": "up",
"Jellyfin": "up",
"Matrix (Conduit)": "up",
"Monitoring (Prometheus)": "up",
"VPN (WireGuard)": "up",
"Web (httpd)": "up"
}
},
{
"time": "2026-03-19T23:00:00Z",
"statuses": {
"DNS (nsd)": "up",
"DNS (unbound)": "up",
"Email (OpenSMTPD)": "up",
"Game Servers": "up",
"Gitea": "down",
"Grafana": "up",
"Jellyfin": "up",
"Matrix (Conduit)": "up",
"Monitoring (Prometheus)": "up",
"VPN (WireGuard)": "up",
"Web (httpd)": "up"
}
},
{
"time": "2026-03-20T02:00:00Z",
"statuses": {
"DNS (nsd)": "up",
"DNS (unbound)": "up",
"Email (OpenSMTPD)": "up",
"Game Servers": "up",
"Gitea": "down",
"Grafana": "up",
"Jellyfin": "up",
"Matrix (Conduit)": "up",
"Monitoring (Prometheus)": "up",
"VPN (WireGuard)": "up",
"Web (httpd)": "up"
}
},
{
"time": "2026-03-24T02:00:00Z",
"statuses": {
"DNS (nsd)": "up",
"DNS (unbound)": "up",
"Email (OpenSMTPD)": "up",
"Game Servers": "up",
"Gitea": "down",
"Grafana": "up",
"Jellyfin": "up",
"Matrix (Conduit)": "up",
"Monitoring (Prometheus)": "up",
"VPN (WireGuard)": "up",
"Web (httpd)": "up"
}
},
{
"time": "2026-03-24T03:00:00Z",
"statuses": {
"DNS (nsd)": "up",
"DNS (unbound)": "up",
"Email (OpenSMTPD)": "up",
"Game Servers": "up",
"Gitea": "down",
"Grafana": "up",
"Jellyfin": "up",
"Matrix (Conduit)": "up",
"Monitoring (Prometheus)": "up",
"VPN (WireGuard)": "up",
"Web (httpd)": "up"
}
},
{
"time": "2026-03-27T12:00:00Z",
"statuses": {
"DNS (nsd)": "up",
"DNS (unbound)": "up",
"Email (OpenSMTPD)": "up",
"Game Servers": "up",
"Gitea": "down",
"Grafana": "up",
"Jellyfin": "up",
"Matrix (Conduit)": "up",
"Monitoring (Prometheus)": "up",
"VPN (WireGuard)": "up",
"Web (httpd)": "up"
}
}
]

View File

@@ -0,0 +1,111 @@
// Package changelog manages the infrastructure changelog log.
package changelog
import (
"encoding/json"
"fmt"
"os"
"sort"
"time"
)
// Categories available for changelog entries.
var Categories = []string{"hardware", "network", "software", "migration"}
// Entry is a single changelog entry.
type Entry struct {
ID string `json:"id"`
Date string `json:"date"` // YYYY-MM-DD
Title string `json:"title"`
Description string `json:"description"`
Category string `json:"category"` // hardware, network, software, migration
}
// Log holds all changelog entries.
type Log struct {
Entries []Entry `json:"entries"`
}
// Load reads the changelog from path. Returns an empty log if the file does not exist.
func Load(path string) (*Log, error) {
raw, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &Log{}, nil
}
return nil, err
}
var l Log
if err := json.Unmarshal(raw, &l); err != nil {
return nil, err
}
sort.Slice(l.Entries, func(i, j int) bool {
return l.Entries[i].Date > l.Entries[j].Date
})
return &l, nil
}
// save writes the log to path without sorting (internal use).
func save(path string, l *Log) error {
raw, err := json.MarshalIndent(l, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, raw, 0644)
}
// Add appends a new entry and saves.
func Add(path string, e Entry) error {
l, err := Load(path)
if err != nil {
return err
}
e.ID = fmt.Sprintf("%d", time.Now().UnixNano())
l.Entries = append(l.Entries, e)
return save(path, l)
}
// Update replaces an entry by ID and saves.
func Update(path string, e Entry) error {
l, err := Load(path)
if err != nil {
return err
}
for i, entry := range l.Entries {
if entry.ID == e.ID {
l.Entries[i] = e
return save(path, l)
}
}
return fmt.Errorf("entry %s not found", e.ID)
}
// Delete removes an entry by ID and saves.
func Delete(path string, id string) error {
l, err := Load(path)
if err != nil {
return err
}
kept := l.Entries[:0]
for _, e := range l.Entries {
if e.ID != id {
kept = append(kept, e)
}
}
l.Entries = kept
return save(path, l)
}
// Get returns a single entry by ID.
func Get(path string, id string) (*Entry, error) {
l, err := Load(path)
if err != nil {
return nil, err
}
for _, e := range l.Entries {
if e.ID == id {
return &e, nil
}
}
return nil, fmt.Errorf("entry %s not found", id)
}

View File

@@ -8,6 +8,7 @@ import (
"time"
"ridgwaysystems.org/website/internal/status"
"ridgwaysystems.org/website/internal/uptime"
)
// Start launches the background checker. It checks services every interval
@@ -26,18 +27,19 @@ func run(dataDir string, interval time.Duration) {
return nil
},
}
path := filepath.Join(dataDir, "status.json")
statusPath := filepath.Join(dataDir, "status.json")
uptimePath := filepath.Join(dataDir, "uptime.json")
for {
check(client, path)
check(client, statusPath, uptimePath)
time.Sleep(interval)
}
}
func check(client *http.Client, path string) {
page, err := status.Load(path)
func check(client *http.Client, statusPath, uptimePath string) {
page, err := status.Load(statusPath)
if err != nil {
log.Printf("checker: load %s: %v", path, err)
log.Printf("checker: load %s: %v", statusPath, err)
return
}
@@ -57,16 +59,24 @@ func check(client *http.Client, path string) {
}
if changed {
if err := status.Save(path, page); err != nil {
log.Printf("checker: save %s: %v", path, err)
if err := status.Save(statusPath, page); err != nil {
log.Printf("checker: save %s: %v", statusPath, err)
}
} else {
// Still update last_checked timestamp so the status page shows freshness
page.LastChecked = time.Now().UTC()
if err := status.Save(path, page); err != nil {
log.Printf("checker: save %s: %v", path, err)
if err := status.Save(statusPath, page); err != nil {
log.Printf("checker: save %s: %v", statusPath, err)
}
}
// Record hourly uptime snapshot.
statuses := make(map[string]string, len(page.Services))
for _, svc := range page.Services {
statuses[svc.Name] = svc.Status
}
if err := uptime.Record(uptimePath, statuses); err != nil {
log.Printf("checker: uptime record: %v", err)
}
}
func probe(client *http.Client, url string) string {
@@ -82,7 +92,6 @@ func probe(client *http.Client, url string) string {
case resp.StatusCode >= 500:
return "down"
default:
// 4xx could mean the service is up but the URL is wrong; treat as degraded
return "degraded"
}
}

View File

@@ -1,7 +1,6 @@
package handler
import (
"encoding/json"
"fmt"
"html/template"
"io"
@@ -12,6 +11,7 @@ import (
"time"
"ridgwaysystems.org/website/internal/blog"
"ridgwaysystems.org/website/internal/changelog"
"ridgwaysystems.org/website/internal/newsletter"
"ridgwaysystems.org/website/internal/status"
)
@@ -55,6 +55,26 @@ func (h *Handler) AdminRouter(w http.ResponseWriter, r *http.Request) {
h.requireAuth(h.adminStatusGet)(w, r)
}
case path == "/admin/changelog":
h.requireAuth(h.adminChangelogList)(w, r)
case path == "/admin/changelog/new":
if r.Method == http.MethodPost {
h.requireAuth(h.adminChangelogNewPost)(w, r)
} else {
h.requireAuth(h.adminChangelogNewGet)(w, r)
}
case strings.HasPrefix(path, "/admin/changelog/edit/"):
if r.Method == http.MethodPost {
h.requireAuth(h.adminChangelogEditPost)(w, r)
} else {
h.requireAuth(h.adminChangelogEditGet)(w, r)
}
case strings.HasPrefix(path, "/admin/changelog/delete/"):
h.requireAuth(h.adminChangelogDelete)(w, r)
case path == "/admin/preview":
h.requireAuth(h.adminPreview)(w, r)
@@ -208,7 +228,7 @@ func (h *Handler) adminDeletePost(w http.ResponseWriter, r *http.Request) {
// --- Status editor ---
type adminStatusData struct {
JSON string
Page *status.Page
Error string
Flash string
}
@@ -219,9 +239,7 @@ func (h *Handler) adminStatusGet(w http.ResponseWriter, r *http.Request) {
h.render(w, "admin-status", adminStatusData{Error: "Could not load status.json: " + err.Error()})
return
}
raw, _ := json.MarshalIndent(p, "", " ")
flash := r.URL.Query().Get("flash")
h.render(w, "admin-status", adminStatusData{JSON: string(raw), Flash: flash})
h.render(w, "admin-status", adminStatusData{Page: p, Flash: r.URL.Query().Get("flash")})
}
func (h *Handler) adminStatusPost(w http.ResponseWriter, r *http.Request) {
@@ -229,15 +247,19 @@ func (h *Handler) adminStatusPost(w http.ResponseWriter, r *http.Request) {
h.renderErr(w, http.StatusBadRequest, "Bad form data.")
return
}
raw := r.FormValue("json")
var p status.Page
if err := json.Unmarshal([]byte(raw), &p); err != nil {
h.render(w, "admin-status", adminStatusData{JSON: raw, Error: "Invalid JSON: " + err.Error()})
p, err := status.Load(filepath.Join(h.dataDir, "status.json"))
if err != nil {
h.render(w, "admin-status", adminStatusData{Error: "Could not load status: " + err.Error()})
return
}
p.LastChecked = time.Now().UTC()
if err := status.Save(filepath.Join(h.dataDir, "status.json"), &p); err != nil {
h.render(w, "admin-status", adminStatusData{JSON: raw, Error: "Save failed: " + err.Error()})
for i := range p.Services {
if s := r.FormValue(fmt.Sprintf("status_%d", i)); s != "" {
p.Services[i].Status = s
}
p.Services[i].Note = strings.TrimSpace(r.FormValue(fmt.Sprintf("note_%d", i)))
}
if err := status.Save(filepath.Join(h.dataDir, "status.json"), p); err != nil {
h.render(w, "admin-status", adminStatusData{Page: p, Error: "Save failed: " + err.Error()})
return
}
http.Redirect(w, r, "/admin/status?flash=Saved", http.StatusSeeOther)
@@ -416,6 +438,121 @@ func (h *Handler) adminNewsletterDelete(w http.ResponseWriter, r *http.Request)
http.Redirect(w, r, "/admin/newsletter?flash=Removed", http.StatusSeeOther)
}
// --- Changelog admin ---
type adminChangelogData struct {
Log *changelog.Log
Entry *changelog.Entry
Categories []string
Error string
Flash string
IsNew bool
}
func (h *Handler) adminChangelogList(w http.ResponseWriter, r *http.Request) {
l, err := changelog.Load(filepath.Join(h.dataDir, "changelog.json"))
if err != nil {
l = &changelog.Log{}
}
h.render(w, "admin-changelog", adminChangelogData{
Log: l,
Flash: r.URL.Query().Get("flash"),
})
}
func (h *Handler) adminChangelogNewGet(w http.ResponseWriter, r *http.Request) {
h.render(w, "admin-changelog-editor", adminChangelogData{
Categories: changelog.Categories,
IsNew: true,
Entry: &changelog.Entry{Date: time.Now().Format("2006-01-02")},
})
}
func (h *Handler) adminChangelogNewPost(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
h.renderErr(w, http.StatusBadRequest, "Bad form data.")
return
}
e := changelog.Entry{
Date: r.FormValue("date"),
Title: strings.TrimSpace(r.FormValue("title")),
Description: strings.TrimSpace(r.FormValue("description")),
Category: r.FormValue("category"),
}
if e.Title == "" || e.Date == "" {
h.render(w, "admin-changelog-editor", adminChangelogData{
Entry: &e, Categories: changelog.Categories, IsNew: true,
Error: "Date and title are required.",
})
return
}
if err := changelog.Add(filepath.Join(h.dataDir, "changelog.json"), e); err != nil {
h.render(w, "admin-changelog-editor", adminChangelogData{
Entry: &e, Categories: changelog.Categories, IsNew: true,
Error: "Failed to save: " + err.Error(),
})
return
}
http.Redirect(w, r, "/admin/changelog?flash=Entry+created", http.StatusSeeOther)
}
func (h *Handler) adminChangelogEditGet(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/admin/changelog/edit/")
e, err := changelog.Get(filepath.Join(h.dataDir, "changelog.json"), id)
if err != nil {
h.renderErr(w, http.StatusNotFound, "Entry not found.")
return
}
h.render(w, "admin-changelog-editor", adminChangelogData{
Entry: e,
Categories: changelog.Categories,
IsNew: false,
})
}
func (h *Handler) adminChangelogEditPost(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/admin/changelog/edit/")
if err := r.ParseForm(); err != nil {
h.renderErr(w, http.StatusBadRequest, "Bad form data.")
return
}
e := changelog.Entry{
ID: id,
Date: r.FormValue("date"),
Title: strings.TrimSpace(r.FormValue("title")),
Description: strings.TrimSpace(r.FormValue("description")),
Category: r.FormValue("category"),
}
if e.Title == "" || e.Date == "" {
h.render(w, "admin-changelog-editor", adminChangelogData{
Entry: &e, Categories: changelog.Categories,
Error: "Date and title are required.",
})
return
}
if err := changelog.Update(filepath.Join(h.dataDir, "changelog.json"), e); err != nil {
h.render(w, "admin-changelog-editor", adminChangelogData{
Entry: &e, Categories: changelog.Categories,
Error: "Failed to save: " + err.Error(),
})
return
}
http.Redirect(w, r, "/admin/changelog?flash=Entry+saved", http.StatusSeeOther)
}
func (h *Handler) adminChangelogDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
h.renderErr(w, http.StatusMethodNotAllowed, "POST required.")
return
}
id := strings.TrimPrefix(r.URL.Path, "/admin/changelog/delete/")
if err := changelog.Delete(filepath.Join(h.dataDir, "changelog.json"), id); err != nil {
h.renderErr(w, http.StatusInternalServerError, "Delete failed: "+err.Error())
return
}
http.Redirect(w, r, "/admin/changelog?flash=Entry+deleted", http.StatusSeeOther)
}
// sanitizeSlug ensures a slug is filesystem-safe.
func sanitizeSlug(s string) string {
s = strings.ToLower(strings.TrimSpace(s))

View File

@@ -8,11 +8,13 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"time"
"ridgwaysystems.org/website/internal/blog"
"ridgwaysystems.org/website/internal/newsletter"
"ridgwaysystems.org/website/internal/ratelimit"
"ridgwaysystems.org/website/internal/status"
)
// Handler holds shared dependencies for all HTTP handlers.
@@ -84,12 +86,15 @@ func mustLoadTemplates() map[string]*template.Template {
{"uses", "templates/uses.html"},
{"projects", "templates/projects.html"},
{"error", "templates/error.html"},
{"changelog", "templates/changelog.html"},
{"admin-login", "templates/admin/login.html"},
{"admin-dashboard", "templates/admin/dashboard.html"},
{"admin-editor", "templates/admin/editor.html"},
{"admin-status", "templates/admin/status.html"},
{"admin-uploads", "templates/admin/uploads.html"},
{"admin-newsletter", "templates/admin/newsletter.html"},
{"admin-changelog", "templates/admin/changelog.html"},
{"admin-changelog-editor", "templates/admin/changelog-editor.html"},
}
for _, p := range pages {
@@ -103,6 +108,56 @@ func mustLoadTemplates() map[string]*template.Template {
return m
}
// baseEnvelope wraps page-specific data with shared layout data for the base template.
type baseEnvelope struct {
Banner *siteBanner
Inner any
}
// siteBanner holds the data for the site-wide status banner.
type siteBanner struct {
Level string // "danger" | "warning"
Message string
}
// computeBanner loads status.json and returns a banner if any services are down or degraded.
func (h *Handler) computeBanner() *siteBanner {
p, err := status.Load(filepath.Join(h.dataDir, "status.json"))
if err != nil || p == nil {
return nil
}
var down, degraded []string
for _, s := range p.Services {
switch s.Status {
case "down":
down = append(down, s.Name)
case "degraded":
degraded = append(degraded, s.Name)
}
}
switch {
case len(down) > 0:
noun := "are unavailable"
if len(down) == 1 {
noun = "is unavailable"
}
return &siteBanner{
Level: "danger",
Message: "Major Outage \u2014 " + strings.Join(down, ", ") + " " + noun + ".",
}
case len(degraded) > 0:
noun := "are experiencing issues"
if len(degraded) == 1 {
noun = "is experiencing issues"
}
return &siteBanner{
Level: "warning",
Message: "Partial Outage \u2014 " + strings.Join(degraded, ", ") + " " + noun + ".",
}
}
return nil
}
func (h *Handler) render(w http.ResponseWriter, name string, data any) {
t := h.tmpl(name)
if t == nil {
@@ -110,7 +165,7 @@ func (h *Handler) render(w http.ResponseWriter, name string, data any) {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.ExecuteTemplate(w, "base", data); err != nil {
if err := t.ExecuteTemplate(w, "base", baseEnvelope{Banner: h.computeBanner(), Inner: data}); err != nil {
log.Printf("render %s: %v", name, err)
}
}
@@ -132,7 +187,7 @@ func (h *Handler) renderErr(w http.ResponseWriter, code int, msg string) {
code, http.StatusText(code), msg)
return
}
if err := t.ExecuteTemplate(w, "base", data); err != nil {
if err := t.ExecuteTemplate(w, "base", baseEnvelope{Banner: h.computeBanner(), Inner: data}); err != nil {
log.Printf("renderErr %d: %v", code, err)
}
}

View File

@@ -11,9 +11,11 @@ import (
"time"
"ridgwaysystems.org/website/internal/blog"
"ridgwaysystems.org/website/internal/changelog"
"ridgwaysystems.org/website/internal/feed"
"ridgwaysystems.org/website/internal/mailer"
"ridgwaysystems.org/website/internal/status"
"ridgwaysystems.org/website/internal/uptime"
)
const postsPerPage = 10
@@ -143,7 +145,7 @@ func (h *Handler) Feed(w http.ResponseWriter, r *http.Request) {
http.Error(w, "feed unavailable", http.StatusInternalServerError)
return
}
rss, err := feed.RSS(h.siteURL, "Ridgway Systems", "A homelab built on OpenBSD.", posts)
rss, err := feed.RSS(h.siteURL, "Ridgway Systems", "A homelab built on FreeBSD.", posts)
if err != nil {
http.Error(w, "feed error", http.StatusInternalServerError)
return
@@ -156,10 +158,17 @@ func (h *Handler) Infrastructure(w http.ResponseWriter, r *http.Request) {
h.render(w, "infrastructure", nil)
}
// serviceHistory bundles per-service uptime data for the status template.
type serviceHistory struct {
Blocks []uptime.DayBlock
UptimePct float64
}
// statusData is passed to the status template.
type statusData struct {
Page *status.Page
LastChecked string
History map[string]serviceHistory // keyed by service name
}
func (h *Handler) Status(w http.ResponseWriter, r *http.Request) {
@@ -171,7 +180,28 @@ func (h *Handler) Status(w http.ResponseWriter, r *http.Request) {
if !p.LastChecked.IsZero() {
lastChecked = p.LastChecked.UTC().Format("2006-01-02 15:04 UTC")
}
h.render(w, "status", statusData{Page: p, LastChecked: lastChecked})
uptimePath := filepath.Join(h.dataDir, "uptime.json")
history := make(map[string]serviceHistory, len(p.Services))
for _, svc := range p.Services {
history[svc.Name] = serviceHistory{
Blocks: uptime.ServiceHistory(uptimePath, svc.Name),
UptimePct: uptime.UptimePct(uptimePath, svc.Name),
}
}
h.render(w, "status", statusData{Page: p, LastChecked: lastChecked, History: history})
}
// changelogData is passed to the changelog template.
type changelogData struct {
Log *changelog.Log
}
func (h *Handler) Changelog(w http.ResponseWriter, r *http.Request) {
l, err := changelog.Load(filepath.Join(h.dataDir, "changelog.json"))
if err != nil {
l = &changelog.Log{}
}
h.render(w, "changelog", changelogData{Log: l})
}
func (h *Handler) About(w http.ResponseWriter, r *http.Request) {

142
internal/uptime/uptime.go Normal file
View File

@@ -0,0 +1,142 @@
// Package uptime stores hourly service status snapshots and computes uptime history.
package uptime
import (
"encoding/json"
"os"
"time"
)
// Snapshot records the status of all services at a point in time.
type Snapshot struct {
Time time.Time `json:"time"`
Statuses map[string]string `json:"statuses"` // service name → status
}
// DayBlock represents one day's aggregated status for display.
type DayBlock struct {
Date string // YYYY-MM-DD
Status string // worst status seen that day: up, degraded, down, or none
}
const maxDays = 30
// Record appends a snapshot to the history file, pruning entries older than 30 days.
// It is safe to call on every checker run; it deduplicates by hour.
func Record(path string, statuses map[string]string) error {
snapshots, _ := load(path)
now := time.Now().UTC()
currentHour := now.Truncate(time.Hour)
// Skip if we already have a snapshot for this hour.
if len(snapshots) > 0 {
last := snapshots[len(snapshots)-1]
if last.Time.Truncate(time.Hour).Equal(currentHour) {
return nil
}
}
snapshots = append(snapshots, Snapshot{
Time: currentHour,
Statuses: statuses,
})
// Prune entries older than 30 days.
cutoff := now.AddDate(0, 0, -maxDays)
kept := snapshots[:0]
for _, s := range snapshots {
if s.Time.After(cutoff) {
kept = append(kept, s)
}
}
return save(path, kept)
}
// ServiceHistory returns the last 30 daily blocks for a named service, oldest first.
func ServiceHistory(path string, serviceName string) []DayBlock {
snapshots, _ := load(path)
// Build a map of date → worst status.
dayStatus := make(map[string]string)
for _, s := range snapshots {
date := s.Time.UTC().Format("2006-01-02")
st, ok := s.Statuses[serviceName]
if !ok {
continue
}
existing := dayStatus[date]
dayStatus[date] = worst(existing, st)
}
// Build the last 30 days in order.
now := time.Now().UTC()
blocks := make([]DayBlock, maxDays)
for i := range blocks {
day := now.AddDate(0, 0, -(maxDays - 1 - i))
date := day.Format("2006-01-02")
status := dayStatus[date]
if status == "" {
status = "none"
}
blocks[i] = DayBlock{Date: date, Status: status}
}
return blocks
}
// UptimePct returns the percentage of hourly snapshots where the service was "up"
// over the last 30 days. Returns -1 if there is no data.
func UptimePct(path string, serviceName string) float64 {
snapshots, _ := load(path)
if len(snapshots) == 0 {
return -1
}
total, up := 0, 0
for _, s := range snapshots {
st, ok := s.Statuses[serviceName]
if !ok {
continue
}
total++
if st == "up" {
up++
}
}
if total == 0 {
return -1
}
return float64(up) / float64(total) * 100
}
// worst returns the more severe of two status strings.
func worst(a, b string) string {
rank := map[string]int{"up": 1, "degraded": 2, "down": 3}
if rank[b] > rank[a] {
return b
}
if a == "" {
return b
}
return a
}
func load(path string) ([]Snapshot, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var s []Snapshot
if err := json.Unmarshal(raw, &s); err != nil {
return nil, err
}
return s, nil
}
func save(path string, snapshots []Snapshot) error {
raw, err := json.MarshalIndent(snapshots, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, raw, 0644)
}

View File

@@ -228,6 +228,35 @@ blockquote {
.site-footer a { color: var(--text-muted); }
.site-footer a:hover { color: var(--accent); }
/* === Status Banner === */
.banner {
text-align: center;
padding: 0.6rem 1.25rem;
font-size: 0.88rem;
font-family: var(--font-mono);
}
.banner a {
color: inherit;
text-decoration: none;
font-weight: 600;
}
.banner a:hover { text-decoration: underline; }
.banner-danger {
background: #a32200;
color: #fdecea;
border-bottom: 2px solid #7a1900;
}
.banner-warning {
background: #8a5c00;
color: #fff8e1;
border-bottom: 2px solid #6b4700;
}
/* === Buttons === */
.btn {
@@ -520,9 +549,9 @@ blockquote {
.status-item {
display: flex;
align-items: center;
gap: 0.85rem;
padding: 0.8rem 0;
flex-direction: column;
gap: 0.45rem;
padding: 0.9rem 0;
border-bottom: 1px solid var(--border);
}
@@ -533,6 +562,7 @@ blockquote {
height: 10px;
border-radius: 50%;
flex-shrink: 0;
justify-self: center;
}
.status-up .status-indicator { background: #2a9a5a; }
@@ -871,6 +901,64 @@ blockquote {
margin-bottom: 0.75rem;
}
/* === Status Editor === */
.status-editor-table td { vertical-align: middle; }
.status-editor-name {
display: block;
font-weight: 600;
font-size: 0.9rem;
}
.status-select {
appearance: none;
background: var(--bg-alt);
border: 1px solid var(--border-dark);
border-radius: var(--radius);
color: var(--text);
font-family: var(--font-mono);
font-size: 0.82rem;
padding: 0.3rem 0.6rem;
cursor: pointer;
min-width: 7rem;
}
.status-select-up { border-color: #2a7a2a; color: #2a7a2a; }
.status-select-degraded { border-color: #8a5c00; color: #8a5c00; }
.status-select-down { border-color: #a32200; color: #a32200; }
.status-select-unknown { border-color: var(--border-dark); }
@media (prefers-color-scheme: dark) {
.status-select-up { border-color: #4caf50; color: #4caf50; }
.status-select-degraded { border-color: #ffb74d; color: #ffb74d; }
.status-select-down { border-color: #ef5350; color: #ef5350; }
}
.status-note-input {
width: 100%;
background: var(--bg-alt);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-family: var(--font-mono);
font-size: 0.82rem;
padding: 0.3rem 0.6rem;
}
.status-note-input:focus,
.status-select:focus {
outline: none;
border-color: var(--accent);
}
.status-last-checked {
font-size: 0.8rem;
color: var(--text-muted);
font-family: var(--font-mono);
margin-left: 1rem;
}
/* === Blog Controls (search + tag filter) === */
.blog-controls {
@@ -1307,6 +1395,20 @@ blockquote {
line-height: 1.55;
}
.resume-reference blockquote {
margin: 0 0 0.5em 0;
font-size: 0.87rem;
line-height: 1.6;
}
.resume-reference-cite {
display: block;
font-size: 0.82rem;
font-style: normal;
color: var(--text-muted);
padding-left: 1em;
}
@media (max-width: 500px) {
.resume-skills dl { grid-template-columns: 1fr; gap: 0.15em; }
.resume-skills dt { margin-top: 0.5em; }
@@ -1499,3 +1601,251 @@ blockquote {
@media (max-width: 420px) {
.infra-grid { grid-template-columns: 1fr; }
}
/* === Changelog === */
.changelog-list {
display: flex;
flex-direction: column;
gap: 0;
}
.changelog-entry {
display: grid;
grid-template-columns: 180px 1fr;
gap: 0 1.5rem;
padding: 1.1rem 0;
border-bottom: 1px solid var(--border);
}
.changelog-entry:last-child { border-bottom: none; }
.changelog-meta {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding-top: 0.1rem;
}
.changelog-date {
font-family: var(--font-mono);
font-size: 0.82rem;
color: var(--text-muted);
}
.changelog-category {
display: inline-block;
font-family: var(--font-mono);
font-size: 0.72rem;
padding: 0.15rem 0.45rem;
border-radius: var(--radius);
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 600;
width: fit-content;
}
.changelog-category-hardware { background: #2a4a7a22; color: #3a6aaa; border: 1px solid #3a6aaa44; }
.changelog-category-network { background: #2a6a4a22; color: #2a8a5a; border: 1px solid #2a8a5a44; }
.changelog-category-software { background: #6a4a2a22; color: #9a6a3a; border: 1px solid #9a6a3a44; }
.changelog-category-migration { background: #6a2a6a22; color: #8a4a8a; border: 1px solid #8a4a8a44; }
@media (prefers-color-scheme: dark) {
.changelog-category-hardware { background: #3a6aaa22; color: #7aaaee; border-color: #7aaaee44; }
.changelog-category-network { background: #2a8a5a22; color: #5acea8; border-color: #5acea844; }
.changelog-category-software { background: #9a6a3a22; color: #ddaa66; border-color: #ddaa6644; }
.changelog-category-migration { background: #8a4a8a22; color: #cc88cc; border-color: #cc88cc44; }
}
.changelog-title {
font-size: 0.97rem;
font-weight: 600;
margin: 0 0 0.3rem;
}
.changelog-desc {
font-size: 0.88rem;
color: var(--text-muted);
margin: 0;
line-height: 1.55;
}
@media (max-width: 600px) {
.changelog-entry { grid-template-columns: 1fr; gap: 0.4rem; }
}
/* === Uptime Bars === */
.status-top {
display: grid;
grid-template-columns: 14px 1fr auto;
align-items: center;
gap: 0 0.75rem;
}
.uptime-bar-row {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 0 0.6rem;
padding-left: calc(14px + 0.75rem);
}
.uptime-bar {
display: flex;
gap: 2px;
}
.uptime-block {
display: inline-block;
height: 20px;
flex: 1;
border-radius: 2px;
min-width: 4px;
}
.uptime-block-up { background: #2ecc71; }
.uptime-block-degraded { background: #e8870a; }
.uptime-block-down { background: #e74c3c; }
.uptime-block-none { background: var(--border-dark); }
.uptime-pct {
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--text-muted);
min-width: 3.5rem;
text-align: right;
}
.uptime-pct-none { font-style: italic; }
/* === Network Map (SVG) === */
.netmap-wrap {
position: relative;
overflow: visible;
}
.netmap {
width: 100%;
max-width: 720px;
height: auto;
display: block;
margin: 0 auto;
}
.netmap-box {
fill: var(--bg-alt);
stroke: var(--border-dark);
stroke-width: 1.5;
}
.netmap-box-internet { stroke: var(--accent); }
.netmap-box-fw { stroke: var(--accent); fill: var(--bg-code); }
.netmap-box-vlan { stroke: var(--border-dark); }
.netmap-box-host { stroke: var(--border); }
.netmap-line {
stroke: var(--border-dark);
stroke-width: 1.5;
}
.netmap-label {
fill: var(--text);
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
text-anchor: middle;
dominant-baseline: auto;
}
.netmap-label-sm { font-size: 11px; }
.netmap-sublabel {
fill: var(--text-muted);
font-family: var(--font-mono);
font-size: 9px;
text-anchor: middle;
}
.netmap-node { cursor: default; }
.netmap-node:hover .netmap-box {
stroke: var(--accent);
fill: var(--bg-code);
}
.netmap-tooltip {
position: fixed;
background: var(--bg-alt);
border: 1px solid var(--border-dark);
border-radius: var(--radius);
padding: 0.45rem 0.7rem;
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--text);
white-space: pre;
line-height: 1.6;
pointer-events: none;
opacity: 0;
transition: opacity 0.1s;
z-index: 100;
max-width: 260px;
}
.netmap-tooltip.visible { opacity: 1; }
/* === Admin Changelog === */
.admin-row-actions {
display: flex;
gap: 0.4rem;
align-items: center;
}
.btn-sm {
padding: 0.2rem 0.6rem;
font-size: 0.78rem;
}
.btn-danger {
background: #a32200;
color: #fdecea;
border-color: #a32200;
}
.btn-danger:hover { background: #7a1900; border-color: #7a1900; }
.form-row {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin-bottom: 1rem;
}
.form-row label {
font-size: 0.85rem;
font-weight: 600;
color: var(--text);
}
.label-optional {
font-weight: 400;
color: var(--text-muted);
}
.form-input {
background: var(--bg-alt);
border: 1px solid var(--border-dark);
border-radius: var(--radius);
color: var(--text);
font-family: var(--font-sans);
font-size: 0.9rem;
padding: 0.45rem 0.65rem;
width: 100%;
}
.form-input:focus {
outline: none;
border-color: var(--accent);
}

View File

@@ -43,7 +43,7 @@
<rect width="1920" height="1080" fill="url(#scanlines)" opacity="0.035"/>
<!-- ============================================================
2. Corner text blocks — actual homelab / OpenBSD content
2. Corner text blocks — actual homelab / FreeBSD content
All at low opacity so they read as ambient context,
not competing with the central mark.
============================================================ -->
@@ -74,7 +74,7 @@
<text x="1840" y="96" opacity="0.55" fill="#b87838"># ridgwaysystems.org -- network</text>
<text x="1840" y="118" opacity="0.38"> internet</text>
<text x="1840" y="136" opacity="0.28" fill="#5a5450"> |</text>
<text x="1840" y="154" opacity="0.42"> fw01 10.0.1.1 OpenBSD</text>
<text x="1840" y="154" opacity="0.42"> fw01 10.0.1.1 OPNsense</text>
<text x="1840" y="172" opacity="0.32" fill="#5a5450"> pf relayd unbound wireguard</text>
<text x="1840" y="190" opacity="0.28" fill="#5a5450"> |</text>
<text x="1840" y="216" opacity="0.38">+-- vlan10 10.0.10.0/24 servers</text>
@@ -108,13 +108,13 @@
<!-- === BOTTOM-RIGHT: hardware inventory === -->
<g font-family="'Courier New', Courier, monospace" font-size="13" fill="#8a8075" text-anchor="end">
<text x="1840" y="700" opacity="0.55" fill="#b87838"># hardware inventory</text>
<text x="1840" y="722" opacity="0.42">fw01 SuperMicro 1U OpenBSD 7.6</text>
<text x="1840" y="722" opacity="0.42">fw01 SuperMicro 1U OPNsense 26.1</text>
<text x="1840" y="740" opacity="0.28" fill="#5a5450"> Xeon E3-1230v2 / 16 GB ECC</text>
<text x="1840" y="758" opacity="0.28" fill="#5a5450"> pf · relayd · wireguard</text>
<text x="1840" y="782" opacity="0.42">srv01 Dell R720 OpenBSD 7.6</text>
<text x="1840" y="782" opacity="0.42">srv01 Dell R720 FreeBSD 15.0</text>
<text x="1840" y="800" opacity="0.28" fill="#5a5450"> 2x Xeon E5-2600 / 64 GB ECC</text>
<text x="1840" y="818" opacity="0.28" fill="#5a5450"> httpd · gitea · smtpd · matrix</text>
<text x="1840" y="842" opacity="0.42">srv02 Dell R710 OpenBSD 7.6</text>
<text x="1840" y="842" opacity="0.42">srv02 Dell R710 FreeBSD 15.0</text>
<text x="1840" y="860" opacity="0.28" fill="#5a5450"> Xeon X5650 / 48 GB ECC</text>
<text x="1840" y="878" opacity="0.28" fill="#5a5450"> nsd · vmm · jellyfin</text>
<text x="1840" y="902" opacity="0.42">ws01 desktop Linux</text>
@@ -128,7 +128,7 @@
<!-- Soft radial glow behind the mark -->
<ellipse cx="960" cy="530" rx="540" ry="340" fill="url(#centerGlow)"/>
<!-- "OpenBSD" label above the mark -->
<!-- "FreeBSD" label above the mark -->
<text
x="960" y="368"
text-anchor="middle"
@@ -136,7 +136,7 @@
font-size="15"
letter-spacing="10"
fill="#b87838"
opacity="0.55">OPENBSD HOMELAB</text>
opacity="0.55">FREEBSD HOMELAB</text>
<!-- >_ mark, centered: x span 7401180, y span 390692 -->
<g filter="url(#glow)">

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -1,5 +1,5 @@
{{define "title"}}About &mdash; Ridgway Systems{{end}}
{{define "meta-desc"}}About Ridgway Systems — a personal OpenBSD homelab project.{{end}}
{{define "meta-desc"}}About Ridgway Systems — a personal FreeBSD homelab project.{{end}}
{{define "content"}}
<div class="about-header">
@@ -11,24 +11,24 @@
<div class="prose">
<p>
Ridgway Systems is a personal homelab project built entirely on OpenBSD. The goal is to self-host
Ridgway Systems is a personal homelab project built entirely on FreeBSD. The goal is to self-host
as many services as practical on owned hardware, with a focus on simplicity, security, and
understanding every layer of the stack.
</p>
<p>
This site documents the build — hardware choices, configuration decisions, and things learned
along the way. If you're setting up your own homelab or migrating to OpenBSD, hopefully something
along the way. If you're setting up your own homelab or migrating to FreeBSD, hopefully something
here is useful.
</p>
<h2>Why OpenBSD?</h2>
<h2>Why FreeBSD?</h2>
<ul>
<li>Security-first design. <code>pledge(2)</code> and <code>unveil(2)</code> are excellent.</li>
<li>Clean, minimal base system. No surprises.</li>
<li>ZFS in the base system. First-class, not bolted on.</li>
<li>Jails for lightweight, auditable service isolation.</li>
<li><code>pf(4)</code> is the best firewall I've used.</li>
<li>Documentation is thorough and accurate. The man pages are genuinely good.</li>
<li>Deliberate, careful development. The OpenBSD team doesn't chase hype.</li>
<li>Clean base system separate from ports and packages. No surprises.</li>
<li>Documentation is thorough and accurate. The Handbook and man pages are genuinely good.</li>
</ul>
<h2>What's Running</h2>

View File

@@ -0,0 +1,46 @@
{{define "title"}}{{if .IsNew}}New Entry{{else}}Edit Entry{{end}} &mdash; Changelog Admin{{end}}
{{define "content"}}
<div class="admin-wrap">
<div class="admin-header">
<h1>{{if .IsNew}}New Changelog Entry{{else}}Edit Entry{{end}}</h1>
<div class="admin-actions">
<a href="/admin/changelog" class="btn btn-outline">Back</a>
</div>
</div>
{{if .Error}}<p class="form-error">{{.Error}}</p>{{end}}
<form method="POST" action="{{if .IsNew}}/admin/changelog/new{{else}}/admin/changelog/edit/{{.Entry.ID}}{{end}}">
<div class="form-row">
<label for="date">Date</label>
<input type="date" id="date" name="date" value="{{.Entry.Date}}" required class="form-input">
</div>
<div class="form-row">
<label for="category">Category</label>
<select id="category" name="category" class="form-input">
{{range .Categories}}
<option value="{{.}}" {{if eq . $.Entry.Category}}selected{{end}}>{{.}}</option>
{{end}}
</select>
</div>
<div class="form-row">
<label for="title">Title</label>
<input type="text" id="title" name="title" value="{{.Entry.Title}}" required
placeholder="e.g. Migrated fw01 from OpenBSD to OPNsense" class="form-input">
</div>
<div class="form-row">
<label for="description">Description <span class="label-optional">(optional)</span></label>
<textarea id="description" name="description" rows="4"
placeholder="Additional details about the change…" class="form-input">{{.Entry.Description}}</textarea>
</div>
<div class="editor-footer">
<button type="submit" class="btn">{{if .IsNew}}Create{{else}}Save{{end}}</button>
</div>
</form>
</div>
{{end}}

View File

@@ -0,0 +1,47 @@
{{define "title"}}Changelog &mdash; Admin{{end}}
{{define "content"}}
<div class="admin-wrap">
<div class="admin-header">
<h1>Changelog</h1>
<div class="admin-actions">
<a href="/admin" class="btn btn-outline">Back</a>
<a href="/changelog" class="btn btn-outline">View Page</a>
<a href="/admin/changelog/new" class="btn">New Entry</a>
</div>
</div>
{{if .Flash}}<p class="flash-msg">{{.Flash}}</p>{{end}}
{{if and .Log .Log.Entries}}
<table class="hw-table">
<thead>
<tr>
<th>Date</th>
<th>Category</th>
<th>Title</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{range .Log.Entries}}
<tr>
<td class="hw-spec">{{.Date}}</td>
<td><span class="changelog-category changelog-category-{{.Category}}">{{.Category}}</span></td>
<td>{{.Title}}</td>
<td class="admin-row-actions">
<a href="/admin/changelog/edit/{{.ID}}" class="btn btn-outline btn-sm">Edit</a>
<form method="POST" action="/admin/changelog/delete/{{.ID}}" style="display:inline">
<button type="submit" class="btn btn-danger btn-sm"
onclick="return confirm('Delete this entry?')">Delete</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="empty-state">No entries yet. <a href="/admin/changelog/new">Create one.</a></p>
{{end}}
</div>
{{end}}

View File

@@ -3,7 +3,7 @@
{{define "content"}}
<div class="admin-wrap">
<div class="admin-header">
<h1>Edit Service Status</h1>
<h1>Service Status</h1>
<div class="admin-actions">
<a href="/admin" class="btn btn-outline">Back</a>
<a href="/status" class="btn btn-outline">View Status Page</a>
@@ -18,16 +18,46 @@
<p class="form-error">{{.Error}}</p>
{{end}}
<p class="page-desc">
Edit the raw JSON below. Valid status values: <code>up</code>, <code>degraded</code>,
<code>down</code>, <code>unknown</code>.
</p>
{{if .Page}}
<form method="POST" action="/admin/status">
<textarea name="json" class="json-editor" rows="30" spellcheck="false">{{.JSON}}</textarea>
<table class="hw-table status-editor-table">
<thead>
<tr>
<th>Service</th>
<th>Status</th>
<th>Note</th>
</tr>
</thead>
<tbody>
{{range $i, $s := .Page.Services}}
<tr>
<td>
<span class="status-editor-name">{{$s.Name}}</span>
{{if $s.Description}}<span class="hw-spec">{{$s.Description}}</span>{{end}}
</td>
<td>
<select name="status_{{$i}}" class="status-select status-select-{{$s.Status}}">
<option value="up" {{if eq $s.Status "up"}}selected{{end}}>up</option>
<option value="degraded" {{if eq $s.Status "degraded"}}selected{{end}}>degraded</option>
<option value="down" {{if eq $s.Status "down"}}selected{{end}}>down</option>
<option value="unknown" {{if eq $s.Status "unknown"}}selected{{end}}>unknown</option>
</select>
</td>
<td>
<input type="text" name="note_{{$i}}" value="{{$s.Note}}" placeholder="Optional note&hellip;" class="status-note-input">
</td>
</tr>
{{end}}
</tbody>
</table>
<div class="editor-footer">
<button type="submit" class="btn">Save</button>
{{if .Page.LastChecked}}
<span class="status-last-checked">Last auto-checked: {{.Page.LastChecked.Format "2006-01-02 15:04 UTC"}}</span>
{{end}}
</div>
</form>
{{end}}
</div>
{{end}}

View File

@@ -4,17 +4,17 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{block "title" .}}Ridgway Systems{{end}}</title>
<meta name="description" content="{{block "meta-desc" .}}A homelab built on OpenBSD from firewall to git server.{{end}}">
<meta name="description" content="{{block "meta-desc" .}}A homelab built on FreeBSD from firewall to git server.{{end}}">
<!-- OpenGraph -->
<meta property="og:site_name" content="Ridgway Systems">
<meta property="og:title" content="{{block "og-title" .}}Ridgway Systems{{end}}">
<meta property="og:description" content="{{block "og-desc" .}}A homelab built on OpenBSD from firewall to git server.{{end}}">
<meta property="og:description" content="{{block "og-desc" .}}A homelab built on FreeBSD from firewall to git server.{{end}}">
<meta property="og:type" content="{{block "og-type" .}}website{{end}}">
<meta property="og:url" content="{{block "og-url" .}}https://ridgwaysystems.org{{end}}">
<!-- Twitter/X card -->
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="{{block "tw-title" .}}Ridgway Systems{{end}}">
<meta name="twitter:description" content="{{block "tw-desc" .}}A homelab built on OpenBSD from firewall to git server.{{end}}">
<meta name="twitter:description" content="{{block "tw-desc" .}}A homelab built on FreeBSD from firewall to git server.{{end}}">
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<meta property="og:image" content="{{block "og-image" .}}https://ridgwaysystems.org/static/img/avatar.svg{{end}}">
<meta name="twitter:image" content="{{block "tw-image" .}}https://ridgwaysystems.org/static/img/avatar.svg{{end}}">
@@ -28,6 +28,7 @@
<a href="/" class="nav-brand">ridgwaysystems.org</a>
<ul class="nav-links">
<li><a href="/blog">blog</a></li>
<li><a href="/changelog">changelog</a></li>
<li><a href="/infrastructure">infrastructure</a></li>
<li><a href="/status">status</a></li>
<li><a href="/about">about</a></li>
@@ -36,14 +37,21 @@
</nav>
</header>
{{if .Banner}}
<div class="banner banner-{{.Banner.Level}}">
<a href="/status">{{.Banner.Message}} View status page &rarr;</a>
</div>
{{end}}
<main class="main-content">
{{block "content" .}}{{end}}
{{block "content" .Inner}}{{end}}
</main>
<footer class="site-footer">
<p>
<a href="/">ridgwaysystems.org</a> &mdash;
running OpenBSD &mdash;
running FreeBSD &mdash;
<a href="/changelog">changelog</a> &mdash;
<a href="/blog/feed.xml">RSS</a> &mdash;
<a href="https://git.ridgwaysystems.org">gitea</a> &mdash;
<a href="/hire">hire me</a>

View File

@@ -1,10 +1,10 @@
{{define "title"}}Build Log &mdash; Ridgway Systems{{end}}
{{define "meta-desc"}}OpenBSD homelab build log — documenting decisions, problems, and solutions.{{end}}
{{define "meta-desc"}}FreeBSD homelab build log — documenting decisions, problems, and solutions.{{end}}
{{define "content"}}
<div class="page-header">
<h1>Build Log</h1>
<p class="page-desc">Documenting the OpenBSD homelab migration: what was built, how, and why.</p>
<p class="page-desc">Documenting the FreeBSD homelab migration: what was built, how, and why.</p>
</div>
<div class="blog-controls">

28
templates/changelog.html Normal file
View File

@@ -0,0 +1,28 @@
{{define "title"}}Changelog &mdash; Ridgway Systems{{end}}
{{define "meta-desc"}}Infrastructure changelog — a running log of hardware, network, software, and migration changes.{{end}}
{{define "content"}}
<div class="page-header">
<h1>Changelog</h1>
<p class="page-desc">A running log of infrastructure changes.</p>
</div>
{{if and .Log .Log.Entries}}
<div class="changelog-list">
{{range .Log.Entries}}
<div class="changelog-entry">
<div class="changelog-meta">
<time class="changelog-date">{{.Date}}</time>
<span class="changelog-category changelog-category-{{.Category}}">{{.Category}}</span>
</div>
<div class="changelog-body">
<h3 class="changelog-title">{{.Title}}</h3>
{{if .Description}}<p class="changelog-desc">{{.Description}}</p>{{end}}
</div>
</div>
{{end}}
</div>
{{else}}
<p class="empty-state">No changelog entries yet.</p>
{{end}}
{{end}}

View File

@@ -8,9 +8,9 @@
<div class="hire-intro">
<h1>Work With Me</h1>
<p class="hire-tagline">Infrastructure that actually works. On OpenBSD, Linux, or wherever the job takes it.</p>
<p class="hire-tagline">Infrastructure that actually works. On FreeBSD, Linux, or wherever the job takes it.</p>
<p>I'm Blake Ridgway &mdash; a Site Reliability Engineer based in Enid, Oklahoma with experience across cloud infrastructure, on-prem networks, security hardening, and automation. I've built policy-as-code firewall frameworks, managed Kubernetes workloads at a fintech startup, designed WAN monitoring systems, and I'm currently running SRE on Azure at a cloud-native shop.</p>
<p>This site runs on a self-hosted OpenBSD server in my homelab. That's not a gimmick &mdash; it's how I approach every system I touch.</p>
<p>This site runs on a self-hosted FreeBSD server in my homelab. That's not a gimmick &mdash; it's how I approach every system I touch.</p>
<p><a href="/resume">View my full resume &rarr;</a></p>
</div>
@@ -22,7 +22,7 @@
<p>pf, iptables, VLANs, VPNs, BGP/OSPF, network segmentation, zero trust architecture.</p>
</div>
<div class="service-card">
<h3>Linux &amp; OpenBSD</h3>
<h3>Linux &amp; FreeBSD</h3>
<p>System hardening, service configuration, performance tuning, and ongoing administration.</p>
</div>
<div class="service-card">
@@ -51,7 +51,7 @@
<section class="subscribe-section">
<h2>Stay updated</h2>
<p>Occasional posts on OpenBSD, homelab builds, and infrastructure work. No spam.</p>
<p>Occasional posts on FreeBSD, homelab builds, and infrastructure work. No spam.</p>
<form method="POST" action="/newsletter" class="subscribe-form">
<div class="hp-field" aria-hidden="true">
<label for="url">URL</label>

View File

@@ -3,9 +3,9 @@
{{define "content"}}
<section class="hero">
<h1>Ridgway Systems</h1>
<p class="tagline">A homelab built on OpenBSD &mdash; from firewall to git server.</p>
<p class="tagline">A homelab built on FreeBSD &mdash; from firewall to git server.</p>
<p class="hero-desc">
A self-hosted infrastructure project running entirely on OpenBSD. This site documents the build:
A self-hosted infrastructure project running entirely on FreeBSD. This site documents the build:
hardware decisions, network configuration, service deployments, and everything learned along the way.
</p>
<div class="hero-links">
@@ -21,7 +21,7 @@
<div class="infra-card">
<div class="infra-host">SuperMicro 1U</div>
<div class="infra-role">Firewall &bull; Router &bull; VPN &bull; Reverse Proxy</div>
<div class="infra-detail">OpenBSD &bull; pf &bull; relayd &bull; WireGuard</div>
<div class="infra-detail">FreeBSD &bull; pf &bull; relayd &bull; WireGuard</div>
</div>
<div class="infra-card">
<div class="infra-host">Dell R720</div>

View File

@@ -1,5 +1,5 @@
{{define "title"}}Infrastructure &mdash; Ridgway Systems{{end}}
{{define "meta-desc"}}Hardware inventory and network diagram for the Ridgway Systems OpenBSD homelab.{{end}}
{{define "meta-desc"}}Hardware inventory and network diagram for the Ridgway Systems FreeBSD homelab.{{end}}
{{define "content"}}
<div class="page-header">
@@ -22,19 +22,19 @@
<tr>
<td class="hw-name">fw01</td>
<td>SuperMicro 1U<br><span class="hw-spec">E3-1230v2 &bull; 16 GB RAM</span></td>
<td>OpenBSD</td>
<td>OPNsense 26.1</td>
<td>Firewall, router, VPN, reverse proxy<br><span class="hw-spec">pf &bull; relayd &bull; WireGuard &bull; unbound</span></td>
</tr>
<tr>
<td class="hw-name">srv01</td>
<td>Dell R720<br><span class="hw-spec">Xeon E5-2620 &bull; 96 GB RAM</span></td>
<td>OpenBSD</td>
<td>FreeBSD</td>
<td>Primary server<br><span class="hw-spec">Gitea &bull; httpd &bull; OpenSMTPD &bull; Prometheus &bull; Grafana &bull; Matrix</span></td>
</tr>
<tr>
<td class="hw-name">srv02</td>
<td>Dell R710<br><span class="hw-spec">Xeon X5560 &bull; 288 GB RAM</span></td>
<td>OpenBSD + Linux VMs</td>
<td>FreeBSD + Linux VMs</td>
<td>Backup, game servers<br><span class="hw-spec">nsd &bull; qemu &bull; Jellyfin &bull; secondary DNS</span></td>
</tr>
<tr>
@@ -43,46 +43,164 @@
<td>Fedora Linux 43</td>
<td>Daily driver, Ansible control node<br><span class="hw-spec">Development &bull; playbook management</span></td>
</tr>
<tr>
<td class="hw-name">ws02</td>
<td>Lenovo ThinkPad T14s<br><span class="hw-spec">AMD Ryzen Pro 5 8640HS &bull; 32 GB RAM</span></td>
<td>Fedora Linux 43</td>
<td>Mobile daily driver<br><span class="hw-spec">Development &bull; remote work</span></td>
</tr>
<tr>
<td class="hw-name">ws03</td>
<td>System76 Lemur Pro<br><span class="hw-spec">Intel Core i7-10210U &bull; 16 GB RAM</span></td>
<td>FreeBSD</td>
<td>FreeBSD testing machine<br><span class="hw-spec">Development &bull; testing</span></td>
</tr>
</tbody>
</table>
</section>
<section class="infra-section">
<h2>Network Diagram</h2>
<pre class="network-diagram">
Internet
|
[WAN interface]
|
+=================+
| fw01 | SuperMicro 1U
| OpenBSD | pf firewall
| relayd | WireGuard VPN
+=====+===========+
|
+-- [Management VLAN 1] -- fw01, switches, OOB
|
+-- [Servers VLAN 10] -- srv01, srv02
| |
| +-- srv01 (R720)
| | httpd / relayd (external traffic routed here)
| | Gitea, mail, monitoring, Matrix
| |
| +-- srv02 (R710)
| DNS (nsd), Jellyfin, game VMs
|
+-- [Desktop VLAN 20] -- ws01, personal devices
|
+-- [Game VLAN 30] -- game clients, gaming VMs
|
+-- [IoT/Guest VLAN 40] -- untrusted devices
<div class="netmap-wrap">
<svg class="netmap" viewBox="0 0 720 430" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Network topology diagram">
External traffic flow:
Internet --&gt; fw01 (relayd) --&gt; srv01 (httpd/app)
<!-- Internet -->
<g class="netmap-node" data-tooltip="Public internet — WAN uplink">
<rect x="285" y="10" width="150" height="38" rx="4" class="netmap-box netmap-box-internet"/>
<text x="360" y="34" class="netmap-label">Internet</text>
</g>
VPN:
WireGuard on fw01 --&gt; routed to server VLANs
</pre>
<!-- Internet → fw01 -->
<line x1="360" y1="48" x2="360" y2="88" class="netmap-line"/>
<!-- fw01 -->
<g class="netmap-node" data-tooltip="fw01 · SuperMicro 1U&#10;OPNsense 26.1&#10;&#10;Services: pf · relayd · WireGuard · unbound">
<rect x="220" y="88" width="280" height="44" rx="4" class="netmap-box netmap-box-fw"/>
<text x="360" y="107" class="netmap-label">fw01</text>
<text x="360" y="123" class="netmap-sublabel">OPNsense 26.1 · SuperMicro 1U</text>
</g>
<!-- fw01 → trunk line -->
<line x1="360" y1="132" x2="360" y2="160" class="netmap-line"/>
<!-- horizontal trunk -->
<line x1="60" y1="160" x2="660" y2="160" class="netmap-line"/>
<!-- VLAN drop lines -->
<line x1="60" y1="160" x2="60" y2="185" class="netmap-line"/>
<line x1="195" y1="160" x2="195" y2="185" class="netmap-line"/>
<line x1="360" y1="160" x2="360" y2="185" class="netmap-line"/>
<line x1="525" y1="160" x2="525" y2="185" class="netmap-line"/>
<line x1="660" y1="160" x2="660" y2="185" class="netmap-line"/>
<!-- VLAN 1 — Mgmt -->
<g class="netmap-node" data-tooltip="VLAN 1 · Management&#10;10.0.1.0/24&#10;&#10;Switches, OOB, firewall mgmt">
<rect x="10" y="185" width="100" height="38" rx="4" class="netmap-box netmap-box-vlan"/>
<text x="60" y="200" class="netmap-label netmap-label-sm">Mgmt</text>
<text x="60" y="215" class="netmap-sublabel">VLAN 1</text>
</g>
<!-- VLAN 10 — Servers -->
<g class="netmap-node" data-tooltip="VLAN 10 · Servers&#10;10.0.10.0/24&#10;&#10;srv01, srv02">
<rect x="145" y="185" width="100" height="38" rx="4" class="netmap-box netmap-box-vlan"/>
<text x="195" y="200" class="netmap-label netmap-label-sm">Servers</text>
<text x="195" y="215" class="netmap-sublabel">VLAN 10</text>
</g>
<!-- VLAN 20 — Desktop -->
<g class="netmap-node" data-tooltip="VLAN 20 · Desktop&#10;10.0.20.0/24&#10;&#10;ws01, personal devices">
<rect x="310" y="185" width="100" height="38" rx="4" class="netmap-box netmap-box-vlan"/>
<text x="360" y="200" class="netmap-label netmap-label-sm">Desktop</text>
<text x="360" y="215" class="netmap-sublabel">VLAN 20</text>
</g>
<!-- VLAN 30 — Game -->
<g class="netmap-node" data-tooltip="VLAN 30 · Game&#10;10.0.30.0/24&#10;&#10;Game clients, gaming VMs">
<rect x="475" y="185" width="100" height="38" rx="4" class="netmap-box netmap-box-vlan"/>
<text x="525" y="200" class="netmap-label netmap-label-sm">Game</text>
<text x="525" y="215" class="netmap-sublabel">VLAN 30</text>
</g>
<!-- VLAN 40 — IoT -->
<g class="netmap-node" data-tooltip="VLAN 40 · IoT/Guest&#10;10.0.40.0/24&#10;&#10;Untrusted / isolated devices">
<rect x="610" y="185" width="100" height="38" rx="4" class="netmap-box netmap-box-vlan"/>
<text x="660" y="200" class="netmap-label netmap-label-sm">IoT/Guest</text>
<text x="660" y="215" class="netmap-sublabel">VLAN 40</text>
</g>
<!-- Servers VLAN → hosts -->
<line x1="170" y1="223" x2="170" y2="255" class="netmap-line"/>
<line x1="170" y1="255" x2="145" y2="255" class="netmap-line"/>
<line x1="170" y1="255" x2="220" y2="255" class="netmap-line"/>
<line x1="145" y1="255" x2="145" y2="275" class="netmap-line"/>
<line x1="220" y1="255" x2="220" y2="275" class="netmap-line"/>
<!-- srv01 -->
<g class="netmap-node" data-tooltip="srv01 · Dell R720&#10;Xeon E5-2620 · 96 GB RAM&#10;FreeBSD&#10;&#10;httpd · Gitea · OpenSMTPD&#10;Prometheus · Grafana · Matrix">
<rect x="90" y="275" width="110" height="44" rx="4" class="netmap-box netmap-box-host"/>
<text x="145" y="293" class="netmap-label netmap-label-sm">srv01</text>
<text x="145" y="308" class="netmap-sublabel">Dell R720 · FreeBSD</text>
</g>
<!-- srv02 -->
<g class="netmap-node" data-tooltip="srv02 · Dell R710&#10;Xeon X5560 · 288 GB RAM&#10;FreeBSD + Linux VMs&#10;&#10;nsd · qemu · Jellyfin&#10;Game servers">
<rect x="165" y="275" width="110" height="44" rx="4" class="netmap-box netmap-box-host"/>
<text x="220" y="293" class="netmap-label netmap-label-sm">srv02</text>
<text x="220" y="308" class="netmap-sublabel">Dell R710 · FreeBSD</text>
</g>
<!-- Desktop VLAN → hosts -->
<line x1="360" y1="223" x2="360" y2="255" class="netmap-line"/>
<line x1="300" y1="255" x2="420" y2="255" class="netmap-line"/>
<line x1="300" y1="255" x2="300" y2="275" class="netmap-line"/>
<line x1="360" y1="255" x2="360" y2="275" class="netmap-line"/>
<line x1="420" y1="255" x2="420" y2="275" class="netmap-line"/>
<!-- ws01 -->
<g class="netmap-node" data-tooltip="ws01 · Desktop&#10;Intel Core i9-12900K · 64 GB RAM&#10;Fedora Linux 43&#10;&#10;Daily driver · Ansible control node">
<rect x="248" y="275" width="104" height="44" rx="4" class="netmap-box netmap-box-host"/>
<text x="300" y="293" class="netmap-label netmap-label-sm">ws01</text>
<text x="300" y="308" class="netmap-sublabel">Desktop · Fedora</text>
</g>
<!-- ws02 -->
<g class="netmap-node" data-tooltip="ws02 · Lenovo ThinkPad T14s&#10;AMD Ryzen Pro 5 8640HS · 32 GB RAM&#10;Fedora Linux 43&#10;&#10;Mobile daily driver">
<rect x="308" y="275" width="104" height="44" rx="4" class="netmap-box netmap-box-host"/>
<text x="360" y="293" class="netmap-label netmap-label-sm">ws02</text>
<text x="360" y="308" class="netmap-sublabel">ThinkPad T14s</text>
</g>
<!-- ws03 -->
<g class="netmap-node" data-tooltip="ws03 · System76 Lemur Pro&#10;Intel Core i7-10210U · 16 GB RAM&#10;FreeBSD&#10;&#10;FreeBSD testing machine">
<rect x="368" y="275" width="104" height="44" rx="4" class="netmap-box netmap-box-host"/>
<text x="420" y="293" class="netmap-label netmap-label-sm">ws03</text>
<text x="420" y="308" class="netmap-sublabel">Lemur Pro · FreeBSD</text>
</g>
</svg>
<!-- Tooltip element -->
<div class="netmap-tooltip" id="netmap-tooltip"></div>
</div>
<script>
(function() {
var tip = document.getElementById('netmap-tooltip');
document.querySelectorAll('.netmap-node').forEach(function(node) {
node.addEventListener('mouseenter', function(e) {
var text = node.getAttribute('data-tooltip') || '';
tip.textContent = text;
tip.classList.add('visible');
});
node.addEventListener('mousemove', function(e) {
tip.style.left = (e.pageX + 14) + 'px';
tip.style.top = (e.pageY - 10) + 'px';
});
node.addEventListener('mouseleave', function() {
tip.classList.remove('visible');
});
});
})();
</script>
</section>
<section class="infra-section">

View File

@@ -1,11 +1,11 @@
{{define "title"}}{{.Title}} &mdash; Ridgway Systems{{end}}
{{define "meta-desc"}}{{.Description}}{{end}}
{{define "og-title"}}{{.Title}} &mdash; Ridgway Systems{{end}}
{{define "og-desc"}}{{.Description}}{{end}}
{{define "title"}}{{.Inner.Title}} &mdash; Ridgway Systems{{end}}
{{define "meta-desc"}}{{.Inner.Description}}{{end}}
{{define "og-title"}}{{.Inner.Title}} &mdash; Ridgway Systems{{end}}
{{define "og-desc"}}{{.Inner.Description}}{{end}}
{{define "og-type"}}article{{end}}
{{define "og-url"}}https://ridgwaysystems.org/blog/{{.Slug}}{{end}}
{{define "tw-title"}}{{.Title}} &mdash; Ridgway Systems{{end}}
{{define "tw-desc"}}{{.Description}}{{end}}
{{define "og-url"}}https://ridgwaysystems.org/blog/{{.Inner.Slug}}{{end}}
{{define "tw-title"}}{{.Inner.Title}} &mdash; Ridgway Systems{{end}}
{{define "tw-desc"}}{{.Inner.Description}}{{end}}
{{define "content"}}
<article class="post">

View File

@@ -15,11 +15,11 @@
<h2 class="project-title">ridgwaysystems.org</h2>
<div class="project-tags">
<span class="tag">Go</span>
<span class="tag">OpenBSD</span>
<span class="tag">FreeBSD</span>
<span class="tag">self-hosted</span>
</div>
</div>
<p>This site. A single Go binary serving a blog, status page, hire page, and admin panel &mdash; no database, no Docker, no external dependencies at runtime. Flat Markdown files on disk, HMAC-signed sessions, chroma syntax highlighting. Deployed on OpenBSD behind relayd. The build log covers the whole thing.</p>
<p>This site. A single Go binary serving a blog, status page, hire page, and admin panel &mdash; no database, no Docker, no external dependencies at runtime. Flat Markdown files on disk, HMAC-signed sessions, chroma syntax highlighting. Deployed on FreeBSD behind nginx. The build log covers the whole thing.</p>
<div class="project-links">
<a href="/blog">Build log &rarr;</a>
<a href="https://git.ridgwaysystems.org">Source &rarr;</a>
@@ -35,7 +35,7 @@
<span class="tag">security</span>
</div>
</div>
<p>A policy-as-code system for managing pf firewall rules across multiple OpenBSD hosts. Rules defined in structured configuration, rendered to pf.conf via templating, with automated geo-location blocking and rule validation before deployment. Deployed at Triangle Insurance to manage ~200 rules across three firewall segments.</p>
<p>A policy-as-code system for managing pf firewall rules across multiple FreeBSD hosts. Rules defined in structured configuration, rendered to pf.conf via templating, with automated geo-location blocking and rule validation before deployment. Deployed at Triangle Insurance to manage ~200 rules across three firewall segments.</p>
<div class="project-links">
<a href="/blog/pf-vlans">Related post &rarr;</a>
</div>
@@ -57,13 +57,13 @@
<div class="project-header">
<h2 class="project-title">Homelab Infrastructure</h2>
<div class="project-tags">
<span class="tag">OpenBSD</span>
<span class="tag">FreeBSD</span>
<span class="tag">Ansible</span>
<span class="tag">Terraform</span>
<span class="tag">homelab</span>
</div>
</div>
<p>The homelab: fw01 running OpenBSD with pf and WireGuard, two Dell rack servers, VLAN-segmented network (management, servers, IoT, guest), self-hosted Gitea, Matrix, Jellyfin, Prometheus, and Grafana. Fully documented, IaC'd where possible, and used as a test bed before anything touches production.</p>
<p>The homelab: fw01 running OPNsense with pf and WireGuard, two Dell rack servers, VLAN-segmented network (management, servers, IoT, guest), self-hosted Gitea, Matrix, Jellyfin, Prometheus, and Grafana. Fully documented, IaC'd where possible, and used as a test bed before anything touches production.</p>
<div class="project-links">
<a href="/infrastructure">Infrastructure diagram &rarr;</a>
<a href="/uses">What I run &rarr;</a>

View File

@@ -135,13 +135,41 @@
<dd>Prometheus, Grafana, Nagios, Splunk, ELK Stack, SIEM integration, Azure Monitor</dd>
<dt>Platforms</dt>
<dd>Linux, OpenBSD, VMware, Hyper-V, Proxmox, Citrix, Docker, Kubernetes, Argo CD</dd>
<dd>Linux, FreeBSD, VMware, Hyper-V, Proxmox, Citrix, Docker, Kubernetes, Argo CD</dd>
</dl>
</div>
</section>
<section class="resume-section">
<h2>References</h2>
<div class="resume-reference">
<blockquote>
<p>Working alongside Blake has been a pleasure from day one. When he joined our team, our network infrastructure was in need of serious attention, and he wasted no time bringing everything up to current standards with precision and purpose.</p>
<p>Beyond the foundational work, he took the initiative to implement comprehensive monitoring solutions that gave our entire team far greater visibility and confidence in our systems.</p>
<p>What truly sets Blake apart is how he stays continuously plugged in to the evolving threat landscape &mdash; always aware of emerging vulnerabilities, new attack vectors, and the latest tools and best practices to address them. This proactive awareness means our network is never just where it needs to be today, but prepared for what&rsquo;s coming tomorrow.</p>
<p>He approaches every challenge with a sharp, innovative mindset that consistently produces solutions we hadn&rsquo;t even considered. He is exactly the kind of colleague you want in your corner when the stakes are high.</p>
</blockquote>
<cite class="resume-reference-cite">&mdash; Austin M. &mdash; Triangle Insurance Company</cite>
</div>
<div class="resume-reference">
<blockquote>
<p>Blake was an invaluable resource for our team. From the jump, he was focused on streamlining processes and automating tasks where applicable. He not only helped in the team we worked on directly, but he worked across multiple teams to make sure there were streamlined processes there, as well. Outside of the progress he made in the team, he was on the forefront of recommending network and security protocols and enhancements for the team and the company as a whole. </p>
<p>Not only was Blake a superb addition to the team on a technical level, his ability to relate to and get along with everyone made him an invaluable asset in that department as well. Whether it was coworkers or it was the clients spanning multiple states, he was able to relate to them all.</p>
</blockquote>
<cite class="resume-reference-cite">&mdash; Nic F. &mdash; BankOnIT</cite>
</div>
<div class="resume-reference">
<blockquote>
<p>Ive had the privilege of working alongside Blake, and I can confidently say he is one of the most exceptional engineers Ive encountered. His expertise across DevOps, systems administration, and software engineering is truly top tier. He consistently demonstrates a depth of knowledge and technical capability that sets him apart.</p>
<p>What makes Blake stand out even more is how he pairs that skill with relentless drive, strong work ethic, and a genuinely positive attitude. He doesnt just solve problems, he elevates the people and teams around him. Working with Blake raises the bar for everyone involved.</p>
<p>Any organization or team that has Blake contributing is gaining a top 1% individual, not just technically, but as a professional and as a person.</p>
</blockquote>
<cite class="resume-reference-cite">&mdash; Bryan B. &mdash; Sr. Director of Software Engineering &mdash; Prime Trust</cite>
</div>
</section>
</div>
{{end}}

View File

@@ -12,14 +12,29 @@
{{if .Page.Services}}
<ul class="status-list">
{{range .Page.Services}}
{{$h := index $.History .Name}}
<li class="status-item status-{{.Status}}">
<span class="status-indicator" aria-label="{{.Status}}"></span>
<div class="status-info">
<span class="status-name">{{.Name}}</span>
{{if .Description}}<span class="status-desc">{{.Description}}</span>{{end}}
{{if .Note}}<span class="status-note">{{.Note}}</span>{{end}}
<div class="status-top">
<span class="status-indicator" aria-label="{{.Status}}"></span>
<div class="status-info">
<span class="status-name">{{.Name}}</span>
{{if .Description}}<span class="status-desc">{{.Description}}</span>{{end}}
{{if .Note}}<span class="status-note">{{.Note}}</span>{{end}}
</div>
<span class="status-badge status-badge-{{.Status}}">{{.Status}}</span>
</div>
<div class="uptime-bar-row">
<div class="uptime-bar">
{{range $h.Blocks}}
<span class="uptime-block uptime-block-{{.Status}}" title="{{.Date}}"></span>
{{end}}
</div>
{{if ge $h.UptimePct 0.0}}
<span class="uptime-pct">{{printf "%.1f" $h.UptimePct}}%</span>
{{else}}
<span class="uptime-pct uptime-pct-none">no data</span>
{{end}}
</div>
<span class="status-badge status-badge-{{.Status}}">{{.Status}}</span>
</li>
{{end}}
</ul>

View File

@@ -16,7 +16,7 @@
<span class="uses-name">fw01</span>
<span class="uses-role">Firewall / Router</span>
</div>
<p>SuperMicro 1U, Intel E3-1230v2, 16GB ECC RAM. Running OpenBSD. Handles all pf firewall rules, VLANs, WireGuard VPN, unbound DNS, and relayd reverse proxy. The critical piece everything else depends on.</p>
<p>SuperMicro 1U, Intel E3-1230v2, 16GB ECC RAM. Running OPNsense (FreeBSD-based). Handles all pf firewall rules, VLANs, WireGuard VPN, unbound DNS, and reverse proxy. The critical piece everything else depends on.</p>
</div>
<div class="uses-item">
@@ -47,8 +47,9 @@
<section class="uses-section">
<h2>Operating Systems</h2>
<ul class="uses-list">
<li><strong>OpenBSD</strong> &mdash; fw01, this web server. Chosen for its security defaults, pf, and the fact that it does exactly what it says on the tin.</li>
<li><strong>AlmaLinux / Rocky</strong> &mdash; srv01, srv02. RHEL-compatible for production workloads where SELinux and systemd are expected.</li>
<li><strong>FreeBSD</strong> &mdash; srv01, srv02. Chosen for ZFS, jails, pf, and a clean coherent base system.</li>
<li><strong>OPNsense</strong> &mdash; fw01. FreeBSD-based firewall/router OS. pf, WireGuard, unbound all built in.</li>
<li><strong>AlmaLinux / Rocky</strong> &mdash; Linux VMs on srv02. RHEL-compatible for workloads where SELinux and systemd are expected.</li>
<li><strong>Fedora</strong> &mdash; Workstation. Stays close to bleeding-edge tooling without being Arch.</li>
</ul>
</section>
@@ -56,11 +57,11 @@
<section class="uses-section">
<h2>Networking</h2>
<ul class="uses-list">
<li><strong>pf</strong> &mdash; OpenBSD packet filter. VLANs, NAT, geo-blocking, antispoof. The whole reason fw01 runs OpenBSD.</li>
<li><strong>pf</strong> &mdash; FreeBSD/OPNsense packet filter. VLANs, NAT, geo-blocking, antispoof. The whole reason fw01 runs what it does.</li>
<li><strong>WireGuard</strong> &mdash; VPN for remote access. Simple, fast, auditable.</li>
<li><strong>unbound</strong> &mdash; Recursive DNS resolver on fw01. Validates DNSSEC, blocks ad/tracking domains.</li>
<li><strong>nsd</strong> &mdash; Authoritative DNS on srv02 for the ridgwaysystems.org zone.</li>
<li><strong>relayd</strong> &mdash; OpenBSD reverse proxy in front of this site and internal services.</li>
<li><strong>nginx</strong> &mdash; Reverse proxy in front of this site and internal services.</li>
</ul>
</section>
@@ -81,7 +82,7 @@
<li><strong>VS Code</strong> &mdash; Primary editor. Remote SSH extension makes working directly on servers seamless.</li>
<li><strong>Go</strong> &mdash; Preferred language for infrastructure tooling and this site. Fast to compile, easy to deploy a single binary.</li>
<li><strong>Python</strong> &mdash; Scripting, automation, quick data processing.</li>
<li><strong>Bash / ksh</strong> &mdash; Bash on Linux, ksh on OpenBSD. Shell scripts for anything that doesn't need to outlast the week.</li>
<li><strong>Bash</strong> &mdash; Shell scripts for anything that doesn't need to outlast the week.</li>
<li><strong>tmux</strong> &mdash; Terminal multiplexer. Multiple panes across multiple SSH sessions, always.</li>
</ul>
</section>