Files
Hammerhead/infra.go
T
VeillaxandClaude Sonnet 5.5 39659f0144
CI / test (push) Successful in 4m36s
Release / release (push) Successful in 5m10s
Domain redirects: send an address to another, over HTTP and HTTPS
A website resource can now redirect to a full address (301 or 302), optionally
keeping the path and query, with no target or login. The Add resource wizard
has a Domain redirect choice that opens the HTTPS and HTTP sides together.
Deleting a site no longer removes redirects. Uses Reef v0.1.10 and Blacktip v0.1.9.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 04:15:40 -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 || m.IsRedirect() })
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
}