From 58831e2429cd3dca42b054432d21f5aafe938351 Mon Sep 17 00:00:00 2001 From: Blake Ridgway Date: Wed, 11 Mar 2026 14:12:52 -0500 Subject: [PATCH] add rate limiting, CSRF, newsletter, auto-checker, /uses and /projects pages --- .env.example | 8 ++ cmd/server/main.go | 35 ++++++- data/status.json | 2 + internal/checker/checker.go | 88 +++++++++++++++++ internal/handler/admin.go | 42 +++++++++ internal/handler/csrf.go | 37 ++++++++ internal/handler/handler.go | 12 ++- internal/handler/public.go | 90 +++++++++++++++--- internal/newsletter/newsletter.go | 111 ++++++++++++++++++++++ internal/ratelimit/ratelimit.go | 91 ++++++++++++++++++ internal/status/status.go | 14 ++- static/css/style.css | 151 ++++++++++++++++++++++++++++++ templates/admin/dashboard.html | 1 + templates/admin/newsletter.html | 45 +++++++++ templates/hire.html | 19 ++++ templates/projects.html | 87 +++++++++++++++++ templates/uses.html | 99 ++++++++++++++++++++ 17 files changed, 913 insertions(+), 19 deletions(-) create mode 100644 internal/checker/checker.go create mode 100644 internal/handler/csrf.go create mode 100644 internal/newsletter/newsletter.go create mode 100644 internal/ratelimit/ratelimit.go create mode 100644 templates/admin/newsletter.html create mode 100644 templates/projects.html create mode 100644 templates/uses.html diff --git a/.env.example b/.env.example index 8d06ec0..7d6119b 100644 --- a/.env.example +++ b/.env.example @@ -43,3 +43,11 @@ SESSION_SECRET=change-me-use-openssl-rand-hex-32 # Email address that receives hire form submissions. # Requires a working local MTA (OpenSMTPD) listening on localhost:25. CONTACT_EMAIL=hire@ridgwaysystems.org + +# ------------------------------------------------------------------ +# Service checker +# ------------------------------------------------------------------ + +# How often (in minutes) to HTTP-check services with a check_url in status.json. +# Default: 5 +CHECK_INTERVAL=5 diff --git a/cmd/server/main.go b/cmd/server/main.go index ef24796..d26957b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -5,12 +5,17 @@ import ( "log" "net/http" "os" + "path/filepath" + "strconv" + "time" chromahtml "github.com/alecthomas/chroma/v2/formatters/html" "github.com/alecthomas/chroma/v2/styles" "ridgwaysystems.org/website/internal/blog" + "ridgwaysystems.org/website/internal/checker" "ridgwaysystems.org/website/internal/handler" + "ridgwaysystems.org/website/internal/newsletter" ) func main() { @@ -28,11 +33,21 @@ func main() { log.Fatal("store:", err) } - h := handler.New(store, dataDir) + news, err := newsletter.NewStore(filepath.Join(dataDir, "subscribers.json")) + if err != nil { + log.Fatal("newsletter store:", err) + } + + h := handler.New(store, news, dataDir) + + // Start service auto-checker + checkInterval := checkerInterval() + checker.Start(dataDir, checkInterval) + log.Printf("service checker running every %s", checkInterval) mux := http.NewServeMux() - // Static files (includes robots.txt via static/robots.txt) + // Static files mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) // robots.txt at root @@ -48,9 +63,12 @@ func main() { mux.HandleFunc("GET /infrastructure", h.Infrastructure) mux.HandleFunc("GET /status", h.Status) mux.HandleFunc("GET /about", h.About) + mux.HandleFunc("GET /uses", h.Uses) + mux.HandleFunc("GET /projects", h.Projects) mux.HandleFunc("GET /hire", h.Hire) mux.HandleFunc("POST /hire", h.HirePost) mux.HandleFunc("GET /resume", h.Resume) + mux.HandleFunc("POST /newsletter", h.NewsletterPost) mux.HandleFunc("GET /sitemap.xml", h.Sitemap) // Admin routes (auth handled per-handler) @@ -66,19 +84,26 @@ func main() { log.Fatal(http.ListenAndServe(":"+port, srv)) } +// checkerInterval returns the status check interval from CHECK_INTERVAL env (minutes). +func checkerInterval() time.Duration { + if s := os.Getenv("CHECK_INTERVAL"); s != "" { + if n, err := strconv.Atoi(s); err == nil && n > 0 { + return time.Duration(n) * time.Minute + } + } + return 5 * time.Minute +} + // 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 { diff --git a/data/status.json b/data/status.json index fe4d95b..0dbe53d 100644 --- a/data/status.json +++ b/data/status.json @@ -4,12 +4,14 @@ { "name": "Web (httpd)", "description": "ridgwaysystems.org", + "check_url": "https://ridgwaysystems.org", "status": "up" }, { "name": "Gitea", "description": "git.ridgwaysystems.org", "url": "https://git.ridgwaysystems.org", + "check_url": "https://git.ridgwaysystems.org", "status": "up" }, { diff --git a/internal/checker/checker.go b/internal/checker/checker.go new file mode 100644 index 0000000..6bfbdb4 --- /dev/null +++ b/internal/checker/checker.go @@ -0,0 +1,88 @@ +// Package checker periodically HTTP-checks services and updates status.json. +package checker + +import ( + "log" + "net/http" + "path/filepath" + "time" + + "ridgwaysystems.org/website/internal/status" +) + +// Start launches the background checker. It checks services every interval +// and updates status.json in dataDir. Services without a CheckURL are skipped. +func Start(dataDir string, interval time.Duration) { + go run(dataDir, interval) +} + +func run(dataDir string, interval time.Duration) { + client := &http.Client{ + Timeout: 10 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 3 { + return http.ErrUseLastResponse + } + return nil + }, + } + path := filepath.Join(dataDir, "status.json") + + for { + check(client, path) + time.Sleep(interval) + } +} + +func check(client *http.Client, path string) { + page, err := status.Load(path) + if err != nil { + log.Printf("checker: load %s: %v", path, err) + return + } + + changed := false + for i, svc := range page.Services { + if svc.CheckURL == "" { + continue + } + + prev := svc.Status + newStatus := probe(client, svc.CheckURL) + if newStatus != prev { + log.Printf("checker: %s %s → %s", svc.Name, prev, newStatus) + page.Services[i].Status = newStatus + changed = true + } + } + + if changed { + if err := status.Save(path, page); err != nil { + log.Printf("checker: save %s: %v", path, err) + } + } else { + // Still update last_checked timestamp so the status page shows freshness + page.LastChecked = time.Now().UTC() + if err := status.Save(path, page); err != nil { + log.Printf("checker: save %s: %v", path, err) + } + } +} + +func probe(client *http.Client, url string) string { + resp, err := client.Get(url) + if err != nil { + return "down" + } + defer resp.Body.Close() + + switch { + case resp.StatusCode < 400: + return "up" + case resp.StatusCode >= 500: + return "down" + default: + // 4xx could mean the service is up but the URL is wrong; treat as degraded + return "degraded" + } +} diff --git a/internal/handler/admin.go b/internal/handler/admin.go index 1d77841..78408c6 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -12,6 +12,7 @@ import ( "time" "ridgwaysystems.org/website/internal/blog" + "ridgwaysystems.org/website/internal/newsletter" "ridgwaysystems.org/website/internal/status" ) @@ -63,6 +64,13 @@ func (h *Handler) AdminRouter(w http.ResponseWriter, r *http.Request) { case path == "/admin/uploads": h.requireAuth(h.adminUploads)(w, r) + case path == "/admin/newsletter": + if r.Method == http.MethodPost { + h.requireAuth(h.adminNewsletterDelete)(w, r) + } else { + h.requireAuth(h.adminNewsletter)(w, r) + } + default: h.renderErr(w, http.StatusNotFound, "Admin page not found.") } @@ -374,6 +382,40 @@ func (h *Handler) adminUploads(w http.ResponseWriter, r *http.Request) { h.render(w, "admin-uploads", uploadsData{Files: files, Flash: r.URL.Query().Get("flash")}) } +// --- Newsletter admin --- + +type adminNewsletterData struct { + Subscribers []newsletter.Subscriber + Flash string + Count int +} + +func (h *Handler) adminNewsletter(w http.ResponseWriter, r *http.Request) { + subs, err := h.news.All() + if err != nil { + h.renderErr(w, http.StatusInternalServerError, "Could not load subscribers.") + return + } + h.render(w, "admin-newsletter", adminNewsletterData{ + Subscribers: subs, + Count: len(subs), + Flash: r.URL.Query().Get("flash"), + }) +} + +func (h *Handler) adminNewsletterDelete(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + h.renderErr(w, http.StatusBadRequest, "Bad form data.") + return + } + email := r.FormValue("email") + if err := h.news.Remove(email); err != nil { + h.renderErr(w, http.StatusInternalServerError, "Remove failed: "+err.Error()) + return + } + http.Redirect(w, r, "/admin/newsletter?flash=Removed", http.StatusSeeOther) +} + // sanitizeSlug ensures a slug is filesystem-safe. func sanitizeSlug(s string) string { s = strings.ToLower(strings.TrimSpace(s)) diff --git a/internal/handler/csrf.go b/internal/handler/csrf.go new file mode 100644 index 0000000..466412f --- /dev/null +++ b/internal/handler/csrf.go @@ -0,0 +1,37 @@ +package handler + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "time" +) + +// csrfToken returns an HMAC token valid for the current and previous hour. +// Reuses the session secret so no additional secret is required. +func csrfToken() string { + return csrfTokenForTime(time.Now().UTC()) +} + +func csrfTokenForTime(t time.Time) string { + bucket := t.Truncate(time.Hour).Unix() + mac := hmac.New(sha256.New, sessionSecret()) + mac.Write([]byte(fmt.Sprintf("csrf:%d", bucket))) + return hex.EncodeToString(mac.Sum(nil)) +} + +// csrfValid returns true if token matches the current or previous hour's token. +func csrfValid(token string) bool { + if token == "" { + return false + } + now := time.Now().UTC() + for _, t := range []time.Time{now, now.Add(-time.Hour)} { + expected := csrfTokenForTime(t) + if hmac.Equal([]byte(token), []byte(expected)) { + return true + } + } + return false +} diff --git a/internal/handler/handler.go b/internal/handler/handler.go index d5d3a6f..25b0ffe 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -8,28 +8,35 @@ import ( "net/http" "os" "path/filepath" + "time" "ridgwaysystems.org/website/internal/blog" + "ridgwaysystems.org/website/internal/newsletter" + "ridgwaysystems.org/website/internal/ratelimit" ) // Handler holds shared dependencies for all HTTP handlers. type Handler struct { store *blog.Store + news *newsletter.Store dataDir string templates map[string]*template.Template siteURL string contactEmail string devMode bool + postLimit *ratelimit.Limiter // rate-limits contact + newsletter POSTs } // New creates a Handler. dataDir is the path to the data/ directory. -func New(store *blog.Store, dataDir string) *Handler { +func New(store *blog.Store, news *newsletter.Store, dataDir string) *Handler { h := &Handler{ store: store, + news: news, dataDir: dataDir, siteURL: getenv("SITE_URL", "https://ridgwaysystems.org"), contactEmail: getenv("CONTACT_EMAIL", "hire@ridgwaysystems.org"), devMode: os.Getenv("DEV") == "1", + postLimit: ratelimit.New(10*time.Minute, 5), } if !h.devMode { h.templates = mustLoadTemplates() @@ -74,12 +81,15 @@ func mustLoadTemplates() map[string]*template.Template { {"about", "templates/about.html"}, {"hire", "templates/hire.html"}, {"resume", "templates/resume.html"}, + {"uses", "templates/uses.html"}, + {"projects", "templates/projects.html"}, {"error", "templates/error.html"}, {"admin-login", "templates/admin/login.html"}, {"admin-dashboard", "templates/admin/dashboard.html"}, {"admin-editor", "templates/admin/editor.html"}, {"admin-status", "templates/admin/status.html"}, {"admin-uploads", "templates/admin/uploads.html"}, + {"admin-newsletter", "templates/admin/newsletter.html"}, } for _, p := range pages { diff --git a/internal/handler/public.go b/internal/handler/public.go index 9ce8e34..0dd7d18 100644 --- a/internal/handler/public.go +++ b/internal/handler/public.go @@ -182,31 +182,57 @@ func (h *Handler) Resume(w http.ResponseWriter, r *http.Request) { h.render(w, "resume", nil) } +func (h *Handler) Uses(w http.ResponseWriter, r *http.Request) { + h.render(w, "uses", nil) +} + +func (h *Handler) Projects(w http.ResponseWriter, r *http.Request) { + h.render(w, "projects", nil) +} + // --- Hire / Contact --- type hireData struct { - Name string - Email string - Company string - Message string - Error string - Success bool + Name string + Email string + Company string + Message string + Error string + Success bool + CSRFToken string } func (h *Handler) Hire(w http.ResponseWriter, r *http.Request) { - h.render(w, "hire", hireData{}) + h.render(w, "hire", hireData{CSRFToken: csrfToken()}) } func (h *Handler) HirePost(w http.ResponseWriter, r *http.Request) { + // Rate limit + if !h.postLimit.Allow(r.RemoteAddr) { + h.renderErr(w, http.StatusTooManyRequests, "Too many requests. Please try again later.") + return + } if err := r.ParseForm(); err != nil { h.renderErr(w, http.StatusBadRequest, "Bad form data.") return } + // Honeypot: bots fill hidden fields, humans don't + if r.FormValue("website") != "" { + // Silently succeed to not reveal the check + h.render(w, "hire", hireData{Success: true}) + return + } + // CSRF + if !csrfValid(r.FormValue("csrf_token")) { + h.renderErr(w, http.StatusForbidden, "Invalid or expired form token. Please reload the page and try again.") + return + } d := hireData{ - Name: strings.TrimSpace(r.FormValue("name")), - Email: strings.TrimSpace(r.FormValue("email")), - Company: strings.TrimSpace(r.FormValue("company")), - Message: strings.TrimSpace(r.FormValue("message")), + Name: strings.TrimSpace(r.FormValue("name")), + Email: strings.TrimSpace(r.FormValue("email")), + Company: strings.TrimSpace(r.FormValue("company")), + Message: strings.TrimSpace(r.FormValue("message")), + CSRFToken: csrfToken(), } if d.Name == "" || d.Email == "" || d.Message == "" { d.Error = "Name, email, and message are required." @@ -234,6 +260,44 @@ func (h *Handler) HirePost(w http.ResponseWriter, r *http.Request) { h.render(w, "hire", d) } +// --- Newsletter --- + +type newsletterData struct { + Error string + Success bool +} + +func (h *Handler) NewsletterPost(w http.ResponseWriter, r *http.Request) { + // Rate limit + if !h.postLimit.Allow(r.RemoteAddr) { + http.Error(w, "Too many requests", http.StatusTooManyRequests) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + // Honeypot + if r.FormValue("url") != "" { + http.Redirect(w, r, r.Referer(), http.StatusSeeOther) + return + } + email := strings.ToLower(strings.TrimSpace(r.FormValue("email"))) + if email == "" || !strings.Contains(email, "@") { + http.Redirect(w, r, r.Referer()+"?subscribe=invalid", http.StatusSeeOther) + return + } + if _, err := h.news.Add(email); err != nil { + log.Printf("newsletter add %s: %v", email, err) + } + // Redirect back with success param regardless (don't confirm existence) + ref := r.Referer() + if ref == "" { + ref = "/" + } + http.Redirect(w, r, ref+"?subscribe=ok", http.StatusSeeOther) +} + // --- Sitemap --- type urlset struct { @@ -256,7 +320,9 @@ func (h *Handler) Sitemap(w http.ResponseWriter, r *http.Request) { {Loc: h.siteURL + "/", Freq: "weekly", Prio: "1.0"}, {Loc: h.siteURL + "/blog", Freq: "weekly", Prio: "0.9"}, {Loc: h.siteURL + "/hire", Freq: "monthly", Prio: "0.9"}, - {Loc: h.siteURL + "/resume", Freq: "monthly", Prio: "0.7"}, + {Loc: h.siteURL + "/resume", Freq: "monthly", Prio: "0.8"}, + {Loc: h.siteURL + "/projects", Freq: "monthly", Prio: "0.7"}, + {Loc: h.siteURL + "/uses", Freq: "monthly", Prio: "0.6"}, {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"}, diff --git a/internal/newsletter/newsletter.go b/internal/newsletter/newsletter.go new file mode 100644 index 0000000..db0f886 --- /dev/null +++ b/internal/newsletter/newsletter.go @@ -0,0 +1,111 @@ +// Package newsletter manages email subscriber storage as a flat JSON file. +package newsletter + +import ( + "encoding/json" + "errors" + "os" + "strings" + "sync" + "time" +) + +// Subscriber holds a single email subscription. +type Subscriber struct { + Email string `json:"email"` + CreatedAt time.Time `json:"created_at"` +} + +// Store manages subscribers persisted to a JSON file. +type Store struct { + mu sync.RWMutex + path string +} + +// NewStore returns a Store backed by path, creating the file if needed. +func NewStore(path string) (*Store, error) { + s := &Store{path: path} + // Create empty file if it doesn't exist + if _, err := os.Stat(path); os.IsNotExist(err) { + if err := s.save(nil); err != nil { + return nil, err + } + } + return s, nil +} + +// All returns all current subscribers. +func (s *Store) All() ([]Subscriber, error) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.load() +} + +// Add adds an email if it isn't already subscribed. Returns false if duplicate. +func (s *Store) Add(email string) (bool, error) { + email = strings.ToLower(strings.TrimSpace(email)) + if email == "" { + return false, errors.New("email is required") + } + + s.mu.Lock() + defer s.mu.Unlock() + + subs, err := s.load() + if err != nil { + return false, err + } + for _, sub := range subs { + if sub.Email == email { + return false, nil // already subscribed + } + } + subs = append(subs, Subscriber{Email: email, CreatedAt: time.Now().UTC()}) + return true, s.save(subs) +} + +// Remove unsubscribes an email address. +func (s *Store) Remove(email string) error { + email = strings.ToLower(strings.TrimSpace(email)) + + s.mu.Lock() + defer s.mu.Unlock() + + subs, err := s.load() + if err != nil { + return err + } + filtered := subs[:0] + for _, sub := range subs { + if sub.Email != email { + filtered = append(filtered, sub) + } + } + return s.save(filtered) +} + +func (s *Store) load() ([]Subscriber, error) { + raw, err := os.ReadFile(s.path) + if err != nil { + return nil, err + } + if len(raw) == 0 { + return nil, nil + } + var subs []Subscriber + if err := json.Unmarshal(raw, &subs); err != nil { + return nil, err + } + return subs, nil +} + +func (s *Store) save(subs []Subscriber) error { + if subs == nil { + subs = []Subscriber{} + } + raw, err := json.MarshalIndent(subs, "", " ") + if err != nil { + return err + } + return os.WriteFile(s.path, raw, 0600) +} diff --git a/internal/ratelimit/ratelimit.go b/internal/ratelimit/ratelimit.go new file mode 100644 index 0000000..d065af2 --- /dev/null +++ b/internal/ratelimit/ratelimit.go @@ -0,0 +1,91 @@ +// Package ratelimit provides a simple in-memory per-IP sliding-window rate limiter. +package ratelimit + +import ( + "net" + "net/http" + "sync" + "time" +) + +// Limiter tracks request counts per IP within a sliding window. +type Limiter struct { + mu sync.Mutex + entries map[string][]time.Time + window time.Duration + max int +} + +// New creates a Limiter that allows at most max requests per window per IP. +func New(window time.Duration, max int) *Limiter { + l := &Limiter{ + entries: make(map[string][]time.Time), + window: window, + max: max, + } + go l.cleanup() + return l +} + +// Allow returns true if the IP is within its rate limit, recording the attempt. +func (l *Limiter) Allow(ip string) bool { + now := time.Now() + cutoff := now.Add(-l.window) + + l.mu.Lock() + defer l.mu.Unlock() + + times := l.entries[ip] + recent := times[:0] + for _, t := range times { + if t.After(cutoff) { + recent = append(recent, t) + } + } + + if len(recent) >= l.max { + l.entries[ip] = recent + return false + } + + l.entries[ip] = append(recent, now) + return true +} + +// Middleware wraps a handler, rejecting over-limit requests with 429. +func (l *Limiter) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + ip = r.RemoteAddr + } + if !l.Allow(ip) { + http.Error(w, "Too many requests. Please wait and try again.", http.StatusTooManyRequests) + return + } + next.ServeHTTP(w, r) + }) +} + +// cleanup periodically removes expired entries to prevent unbounded growth. +func (l *Limiter) cleanup() { + for { + time.Sleep(5 * time.Minute) + cutoff := time.Now().Add(-l.window) + l.mu.Lock() + for ip, times := range l.entries { + recent := times[:0] + for _, t := range times { + if t.After(cutoff) { + recent = append(recent, t) + } + } + if len(recent) == 0 { + delete(l.entries, ip) + } else { + l.entries[ip] = recent + } + } + l.mu.Unlock() + } +} diff --git a/internal/status/status.go b/internal/status/status.go index 77266ad..f51a422 100644 --- a/internal/status/status.go +++ b/internal/status/status.go @@ -4,15 +4,19 @@ package status import ( "encoding/json" "os" + "sync" "time" ) +var mu sync.RWMutex + // 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" + CheckURL string `json:"check_url,omitempty"` // HTTP URL probed automatically; empty = manual + Status string `json:"status"` // "up", "degraded", "down", "unknown" Note string `json:"note,omitempty"` } @@ -24,6 +28,12 @@ type Page struct { // Load reads and parses the status JSON from path. func Load(path string) (*Page, error) { + mu.RLock() + defer mu.RUnlock() + return load(path) +} + +func load(path string) (*Page, error) { raw, err := os.ReadFile(path) if err != nil { return nil, err @@ -37,6 +47,8 @@ func Load(path string) (*Page, error) { // Save writes the status page data back to path. func Save(path string, p *Page) error { + mu.Lock() + defer mu.Unlock() p.LastChecked = time.Now().UTC() raw, err := json.MarshalIndent(p, "", " ") if err != nil { diff --git a/static/css/style.css b/static/css/style.css index c8c54a5..1e49491 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1328,6 +1328,157 @@ blockquote { .resume-skills dd { color: #333 !important; } } +/* === Honeypot field (hidden from humans) === */ + +.hp-field { + position: absolute; + left: -9999px; + top: -9999px; + width: 1px; + height: 1px; + overflow: hidden; + opacity: 0; + pointer-events: none; + tab-index: -1; +} + +/* === Subscribe widget === */ + +.subscribe-section { + margin-bottom: 2.5rem; + padding-bottom: 2rem; + border-bottom: 1px solid var(--border); +} + +.subscribe-section h2 { margin-bottom: 0.3em; } + +.subscribe-form { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin-top: 0.85rem; + max-width: 440px; +} + +.subscribe-form input[type="email"] { + flex: 1; + min-width: 200px; + 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-sans); +} + +.subscribe-form input[type="email"]:focus { + outline: 2px solid var(--accent); + outline-offset: 1px; + border-color: var(--accent); +} + +/* === Uses Page === */ + +.uses-page { max-width: var(--max-w); } + +.uses-section { + margin-bottom: 2.5rem; +} + +.uses-section h2 { + font-size: 1.1rem; + border-bottom: 1px solid var(--border); + padding-bottom: 0.35em; + margin-bottom: 1.25rem; +} + +.uses-item { + margin-bottom: 1.5rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid var(--border); +} + +.uses-item:last-child { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; +} + +.uses-item-header { + display: flex; + align-items: baseline; + gap: 0.75rem; + margin-bottom: 0.4em; + flex-wrap: wrap; +} + +.uses-name { + font-family: var(--font-mono); + font-weight: 700; + font-size: 1rem; + color: var(--accent); +} + +.uses-role { + font-size: 0.85rem; + color: var(--text-muted); + font-family: var(--font-mono); +} + +.uses-item p { margin: 0; font-size: 0.9rem; line-height: 1.7; } + +.uses-list { + font-size: 0.9rem; + line-height: 1.7; + margin: 0; +} + +.uses-list li { margin-bottom: 0.4em; } + +/* === Projects Page === */ + +.projects-page { max-width: var(--max-w); } + +.project-list { display: flex; flex-direction: column; gap: 0; } + +.project-item { + padding: 1.75rem 0; + border-bottom: 1px solid var(--border); +} + +.project-item:first-child { padding-top: 0; } +.project-item:last-child { border-bottom: none; } + +.project-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 0.6em; +} + +.project-title { + font-size: 1.15rem; + margin: 0; + line-height: 1.3; +} + +.project-item p { + font-size: 0.9rem; + line-height: 1.7; + color: var(--text); + margin-bottom: 0.75em; +} + +.project-links { + display: flex; + gap: 1rem; + font-size: 0.85rem; + font-family: var(--font-mono); +} + /* === Responsive === */ @media (max-width: 600px) { diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html index cb14974..f35fded 100644 --- a/templates/admin/dashboard.html +++ b/templates/admin/dashboard.html @@ -8,6 +8,7 @@ New Post Edit Status Uploads + Newsletter View Site
diff --git a/templates/admin/newsletter.html b/templates/admin/newsletter.html new file mode 100644 index 0000000..17c4d72 --- /dev/null +++ b/templates/admin/newsletter.html @@ -0,0 +1,45 @@ +{{define "title"}}Newsletter — Admin{{end}} + +{{define "content"}} +
+
+

