first commit
This commit is contained in:
37
.env.example
Normal file
37
.env.example
Normal 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
24
.gitignore
vendored
Normal 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
76
Makefile
Normal 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
90
cmd/server/main.go
Normal 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
173
content/posts/pf-vlans.md
Normal 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.
|
||||
98
content/posts/the-hardware.md
Normal file
98
content/posts/the-hardware.md
Normal 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.
|
||||
85
content/posts/why-openbsd.md
Normal file
85
content/posts/why-openbsd.md
Normal 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
63
data/status.json
Normal 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
15
go.mod
Normal 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
27
go.sum
Normal 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
115
internal/blog/post.go
Normal 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
182
internal/blog/store.go
Normal 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
82
internal/feed/feed.go
Normal 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
352
internal/handler/admin.go
Normal 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":""}`, 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
124
internal/handler/auth.go
Normal 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
105
internal/handler/handler.go
Normal 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>"))
|
||||
}
|
||||
17
internal/handler/helpers.go
Normal file
17
internal/handler/helpers.go
Normal 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
219
internal/handler/public.go
Normal 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
46
internal/status/status.go
Normal 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
937
static/css/style.css
Normal 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
5
static/robots.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
User-agent: *
|
||||
Disallow: /admin/
|
||||
Disallow: /static/uploads/
|
||||
|
||||
Sitemap: https://ridgwaysystems.org/sitemap.xml
|
||||
52
templates/about.html
Normal file
52
templates/about.html
Normal file
@@ -0,0 +1,52 @@
|
||||
{{define "title"}}About — 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}}
|
||||
55
templates/admin/dashboard.html
Normal file
55
templates/admin/dashboard.html
Normal file
@@ -0,0 +1,55 @@
|
||||
{{define "title"}}Admin Dashboard — 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
104
templates/admin/editor.html
Normal file
@@ -0,0 +1,104 @@
|
||||
{{define "title"}}{{if .IsNew}}New Post{{else}}Edit Post{{end}} — 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}}
|
||||
15
templates/admin/login.html
Normal file
15
templates/admin/login.html
Normal file
@@ -0,0 +1,15 @@
|
||||
{{define "title"}}Admin Login — 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}}
|
||||
33
templates/admin/status.html
Normal file
33
templates/admin/status.html
Normal file
@@ -0,0 +1,33 @@
|
||||
{{define "title"}}Edit Status — 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
49
templates/base.html
Normal 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> —
|
||||
running OpenBSD —
|
||||
<a href="/blog/feed.xml">RSS</a> —
|
||||
<a href="https://git.ridgwaysystems.org">gitea</a>
|
||||
</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
67
templates/blog.html
Normal file
67
templates/blog.html
Normal file
@@ -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"}}
|
||||
<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 “{{.SearchQuery}}”. <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">← 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 →</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
63
templates/index.html
Normal 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 — 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 • Router • VPN • Reverse Proxy</div>
|
||||
<div class="infra-detail">OpenBSD • pf • relayd • 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 • Web • Mail • Monitoring • Chat</div>
|
||||
</div>
|
||||
<div class="infra-card">
|
||||
<div class="infra-host">Dell R710</div>
|
||||
<div class="infra-role">Backup • Game Servers</div>
|
||||
<div class="infra-detail">DNS • Linux VMs • Secondary services</div>
|
||||
</div>
|
||||
<div class="infra-card">
|
||||
<div class="infra-host">Desktop</div>
|
||||
<div class="infra-role">Daily Driver • Ansible Control</div>
|
||||
<div class="infra-detail">Development • 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 →</a>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
123
templates/infrastructure.html
Normal file
123
templates/infrastructure.html
Normal file
@@ -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"}}
|
||||
<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 • 16 GB RAM</span></td>
|
||||
<td>OpenBSD</td>
|
||||
<td>Firewall, router, VPN, reverse proxy<br><span class="hw-spec">pf • relayd • WireGuard • unbound</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="hw-name">srv01</td>
|
||||
<td>Dell R720<br><span class="hw-spec">Xeon E5-2600 • 64 GB RAM</span></td>
|
||||
<td>OpenBSD</td>
|
||||
<td>Primary server<br><span class="hw-spec">Gitea • httpd • OpenSMTPD • Prometheus • Grafana • Matrix</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="hw-name">srv02</td>
|
||||
<td>Dell R710<br><span class="hw-spec">Xeon 5500/5600 • 48 GB RAM</span></td>
|
||||
<td>OpenBSD + Linux VMs</td>
|
||||
<td>Backup, game servers<br><span class="hw-spec">nsd • vmm • Jellyfin • secondary DNS</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="hw-name">ws01</td>
|
||||
<td>Desktop<br><span class="hw-spec">Ryzen • 32 GB RAM</span></td>
|
||||
<td>Linux</td>
|
||||
<td>Daily driver, Ansible control node<br><span class="hw-spec">Development • 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 --> fw01 (relayd) --> srv01 (httpd/app)
|
||||
|
||||
VPN:
|
||||
WireGuard on fw01 --> 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>—</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
29
templates/post.html
Normal file
@@ -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"}}
|
||||
<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}}
|
||||
—
|
||||
{{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">← Back to build log</a>
|
||||
</footer>
|
||||
</article>
|
||||
{{end}}
|
||||
36
templates/status.html
Normal file
36
templates/status.html
Normal file
@@ -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"}}
|
||||
<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
|
||||
<span class="status-badge status-badge-degraded">degraded</span> reduced capacity
|
||||
<span class="status-badge status-badge-down">down</span> unavailable
|
||||
<span class="status-badge status-badge-unknown">unknown</span> not checked
|
||||
</div>
|
||||
{{end}}
|
||||
34
tools/genhash/main.go
Normal file
34
tools/genhash/main.go
Normal 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.")
|
||||
}
|
||||
Reference in New Issue
Block a user