Ephemeral exposures: short-lived site and port or hostname with a TTL, for shark expose
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
39659f0144
commit
bce44e21ce
7 files changed
+705
No files matched your search
@@ -0,0 +1,389 @@
|
||||
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 <name>-<random>.<domain>
|
||||
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:
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user