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
@@ -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
|
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.
|
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 `<name>-<random>.<one of your domains>` (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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -100,6 +100,9 @@ func (s *Server) withOrg(needAdmin bool, fn func(http.ResponseWriter, *http.Requ
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
id := r.PathValue("org")
|
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()
|
s.mu.Lock()
|
||||||
org := s.findOrg(id)
|
org := s.findOrg(id)
|
||||||
c := &orgCtx{User: p.User, Key: p.Key}
|
c := &orgCtx{User: p.User, Key: p.Key}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+195
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}/shares", s.withOrg(true, s.createShare))
|
||||||
mux.HandleFunc("POST /api/orgs/{org}/mappings/{id}/revoke", s.withOrg(true, s.revokeVisitors))
|
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("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("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("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))
|
mux.HandleFunc("DELETE /api/orgs/{org}/mail-egress/{id}", s.withOrg(true, s.deleteMailEgress))
|
||||||
|
|||||||
@@ -218,6 +218,8 @@ type Server struct {
|
|||||||
verAt time.Time
|
verAt time.Time
|
||||||
mtaSent map[string]time.Time // reminder throttle for repeat notifications
|
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
|
logs *logStore
|
||||||
db *DB
|
db *DB
|
||||||
stop chan struct{}
|
stop chan struct{}
|
||||||
@@ -258,6 +260,7 @@ func New(dir string, opts Options) (*Server, error) {
|
|||||||
alerts: map[string]*alertState{},
|
alerts: map[string]*alertState{},
|
||||||
mtaSent: map[string]time.Time{},
|
mtaSent: map[string]time.Time{},
|
||||||
updRuns: map[string]*updRun{},
|
updRuns: map[string]*updRun{},
|
||||||
|
expose: map[string]time.Time{},
|
||||||
}
|
}
|
||||||
s.st.Sessions = map[string]*Session{}
|
s.st.Sessions = map[string]*Session{}
|
||||||
s.st.Secrets = map[string]*Secret{}
|
s.st.Secrets = map[string]*Secret{}
|
||||||
@@ -298,6 +301,7 @@ func New(dir string, opts Options) (*Server, error) {
|
|||||||
}
|
}
|
||||||
s.loadAlerts()
|
s.loadAlerts()
|
||||||
go s.housekeeping()
|
go s.housekeeping()
|
||||||
|
go s.exposeLoop()
|
||||||
go s.alertLoop()
|
go s.alertLoop()
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user