Dashboard and API, accounts (organizations, roles, invitations, 2FA, OIDC single sign-on, API keys, revocable sessions), an encrypted SQLite database, resource logins, alerts and /metrics, mail egress management with DKIM keys, and the .env loader. Split out of the original single repository; depends on Reef for the shared types. The end-to-end tests that run Hammerhead, Blacktip and Bamboo together live in e2e/. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
232 lines
8.2 KiB
Go
232 lines
8.2 KiB
Go
package hammerhead
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.veillax.com/Veillax/hammerhead/internal/secretbox"
|
|
"git.veillax.com/Veillax/reef/model"
|
|
)
|
|
|
|
func apiClient(t *testing.T, s *Server) (do func(method, path string, in any) (int, []byte)) {
|
|
ts := httptest.NewServer(s.Handler())
|
|
t.Cleanup(ts.Close)
|
|
jar, _ := cookiejar.New(nil)
|
|
c := &http.Client{Jar: jar}
|
|
do = func(method, path string, in any) (int, []byte) {
|
|
var body bytes.Buffer
|
|
if in != nil {
|
|
json.NewEncoder(&body).Encode(in)
|
|
}
|
|
req, _ := http.NewRequest(method, ts.URL+path, &body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := c.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
var out bytes.Buffer
|
|
out.ReadFrom(resp.Body)
|
|
return resp.StatusCode, out.Bytes()
|
|
}
|
|
if code, b := do("POST", "/api/login", map[string]string{"email": "admin@test.local", "password": "password123"}); code != 200 {
|
|
t.Fatalf("login: %d %s", code, b)
|
|
}
|
|
return do
|
|
}
|
|
|
|
func fileHas(t *testing.T, dir, needle string) bool {
|
|
found := false
|
|
filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
|
|
if err == nil && !info.IsDir() && strings.HasPrefix(filepath.Base(p), "hammerhead.db") {
|
|
if b, _ := os.ReadFile(p); bytes.Contains(b, []byte(needle)) {
|
|
found = true
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
return found
|
|
}
|
|
|
|
func TestPersistsAcrossRestartsAndEncryptsSecrets(t *testing.T) {
|
|
dir := t.TempDir()
|
|
key := secretbox.GenerateKey()
|
|
opts := Options{AdminEmail: "admin@test.local", AdminPassword: "password123", MasterKey: key}
|
|
|
|
s, err := New(dir, opts)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
do := apiClient(t, s)
|
|
code, b := do("POST", "/api/edges", map[string]any{"name": "edge-one", "tunnelAddr": "203.0.113.5:51820"})
|
|
if code != 200 {
|
|
t.Fatalf("edge: %d %s", code, b)
|
|
}
|
|
var edge struct{ ID, Token string }
|
|
json.Unmarshal(b, &edge)
|
|
if code, b := do("PUT", "/api/admin/settings", map[string]any{"dashboardUrl": "http://x.test", "smtp": map[string]any{"host": "smtp.test", "pass": "smtp-secret-pw-31337", "mode": "none"}}); code != 200 {
|
|
t.Fatalf("settings: %d %s", code, b)
|
|
}
|
|
s.Close()
|
|
|
|
// nothing sensitive is readable in the database file
|
|
for _, secret := range []string{edge.Token, "smtp-secret-pw-31337"} {
|
|
if secret == "" || fileHas(t, dir, secret) {
|
|
t.Errorf("secret %q is readable in the database file", secret)
|
|
}
|
|
}
|
|
|
|
// reopening with the same key restores everything, secrets included
|
|
s2, err := New(dir, opts)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
do = apiClient(t, s2)
|
|
_, b = do("GET", "/api/edges", nil)
|
|
var edges []struct{ ID, Name, Token string }
|
|
json.Unmarshal(b, &edges)
|
|
if len(edges) != 1 || edges[0].Name != "edge-one" || edges[0].Token != edge.Token {
|
|
t.Errorf("edge after restart: %+v (want token %s)", edges, edge.Token)
|
|
}
|
|
_, b = do("GET", "/api/admin/settings", nil)
|
|
if !strings.Contains(string(b), `"smtpPassSet":true`) {
|
|
t.Errorf("smtp password lost after restart: %s", b)
|
|
}
|
|
s2.mu.Lock()
|
|
pw := s2.st.Settings.SMTP.Pass
|
|
s2.mu.Unlock()
|
|
if pw != "smtp-secret-pw-31337" {
|
|
t.Errorf("decrypted smtp password = %q", pw)
|
|
}
|
|
s2.Close()
|
|
|
|
// the wrong key is a loud failure, not silent garbage
|
|
if _, err := New(dir, Options{MasterKey: secretbox.GenerateKey()}); err == nil || !strings.Contains(err.Error(), "decrypt") {
|
|
t.Errorf("opening with the wrong key: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGeneratedKeyFileKeepsWorking(t *testing.T) {
|
|
dir := t.TempDir()
|
|
s, err := New(dir, Options{AdminEmail: "admin@test.local", AdminPassword: "password123"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
apiClient(t, s)("POST", "/api/edges", map[string]any{"name": "e", "tunnelAddr": "203.0.113.5:51820"})
|
|
s.Close()
|
|
if _, err := os.Stat(filepath.Join(dir, "master.key")); err != nil {
|
|
t.Fatalf("no key file was generated: %v", err)
|
|
}
|
|
s2, err := New(dir, Options{})
|
|
if err != nil {
|
|
t.Fatalf("reopen with the generated key: %v", err)
|
|
}
|
|
s2.Close()
|
|
}
|
|
|
|
func TestMigratesALegacyStateFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
legacy := map[string]any{
|
|
"version": 7,
|
|
"users": []any{map[string]any{"id": "u1", "email": "old@test.local", "name": "Old", "serverAdmin": true, "created": time.Now()}},
|
|
"orgs": []any{map[string]any{"id": "o1", "name": "Legacy org", "allowLocal": true}},
|
|
"edges": []any{map[string]any{"id": "e1", "name": "old-edge", "token": "legacy-plain-token-abc", "authKey": "k", "tunnelAddr": "1.2.3.4:51820", "tunnelIp": "100.89.0.1", "ips": []string{}}},
|
|
}
|
|
b, _ := json.Marshal(legacy)
|
|
os.WriteFile(filepath.Join(dir, "state.json"), b, 0o600)
|
|
os.MkdirAll(filepath.Join(dir, "logs"), 0o700)
|
|
os.WriteFile(filepath.Join(dir, "logs", "access.jsonl"), []byte(`{"time":"`+time.Now().UTC().Format(time.RFC3339)+`","mapping":"m1","orgId":"o1","remote":"9.9.9.9","method":"GET","host":"h","path":"/x","status":200}`+"\n"), 0o600)
|
|
|
|
s, err := New(dir, Options{MasterKey: secretbox.GenerateKey()})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer s.Close()
|
|
s.mu.Lock()
|
|
ok := len(s.st.Users) == 1 && s.st.Users[0].Email == "old@test.local" && len(s.st.Orgs) == 1 && len(s.st.Edges) == 1 && s.st.Edges[0].Token == "legacy-plain-token-abc" && s.st.Version >= 7
|
|
s.mu.Unlock()
|
|
if !ok {
|
|
t.Fatal("legacy state was not imported")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "state.json.migrated")); err != nil {
|
|
t.Error("the old file should be kept aside as state.json.migrated")
|
|
}
|
|
if got := s.logs.queryAccess(accessFilter{Org: "o1"}); len(got) != 1 || got[0].Remote != "9.9.9.9" {
|
|
t.Errorf("legacy access log not imported: %+v", got)
|
|
}
|
|
s.Close()
|
|
if fileHas(t, dir, "legacy-plain-token-abc") {
|
|
t.Error("the imported plaintext token was stored unencrypted")
|
|
}
|
|
}
|
|
|
|
func TestLogSearchPaginationAndRetention(t *testing.T) {
|
|
s, err := New(t.TempDir(), Options{MasterKey: secretbox.GenerateKey()})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer s.Close()
|
|
var batch []model.LogEntry
|
|
for i := 0; i < 250; i++ {
|
|
status := 200
|
|
if i%10 == 0 {
|
|
status = 404
|
|
}
|
|
batch = append(batch, model.LogEntry{Time: time.Now(), OrgID: "o1", Mapping: fmt.Sprintf("m%d", i%2), Remote: "1.1.1.1", Method: "GET",
|
|
Host: "site.test", Path: fmt.Sprintf("/page/%d", i), Status: status, Result: "allowed", User: "bob"})
|
|
}
|
|
batch = append(batch, model.LogEntry{Time: time.Now(), OrgID: "o1", Mapping: "m0", Path: "/100%_sure", Status: 200, Result: "denied", Host: "site.test"},
|
|
model.LogEntry{Time: time.Now(), OrgID: "o2", Mapping: "m9", Path: "/other-org", Status: 200})
|
|
s.logs.addAccess(batch)
|
|
|
|
page1 := s.logs.queryAccess(accessFilter{Org: "o1", Limit: 100})
|
|
if len(page1) != 100 {
|
|
t.Fatalf("page 1 has %d entries", len(page1))
|
|
}
|
|
page2 := s.logs.queryAccess(accessFilter{Org: "o1", Limit: 100, Before: page1[len(page1)-1].ID})
|
|
if len(page2) != 100 || page2[0].ID >= page1[len(page1)-1].ID {
|
|
t.Errorf("pagination is wrong: page2=%d", len(page2))
|
|
}
|
|
if n := len(s.logs.queryAccess(accessFilter{Org: "o1", Limit: 1000, StatusClass: 4})); n != 25 {
|
|
t.Errorf("status class 4xx matched %d, want 25", n)
|
|
}
|
|
if n := len(s.logs.queryAccess(accessFilter{Org: "o1", Limit: 1000, Mapping: "m1"})); n != 125 {
|
|
t.Errorf("mapping filter matched %d, want 125", n)
|
|
}
|
|
if got := s.logs.queryAccess(accessFilter{Org: "o1", Q: "100%_sure"}); len(got) != 1 {
|
|
t.Errorf("a search containing LIKE wildcards must match literally, got %d", len(got))
|
|
}
|
|
if got := s.logs.queryAccess(accessFilter{Org: "o1", Result: "denied"}); len(got) != 1 {
|
|
t.Errorf("result filter: %d", len(got))
|
|
}
|
|
for _, e := range s.logs.queryAccess(accessFilter{Org: "o1", Limit: 1000}) {
|
|
if e.OrgID != "o1" || strings.Contains(e.Path, "other-org") {
|
|
t.Fatal("another organization's log entry leaked")
|
|
}
|
|
}
|
|
|
|
// retention
|
|
s.db.sql.Exec(`UPDATE access_log SET ts = ? WHERE path = '/page/3'`, time.Now().AddDate(0, 0, -60).UnixMilli())
|
|
s.db.purge(30, 365)
|
|
if got := s.logs.queryAccess(accessFilter{Org: "o1", Q: "/page/3", Limit: 1000}); len(got) != 0 && got[0].Path == "/page/3" {
|
|
t.Error("old entries should have been purged")
|
|
}
|
|
|
|
// traffic history
|
|
s.db.addTraffic("m0", model.Stat{Conns: 2, BytesIn: 100, BytesOut: 900})
|
|
s.db.addTraffic("m0", model.Stat{Conns: 1, BytesIn: 50, BytesOut: 50})
|
|
h := s.db.trafficHistory([]string{"m0", "m1"}, 24)
|
|
if len(h["m0"]) != 24 || h["m0"][23] != 1100 || h["m1"][23] != 0 {
|
|
t.Errorf("traffic history: %v", h["m0"])
|
|
}
|
|
}
|