package hammerhead import ( "encoding/json" "errors" "fmt" "net" "net/http" "strings" "time" "git.veillax.com/Veillax/reef/model" "golang.org/x/crypto/bcrypt" ) type mappingInput struct { model.Mapping NewPassword *string `json:"newPassword"` // nil = keep, "" = remove NewPin *string `json:"newPin"` } type mappingView struct { model.Mapping Status model.MappingStatus `json:"status"` Known bool `json:"statusKnown"` Total Total `json:"total"` } func isWild(ip string) bool { p := net.ParseIP(ip) return p != nil && p.IsUnspecified() } func overlap(a, b string) bool { return a == b || isWild(a) || isWild(b) } func netClass(proto string) string { if proto == model.ProtoUDP { return "udp" } return "tcp" } func listFromCSV(in []string) []string { out := []string{} seen := map[string]bool{} for _, v := range in { v = strings.ToLower(strings.TrimSpace(v)) if v != "" && !seen[v] { seen[v] = true out = append(out, v) } } return out } // normalizeTags trims tags, drops blanks and repeats (ignoring case; the first spelling wins) // and keeps the list short enough to stay readable in the dashboard. func normalizeTags(in []string) ([]string, error) { var out []string seen := map[string]bool{} for _, t := range in { t = strings.Join(strings.Fields(t), " ") if t == "" || seen[strings.ToLower(t)] { continue } if len([]rune(t)) > 32 { return nil, errors.New("A tag can be at most 32 characters.") } if strings.ContainsAny(t, ",<>\"") { return nil, errors.New("Tags can't contain commas, quotes or angle brackets.") } seen[strings.ToLower(t)] = true out = append(out, t) } if len(out) > 10 { return nil, errors.New("Use at most 10 tags.") } return out, nil } // validateMapping checks m against org c and normalises it. Caller holds s.mu. func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error { m.Name = strings.TrimSpace(m.Name) tags, err := normalizeTags(m.Tags) if err != nil { return err } m.Tags = tags m.Hostname = strings.ToLower(strings.TrimSpace(m.Hostname)) m.OrgID = c.Org.ID switch m.Proto { case model.ProtoTCP, model.ProtoUDP, model.ProtoHTTP, model.ProtoHTTPS, model.ProtoTLS: default: return errors.New("protocol must be tcp, udp, http, https or tls") } edge := s.findEdge(m.EdgeID) if edge == nil || !s.edgeAllowedFor(edge, c.Org.ID) { return errors.New("choose an edge") } if len(m.Targets) == 0 { return errors.New("add at least one target") } if m.Proto == model.ProtoUDP && len(m.Targets) > 1 { return errors.New("udp ports support one target") } for i := range m.Targets { t := &m.Targets[i] t.Host = strings.TrimSpace(t.Host) site := s.findSite(t.SiteID) if site == nil || !s.siteVisible(c, site) { return errors.New("choose a site for every target") } if site.Local && site.EdgeID != m.EdgeID { return errors.New("a local site can only be used with its own edge") } if t.Host == "" { return errors.New("every target needs a host (e.g. 127.0.0.1 or 192.168.1.20)") } if t.Port < 1 || t.Port > 65535 { return errors.New("Target ports must be between 1 and 65535.") } } if m.BindIP == "" { m.BindIP = "0.0.0.0" } ip := net.ParseIP(m.BindIP) if ip == nil { return errors.New("bind IP is not a valid address") } m.BindIP = ip.String() if m.Port < 1 || m.Port > 65535 { return errors.New("The public port must be between 1 and 65535.") } switch { case m.PortEnd == 0 || m.PortEnd == m.Port: m.PortEnd = 0 case m.PortEnd < m.Port: return errors.New("the end of a port range can't be before its start") case m.PortEnd > 65535: return errors.New("Public ports must be between 1 and 65535.") case m.Proto != model.ProtoTCP && m.Proto != model.ProtoUDP: return errors.New("port ranges work for tcp and udp only") case m.PortEnd-m.Port+1 > 1000: return errors.New("a range can open at most 1000 ports") } for _, t := range m.Targets { if t.Port+m.LastPort()-m.Port > 65535 { return errors.New("the target ports would run past 65535: each public port maps to the target port plus the same offset") } } if model.HostBased(m.Proto) { if m.Hostname == "" { return errors.New("a hostname is required for http, https and tls ports") } if !validHostname(m.Hostname) { return errors.New("hostname looks invalid (use something like app.example.com or *.example.com)") } if !s.hostnameAllowed(c.Org.ID, m.Hostname) { return fmt.Errorf("%s isn't under one of your domains; add the domain on the Domains tab first", m.Hostname) } } else { m.Hostname = "" } switch m.ProxyProtocol { case "": case "v1", "v2": if m.Proto != model.ProtoTCP && m.Proto != model.ProtoTLS { return errors.New("the PROXY protocol is for tcp and tls ports (web ports already pass the client address in X-Forwarded-For)") } default: return errors.New("PROXY protocol must be v1, v2 or off") } // web-only options if !model.Web(m.Proto) { m.Auth, m.Rules, m.RewriteHost = model.Auth{}, nil, "" m.RedirectHTTP, m.BackendHTTPS, m.HealthPath = false, false, "" } else { a := &m.Auth a.AllowedRoles, a.AllowedUsers, a.AllowedMails = listFromCSV(a.AllowedRoles), listFromCSV(a.AllowedUsers), listFromCSV(a.AllowedMails) for _, id := range a.AllowedRoles { if r := s.findRole(id); r == nil || r.OrgID != c.Org.ID { return errors.New("an allowed role doesn't belong to this organization") } } for _, id := range a.AllowedUsers { if s.member(id, c.Org.ID) == nil { return errors.New("an allowed user isn't a member of this organization") } } if a.Email && len(a.AllowedMails) == 0 { return errors.New("email codes need at least one allowed address (or *@example.com)") } if a.SessionHours < 0 || a.SessionHours > 24*90 { return errors.New("session length must be between 1 hour and 90 days") } for _, r := range m.Rules { if r.Action != "allow" && r.Action != "deny" { return errors.New("rule action must be allow or deny") } switch r.Match { case "path": if !strings.HasPrefix(r.Value, "/") { return errors.New("path rules must start with /") } case "ip": if net.ParseIP(r.Value) == nil { if _, _, err := net.ParseCIDR(r.Value); err != nil { return fmt.Errorf("%q is not an IP address or CIDR range", r.Value) } } default: return errors.New("rule match must be path or ip") } } m.RewriteHost = strings.TrimSpace(m.RewriteHost) if m.Proto != model.ProtoHTTPS { m.RedirectHTTP = false } if m.HealthPath != "" && !strings.HasPrefix(m.HealthPath, "/") { return errors.New("health check path must start with /") } } if m.HealthSeconds != 0 && m.HealthSeconds != -1 && (m.HealthSeconds < 5 || m.HealthSeconds > 3600) { return errors.New("The health check interval must be 5 to 3600 seconds, or off.") } for _, o := range s.st.Mappings { if o.ID == m.ID || o.EdgeID != m.EdgeID || netClass(o.Proto) != netClass(m.Proto) || !overlap(o.BindIP, m.BindIP) || o.Port > m.LastPort() || m.Port > o.LastPort() { continue } if m.Proto == o.Proto && model.HostBased(m.Proto) && m.BindIP == o.BindIP && m.Hostname != o.Hostname { continue // same listener, different hostname } if m.Proto == o.Proto && model.Web(m.Proto) && m.BindIP != o.BindIP && isWild(m.BindIP) != isWild(o.BindIP) { continue // a website on every address steps aside for one that names an address } return fmt.Errorf("%s port %s on %s is already used by %q. Choose another port or address.", strings.ToUpper(netClass(m.Proto)), portLabel(*m), o.BindIP, o.Name) } // an https resource with the redirect on also answers on port 80 at its address, so a raw port // there would clash with it (and the other way round) for _, o := range s.st.Mappings { if o.ID == m.ID || o.EdgeID != m.EdgeID || !overlap(o.BindIP, m.BindIP) { continue } switch { case m.Proto == model.ProtoHTTPS && m.RedirectHTTP && netClass(o.Proto) == "tcp" && !model.Web(o.Proto) && o.Port <= 80 && 80 <= o.LastPort(): return fmt.Errorf("Redirecting HTTP to HTTPS also uses TCP port 80 on %s, which %q uses. Turn the redirect off or choose another port for %q.", o.BindIP, o.Name, o.Name) case o.Proto == model.ProtoHTTPS && o.RedirectHTTP && netClass(m.Proto) == "tcp" && !model.Web(m.Proto) && m.Port <= 80 && 80 <= m.LastPort(): return fmt.Errorf("TCP port 80 on %s is also used by %q to redirect HTTP to HTTPS. Choose another port, or turn that redirect off.", o.BindIP, o.Name) } } if m.Name == "" { if m.Hostname != "" { m.Name = m.Hostname } else { m.Name = fmt.Sprintf("%s %s:%s", m.Proto, m.BindIP, portLabel(*m)) } } return nil } // portLabel is "25565" or, for a range, "2456-2458". func portLabel(m model.Mapping) string { if m.LastPort() > m.Port { return fmt.Sprintf("%d-%d", m.Port, m.LastPort()) } return fmt.Sprint(m.Port) } func checkPIN(p string) error { if len(p) < 4 || len(p) > 12 { return errors.New("A PIN is 4 to 12 digits.") } for _, c := range p { if c < '0' || c > '9' { return errors.New("a PIN is digits only") } } return nil } // applySecrets updates the password/PIN for mapping id and syncs the Auth flags. Caller holds s.mu. func (s *Server) applySecrets(m *model.Mapping, pwHash, pinHash *string) { sec := s.st.Secrets[m.ID] if sec == nil { sec = &Secret{} } if pwHash != nil { sec.PasswordHash = *pwHash } if pinHash != nil { sec.PinHash = *pinHash } if !m.Auth.Password { sec.PasswordHash = "" } if !m.Auth.Pin { sec.PinHash = "" } m.Auth.Password, m.Auth.Pin = sec.PasswordHash != "", sec.PinHash != "" if sec.PasswordHash == "" && sec.PinHash == "" { delete(s.st.Secrets, m.ID) } else { s.st.Secrets[m.ID] = sec } } // hashSecrets prepares optional new secrets outside the lock (bcrypt is slow). func hashSecrets(in *mappingInput) (pw, pin *string, err error) { one := func(v *string, check func(string) error) (*string, error) { if v == nil { return nil, nil } if *v == "" { e := "" return &e, nil } if err := check(*v); err != nil { return nil, err } b, err := bcrypt.GenerateFromPassword([]byte(*v), 10) h := string(b) return &h, err } if pw, err = one(in.NewPassword, func(p string) error { if len(p) < 6 { return errors.New("a resource password needs at least 6 characters") } return nil }); err != nil { return } pin, err = one(in.NewPin, checkPIN) return } func (s *Server) addMapping(w http.ResponseWriter, r *http.Request, c *orgCtx) { var in mappingInput if !decode(w, r, &in) { return } pw, pin, err := hashSecrets(&in) if err != nil { fail(w, 400, err) return } s.mu.Lock() defer s.mu.Unlock() m := in.Mapping m.ID, m.AuthEpoch = "", 0 if err := s.validateMapping(c, &m); err != nil { fail(w, 400, err) return } m.ID = newID() s.applySecrets(&m, pw, pin) s.st.Mappings = append(s.st.Mappings, &m) s.bump() s.audit(c.Org.ID, c.Actor, "mapping.create", m.Name) writeJSON(w, 200, m) } // authChanged reports whether an edit changes who may open a resource, which // must sign existing visitors out (their old sessions were granted under the old rules). func authChanged(o, n model.Auth, pwChanged, pinChanged bool) bool { o.SessionHours, n.SessionHours = 0, 0 ob, _ := json.Marshal(o) nb, _ := json.Marshal(n) return pwChanged || pinChanged || string(ob) != string(nb) } // revokeOrgVisitorsLocked signs out every visitor of the org's protected resources. // The caller must then call s.bump() so the edges learn the new epoch. func (s *Server) revokeOrgVisitorsLocked(orgID string) { for _, m := range s.st.Mappings { if m.OrgID == orgID && m.Auth.Protected() { m.AuthEpoch++ } } } // revokeVisitors signs everyone out of one resource. func (s *Server) revokeVisitors(w http.ResponseWriter, r *http.Request, c *orgCtx) { s.mu.Lock() defer s.mu.Unlock() m := s.findMapping(r.PathValue("id")) if m == nil || m.OrgID != c.Org.ID { fail(w, 404, errors.New("no such port")) return } m.AuthEpoch++ s.bump() s.audit(c.Org.ID, c.Actor, "mapping.signout-visitors", m.Name) ok(w) } func (s *Server) putMapping(w http.ResponseWriter, r *http.Request, c *orgCtx) { var in mappingInput if !decode(w, r, &in) { return } pw, pin, err := hashSecrets(&in) if err != nil { fail(w, 400, err) return } s.mu.Lock() defer s.mu.Unlock() id := r.PathValue("id") for i, old := range s.st.Mappings { if old.ID != id || old.OrgID != c.Org.ID { continue } m := in.Mapping m.ID = id if err := s.validateMapping(c, &m); err != nil { fail(w, 400, err) return } s.applySecrets(&m, pw, pin) m.AuthEpoch = old.AuthEpoch // only hammerhead moves this, never the request if authChanged(old.Auth, m.Auth, pw != nil, pin != nil) { m.AuthEpoch++ } s.st.Mappings[i] = &m delete(s.status, id) s.bump() s.audit(c.Org.ID, c.Actor, "mapping.update", m.Name) writeJSON(w, 200, m) return } fail(w, 404, errors.New("no such port")) } func (s *Server) deleteMapping(w http.ResponseWriter, r *http.Request, c *orgCtx) { id := r.PathValue("id") s.mu.Lock() defer s.mu.Unlock() m := s.findMapping(id) if m == nil || m.OrgID != c.Org.ID { fail(w, 404, errors.New("no such port")) return } s.dropMappingLocked(id) s.st.Mappings = filter(s.st.Mappings, func(o *model.Mapping) bool { return o.ID != id }) s.bump() s.audit(c.Org.ID, c.Actor, "mapping.delete", m.Name) ok(w) } // ---------- share links ---------- func (s *Server) listShares(w http.ResponseWriter, r *http.Request, c *orgCtx) { s.mu.Lock() defer s.mu.Unlock() out := []ShareLink{} for _, l := range s.st.Shares { if l.OrgID == c.Org.ID && l.MappingID == r.PathValue("id") { cp := *l cp.TokenHash = "" out = append(out, cp) } } writeJSON(w, 200, out) } // publicURL is how visitors reach a web mapping, or "" for wildcard hostnames. func publicURL(m *model.Mapping) string { if !model.Web(m.Proto) || m.Hostname == "" || strings.HasPrefix(m.Hostname, "*") { return "" } if m.Proto == model.ProtoHTTPS { if m.Port == 443 { return "https://" + m.Hostname } return fmt.Sprintf("https://%s:%d", m.Hostname, m.Port) } if m.Port == 80 { return "http://" + m.Hostname } return fmt.Sprintf("http://%s:%d", m.Hostname, m.Port) } func (s *Server) createShare(w http.ResponseWriter, r *http.Request, c *orgCtx) { var in struct { Name string Hours int MaxUses int } if !decode(w, r, &in) { return } s.mu.Lock() defer s.mu.Unlock() m := s.findMapping(r.PathValue("id")) if m == nil || m.OrgID != c.Org.ID { fail(w, 404, errors.New("no such port")) return } base := publicURL(m) if base == "" { fail(w, 400, errors.New("share links need a web port with one concrete hostname")) return } if !m.Auth.Protected() { fail(w, 400, errors.New("this port has no login in front of it, so there is nothing to share past")) return } tok := randHex(20) l := &ShareLink{ID: newID(), MappingID: m.ID, OrgID: c.Org.ID, Name: strings.TrimSpace(in.Name), TokenHash: sha(tok), MaxUses: in.MaxUses, Created: time.Now()} if in.Hours > 0 { l.Expires = time.Now().Add(time.Duration(in.Hours) * time.Hour) } s.st.Shares = append(s.st.Shares, l) s.save() s.audit(c.Org.ID, c.Actor, "share.create", m.Name) writeJSON(w, 200, map[string]string{"url": base + "/?hh_share=" + tok}) } func (s *Server) deleteShare(w http.ResponseWriter, r *http.Request, c *orgCtx) { id := r.PathValue("share") s.mu.Lock() defer s.mu.Unlock() for _, l := range s.st.Shares { if l.ID == id && l.OrgID == c.Org.ID { // sessions started from a share link can't be told apart from others, so // revoking one signs out everyone on that resource if m := s.findMapping(l.MappingID); m != nil { m.AuthEpoch++ } } } s.st.Shares = filter(s.st.Shares, func(l *ShareLink) bool { return !(l.ID == id && l.OrgID == c.Org.ID) }) s.bump() ok(w) } // ---------- combined state for the UI ---------- func (s *Server) orgState(w http.ResponseWriter, r *http.Request, c *orgCtx) { s.mu.Lock() defer s.mu.Unlock() edges := []edgeView{} for _, e := range s.st.Edges { if s.edgeAllowedFor(e, c.Org.ID) { edges = append(edges, s.viewEdge(e, false)) } } sites := []model.Site{} online := map[string]bool{} for _, st := range s.st.Sites { if !s.siteVisible(c, st) { continue } cp := *st if !c.Admin || st.Local { cp.Token = "" } sites = append(sites, cp) online[st.ID] = s.siteOnline(st) } domains := []model.Domain{} for _, d := range s.st.Domains { if d.OrgID == c.Org.ID { domains = append(domains, *d) } } maps := []mappingView{} for _, m := range s.st.Mappings { if m.OrgID != c.Org.ID { continue } v := mappingView{Mapping: *m} v.Status, v.Known = s.status[m.ID] if t := s.st.Totals[m.ID]; t != nil { v.Total = *t } maps = append(maps, v) } writeJSON(w, 200, map[string]any{ "org": c.Org, "admin": c.Admin, "edges": edges, "sites": sites, "online": online, "domains": domains, "mappings": maps, "roles": s.rolesLocked(c.Org.ID), "mailEgress": s.mailEgressViewsLocked(c.Org.ID), "mailConfigured": s.st.Settings.SMTP.Host != "", "dashboardUrl": s.st.Settings.DashboardURL, }) }