Add the Hammerhead control plane
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>
This commit is contained in:
1 parent
3daa368233
commit
f521fa8668
35 files changed
+10392
-1
No files matched your search
@@ -0,0 +1,367 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user