From bce44e21ced3ccc2af37b1ad751f5430ed585d09 Mon Sep 17 00:00:00 2001 From: Veillax <126523099+Veillax@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:57:21 -0600 Subject: [PATCH] Ephemeral exposures: short-lived site and port or hostname with a TTL, for shark expose Co-Authored-By: Claude Sonnet 5.5 --- README.md | 13 ++ accounts.go | 3 + e2e/expose_test.go | 96 +++++++++++ expose.go | 389 +++++++++++++++++++++++++++++++++++++++++++++ expose_test.go | 195 +++++++++++++++++++++++ server.go | 5 + store.go | 4 + 7 files changed, 705 insertions(+) create mode 100644 e2e/expose_test.go create mode 100644 expose.go create mode 100644 expose_test.go diff --git a/README.md b/README.md index ac963ba..a775892 100644 --- a/README.md +++ b/README.md @@ -259,6 +259,19 @@ Blacktip redirects an unauthenticated visitor to Hammerhead's login page. Hammer token with a per-edge key and sends the visitor back; Blacktip verifies it and sets a cookie. The same decision function runs in both proxy modes. +## Ephemeral exposures (`shark expose`) + +A short-lived port or website for development, created and removed by an API key. [shark-expose](https://git.veillax.com/Veillax/shark-expose) is the command-line client; the API is plain JSON, with an API key (**Access**) as the bearer token. `-` can stand for the key's own organization in the path. + +| Call | Does | +|---|---| +| `POST /api/orgs/-/expose` | `{proto: "http"\|"https"\|"tcp", targetPort, targetHost?, name?, ttlSeconds?, port?, hostname?, edgeId?, auth?: {password?, pin?, sso?}}`. Makes a site and a port on an online edge, and returns the public address plus `siteToken`, the token Bamboo dials in with. | +| `POST /api/orgs/-/expose/{id}/renew` | Pushes the deadline out by `ttlSeconds` (default 300) from now. | +| `GET /api/orgs/-/expose[/{id}]` | Lists exposures, with whether the edge reports the port listening. | +| `DELETE /api/orgs/-/expose/{id}` | Removes it now. Stopping twice is fine. | + +TCP gets a free port from 20000-29999. A website gets `-.` (so a wildcard DNS record for that domain should point at the edge) on port 80/443, or on `port`. An exposure that isn't renewed within its TTL (30 seconds to 24 hours) is removed, along with its site and any login secrets. The dashboard shows them with the `ephemeral` tag; the deadlines live in memory, so a Hammerhead restart removes any that are still there. An organization can have 20 at a time. + ## Design The dashboard follows the Lightwell design language: warm neutrals, one teal accent, system fonts, status badges with text, and dialogs that name what they affect. See [docs/design.md](docs/design.md). `node scripts/lint-design.mjs` checks the rules that a machine can check. diff --git a/accounts.go b/accounts.go index 45742ef..8316b19 100644 --- a/accounts.go +++ b/accounts.go @@ -100,6 +100,9 @@ func (s *Server) withOrg(needAdmin bool, fn func(http.ResponseWriter, *http.Requ return } id := r.PathValue("org") + if id == "-" && p.Key != nil { + id = p.Key.OrgID // an API key belongs to one organization, so "-" can stand for it + } s.mu.Lock() org := s.findOrg(id) c := &orgCtx{User: p.User, Key: p.Key} diff --git a/e2e/expose_test.go b/e2e/expose_test.go new file mode 100644 index 0000000..c006f38 --- /dev/null +++ b/e2e/expose_test.go @@ -0,0 +1,96 @@ +package e2e + +import ( + "context" + "net" + "strconv" + "testing" + "time" + + "git.veillax.com/Veillax/bamboo" + "git.veillax.com/Veillax/blacktip" +) + +type exposed struct { + ID, Host, Hostname, URL, SiteToken string + Port int +} + +// createExpose retries until an edge is online and the call succeeds. +func (w *world) createExpose(c *client, in map[string]any) exposed { + w.t.Helper() + var out exposed + for i := 0; i < 40; i++ { + if code, _ := c.do("POST", "/api/orgs/-/expose", in, &out); code == 200 { + return out + } + time.Sleep(150 * time.Millisecond) + } + c.must("POST", "/api/orgs/-/expose", in, &out) + return out +} + +func (w *world) agentFor(e exposed) context.CancelFunc { + ctx, cancel := context.WithCancel(context.Background()) + w.t.Cleanup(cancel) + b, err := bamboo.New(bamboo.Config{Hammerhead: w.ts.URL, Token: e.SiteToken, DataDir: w.t.TempDir()}) + if err != nil { + w.t.Fatal(err) + } + go b.Run(ctx) + return cancel +} + +// An ephemeral exposure carries traffic through the tunnel, and is gone after DELETE. +func TestEphemeralExpose(t *testing.T) { + w := setup(t, blacktip.TraefikConfig{}) + var key struct{ Key string } + w.admin.must("POST", "/api/orgs/"+w.org+"/apikeys", map[string]string{"name": "shark"}, &key) + kc := newClient(t, w.ts.URL) + kc.key = key.Key + w.admin.must("POST", "/api/orgs/"+w.org+"/domains", map[string]any{"name": "example.test", "edgeId": w.edge}, nil) + + // ----- tcp ----- + svc := tcpBackend(t, "SVC") + e := w.createExpose(kc, map[string]any{"proto": "tcp", "targetPort": svc, "ttlSeconds": 60}) + stop := w.agentFor(e) + addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(e.Port)) + if got := tcpAsk(t, addr, "hi"); got != "SVC:hi" { + t.Fatalf("through the tunnel: %q", got) + } + kc.must("POST", "/api/orgs/-/expose/"+e.ID+"/renew", nil, nil) + kc.must("DELETE", "/api/orgs/-/expose/"+e.ID, nil, nil) + stop() + closed := false + for i := 0; i < 40 && !closed; i++ { + c, err := net.DialTimeout("tcp", addr, 300*time.Millisecond) + if err != nil { + closed = true + break + } + c.Close() + time.Sleep(150 * time.Millisecond) + } + if !closed { + t.Error("the port is still open after DELETE") + } + kc.expect(404, "GET", "/api/orgs/-/expose/"+e.ID, nil) + + // ----- http, with a password ----- + web := webBackend(t, "app", 200) + port := freePort(t) + h := w.createExpose(kc, map[string]any{"proto": "http", "name": "demo", "port": port, "targetPort": web, + "auth": map[string]any{"password": "hunter22"}}) + w.agentFor(h) + _, get := edgeClient("http", port) + var r webResp + for i := 0; i < 60; i++ { + if r = get("GET", h.Hostname, "/x"); r.status == 302 { + break + } + time.Sleep(150 * time.Millisecond) + } + if r.status != 302 { + t.Fatalf("protected exposure should send visitors to the login, got %+v", r) + } +} diff --git a/expose.go b/expose.go new file mode 100644 index 0000000..077217d --- /dev/null +++ b/expose.go @@ -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 -. + 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: + } + } +} diff --git a/expose_test.go b/expose_test.go new file mode 100644 index 0000000..bea3f43 --- /dev/null +++ b/expose_test.go @@ -0,0 +1,195 @@ +package hammerhead + +import ( + "encoding/json" + "net/http/httptest" + "strings" + "testing" + "time" + + "git.veillax.com/Veillax/reef/model" +) + +func exposeServer(t *testing.T) (*Server, func(method, path string, in any) (int, []byte), string) { + t.Helper() + dir := t.TempDir() + s, err := New(dir, Options{AdminEmail: "admin@test.local", AdminPassword: "password123", AlertInterval: time.Hour}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(s.Close) + do := apiClient(t, s) + _, b := do("GET", "/api/me", nil) + var me struct{ Orgs []struct{ ID string } } + json.Unmarshal(b, &me) + code, raw := do("POST", "/api/edges", map[string]any{"name": "e", "tunnelAddr": "203.0.113.5:51820"}) + if code != 200 { + t.Fatalf("edge: %d %s", code, raw) + } + var e model.Edge + json.Unmarshal(raw, &e) + s.mu.Lock() + s.findEdge(e.ID).LastSeen = time.Now() + s.mu.Unlock() + if code, raw := do("POST", "/api/orgs/"+me.Orgs[0].ID+"/domains", map[string]any{"name": "t.example", "edgeId": e.ID}); code != 200 { + t.Fatalf("domain: %d %s", code, raw) + } + return s, do, me.Orgs[0].ID +} + +func TestExposeLifecycle(t *testing.T) { + s, do, org := exposeServer(t) + base := "/api/orgs/" + org + "/expose" + + code, raw := do("POST", base, map[string]any{"proto": "tcp", "targetPort": 3000}) + if code != 200 { + t.Fatalf("create tcp: %d %s", code, raw) + } + var tcp exposeOut + json.Unmarshal(raw, &tcp) + if tcp.Host != "203.0.113.5" || tcp.Port < exposePortMin || tcp.Port > exposePortMax || tcp.SiteToken == "" || tcp.ID == "" { + t.Fatalf("tcp exposure: %+v", tcp) + } + + code, raw = do("POST", base, map[string]any{"proto": "http", "name": "My App!", "targetPort": 8080, "auth": map[string]any{"password": "hunter22"}}) + if code != 200 { + t.Fatalf("create http: %d %s", code, raw) + } + var web exposeOut + json.Unmarshal(raw, &web) + if !strings.HasPrefix(web.Hostname, "my-app-") || !strings.HasSuffix(web.Hostname, ".t.example") || web.URL != "http://"+web.Hostname || !web.Protected { + t.Fatalf("http exposure: %+v", web) + } + + // validation + for name, in := range map[string]map[string]any{ + "bad proto": {"proto": "udp", "targetPort": 1}, + "bad ttl": {"proto": "tcp", "targetPort": 1, "ttlSeconds": 5}, + "bad target": {"proto": "tcp", "targetPort": 0}, + "tcp login": {"proto": "tcp", "targetPort": 1, "auth": map[string]any{"password": "hunter22"}}, + "short pass": {"proto": "http", "targetPort": 1, "auth": map[string]any{"password": "x"}}, + "foreign host": {"proto": "http", "targetPort": 1, "hostname": "evil.example.com"}, + "unknown edge": {"proto": "tcp", "targetPort": 1, "edgeId": "nope"}, + "bad pin": {"proto": "http", "targetPort": 1, "auth": map[string]any{"pin": "12"}}, + } { + if code, raw := do("POST", base, in); code < 400 { + t.Errorf("%s accepted: %d %s", name, code, raw) + } + } + + _, raw = do("GET", base, nil) + var list []exposeOut + json.Unmarshal(raw, &list) + if len(list) != 2 { + t.Fatalf("list: %s", raw) + } + // failed creates must not leave sites behind + if sites := s.ephemeralSites(); sites != 2 { + t.Errorf("want 2 ephemeral sites, have %d", sites) + } + + // renew pushes the deadline out; a deadline in the past expires it + s.mu.Lock() + s.expose[tcp.ID] = time.Now().Add(time.Second) + s.mu.Unlock() + if code, _ := do("POST", base+"/"+tcp.ID+"/renew", map[string]any{"ttlSeconds": 600}); code != 200 { + t.Fatalf("renew: %d", code) + } + s.mu.Lock() + left := time.Until(s.expose[tcp.ID]) + s.expose[web.ID] = time.Now().Add(-time.Second) + s.mu.Unlock() + if left < 9*time.Minute { + t.Errorf("renew left %v", left) + } + if code, _ := do("GET", base+"/"+web.ID, nil); code != 404 { + t.Errorf("expired exposure still there: %d", code) + } + if code, _ := do("POST", base+"/"+web.ID+"/renew", nil); code != 404 { + t.Errorf("renewed an expired exposure: %d", code) + } + s.mu.Lock() + if s.ephemeralSitesLocked() != 1 || len(s.st.Mappings) != 1 || len(s.st.Secrets) != 0 { + t.Errorf("expiry left debris: %d sites, %d mappings, %d secrets", s.ephemeralSitesLocked(), len(s.st.Mappings), len(s.st.Secrets)) + } + s.mu.Unlock() + + // stopping is idempotent + for i := 0; i < 2; i++ { + if code, _ := do("DELETE", base+"/"+tcp.ID, nil); code != 200 { + t.Errorf("delete %d: %d", i, code) + } + } + s.mu.Lock() + if s.ephemeralSitesLocked() != 0 || len(s.st.Mappings) != 0 { + t.Errorf("delete left debris") + } + s.mu.Unlock() +} + +func TestExposeSweptAfterRestart(t *testing.T) { + s, do, org := exposeServer(t) + if code, raw := do("POST", "/api/orgs/"+org+"/expose", map[string]any{"proto": "tcp", "targetPort": 3000}); code != 200 { + t.Fatalf("%d %s", code, raw) + } + // a regular mapping on a regular site must survive the sweep + _, raw := do("POST", "/api/orgs/"+org+"/sites", map[string]any{"name": "home"}) + var site model.Site + json.Unmarshal(raw, &site) + s.mu.Lock() + s.expose = map[string]time.Time{} // what a restart does + s.sweepExposedLocked() + n, m := len(s.st.Mappings), 0 + for _, x := range s.st.Sites { + if x.ID == site.ID { + m++ + } + } + s.mu.Unlock() + if n != 0 || m != 1 { + t.Fatalf("mappings left %d, regular site kept %d", n, m) + } +} + +func (s *Server) ephemeralSitesLocked() int { + n := 0 + for _, x := range s.st.Sites { + if isExposed(x.Tags) { + n++ + } + } + return n +} + +func (s *Server) ephemeralSites() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.ephemeralSitesLocked() +} + +// An API key belongs to one organization, so "-" in the path stands for it. +func TestExposeKeyOrgShorthand(t *testing.T) { + s, do, org := exposeServer(t) + _, raw := do("POST", "/api/orgs/"+org+"/apikeys", map[string]any{"name": "cli"}) + var k struct{ Key string } + json.Unmarshal(raw, &k) + if !strings.HasPrefix(k.Key, "hhk_") { + t.Fatal(string(raw)) + } + call := func(key, body string) int { + req := httptest.NewRequest("POST", "/api/orgs/-/expose", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+key) + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + return rec.Code + } + if got := call(k.Key, `{"proto":"tcp","targetPort":3000}`); got != 200 { + t.Errorf("key: %d", got) + } + if got := call("hhk_dead_beef", `{"proto":"tcp","targetPort":3000}`); got != 401 { + t.Errorf("bad key: %d", got) + } + if got := call("", `{"proto":"tcp","targetPort":3000}`); got != 401 { + t.Errorf("no key: %d", got) + } +} diff --git a/server.go b/server.go index 2b60aae..2eb2c2c 100644 --- a/server.go +++ b/server.go @@ -140,6 +140,11 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /api/orgs/{org}/mappings/{id}/shares", s.withOrg(true, s.createShare)) mux.HandleFunc("POST /api/orgs/{org}/mappings/{id}/revoke", s.withOrg(true, s.revokeVisitors)) mux.HandleFunc("DELETE /api/orgs/{org}/mappings/{id}/shares/{share}", s.withOrg(true, s.deleteShare)) + mux.HandleFunc("POST /api/orgs/{org}/expose", s.withOrg(true, s.createExpose)) + mux.HandleFunc("GET /api/orgs/{org}/expose", s.withOrg(true, s.listExpose)) + mux.HandleFunc("GET /api/orgs/{org}/expose/{id}", s.withOrg(true, s.getExpose)) + mux.HandleFunc("POST /api/orgs/{org}/expose/{id}/renew", s.withOrg(true, s.renewExpose)) + mux.HandleFunc("DELETE /api/orgs/{org}/expose/{id}", s.withOrg(true, s.deleteExpose)) mux.HandleFunc("POST /api/orgs/{org}/mail-egress", s.withOrg(true, s.saveMailEgress)) mux.HandleFunc("PUT /api/orgs/{org}/mail-egress/{id}", s.withOrg(true, s.saveMailEgress)) mux.HandleFunc("DELETE /api/orgs/{org}/mail-egress/{id}", s.withOrg(true, s.deleteMailEgress)) diff --git a/store.go b/store.go index 6a99b1c..edefb8e 100644 --- a/store.go +++ b/store.go @@ -218,6 +218,8 @@ type Server struct { verAt time.Time mtaSent map[string]time.Time // reminder throttle for repeat notifications + expose map[string]time.Time // deadline of each ephemeral mapping, by ID (see expose.go) + logs *logStore db *DB stop chan struct{} @@ -258,6 +260,7 @@ func New(dir string, opts Options) (*Server, error) { alerts: map[string]*alertState{}, mtaSent: map[string]time.Time{}, updRuns: map[string]*updRun{}, + expose: map[string]time.Time{}, } s.st.Sessions = map[string]*Session{} s.st.Secrets = map[string]*Secret{} @@ -298,6 +301,7 @@ func New(dir string, opts Options) (*Server, error) { } s.loadAlerts() go s.housekeeping() + go s.exposeLoop() go s.alertLoop() return s, nil }