Files
Hammerhead/agent.go
T
VeillaxandClaude Sonnet 5.5 2b7cb5dd35
CI / test (push) Successful in 5m21s
Release / release (push) Successful in 6m9s
Show a progress bar and live log while Hammerhead, edges and sites update
The updater reports each step (checking, downloading with percent, verifying, installing, restarting).
Edges and sites send that progress and their log with their reports; Hammerhead serves it to a dialog
that opens when an update starts, and keeps its own last update log on disk across the restart.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 18:05:42 -06:00

280 lines
7.1 KiB
Go

package hammerhead
import (
"crypto/subtle"
"errors"
"net/http"
"strconv"
"time"
"git.veillax.com/Veillax/reef/model"
)
// Endpoints used by blacktip and bamboo. They authenticate with the edge/site token.
func (s *Server) edgeByToken(r *http.Request) *model.Edge {
t := bearer(r)
if t == "" {
return nil
}
for _, e := range s.st.Edges {
if subtle.ConstantTimeCompare([]byte(e.Token), []byte(t)) == 1 {
return e
}
}
return nil
}
func (s *Server) siteByToken(r *http.Request) *model.Site {
t := bearer(r)
if t == "" {
return nil
}
for _, e := range s.st.Sites {
if !e.Local && subtle.ConstantTimeCompare([]byte(e.Token), []byte(t)) == 1 {
return e
}
}
return nil
}
func (s *Server) edgeConfig(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
e := s.edgeByToken(r)
s.mu.Unlock()
if e == nil {
fail(w, 401, errors.New("bad edge token"))
return
}
have, _ := strconv.ParseInt(r.URL.Query().Get("v"), 10, 64)
if !s.wait(r, have) {
s.mu.Lock()
if e := s.edgeByToken(r); e != nil {
e.LastSeen = time.Now()
}
s.mu.Unlock()
w.WriteHeader(http.StatusNoContent)
return
}
s.mu.Lock()
defer s.mu.Unlock()
if e = s.edgeByToken(r); e == nil {
fail(w, 401, errors.New("bad edge token"))
return
}
e.LastSeen = time.Now()
cfg := model.EdgeConfig{
Version: s.st.Version, EdgeID: e.ID, AuthKey: e.AuthKey, Dashboard: s.st.Settings.DashboardURL,
Mappings: []model.Mapping{}, Peers: []model.WGPeer{}, Local: []string{}, TunnelIP: e.TunnelIP,
AcmeEmail: s.st.Settings.AcmeEmail, AcmeStages: s.st.Settings.AcmeStaging,
}
for _, m := range s.st.Mappings {
if m.EdgeID == e.ID && m.Enabled {
cfg.Mappings = append(cfg.Mappings, *m)
}
}
if m := s.selfMapping(e); m != nil {
cfg.Mappings = append(cfg.Mappings, *m)
}
cfg.UpdateID = e.UpdateID
cfg.MailEgress = []model.MailPolicy{}
for _, x := range s.st.MailEgress {
if x.EdgeID == e.ID && x.Enabled {
cfg.MailEgress = append(cfg.MailEgress, model.MailPolicy{ID: x.ID, SiteID: x.SiteID, SourceIP: x.SourceIP, HeloName: x.HeloName,
Smarthost: x.Smarthost, RequireTLS: x.RequireTLS, RatePerMinute: x.RatePerMinute, DKIM: s.signersLocked(x.OrgID)})
}
}
for _, st := range s.st.Sites {
switch {
case st.Local:
if st.EdgeID == e.ID {
cfg.Local = append(cfg.Local, st.ID)
}
case st.PublicKey != "" && st.TunnelIP != "" && s.edgeAllowedFor(e, st.OrgID):
cfg.Peers = append(cfg.Peers, model.WGPeer{SiteID: st.ID, PublicKey: st.PublicKey, IP: st.TunnelIP})
}
}
writeJSON(w, 200, cfg)
}
func (s *Server) edgeReport(w http.ResponseWriter, r *http.Request) {
var in model.EdgeReport
if !decode(w, r, &in) {
return
}
s.mu.Lock()
defer s.mu.Unlock()
e := s.edgeByToken(r)
if e == nil {
fail(w, 401, errors.New("bad edge token"))
return
}
e.LastSeen = time.Now()
e.IPs = in.IPs
oldVersion := e.Version
answered := noteUpdateReport(&e.Version, &e.AutoUpdate, &e.UpdateID, &e.UpdateMsg, in.Version, in.AutoUpdate, in.UpdateID, in.UpdateMsg)
s.recordUpdateProgress(e.ID, in.UpdateID, in.UpdateStage, in.UpdatePct, in.UpdateLog, oldVersion, e.Version)
for _, x := range s.st.MailEgress {
if x.EdgeID == e.ID {
if chk, found := in.MailChecks[x.ID]; found {
s.mailChecks[x.ID] = chk
}
}
}
for _, m := range s.st.Mappings {
if m.EdgeID != e.ID {
continue
}
if st, found := in.Status[m.ID]; found {
s.status[m.ID] = st
} else {
delete(s.status, m.ID)
}
if d, found := in.Stats[m.ID]; found {
t := s.st.Totals[m.ID]
if t == nil {
t = &Total{}
s.st.Totals[m.ID] = t
}
t.Conns += d.Conns
t.BytesIn += d.BytesIn
t.BytesOut += d.BytesOut
s.db.addTraffic(m.ID, d)
}
}
for _, st := range s.st.Sites {
if st.Local && st.EdgeID == e.ID {
s.containers[st.ID] = in.Containers
}
}
if e.PublicKey != in.PublicKey {
e.PublicKey = in.PublicKey
s.bump() // sites need the edge's new key
} else if answered {
s.bump() // the edge's config no longer carries the update request
} else {
s.save()
}
ok(w)
}
func (s *Server) edgeLogs(w http.ResponseWriter, r *http.Request) {
var in []model.LogEntry
if !decode(w, r, &in) {
return
}
s.mu.Lock()
e := s.edgeByToken(r)
if e == nil {
s.mu.Unlock()
fail(w, 401, errors.New("bad edge token"))
return
}
out := make([]model.LogEntry, 0, len(in))
for _, l := range in {
if m := s.findMapping(l.Mapping); m != nil && m.EdgeID == e.ID {
l.OrgID = m.OrgID
} else if x := s.findMailEgress(l.Mapping); x != nil && x.EdgeID == e.ID {
l.OrgID = x.OrgID
} else {
continue
}
out = append(out, l)
}
s.mu.Unlock()
s.logs.addAccess(out)
ok(w)
}
func (s *Server) siteConfig(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
st := s.siteByToken(r)
s.mu.Unlock()
if st == nil {
fail(w, 401, errors.New("bad site token"))
return
}
have, _ := strconv.ParseInt(r.URL.Query().Get("v"), 10, 64)
if !s.wait(r, have) {
s.mu.Lock()
if st := s.siteByToken(r); st != nil {
st.LastSeen = time.Now()
}
s.mu.Unlock()
w.WriteHeader(http.StatusNoContent)
return
}
s.mu.Lock()
defer s.mu.Unlock()
if st = s.siteByToken(r); st == nil {
fail(w, 401, errors.New("bad site token"))
return
}
st.LastSeen = time.Now()
cfg := model.SiteConfig{Version: s.st.Version, SiteID: st.ID, TunnelIP: st.TunnelIP, Edges: []model.EdgeRef{}, MailEgress: []model.MailSpec{}, UpdateID: st.UpdateID}
seen := map[string]bool{}
addEdge := func(id string) {
if seen[id] {
return
}
seen[id] = true
if e := s.findEdge(id); e != nil {
cfg.Edges = append(cfg.Edges, model.EdgeRef{ID: e.ID, Name: e.Name, Endpoint: e.TunnelAddr, PublicKey: e.PublicKey, IP: e.TunnelIP})
}
}
for _, m := range s.st.Mappings {
for _, t := range m.Targets {
if t.SiteID == st.ID {
addEdge(m.EdgeID)
}
}
}
for _, x := range s.st.MailEgress {
if x.SiteID == st.ID && x.Enabled {
addEdge(x.EdgeID)
cfg.MailEgress = append(cfg.MailEgress, model.MailSpec{ID: x.ID, EdgeID: x.EdgeID, Listen: x.Listen, Username: x.Username, Password: x.Password})
}
}
writeJSON(w, 200, cfg)
}
func (s *Server) siteReport(w http.ResponseWriter, r *http.Request) {
var in model.SiteReport
if !decode(w, r, &in) {
return
}
s.mu.Lock()
defer s.mu.Unlock()
st := s.siteByToken(r)
if st == nil {
fail(w, 401, errors.New("bad site token"))
return
}
st.LastSeen = time.Now()
if in.Connected == nil {
in.Connected = []string{}
}
st.Connected = in.Connected
oldVersion := st.Version
answered := noteUpdateReport(&st.Version, &st.AutoUpdate, &st.UpdateID, &st.UpdateMsg, in.Version, in.AutoUpdate, in.UpdateID, in.UpdateMsg)
s.recordUpdateProgress(st.ID, in.UpdateID, in.UpdateStage, in.UpdatePct, in.UpdateLog, oldVersion, st.Version)
s.containers[st.ID] = in.Containers
for _, x := range s.st.MailEgress {
if x.SiteID != st.ID {
continue
}
if msg, known := in.MailEgress[x.ID]; known {
s.mailStatus[x.ID] = msg
} else {
delete(s.mailStatus, x.ID)
}
}
if in.PublicKey != "" && st.PublicKey != in.PublicKey {
st.PublicKey = in.PublicKey
s.bump() // every edge this site talks to needs the new key
} else if answered {
s.bump() // the site's config no longer carries the update request
}
ok(w)
}