first commit

This commit is contained in:
Blake Ridgway
2026-03-07 21:16:51 -06:00
parent 21bd542469
commit 03fcf37beb
33 changed files with 3532 additions and 0 deletions

37
.env.example Normal file
View File

@@ -0,0 +1,37 @@
# Ridgway Systems website configuration
# Copy to .env and fill in values. Never commit .env to version control.
# ------------------------------------------------------------------
# Server
# ------------------------------------------------------------------
# Port to listen on (default: 8080)
PORT=8080
# Public URL of the site (no trailing slash)
SITE_URL=https://ridgwaysystems.org
# Path to content directory (default: content)
CONTENT_DIR=content
# Path to data directory containing status.json (default: data)
DATA_DIR=data
# Set to "1" to enable development mode (template reloading on each request)
DEV=0
# ------------------------------------------------------------------
# Admin authentication
# ------------------------------------------------------------------
# bcrypt hash of the admin password.
# Generate with: htpasswd -bnBC 12 "" yourpassword | tr -d ':\n'
# Or in Go:
# import "golang.org/x/crypto/bcrypt"
# hash, _ := bcrypt.GenerateFromPassword([]byte("yourpassword"), 12)
# fmt.Println(string(hash))
ADMIN_PASSWORD_HASH=$2a$12$examplehashgoeshere...
# Secret key for signing session cookies. Use a long random string.
# Generate with: openssl rand -hex 32
SESSION_SECRET=change-me-use-openssl-rand-hex-32

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Binary
website
/website
# Environment — never commit secrets
.env
# Generated syntax CSS (regenerated at startup)
static/css/syntax.css
# Uploaded images (large/binary files)
static/uploads/
# Editor / OS
.DS_Store
*.swp
*.swo
*~
.vscode/
.idea/
# Go build cache
*.test
*.out

76
Makefile Normal file
View File

@@ -0,0 +1,76 @@
BINARY = website
MODULE = ridgwaysystems.org/website
CMD = ./cmd/server
# Default target
.PHONY: build
build:
go build -o $(BINARY) $(CMD)
# Run locally in dev mode (template hot-reloading)
.PHONY: run
run:
DEV=1 PORT=8080 go run $(CMD)
# Run with a .env file
.PHONY: run-env
run-env:
@test -f .env || (echo "No .env file found. Copy .env.example and fill it in." && exit 1)
env $$(cat .env | grep -v '^\#' | xargs) go run $(CMD)
# Cross-compile for OpenBSD amd64
.PHONY: cross
cross:
GOOS=openbsd GOARCH=amd64 go build -ldflags="-s -w" -o $(BINARY)-openbsd-amd64 $(CMD)
# Generate an admin password hash
.PHONY: genhash
genhash:
@read -p "Password: " pw && go run ./tools/genhash "$$pw"
# Download / tidy dependencies
.PHONY: tidy
tidy:
go mod tidy
# Vet and lint
.PHONY: vet
vet:
go vet ./...
# Run tests (when there are any)
.PHONY: test
test:
go test ./...
# Clean build artifacts
.PHONY: clean
clean:
rm -f $(BINARY) $(BINARY)-openbsd-amd64
rm -f static/css/syntax.css
# Upload static files and binary to OpenBSD server
# Set DEPLOY_HOST to your server (e.g. srv01 or user@10.0.10.10)
DEPLOY_HOST ?= srv01
DEPLOY_DIR ?= /var/www/ridgwaysystems
.PHONY: deploy
deploy: cross
scp $(BINARY)-openbsd-amd64 $(DEPLOY_HOST):/usr/local/bin/$(BINARY)
rsync -av --delete templates/ $(DEPLOY_HOST):$(DEPLOY_DIR)/templates/
rsync -av --delete static/ $(DEPLOY_HOST):$(DEPLOY_DIR)/static/
rsync -av content/ $(DEPLOY_HOST):$(DEPLOY_DIR)/content/
ssh $(DEPLOY_HOST) rcctl restart $(BINARY)
.PHONY: help
help:
@echo "Targets:"
@echo " build Build binary for current OS"
@echo " run Run locally in dev mode"
@echo " run-env Run with .env file"
@echo " cross Cross-compile for OpenBSD amd64"
@echo " genhash Generate bcrypt hash for admin password"
@echo " tidy go mod tidy"
@echo " vet go vet"
@echo " deploy Cross-compile and deploy to OpenBSD server"
@echo " clean Remove build artifacts"

90
cmd/server/main.go Normal file
View File

@@ -0,0 +1,90 @@
package main
import (
"bytes"
"log"
"net/http"
"os"
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
"github.com/alecthomas/chroma/v2/styles"
"ridgwaysystems.org/website/internal/blog"
"ridgwaysystems.org/website/internal/handler"
)
func main() {
port := getenv("PORT", "8080")
contentDir := getenv("CONTENT_DIR", "content")
dataDir := getenv("DATA_DIR", "data")
// Generate syntax.css at startup (light + dark via media query)
if err := generateSyntaxCSS("static/css/syntax.css"); err != nil {
log.Printf("warning: could not generate syntax.css: %v", err)
}
store, err := blog.NewStore(contentDir + "/posts")
if err != nil {
log.Fatal("store:", err)
}
h := handler.New(store, dataDir)
mux := http.NewServeMux()
// Static files (includes robots.txt via static/robots.txt)
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
// robots.txt at root
mux.HandleFunc("GET /robots.txt", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "static/robots.txt")
})
// Public routes
mux.HandleFunc("GET /{$}", h.Index)
mux.HandleFunc("GET /blog", h.BlogList)
mux.HandleFunc("GET /blog/feed.xml", h.Feed)
mux.HandleFunc("GET /blog/{slug}", h.BlogPost)
mux.HandleFunc("GET /infrastructure", h.Infrastructure)
mux.HandleFunc("GET /status", h.Status)
mux.HandleFunc("GET /about", h.About)
mux.HandleFunc("GET /sitemap.xml", h.Sitemap)
// Admin routes (auth handled per-handler)
mux.HandleFunc("/admin", h.AdminDashboard)
mux.HandleFunc("/admin/", h.AdminRouter)
log.Printf("ridgwaysystems.org starting on :%s (DEV=%s)", port, os.Getenv("DEV"))
log.Fatal(http.ListenAndServe(":"+port, mux))
}
// generateSyntaxCSS writes a chroma CSS file with light and dark themes.
func generateSyntaxCSS(path string) error {
formatter := chromahtml.New(chromahtml.WithClasses(true))
var buf bytes.Buffer
// Light theme (github)
buf.WriteString("/* Auto-generated by chroma at server startup. Do not edit. */\n\n")
if err := formatter.WriteCSS(&buf, styles.Get("github")); err != nil {
return err
}
// Dark theme wrapped in prefers-color-scheme media query
buf.WriteString("\n@media (prefers-color-scheme: dark) {\n")
var dark bytes.Buffer
if err := formatter.WriteCSS(&dark, styles.Get("github-dark")); err != nil {
return err
}
buf.Write(dark.Bytes())
buf.WriteString("}\n")
return os.WriteFile(path, buf.Bytes(), 0644)
}
func getenv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}

173
content/posts/pf-vlans.md Normal file
View File

