Files
VeillaxandClaude Sonnet 5.5 227c890409
CI / test (push) Successful in 4m52s
Release / release (push) Successful in 5m24s
Add Sandbar alert channel, tags for resources and sites, use Reef's secretbox
Sandbar channels raise and resolve alerts through its API, with the API key
sealed at rest and never returned to the browser. Resources and sites can be
tagged and filtered by tag; sites can be renamed. The secretbox package now
comes from Reef v0.1.9 instead of a private copy.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 03:32:43 -06:00

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/reef/model"
"git.veillax.com/Veillax/reef/secretbox"
)
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"])
}
}