Newsletter Subscribers

+ +
+ + {{if .Flash}}

{{.Flash}}

{{end}} + +

{{.Count}} subscriber{{if ne .Count 1}}s{{end}}

+ + {{if not .Subscribers}} +

No subscribers yet.

+ {{else}} + + + + + + + + + + {{range .Subscribers}} + + + + + + {{end}} + +
EmailSubscribedActions
{{.Email}}{{formatDate .CreatedAt}} + + + + +
+ {{end}} +
+{{end}} diff --git a/templates/hire.html b/templates/hire.html index 5d38f80..d541a94 100644 --- a/templates/hire.html +++ b/templates/hire.html @@ -49,6 +49,19 @@ Pricing by project or hourly — contact me for details. + +

Get in touch

Tell me about your project or problem. I'll respond within one business day.

@@ -61,6 +74,12 @@ {{if .Error}}

{{.Error}}

{{end}}
+ + {{/* Honeypot: hidden from humans, bots fill it in */}} +
diff --git a/templates/projects.html b/templates/projects.html new file mode 100644 index 0000000..eab7c33 --- /dev/null +++ b/templates/projects.html @@ -0,0 +1,87 @@ +{{define "title"}}Projects — Ridgway Systems{{end}} +{{define "meta-desc"}}Infrastructure projects and builds by Blake Ridgway — homelab, monitoring systems, security tooling, and more.{{end}} + +{{define "content"}} +
+ + +
+ +
+
+

