From 03fcf37beb9838e40275a04e3924e1ba8ef690dd Mon Sep 17 00:00:00 2001 From: Blake Ridgway Date: Sat, 7 Mar 2026 21:16:51 -0600 Subject: [PATCH] first commit --- .env.example | 37 ++ .gitignore | 24 + Makefile | 76 +++ cmd/server/main.go | 90 ++++ content/posts/pf-vlans.md | 173 ++++++ content/posts/the-hardware.md | 98 ++++ content/posts/why-openbsd.md | 85 +++ data/status.json | 63 +++ go.mod | 15 + go.sum | 27 + internal/blog/post.go | 115 ++++ internal/blog/store.go | 182 +++++++ internal/feed/feed.go | 82 +++ internal/handler/admin.go | 352 +++++++++++++ internal/handler/auth.go | 124 +++++ internal/handler/handler.go | 105 ++++ internal/handler/helpers.go | 17 + internal/handler/public.go | 219 ++++++++ internal/status/status.go | 46 ++ static/css/style.css | 937 +++++++++++++++++++++++++++++++++ static/robots.txt | 5 + templates/about.html | 52 ++ templates/admin/dashboard.html | 55 ++ templates/admin/editor.html | 104 ++++ templates/admin/login.html | 15 + templates/admin/status.html | 33 ++ templates/base.html | 49 ++ templates/blog.html | 67 +++ templates/index.html | 63 +++ templates/infrastructure.html | 123 +++++ templates/post.html | 29 + templates/status.html | 36 ++ tools/genhash/main.go | 34 ++ 33 files changed, 3532 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 cmd/server/main.go create mode 100644 content/posts/pf-vlans.md create mode 100644 content/posts/the-hardware.md create mode 100644 content/posts/why-openbsd.md create mode 100644 data/status.json create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/blog/post.go create mode 100644 internal/blog/store.go create mode 100644 internal/feed/feed.go create mode 100644 internal/handler/admin.go create mode 100644 internal/handler/auth.go create mode 100644 internal/handler/handler.go create mode 100644 internal/handler/helpers.go create mode 100644 internal/handler/public.go create mode 100644 internal/status/status.go create mode 100644 static/css/style.css create mode 100644 static/robots.txt create mode 100644 templates/about.html create mode 100644 templates/admin/dashboard.html create mode 100644 templates/admin/editor.html create mode 100644 templates/admin/login.html create mode 100644 templates/admin/status.html create mode 100644 templates/base.html create mode 100644 templates/blog.html create mode 100644 templates/index.html create mode 100644 templates/infrastructure.html create mode 100644 templates/post.html create mode 100644 templates/status.html create mode 100644 tools/genhash/main.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a14bc11 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac5c6a9 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..14dcceb --- /dev/null +++ b/Makefile @@ -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" diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..8df64dc --- /dev/null +++ b/cmd/server/main.go @@ -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 +} diff --git a/content/posts/pf-vlans.md b/content/posts/pf-vlans.md new file mode 100644 index 0000000..fde17bd --- /dev/null +++ b/content/posts/pf-vlans.md @@ -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 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 to any +block out quick on $ext_if from any to + +# --- 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. diff --git a/content/posts/the-hardware.md b/content/posts/the-hardware.md new file mode 100644 index 0000000..e6f1024 --- /dev/null +++ b/content/posts/the-hardware.md @@ -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. diff --git a/content/posts/why-openbsd.md b/content/posts/why-openbsd.md new file mode 100644 index 0000000..a056bfb --- /dev/null +++ b/content/posts/why-openbsd.md @@ -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. diff --git a/data/status.json b/data/status.json new file mode 100644 index 0000000..fe4d95b --- /dev/null +++ b/data/status.json @@ -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" + } + ] +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b10a266 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3853500 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/blog/post.go b/internal/blog/post.go new file mode 100644 index 0000000..c51e74f --- /dev/null +++ b/internal/blog/post.go @@ -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 +} diff --git a/internal/blog/store.go b/internal/blog/store.go new file mode 100644 index 0000000..c459040 --- /dev/null +++ b/internal/blog/store.go @@ -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 +} diff --git a/internal/feed/feed.go b/internal/feed/feed.go new file mode 100644 index 0000000..00419f3 --- /dev/null +++ b/internal/feed/feed.go @@ -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 +} diff --git a/internal/handler/admin.go b/internal/handler/admin.go new file mode 100644 index 0000000..1076c78 --- /dev/null +++ b/internal/handler/admin.go @@ -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, "
%s
", 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() +} diff --git a/internal/handler/auth.go b/internal/handler/auth.go new file mode 100644 index 0000000..bb5f1fb --- /dev/null +++ b/internal/handler/auth.go @@ -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|" + 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) + } +} diff --git a/internal/handler/handler.go b/internal/handler/handler.go new file mode 100644 index 0000000..641ff91 --- /dev/null +++ b/internal/handler/handler.go @@ -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("

" + http.StatusText(code) + "

" + msg + "

Home")) +} diff --git a/internal/handler/helpers.go b/internal/handler/helpers.go new file mode 100644 index 0000000..58a8bd4 --- /dev/null +++ b/internal/handler/helpers.go @@ -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, ", ") +} diff --git a/internal/handler/public.go b/internal/handler/public.go new file mode 100644 index 0000000..583d758 --- /dev/null +++ b/internal/handler/public.go @@ -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) +} diff --git a/internal/status/status.go b/internal/status/status.go new file mode 100644 index 0000000..77266ad --- /dev/null +++ b/internal/status/status.go @@ -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) +} diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..3ac01ad --- /dev/null +++ b/static/css/style.css @@ -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; } +} diff --git a/static/robots.txt b/static/robots.txt new file mode 100644 index 0000000..7b570fb --- /dev/null +++ b/static/robots.txt @@ -0,0 +1,5 @@ +User-agent: * +Disallow: /admin/ +Disallow: /static/uploads/ + +Sitemap: https://ridgwaysystems.org/sitemap.xml diff --git a/templates/about.html b/templates/about.html new file mode 100644 index 0000000..17690d3 --- /dev/null +++ b/templates/about.html @@ -0,0 +1,52 @@ +{{define "title"}}About — Ridgway Systems{{end}} +{{define "meta-desc"}}About Ridgway Systems — a personal OpenBSD homelab project.{{end}} + +{{define "content"}} + + +
+

+ 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. +

+ +

+ 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. +

+ +

Why OpenBSD?

+
    +
  • Security-first design. pledge(2) and unveil(2) are excellent.
  • +
  • Clean, minimal base system. No surprises.
  • +
  • pf(4) is the best firewall I've used.
  • +
  • Documentation is thorough and accurate. The man pages are genuinely good.
  • +
  • Deliberate, careful development. The OpenBSD team doesn't chase hype.
  • +
+ +

What's Running

+

+ See the infrastructure page 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. +

+ +

Can I see the Ansible playbooks?

+

+ Eventually. The playbooks are on the Gitea instance + (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. +

+ +

Contact

+ +
+{{end}} diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html new file mode 100644 index 0000000..e7a21e2 --- /dev/null +++ b/templates/admin/dashboard.html @@ -0,0 +1,55 @@ +{{define "title"}}Admin Dashboard — Ridgway Systems{{end}} + +{{define "content"}} +
+
+

Admin Dashboard

+
+ New Post + Edit Status + View Site +
+ +
+
+
+ + {{if .Flash}} +

{{.Flash}}

+ {{end}} + +

Posts

+ {{if .Posts}} + + + + + + + + + + + + {{range .Posts}} + + + + + + + + {{end}} + +
TitleDateStatusTagsActions
{{.Title}}{{formatDate .ParsedDate}}{{if .Draft}}draft{{else}}published{{end}}{{range .Tags}}#{{.}} {{end}} + Edit +
+ +
+
+ {{else}} +

No posts yet. Create the first one.

+ {{end}} +
+{{end}} diff --git a/templates/admin/editor.html b/templates/admin/editor.html new file mode 100644 index 0000000..509d1d5 --- /dev/null +++ b/templates/admin/editor.html @@ -0,0 +1,104 @@ +{{define "title"}}{{if .IsNew}}New Post{{else}}Edit Post{{end}} — Admin{{end}} + +{{define "content"}} +
+
+

{{if .IsNew}}New Post{{else}}Edit: {{if .Post}}{{.Post.Title}}{{end}}{{end}}

+ +
+ + {{if .Error}} +

{{.Error}}

+ {{end}} + +
+ {{if .IsNew}} +
+ + +
+ {{end}} + +
+ + + +
+ +
+
+ + +
+
+
Preview
+
+
+
+ + +
+
+ + +{{end}} diff --git a/templates/admin/login.html b/templates/admin/login.html new file mode 100644 index 0000000..f66aeb5 --- /dev/null +++ b/templates/admin/login.html @@ -0,0 +1,15 @@ +{{define "title"}}Admin Login — Ridgway Systems{{end}} + +{{define "content"}} + +{{end}} diff --git a/templates/admin/status.html b/templates/admin/status.html new file mode 100644 index 0000000..22bed01 --- /dev/null +++ b/templates/admin/status.html @@ -0,0 +1,33 @@ +{{define "title"}}Edit Status — Admin{{end}} + +{{define "content"}} +
+
+

Edit Service Status

+ +
+ + {{if .Flash}} +

{{.Flash}}

+ {{end}} + + {{if .Error}} +

{{.Error}}

+ {{end}} + +

+ Edit the raw JSON below. Valid status values: up, degraded, + down, unknown. +

+ +
+ + +
+
+{{end}} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..698ac9b --- /dev/null +++ b/templates/base.html @@ -0,0 +1,49 @@ +{{define "base"}} + + + + + {{block "title" .}}Ridgway Systems{{end}} + + + + + + + + + + + + + + + + + + +
+ {{block "content" .}}{{end}} +
+ + + + +{{end}} diff --git a/templates/blog.html b/templates/blog.html new file mode 100644 index 0000000..49663fe --- /dev/null +++ b/templates/blog.html @@ -0,0 +1,67 @@ +{{define "title"}}Build Log — Ridgway Systems{{end}} +{{define "meta-desc"}}OpenBSD homelab build log — documenting decisions, problems, and solutions.{{end}} + +{{define "content"}} + + +
+ + + {{if .Tags}} +
+ filter: + #all + {{range .Tags}} + #{{.}} + {{end}} +
+ {{end}} +
+ +{{if and .SearchQuery (not .Posts)}} +

No results for “{{.SearchQuery}}”. Clear search.

+{{else if .Posts}} +
    + {{range .Posts}} +
  • + + {{.Title}} + {{if .Description}}

    {{.Description}}

    {{end}} + {{if .Tags}} + + {{end}} +
  • + {{end}} +
+ +{{if gt .TotalPages 1}} + +{{end}} + +{{else}} +

No posts yet. + {{if .ActiveTag}}Try removing the filter.{{end}} +

+{{end}} +{{end}} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..04a9f8d --- /dev/null +++ b/templates/index.html @@ -0,0 +1,63 @@ +{{define "title"}}Ridgway Systems{{end}} + +{{define "content"}} +
+

Ridgway Systems

+

A homelab built on OpenBSD — from firewall to git server.

+

+ 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. +

+ +
+ +
+

What's Running

+
+
+
SuperMicro 1U
+
Firewall • Router • VPN • Reverse Proxy
+
OpenBSD • pf • relayd • WireGuard
+
+
+
Dell R720
+
Primary Server
+
Gitea • Web • Mail • Monitoring • Chat
+
+
+
Dell R710
+
Backup • Game Servers
+
DNS • Linux VMs • Secondary services
+
+
+
Desktop
+
Daily Driver • Ansible Control
+
Development • Playbook management
+
+
+
+ +{{if .RecentPosts}} +
+

Recent Posts

+
    + {{range .RecentPosts}} +
  • + + {{.Title}} + {{if .Tags}} + + {{end}} +
  • + {{end}} +
+ All posts → +
+{{end}} +{{end}} diff --git a/templates/infrastructure.html b/templates/infrastructure.html new file mode 100644 index 0000000..ccd6288 --- /dev/null +++ b/templates/infrastructure.html @@ -0,0 +1,123 @@ +{{define "title"}}Infrastructure — Ridgway Systems{{end}} +{{define "meta-desc"}}Hardware inventory and network diagram for the Ridgway Systems OpenBSD homelab.{{end}} + +{{define "content"}} + + +
+

Hardware

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
HostHardwareOSRole
fw01SuperMicro 1U
E3-1230v2 • 16 GB RAM
OpenBSDFirewall, router, VPN, reverse proxy
pf • relayd • WireGuard • unbound
srv01Dell R720
Xeon E5-2600 • 64 GB RAM
OpenBSDPrimary server
Gitea • httpd • OpenSMTPD • Prometheus • Grafana • Matrix
srv02Dell R710
Xeon 5500/5600 • 48 GB RAM
OpenBSD + Linux VMsBackup, game servers
nsd • vmm • Jellyfin • secondary DNS
ws01Desktop
Ryzen • 32 GB RAM
LinuxDaily driver, Ansible control node
Development • playbook management
+
+ +
+

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 --> fw01 (relayd) --> srv01 (httpd/app)
+
+  VPN:
+  WireGuard on fw01 --> routed to server VLANs
+  
+
+ +
+

Services

+ + + + + + + + + + + + + + + +
ServiceHostURL
Web / httpdsrv01ridgwaysystems.org
Giteasrv01git.ridgwaysystems.org
Email (OpenSMTPD)srv01
DNS (unbound)fw01internal resolver
DNS (nsd)srv02authoritative
Prometheus + Grafanasrv01monitoring.ridgwaysystems.org
Matrixsrv01matrix.ridgwaysystems.org
Jellyfinsrv02jellyfin.ridgwaysystems.org
WireGuard VPNfw01vpn.ridgwaysystems.org
+
+ +
+

VLAN Layout

+ + + + + + + + + + + +
VLANIDSubnetPurpose
Management110.0.1.0/24Switches, OOB, firewall management
Servers1010.0.10.0/24srv01, srv02 — all hosted services
Desktop2010.0.20.0/24ws01 and personal devices
Game3010.0.30.0/24Gaming VMs and clients
IoT/Guest4010.0.40.0/24Untrusted / isolated devices
+
+{{end}} diff --git a/templates/post.html b/templates/post.html new file mode 100644 index 0000000..7413a67 --- /dev/null +++ b/templates/post.html @@ -0,0 +1,29 @@ +{{define "title"}}{{.Title}} — Ridgway Systems{{end}} +{{define "meta-desc"}}{{.Description}}{{end}} +{{define "og-title"}}{{.Title}} — 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}} — Ridgway Systems{{end}} +{{define "tw-desc"}}{{.Description}}{{end}} + +{{define "content"}} +
+
+

