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>
266 lines
6.2 KiB
Go
266 lines
6.2 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)
|
|
}
|
|
}
|
|
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
|
|
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 {
|
|
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{}}
|
|
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
|
|
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
|
|
}
|
|
ok(w)
|
|
}
|