ridgwaysystems.org

+
+ Go + OpenBSD + self-hosted +
+
+

This site. A single Go binary serving a blog, status page, hire page, and admin panel — no database, no Docker, no external dependencies at runtime. Flat Markdown files on disk, HMAC-signed sessions, chroma syntax highlighting. Deployed on OpenBSD behind relayd. The build log covers the whole thing.

+ +
+ +
+
+

Policy-as-Code Firewall Framework

+
+ pf + IaC + security +
+
+

A policy-as-code system for managing pf firewall rules across multiple OpenBSD hosts. Rules defined in structured configuration, rendered to pf.conf via templating, with automated geo-location blocking and rule validation before deployment. Deployed at Triangle Insurance to manage ~200 rules across three firewall segments.

+ +
+ +
+
+

ISP Network Monitoring

+
+ Prometheus + Grafana + Go +
+
+

Custom Prometheus exporter that continuously measures ISP throughput, latency, and packet loss across multiple WAN connections. Exports to Grafana for real-time dashboards and alerting. Replaced manual speed tests that only caught outages after users complained. Cut time-to-detect WAN degradation from hours to minutes.

+
+ +
+
+

Homelab Infrastructure

+
+ OpenBSD + Ansible + Terraform + homelab +
+
+

The homelab: fw01 running OpenBSD with pf and WireGuard, two Dell rack servers, VLAN-segmented network (management, servers, IoT, guest), self-hosted Gitea, Matrix, Jellyfin, Prometheus, and Grafana. Fully documented, IaC'd where possible, and used as a test bed before anything touches production.

