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>
411 lines
11 KiB
Go
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
|
|
}
|