Add the Hammerhead control plane
Dashboard and API, accounts (organizations, roles, invitations, 2FA, OIDC single sign-on, API keys, revocable sessions), an encrypted SQLite database, resource logins, alerts and /metrics, mail egress management with DKIM keys, and the .env loader. Split out of the original single repository; depends on Reef for the shared types. The end-to-end tests that run Hammerhead, Blacktip and Bamboo together live in e2e/. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3daa368233
commit
f521fa8668
35 files changed
+10392
-1
No files matched your search
+203
@@ -0,0 +1,203 @@
|
||||
package hammerhead
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The same handlers serve an organization's channels and the server-level ones
|
||||
// (for edges, which belong to nobody); only the scope differs.
|
||||
|
||||
type alertScope struct {
|
||||
org string // "" = server level
|
||||
trusted bool // may allow private webhook targets
|
||||
actor string
|
||||
}
|
||||
|
||||
type channelView struct {
|
||||
AlertChannel
|
||||
Preview string `json:"preview"`
|
||||
}
|
||||
|
||||
func preview(ch AlertChannel) string {
|
||||
if ch.Kind != "webhook" {
|
||||
return ch.Target
|
||||
}
|
||||
if u, err := url.Parse(ch.Target); err == nil {
|
||||
return u.Scheme + "://" + u.Host + "/…"
|
||||
}
|
||||
return "webhook"
|
||||
}
|
||||
|
||||
type alertRow struct {
|
||||
Key string `json:"key"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Message string `json:"message"`
|
||||
Since time.Time `json:"since"`
|
||||
Fired bool `json:"fired"`
|
||||
}
|
||||
|
||||
func (s *Server) alertOverview(w http.ResponseWriter, sc alertScope) {
|
||||
s.mu.Lock()
|
||||
chans := []channelView{}
|
||||
for _, c := range s.st.Channels {
|
||||
if c.OrgID == sc.org {
|
||||
v := channelView{AlertChannel: *c, Preview: preview(*c)}
|
||||
v.Target = "" // webhook URLs often contain tokens
|
||||
if c.Kind == "email" {
|
||||
v.Target = c.Target
|
||||
}
|
||||
chans = append(chans, v)
|
||||
}
|
||||
}
|
||||
active := []alertRow{}
|
||||
for _, a := range s.alerts {
|
||||
if a.OrgID == sc.org {
|
||||
active = append(active, alertRow{a.Key, a.Kind, a.Name, a.Message, a.Since, a.Fired})
|
||||
}
|
||||
}
|
||||
sort.Slice(active, func(i, j int) bool { return active[i].Since.Before(active[j].Since) })
|
||||
settings := map[string]int{"debounceMin": 2, "repeatHours": 0}
|
||||
if o := s.findOrg(sc.org); o != nil {
|
||||
if o.AlertDebounceMin > 0 {
|
||||
settings["debounceMin"] = o.AlertDebounceMin
|
||||
}
|
||||
settings["repeatHours"] = o.AlertRepeatHours
|
||||
}
|
||||
mailOK := s.st.Settings.SMTP.Host != ""
|
||||
s.mu.Unlock()
|
||||
writeJSON(w, 200, map[string]any{"channels": chans, "active": active, "history": s.db.queryAlertEvents(sc.org, 100), "settings": settings, "emailConfigured": mailOK})
|
||||
}
|
||||
|
||||
func (s *Server) saveChannel(w http.ResponseWriter, r *http.Request, sc alertScope) {
|
||||
var in AlertChannel
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
id := r.PathValue("id")
|
||||
var existing *AlertChannel
|
||||
if id != "" {
|
||||
for _, c := range s.st.Channels {
|
||||
if c.ID == id && c.OrgID == sc.org {
|
||||
existing = c
|
||||
}
|
||||
}
|
||||
if existing == nil {
|
||||
fail(w, 404, errors.New("no such channel"))
|
||||
return
|
||||
}
|
||||
if in.Target == "" {
|
||||
in.Target = existing.Target // blank keeps the saved URL/address
|
||||
}
|
||||
}
|
||||
trusted := sc.trusted
|
||||
if err := s.validateChannel(&in, trusted); err != nil {
|
||||
fail(w, 400, err)
|
||||
return
|
||||
}
|
||||
in.OrgID = sc.org
|
||||
if existing != nil {
|
||||
in.ID = id
|
||||
*existing = in
|
||||
} else {
|
||||
in.ID = newID()
|
||||
s.st.Channels = append(s.st.Channels, &in)
|
||||
}
|
||||
s.save()
|
||||
s.audit(sc.org, sc.actor, "alert.channel.save", in.Name)
|
||||
ok(w)
|
||||
}
|
||||
|
||||
func (s *Server) deleteChannel(w http.ResponseWriter, r *http.Request, sc alertScope) {
|
||||
id := r.PathValue("id")
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.st.Channels = filter(s.st.Channels, func(c *AlertChannel) bool { return !(c.ID == id && c.OrgID == sc.org) })
|
||||
s.save()
|
||||
ok(w)
|
||||
}
|
||||
|
||||
func (s *Server) testChannel(w http.ResponseWriter, r *http.Request, sc alertScope) {
|
||||
id := r.PathValue("id")
|
||||
s.mu.Lock()
|
||||
var ch *AlertChannel
|
||||
for _, c := range s.st.Channels {
|
||||
if c.ID == id && c.OrgID == sc.org {
|
||||
cp := *c
|
||||
ch = &cp
|
||||
}
|
||||
}
|
||||
name := "server"
|
||||
if o := s.findOrg(sc.org); o != nil {
|
||||
name = o.Name
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if ch == nil {
|
||||
fail(w, 404, errors.New("no such channel"))
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
if err := s.send(*ch, alertNote{Status: "test", Org: name, Kind: "test", Name: "Test alert", Message: "test", Since: now, Time: now}); err != nil {
|
||||
fail(w, 400, err)
|
||||
return
|
||||
}
|
||||
ok(w)
|
||||
}
|
||||
|
||||
func (s *Server) alertSettings(w http.ResponseWriter, r *http.Request, sc alertScope) {
|
||||
var in struct{ DebounceMin, RepeatHours int }
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if in.DebounceMin < 1 || in.DebounceMin > 120 || in.RepeatHours < 0 || in.RepeatHours > 168 {
|
||||
fail(w, 400, errors.New("wait 1-120 minutes before alerting; remind every 0-168 hours (0 = never)"))
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
o := s.findOrg(sc.org)
|
||||
if o == nil {
|
||||
fail(w, 404, errors.New("organization settings only"))
|
||||
return
|
||||
}
|
||||
o.AlertDebounceMin, o.AlertRepeatHours = in.DebounceMin, in.RepeatHours
|
||||
s.save()
|
||||
ok(w)
|
||||
}
|
||||
|
||||
// wrappers: an organization's admins manage its channels; server admins manage the server-level ones
|
||||
|
||||
func (s *Server) orgAlertScope(c *orgCtx) alertScope {
|
||||
return alertScope{org: c.Org.ID, trusted: c.Org.AllowLocal || c.User.ServerAdmin, actor: c.Actor}
|
||||
}
|
||||
|
||||
func (s *Server) withOrgAlerts(fn func(http.ResponseWriter, *http.Request, alertScope)) http.HandlerFunc {
|
||||
return s.withOrg(true, func(w http.ResponseWriter, r *http.Request, c *orgCtx) { fn(w, r, s.orgAlertScope(c)) })
|
||||
}
|
||||
|
||||
func (s *Server) withServerAlerts(fn func(http.ResponseWriter, *http.Request, alertScope)) http.HandlerFunc {
|
||||
return s.withServerAdmin(func(w http.ResponseWriter, r *http.Request, p *principal) {
|
||||
fn(w, r, alertScope{org: "", trusted: true, actor: p.User.Email})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) registerAlertRoutes(mux *http.ServeMux) {
|
||||
overview := func(w http.ResponseWriter, r *http.Request, sc alertScope) { s.alertOverview(w, sc) }
|
||||
mux.HandleFunc("GET /api/orgs/{org}/alerts", s.withOrgAlerts(overview))
|
||||
mux.HandleFunc("POST /api/orgs/{org}/alerts/channels", s.withOrgAlerts(s.saveChannel))
|
||||
mux.HandleFunc("PUT /api/orgs/{org}/alerts/channels/{id}", s.withOrgAlerts(s.saveChannel))
|
||||
mux.HandleFunc("DELETE /api/orgs/{org}/alerts/channels/{id}", s.withOrgAlerts(s.deleteChannel))
|
||||
mux.HandleFunc("POST /api/orgs/{org}/alerts/channels/{id}/test", s.withOrgAlerts(s.testChannel))
|
||||
mux.HandleFunc("PUT /api/orgs/{org}/alerts/settings", s.withOrgAlerts(s.alertSettings))
|
||||
mux.HandleFunc("GET /api/admin/alerts", s.withServerAlerts(overview))
|
||||
mux.HandleFunc("POST /api/admin/alerts/channels", s.withServerAlerts(s.saveChannel))
|
||||
mux.HandleFunc("PUT /api/admin/alerts/channels/{id}", s.withServerAlerts(s.saveChannel))
|
||||
mux.HandleFunc("DELETE /api/admin/alerts/channels/{id}", s.withServerAlerts(s.deleteChannel))
|
||||
mux.HandleFunc("POST /api/admin/alerts/channels/{id}/test", s.withServerAlerts(s.testChannel))
|
||||
}
|
||||
Reference in new issue
Block a user