+ +
+ +
+
+

Zero-Touch Provisioning System

+
+ PXE + Ansible + automation +
+
+

PXE boot + Ansible-based provisioning pipeline for deploying standardized workstation images across Air Force Training bases. Reduced per-machine setup time by 75% and eliminated configuration drift between deployments. Machines boot, pull config from the server, and are production-ready without a human touching them after the initial PXE boot.

+
+ +
+
+{{end}} diff --git a/templates/uses.html b/templates/uses.html new file mode 100644 index 0000000..b978a13 --- /dev/null +++ b/templates/uses.html @@ -0,0 +1,99 @@ +{{define "title"}}Uses — Ridgway Systems{{end}} +{{define "meta-desc"}}Hardware, software, and tools Blake Ridgway uses in the homelab and day-to-day work.{{end}} + +{{define "content"}} +
+ + +
+

Hardware

+ +
+
+ fw01 + Firewall / Router +
+

SuperMicro 1U, Intel E3-1230v2, 16GB ECC RAM. Running OpenBSD. Handles all pf firewall rules, VLANs, WireGuard VPN, unbound DNS, and relayd reverse proxy. The critical piece everything else depends on.

+
+ +
+
+ srv01 + Primary Services +
+

Dell PowerEdge R720, dual Xeon E5-2600, 64GB RAM. Main workload server — runs Prometheus, Grafana, Gitea, OpenSMTPD, Matrix/Conduit. Loud and power-hungry, but handles everything without complaint.

