WayfinderAK 6f7549d439
All checks were successful
build-image / docker (push) Successful in 53s
Implement phase 1 foundations
2026-07-03 20:14:06 -08:00

203 lines
11 KiB
Go

package server
import (
"context"
"html/template"
"net/http"
"strconv"
"strings"
"time"
"gitea.wayfinderak.com/wayfinderak/log-guardian/internal/alerts"
"gitea.wayfinderak.com/wayfinderak/log-guardian/internal/loki"
"gitea.wayfinderak.com/wayfinderak/log-guardian/internal/rules"
"gitea.wayfinderak.com/wayfinderak/log-guardian/internal/store"
)
type Server struct {
store *store.FileStore
engine *rules.Engine
loki *loki.Client
dispatcher *alerts.Dispatcher
authUsername string
authPassword string
}
func New(s *store.FileStore, engine *rules.Engine, lokiClient *loki.Client, dispatcher *alerts.Dispatcher, authUsername, authPassword string) *Server {
return &Server{store: s, engine: engine, loki: lokiClient, dispatcher: dispatcher, authUsername: authUsername, authPassword: authPassword}
}
func (s *Server) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", s.health)
mux.HandleFunc("GET /", s.index)
mux.HandleFunc("POST /rules", s.saveRule)
mux.HandleFunc("POST /check", s.checkNow)
mux.HandleFunc("POST /channels", s.saveChannel)
mux.HandleFunc("POST /channels/delete", s.deleteChannel)
mux.HandleFunc("POST /channels/test", s.testChannel)
mux.HandleFunc("GET /loki/labels", s.lokiLabels)
mux.HandleFunc("GET /loki/label-values", s.lokiLabelValues)
mux.HandleFunc("POST /loki/query-test", s.lokiQueryTest)
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("web/static"))))
return securityHeaders(requestLog(basicAuth(mux, s.authUsername, s.authPassword)))
}
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]string{"status": "ok", "service": "log-guardian"})
}
func (s *Server) index(w http.ResponseWriter, r *http.Request) {
data, err := s.store.Load()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
render(w, data)
}
func (s *Server) saveRule(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
threshold, _ := strconv.Atoi(r.FormValue("threshold"))
rule := store.Rule{ID: strings.TrimSpace(r.FormValue("id")), Name: strings.TrimSpace(r.FormValue("name")), Enabled: r.FormValue("enabled") == "on", LogQL: strings.TrimSpace(r.FormValue("logql")), Severity: strings.TrimSpace(r.FormValue("severity")), Threshold: threshold, Window: strings.TrimSpace(r.FormValue("window")), Cooldown: strings.TrimSpace(r.FormValue("cooldown")), AnalysisEnabled: r.FormValue("analysis_enabled") == "on", AlertChannels: r.Form["alert_channels"]}
if rule.Name == "" || rule.LogQL == "" {
http.Error(w, "name and LogQL are required", http.StatusBadRequest)
return
}
if rule.Severity == "" {
rule.Severity = "warning"
}
if err := s.store.UpsertRule(rule); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) saveChannel(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
channel := store.AlertChannel{ID: strings.TrimSpace(r.FormValue("id")), Name: strings.TrimSpace(r.FormValue("name")), Type: strings.TrimSpace(r.FormValue("type")), Enabled: r.FormValue("enabled") == "on", Params: map[string]string{}}
for _, key := range []string{"server_url", "topic", "token", "priority", "tags", "url", "api_url", "email"} {
if value := strings.TrimSpace(r.FormValue(key)); value != "" {
channel.Params[key] = value
}
}
if channel.Name == "" || channel.Type == "" {
http.Error(w, "name and type are required", http.StatusBadRequest)
return
}
if err := s.store.UpsertAlertChannel(channel); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) deleteChannel(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := s.store.DeleteAlertChannel(r.FormValue("id")); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) testChannel(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
data, err := s.store.Load()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
for _, channel := range data.AlertChannels {
if channel.ID == r.FormValue("id") {
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
if err := s.dispatcher.Test(ctx, channel); err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
writeJSON(w, map[string]string{"status": "sent"})
return
}
}
http.Error(w, "channel not found", http.StatusNotFound)
}
func (s *Server) checkNow(w http.ResponseWriter, r *http.Request) {
s.engine.CheckAll(context.Background())
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) lokiLabels(w http.ResponseWriter, r *http.Request) {
labels, err := s.loki.Labels(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
writeJSON(w, map[string]any{"labels": labels})
}
func (s *Server) lokiLabelValues(w http.ResponseWriter, r *http.Request) {
values, err := s.loki.LabelValues(r.Context(), r.URL.Query().Get("name"))
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
writeJSON(w, map[string]any{"values": values})
}
func (s *Server) lokiQueryTest(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
window, err := time.ParseDuration(r.FormValue("window"))
if err != nil {
window = 5 * time.Minute
}
result, err := s.loki.QueryRange(r.Context(), strings.TrimSpace(r.FormValue("logql")), window, 20)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
if len(result.Matches) > 5 {
result.Matches = result.Matches[:5]
}
writeJSON(w, result)
}
var tmpl = template.Must(template.New("index").Funcs(template.FuncMap{"secretState": secretState}).Parse(`<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Log Guardian</title><link rel="stylesheet" href="/static/app.css"></head>
<body><main class="shell"><header><h1>Log Guardian</h1><p>Loki-backed log monitoring, alerting, and guarded remediation.</p></header>
<section class="card"><h2>Loki diagnostics</h2><p>Use authenticated endpoints: <code>/loki/labels</code>, <code>/loki/label-values?name=service</code>.</p><form method="post" action="/loki/query-test" class="grid"><label class="wide">Test LogQL <input name="logql" placeholder='{service="api"} |= "error"'></label><label>Window <input name="window" value="5m"></label><button type="submit">Return JSON sample</button></form></section>
<section class="card"><h2>Add or replace rule</h2><form method="post" action="/rules" class="grid"><label>Name <input name="name" required placeholder="API errors"></label><label>ID <input name="id" placeholder="optional stable id"></label><label>Severity <input name="severity" value="warning"></label><label>Window <input name="window" value="5m"></label><label>Cooldown <input name="cooldown" value="1h"></label><label>Threshold <input name="threshold" type="number" min="1" value="1"></label><label class="wide">LogQL <input name="logql" required placeholder='{service="api"} |= "error"'></label><fieldset class="wide"><legend>Alert channels</legend>{{range .AlertChannels}}<label class="check"><input type="checkbox" name="alert_channels" value="{{.ID}}"> {{.Name}} ({{.Type}})</label>{{else}}<span class="muted">No channels configured.</span>{{end}}</fieldset><label class="check"><input type="checkbox" name="enabled" checked> Enabled</label><label class="check"><input type="checkbox" name="analysis_enabled"> AI analysis</label><button type="submit">Save rule</button></form></section>
<section class="card"><h2>Alert channels</h2><form method="post" action="/channels" class="grid"><label>Name <input name="name" required></label><label>ID <input name="id" placeholder="optional stable id"></label><label>Type <select name="type"><option value="ntfy">ntfy</option><option value="gotify">gotify (scaffold)</option><option value="generic_webhook">generic webhook (scaffold)</option><option value="smtp">smtp (scaffold)</option></select></label><label class="check"><input type="checkbox" name="enabled" checked> Enabled</label><label>Server URL <input name="server_url" placeholder="https://ntfy.example.com"></label><label>Topic <input name="topic"></label><label>Token <input name="token" type="password" placeholder="leave blank to preserve"></label><label>Priority <input name="priority" placeholder="default, high, urgent"></label><label>Tags <input name="tags" placeholder="warning,rotating_light"></label><button type="submit">Save channel</button></form><table><thead><tr><th>Name</th><th>Type</th><th>Status</th><th>Secret</th><th>Actions</th></tr></thead><tbody>{{range .AlertChannels}}<tr><td>{{.Name}}</td><td>{{.Type}}</td><td>{{if .Enabled}}enabled{{else}}disabled{{end}}</td><td>{{secretState .Params}}</td><td><form method="post" action="/channels/test" class="inline"><input type="hidden" name="id" value="{{.ID}}"><button>Test</button></form><form method="post" action="/channels/delete" class="inline"><input type="hidden" name="id" value="{{.ID}}"><button>Delete</button></form></td></tr>{{else}}<tr><td colspan="5" class="muted">No alert channels configured.</td></tr>{{end}}</tbody></table></section>
<section class="card"><div class="row"><h2>Rules</h2><form method="post" action="/check"><button type="submit">Check now</button></form></div><table><thead><tr><th>Name</th><th>LogQL</th><th>Cooldown</th><th>Last count</th><th>Suppressed</th><th>Status</th></tr></thead><tbody>{{range .Rules}}<tr><td>{{.Name}}{{if not .Enabled}} <span class="muted">disabled</span>{{end}}</td><td><code>{{.LogQL}}</code></td><td>{{.Cooldown}}</td><td>{{.LastMatchCount}}</td><td>{{.SuppressedCount}}</td><td>{{if .LastError}}<span class="error">{{.LastError}}</span>{{else}}checked {{.LastCheckedAt}}{{end}}</td></tr>{{else}}<tr><td colspan="6" class="muted">No rules configured yet.</td></tr>{{end}}</tbody></table></section>
<section class="card"><h2>Recent incidents</h2><table><thead><tr><th>Time</th><th>Rule</th><th>Severity</th><th>Summary</th><th>Evidence</th></tr></thead><tbody>{{range .Incidents}}<tr><td>{{.CreatedAt}}</td><td>{{.RuleName}}</td><td>{{.Severity}}</td><td>{{.Summary}}</td><td>{{range .AlertResults}}<div>{{.}}</div>{{end}}{{range .RemediationEvidence}}<div>{{.}}</div>{{end}}</td></tr>{{else}}<tr><td colspan="5" class="muted">No incidents recorded.</td></tr>{{end}}</tbody></table></section>
</main></body></html>`))
func secretState(params map[string]string) string {
if params["token"] != "" || params["password"] != "" || params["secret"] != "" {
return "configured"
}
return "not configured"
}
func render(w http.ResponseWriter, data store.Data) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = tmpl.Execute(w, data)
}