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>
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/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"])
|
|
}
|
|
}
|