+
+ +
+
+ srv02 + Media / Secondary +
+

Dell PowerEdge R710. Jellyfin media server, game server VMs, secondary storage, authoritative DNS (nsd). The workhorse for anything that doesn't need to be bulletproof.

+
+ +
+
+ ws01 + Workstation +
+

Desktop, AMD Ryzen. Daily driver for development, terminal sessions, and homelab management. Running Fedora Linux.

+
+
+ +
+

Operating Systems

+
    +
  • OpenBSD — fw01, this web server. Chosen for its security defaults, pf, and the fact that it does exactly what it says on the tin.
  • +
  • AlmaLinux / Rocky — srv01, srv02. RHEL-compatible for production workloads where SELinux and systemd are expected.
  • +
  • Fedora — Workstation. Stays close to bleeding-edge tooling without being Arch.
  • +
+
+ +
+

Networking

+
    +
  • pf — OpenBSD packet filter. VLANs, NAT, geo-blocking, antispoof. The whole reason fw01 runs OpenBSD.
  • +
  • WireGuard — VPN for remote access. Simple, fast, auditable.
  • +
  • unbound — Recursive DNS resolver on fw01. Validates DNSSEC, blocks ad/tracking domains.
  • +
  • nsd — Authoritative DNS on srv02 for the ridgwaysystems.org zone.
  • +
  • relayd — OpenBSD reverse proxy in front of this site and internal services.
  • +
