Lots of changes to the website
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
86
content/posts/openbsd-to-opnsense.md
Normal file
86
content/posts/openbsd-to-opnsense.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
1
data/changelog.json
Normal file
@@ -0,0 +1 @@
|
||||
{"entries": []}
|
||||
@@ -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
114
data/uptime.json
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
111
internal/changelog/changelog.go
Normal file
111
internal/changelog/changelog.go
Normal 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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
142
internal/uptime/uptime.go
Normal 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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 740–1180, y span 390–692 -->
|
||||
<g filter="url(#glow)">
|
||||
|
||||
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
@@ -1,5 +1,5 @@
|
||||
{{define "title"}}About — 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>
|
||||
|
||||
46
templates/admin/changelog-editor.html
Normal file
46
templates/admin/changelog-editor.html
Normal file
@@ -0,0 +1,46 @@
|
||||
{{define "title"}}{{if .IsNew}}New Entry{{else}}Edit Entry{{end}} — 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}}
|
||||
47
templates/admin/changelog.html
Normal file
47
templates/admin/changelog.html
Normal file
@@ -0,0 +1,47 @@
|
||||
{{define "title"}}Changelog — 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}}
|
||||
@@ -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…" 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}}
|
||||
|
||||
@@ -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 →</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> —
|
||||
running OpenBSD —
|
||||
running FreeBSD —
|
||||
<a href="/changelog">changelog</a> —
|
||||
<a href="/blog/feed.xml">RSS</a> —
|
||||
<a href="https://git.ridgwaysystems.org">gitea</a> —
|
||||
<a href="/hire">hire me</a>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{{define "title"}}Build Log — 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
28
templates/changelog.html
Normal file
@@ -0,0 +1,28 @@
|
||||
{{define "title"}}Changelog — 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}}
|
||||
@@ -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 — 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 — 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 — it's how I approach every system I touch.</p>
|
||||
<p><a href="/resume">View my full resume →</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 & OpenBSD</h3>
|
||||
<h3>Linux & 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>
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
{{define "content"}}
|
||||
<section class="hero">
|
||||
<h1>Ridgway Systems</h1>
|
||||
<p class="tagline">A homelab built on OpenBSD — from firewall to git server.</p>
|
||||
<p class="tagline">A homelab built on FreeBSD — 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 • Router • VPN • Reverse Proxy</div>
|
||||
<div class="infra-detail">OpenBSD • pf • relayd • WireGuard</div>
|
||||
<div class="infra-detail">FreeBSD • pf • relayd • WireGuard</div>
|
||||
</div>
|
||||
<div class="infra-card">
|
||||
<div class="infra-host">Dell R720</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{{define "title"}}Infrastructure — 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 • 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 • relayd • WireGuard • unbound</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="hw-name">srv01</td>
|
||||
<td>Dell R720<br><span class="hw-spec">Xeon E5-2620 • 96 GB RAM</span></td>
|
||||
<td>OpenBSD</td>
|
||||
<td>FreeBSD</td>
|
||||
<td>Primary server<br><span class="hw-spec">Gitea • httpd • OpenSMTPD • Prometheus • Grafana • Matrix</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="hw-name">srv02</td>
|
||||
<td>Dell R710<br><span class="hw-spec">Xeon X5560 • 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 • qemu • Jellyfin • 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 • 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 • 32 GB RAM</span></td>
|
||||
<td>Fedora Linux 43</td>
|
||||
<td>Mobile daily driver<br><span class="hw-spec">Development • 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 • 16 GB RAM</span></td>
|
||||
<td>FreeBSD</td>
|
||||
<td>FreeBSD testing machine<br><span class="hw-spec">Development • 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 --> fw01 (relayd) --> 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 --> 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 OPNsense 26.1 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.0.1.0/24 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.0.10.0/24 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.0.20.0/24 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.0.30.0/24 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.0.40.0/24 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 Xeon E5-2620 · 96 GB RAM FreeBSD httpd · Gitea · OpenSMTPD 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 Xeon X5560 · 288 GB RAM FreeBSD + Linux VMs nsd · qemu · Jellyfin 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 Intel Core i9-12900K · 64 GB RAM Fedora Linux 43 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 AMD Ryzen Pro 5 8640HS · 32 GB RAM Fedora Linux 43 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 Intel Core i7-10210U · 16 GB RAM FreeBSD 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">
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{{define "title"}}{{.Title}} — Ridgway Systems{{end}}
|
||||
{{define "meta-desc"}}{{.Description}}{{end}}
|
||||
{{define "og-title"}}{{.Title}} — Ridgway Systems{{end}}
|
||||
{{define "og-desc"}}{{.Description}}{{end}}
|
||||
{{define "title"}}{{.Inner.Title}} — Ridgway Systems{{end}}
|
||||
{{define "meta-desc"}}{{.Inner.Description}}{{end}}
|
||||
{{define "og-title"}}{{.Inner.Title}} — 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}} — Ridgway Systems{{end}}
|
||||
{{define "tw-desc"}}{{.Description}}{{end}}
|
||||
{{define "og-url"}}https://ridgwaysystems.org/blog/{{.Inner.Slug}}{{end}}
|
||||
{{define "tw-title"}}{{.Inner.Title}} — Ridgway Systems{{end}}
|
||||
{{define "tw-desc"}}{{.Inner.Description}}{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<article class="post">
|
||||
|
||||
@@ -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 — 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 — 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 →</a>
|
||||
<a href="https://git.ridgwaysystems.org">Source →</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 →</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 →</a>
|
||||
<a href="/uses">What I run →</a>
|
||||
|
||||
@@ -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 — 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’s coming tomorrow.</p>
|
||||
<p>He approaches every challenge with a sharp, innovative mindset that consistently produces solutions we hadn’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">— Austin M. — 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">— Nic F. — BankOnIT</cite>
|
||||
</div>
|
||||
|
||||
<div class="resume-reference">
|
||||
<blockquote>
|
||||
<p>I’ve had the privilege of working alongside Blake, and I can confidently say he is one of the most exceptional engineers I’ve 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 doesn’t 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">— Bryan B. — Sr. Director of Software Engineering — Prime Trust</cite>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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> — 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> — srv01, srv02. RHEL-compatible for production workloads where SELinux and systemd are expected.</li>
|
||||
<li><strong>FreeBSD</strong> — srv01, srv02. Chosen for ZFS, jails, pf, and a clean coherent base system.</li>
|
||||
<li><strong>OPNsense</strong> — fw01. FreeBSD-based firewall/router OS. pf, WireGuard, unbound all built in.</li>
|
||||
<li><strong>AlmaLinux / Rocky</strong> — Linux VMs on srv02. RHEL-compatible for workloads where SELinux and systemd are expected.</li>
|
||||
<li><strong>Fedora</strong> — 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> — OpenBSD packet filter. VLANs, NAT, geo-blocking, antispoof. The whole reason fw01 runs OpenBSD.</li>
|
||||
<li><strong>pf</strong> — FreeBSD/OPNsense packet filter. VLANs, NAT, geo-blocking, antispoof. The whole reason fw01 runs what it does.</li>
|
||||
<li><strong>WireGuard</strong> — VPN for remote access. Simple, fast, auditable.</li>
|
||||
<li><strong>unbound</strong> — Recursive DNS resolver on fw01. Validates DNSSEC, blocks ad/tracking domains.</li>
|
||||
<li><strong>nsd</strong> — Authoritative DNS on srv02 for the ridgwaysystems.org zone.</li>
|
||||
<li><strong>relayd</strong> — OpenBSD reverse proxy in front of this site and internal services.</li>
|
||||
<li><strong>nginx</strong> — Reverse proxy in front of this site and internal services.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -81,7 +82,7 @@
|
||||
<li><strong>VS Code</strong> — Primary editor. Remote SSH extension makes working directly on servers seamless.</li>
|
||||
<li><strong>Go</strong> — Preferred language for infrastructure tooling and this site. Fast to compile, easy to deploy a single binary.</li>
|
||||
<li><strong>Python</strong> — Scripting, automation, quick data processing.</li>
|
||||
<li><strong>Bash / ksh</strong> — Bash on Linux, ksh on OpenBSD. Shell scripts for anything that doesn't need to outlast the week.</li>
|
||||
<li><strong>Bash</strong> — Shell scripts for anything that doesn't need to outlast the week.</li>
|
||||
<li><strong>tmux</strong> — Terminal multiplexer. Multiple panes across multiple SSH sessions, always.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user