mirror of
https://github.com/wweir/sower.git
synced 2026-10-20 00:15:34 +00:00
- Config override validation now accepts bracketed IPv6 remote addresses ([2001:db8::1]:8443): the host part is checked as an IP literal before falling back to hostname rules, so a valid IPv6 remote no longer fails validation. - Directory fake sites serve local relays even when serve_ip is a concrete non-loopback address: the local check now recognizes any address bound to a local interface, not just loopback, so fallback traffic is not wrongly 307-redirected to https. - The config view's restart handler ignores failures after the component is destroyed, so a late 401 from the in-flight restart request can no longer trigger a global logout from a dead page. - The login throttle bounds its failure map (purges expired entries when full) so rotating source IPs cannot grow it without bound.
1789 lines
56 KiB
Go
1789 lines
56 KiB
Go
package admin
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type fakeRules struct {
|
|
lists map[Category][]string
|
|
hits []RuleHit
|
|
miss []RuleHit
|
|
// errAdd makes RuleAdd fail, simulating a state persistence failure.
|
|
errAdd error
|
|
}
|
|
|
|
func (f *fakeRules) RuleMiss(byCount bool, limit int) []RuleHit {
|
|
out := append([]RuleHit(nil), f.miss...)
|
|
sort.SliceStable(out, func(i, j int) bool {
|
|
if byCount {
|
|
if out[i].Count != out[j].Count {
|
|
return out[i].Count > out[j].Count
|
|
}
|
|
return out[i].LastSeen.After(out[j].LastSeen)
|
|
}
|
|
if !out[i].LastSeen.Equal(out[j].LastSeen) {
|
|
return out[i].LastSeen.After(out[j].LastSeen)
|
|
}
|
|
return out[i].Count > out[j].Count
|
|
})
|
|
if limit > 0 && limit < len(out) {
|
|
return out[:limit]
|
|
}
|
|
return out
|
|
}
|
|
|
|
func newFakeRules() *fakeRules {
|
|
return &fakeRules{lists: map[Category][]string{
|
|
CategoryBlock: {},
|
|
CategoryDirect: {},
|
|
CategoryProxy: {},
|
|
}}
|
|
}
|
|
|
|
func (f *fakeRules) RuleSearch(c Category, q string, offset, limit int, sortBy RuleSort, dir SortDir) ([]RuleEntry, uint64, error) {
|
|
all := f.lists[c]
|
|
matched := make([]RuleEntry, 0, len(all))
|
|
q = strings.ToLower(q)
|
|
for _, rule := range all {
|
|
if q != "" && !strings.Contains(strings.ToLower(rule), q) {
|
|
continue
|
|
}
|
|
e := RuleEntry{Rule: rule}
|
|
for _, h := range f.hits {
|
|
if h.Rule == rule {
|
|
e.Count = h.Count
|
|
last := h.LastSeen
|
|
e.LastSeen = &last
|
|
break
|
|
}
|
|
}
|
|
matched = append(matched, e)
|
|
}
|
|
total := uint64(len(matched))
|
|
switch sortBy {
|
|
case RuleSortRule:
|
|
sort.SliceStable(matched, func(i, j int) bool {
|
|
if dir == SortDirAsc {
|
|
return matched[i].Rule < matched[j].Rule
|
|
}
|
|
return matched[i].Rule > matched[j].Rule
|
|
})
|
|
case RuleSortHits:
|
|
sort.SliceStable(matched, func(i, j int) bool {
|
|
if dir == SortDirAsc {
|
|
return matched[i].Count < matched[j].Count
|
|
}
|
|
return matched[i].Count > matched[j].Count
|
|
})
|
|
case RuleSortLastSeen:
|
|
sort.SliceStable(matched, func(i, j int) bool {
|
|
var ti, tj time.Time
|
|
if matched[i].LastSeen != nil {
|
|
ti = *matched[i].LastSeen
|
|
}
|
|
if matched[j].LastSeen != nil {
|
|
tj = *matched[j].LastSeen
|
|
}
|
|
if dir == SortDirAsc {
|
|
return ti.Before(tj)
|
|
}
|
|
return ti.After(tj)
|
|
})
|
|
}
|
|
if offset >= len(matched) {
|
|
return []RuleEntry{}, total, nil
|
|
}
|
|
end := offset + limit
|
|
if end > len(matched) {
|
|
end = len(matched)
|
|
}
|
|
return matched[offset:end], total, nil
|
|
}
|
|
|
|
func (f *fakeRules) RuleAdd(c Category, rules ...string) error {
|
|
if f.errAdd != nil {
|
|
return f.errAdd
|
|
}
|
|
f.lists[c] = append(f.lists[c], rules...)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeRules) RuleRemove(c Category, rule string) (bool, error) {
|
|
for i, r := range f.lists[c] {
|
|
if r == rule {
|
|
f.lists[c] = append(f.lists[c][:i], f.lists[c][i+1:]...)
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
func (f *fakeRules) RuleRemoveMany(c Category, rules ...string) ([]string, error) {
|
|
removed := make([]string, 0, len(rules))
|
|
seen := make(map[string]struct{}, len(rules))
|
|
for _, rule := range rules {
|
|
if _, ok := seen[rule]; ok {
|
|
continue
|
|
}
|
|
seen[rule] = struct{}{}
|
|
if found, err := f.RuleRemove(c, rule); err != nil {
|
|
return nil, err
|
|
} else if found {
|
|
removed = append(removed, rule)
|
|
}
|
|
}
|
|
return removed, nil
|
|
}
|
|
|
|
func (f *fakeRules) RuleCount(c Category) uint64 {
|
|
return uint64(len(f.lists[c]))
|
|
}
|
|
|
|
func (f *fakeRules) RuleChanges() RuleChangeSet {
|
|
return RuleChangeSet{
|
|
Persistent: true,
|
|
Rules: map[Category]RuleDelta{
|
|
CategoryBlock: {Add: []string{}, Remove: []string{}},
|
|
CategoryDirect: {Add: []string{}, Remove: []string{}},
|
|
CategoryProxy: {Add: []string{}, Remove: []string{}},
|
|
},
|
|
}
|
|
}
|
|
|
|
func (f *fakeRules) RuleReset(c Category) error {
|
|
if c == "" {
|
|
return nil
|
|
}
|
|
if !c.valid() {
|
|
return fmt.Errorf("invalid category")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeRules) TestDomain(domain string) (DomainTest, error) {
|
|
domain = strings.ToLower(strings.TrimSpace(strings.TrimSuffix(domain, ".")))
|
|
if domain == "" {
|
|
return DomainTest{}, fmt.Errorf("domain is required")
|
|
}
|
|
res := DomainTest{Domain: domain}
|
|
for _, c := range []Category{CategoryBlock, CategoryDirect, CategoryProxy} {
|
|
rule, ok := fakeRuleMatch(f.lists[c], domain)
|
|
res.Matches = append(res.Matches, CategoryTest{Category: c, Matched: ok, Rule: rule})
|
|
if ok && res.Route == "" {
|
|
res.Route = string(c)
|
|
}
|
|
}
|
|
if res.Route == "" {
|
|
res.Route = "auto"
|
|
res.Note = "no rule matched"
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// fakeRuleMatch is the fake's linear matcher: exact match, "*", or "**." prefix.
|
|
func fakeRuleMatch(rules []string, item string) (string, bool) {
|
|
for _, rule := range rules {
|
|
switch {
|
|
case rule == "*":
|
|
return rule, true
|
|
case strings.HasPrefix(rule, "**.") && strings.HasSuffix(item, strings.TrimPrefix(rule, "**")):
|
|
return rule, true
|
|
case rule == item:
|
|
return rule, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func newTestServer(t *testing.T, rules RuleManager) *httptest.Server {
|
|
t.Helper()
|
|
s := NewServer(Options{Password: "secret", Version: "v1.2.3", Date: "2026-01-01", Rules: rules, Stats: newTestStats(t)})
|
|
ts := httptest.NewServer(s.http.Handler)
|
|
t.Cleanup(ts.Close)
|
|
return ts
|
|
}
|
|
|
|
// fakeConfig implements ConfigManager backed by a real StateStore, so PATCH
|
|
// revision semantics are exercised end to end.
|
|
type fakeConfig struct {
|
|
state *StateStore
|
|
}
|
|
|
|
func newFakeConfig(t *testing.T) *fakeConfig {
|
|
t.Helper()
|
|
return &fakeConfig{state: LoadStateStore("")}
|
|
}
|
|
|
|
func (f *fakeConfig) ConfigView() ConfigView {
|
|
return ConfigView{
|
|
Revision: f.state.Revision(),
|
|
Sections: []ConfigSection{
|
|
{Name: "运行", Fields: []ConfigField{
|
|
{Key: "log_level", Value: "info", Editable: true, ApplyMode: ApplyImmediate, Source: SourceConfig},
|
|
{Key: "remote.password", ApplyMode: ApplyReadonly, Source: SourceConfig, Secret: true, Configured: true},
|
|
}},
|
|
},
|
|
}
|
|
}
|
|
|
|
func (f *fakeConfig) ApplyConfigChanges(changes ConfigChanges, revision uint64) (ConfigView, error) {
|
|
overrides := f.state.ConfigOverrides()
|
|
if changes.LogLevel != nil {
|
|
overrides.LogLevel = changes.LogLevel
|
|
}
|
|
if changes.DNSUpstream != nil {
|
|
overrides.DNSUpstream = changes.DNSUpstream
|
|
}
|
|
if changes.DNSFallback != nil {
|
|
overrides.DNSFallback = changes.DNSFallback
|
|
}
|
|
if _, err := f.state.ApplyConfig(overrides, revision); err != nil {
|
|
return ConfigView{}, err
|
|
}
|
|
return f.ConfigView(), nil
|
|
}
|
|
|
|
func newTestConfigServer(t *testing.T, cfg ConfigManager) *httptest.Server {
|
|
t.Helper()
|
|
s := NewServer(Options{
|
|
Password: "secret", Version: "v1.2.3", Date: "2026-01-01",
|
|
Rules: newFakeRules(), Stats: newTestStats(t), Config: cfg,
|
|
})
|
|
ts := httptest.NewServer(s.http.Handler)
|
|
t.Cleanup(ts.Close)
|
|
return ts
|
|
}
|
|
|
|
func login(t *testing.T, ts *httptest.Server, password string) string {
|
|
t.Helper()
|
|
req, err := http.NewRequest(http.MethodPost, ts.URL+"/api/session", strings.NewReader(fmt.Sprintf(`{"password":%q}`, password)))
|
|
if err != nil {
|
|
t.Fatalf("new request: %v", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("login: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("login status: %d", resp.StatusCode)
|
|
}
|
|
for _, c := range resp.Cookies() {
|
|
if c.Name == sessionCookieName {
|
|
if c.HttpOnly == false || c.SameSite != http.SameSiteStrictMode {
|
|
t.Fatalf("cookie flags missing: HttpOnly=%v SameSite=%v", c.HttpOnly, c.SameSite)
|
|
}
|
|
return c.Value
|
|
}
|
|
}
|
|
t.Fatal("login cookie not set")
|
|
return ""
|
|
}
|
|
|
|
func authedRequest(t *testing.T, ts *httptest.Server, method, path, cookie, body string) *http.Response {
|
|
t.Helper()
|
|
var reader *strings.Reader
|
|
if body == "" {
|
|
reader = strings.NewReader("")
|
|
} else {
|
|
reader = strings.NewReader(body)
|
|
}
|
|
req, err := http.NewRequest(method, ts.URL+path, reader)
|
|
if err != nil {
|
|
t.Fatalf("new request: %v", err)
|
|
}
|
|
if body != "" {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
if cookie != "" {
|
|
req.Header.Set("Cookie", sessionCookieName+"="+cookie)
|
|
}
|
|
req.Header.Set("Origin", ts.URL)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("request %s %s: %v", method, path, err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func decodeBody(t *testing.T, resp *http.Response) map[string]any {
|
|
t.Helper()
|
|
defer resp.Body.Close()
|
|
var out map[string]any
|
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
t.Fatalf("decode body: %v", err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestLoginInfoTemporaryPassword(t *testing.T) {
|
|
s := NewServer(Options{Password: "generated", TemporaryPassword: true, Rules: newFakeRules()})
|
|
ts := httptest.NewServer(s.http.Handler)
|
|
t.Cleanup(ts.Close)
|
|
|
|
resp, err := http.Get(ts.URL + "/api/login-info")
|
|
if err != nil {
|
|
t.Fatalf("get login info: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
var out struct {
|
|
TemporaryPassword bool `json:"temporaryPassword"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
t.Fatalf("decode body: %v", err)
|
|
}
|
|
if !out.TemporaryPassword {
|
|
t.Fatal("expected temporaryPassword=true for a startup-generated password")
|
|
}
|
|
}
|
|
|
|
func TestLoginInfoFixedPassword(t *testing.T) {
|
|
s := NewServer(Options{Password: "secret", Rules: newFakeRules()})
|
|
ts := httptest.NewServer(s.http.Handler)
|
|
t.Cleanup(ts.Close)
|
|
|
|
resp, err := http.Get(ts.URL + "/api/login-info")
|
|
if err != nil {
|
|
t.Fatalf("get login info: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
var out struct {
|
|
TemporaryPassword bool `json:"temporaryPassword"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
t.Fatalf("decode body: %v", err)
|
|
}
|
|
if out.TemporaryPassword {
|
|
t.Fatal("expected temporaryPassword=false for a configured password")
|
|
}
|
|
}
|
|
|
|
func TestLoginWrongPassword(t *testing.T) {
|
|
ts := newTestServer(t, newFakeRules())
|
|
resp, err := http.Post(ts.URL+"/api/session", "application/json", strings.NewReader(`{"password":"wrong"}`))
|
|
if err != nil {
|
|
t.Fatalf("login: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestLoginThrottlesRepeatedFailures(t *testing.T) {
|
|
ts := newTestServer(t, newFakeRules())
|
|
|
|
// Each wrong attempt counts; after loginMaxFails the IP is locked out
|
|
// with 429 even for the correct password.
|
|
for i := 0; i < loginMaxFails; i++ {
|
|
resp, err := http.Post(ts.URL+"/api/session", "application/json", strings.NewReader(`{"password":"wrong"}`))
|
|
if err != nil {
|
|
t.Fatalf("login %d: %v", i, err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("attempt %d: expected 401, got %d", i, resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
resp, err := http.Post(ts.URL+"/api/session", "application/json", strings.NewReader(`{"password":"secret"}`))
|
|
if err != nil {
|
|
t.Fatalf("locked login: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusTooManyRequests {
|
|
t.Fatalf("expected 429 after repeated failures, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestLoginSuccessResetsThrottle(t *testing.T) {
|
|
ts := newTestServer(t, newFakeRules())
|
|
|
|
// A success clears the failure state, so a later failure starts over.
|
|
for i := 0; i < loginMaxFails-1; i++ {
|
|
resp, err := http.Post(ts.URL+"/api/session", "application/json", strings.NewReader(`{"password":"wrong"}`))
|
|
if err != nil {
|
|
t.Fatalf("login %d: %v", i, err)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
resp, err := http.Post(ts.URL+"/api/session", "application/json", strings.NewReader(`{"password":"secret"}`))
|
|
if err != nil {
|
|
t.Fatalf("login: %v", err)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
resp, err = http.Post(ts.URL+"/api/session", "application/json", strings.NewReader(`{"password":"wrong"}`))
|
|
if err != nil {
|
|
t.Fatalf("login after reset: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 after reset, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestLoginRejectsMalformedJSON(t *testing.T) {
|
|
ts := newTestServer(t, newFakeRules())
|
|
resp, err := http.Post(ts.URL+"/api/session", "application/json", strings.NewReader(`{invalid`))
|
|
if err != nil {
|
|
t.Fatalf("login: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestAPIRequiresSession(t *testing.T) {
|
|
ts := newTestServer(t, newFakeRules())
|
|
resp, err := http.Get(ts.URL + "/api/status")
|
|
if err != nil {
|
|
t.Fatalf("get status: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestSessionEndpoint(t *testing.T) {
|
|
ts := newTestServer(t, newFakeRules())
|
|
|
|
resp, err := http.Get(ts.URL + "/api/session")
|
|
if err != nil {
|
|
t.Fatalf("get session without cookie: %v", err)
|
|
}
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
resp.Body.Close()
|
|
t.Fatalf("expected 401 without session, got %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
cookie := login(t, ts, "secret")
|
|
resp = authedRequest(t, ts, http.MethodGet, "/api/session", cookie, "")
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("expected 204 for valid session, got %d", resp.StatusCode)
|
|
}
|
|
if cacheControl := resp.Header.Get("Cache-Control"); cacheControl != "no-store" {
|
|
t.Fatalf("Cache-Control = %q, want no-store", cacheControl)
|
|
}
|
|
for _, c := range resp.Cookies() {
|
|
if c.Name == sessionCookieName && c.MaxAge == int(sessionCookieMaxAge.Seconds()) {
|
|
return
|
|
}
|
|
}
|
|
t.Fatal("session probe did not renew the session cookie")
|
|
}
|
|
|
|
func TestStatusEndpoint(t *testing.T) {
|
|
ts := newTestServer(t, newFakeRules())
|
|
cookie := login(t, ts, "secret")
|
|
|
|
resp := authedRequest(t, ts, http.MethodGet, "/api/status", cookie, "")
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
body := decodeBody(t, resp)
|
|
if body["version"] != "v1.2.3" {
|
|
t.Fatalf("unexpected version: %v", body["version"])
|
|
}
|
|
rules := body["rules"].(map[string]any)
|
|
if rules["proxy"] != float64(0) {
|
|
t.Fatalf("unexpected rules: %v", rules)
|
|
}
|
|
}
|
|
|
|
// TestRulesAddPersistFailureReturns500 pins the contract that a state
|
|
// persistence failure surfaces as a 500 instead of a silent 204, so the
|
|
// console cannot pretend a rule change landed when it was not written.
|
|
func TestRulesAddPersistFailureReturns500(t *testing.T) {
|
|
fr := newFakeRules()
|
|
fr.errAdd = errors.New("persist rule additions: write /tmp/admin-state.json: no space left")
|
|
ts := newTestServer(t, fr)
|
|
cookie := login(t, ts, "secret")
|
|
|
|
resp := authedRequest(t, ts, http.MethodPost, "/api/rules", cookie,
|
|
`{"category":"proxy","rules":["example.com"]}`)
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusInternalServerError {
|
|
t.Fatalf("persist failure must yield 500, got %d", resp.StatusCode)
|
|
}
|
|
body := decodeBody(t, resp)
|
|
msg, _ := body["error"].(string)
|
|
if !strings.Contains(msg, "persist") {
|
|
t.Fatalf("error must surface the persist failure, got %q", msg)
|
|
}
|
|
}
|
|
|
|
func TestRulesCRUD(t *testing.T) {
|
|
ts := newTestServer(t, newFakeRules())
|
|
cookie := login(t, ts, "secret")
|
|
ruleOf := func(v any) string { return v.(map[string]any)["rule"].(string) }
|
|
|
|
// list empty
|
|
resp := authedRequest(t, ts, http.MethodGet, "/api/rules?category=proxy", cookie, "")
|
|
body := decodeBody(t, resp)
|
|
if rules, _ := body["rules"].([]any); len(rules) != 0 {
|
|
t.Fatalf("expected empty rules, got %v", rules)
|
|
}
|
|
|
|
// add returns 204 and the list reflects the addition
|
|
resp = authedRequest(t, ts, http.MethodPost, "/api/rules", cookie, `{"category":"proxy","rules":["example.com","**.cn"]}`)
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("add status: %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
body = decodeBody(t, authedRequest(t, ts, http.MethodGet, "/api/rules?category=proxy", cookie, ""))
|
|
if rules, _ := body["rules"].([]any); len(rules) != 2 {
|
|
t.Fatalf("expected 2 rules after add, got %v", rules)
|
|
}
|
|
|
|
// remove returns 204 and the list reflects the removal
|
|
resp = authedRequest(t, ts, http.MethodDelete, "/api/rules", cookie, `{"category":"proxy","rules":["example.com"]}`)
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("remove status: %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
body = decodeBody(t, authedRequest(t, ts, http.MethodGet, "/api/rules?category=proxy", cookie, ""))
|
|
if rules, _ := body["rules"].([]any); len(rules) != 1 || ruleOf(rules[0]) != "**.cn" {
|
|
t.Fatalf("unexpected rules after remove: %v", rules)
|
|
}
|
|
}
|
|
|
|
func TestRulesListPaginationAndSearch(t *testing.T) {
|
|
ts := newTestServer(t, newFakeRules())
|
|
cookie := login(t, ts, "secret")
|
|
|
|
for _, rule := range []string{"a.com", "b.cn", "a.cn", "github.com", "mail.com"} {
|
|
resp := authedRequest(t, ts, http.MethodPost, "/api/rules", cookie,
|
|
fmt.Sprintf(`{"category":"proxy","rules":[%q]}`, rule))
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("add %s: %d", rule, resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
// pagination
|
|
resp := authedRequest(t, ts, http.MethodGet, "/api/rules?category=proxy&offset=1&limit=2", cookie, "")
|
|
body := decodeBody(t, resp)
|
|
ruleOf := func(v any) string { return v.(map[string]any)["rule"].(string) }
|
|
if rules, _ := body["rules"].([]any); len(rules) != 2 || ruleOf(rules[0]) != "b.cn" || ruleOf(rules[1]) != "a.cn" {
|
|
t.Fatalf("unexpected page: %v", rules)
|
|
}
|
|
if total, _ := body["total"].(float64); total != 5 {
|
|
t.Fatalf("expected total 5, got %v", total)
|
|
}
|
|
|
|
// fuzzy search (case-insensitive substring)
|
|
resp = authedRequest(t, ts, http.MethodGet, "/api/rules?category=proxy&q=CN", cookie, "")
|
|
body = decodeBody(t, resp)
|
|
if rules, _ := body["rules"].([]any); len(rules) != 2 {
|
|
t.Fatalf("expected 2 matches for CN, got %v", rules)
|
|
}
|
|
|
|
// search + pagination
|
|
resp = authedRequest(t, ts, http.MethodGet, "/api/rules?category=proxy&q=cn&offset=1&limit=1", cookie, "")
|
|
body = decodeBody(t, resp)
|
|
if rules, _ := body["rules"].([]any); len(rules) != 1 || ruleOf(rules[0]) != "a.cn" {
|
|
t.Fatalf("unexpected search page: %v", rules)
|
|
}
|
|
|
|
// limit clamp
|
|
resp = authedRequest(t, ts, http.MethodGet, "/api/rules?category=proxy&limit=99999", cookie, "")
|
|
body = decodeBody(t, resp)
|
|
if limit, _ := body["limit"].(float64); limit != maxPageSize {
|
|
t.Fatalf("expected limit clamped to %d, got %v", maxPageSize, limit)
|
|
}
|
|
}
|
|
|
|
func TestRulesListSortByHits(t *testing.T) {
|
|
rules := newFakeRules()
|
|
rules.hits = []RuleHit{
|
|
{Rule: "b.com", Count: 5},
|
|
{Rule: "a.com", Count: 3},
|
|
}
|
|
ts := newTestServer(t, rules)
|
|
cookie := login(t, ts, "secret")
|
|
for _, rule := range []string{"a.com", "b.com", "c.com"} {
|
|
resp := authedRequest(t, ts, http.MethodPost, "/api/rules", cookie,
|
|
fmt.Sprintf(`{"category":"proxy","rules":[%q]}`, rule))
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("add %s: %d", rule, resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
resp := authedRequest(t, ts, http.MethodGet, "/api/rules?category=proxy&sort=hits", cookie, "")
|
|
defer resp.Body.Close()
|
|
var page rulesResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&page); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
got := make([]string, len(page.Rules))
|
|
counts := make([]uint64, len(page.Rules))
|
|
for i, e := range page.Rules {
|
|
got[i] = e.Rule
|
|
counts[i] = e.Count
|
|
}
|
|
want := []string{"b.com", "a.com", "c.com"}
|
|
if strings.Join(got, ",") != strings.Join(want, ",") {
|
|
t.Fatalf("unexpected hit ordering: %v", got)
|
|
}
|
|
if counts[0] != 5 || counts[1] != 3 || counts[2] != 0 {
|
|
t.Fatalf("unexpected counts: %v", counts)
|
|
}
|
|
|
|
// unknown sort falls back to default order
|
|
resp = authedRequest(t, ts, http.MethodGet, "/api/rules?category=proxy&sort=bogus", cookie, "")
|
|
defer resp.Body.Close()
|
|
if err := json.NewDecoder(resp.Body).Decode(&page); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if page.Rules[0].Rule != "a.com" {
|
|
t.Fatalf("expected default order for bogus sort, got %+v", page.Rules)
|
|
}
|
|
}
|
|
|
|
func TestRulesListSorts(t *testing.T) {
|
|
t1 := time.Date(2025, 1, 1, 10, 0, 0, 0, time.UTC)
|
|
t2 := time.Date(2025, 1, 2, 10, 0, 0, 0, time.UTC)
|
|
rules := newFakeRules()
|
|
rules.hits = []RuleHit{
|
|
{Rule: "b.com", Count: 5, LastSeen: t1},
|
|
{Rule: "a.com", Count: 3, LastSeen: t2},
|
|
}
|
|
ts := newTestServer(t, rules)
|
|
cookie := login(t, ts, "secret")
|
|
for _, rule := range []string{"b.com", "c.com", "a.com"} {
|
|
resp := authedRequest(t, ts, http.MethodPost, "/api/rules", cookie,
|
|
fmt.Sprintf(`{"category":"proxy","rules":[%q]}`, rule))
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("add %s: %d", rule, resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
orderOf := func(path string) []string {
|
|
t.Helper()
|
|
resp := authedRequest(t, ts, http.MethodGet, path, cookie, "")
|
|
defer resp.Body.Close()
|
|
var page rulesResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&page); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
got := make([]string, len(page.Rules))
|
|
for i, e := range page.Rules {
|
|
got[i] = e.Rule
|
|
}
|
|
return got
|
|
}
|
|
join := func(got []string) string { return strings.Join(got, ",") }
|
|
|
|
// rule sort defaults to ascending, honors explicit direction
|
|
if got := orderOf("/api/rules?category=proxy&sort=rule"); join(got) != "a.com,b.com,c.com" {
|
|
t.Fatalf("rule asc: %v", got)
|
|
}
|
|
if got := orderOf("/api/rules?category=proxy&sort=rule&dir=desc"); join(got) != "c.com,b.com,a.com" {
|
|
t.Fatalf("rule desc: %v", got)
|
|
}
|
|
|
|
// hits asc puts zero-count rules first, ascending counts behind
|
|
if got := orderOf("/api/rules?category=proxy&sort=hits&dir=asc"); join(got) != "c.com,a.com,b.com" {
|
|
t.Fatalf("hits asc: %v", got)
|
|
}
|
|
|
|
// last_seen defaults to most recent first and serializes the timestamp
|
|
resp := authedRequest(t, ts, http.MethodGet, "/api/rules?category=proxy&sort=last_seen", cookie, "")
|
|
defer resp.Body.Close()
|
|
var page rulesResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&page); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if page.Rules[0].Rule != "a.com" || page.Rules[1].Rule != "b.com" {
|
|
t.Fatalf("last_seen order: %+v", page.Rules)
|
|
}
|
|
if page.Rules[0].LastSeen == nil || !page.Rules[0].LastSeen.Equal(t2) {
|
|
t.Fatalf("lastSeen not serialized: %+v", page.Rules[0])
|
|
}
|
|
}
|
|
|
|
func TestRulesRejectsInvalidCategory(t *testing.T) {
|
|
ts := newTestServer(t, newFakeRules())
|
|
cookie := login(t, ts, "secret")
|
|
|
|
for _, path := range []string{"/api/rules?category=bogus"} {
|
|
resp := authedRequest(t, ts, http.MethodGet, path, cookie, "")
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for %s, got %d", path, resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
resp := authedRequest(t, ts, http.MethodPost, "/api/rules", cookie, `{"category":"bogus","rules":["a.com"]}`)
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for add with bad category, got %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
func TestRulesChangesAndResetEndpoints(t *testing.T) {
|
|
ts := newTestServer(t, newFakeRules())
|
|
cookie := login(t, ts, "secret")
|
|
|
|
// changes reports all three categories and the persistence flag
|
|
resp := authedRequest(t, ts, http.MethodGet, "/api/rules/changes", cookie, "")
|
|
body := decodeBody(t, resp)
|
|
if persistent, _ := body["persistent"].(bool); !persistent {
|
|
t.Fatalf("expected persistent=true, got %v", body["persistent"])
|
|
}
|
|
rules, _ := body["rules"].(map[string]any)
|
|
for _, cat := range []string{"block", "direct", "proxy"} {
|
|
if _, ok := rules[cat]; !ok {
|
|
t.Fatalf("changes missing category %s: %v", cat, rules)
|
|
}
|
|
}
|
|
|
|
// changes requires a session
|
|
resp = authedRequest(t, ts, http.MethodGet, "/api/rules/changes", "", "")
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without session, got %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
// reset rejects an invalid category
|
|
resp = authedRequest(t, ts, http.MethodPost, "/api/rules/reset", cookie, `{"category":"bogus"}`)
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for reset with bad category, got %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
// reset one category, then all
|
|
for _, payload := range []string{`{"category":"proxy"}`, `{}`} {
|
|
resp = authedRequest(t, ts, http.MethodPost, "/api/rules/reset", cookie, payload)
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("expected 204 for reset %s, got %d", payload, resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
}
|
|
|
|
func TestRulesRejectsInvalidBody(t *testing.T) {
|
|
ts := newTestServer(t, newFakeRules())
|
|
cookie := login(t, ts, "secret")
|
|
|
|
// unknown field
|
|
resp := authedRequest(t, ts, http.MethodPost, "/api/rules", cookie, `{"category":"proxy","rules":["a.com"],"extra":1}`)
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for unknown field, got %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
// empty rule list
|
|
resp = authedRequest(t, ts, http.MethodPost, "/api/rules", cookie, `{"category":"proxy","rules":[]}`)
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for empty rules, got %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
// whitespace-only rule
|
|
resp = authedRequest(t, ts, http.MethodPost, "/api/rules", cookie, `{"category":"proxy","rules":[" "]}`)
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for whitespace rule, got %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
// rule longer than the max rule length
|
|
resp = authedRequest(t, ts, http.MethodPost, "/api/rules", cookie, `{"category":"proxy","rules":["`+strings.Repeat("a", 300)+`"]}`)
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for oversized rule, got %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
// oversized body
|
|
big := strings.Repeat("a", 70<<10)
|
|
resp = authedRequest(t, ts, http.MethodPost, "/api/rules", cookie, `{"category":"proxy","rules":["`+big+`"]}`)
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for oversized body, got %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
func TestCrossOriginMutationRejected(t *testing.T) {
|
|
ts := newTestServer(t, newFakeRules())
|
|
cookie := login(t, ts, "secret")
|
|
|
|
req, err := http.NewRequest(http.MethodPost, ts.URL+"/api/rules", strings.NewReader(`{"category":"proxy","rules":["a.com"]}`))
|
|
if err != nil {
|
|
t.Fatalf("new request: %v", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Cookie", sessionCookieName+"="+cookie)
|
|
req.Header.Set("Origin", "http://evil.example")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("request: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for cross-origin mutation, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestLogoutInvalidatesSession(t *testing.T) {
|
|
ts := newTestServer(t, newFakeRules())
|
|
cookie := login(t, ts, "secret")
|
|
|
|
resp := authedRequest(t, ts, http.MethodDelete, "/api/session", cookie, "")
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("logout status: %d", resp.StatusCode)
|
|
}
|
|
cookieExpired := false
|
|
for _, c := range resp.Cookies() {
|
|
if c.Name == sessionCookieName && c.MaxAge < 0 {
|
|
cookieExpired = true
|
|
}
|
|
}
|
|
resp.Body.Close()
|
|
if !cookieExpired {
|
|
t.Fatal("logout did not expire the session cookie")
|
|
}
|
|
|
|
resp = authedRequest(t, ts, http.MethodGet, "/api/status", cookie, "")
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 after logout, got %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
func TestTrafficEndpoint(t *testing.T) {
|
|
stats := newTestStats(t)
|
|
s := NewServer(Options{Password: "secret", Version: "v1.2.3", Date: "2026-01-01", Rules: newFakeRules(), Stats: stats})
|
|
ts := httptest.NewServer(s.http.Handler)
|
|
t.Cleanup(ts.Close)
|
|
|
|
cookie := login(t, ts, "secret")
|
|
stats.RecordDNS("example.com.", "")
|
|
stats.WrapConn(&fakeConn{}, "socks5")
|
|
|
|
resp := authedRequest(t, ts, http.MethodGet, "/api/traffic", cookie, "")
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
body := decodeBody(t, resp)
|
|
if body["dnsQueries"] != float64(1) {
|
|
t.Fatalf("unexpected dnsQueries: %v", body["dnsQueries"])
|
|
}
|
|
if body["uptime"] == nil {
|
|
t.Fatal("expected uptime field")
|
|
}
|
|
}
|
|
|
|
func TestTotalsEndpoint(t *testing.T) {
|
|
stats := newTestStats(t)
|
|
s := NewServer(Options{Password: "secret", Version: "v1.2.3", Date: "2026-01-01", Rules: newFakeRules(), Stats: stats})
|
|
ts := httptest.NewServer(s.http.Handler)
|
|
t.Cleanup(ts.Close)
|
|
|
|
cookie := login(t, ts, "secret")
|
|
stats.RecordDNS("example.com.", "192.168.1.10")
|
|
|
|
resp := authedRequest(t, ts, http.MethodGet, "/api/totals", cookie, "")
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
body := decodeBody(t, resp)
|
|
if domains, ok := body["domains"].([]any); !ok || len(domains) != 1 {
|
|
t.Fatalf("expected 1 domain in totals, got %v", body["domains"])
|
|
}
|
|
if clients, ok := body["clients"].([]any); !ok || len(clients) != 1 {
|
|
t.Fatalf("expected 1 client in totals, got %v", body["clients"])
|
|
}
|
|
}
|
|
|
|
func TestRulesTestEndpoint(t *testing.T) {
|
|
s := NewServer(Options{Password: "secret", Rules: newFakeRules()})
|
|
if err := s.opts.Rules.RuleAdd(CategoryBlock, "ads.example.com"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.opts.Rules.RuleAdd(CategoryProxy, "**.example.org"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ts := httptest.NewServer(s.http.Handler)
|
|
t.Cleanup(ts.Close)
|
|
cookie := login(t, ts, "secret")
|
|
|
|
resp := authedRequest(t, ts, http.MethodGet, "/api/rules/test?domain=ads.example.com", cookie, "")
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
body := decodeBody(t, resp)
|
|
if body["domain"] != "ads.example.com" || body["route"] != "block" {
|
|
t.Fatalf("unexpected test result: %v", body)
|
|
}
|
|
matches := body["matches"].([]any)
|
|
first := matches[0].(map[string]any)
|
|
if first["category"] != "block" || first["matched"] != true || first["rule"] != "ads.example.com" {
|
|
t.Fatalf("unexpected block match: %v", first)
|
|
}
|
|
|
|
resp = authedRequest(t, ts, http.MethodGet, "/api/rules/test?domain=sub.example.org", cookie, "")
|
|
body = decodeBody(t, resp)
|
|
if body["route"] != "proxy" {
|
|
t.Fatalf("expected proxy route for wildcard match, got %v", body)
|
|
}
|
|
|
|
// no rules matched: auto with a note, block takes priority over proxy
|
|
resp = authedRequest(t, ts, http.MethodGet, "/api/rules/test?domain=unknown.test", cookie, "")
|
|
body = decodeBody(t, resp)
|
|
if body["route"] != "auto" {
|
|
t.Fatalf("expected auto route, got %v", body)
|
|
}
|
|
|
|
resp = authedRequest(t, ts, http.MethodGet, "/api/rules/test", cookie, "")
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 without domain, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestRuleMissEndpoint(t *testing.T) {
|
|
t1 := time.Date(2025, 1, 1, 10, 0, 0, 0, time.UTC)
|
|
t2 := time.Date(2025, 1, 2, 10, 0, 0, 0, time.UTC)
|
|
rules := newFakeRules()
|
|
rules.miss = []RuleHit{
|
|
{Rule: "baidu.com", Count: 5, LastSeen: t1},
|
|
{Rule: "qq.com", Count: 3, LastSeen: t2},
|
|
}
|
|
s := NewServer(Options{Password: "secret", Rules: rules})
|
|
ts := httptest.NewServer(s.http.Handler)
|
|
t.Cleanup(ts.Close)
|
|
cookie := login(t, ts, "secret")
|
|
|
|
// Default sort: by connection count, descending.
|
|
resp := authedRequest(t, ts, http.MethodGet, "/api/rules/miss", cookie, "")
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
var miss []RuleHit
|
|
if err := json.NewDecoder(resp.Body).Decode(&miss); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
resp.Body.Close()
|
|
if len(miss) != 2 || miss[0].Rule != "baidu.com" || miss[0].Count != 5 {
|
|
t.Fatalf("unexpected miss order: %+v", miss)
|
|
}
|
|
|
|
// Recent sort: by most recent access, descending.
|
|
resp = authedRequest(t, ts, http.MethodGet, "/api/rules/miss?sort=recent", cookie, "")
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&miss); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
resp.Body.Close()
|
|
if len(miss) != 2 || miss[0].Rule != "qq.com" {
|
|
t.Fatalf("unexpected recent order: %+v", miss)
|
|
}
|
|
|
|
// Limit clamp.
|
|
resp = authedRequest(t, ts, http.MethodGet, "/api/rules/miss?limit=1", cookie, "")
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&miss); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
resp.Body.Close()
|
|
if len(miss) != 1 || miss[0].Rule != "baidu.com" {
|
|
t.Fatalf("unexpected limited miss: %+v", miss)
|
|
}
|
|
}
|
|
|
|
// ruleManagerNoHits wraps a RuleManager to hide the RuleHitProvider
|
|
// implementation, exercising the 404 path of the hits endpoint.
|
|
type ruleManagerNoHits struct {
|
|
RuleManager
|
|
}
|
|
|
|
// TestRuleHitsUnavailable guards the 404 path when the RuleManager does not
|
|
// implement the hit provider (e.g. an alternative adapter).
|
|
func TestRuleHitsUnavailable(t *testing.T) {
|
|
s := NewServer(Options{Password: "secret", Rules: ruleManagerNoHits{newFakeRules()}})
|
|
ts := httptest.NewServer(s.http.Handler)
|
|
t.Cleanup(ts.Close)
|
|
cookie := login(t, ts, "secret")
|
|
|
|
resp := authedRequest(t, ts, http.MethodGet, "/api/rules/hits", cookie, "")
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("expected 404 without provider, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestTrafficEndpointRejectsMissingStats(t *testing.T) {
|
|
s := NewServer(Options{Password: "secret", Rules: newFakeRules()})
|
|
ts := httptest.NewServer(s.http.Handler)
|
|
t.Cleanup(ts.Close)
|
|
cookie := login(t, ts, "secret")
|
|
|
|
resp := authedRequest(t, ts, http.MethodGet, "/api/traffic", cookie, "")
|
|
if resp.StatusCode != http.StatusInternalServerError {
|
|
t.Fatalf("expected 500 when stats are unavailable, got %d", resp.StatusCode)
|
|
}
|
|
body := decodeBody(t, resp)
|
|
if body["error"] != "stats unavailable" {
|
|
t.Fatalf("unexpected error: %v", body)
|
|
}
|
|
}
|
|
|
|
func TestStaticServesIndexAndSPAFallback(t *testing.T) {
|
|
ts := newTestServer(t, newFakeRules())
|
|
|
|
resp, err := http.Get(ts.URL + "/")
|
|
if err != nil {
|
|
t.Fatalf("get /: %v", err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200 for /, got %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
// SPA fallback for client-side routes
|
|
resp, err = http.Get(ts.URL + "/some/client/route")
|
|
if err != nil {
|
|
t.Fatalf("get route: %v", err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200 for SPA route, got %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
func TestUnknownAPIPathReturnsJSONNotFound(t *testing.T) {
|
|
ts := newTestServer(t, newFakeRules())
|
|
for _, path := range []string{"/api", "/api/missing"} {
|
|
resp, err := http.Get(ts.URL + path)
|
|
if err != nil {
|
|
t.Fatalf("get %s: %v", path, err)
|
|
}
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
resp.Body.Close()
|
|
t.Fatalf("expected 404 for %s, got %d", path, resp.StatusCode)
|
|
}
|
|
if !strings.HasPrefix(resp.Header.Get("Content-Type"), "application/json") {
|
|
resp.Body.Close()
|
|
t.Fatalf("expected JSON response for %s, got %q", path, resp.Header.Get("Content-Type"))
|
|
}
|
|
body := decodeBody(t, resp)
|
|
if body["error"] != "unknown API endpoint" {
|
|
t.Fatalf("unexpected error for %s: %v", path, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestShutdown(t *testing.T) {
|
|
s := NewServer(Options{Password: "secret", Rules: newFakeRules(), Stats: newTestStats(t)})
|
|
ts := httptest.NewServer(s.http.Handler)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
if err := s.Shutdown(ctx); err != nil {
|
|
t.Fatalf("shutdown: %v", err)
|
|
}
|
|
ts.Close()
|
|
}
|
|
|
|
func TestHistoryEndpoint(t *testing.T) {
|
|
stats := newTestStats(t)
|
|
s := NewServer(Options{Password: "secret", Version: "v1.2.3", Date: "2026-01-01", Rules: newFakeRules(), Stats: stats})
|
|
ts := httptest.NewServer(s.http.Handler)
|
|
defer ts.Close()
|
|
|
|
cookie := login(t, ts, "secret")
|
|
resp := authedRequest(t, ts, http.MethodGet, "/api/history", cookie, "")
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
var body struct {
|
|
Samples []HistorySample `json:"samples"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if body.Samples == nil {
|
|
t.Fatal("expected samples array")
|
|
}
|
|
}
|
|
|
|
func TestHistoryEndpointRequiresAuth(t *testing.T) {
|
|
stats := newTestStats(t)
|
|
s := NewServer(Options{Password: "secret", Version: "v1.2.3", Date: "2026-01-01", Rules: newFakeRules(), Stats: stats})
|
|
ts := httptest.NewServer(s.http.Handler)
|
|
defer ts.Close()
|
|
|
|
resp, err := http.Get(ts.URL + "/api/history")
|
|
if err != nil {
|
|
t.Fatalf("get history: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without session, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestMetricsEndpointServesPrometheusFormat(t *testing.T) {
|
|
stats := newTestStats(t)
|
|
s := NewServer(Options{Password: "secret", Version: "v1.2.3", Date: "2026-01-01", Rules: newFakeRules(), Stats: stats})
|
|
ts := httptest.NewServer(s.http.Handler)
|
|
defer ts.Close()
|
|
|
|
stats.RecordDNS("example.com", "")
|
|
stats.RecordRoute("proxy", "")
|
|
conn := stats.WrapConn(&fakeConn{}, "https")
|
|
_ = conn.Close()
|
|
|
|
resp, err := http.Get(ts.URL + "/metrics")
|
|
if err != nil {
|
|
t.Fatalf("get metrics: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
t.Fatalf("read body: %v", err)
|
|
}
|
|
for _, want := range []string{
|
|
"sower_dns_queries_total 1",
|
|
`sower_rule_hits_total{route="proxy"} 1`,
|
|
`sower_connections_active{protocol="https"} 0`,
|
|
} {
|
|
if !strings.Contains(string(body), want) {
|
|
t.Fatalf("expected %q in metrics, got:\n%s", want, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestMetricsBytesCountedOncePerFlush guards the OTel byte counter against
|
|
// double counting: each flush must record the batch exactly once, and bytes
|
|
// read before BindConn (e.g. TLS ClientHello) must still be counted.
|
|
func TestMetricsBytesCountedOncePerFlush(t *testing.T) {
|
|
stats := newTestStats(t)
|
|
s := NewServer(Options{Password: "secret", Version: "v1.2.3", Date: "2026-01-01", Rules: newFakeRules(), Stats: stats})
|
|
ts := httptest.NewServer(s.http.Handler)
|
|
defer ts.Close()
|
|
|
|
conn := stats.WrapConn(&fakeConn{readBuf: bytes.Repeat([]byte("x"), 300)}, "https")
|
|
pre := make([]byte, 100) // protocol bytes read before the domain is known
|
|
if _, err := conn.Read(pre); err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
stats.BindConn(conn, "example.com")
|
|
payload := make([]byte, 200)
|
|
if _, err := conn.Read(payload); err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
if _, err := conn.Write(bytes.Repeat([]byte("y"), 1000)); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
_ = conn.Close() // drains the pending batches
|
|
|
|
resp, err := http.Get(ts.URL + "/metrics")
|
|
if err != nil {
|
|
t.Fatalf("get metrics: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
t.Fatalf("read body: %v", err)
|
|
}
|
|
for _, want := range []string{
|
|
`sower_bytes_total{direction="up"} 300`,
|
|
`sower_bytes_total{direction="down"} 1000`,
|
|
} {
|
|
if !strings.Contains(string(body), want) {
|
|
t.Fatalf("expected %q in metrics, got:\n%s", want, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSessionSurvivesRestart(t *testing.T) {
|
|
dir := t.TempDir()
|
|
file := filepath.Join(dir, "sessions.json")
|
|
newServer := func() *httptest.Server {
|
|
s := NewServer(Options{
|
|
Password: "secret", Version: "v1.2.3", Date: "2026-01-01",
|
|
Rules: newFakeRules(), Stats: newTestStats(t), SessionFile: file,
|
|
})
|
|
ts := httptest.NewServer(s.http.Handler)
|
|
t.Cleanup(ts.Close)
|
|
return ts
|
|
}
|
|
|
|
ts1 := newServer()
|
|
cookie := login(t, ts1, "secret")
|
|
|
|
// simulate a process restart: a frontend-style session probe on the new
|
|
// server must accept the persisted cookie before rendering the console.
|
|
ts2 := newServer()
|
|
resp := authedRequest(t, ts2, http.MethodGet, "/api/session", cookie, "")
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("expected session probe to survive restart, got %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
// the persisted file must be private
|
|
fi, err := os.Stat(file)
|
|
if err != nil {
|
|
t.Fatalf("stat session file: %v", err)
|
|
}
|
|
if fi.Mode().Perm() != 0o600 {
|
|
t.Fatalf("expected session file 0600, got %v", fi.Mode().Perm())
|
|
}
|
|
|
|
// logout on the new server must revoke the persisted session
|
|
resp = authedRequest(t, ts2, http.MethodDelete, "/api/session", cookie, "")
|
|
resp.Body.Close()
|
|
resp = authedRequest(t, ts2, http.MethodGet, "/api/status", cookie, "")
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("expected logout to revoke session, got %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
func TestSessionSlidingExpiry(t *testing.T) {
|
|
s := NewServer(Options{Password: "secret", Rules: newFakeRules(), Stats: newTestStats(t)})
|
|
func() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.sessions["token"] = time.Now().Add(sessionTTL / 4) // less than half TTL remaining
|
|
}()
|
|
ts := httptest.NewServer(s.http.Handler)
|
|
t.Cleanup(ts.Close)
|
|
|
|
resp := authedRequest(t, ts, http.MethodGet, "/api/session", "token", "")
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("expected renewed session probe to return 204, got %d", resp.StatusCode)
|
|
}
|
|
renewedCookie := false
|
|
for _, c := range resp.Cookies() {
|
|
if c.Name != sessionCookieName {
|
|
continue
|
|
}
|
|
if c.MaxAge != int(sessionCookieMaxAge.Seconds()) {
|
|
t.Fatalf("renewed cookie MaxAge = %d, want %d", c.MaxAge, int(sessionCookieMaxAge.Seconds()))
|
|
}
|
|
renewedCookie = true
|
|
}
|
|
if !renewedCookie {
|
|
t.Fatal("session renewal did not emit a cookie")
|
|
}
|
|
|
|
var exp time.Time
|
|
func() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
exp = s.sessions["token"]
|
|
}()
|
|
if remaining := time.Until(exp); remaining < sessionTTL/2 {
|
|
t.Fatalf("expected sliding expiry to refresh TTL, remaining %v", remaining)
|
|
}
|
|
}
|
|
|
|
func TestLoginSucceedsWhenSessionPersistenceFails(t *testing.T) {
|
|
notDir := filepath.Join(t.TempDir(), "not-a-directory")
|
|
if err := os.WriteFile(notDir, []byte("x"), 0o600); err != nil {
|
|
t.Fatalf("write persistence blocker: %v", err)
|
|
}
|
|
s := NewServer(Options{
|
|
Password: "secret", Rules: newFakeRules(), Stats: newTestStats(t),
|
|
SessionFile: filepath.Join(notDir, "sessions.json"),
|
|
})
|
|
ts := httptest.NewServer(s.http.Handler)
|
|
t.Cleanup(ts.Close)
|
|
|
|
resp, err := http.Post(ts.URL+"/api/session", "application/json", strings.NewReader(`{"password":"secret"}`))
|
|
if err != nil {
|
|
t.Fatalf("login with unwritable persistence: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("expected 204 despite persistence failure, got %d", resp.StatusCode)
|
|
}
|
|
var cookie string
|
|
for _, c := range resp.Cookies() {
|
|
if c.Name == sessionCookieName {
|
|
cookie = c.Value
|
|
}
|
|
}
|
|
if cookie == "" {
|
|
t.Fatal("login did not issue a session cookie")
|
|
}
|
|
func() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if _, ok := s.sessions[cookie]; !ok {
|
|
t.Fatal("login did not retain the session in memory")
|
|
}
|
|
}()
|
|
|
|
// The in-memory session must still authenticate API requests.
|
|
authed := authedRequest(t, ts, http.MethodGet, "/api/session", cookie, "")
|
|
defer authed.Body.Close()
|
|
if authed.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("expected in-memory session to authenticate, got %d", authed.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestLogoutRevokesMemoryWhenPersistenceFails(t *testing.T) {
|
|
notDir := filepath.Join(t.TempDir(), "not-a-directory")
|
|
if err := os.WriteFile(notDir, []byte("x"), 0o600); err != nil {
|
|
t.Fatalf("write persistence blocker: %v", err)
|
|
}
|
|
s := NewServer(Options{
|
|
Password: "secret", Rules: newFakeRules(), Stats: newTestStats(t),
|
|
SessionFile: filepath.Join(notDir, "sessions.json"),
|
|
})
|
|
ts := httptest.NewServer(s.http.Handler)
|
|
t.Cleanup(ts.Close)
|
|
|
|
cookie := login(t, ts, "secret")
|
|
req, err := http.NewRequest(http.MethodDelete, ts.URL+"/api/session", nil)
|
|
if err != nil {
|
|
t.Fatalf("build logout request: %v", err)
|
|
}
|
|
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: cookie})
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("logout with unwritable persistence: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("expected 204 despite persistence failure, got %d", resp.StatusCode)
|
|
}
|
|
func() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if _, ok := s.sessions[cookie]; ok {
|
|
t.Fatal("logout left the session valid in memory")
|
|
}
|
|
}()
|
|
}
|
|
|
|
func TestLoginCookieSecureFromConfig(t *testing.T) {
|
|
s := NewServer(Options{Password: "secret", Rules: newFakeRules(), CookieSecure: true})
|
|
ts := httptest.NewServer(s.http.Handler)
|
|
t.Cleanup(ts.Close)
|
|
|
|
resp, err := http.Post(ts.URL+"/api/session", "application/json", strings.NewReader(`{"password":"secret"}`))
|
|
if err != nil {
|
|
t.Fatalf("login: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("expected 204, got %d", resp.StatusCode)
|
|
}
|
|
for _, c := range resp.Cookies() {
|
|
if c.Name == sessionCookieName {
|
|
if !c.Secure {
|
|
t.Fatal("expected Secure cookie from CookieSecure option")
|
|
}
|
|
return
|
|
}
|
|
}
|
|
t.Fatal("login cookie not set")
|
|
}
|
|
|
|
func TestExpiredSessionsDroppedOnLoad(t *testing.T) {
|
|
file := filepath.Join(t.TempDir(), "sessions.json")
|
|
now := time.Now()
|
|
data, err := json.Marshal(map[string]time.Time{
|
|
"expired": now.Add(-time.Minute),
|
|
"alive": now.Add(time.Hour),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
if err := os.WriteFile(file, data, 0o600); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
|
|
s := NewServer(Options{Password: "secret", Rules: newFakeRules(), Stats: newTestStats(t), SessionFile: file})
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if _, ok := s.sessions["expired"]; ok {
|
|
t.Fatal("expected expired session to be dropped on load")
|
|
}
|
|
if _, ok := s.sessions["alive"]; !ok {
|
|
t.Fatal("expected live session to be restored")
|
|
}
|
|
}
|
|
|
|
func TestStreamEndpointRequiresAuth(t *testing.T) {
|
|
ts := newTestServer(t, newFakeRules())
|
|
defer ts.Close()
|
|
|
|
resp, err := http.Get(ts.URL + "/api/stream")
|
|
if err != nil {
|
|
t.Fatalf("get stream: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without session, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestStreamEndpointPushesEvents(t *testing.T) {
|
|
stats := newTestStats(t)
|
|
s := NewServer(Options{Password: "secret", Version: "v1.2.3", Date: "2026-01-01", Rules: newFakeRules(), Stats: stats})
|
|
ts := httptest.NewServer(s.http.Handler)
|
|
defer ts.Close()
|
|
|
|
cookie := login(t, ts, "secret")
|
|
req, err := http.NewRequest(http.MethodGet, ts.URL+"/api/stream?source=dns", nil)
|
|
if err != nil {
|
|
t.Fatalf("new request: %v", err)
|
|
}
|
|
req.Header.Set("Cookie", sessionCookieName+"="+cookie)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("get stream: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
|
|
t.Fatalf("expected text/event-stream, got %q", ct)
|
|
}
|
|
|
|
type line struct{ text string }
|
|
lines := make(chan line, 8)
|
|
go func() {
|
|
scanner := bufio.NewScanner(resp.Body)
|
|
scanner.Buffer(make([]byte, 64<<10), 1<<20)
|
|
for scanner.Scan() {
|
|
lines <- line{scanner.Text()}
|
|
}
|
|
}()
|
|
|
|
saw := map[string]bool{}
|
|
deadline := time.After(3 * time.Second)
|
|
for !saw["status"] || !saw["traffic"] || !saw["history"] {
|
|
select {
|
|
case <-deadline:
|
|
t.Fatalf("timed out waiting for events: %v", saw)
|
|
case l := <-lines:
|
|
if prefix, ok := strings.CutPrefix(l.text, "event: "); ok {
|
|
saw[prefix] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStreamSessionRenewalClosesWithoutAuth(t *testing.T) {
|
|
stats := newTestStats(t)
|
|
s := NewServer(Options{Password: "secret", Rules: newFakeRules(), Stats: stats})
|
|
ts := httptest.NewServer(s.http.Handler)
|
|
t.Cleanup(ts.Close)
|
|
|
|
cookie := login(t, ts, "secret")
|
|
req, err := http.NewRequest(http.MethodGet, ts.URL+"/api/stream", nil)
|
|
if err != nil {
|
|
t.Fatalf("new stream request: %v", err)
|
|
}
|
|
req.Header.Set("Cookie", sessionCookieName+"="+cookie)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("open stream: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
events := make(chan string, 16)
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
defer close(events)
|
|
scanner := bufio.NewScanner(resp.Body)
|
|
for scanner.Scan() {
|
|
if event, ok := strings.CutPrefix(scanner.Text(), "event: "); ok {
|
|
events <- event
|
|
}
|
|
}
|
|
}()
|
|
|
|
func() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.sessions[cookie] = time.Now().Add(sessionTTL / 4)
|
|
}()
|
|
|
|
deadline := time.After(8 * time.Second)
|
|
for {
|
|
select {
|
|
case event, ok := <-events:
|
|
if !ok {
|
|
t.Fatal("stream closed before sending renewal event")
|
|
}
|
|
switch event {
|
|
case "auth":
|
|
t.Fatal("renewal emitted auth event")
|
|
case "renew":
|
|
select {
|
|
case <-done:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("stream stayed open after renewal event")
|
|
}
|
|
return
|
|
}
|
|
case <-deadline:
|
|
t.Fatal("timed out waiting for session renewal")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStreamDefaultTrafficSnapshotCacheConcurrent(t *testing.T) {
|
|
stats := newTestStats(t)
|
|
s := NewServer(Options{Password: "secret", Rules: newFakeRules(), Stats: stats})
|
|
|
|
var wg sync.WaitGroup
|
|
errors := make(chan string, 16)
|
|
for range 8 {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for i := 0; i < 50; i++ {
|
|
snap := s.streamTrafficSnapshot(DomainSortBytes, SourceAll, "")
|
|
if snap.DNSQueries != 0 {
|
|
errors <- fmt.Sprintf("unexpected DNS queries %d in cached view", snap.DNSQueries)
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
close(errors)
|
|
for err := range errors {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestStreamDefaultTrafficSnapshotCache(t *testing.T) {
|
|
stats := newTestStats(t)
|
|
s := NewServer(Options{Password: "secret", Rules: newFakeRules(), Stats: stats})
|
|
|
|
first := s.streamTrafficSnapshot(DomainSortBytes, SourceAll, "")
|
|
stats.RecordDNS("fresh.example", "")
|
|
cached := s.streamTrafficSnapshot(DomainSortBytes, SourceAll, "")
|
|
if cached.DNSQueries != first.DNSQueries {
|
|
t.Fatalf("expected cached snapshot, got DNS queries %d after %d", cached.DNSQueries, first.DNSQueries)
|
|
}
|
|
|
|
s.trafficMu.Lock()
|
|
s.traffic.at = time.Now().Add(-trafficCacheTTL)
|
|
s.trafficMu.Unlock()
|
|
refreshed := s.streamTrafficSnapshot(DomainSortBytes, SourceAll, "")
|
|
if refreshed.DNSQueries != first.DNSQueries+1 {
|
|
t.Fatalf("expected refreshed snapshot with new DNS query, got %d", refreshed.DNSQueries)
|
|
}
|
|
|
|
stats.RecordDNS("filtered.example", "")
|
|
filtered := s.streamTrafficSnapshot(DomainSortRecent, SourceAll, "")
|
|
if filtered.DNSQueries != refreshed.DNSQueries+1 {
|
|
t.Fatalf("expected filtered snapshot to bypass cache, got %d", filtered.DNSQueries)
|
|
}
|
|
}
|
|
|
|
// failingFlusher is a ResponseWriter whose flush can be made to fail on
|
|
// demand, letting the stream handler's write-failure path be exercised
|
|
// without a real socket. The first flush errors like a dead connection
|
|
// after the initial payload has been delivered.
|
|
type failingFlusher struct {
|
|
header http.Header
|
|
flushed atomic.Int32
|
|
fail atomic.Bool
|
|
}
|
|
|
|
func (f *failingFlusher) Header() http.Header {
|
|
if f.header == nil {
|
|
f.header = make(http.Header)
|
|
}
|
|
return f.header
|
|
}
|
|
func (*failingFlusher) WriteHeader(int) {}
|
|
func (*failingFlusher) Write(p []byte) (int, error) { return len(p), nil }
|
|
|
|
// FlushError is the error-returning form http.ResponseController.Flush
|
|
// looks for (Go 1.26+); it lets the test fail flushes on demand.
|
|
func (f *failingFlusher) FlushError() error {
|
|
f.flushed.Add(1)
|
|
if f.fail.Load() {
|
|
return errors.New("flush failed")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TestStreamHandlerExitsOnWriteFailure pins that a dead connection cannot
|
|
// hold the handler open: once a write fails, the stream is torn down instead
|
|
// of looping forever on a client that vanished without closing the TCP
|
|
// connection.
|
|
func TestStreamHandlerExitsOnWriteFailure(t *testing.T) {
|
|
s := NewServer(Options{Password: "secret", Rules: newFakeRules(), Stats: newTestStats(t)})
|
|
s.mu.Lock()
|
|
s.sessions["token"] = time.Now().Add(time.Hour)
|
|
s.mu.Unlock()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/stream", nil)
|
|
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "token"})
|
|
w := &failingFlusher{}
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
s.handleStream(w, req)
|
|
close(done)
|
|
}()
|
|
|
|
// The initial payload goes out while writes succeed.
|
|
deadline := time.After(2 * time.Second)
|
|
for w.flushed.Load() < 5 { // retry line + four initial events
|
|
select {
|
|
case <-deadline:
|
|
t.Fatalf("initial payload not flushed after 2s (flushed=%d)", w.flushed.Load())
|
|
case <-done:
|
|
t.Fatal("stream exited before the write failure")
|
|
case <-time.After(10 * time.Millisecond):
|
|
}
|
|
}
|
|
|
|
// The next tick write fails, so the handler must return instead of
|
|
// blocking forever on the dead connection.
|
|
w.fail.Store(true)
|
|
select {
|
|
case <-done:
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("stream handler stayed alive after write failures")
|
|
}
|
|
}
|
|
|
|
func TestConfigGetSanitizesSecrets(t *testing.T) {
|
|
ts := newTestConfigServer(t, newFakeConfig(t))
|
|
cookie := login(t, ts, "secret")
|
|
|
|
resp := authedRequest(t, ts, http.MethodGet, "/api/config", cookie, "")
|
|
body := decodeBody(t, resp)
|
|
raw, _ := json.Marshal(body)
|
|
if strings.Contains(string(raw), "secret") && strings.Contains(string(raw), "password\":") {
|
|
t.Fatalf("secret value leaked in config view: %s", raw)
|
|
}
|
|
// The password field must surface as metadata, not a value.
|
|
if !strings.Contains(string(raw), `"secret":true`) || !strings.Contains(string(raw), `"configured":true`) {
|
|
t.Fatalf("expected secret metadata, got %s", raw)
|
|
}
|
|
|
|
// config requires a session
|
|
resp = authedRequest(t, ts, http.MethodGet, "/api/config", "", "")
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without session, got %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
func TestConfigPatchValidationAndRevision(t *testing.T) {
|
|
fake := newFakeConfig(t)
|
|
ts := newTestConfigServer(t, fake)
|
|
cookie := login(t, ts, "secret")
|
|
|
|
// invalid log level
|
|
resp := authedRequest(t, ts, http.MethodPatch, "/api/config", cookie,
|
|
`{"revision":0,"changes":{"log_level":"bogus"}}`)
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for invalid log_level, got %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
// invalid DNS IP
|
|
resp = authedRequest(t, ts, http.MethodPatch, "/api/config", cookie,
|
|
`{"revision":0,"changes":{"dns_upstream":"not-an-ip"}}`)
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for invalid dns_upstream, got %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
// valid IPv6 remote addresses must pass (bracketed host:port and bare literal)
|
|
resp = authedRequest(t, ts, http.MethodPatch, "/api/config", cookie,
|
|
`{"revision":0,"changes":{"remote_addr":"[2001:db8::1]:8443"}}`)
|
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusConflict {
|
|
t.Fatalf("expected bracketed ipv6 remote_addr to validate, got %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
// malformed remote address rejected
|
|
resp = authedRequest(t, ts, http.MethodPatch, "/api/config", cookie,
|
|
`{"revision":0,"changes":{"remote_addr":"::::"}}`)
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for garbage remote_addr, got %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
// unknown fields are rejected by the strict decoder
|
|
resp = authedRequest(t, ts, http.MethodPatch, "/api/config", cookie,
|
|
`{"revision":0,"changes":{"remote_password":"x"}}`)
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for unknown change field, got %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
// stale revision
|
|
resp = authedRequest(t, ts, http.MethodPatch, "/api/config", cookie,
|
|
`{"revision":7,"changes":{"log_level":"debug"}}`)
|
|
if resp.StatusCode != http.StatusConflict {
|
|
t.Fatalf("expected 409 for stale revision, got %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
// happy path: revision bumps, override persisted
|
|
resp = authedRequest(t, ts, http.MethodPatch, "/api/config", cookie,
|
|
`{"revision":1,"changes":{"log_level":"debug","dns_upstream":"1.1.1.1"}}`)
|
|
body := decodeBody(t, resp)
|
|
if rev, _ := body["revision"].(float64); rev != 2 {
|
|
t.Fatalf("expected revision 2 after patch, got %v", body["revision"])
|
|
}
|
|
if got := fake.state.ConfigOverrides(); got.LogLevel == nil || *got.LogLevel != "debug" || got.DNSUpstream == nil || *got.DNSUpstream != "1.1.1.1" {
|
|
t.Fatalf("overrides not persisted: %+v", got)
|
|
}
|
|
|
|
// clearing an override with an empty string is valid
|
|
resp = authedRequest(t, ts, http.MethodPatch, "/api/config", cookie,
|
|
`{"revision":2,"changes":{"dns_upstream":""}}`)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200 for clearing override, got %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
if got := fake.state.ConfigOverrides(); got.DNSUpstream == nil || *got.DNSUpstream != "" {
|
|
t.Fatalf("override not cleared: %+v", got)
|
|
}
|
|
}
|