+
+ +
+

Infrastructure & Automation

+
    +
  • Terraform — Cloud infrastructure (Azure, AWS). Anything that touches a cloud API gets IaC'd.
  • +
  • Ansible — Configuration management for Linux servers. Idempotent, no agent required.
  • +
  • Gitea — Self-hosted git at git.ridgwaysystems.org. Lightweight, fast, no subscription required.
  • +
  • Prometheus + Grafana — Metrics and dashboards for everything. Custom exporters for pf counters, ISP throughput, and hardware sensors.
  • +
  • Nagios — Service alerting. Opinionated but reliable — been running since before dashboards were cool.
  • +
+
+ +
+

Development

+
    +
  • VS Code — Primary editor. Remote SSH extension makes working directly on servers seamless.
  • +
  • Go — Preferred language for infrastructure tooling and this site. Fast to compile, easy to deploy a single binary.
  • +
  • Python — Scripting, automation, quick data processing.
  • +
  • Bash / ksh — Bash on Linux, ksh on OpenBSD. Shell scripts for anything that doesn't need to outlast the week.
  • +
  • tmux — Terminal multiplexer. Multiple panes across multiple SSH sessions, always.
  • +
+
+ +
+

Self-hosted Services

+
    +
  • OpenSMTPD — Mail server. Handles inbound and outbound for ridgwaysystems.org.
  • +
  • Matrix / Conduit — Self-hosted chat. Federated, encrypted. Currently migrating.
  • +
  • Jellyfin — Media server. No subscription, no phone-home, streams anywhere on the LAN.
  • +
+
+ +
+{{end}}