@@ -0,0 +1,173 @@
---
title: "Setting Up pf with VLANs"
date: 2025-02-10
tags: [pf, networking, openbsd]
slug: pf-vlans
description: "Configuring OpenBSD pf.conf with VLAN segmentation — separating servers, desktop, IoT, and game traffic with sensible firewall rules."
draft: false
---
Network segmentation is one of the first things to get right. Once it's working, everything
else builds on top of it. Once it's broken, debugging why `ssh` works but `rsync` doesn't
becomes a special kind of misery.
This post covers the VLAN setup and the pf rules that go with it.
## The VLAN Layout
Five VLANs, each on a different subnet:
| VLAN | ID | Subnet | Purpose |
|------|-----|---------------|--------------------------------|
| mgmt | 1 | 10.0.1.0/24 | Switches, OOB, firewall mgmt |
| srv | 10 | 10.0.10.0/24 | Servers (srv01, srv02) |
| desk | 20 | 10.0.20.0/24 | Desktop and personal devices |
| 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
(`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.
## Configuring VLAN Interfaces
In `/etc/hostname.em1` (the trunked NIC):
```
up
```
Then individual VLAN interface files, e.g. `/etc/hostname.vlan10`:
```
vlandev em1 vlanid 10
inet 10.0.10.1 255.255.255.0
up
```
Repeat for each VLAN. After a reboot (or `sh /etc/netstart`), `ifconfig` should show
all the VLAN interfaces up with their addresses.
## The pf Configuration
This is a simplified version of the actual `pf.conf`. The real one has more rules, but
this captures the structure.
```
# /etc/pf.conf
# --- Interfaces ---
ext_if = "em0" # WAN
vlan_mgmt = "vlan1"
vlan_srv = "vlan10"
vlan_desk = "vlan20"
vlan_game = "vlan30"
vlan_iot = "vlan40"
# --- Tables ---
table <martians> const { 0.0.0.0/8, 10.0.0.0/8, 127.0.0.0/8, \
172.16.0.0/12, 192.168.0.0/16, 224.0.0.0/4, 240.0.0.0/4 }
# --- Options ---
set block-policy drop
set loginterface $ext_if
set skip on lo
# --- Normalization ---
match in all scrub (no-df random-id max-mss 1440)
# --- Default deny ---
block all
# --- Antispoofing ---
antispoof for $ext_if inet
# --- Block martians on WAN ---
block in quick on $ext_if from <martians> to any
block out quick on $ext_if from any to <martians>
# --- Inbound: allow public traffic to services ---
pass in on $ext_if proto tcp to port 80 keep state
pass in on $ext_if proto tcp to port 443 keep state
pass in on $ext_if proto tcp to port 22 keep state
pass in on $ext_if proto udp to port 51820 keep state # WireGuard
# --- Allow all outbound from firewall ---
pass out on $ext_if all keep state
# --- Management VLAN: full access ---
pass in on $vlan_mgmt all keep state
pass out on $vlan_mgmt all keep state
# --- Server VLAN: allow inter-VLAN from desk to srv ---
pass in on $vlan_srv all keep state
pass in on $vlan_desk to $vlan_srv keep state
# Block srv -> desk (servers shouldn't initiate to desktop)
block in on $vlan_srv to 10.0.20.0/24
# --- Desktop VLAN: full internet, access to servers ---
pass in on $vlan_desk all keep state
pass out on $vlan_desk all keep state
# --- Game VLAN: internet only, no access to other VLANs ---
pass in on $vlan_game proto { tcp udp } to port { 80 443 } keep state
pass in on $vlan_game proto udp keep state # game protocols
block in on $vlan_game to 10.0.0.0/8 # no access to RFC1918
# --- IoT VLAN: internet only, fully isolated ---
pass in on $vlan_iot proto tcp to port { 80 443 } keep state
pass in on $vlan_iot proto udp to port 53 keep state # DNS
block in on $vlan_iot to 10.0.0.0/8
```
## The Key Design Decisions
**Default deny with explicit allows** is the only sane approach. Start with `block all` and
add passes for what you actually need. Never the other way around.
**IoT is fully isolated.** Smart home devices get internet access and nothing else. They
cannot reach any other VLAN. If one of them is compromised, the blast radius is just
"attacker can make API calls from your IP." Annoying, not catastrophic.
**Game VLAN blocks RFC1918.** Game clients and VMs get internet but cannot reach any
private address space. This isolates them from everything internal.
**Servers can't initiate to desktop.** A compromised service on srv01 shouldn't be able to
reach my desktop. The server VMs serve; they don't call home.
## relayd for Reverse Proxying
External traffic hits fw01 on port 80/443. `relayd(8)` forwards it to srv01. The pf rules
above allow the initial connection in; relayd handles the rest.
Minimal `/etc/relayd.conf`:
```
# TLS termination and forwarding
http protocol "https" {
tls { keypair ridgwaysystems.org }
pass request header "X-Forwarded-For" value "$REMOTE_ADDR"
pass
}
relay "web" {
listen on egress port 443 tls
protocol "https"
forward to 10.0.10.10 port 8080 check http "/" code 200
}
```
Let's Encrypt certificates via `acme-client(1)` handle the TLS side. A daily cron job
runs `acme-client` and sends SIGHUP to relayd when certs are renewed.
## Debugging
When rules aren't working as expected, `pfctl -ss` shows current state table entries.
`tcpdump -i pflog0` shows what pf is logging. `pfctl -sr` shows the active ruleset.
The most common mistake: forgetting that `block` rules need `quick` to stop rule evaluation
immediately, while without `quick` pf continues evaluating and the last matching rule wins.
Learn this early.
## What's Next
With the network segmented, the next step is getting services deployed on srv01 — starting
with httpd and this website.

View File

@@ -0,0 +1,98 @@
---
title: "The Hardware: What's in the Rack"
date: 2025-01-28
tags: [hardware, homelab]
slug: the-hardware
description: "A tour of the physical hardware — SuperMicro 1U firewall, Dell R720 primary server, Dell R710 secondary, and the desktop control node."
draft: false
---
Before getting into software configuration, it's worth documenting the physical layer. What's
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:
- **pf** — stateful packet filtering, VLAN routing
- **relayd** — reverse proxy, TLS termination for external services
- **WireGuard** — VPN for remote access
- **unbound** — recursive DNS resolver for internal clients
- **dhcpd** — DHCP for all VLANs
The E3-1230v2 is modest by current standards but easily handles firewall workloads. More
importantly, it supports AES-NI (important for VPN throughput) and runs cool and quiet.
Multiple NICs: one for WAN, one trunked to the main switch carrying tagged VLANs for each
network segment.
## The Primary Server: Dell R720
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:
- **httpd** — web server for this site
- **Gitea** — self-hosted git
- **OpenSMTPD** — email (outbound, plus some inbound)
- **Prometheus + Grafana** — metrics collection and dashboards
- **Matrix** (Conduit) — self-hosted chat
- **Various smaller services** — RSS aggregator, bookmarks, etc.
The R720 is loud. Server-grade loud. It's in a separate room with the rack, so this is
tolerable. The fans throttle down after a few minutes under light load, but under any real
IO they spin up fast.
Power draw is significant — plan for 150-300W depending on load. Not a machine you run
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)`.
Primary roles:
- **nsd** — authoritative DNS for ridgwaysystems.org zones
- **Linux VMs** — game servers (Minecraft, Valheim, etc.), running in `vmm(4)`
- **Jellyfin** — media server
- **Backup target** — receiving 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.
## The Desktop: Daily Driver and Ansible Control Node
Standard desktop setup: Ryzen, 32GB RAM, NVMe storage, running Linux.
This is the Ansible control node. All infrastructure changes go through playbooks on this
machine and push to the servers. The goal is to get to a point where I could wipe any server
and bring it back up cleanly with `ansible-playbook`.
Not there yet — the playbooks are more "documentation that happens to be executable" than
a fully idempotent rebuild-from-scratch system. That's the project.
## Why Old Server Hardware?
Two reasons: price and ECC RAM.
A Dell R720 can be had for $200-400 on eBay depending on configuration. For that price you
get two server-grade CPUs, ECC RAM, hot-swap storage bays, iDRAC out-of-band management,
and hardware RAID if you want it. Nothing in the consumer space touches this value per
dollar for a home server.
The tradeoffs are power consumption and noise. For a rack in a utility room or basement,
those are manageable.
ECC RAM matters for a storage server. Silent corruption from a bit flip in a RAID controller
or filesystem is the worst kind of failure — the kind you don't notice until you need the
data. ECC doesn't eliminate all failure modes, but it eliminates the commonest one.
## What's Next
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.

View File

@@ -0,0 +1,85 @@
---
title: "Why OpenBSD for a Homelab"
date: 2025-01-15
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."
draft: false
---
A few people have asked why I chose OpenBSD 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.
## The Security Model Is Different
Most operating systems bolt security on. OpenBSD builds it in.
`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 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.
## 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.
Here's a minimal pf.conf to give a sense of the syntax:
```
set skip on lo
block all
pass in on egress proto tcp to port { 80 443 } keep state
pass in on egress proto tcp to port 22 keep state
pass out all keep state
```
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.
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.
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.
## 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.
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.
## What's Next
The next post covers the hardware — what's actually in the rack and why those particular
machines. After that, I'll get into the pf configuration and VLAN setup.

63
data/status.json Normal file
View File

@@ -0,0 +1,63 @@
{
"last_checked": "2025-02-10T12:00:00Z",
"services": [
{
"name": "Web (httpd)",
"description": "ridgwaysystems.org",
"status": "up"
},
{
"name": "Gitea",
"description": "git.ridgwaysystems.org",
"url": "https://git.ridgwaysystems.org",
"status": "up"
},
{
"name": "DNS (unbound)",
"description": "Internal recursive resolver on fw01",
"status": "up"
},
{
"name": "DNS (nsd)",
"description": "Authoritative DNS on srv02",
"status": "up"
},
{
"name": "Email (OpenSMTPD)",
"description": "Outbound and inbound mail on srv01",
"status": "up"
},
{
"name": "Monitoring (Prometheus)",
"description": "Metrics collection",
"status": "up"
},
{
"name": "Grafana",
"description": "Dashboards and alerting",
"status": "up"
},
{
"name": "VPN (WireGuard)",
"description": "vpn.ridgwaysystems.org",
"status": "up"
},
{
"name": "Matrix (Conduit)",
"description": "Self-hosted chat",
"status": "degraded",
"note": "Migrating to new server — intermittent"
},
{
"name": "Jellyfin",
"description": "Media server on srv02",
"status": "up"
},
{
"name": "Game Servers",
"description": "Minecraft, Valheim VMs on srv02",
"status": "down",
"note": "Offline for maintenance"
}
]
}

15
go.mod Normal file
View File

@@ -0,0 +1,15 @@
module ridgwaysystems.org/website
go 1.22
require (
github.com/yuin/goldmark v1.7.1
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
golang.org/x/crypto v0.23.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/alecthomas/chroma/v2 v2.2.0 // indirect
github.com/dlclark/regexp2 v1.7.0 // indirect
)

27
go.sum Normal file
View File

@@ -0,0 +1,27 @@
github.com/alecthomas/chroma/v2 v2.2.0 h1:Aten8jfQwUqEdadVFFjNyjx7HTexhKP0XuqBG67mRDY=
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae h1:zzGwJfFlFGD94CyyYwCJeSuD32Gj9GTaSi5y9hoVzdY=
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.1 h1:3bajkSilaCbjdKVsKdZjZCLBNPL9pYzrCakKaf4U49U=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

115
internal/blog/post.go Normal file
View File

@@ -0,0 +1,115 @@
package blog
import (
"bytes"
"html/template"
"strings"
"time"
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
highlighting "github.com/yuin/goldmark-highlighting/v2"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
"gopkg.in/yaml.v3"
)
// FrontMatter is the YAML block at the top of a post file.
type FrontMatter struct {
Title string `yaml:"title"`
Date string `yaml:"date"`
Tags []string `yaml:"tags"`
Slug string `yaml:"slug"`
Draft bool `yaml:"draft"`
Description string `yaml:"description"`
}
// Post is a parsed blog post with rendered HTML content.
type Post struct {
FrontMatter
ParsedDate time.Time
Content template.HTML
Slug string
}
// RenderMarkdown converts a markdown string to HTML.
func RenderMarkdown(src string) (template.HTML, error) {
md := goldmark.New(
goldmark.WithExtensions(
extension.GFM,
extension.Footnote,
extension.DefinitionList,
extension.Strikethrough,
extension.Table,
highlighting.NewHighlighting(
highlighting.WithFormatOptions(
chromahtml.WithClasses(true),
),
),
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
),
goldmark.WithRendererOptions(
html.WithXHTML(),
),
)
var buf bytes.Buffer
if err := md.Convert([]byte(src), &buf); err != nil {
return "", err
}
return template.HTML(buf.String()), nil
}
// ParsePost parses raw markdown bytes (with optional YAML frontmatter) into a Post.
// filename is the bare filename (e.g. "my-post.md") used to derive the slug if not set.
func ParsePost(raw []byte, filename string) (*Post, error) {
content := string(raw)
var fm FrontMatter
var markdownContent string
if strings.HasPrefix(content, "---") {
// Find closing ---
rest := content[3:]
idx := strings.Index(rest, "\n---")
if idx >= 0 {
fmRaw := rest[:idx]
if err := yaml.Unmarshal([]byte(fmRaw), &fm); err != nil {
return nil, err
}
markdownContent = strings.TrimSpace(rest[idx+4:])
} else {
markdownContent = content
}
} else {
markdownContent = content
}
htmlContent, err := RenderMarkdown(markdownContent)
if err != nil {
return nil, err
}
slug := fm.Slug
if slug == "" {
slug = strings.TrimSuffix(filename, ".md")
}
var parsedDate time.Time
if fm.Date != "" {
for _, layout := range []string{"2006-01-02", "2006-01-02T15:04:05Z07:00"} {
if t, err := time.Parse(layout, fm.Date); err == nil {
parsedDate = t
break
}
}
}
return &Post{
FrontMatter: fm,
ParsedDate: parsedDate,
Content: htmlContent,
Slug: slug,
}, nil
}

182
internal/blog/store.go Normal file
View File

@@ -0,0 +1,182 @@
package blog
import (
"errors"
"os"
"path/filepath"
"sort"
"strings"
)
// Store manages blog posts stored as markdown files on disk.
type Store struct {
dir string
}
// NewStore creates a Store rooted at dir, creating the directory if needed.
func NewStore(dir string) (*Store, error) {
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, err
}
return &Store{dir: dir}, nil
}
// All returns posts sorted by date descending.
// If includeDrafts is false, draft posts are excluded.
func (s *Store) All(includeDrafts bool) ([]*Post, error) {
entries, err := os.ReadDir(s.dir)
if err != nil {
return nil, err
}
var posts []*Post
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
continue
}
raw, err := os.ReadFile(filepath.Join(s.dir, e.Name()))
if err != nil {
continue
}
post, err := ParsePost(raw, e.Name())
if err != nil {
continue
}
if post.Draft && !includeDrafts {
continue
}
posts = append(posts, post)
}
sort.Slice(posts, func(i, j int) bool {
return posts[i].ParsedDate.After(posts[j].ParsedDate)
})
return posts, nil
}
// ByTag returns published posts matching a given tag.
func (s *Store) ByTag(tag string) ([]*Post, error) {
all, err := s.All(false)
if err != nil {
return nil, err
}
var filtered []*Post
for _, p := range all {
for _, t := range p.Tags {
if strings.EqualFold(t, tag) {
filtered = append(filtered, p)
break
}
}
}
return filtered, nil
}
// Get returns a single post by slug.
func (s *Store) Get(slug string) (*Post, error) {
// Try filename = slug + ".md"
raw, err := os.ReadFile(filepath.Join(s.dir, slug+".md"))
if err == nil {
return ParsePost(raw, slug+".md")
}
// Fall back: scan all posts
posts, err := s.All(true)
if err != nil {
return nil, err
}
for _, p := range posts {
if p.Slug == slug {
return p, nil
}
}
return nil, errors.New("post not found: " + slug)
}
// RawContent returns the raw markdown source for a post.
func (s *Store) RawContent(slug string) (string, error) {
raw, err := os.ReadFile(filepath.Join(s.dir, slug+".md"))
if err != nil {
return "", err
}
return string(raw), nil
}
// Save writes raw markdown content to a file named slug+".md".
func (s *Store) Save(slug, content string) error {
return os.WriteFile(filepath.Join(s.dir, slug+".md"), []byte(content), 0644)
}
// Delete removes the file for a given slug.
func (s *Store) Delete(slug string) error {
return os.Remove(filepath.Join(s.dir, slug+".md"))
}
// Search returns published posts whose title, description, tags, or raw markdown
// content contain the query string (case-insensitive).
func (s *Store) Search(query string) ([]*Post, error) {
posts, err := s.All(false)
if err != nil {
return nil, err
}
q := strings.ToLower(strings.TrimSpace(query))
if q == "" {
return posts, nil
}
seen := map[string]bool{}
var results []*Post
add := func(p *Post) {
if !seen[p.Slug] {
seen[p.Slug] = true
results = append(results, p)
}
}
for _, p := range posts {
if strings.Contains(strings.ToLower(p.Title), q) {
add(p)
continue
}
if strings.Contains(strings.ToLower(p.Description), q) {
add(p)
continue
}
matched := false
for _, t := range p.Tags {
if strings.Contains(strings.ToLower(t), q) {
matched = true
break
}
}
if matched {
add(p)
continue
}
// Fall back to raw markdown search (avoids rendering overhead)
raw, err := s.RawContent(p.Slug)
if err == nil && strings.Contains(strings.ToLower(raw), q) {
add(p)
}
}
return results, nil
}
// AllTags returns a deduplicated list of tags across all published posts.
func (s *Store) AllTags() ([]string, error) {
posts, err := s.All(false)
if err != nil {
return nil, err
}
seen := map[string]bool{}
var tags []string
for _, p := range posts {
for _, t := range p.Tags {
if !seen[t] {
seen[t] = true
tags = append(tags, t)
}
}
}
sort.Strings(tags)
return tags, nil
}

82
internal/feed/feed.go Normal file
View File

@@ -0,0 +1,82 @@
// Package feed generates RSS 2.0 / Atom feeds from blog posts.
package feed
import (
"encoding/xml"
"time"
"ridgwaysystems.org/website/internal/blog"
)
// --- RSS 2.0 ---
type rssChannel struct {
XMLName xml.Name `xml:"channel"`
Title string `xml:"title"`
Link string `xml:"link"`
Description string `xml:"description"`
Language string `xml:"language"`
LastBuildDate string `xml:"lastBuildDate"`
AtomLink atomLink `xml:"atom:link"`
Items []rssItem `xml:"item"`
}
type atomLink struct {
Href string `xml:"href,attr"`
Rel string `xml:"rel,attr"`
Type string `xml:"type,attr"`
}
type rssItem struct {
Title string `xml:"title"`
Link string `xml:"link"`
Description string `xml:"description"`
PubDate string `xml:"pubDate"`
GUID string `xml:"guid"`
}
type rssFeed struct {
XMLName xml.Name `xml:"rss"`
Version string `xml:"version,attr"`
Atom string `xml:"xmlns:atom,attr"`
Channel rssChannel `xml:"channel"`
}
// RSS generates RSS 2.0 XML for the given posts.
func RSS(siteURL, siteTitle, siteDesc string, posts []*blog.Post) ([]byte, error) {
var items []rssItem
for _, p := range posts {
postURL := siteURL + "/blog/" + p.Slug
items = append(items, rssItem{
Title: p.Title,
Link: postURL,
Description: p.Description,
PubDate: p.ParsedDate.Format(time.RFC1123Z),
GUID: postURL,
})
}
feed := rssFeed{
Version: "2.0",
Atom: "http://www.w3.org/2005/Atom",
Channel: rssChannel{
Title: siteTitle,
Link: siteURL,
Description: siteDesc,
Language: "en-us",
LastBuildDate: time.Now().Format(time.RFC1123Z),
AtomLink: atomLink{
Href: siteURL + "/blog/feed.xml",
Rel: "self",
Type: "application/rss+xml",
},
Items: items,
},
}
out, err := xml.MarshalIndent(feed, "", " ")
if err != nil {
return nil, err
}
return append([]byte(xml.Header), out...), nil
}

352
internal/handler/admin.go Normal file
View File

@@ -0,0 +1,352 @@
package handler
import (
"encoding/json"
"fmt"
"html/template"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"ridgwaysystems.org/website/internal/blog"
"ridgwaysystems.org/website/internal/status"
)
// AdminRouter dispatches /admin/* paths.
func (h *Handler) AdminRouter(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
switch {
case path == "/admin/login":
if r.Method == http.MethodPost {
h.adminLoginPost(w, r)
} else {
h.adminLoginGet(w, r)
}
case path == "/admin/logout":
h.requireAuth(h.adminLogout)(w, r)
case path == "/admin/new":
if r.Method == http.MethodPost {
h.requireAuth(h.adminNewPost)(w, r)
} else {
h.requireAuth(h.adminNewGet)(w, r)
}
case strings.HasPrefix(path, "/admin/edit/"):
if r.Method == http.MethodPost {
h.requireAuth(h.adminEditPost)(w, r)
} else {
h.requireAuth(h.adminEditGet)(w, r)
}
case strings.HasPrefix(path, "/admin/delete/"):
h.requireAuth(h.adminDeletePost)(w, r)
case path == "/admin/status":
if r.Method == http.MethodPost {
h.requireAuth(h.adminStatusPost)(w, r)
} else {
h.requireAuth(h.adminStatusGet)(w, r)
}
case path == "/admin/preview":
h.requireAuth(h.adminPreview)(w, r)
case path == "/admin/upload":
h.requireAuth(h.adminUpload)(w, r)
default:
h.renderErr(w, http.StatusNotFound, "Admin page not found.")
}
}
// AdminDashboard handles GET /admin.
func (h *Handler) AdminDashboard(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/admin" && r.URL.Path != "/admin/" {
// Fall through to router
h.AdminRouter(w, r)
return
}
h.requireAuth(h.adminDashboard)(w, r)
}
type dashboardData struct {
Posts []*blog.Post
Flash string
}
func (h *Handler) adminDashboard(w http.ResponseWriter, r *http.Request) {
flash := r.URL.Query().Get("flash")
posts, err := h.store.All(true)
if err != nil {
h.renderErr(w, http.StatusInternalServerError, "Could not load posts.")
return
}
h.render(w, "admin-dashboard", dashboardData{Posts: posts, Flash: flash})
}
// --- Login ---
func (h *Handler) adminLoginGet(w http.ResponseWriter, r *http.Request) {
if isAuthenticated(r) {
http.Redirect(w, r, "/admin", http.StatusSeeOther)
return
}
h.render(w, "admin-login", map[string]string{"Error": ""})
}
func (h *Handler) adminLoginPost(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
h.renderErr(w, http.StatusBadRequest, "Bad form data.")
return
}
password := r.FormValue("password")
if !checkPassword(password) {
h.render(w, "admin-login", map[string]string{"Error": "Invalid password."})
return
}
setSession(w)
http.Redirect(w, r, "/admin", http.StatusSeeOther)
}
func (h *Handler) adminLogout(w http.ResponseWriter, r *http.Request) {
clearSession(w)
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
}
// --- New post ---
type editorData struct {
Post *blog.Post
Raw string
IsNew bool
Error string
}
func (h *Handler) adminNewGet(w http.ResponseWriter, r *http.Request) {
now := time.Now().Format("2006-01-02")
raw := fmt.Sprintf("---\ntitle: New Post\ndate: %s\ntags: []\ndraft: true\ndescription: \"\"\n---\n\n", now)
h.render(w, "admin-editor", editorData{Raw: raw, IsNew: true})
}
func (h *Handler) adminNewPost(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
h.renderErr(w, http.StatusBadRequest, "Bad form data.")
return
}
slug := sanitizeSlug(r.FormValue("slug"))
content := r.FormValue("content")
if slug == "" {
h.render(w, "admin-editor", editorData{Raw: content, IsNew: true, Error: "Slug is required."})
return
}
if err := h.store.Save(slug, content); err != nil {
h.render(w, "admin-editor", editorData{Raw: content, IsNew: true, Error: "Failed to save: " + err.Error()})
return
}
http.Redirect(w, r, "/admin?flash=Post+created", http.StatusSeeOther)
}
// --- Edit post ---
func (h *Handler) adminEditGet(w http.ResponseWriter, r *http.Request) {
slug := strings.TrimPrefix(r.URL.Path, "/admin/edit/")
raw, err := h.store.RawContent(slug)
if err != nil {
h.renderErr(w, http.StatusNotFound, "Post not found.")
return
}
post, _ := h.store.Get(slug)
h.render(w, "admin-editor", editorData{Post: post, Raw: raw, IsNew: false})
}
func (h *Handler) adminEditPost(w http.ResponseWriter, r *http.Request) {
slug := strings.TrimPrefix(r.URL.Path, "/admin/edit/")
if err := r.ParseForm(); err != nil {
h.renderErr(w, http.StatusBadRequest, "Bad form data.")
return
}
content := r.FormValue("content")
if err := h.store.Save(slug, content); err != nil {
h.render(w, "admin-editor", editorData{Raw: content, Error: "Failed to save: " + err.Error()})
return
}
http.Redirect(w, r, "/admin?flash=Post+saved", http.StatusSeeOther)
}
// --- Delete post ---
func (h *Handler) adminDeletePost(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
h.renderErr(w, http.StatusMethodNotAllowed, "POST required.")
return
}
slug := strings.TrimPrefix(r.URL.Path, "/admin/delete/")
if err := h.store.Delete(slug); err != nil {
h.renderErr(w, http.StatusInternalServerError, "Delete failed: "+err.Error())
return
}
http.Redirect(w, r, "/admin?flash=Post+deleted", http.StatusSeeOther)
}
// --- Status editor ---
type adminStatusData struct {
JSON string
Error string
Flash string
}
func (h *Handler) adminStatusGet(w http.ResponseWriter, r *http.Request) {
p, err := status.Load(filepath.Join(h.dataDir, "status.json"))
if err != nil {
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})
}
func (h *Handler) adminStatusPost(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
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()})
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()})
return
}
http.Redirect(w, r, "/admin/status?flash=Saved", http.StatusSeeOther)
}
// --- Preview ---
func (h *Handler) adminPreview(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
h.renderErr(w, http.StatusMethodNotAllowed, "POST required.")
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
content := r.FormValue("content")
html, err := blog.RenderMarkdown(content)
if err != nil {
http.Error(w, "render error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, "<div class='preview-body'>%s</div>", template.HTML(html))
}
// --- Image upload ---
var allowedImageTypes = map[string]string{
"image/jpeg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
}
const maxUploadSize = 8 << 20 // 8 MB
func (h *Handler) adminUpload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, `{"error":"POST required"}`, http.StatusMethodNotAllowed)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `{"error":"file too large (max 8 MB)"}`)
return
}
file, header, err := r.FormFile("image")
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `{"error":"no image field in form"}`)
return
}
defer file.Close()
// Read first 512 bytes to detect MIME type
buf := make([]byte, 512)
n, _ := file.Read(buf)
mimeType := http.DetectContentType(buf[:n])
ext, ok := allowedImageTypes[mimeType]
if !ok {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `{"error":"unsupported image type: %s"}`, mimeType)
return
}
// Build a safe filename: sanitize original name + timestamp
base := sanitizeSlug(strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename)))
if base == "" {
base = "image"
}
filename := fmt.Sprintf("%s-%d%s", base, time.Now().UnixMilli(), ext)
dest := filepath.Join("static", "uploads", filename)
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, `{"error":"could not create upload directory"}`)
return
}
out, err := os.Create(dest)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, `{"error":"could not save file"}`)
return
}
defer out.Close()
// Write the already-read bytes, then the rest
out.Write(buf[:n])
if _, err := io.Copy(out, file); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, `{"error":"write failed"}`)
return
}
url := "/static/uploads/" + filename
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"url":"%s","markdown":"![image](%s)"}`, url, url)
}
// sanitizeSlug ensures a slug is filesystem-safe.
func sanitizeSlug(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
var b strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
b.WriteRune(r)
} else if r == ' ' {
b.WriteRune('-')
}
}
return b.String()
}

