Edges and sites report their version and whether auto-update is on. Hammerhead can ask a machine that has auto-update on to install the newest release, and shows the outcome. Requests carry an ID so a stale result can't answer a new one. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
374 lines
10 KiB
Go
374 lines
10 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)
|
|
}
|
|
|
|
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
|
|
}
|