Files
Hammerhead/infra.go
T

464 lines
12 KiB
Go

package hammerhead
import (
"errors"
"net"
"net/http"
"net/netip"
"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 := s.principalFrom(r)
if p == nil {
fail(w, 401, errors.New("Sign in to continue."))
return
}
s.mu.Lock()
defer s.mu.Unlock()
out := []edgeView{}
for _, e := range s.st.Edges {
if p.Key != nil && !p.serverKey() { // an org key sees the edges its organization may use
if s.edgeAllowedFor(e, p.Key.OrgID) {
out = append(out, s.viewEdge(e, false))
}
continue
}
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)
}
// cleanDiscovered keeps what an agent reports as suggestions only if it is well formed: it is shown in
// the dashboard, so lengths are bounded and anything that is not a plain address is dropped.
func cleanDiscovered(in []model.DiscoveredService) []model.DiscoveredService {
out := []model.DiscoveredService{}
clip := func(s string, n int) string {
if len(s) > n {
s = s[:n]
}
return strings.ToValidUTF8(s, "")
}
for _, d := range in {
if len(out) >= 500 {
break
}
if _, err := netip.ParseAddr(d.Host); err != nil || d.Port < 1 || d.Port > 65535 {
continue
}
src := "scan"
if d.Source == "local" {
src = "local"
}
out = append(out, model.DiscoveredService{Host: d.Host, Port: d.Port, Proto: "tcp", Service: clip(d.Service, 20), Detail: clip(d.Detail, 160), Source: src})
}
return out
}
func (s *Server) siteDiscovered(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.discovered[st.ID]
if out == nil {
out = []model.DiscoveredService{}
}
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
}