package hammerhead import ( "errors" "fmt" "math/rand/v2" "net" "net/http" "regexp" "strings" "time" "git.veillax.com/Veillax/reef/model" ) // Ephemeral exposures back `shark expose`: a short-lived site plus one port or hostname that // vanish unless the client keeps renewing them. Both records carry exposeTag so they show up // in the dashboard as temporary, and so a restart (which forgets the deadlines) can sweep them. const ( exposeTag = "ephemeral" defaultExposeTTL = 5 * time.Minute minExposeTTL = 30 * time.Second maxExposeTTL = 24 * time.Hour exposePortMin = 20000 exposePortMax = 29999 maxExposePerOrg = 20 ) type exposeIn struct { Proto string `json:"proto"` // http, https or tcp Name string `json:"name"` // label for the subdomain and the dashboard EdgeID string `json:"edgeId"` Hostname string `json:"hostname"` // http: full name under one of the org's domains; default -. Port int `json:"port"` // public port; default 80/443 for web, a free one in the ephemeral range for tcp TargetHost string `json:"targetHost"` TargetPort int `json:"targetPort"` TTL int `json:"ttlSeconds"` Auth *struct { Password string `json:"password"` Pin string `json:"pin"` SSO bool `json:"sso"` } `json:"auth"` } type exposeOut struct { ID string `json:"id"` Proto string `json:"proto"` URL string `json:"url,omitempty"` // http: the address to open Host string `json:"host"` // public host (tcp: where to connect) Port int `json:"port"` Hostname string `json:"hostname,omitempty"` SiteToken string `json:"siteToken,omitempty"` // only in the create response: what Bamboo dials in with Hammerhead string `json:"hammerhead,omitempty"` // only in the create response Protected bool `json:"protected"` ExpiresAt time.Time `json:"expiresAt"` Live bool `json:"live"` // the edge reports the port listening Error string `json:"error,omitempty"` } var slugBad = regexp.MustCompile(`[^a-z0-9]+`) func exposeSlug(n string) string { n = strings.Trim(slugBad.ReplaceAllString(strings.ToLower(n), "-"), "-") if len(n) > 20 { n = strings.Trim(n[:20], "-") } if n == "" { n = "expose" } return n } func exposeTTL(sec int) (time.Duration, error) { if sec == 0 { return defaultExposeTTL, nil } d := time.Duration(sec) * time.Second if d < minExposeTTL || d > maxExposeTTL { return 0, fmt.Errorf("ttlSeconds must be between %d and %d", int(minExposeTTL.Seconds()), int(maxExposeTTL.Seconds())) } return d, nil } func isExposed(tags []string) bool { for _, t := range tags { if t == exposeTag { return true } } return false } // edgeHost is the public host of an edge's WireGuard endpoint, which is where its ports are reachable. func edgeHost(e *model.Edge) string { h, _, err := net.SplitHostPort(e.TunnelAddr) if err != nil { return e.TunnelAddr } return h } // exposeEdges lists the edges an exposure may use: the named one, or every online edge the org may use. func (s *Server) exposeEdges(orgID, want string) []*model.Edge { var out []*model.Edge for _, e := range s.st.Edges { if (want != "" && e.ID != want) || !s.edgeAllowedFor(e, orgID) { continue } if want != "" || s.edgeOnline(e) { out = append(out, e) } } return out } func (s *Server) exposeView(m *model.Mapping, e *model.Edge) exposeOut { st, known := s.status[m.ID] o := exposeOut{ID: m.ID, Proto: m.Proto, Hostname: m.Hostname, Port: m.Port, Protected: m.Auth.Protected(), ExpiresAt: s.expose[m.ID], URL: publicURL(m), Live: known && st.Error == "", Error: st.Error} if e != nil { o.Host = edgeHost(e) } if m.Hostname != "" { o.Host = m.Hostname } return o } func (s *Server) createExpose(w http.ResponseWriter, r *http.Request, c *orgCtx) { var in exposeIn if !decode(w, r, &in) { return } ttl, err := exposeTTL(in.TTL) if err != nil { fail(w, 400, err) return } if in.Proto != model.ProtoHTTP && in.Proto != model.ProtoHTTPS && in.Proto != model.ProtoTCP { fail(w, 400, errors.New("proto must be http, https or tcp")) return } if in.TargetHost == "" { in.TargetHost = "127.0.0.1" } if in.Auth != nil && !model.Web(in.Proto) { fail(w, 400, errors.New("login protection works for http and https only")) return } mi := mappingInput{} if in.Auth != nil { if in.Auth.Password != "" { mi.NewPassword = &in.Auth.Password } if in.Auth.Pin != "" { mi.NewPin = &in.Auth.Pin } } pw, pin, err := hashSecrets(&mi) if err != nil { fail(w, 400, err) return } slug := exposeSlug(in.Name) s.mu.Lock() defer s.mu.Unlock() s.sweepExposedLocked() edges := s.exposeEdges(c.Org.ID, in.EdgeID) if len(edges) == 0 { fail(w, 409, errors.New("no edge is online for this organization")) return } active := 0 for _, m := range s.st.Mappings { if m.OrgID == c.Org.ID && isExposed(m.Tags) { active++ } } if active >= maxExposePerOrg { fail(w, 429, errors.New("too many active exposures; stop one first")) return } site := &model.Site{ID: newID(), OrgID: c.Org.ID, Name: "expose " + slug, Token: randHex(24), Connected: []string{}, TunnelIP: s.allocIPLocked(), Tags: []string{exposeTag}} if site.TunnelIP == "" { fail(w, 507, errors.New("tunnel address space is full")) return } // validateMapping looks the site up, so it is added first and taken out again if nothing fits s.st.Sites = append(s.st.Sites, site) lastErr := errors.New("no edge could take it") for _, e := range edges { m := model.Mapping{Name: "expose " + slug, Proto: in.Proto, EdgeID: e.ID, Enabled: true, Tags: []string{exposeTag}, Targets: []model.Target{{SiteID: site.ID, Host: in.TargetHost, Port: in.TargetPort}}, HealthSeconds: -1} if model.Web(in.Proto) { m.Port = in.Port if m.Port == 0 { m.Port = map[string]int{model.ProtoHTTP: 80, model.ProtoHTTPS: 443}[in.Proto] } m.Auth = model.Auth{Password: pw != nil, Pin: pin != nil, SSO: in.Auth != nil && in.Auth.SSO} hosts := []string{strings.ToLower(in.Hostname)} if in.Hostname == "" { hosts = nil for _, d := range s.st.Domains { if d.OrgID == c.Org.ID && d.EdgeID == e.ID { hosts = append(hosts, fmt.Sprintf("%s-%s.%s", slug, randHex(2), d.Name)) } } if len(hosts) == 0 { lastErr = errors.New("no domain is set up for this edge; add one on the Domains tab, or give a hostname") } } for _, h := range hosts { m.Hostname = h if lastErr = s.validateMapping(c, &m); lastErr == nil { break } } } else { tries := 1 if in.Port == 0 { tries = 60 } for i := 0; i < tries; i++ { m.Port = in.Port if in.Port == 0 { m.Port = exposePortMin + rand.IntN(exposePortMax-exposePortMin+1) } if lastErr = s.validateMapping(c, &m); lastErr == nil { break } } } if lastErr != nil { continue } m.ID = newID() s.applySecrets(&m, pw, pin) s.st.Mappings = append(s.st.Mappings, &m) s.expose[m.ID] = time.Now().Add(ttl) s.bump() s.audit(c.Org.ID, c.Actor, "expose.create", m.Name+" on "+portLabel(m)) out := s.exposeView(&m, e) out.SiteToken = site.Token out.Hammerhead = s.dashboardURL() writeJSON(w, 200, out) return } s.st.Sites = filter(s.st.Sites, func(x *model.Site) bool { return x.ID != site.ID }) fail(w, 400, lastErr) } func (s *Server) findExposeLocked(c *orgCtx, id string) *model.Mapping { m := s.findMapping(id) if m == nil || m.OrgID != c.Org.ID || !isExposed(m.Tags) { return nil } return m } func (s *Server) renewExpose(w http.ResponseWriter, r *http.Request, c *orgCtx) { var in struct { TTL int `json:"ttlSeconds"` } if r.ContentLength != 0 && !decode(w, r, &in) { return } ttl, err := exposeTTL(in.TTL) if err != nil { fail(w, 400, err) return } s.mu.Lock() defer s.mu.Unlock() s.sweepExposedLocked() m := s.findExposeLocked(c, r.PathValue("id")) if m == nil { fail(w, 404, errors.New("no such exposure (it may have expired)")) return } s.expose[m.ID] = time.Now().Add(ttl) writeJSON(w, 200, s.exposeView(m, s.findEdge(m.EdgeID))) } func (s *Server) getExpose(w http.ResponseWriter, r *http.Request, c *orgCtx) { s.mu.Lock() defer s.mu.Unlock() s.sweepExposedLocked() m := s.findExposeLocked(c, r.PathValue("id")) if m == nil { fail(w, 404, errors.New("no such exposure (it may have expired)")) return } writeJSON(w, 200, s.exposeView(m, s.findEdge(m.EdgeID))) } func (s *Server) listExpose(w http.ResponseWriter, r *http.Request, c *orgCtx) { s.mu.Lock() defer s.mu.Unlock() s.sweepExposedLocked() out := []exposeOut{} for _, m := range s.st.Mappings { if m.OrgID == c.Org.ID && isExposed(m.Tags) { out = append(out, s.exposeView(m, s.findEdge(m.EdgeID))) } } writeJSON(w, 200, out) } func (s *Server) deleteExpose(w http.ResponseWriter, r *http.Request, c *orgCtx) { s.mu.Lock() defer s.mu.Unlock() m := s.findExposeLocked(c, r.PathValue("id")) if m == nil { ok(w) // already gone: stopping twice is fine return } name := m.Name s.dropExposeLocked(m.ID) s.audit(c.Org.ID, c.Actor, "expose.delete", name) ok(w) } // dropExposeLocked removes an ephemeral mapping and the site that served it. func (s *Server) dropExposeLocked(mappingID string) { m := s.findMapping(mappingID) if m == nil { return } gone := map[string]bool{} for _, t := range m.Targets { if st := s.findSite(t.SiteID); st != nil && !st.Local && isExposed(st.Tags) { gone[st.ID] = true } } s.dropMappingLocked(mappingID) delete(s.expose, mappingID) s.st.Mappings = filter(s.st.Mappings, func(x *model.Mapping) bool { return x.ID != mappingID }) s.st.Sites = filter(s.st.Sites, func(x *model.Site) bool { return !gone[x.ID] }) s.bump() } // sweepExposedLocked removes every ephemeral mapping whose deadline has passed, or that has no // deadline at all (left over from before a restart), and any ephemeral site nothing uses. func (s *Server) sweepExposedLocked() { now := time.Now() var dead []string for _, m := range s.st.Mappings { if !isExposed(m.Tags) { continue } if at, known := s.expose[m.ID]; !known || now.After(at) { dead = append(dead, m.ID) } } for _, id := range dead { s.dropExposeLocked(id) } used := map[string]bool{} for _, m := range s.st.Mappings { for _, t := range m.Targets { used[t.SiteID] = true } } n := len(s.st.Sites) s.st.Sites = filter(s.st.Sites, func(x *model.Site) bool { return !(isExposed(x.Tags) && !x.Local && !used[x.ID]) }) if n != len(s.st.Sites) { s.bump() } } // exposeLoop expires ephemeral exposures. Its first pass clears everything left from the last run, // since deadlines are kept in memory only. func (s *Server) exposeLoop() { t := time.NewTicker(10 * time.Second) defer t.Stop() for { s.mu.Lock() s.sweepExposedLocked() s.mu.Unlock() select { case <-s.stop: return case <-t.C: } } }