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 }