{{.Title}}

+ +
+
+ {{.Content}} +
+ +
+{{end}} diff --git a/templates/status.html b/templates/status.html new file mode 100644 index 0000000..e2abafe --- /dev/null +++ b/templates/status.html @@ -0,0 +1,36 @@ +{{define "title"}}Service Status — Ridgway Systems{{end}} +{{define "meta-desc"}}Live status of services running on the Ridgway Systems homelab.{{end}} + +{{define "content"}} + + +{{if .Page.Services}} +
    + {{range .Page.Services}} +
  • + +
    + {{.Name}} + {{if .Description}}{{.Description}}{{end}} + {{if .Note}}{{.Note}}{{end}} +
    + {{.Status}} +
  • + {{end}} +
+{{else}} +

No services configured.

+{{end}} + +
+ up operational   + degraded reduced capacity   + down unavailable   + unknown not checked +
+{{end}} diff --git a/tools/genhash/main.go b/tools/genhash/main.go new file mode 100644 index 0000000..32e152a --- /dev/null +++ b/tools/genhash/main.go @@ -0,0 +1,34 @@ +// genhash generates a bcrypt password hash suitable for use as ADMIN_PASSWORD_HASH. +// +// Usage: +// +// go run ./tools/genhash +// 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 ") + 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.") +}