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
+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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user