124
internal/handler/auth.go Normal file
View File

@@ -0,0 +1,124 @@
package handler
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"net/http"
"os"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
const sessionCookie = "rs_session"
const sessionDuration = 24 * time.Hour
// sessionSecret returns the HMAC signing key from env, with a fallback warning.
func sessionSecret() []byte {
s := os.Getenv("SESSION_SECRET")
if s == "" {
// Insecure fallback for local dev only — set SESSION_SECRET in production
return []byte("change-me-in-production")
}
return []byte(s)
}
// adminPasswordHash returns the bcrypt hash of the admin password from env.
func adminPasswordHash() string {
return os.Getenv("ADMIN_PASSWORD_HASH")
}
// checkPassword verifies a plaintext password against the stored bcrypt hash.
func checkPassword(password string) bool {
hash := adminPasswordHash()
if hash == "" {
return false
}
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
// signValue creates an HMAC-signed value: base64(payload)|base64(sig).
func signValue(payload string) string {
mac := hmac.New(sha256.New, sessionSecret())
mac.Write([]byte(payload))
sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "|" + sig
}
// verifyValue checks the signature and returns the original payload.
func verifyValue(signed string) (string, bool) {
parts := strings.SplitN(signed, "|", 2)
if len(parts) != 2 {
return "", false
}
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return "", false
}
payload := string(payloadBytes)
expected := signValue(payload)
if !hmac.Equal([]byte(signed), []byte(expected)) {
return "", false
}
return payload, true
}
// setSession writes a signed session cookie.
func setSession(w http.ResponseWriter) {
expiry := time.Now().Add(sessionDuration).Format(time.RFC3339)
value := signValue("admin|" + expiry)
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: value,
Path: "/admin",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: int(sessionDuration.Seconds()),
})
}
// clearSession deletes the session cookie.
func clearSession(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: "",
Path: "/admin",
MaxAge: -1,
Expires: time.Unix(0, 0),
})
}
// isAuthenticated returns true if the request has a valid session cookie.
func isAuthenticated(r *http.Request) bool {
c, err := r.Cookie(sessionCookie)
if err != nil {
return false
}
payload, ok := verifyValue(c.Value)
if !ok {
return false
}
// payload = "admin|<RFC3339 expiry>"
parts := strings.SplitN(payload, "|", 2)
if len(parts) != 2 || parts[0] != "admin" {
return false
}
expiry, err := time.Parse(time.RFC3339, parts[1])
if err != nil {
return false
}
return time.Now().Before(expiry)
}
// requireAuth is middleware that redirects to /admin/login if not authenticated.
func (h *Handler) requireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !isAuthenticated(r) {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
next(w, r)
}
}

