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) } }