Files
VeillaxandClaude Sonnet 5.5 35d4000bc4
CI / test (push) Successful in 5m50s
Release / release (push) Successful in 5m42s
Terminal without an open port: the agent calls back, and the SSH dialog shows its state
Bamboo reports where its SSH server listens. When a terminal is opened,
Hammerhead lists the session in the site's config and the agent connects out and
joins that connection to its SSH server; Hammerhead signs in over it. No port
resource or edge is involved. A site whose Bamboo does not report an SSH server
still works through an SSH port. The long poll now returns at once for a session
the agent has not been told about, so a request between two polls is not missed.

The SSH dialog shows whether the SSH server is running, says when no port is
needed, and offers the port only for an ordinary ssh client. Builds against Reef
v0.1.12 and Bamboo v0.1.9.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 01:40:16 -06:00

283 lines
7.3 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.waitUntil(r, have, func() bool { return s.newTerminalsLocked(st.ID) }) {
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})
}
}
cfg.Terminals = s.waitingTerminalsLocked(st.ID)
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
s.discovered[st.ID] = cleanDiscovered(in.Discovered)
st.SSH, st.SSHRelay = clipText(in.SSH, 100), in.SSHRelay
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)
}