105
internal/handler/handler.go Normal file
View File

@@ -0,0 +1,105 @@
// Package handler contains all HTTP request handlers.
package handler
import (
"html/template"
"log"
"net/http"
"os"
"path/filepath"
"ridgwaysystems.org/website/internal/blog"
)
// Handler holds shared dependencies for all HTTP handlers.
type Handler struct {
store *blog.Store
dataDir string
templates map[string]*template.Template
siteURL string
devMode bool
}
// New creates a Handler. dataDir is the path to the data/ directory.
func New(store *blog.Store, dataDir string) *Handler {
h := &Handler{
store: store,
dataDir: dataDir,
siteURL: getenv("SITE_URL", "https://ridgwaysystems.org"),
devMode: os.Getenv("DEV") == "1",
}
if !h.devMode {
h.templates = mustLoadTemplates()
}
return h
}
func getenv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
// tmpl returns the template set for a given name, reloading in dev mode.
func (h *Handler) tmpl(name string) *template.Template {
if h.devMode {
return mustLoadTemplates()[name]
}
return h.templates[name]
}
func mustLoadTemplates() map[string]*template.Template {
m := make(map[string]*template.Template)
funcMap := template.FuncMap{
"formatDate": formatDate,
"joinTags": joinTags,
}
base := "templates/base.html"
pages := []struct {
name string
file string
}{
{"index", "templates/index.html"},
{"blog", "templates/blog.html"},
{"post", "templates/post.html"},
{"infrastructure", "templates/infrastructure.html"},
{"status", "templates/status.html"},
{"about", "templates/about.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"},
}
for _, p := range pages {
t, err := template.New(filepath.Base(p.file)).Funcs(funcMap).ParseFiles(base, p.file)
if err != nil {
log.Fatalf("template %s: %v", p.name, err)
}
m[p.name] = t
}
return m
}
func (h *Handler) render(w http.ResponseWriter, name string, data any) {
t := h.tmpl(name)
if t == nil {
http.Error(w, "template not found: "+name, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.ExecuteTemplate(w, "base", data); err != nil {
log.Printf("render %s: %v", name, err)
}
}
func (h *Handler) renderErr(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(code)
w.Write([]byte("<html><body><h1>" + http.StatusText(code) + "</h1><p>" + msg + "</p><a href='/'>Home</a></body></html>"))
}

View File

@@ -0,0 +1,17 @@
package handler
import (
"strings"
"time"
)
func formatDate(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format("2 January 2006")
}
func joinTags(tags []string) string {
return strings.Join(tags, ", ")
}

219
internal/handler/public.go Normal file
View File

@@ -0,0 +1,219 @@
package handler
import (
"encoding/xml"
"net/http"
"path/filepath"
"strconv"
"strings"
"time"
"ridgwaysystems.org/website/internal/blog"
"ridgwaysystems.org/website/internal/feed"
"ridgwaysystems.org/website/internal/status"
)
const postsPerPage = 10
// indexData is passed to the index template.
type indexData struct {
RecentPosts []*blog.Post
}
func (h *Handler) Index(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
h.renderErr(w, http.StatusNotFound, "Page not found.")
return
}
posts, err := h.store.All(false)
if err != nil {
h.renderErr(w, http.StatusInternalServerError, "Could not load posts.")
return
}
limit := 5
if len(posts) < limit {
limit = len(posts)
}
h.render(w, "index", indexData{RecentPosts: posts[:limit]})
}
// blogData is passed to the blog list template.
type blogData struct {
Posts []*blog.Post
Tags []string
ActiveTag string
SearchQuery string
Page int
TotalPages int
HasPrev bool
HasNext bool
PrevPage int
NextPage int
}
func (h *Handler) BlogList(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
tag := q.Get("tag")
search := strings.TrimSpace(q.Get("q"))
page, _ := strconv.Atoi(q.Get("page"))
if page < 1 {
page = 1
}
var posts []*blog.Post
var err error
switch {
case search != "":
posts, err = h.store.Search(search)
case tag != "":
posts, err = h.store.ByTag(tag)
default:
posts, err = h.store.All(false)
}
if err != nil {
h.renderErr(w, http.StatusInternalServerError, "Could not load posts.")
return
}
tags, _ := h.store.AllTags()
// Paginate
total := len(posts)
totalPages := (total + postsPerPage - 1) / postsPerPage
if totalPages < 1 {
totalPages = 1
}
if page > totalPages {
page = totalPages
}
start := (page - 1) * postsPerPage
end := start + postsPerPage
if end > total {
end = total
}
h.render(w, "blog", blogData{
Posts: posts[start:end],
Tags: tags,
ActiveTag: tag,
SearchQuery: search,
Page: page,
TotalPages: totalPages,
HasPrev: page > 1,
HasNext: page < totalPages,
PrevPage: page - 1,
NextPage: page + 1,
})
}
func (h *Handler) BlogPost(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "" {
http.Redirect(w, r, "/blog", http.StatusSeeOther)
return
}
post, err := h.store.Get(slug)
if err != nil {
h.renderErr(w, http.StatusNotFound, "Post not found.")
return
}
// Drafts are visible to authenticated admins only
if post.Draft && !isAuthenticated(r) {
h.renderErr(w, http.StatusNotFound, "Post not found.")
return
}
h.render(w, "post", post)
}
func (h *Handler) Feed(w http.ResponseWriter, r *http.Request) {
posts, err := h.store.All(false)
if err != nil {
http.Error(w, "feed unavailable", http.StatusInternalServerError)
return
}
rss, err := feed.RSS(h.siteURL, "Ridgway Systems", "A homelab built on OpenBSD.", posts)
if err != nil {
http.Error(w, "feed error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
w.Write(rss)
}
func (h *Handler) Infrastructure(w http.ResponseWriter, r *http.Request) {
h.render(w, "infrastructure", nil)
}
// statusData is passed to the status template.
type statusData struct {
Page *status.Page
LastChecked string
}
func (h *Handler) Status(w http.ResponseWriter, r *http.Request) {
p, err := status.Load(filepath.Join(h.dataDir, "status.json"))
if err != nil {
p = &status.Page{LastChecked: time.Now(), Services: []status.Service{}}
}
var lastChecked string
if !p.LastChecked.IsZero() {
lastChecked = p.LastChecked.UTC().Format("2006-01-02 15:04 UTC")
}
h.render(w, "status", statusData{Page: p, LastChecked: lastChecked})
}
func (h *Handler) About(w http.ResponseWriter, r *http.Request) {
h.render(w, "about", nil)
}
// --- Sitemap ---
type urlset struct {
XMLName xml.Name `xml:"urlset"`
Xmlns string `xml:"xmlns,attr"`
URLs []sitemapURL `xml:"url"`
}
type sitemapURL struct {
Loc string `xml:"loc"`
LastMod string `xml:"lastmod,omitempty"`
Freq string `xml:"changefreq,omitempty"`
Prio string `xml:"priority,omitempty"`
}
func (h *Handler) Sitemap(w http.ResponseWriter, r *http.Request) {
posts, _ := h.store.All(false)
urls := []sitemapURL{
{Loc: h.siteURL + "/", Freq: "weekly", Prio: "1.0"},
{Loc: h.siteURL + "/blog", Freq: "weekly", Prio: "0.9"},
{Loc: h.siteURL + "/infrastructure", Freq: "monthly", Prio: "0.7"},
{Loc: h.siteURL + "/status", Freq: "daily", Prio: "0.6"},
{Loc: h.siteURL + "/about", Freq: "monthly", Prio: "0.5"},
}
for _, p := range posts {
u := sitemapURL{
Loc: h.siteURL + "/blog/" + p.Slug,
Freq: "never",
Prio: "0.8",
}
if !p.ParsedDate.IsZero() {
u.LastMod = p.ParsedDate.Format("2006-01-02")
}
urls = append(urls, u)
}
out, err := xml.MarshalIndent(urlset{
Xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9",
URLs: urls,
}, "", " ")
if err != nil {
http.Error(w, "sitemap error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
w.Write([]byte(xml.Header))
w.Write(out)
}

46
internal/status/status.go Normal file
View File

@@ -0,0 +1,46 @@
// Package status loads and manages the service status JSON.
package status
import (
"encoding/json"
"os"
"time"
)
// Service represents a single monitored service.
type Service struct {
Name string `json:"name"`
Description string `json:"description"`
URL string `json:"url,omitempty"`
Status string `json:"status"` // "up", "degraded", "down", "unknown"
Note string `json:"note,omitempty"`
}
// Page is the full status page data loaded from JSON.
type Page struct {
LastChecked time.Time `json:"last_checked"`
Services []Service `json:"services"`
}
// Load reads and parses the status JSON from path.
func Load(path string) (*Page, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var p Page
if err := json.Unmarshal(raw, &p); err != nil {
return nil, err
}
return &p, nil
}
// Save writes the status page data back to path.
func Save(path string, p *Page) error {
p.LastChecked = time.Now().UTC()
raw, err := json.MarshalIndent(p, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, raw, 0644)
}

937
static/css/style.css Normal file
View File

@@ -0,0 +1,937 @@
/* Ridgway Systems — ridgwaysystems.org
OpenBSD-inspired: clean, functional, no-nonsense.
-------------------------------------------------- */
/* === Custom Properties === */
:root {
--bg: #f5f3ef;
--bg-alt: #eceae4;
--bg-code: #e5e2dc;
--text: #1e1c1a;
--text-muted: #5a5650;
--accent: #c75000;
--accent-dim: #9e3f00;
--border: #ccc8c0;
--border-dark: #b0aba0;
--font-sans: system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
--font-mono: "SFMono-Regular", "Consolas", "Liberation Mono", Menlo, monospace;
--max-w: 760px;
--radius: 3px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1a1918;
--bg-alt: #222120;
--bg-code: #252422;
--text: #d4d1ca;
--text-muted: #888580;
--accent: #e8870a;
--accent-dim: #c06b00;
--border: #333230;
--border-dark: #4a4845;
}
}
/* === Reset & Base === */
*, *::before, *::after { box-sizing: border-box; }
html {
font-size: 16px;
scroll-behavior: smooth;
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--font-sans);
font-size: 1rem;
line-height: 1.65;
min-height: 100vh;
display: flex;
flex-direction: column;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
color: var(--accent-dim);
}
h1, h2, h3, h4, h5, h6 {
margin: 1.5em 0 0.5em;
line-height: 1.25;
color: var(--text);
font-weight: 600;
}
h1 { font-size: 1.9rem; margin-top: 0; }
h2 { font-size: 1.4rem; }
h3 { font-size: 1.15rem; }
p { margin: 0 0 1em; }
ul, ol { padding-left: 1.5em; margin: 0 0 1em; }
li { margin-bottom: 0.25em; }
hr {
border: none;
border-top: 1px solid var(--border);
margin: 2em 0;
}
img { max-width: 100%; height: auto; }
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
margin: 0 0 1.5em;
}
th {
text-align: left;
padding: 0.5em 0.75em;
background: var(--bg-alt);
border-bottom: 2px solid var(--border-dark);
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
}
td {
padding: 0.5em 0.75em;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
tr:last-child td { border-bottom: none; }
code {
font-family: var(--font-mono);
font-size: 0.875em;
background: var(--bg-code);
padding: 0.15em 0.4em;
border-radius: var(--radius);
border: 1px solid var(--border);
}
pre {
background: var(--bg-code);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1em 1.25em;
overflow-x: auto;
margin: 0 0 1.5em;
font-family: var(--font-mono);
font-size: 0.85rem;
line-height: 1.6;
}
pre code {
background: none;
border: none;
padding: 0;
font-size: inherit;
}
blockquote {
border-left: 3px solid var(--accent);
margin: 1.5em 0;
padding: 0.5em 1em;
color: var(--text-muted);
font-style: italic;
}
/* === Layout === */
.main-content {
flex: 1;
width: 100%;
max-width: var(--max-w);
margin: 0 auto;
padding: 2rem 1.25rem;
}
/* === Header / Nav === */
.site-header {
background: var(--bg);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 100;
}
.nav {
max-width: var(--max-w);
margin: 0 auto;
padding: 0.75rem 1.25rem;
display: flex;
align-items: center;
gap: 1.5rem;
}
.nav-brand {
font-family: var(--font-mono);
font-size: 0.9rem;
font-weight: bold;
color: var(--text);
text-decoration: none;
white-space: nowrap;
}
.nav-brand:hover { color: var(--accent); text-decoration: none; }
.nav-links {
list-style: none;
margin: 0;
padding: 0;
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.nav-links a {
color: var(--text-muted);
font-size: 0.9rem;
text-decoration: none;
font-family: var(--font-mono);
}
.nav-links a:hover {
color: var(--accent);
text-decoration: none;
}
/* === Footer === */
.site-footer {
border-top: 1px solid var(--border);
padding: 1rem 1.25rem;
text-align: center;
font-size: 0.82rem;
color: var(--text-muted);
font-family: var(--font-mono);
}
.site-footer a { color: var(--text-muted); }
.site-footer a:hover { color: var(--accent); }
/* === Buttons === */
.btn {
display: inline-block;
padding: 0.4em 0.85em;
background: var(--accent);
color: #fff;
border: 1px solid var(--accent);
border-radius: var(--radius);
font-size: 0.85rem;
font-family: var(--font-mono);
cursor: pointer;
text-decoration: none;
transition: background 0.1s, color 0.1s;
line-height: 1.5;
}
.btn:hover { background: var(--accent-dim); border-color: var(--accent-dim); text-decoration: none; color: #fff; }
.btn-outline {
background: transparent;
color: var(--accent);
}
.btn-outline:hover { background: var(--accent); color: #fff; }
.btn-sm {
font-size: 0.78rem;
padding: 0.25em 0.6em;
}
.btn-danger {
background: #b00;
border-color: #b00;
color: #fff;
}
.btn-danger:hover { background: #900; border-color: #900; color: #fff; }
/* === Tags === */
.tag {
display: inline-block;
font-size: 0.78rem;
font-family: var(--font-mono);
color: var(--text-muted);
background: var(--bg-alt);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.1em 0.45em;
text-decoration: none;
line-height: 1.5;
}
.tag:hover { color: var(--accent); text-decoration: none; border-color: var(--accent); }
.tag-active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.tag-active:hover { background: var(--accent-dim); border-color: var(--accent-dim); color: #fff; }
/* === Page Header === */
.page-header {
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.page-header h1 { margin-bottom: 0.25em; }
.page-desc {
color: var(--text-muted);
font-size: 0.95rem;
margin: 0;
}
/* === Hero === */
.hero {
padding: 2.5rem 0 2rem;
border-bottom: 1px solid var(--border);
margin-bottom: 2.5rem;
}
.hero h1 {
font-size: 2.25rem;
font-family: var(--font-mono);
letter-spacing: -0.02em;
color: var(--text);
margin-bottom: 0.35em;
}
.tagline {
font-size: 1.1rem;
color: var(--text-muted);
margin: 0 0 1em;
font-style: italic;
}
.hero-desc {
max-width: 600px;
color: var(--text);
margin-bottom: 1.5em;
}
.hero-links { display: flex; gap: 0.75rem; flex-wrap: wrap; }
/* === Infrastructure Summary (Index) === */
.infra-summary { margin-bottom: 2.5rem; }
.infra-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.infra-card {
background: var(--bg-alt);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.9rem 1rem;
}
.infra-host {
font-family: var(--font-mono);
font-weight: 600;
font-size: 0.9rem;
margin-bottom: 0.2em;
}
.infra-role {
font-size: 0.85rem;
font-weight: 600;
color: var(--text);
margin-bottom: 0.2em;
}
.infra-detail {
font-size: 0.78rem;
color: var(--text-muted);
font-family: var(--font-mono);
}
/* === Post Lists === */
.post-list {
list-style: none;
padding: 0;
margin: 0;
}
.post-item {
display: flex;
flex-direction: column;
gap: 0.2em;
padding: 0.85rem 0;
border-bottom: 1px solid var(--border);
}
.post-item:last-child { border-bottom: none; }
.post-date {
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--text-muted);
}
.post-title {
font-size: 1.05rem;
font-weight: 600;
color: var(--text);
text-decoration: none;
}
.post-title:hover { color: var(--accent); text-decoration: none; }
.post-desc {
font-size: 0.88rem;
color: var(--text-muted);
margin: 0;
}
.post-tags { display: flex; flex-wrap: wrap; gap: 0.3rem; }
.post-meta {
display: flex;
align-items: center;
gap: 0.75rem;
}
.all-posts-link {
display: inline-block;
margin-top: 1.25rem;
font-size: 0.9rem;
font-family: var(--font-mono);
}
/* === Tag Filter === */
.tag-filter {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
align-items: center;
margin-bottom: 1.75rem;
}
.tag-filter-label {
font-size: 0.8rem;
color: var(--text-muted);
font-family: var(--font-mono);
margin-right: 0.25rem;
}
/* === Post (Single) === */
.post { max-width: var(--max-w); }
.post-header {
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.post-header h1 { margin-bottom: 0.4em; }
.post-header .post-meta {
font-size: 0.85rem;
color: var(--text-muted);
font-family: var(--font-mono);
}
.post-content {
line-height: 1.75;
}
.post-content h1,
.post-content h2,
.post-content h3,
.post-content h4 {
margin-top: 2em;
}
.post-content p { margin-bottom: 1.1em; }
.post-footer {
margin-top: 2.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
font-size: 0.9rem;
}
/* === Draft Badge === */
.draft-badge {
font-family: var(--font-mono);
font-size: 0.72rem;
background: #e0a000;
color: #fff;
padding: 0.1em 0.5em;
border-radius: var(--radius);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.pub-badge {
font-family: var(--font-mono);
font-size: 0.72rem;
background: #2a9a5a;
color: #fff;
padding: 0.1em 0.5em;
border-radius: var(--radius);
text-transform: uppercase;
letter-spacing: 0.04em;
}
/* === Status Page === */
.status-list {
list-style: none;
padding: 0;
margin: 0 0 2rem;
}
.status-item {
display: flex;
align-items: center;
gap: 0.85rem;
padding: 0.8rem 0;
border-bottom: 1px solid var(--border);
}
.status-item:last-child { border-bottom: none; }
.status-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.status-up .status-indicator { background: #2a9a5a; }
.status-degraded .status-indicator { background: #e0a000; }
.status-down .status-indicator { background: #cc2200; }
.status-unknown .status-indicator { background: var(--border-dark); }
.status-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.1em;
}
.status-name {
font-weight: 600;
font-size: 0.95rem;
}
.status-desc {
font-size: 0.82rem;
color: var(--text-muted);
font-family: var(--font-mono);
}
.status-note {
font-size: 0.82rem;
color: var(--text-muted);
font-style: italic;
}
.status-badge {
font-family: var(--font-mono);
font-size: 0.75rem;
padding: 0.15em 0.55em;
border-radius: var(--radius);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
flex-shrink: 0;
}
.status-badge-up { background: #2a9a5a; color: #fff; }
.status-badge-degraded { background: #e0a000; color: #fff; }
.status-badge-down { background: #cc2200; color: #fff; }
.status-badge-unknown { background: var(--bg-alt); color: var(--text-muted); border: 1px solid var(--border); }
.status-legend {
margin-top: 1rem;
font-size: 0.82rem;
color: var(--text-muted);
}
/* === Infrastructure Page === */
.infra-section { margin-bottom: 3rem; }
.infra-section h2 { border-bottom: 1px solid var(--border); padding-bottom: 0.4em; }
.hw-table { font-size: 0.88rem; }
.hw-name {
font-family: var(--font-mono);
font-weight: 600;
white-space: nowrap;
}
.hw-spec {
display: block;
font-size: 0.8rem;
color: var(--text-muted);
font-family: var(--font-mono);
}
.network-diagram {
font-size: 0.82rem;
line-height: 1.5;
white-space: pre;
overflow-x: auto;
background: var(--bg-code);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25em 1.5em;
}
/* === About / Prose === */
.prose {
max-width: 640px;
line-height: 1.75;
}
.prose h2 { margin-top: 2em; }
.contact-list {
list-style: none;
padding: 0;
}
.contact-list li { margin-bottom: 0.5em; }
/* === Empty State === */
.empty-state {
color: var(--text-muted);
font-size: 0.9rem;
padding: 2rem 0;
}
/* === Admin === */
.admin-wrap {
max-width: 960px;
}
.admin-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.admin-header h1 { margin: 0; }
.admin-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
}
.admin-table {
font-size: 0.875rem;
width: 100%;
}
.admin-table th { font-size: 0.8rem; }
.tags-cell { font-size: 0.8rem; }
.actions-cell {
white-space: nowrap;
display: flex;
gap: 0.4rem;
}
.inline-form { display: inline; }
.form-error {
background: #fee;
border: 1px solid #faa;
border-radius: var(--radius);
padding: 0.6em 0.9em;
color: #c00;
font-size: 0.88rem;
margin-bottom: 1rem;
}
.flash-msg {
background: #efe;
border: 1px solid #9d9;
border-radius: var(--radius);
padding: 0.6em 0.9em;
color: #2a7a2a;
font-size: 0.88rem;
margin-bottom: 1rem;
}
/* Admin login */
.admin-login-wrap {
max-width: 360px;
margin: 3rem auto;
}
.admin-login-wrap h1 {
font-family: var(--font-mono);
margin-bottom: 1.5rem;
}
.login-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.login-form label {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-muted);
}
.login-form input {
width: 100%;
padding: 0.5em 0.75em;
background: var(--bg);
border: 1px solid var(--border-dark);
border-radius: var(--radius);
color: var(--text);
font-size: 0.95rem;
font-family: var(--font-mono);
}
.login-form input:focus {
outline: 2px solid var(--accent);
outline-offset: 1px;
border-color: var(--accent);
}
/* Editor */
.form-row {
margin-bottom: 1rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.form-row label {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-muted);
}
.form-row input {
max-width: 380px;
padding: 0.4em 0.7em;
background: var(--bg);
border: 1px solid var(--border-dark);
border-radius: var(--radius);
color: var(--text);
font-family: var(--font-mono);
font-size: 0.9rem;
}
.editor-form { display: flex; flex-direction: column; }
.editor-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1rem;
}
@media (max-width: 700px) {
.editor-layout { grid-template-columns: 1fr; }
}
.editor-pane label,
.preview-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.82rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.4rem;
}
.editor-textarea {
width: 100%;
height: 60vh;
min-height: 400px;
background: var(--bg-code);
border: 1px solid var(--border-dark);
border-radius: var(--radius);
color: var(--text);
font-family: var(--font-mono);
font-size: 0.85rem;
line-height: 1.65;
padding: 0.85rem 1rem;
resize: vertical;
tab-size: 2;
}
.editor-textarea:focus {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.preview-output {
height: 60vh;
min-height: 400px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.85rem 1rem;
background: var(--bg);
font-size: 0.9rem;
line-height: 1.7;
}
.editor-footer {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.json-editor {
width: 100%;
background: var(--bg-code);
border: 1px solid var(--border-dark);
border-radius: var(--radius);
color: var(--text);
font-family: var(--font-mono);
font-size: 0.85rem;
line-height: 1.6;
padding: 0.85rem 1rem;
resize: vertical;
margin-bottom: 0.75rem;
}
/* === Blog Controls (search + tag filter) === */
.blog-controls {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1.75rem;
}
.search-form {
display: flex;
gap: 0.4rem;
align-items: center;
flex-wrap: wrap;
}
.search-form input[type="search"] {
flex: 1;
min-width: 180px;
max-width: 340px;
padding: 0.4em 0.7em;
background: var(--bg);
border: 1px solid var(--border-dark);
border-radius: var(--radius);
color: var(--text);
font-family: var(--font-sans);
font-size: 0.9rem;
}
.search-form input[type="search"]:focus {
outline: 2px solid var(--accent);
outline-offset: 1px;
border-color: var(--accent);
}
/* === Pagination === */
.pagination {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
.page-indicator {
font-family: var(--font-mono);
font-size: 0.82rem;
color: var(--text-muted);
}
/* === Editor toolbar (image upload) === */
.editor-toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.6rem;
}
.upload-status {
font-size: 0.8rem;
color: var(--text-muted);
font-family: var(--font-mono);
}
/* === Responsive === */
@media (max-width: 600px) {
.hero h1 { font-size: 1.75rem; }
.infra-grid { grid-template-columns: 1fr 1fr; }
.nav { gap: 0.75rem; }
.nav-links { gap: 0.6rem; }
.admin-header {
flex-direction: column;
align-items: flex-start;
}
.hw-table { font-size: 0.82rem; }
th, td { padding: 0.4em 0.5em; }
}
@media (max-width: 420px) {
.infra-grid { grid-template-columns: 1fr; }
}

5
static/robots.txt Normal file
View File

@@ -0,0 +1,5 @@
User-agent: *
Disallow: /admin/
Disallow: /static/uploads/
Sitemap: https://ridgwaysystems.org/sitemap.xml

52
templates/about.html Normal file
View File

@@ -0,0 +1,52 @@
{{define "title"}}About &mdash; Ridgway Systems{{end}}
{{define "meta-desc"}}About Ridgway Systems — a personal OpenBSD homelab project.{{end}}
{{define "content"}}
<div class="page-header">
<h1>About</h1>
</div>
<div class="prose">
<p>
Ridgway Systems is a personal homelab project built entirely on OpenBSD. 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
here is useful.
</p>
<h2>Why OpenBSD?</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><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>
</ul>
<h2>What's Running</h2>
<p>
See the <a href="/infrastructure">infrastructure page</a> for the full hardware and service list.
Briefly: a SuperMicro 1U as the firewall/router, a Dell R720 as the primary server, and a
Dell R710 for backup and game servers. Everything is managed with Ansible.
</p>
<h2>Can I see the Ansible playbooks?</h2>
<p>
Eventually. The playbooks are on the <a href="https://git.ridgwaysystems.org">Gitea instance</a>
(private for now while things are in flux). Plan is to open them up once they're in a state
I'm not embarrassed by.
</p>
<h2>Contact</h2>
<ul class="contact-list">
<li>Email: <a href="mailto:bridgway@ridgwaysystems.org">bridgway@ridgwaysystems.org</a></li>
<li>Gitea: <a href="https://git.ridgwaysystems.org">git.ridgwaysystems.org</a></li>
<li>Mastodon: <a href="https://mastodon.social/@bridgway" rel="me">@bridgway@mastodon.social</a></li>
</ul>
</div>
{{end}}

View File

@@ -0,0 +1,55 @@
{{define "title"}}Admin Dashboard &mdash; Ridgway Systems{{end}}
{{define "content"}}
<div class="admin-wrap">
<div class="admin-header">
<h1>Admin Dashboard</h1>
<div class="admin-actions">
<a href="/admin/new" class="btn">New Post</a>
<a href="/admin/status" class="btn btn-outline">Edit Status</a>
<a href="/" class="btn btn-outline">View Site</a>
<form method="POST" action="/admin/logout" class="inline-form">
<button type="submit" class="btn btn-outline">Logout</button>
</form>
</div>
</div>
{{if .Flash}}
<p class="flash-msg">{{.Flash}}</p>
{{end}}
<h2>Posts</h2>
{{if .Posts}}
<table class="admin-table">
<thead>
<tr>
<th>Title</th>
<th>Date</th>
<th>Status</th>
<th>Tags</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{range .Posts}}
<tr>
<td><a href="/blog/{{.Slug}}">{{.Title}}</a></td>
<td>{{formatDate .ParsedDate}}</td>
<td>{{if .Draft}}<span class="draft-badge">draft</span>{{else}}<span class="pub-badge">published</span>{{end}}</td>
<td class="tags-cell">{{range .Tags}}<span class="tag">#{{.}}</span> {{end}}</td>
<td class="actions-cell">
<a href="/admin/edit/{{.Slug}}" class="btn btn-sm">Edit</a>
<form method="POST" action="/admin/delete/{{.Slug}}" class="inline-form"
onsubmit="return confirm('Delete {{.Title}}?')">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="empty-state">No posts yet. <a href="/admin/new">Create the first one.</a></p>
{{end}}
</div>
{{end}}

104
templates/admin/editor.html Normal file
View File

@@ -0,0 +1,104 @@
{{define "title"}}{{if .IsNew}}New Post{{else}}Edit Post{{end}} &mdash; Admin{{end}}
{{define "content"}}
<div class="admin-wrap">
<div class="admin-header">
<h1>{{if .IsNew}}New Post{{else}}Edit: {{if .Post}}{{.Post.Title}}{{end}}{{end}}</h1>
<div class="admin-actions">
<a href="/admin" class="btn btn-outline">Back to Dashboard</a>
</div>
</div>
{{if .Error}}
<p class="form-error">{{.Error}}</p>
{{end}}
<form method="POST" action="{{if .IsNew}}/admin/new{{else}}/admin/edit/{{if .Post}}{{.Post.Slug}}{{end}}{{end}}" class="editor-form" id="post-form">
{{if .IsNew}}
<div class="form-row">
<label for="slug">Slug (filename, no .md)</label>
<input type="text" id="slug" name="slug" placeholder="my-post-slug" pattern="[a-z0-9\-_]+" required>
</div>
{{end}}
<div class="editor-toolbar">
<input type="file" id="img-file" accept="image/*" style="display:none">
<button type="button" id="upload-btn" class="btn btn-sm btn-outline">Insert Image</button>
<span id="upload-status" class="upload-status"></span>
</div>
<div class="editor-layout">
<div class="editor-pane">
<label for="content">Markdown</label>
<textarea id="content" name="content" class="editor-textarea" spellcheck="false">{{.Raw}}</textarea>
</div>
<div class="preview-pane">
<div class="preview-label">Preview <button type="button" id="preview-btn" class="btn btn-sm">Refresh</button></div>
<div id="preview-output" class="preview-output prose"></div>
</div>
</div>
<div class="editor-footer">
<button type="submit" class="btn">Save</button>
<a href="/admin" class="btn btn-outline">Cancel</a>
</div>
</form>
</div>
<script>
(function() {
var previewBtn = document.getElementById('preview-btn');
var uploadBtn = document.getElementById('upload-btn');
var imgFile = document.getElementById('img-file');
var textarea = document.getElementById('content');
var output = document.getElementById('preview-output');
var uploadStatus = document.getElementById('upload-status');
// --- Preview ---
function refreshPreview() {
var fd = new FormData();
fd.append('content', textarea.value);
fetch('/admin/preview', { method: 'POST', body: fd })
.then(function(r) { return r.text(); })
.then(function(html) { output.innerHTML = html; })
.catch(function() { output.innerHTML = '<p class="form-error">Preview failed.</p>'; });
}
previewBtn.addEventListener('click', refreshPreview);
if (textarea.value.trim()) { refreshPreview(); }
// --- Image upload ---
uploadBtn.addEventListener('click', function() { imgFile.click(); });
imgFile.addEventListener('change', function() {
if (!this.files.length) return;
var file = this.files[0];
var fd = new FormData();
fd.append('image', file);
uploadStatus.textContent = 'Uploading…';
uploadBtn.disabled = true;
fetch('/admin/upload', { method: 'POST', body: fd })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) {
uploadStatus.textContent = 'Error: ' + data.error;
return;
}
// Insert markdown at cursor position
var pos = textarea.selectionStart;
var before = textarea.value.substring(0, pos);
var after = textarea.value.substring(textarea.selectionEnd);
textarea.value = before + data.markdown + after;
textarea.selectionStart = textarea.selectionEnd = pos + data.markdown.length;
textarea.focus();
uploadStatus.textContent = 'Inserted: ' + data.url;
setTimeout(function() { uploadStatus.textContent = ''; }, 3000);
})
.catch(function() { uploadStatus.textContent = 'Upload failed.'; })
.finally(function() { uploadBtn.disabled = false; imgFile.value = ''; });
});
})();
</script>
{{end}}

View File

@@ -0,0 +1,15 @@
{{define "title"}}Admin Login &mdash; Ridgway Systems{{end}}
{{define "content"}}
<div class="admin-login-wrap">
<h1>Admin</h1>
{{if .Error}}
<p class="form-error">{{.Error}}</p>
{{end}}
<form method="POST" action="/admin/login" class="login-form">
<label for="password">Password</label>
<input type="password" id="password" name="password" autofocus required>
<button type="submit">Login</button>
</form>
</div>
{{end}}

View File

@@ -0,0 +1,33 @@
{{define "title"}}Edit Status &mdash; Admin{{end}}
{{define "content"}}
<div class="admin-wrap">
<div class="admin-header">
<h1>Edit 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>
</div>
</div>
{{if .Flash}}
<p class="flash-msg">{{.Flash}}</p>
{{end}}
{{if .Error}}
<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>
<form method="POST" action="/admin/status">
<textarea name="json" class="json-editor" rows="30" spellcheck="false">{{.JSON}}</textarea>
<div class="editor-footer">
<button type="submit" class="btn">Save</button>
</div>
</form>
</div>
{{end}}

49
templates/base.html Normal file
View File

@@ -0,0 +1,49 @@
{{define "base"}}<!DOCTYPE html>
<html lang="en">
<head>
<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}}">
<!-- 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: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}}">
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/syntax.css">
<link rel="alternate" type="application/rss+xml" title="Ridgway Systems" href="/blog/feed.xml">
</head>
<body>
<header class="site-header">
<nav class="nav">
<a href="/" class="nav-brand">ridgwaysystems.org</a>
<ul class="nav-links">
<li><a href="/blog">blog</a></li>
<li><a href="/infrastructure">infrastructure</a></li>
<li><a href="/status">status</a></li>
<li><a href="/about">about</a></li>
</ul>
</nav>
</header>
<main class="main-content">
{{block "content" .}}{{end}}
</main>
<footer class="site-footer">
<p>
<a href="/">ridgwaysystems.org</a> &mdash;
running OpenBSD &mdash;
<a href="/blog/feed.xml">RSS</a> &mdash;
<a href="https://git.ridgwaysystems.org">gitea</a>
</p>
</footer>
</body>
</html>
{{end}}

67
templates/blog.html Normal file
View File

@@ -0,0 +1,67 @@
{{define "title"}}Build Log &mdash; Ridgway Systems{{end}}
{{define "meta-desc"}}OpenBSD 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>
</div>
<div class="blog-controls">
<form action="/blog" method="GET" class="search-form" role="search">
{{if .ActiveTag}}<input type="hidden" name="tag" value="{{.ActiveTag}}">{{end}}
<input type="search" name="q" value="{{.SearchQuery}}" placeholder="Search posts…" aria-label="Search posts">
<button type="submit" class="btn btn-sm">Search</button>
{{if .SearchQuery}}<a href="/blog{{if .ActiveTag}}?tag={{.ActiveTag}}{{end}}" class="btn btn-sm btn-outline">Clear</a>{{end}}
</form>
{{if .Tags}}
<div class="tag-filter">
<span class="tag-filter-label">filter:</span>
<a href="/blog{{if .SearchQuery}}?q={{.SearchQuery}}{{end}}" class="tag{{if eq .ActiveTag ""}} tag-active{{end}}">#all</a>
{{range .Tags}}
<a href="/blog?tag={{.}}{{if $.SearchQuery}}&q={{$.SearchQuery}}{{end}}" class="tag{{if eq . $.ActiveTag}} tag-active{{end}}">#{{.}}</a>
{{end}}
</div>
{{end}}
</div>
{{if and .SearchQuery (not .Posts)}}
<p class="empty-state">No results for &ldquo;{{.SearchQuery}}&rdquo;. <a href="/blog">Clear search.</a></p>
{{else if .Posts}}
<ul class="post-list post-list-full">
{{range .Posts}}
<li class="post-item">
<div class="post-meta">
<span class="post-date">{{formatDate .ParsedDate}}</span>
{{if .Draft}}<span class="draft-badge">draft</span>{{end}}
</div>
<a href="/blog/{{.Slug}}" class="post-title">{{.Title}}</a>
{{if .Description}}<p class="post-desc">{{.Description}}</p>{{end}}
{{if .Tags}}
<span class="post-tags">
{{range .Tags}}<a href="/blog?tag={{.}}" class="tag">#{{.}}</a> {{end}}
</span>
{{end}}
</li>
{{end}}
</ul>
{{if gt .TotalPages 1}}
<nav class="pagination" aria-label="Page navigation">
{{if .HasPrev}}
<a href="/blog?page={{.PrevPage}}{{if .ActiveTag}}&tag={{.ActiveTag}}{{end}}{{if .SearchQuery}}&q={{.SearchQuery}}{{end}}" class="btn btn-outline btn-sm">&larr; Newer</a>
{{end}}
<span class="page-indicator">page {{.Page}} of {{.TotalPages}}</span>
{{if .HasNext}}
<a href="/blog?page={{.NextPage}}{{if .ActiveTag}}&tag={{.ActiveTag}}{{end}}{{if .SearchQuery}}&q={{.SearchQuery}}{{end}}" class="btn btn-outline btn-sm">Older &rarr;</a>
{{end}}
</nav>
{{end}}
{{else}}
<p class="empty-state">No posts yet.
{{if .ActiveTag}}Try <a href="/blog">removing the filter</a>.{{end}}
</p>
{{end}}
{{end}}

63
templates/index.html Normal file
View File

@@ -0,0 +1,63 @@
{{define "title"}}Ridgway Systems{{end}}
{{define "content"}}
<section class="hero">
<h1>Ridgway Systems</h1>
<p class="tagline">A homelab built on OpenBSD &mdash; from firewall to git server.</p>
<p class="hero-desc">
A self-hosted infrastructure project running entirely on OpenBSD. This site documents the build:
hardware decisions, network configuration, service deployments, and everything learned along the way.
</p>
<div class="hero-links">
<a href="/blog" class="btn">build log</a>
<a href="/infrastructure" class="btn btn-outline">infrastructure</a>
<a href="/status" class="btn btn-outline">status</a>
</div>
</section>
<section class="infra-summary">
<h2>What's Running</h2>
<div class="infra-grid">
<div class="infra-card">
<div class="infra-host">SuperMicro 1U</div>
<div class="infra-role">Firewall &bull; Router &bull; VPN &bull; Reverse Proxy</div>
<div class="infra-detail">OpenBSD &bull; pf &bull; relayd &bull; WireGuard</div>
</div>
<div class="infra-card">
<div class="infra-host">Dell R720</div>
<div class="infra-role">Primary Server</div>
<div class="infra-detail">Gitea &bull; Web &bull; Mail &bull; Monitoring &bull; Chat</div>
</div>
<div class="infra-card">
<div class="infra-host">Dell R710</div>
<div class="infra-role">Backup &bull; Game Servers</div>
<div class="infra-detail">DNS &bull; Linux VMs &bull; Secondary services</div>
</div>
<div class="infra-card">
<div class="infra-host">Desktop</div>
<div class="infra-role">Daily Driver &bull; Ansible Control</div>
<div class="infra-detail">Development &bull; Playbook management</div>
</div>
</div>
</section>
{{if .RecentPosts}}
<section class="recent-posts">
<h2>Recent Posts</h2>
<ul class="post-list">
{{range .RecentPosts}}
<li class="post-item">
<span class="post-date">{{formatDate .ParsedDate}}</span>
<a href="/blog/{{.Slug}}" class="post-title">{{.Title}}</a>
{{if .Tags}}
<span class="post-tags">
{{range .Tags}}<a href="/blog?tag={{.}}" class="tag">#{{.}}</a> {{end}}
</span>
{{end}}
</li>
{{end}}
</ul>
<a href="/blog" class="all-posts-link">All posts &rarr;</a>
</section>
{{end}}
{{end}}

View File

@@ -0,0 +1,123 @@
{{define "title"}}Infrastructure &mdash; Ridgway Systems{{end}}
{{define "meta-desc"}}Hardware inventory and network diagram for the Ridgway Systems OpenBSD homelab.{{end}}
{{define "content"}}
<div class="page-header">
<h1>Infrastructure</h1>
<p class="page-desc">Physical hardware, network layout, and service placement.</p>
</div>
<section class="infra-section">
<h2>Hardware</h2>
<table class="hw-table">
<thead>
<tr>
<th>Host</th>
<th>Hardware</th>
<th>OS</th>
<th>Role</th>
</tr>
</thead>
<tbody>
<tr>
<td class="hw-name">fw01</td>
<td>SuperMicro 1U<br><span class="hw-spec">E3-1230v2 &bull; 16 GB RAM</span></td>
<td>OpenBSD</td>
<td>Firewall, router, VPN, reverse proxy<br><span class="hw-spec">pf &bull; relayd &bull; WireGuard &bull; unbound</span></td>
</tr>
<tr>
<td class="hw-name">srv01</td>
<td>Dell R720<br><span class="hw-spec">Xeon E5-2600 &bull; 64 GB RAM</span></td>
<td>OpenBSD</td>
<td>Primary server<br><span class="hw-spec">Gitea &bull; httpd &bull; OpenSMTPD &bull; Prometheus &bull; Grafana &bull; Matrix</span></td>
</tr>
<tr>
<td class="hw-name">srv02</td>
<td>Dell R710<br><span class="hw-spec">Xeon 5500/5600 &bull; 48 GB RAM</span></td>
<td>OpenBSD + Linux VMs</td>
<td>Backup, game servers<br><span class="hw-spec">nsd &bull; vmm &bull; Jellyfin &bull; secondary DNS</span></td>
</tr>
<tr>
<td class="hw-name">ws01</td>
<td>Desktop<br><span class="hw-spec">Ryzen &bull; 32 GB RAM</span></td>
<td>Linux</td>
<td>Daily driver, Ansible control node<br><span class="hw-spec">Development &bull; playbook management</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
External traffic flow:
Internet --&gt; fw01 (relayd) --&gt; srv01 (httpd/app)
VPN:
WireGuard on fw01 --&gt; routed to server VLANs
</pre>
</section>
<section class="infra-section">
<h2>Services</h2>
<table class="hw-table">
<thead>
<tr><th>Service</th><th>Host</th><th>URL</th></tr>
</thead>
<tbody>
<tr><td>Web / httpd</td><td>srv01</td><td>ridgwaysystems.org</td></tr>
<tr><td>Gitea</td><td>srv01</td><td>git.ridgwaysystems.org</td></tr>
<tr><td>Email (OpenSMTPD)</td><td>srv01</td><td>&mdash;</td></tr>
<tr><td>DNS (unbound)</td><td>fw01</td><td>internal resolver</td></tr>
<tr><td>DNS (nsd)</td><td>srv02</td><td>authoritative</td></tr>
<tr><td>Prometheus + Grafana</td><td>srv01</td><td>monitoring.ridgwaysystems.org</td></tr>
<tr><td>Matrix</td><td>srv01</td><td>matrix.ridgwaysystems.org</td></tr>
<tr><td>Jellyfin</td><td>srv02</td><td>jellyfin.ridgwaysystems.org</td></tr>
<tr><td>WireGuard VPN</td><td>fw01</td><td>vpn.ridgwaysystems.org</td></tr>
</tbody>
</table>
</section>
<section class="infra-section">
<h2>VLAN Layout</h2>
<table class="hw-table">
<thead>
<tr><th>VLAN</th><th>ID</th><th>Subnet</th><th>Purpose</th></tr>
</thead>
<tbody>
<tr><td>Management</td><td>1</td><td>10.0.1.0/24</td><td>Switches, OOB, firewall management</td></tr>
<tr><td>Servers</td><td>10</td><td>10.0.10.0/24</td><td>srv01, srv02 — all hosted services</td></tr>
<tr><td>Desktop</td><td>20</td><td>10.0.20.0/24</td><td>ws01 and personal devices</td></tr>
<tr><td>Game</td><td>30</td><td>10.0.30.0/24</td><td>Gaming VMs and clients</td></tr>
<tr><td>IoT/Guest</td><td>40</td><td>10.0.40.0/24</td><td>Untrusted / isolated devices</td></tr>
</tbody>
</table>
</section>
{{end}}

29
templates/post.html Normal file
View File

@@ -0,0 +1,29 @@
{{define "title"}}{{.Title}} &mdash; Ridgway Systems{{end}}
{{define "meta-desc"}}{{.Description}}{{end}}
{{define "og-title"}}{{.Title}} &mdash; Ridgway Systems{{end}}
{{define "og-desc"}}{{.Description}}{{end}}
{{define "og-type"}}article{{end}}
{{define "og-url"}}https://ridgwaysystems.org/blog/{{.Slug}}{{end}}
{{define "tw-title"}}{{.Title}} &mdash; Ridgway Systems{{end}}
{{define "tw-desc"}}{{.Description}}{{end}}
{{define "content"}}
<article class="post">
<header class="post-header">
<h1>{{.Title}}</h1>
<div class="post-meta">
<time datetime="{{.ParsedDate.Format "2006-01-02"}}">{{formatDate .ParsedDate}}</time>
{{if .Tags}}
&mdash;
{{range .Tags}}<a href="/blog?tag={{.}}" class="tag">#{{.}}</a> {{end}}
{{end}}
</div>
</header>
<div class="post-content">
{{.Content}}
</div>
<footer class="post-footer">
<a href="/blog">&larr; Back to build log</a>
</footer>
</article>
{{end}}

36
templates/status.html Normal file
View File

@@ -0,0 +1,36 @@
{{define "title"}}Service Status &mdash; Ridgway Systems{{end}}
{{define "meta-desc"}}Live status of services running on the Ridgway Systems homelab.{{end}}
{{define "content"}}
<div class="page-header">
<h1>Service Status</h1>
{{if .LastChecked}}
<p class="page-desc">Last updated: <time>{{.LastChecked}}</time></p>
{{end}}
</div>
{{if .Page.Services}}
<ul class="status-list">
{{range .Page.Services}}
<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>
<span class="status-badge status-badge-{{.Status}}">{{.Status}}</span>
</li>
{{end}}
</ul>
{{else}}
<p class="empty-state">No services configured.</p>
{{end}}
<div class="status-legend">
<span class="status-badge status-badge-up">up</span> operational &nbsp;
<span class="status-badge status-badge-degraded">degraded</span> reduced capacity &nbsp;
<span class="status-badge status-badge-down">down</span> unavailable &nbsp;
<span class="status-badge status-badge-unknown">unknown</span> not checked
</div>
{{end}}

34
tools/genhash/main.go Normal file
View File

@@ -0,0 +1,34 @@
// genhash generates a bcrypt password hash suitable for use as ADMIN_PASSWORD_HASH.
//
// Usage:
//
// go run ./tools/genhash <password>
// make genhash
package main
import (
"fmt"
"os"
"golang.org/x/crypto/bcrypt"
)
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: genhash <password>")
fmt.Fprintln(os.Stderr, " or: make genhash")
os.Exit(1)
}
password := os.Args[1]
if len(password) == 0 {
fmt.Fprintln(os.Stderr, "error: password cannot be empty")
os.Exit(1)
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
fmt.Println(string(hash))
fmt.Fprintln(os.Stderr, "\nSet this as ADMIN_PASSWORD_HASH in your .env file.")
}