Files
Hammerhead/infra.go
T
VeillaxandClaude Sonnet 5.5 227c890409
CI / test (push) Successful in 4m52s
Release / release (push) Successful in 5m24s
Add Sandbar alert channel, tags for resources and sites, use Reef's secretbox
Sandbar channels raise and resolve alerts through its API, with the API key
sealed at rest and never returned to the browser. Resources and sites can be
tagged and filtered by tag; sites can be renamed. The secretbox package now
comes from Reef v0.1.9 instead of a private copy.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 03:32:43 -06:00

411 lines
11 KiB
Go

package hammerhead
import (
"errors"
"net"
"net/http"
"sort"
"strings"
"time"
"git.veillax.com/Veillax/reef/model"
)
const onlineWindow = 60 * time.Second
func (s *Server) edgeOnline(e *model.Edge) bool { return time.Since(e.LastSeen) < onlineWindow }
// edgeView is what browsers see. Only server admins get tokens and org lists.
type edgeView struct {
ID string `json:"id"`
Name string `json:"name"`
IPs []string `json:"ips"`
Online bool `json:"online"`
TunnelAddr string `json:"tunnelAddr,omitempty"`
Token string `json:"token,omitempty"`
OrgIDs []string `json:"orgIds,omitempty"`
Version string `json:"version,omitempty"`
AutoUpdate bool `json:"autoUpdate,omitempty"`
UpdatePending bool `json:"updatePending,omitempty"`
UpdateMsg string `json:"updateMsg,omitempty"`
}
func (s *Server) viewEdge(e *model.Edge, admin bool) edgeView {
v := edgeView{ID: e.ID, Name: e.Name, IPs: e.IPs, Online: s.edgeOnline(e)}
if v.IPs == nil {
v.IPs = []string{}
}
if admin {
v.TunnelAddr, v.Token, v.OrgIDs = e.TunnelAddr, e.Token, e.OrgIDs
v.Version, v.AutoUpdate, v.UpdatePending, v.UpdateMsg = e.Version, e.AutoUpdate, e.UpdateID != "", e.UpdateMsg
if v.OrgIDs == nil {
v.OrgIDs = []string{}
}
}
return v
}
func validAddr(a string) bool {
h, p, err := net.SplitHostPort(a)
if err != nil || h == "" {
return false
}
n := 0
for _, c := range p {
if c < '0' || c > '9' {
return false
}
n = n*10 + int(c-'0')
if n > 65535 {
return false
}
}
return n > 0
}
func (s *Server) listEdges(w http.ResponseWriter, r *http.Request, p *principal) {
s.mu.Lock()
defer s.mu.Unlock()
out := []edgeView{}
for _, e := range s.st.Edges {
if p.User.ServerAdmin {
out = append(out, s.viewEdge(e, true))
continue
}
for _, m := range s.st.Members {
if m.UserID == p.User.ID && s.edgeAllowedFor(e, m.OrgID) {
out = append(out, s.viewEdge(e, false))
break
}
}
}
writeJSON(w, 200, out)
}
func (s *Server) saveEdge(w http.ResponseWriter, r *http.Request, p *principal) {
var in model.Edge
if !decode(w, r, &in) {
return
}
in.Name = strings.TrimSpace(in.Name)
if in.Name == "" {
fail(w, 400, errors.New("name is required"))
return
}
if !validAddr(in.TunnelAddr) {
fail(w, 400, errors.New("WireGuard endpoint must look like host:port (the edge's public address and UDP port)"))
return
}
s.mu.Lock()
defer s.mu.Unlock()
for _, o := range in.OrgIDs {
if s.findOrg(o) == nil {
fail(w, 400, errors.New("unknown organization in the allowed list"))
return
}
}
if id := r.PathValue("id"); id != "" {
e := s.findEdge(id)
if e == nil {
fail(w, 404, errors.New("no such edge"))
return
}
e.Name, e.TunnelAddr, e.OrgIDs = in.Name, in.TunnelAddr, in.OrgIDs
for _, st := range s.st.Sites {
if st.Local && st.EdgeID == e.ID {
st.Name = e.Name + " (this machine)"
}
}
s.bump()
writeJSON(w, 200, s.viewEdge(e, true))
return
}
e := &model.Edge{ID: newID(), Name: in.Name, Token: randHex(24), AuthKey: randHex(32),
TunnelAddr: in.TunnelAddr, TunnelIP: s.allocIPLocked(), OrgIDs: in.OrgIDs, IPs: []string{}}
s.st.Edges = append(s.st.Edges, e)
// every edge gets a built-in site for services on the edge's own machine
s.st.Sites = append(s.st.Sites, &model.Site{ID: newID(), Name: in.Name + " (this machine)",
Local: true, EdgeID: e.ID, Connected: []string{}})
s.bump()
s.audit("", p.User.Email, "edge.create", e.Name)
writeJSON(w, 200, s.viewEdge(e, true))
}
func (s *Server) deleteEdge(w http.ResponseWriter, r *http.Request, p *principal) {
id := r.PathValue("id")
s.mu.Lock()
defer s.mu.Unlock()
e := s.findEdge(id)
if e == nil {
fail(w, 404, errors.New("no such edge"))
return
}
s.st.Edges = filter(s.st.Edges, func(x *model.Edge) bool { return x.ID != id })
s.st.Sites = filter(s.st.Sites, func(x *model.Site) bool { return !(x.Local && x.EdgeID == id) })
s.st.Domains = filter(s.st.Domains, func(d *model.Domain) bool { return d.EdgeID != id })
s.st.MailEgress = filter(s.st.MailEgress, func(x *model.MailEgress) bool { return x.EdgeID != id })
for _, m := range s.st.Mappings {
if m.EdgeID == id {
s.dropMappingLocked(m.ID)
}
}
s.st.Mappings = filter(s.st.Mappings, func(m *model.Mapping) bool { return m.EdgeID != id })
s.bump()
s.audit("", p.User.Email, "edge.delete", e.Name)
ok(w)
}
// dropMappingLocked removes everything hanging off a mapping except the mapping itself.
func (s *Server) dropMappingLocked(id string) {
delete(s.st.Secrets, id)
delete(s.st.Totals, id)
delete(s.status, id)
s.st.Shares = filter(s.st.Shares, func(l *ShareLink) bool { return l.MappingID != id })
}
// ---------- sites ----------
func (s *Server) siteVisible(c *orgCtx, st *model.Site) bool {
if st.Local {
if !c.Org.AllowLocal {
return false
}
e := s.findEdge(st.EdgeID)
return e != nil && s.edgeAllowedFor(e, c.Org.ID)
}
return st.OrgID == c.Org.ID
}
func (s *Server) siteOnline(st *model.Site) bool {
if st.Local {
e := s.findEdge(st.EdgeID)
return e != nil && s.edgeOnline(e)
}
return time.Since(st.LastSeen) < onlineWindow
}
func (s *Server) addSite(w http.ResponseWriter, r *http.Request, c *orgCtx) {
var in struct{ Name string }
if !decode(w, r, &in) {
return
}
in.Name = strings.TrimSpace(in.Name)
if in.Name == "" {
fail(w, 400, errors.New("name is required"))
return
}
s.mu.Lock()
defer s.mu.Unlock()
st := &model.Site{ID: newID(), OrgID: c.Org.ID, Name: in.Name, Token: randHex(24), Connected: []string{}, TunnelIP: s.allocIPLocked()}
s.st.Sites = append(s.st.Sites, st)
s.bump()
s.audit(c.Org.ID, c.Actor, "site.create", in.Name)
writeJSON(w, 200, st)
}
// putSite renames a site and sets its tags. The built-in local site is shared between
// organizations, so it can't be edited here.
func (s *Server) putSite(w http.ResponseWriter, r *http.Request, c *orgCtx) {
var in struct {
Name string `json:"name"`
Tags []string `json:"tags"`
}
if !decode(w, r, &in) {
return
}
in.Name = strings.TrimSpace(in.Name)
if in.Name == "" {
fail(w, 400, errors.New("name is required"))
return
}
tags, err := normalizeTags(in.Tags)
if err != nil {
fail(w, 400, err)
return
}
s.mu.Lock()
defer s.mu.Unlock()
st := s.findSite(r.PathValue("id"))
if st == nil || st.OrgID != c.Org.ID {
fail(w, 404, errors.New("no such site"))
return
}
if st.Local {
fail(w, 400, errors.New("the built-in local site can't be edited"))
return
}
st.Name, st.Tags = in.Name, tags
s.bump()
s.audit(c.Org.ID, c.Actor, "site.update", st.Name)
ok(w)
}
func (s *Server) deleteSite(w http.ResponseWriter, r *http.Request, c *orgCtx) {
id := r.PathValue("id")
s.mu.Lock()
defer s.mu.Unlock()
st := s.findSite(id)
if st == nil || st.OrgID != c.Org.ID {
fail(w, 404, errors.New("no such site"))
return
}
if st.Local {
fail(w, 400, errors.New("the built-in local site goes away with its edge"))
return
}
s.st.Sites = filter(s.st.Sites, func(e *model.Site) bool { return e.ID != id })
s.st.MailEgress = filter(s.st.MailEgress, func(x *model.MailEgress) bool { return x.SiteID != id })
for _, m := range s.st.Mappings {
kept := []model.Target{}
for _, t := range m.Targets {
if t.SiteID != id {
kept = append(kept, t)
}
}
if len(kept) != len(m.Targets) {
m.Targets = kept
if len(kept) == 0 {
s.dropMappingLocked(m.ID)
}
}
}
s.st.Mappings = filter(s.st.Mappings, func(m *model.Mapping) bool { return len(m.Targets) > 0 })
s.bump()
s.audit(c.Org.ID, c.Actor, "site.delete", st.Name)
ok(w)
}
func (s *Server) siteContainers(w http.ResponseWriter, r *http.Request, c *orgCtx) {
s.mu.Lock()
defer s.mu.Unlock()
st := s.findSite(r.PathValue("id"))
if st == nil || !s.siteVisible(c, st) {
fail(w, 404, errors.New("no such site"))
return
}
out := s.containers[st.ID]
if out == nil {
out = []model.Container{}
}
writeJSON(w, 200, out)
}
// ---------- domains ----------
func domainsOverlap(a, b string) bool {
return a == b || strings.HasSuffix(a, "."+b) || strings.HasSuffix(b, "."+a)
}
func validHostname(h string) bool {
h = strings.TrimPrefix(h, "*.")
if h == "" || len(h) > 253 || !strings.Contains(h, ".") {
return false
}
for _, c := range h {
if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-' || c == '.') {
return false
}
}
return !strings.HasPrefix(h, ".") && !strings.HasSuffix(h, ".") && !strings.Contains(h, "..")
}
func (s *Server) addDomain(w http.ResponseWriter, r *http.Request, c *orgCtx) {
var in model.Domain
if !decode(w, r, &in) {
return
}
in.Name = strings.ToLower(strings.TrimSpace(in.Name))
if !validHostname(in.Name) || strings.HasPrefix(in.Name, "*.") {
fail(w, 400, errors.New("enter a domain like example.com"))
return
}
s.mu.Lock()
defer s.mu.Unlock()
if in.EdgeID != "" {
e := s.findEdge(in.EdgeID)
if e == nil || !s.edgeAllowedFor(e, c.Org.ID) {
fail(w, 400, errors.New("choose an edge"))
return
}
}
for _, d := range s.st.Domains {
if d.OrgID != c.Org.ID && domainsOverlap(d.Name, in.Name) {
fail(w, 400, errors.New("that domain (or a parent/child of it) belongs to another organization"))
return
}
if d.OrgID == c.Org.ID && d.Name == in.Name {
fail(w, 400, errors.New("you already added that domain"))
return
}
}
in.ID, in.OrgID = newID(), c.Org.ID
s.st.Domains = append(s.st.Domains, &in)
s.save()
s.audit(c.Org.ID, c.Actor, "domain.add", in.Name)
writeJSON(w, 200, in)
}
func (s *Server) deleteDomain(w http.ResponseWriter, r *http.Request, c *orgCtx) {
id := r.PathValue("id")
s.mu.Lock()
defer s.mu.Unlock()
s.st.Domains = filter(s.st.Domains, func(d *model.Domain) bool { return !(d.ID == id && d.OrgID == c.Org.ID) })
s.save()
ok(w)
}
// checkDomain resolves the domain (and a probe subdomain) and compares with the edge's IPs.
func (s *Server) checkDomain(w http.ResponseWriter, r *http.Request, c *orgCtx) {
s.mu.Lock()
var d *model.Domain
for _, x := range s.st.Domains {
if x.ID == r.PathValue("id") && x.OrgID == c.Org.ID {
cp := *x
d = &cp
}
}
var edgeIPs []string
if d != nil {
if e := s.findEdge(d.EdgeID); e != nil {
edgeIPs = append(edgeIPs, e.IPs...)
}
}
s.mu.Unlock()
if d == nil {
fail(w, 404, errors.New("no such domain"))
return
}
want := map[string]bool{}
for _, ip := range edgeIPs {
want[ip] = true
}
if d.IP != "" {
want[d.IP] = true
}
resolved, err := net.LookupHost(d.Name)
if err != nil {
writeJSON(w, 200, map[string]any{"resolved": []string{}, "matches": false, "error": "no DNS record found"})
return
}
sort.Strings(resolved)
matches := false
for _, ip := range resolved {
if want[ip] {
matches = true
}
}
writeJSON(w, 200, map[string]any{"resolved": resolved, "matches": matches})
}
// hostnameAllowed reports whether org owns a domain that covers host.
func (s *Server) hostnameAllowed(orgID, host string) bool {
host = strings.TrimPrefix(host, "*.")
for _, d := range s.st.Domains {
if d.OrgID == orgID && (host == d.Name || strings.HasSuffix(host, "."+d.Name)) {
return true
}
}
return false
}