Files
sower/internal/admin/server_test.go
T
wweir fbc0d54893 feat(admin): track and filter traffic by client IP
The console now records the client source IP for every connection and
DNS query, shows the top clients, and can filter the domain list by one:

- WrapConn captures conn.RemoteAddr into the counting conn; RecordDNS
  takes the client IP from the DNS ResponseWriter. Domain stats keep a
  per-client breakdown alongside the per-source one.
- /api/traffic?client=<ip> restricts domains to that client and reports
  the client's conns/bytes/lastSeen; it composes with ?source=.
- The snapshot now carries clients: top 50 per-IP aggregates (conns,
  bytes up/down, last seen) for display.
- Traffic page shows a client chip row (全部客户端 + IPs with counts);
  clicking one filters, tooltips show byte totals.

Verified live: ?client=192.168.1.1 lists that device's domains with its
counts; ?source=dns&client=... composes correctly.
2026-08-06 19:54:43 +08:00

637 lines
19 KiB
Go

package admin
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
type fakeRules struct {
lists map[Category][]string
}
func newFakeRules() *fakeRules {
return &fakeRules{lists: map[Category][]string{
CategoryBlock: {},
CategoryDirect: {},
CategoryProxy: {},
}}
}
func (f *fakeRules) RuleList(c Category) ([]string, error) {
return append([]string(nil), f.lists[c]...), nil
}
func (f *fakeRules) RuleSearch(c Category, q string, offset, limit int) ([]string, uint64, error) {
all := f.lists[c]
matched := make([]string, 0, len(all))
q = strings.ToLower(q)
for _, rule := range all {
if q == "" || strings.Contains(strings.ToLower(rule), q) {
matched = append(matched, rule)
}
}
total := uint64(len(matched))
if offset >= len(matched) {
return []string{}, 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 {
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) RuleCount(c Category) uint64 {
return uint64(len(f.lists[c]))
}
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
}
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 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 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 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)
}
}
func TestRulesCRUD(t *testing.T) {
ts := newTestServer(t, newFakeRules())
cookie := login(t, ts, "secret")
// 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 || 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)
if rules, _ := body["rules"].([]any); len(rules) != 2 || rules[0] != "b.cn" || 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 || 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 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 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 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)
}
}
}
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 brand-new server sharing the file
ts2 := newServer()
resp := authedRequest(t, ts2, http.MethodGet, "/api/status", cookie, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected session 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)})
s.mu.Lock()
s.sessions["token"] = time.Now().Add(sessionTTL / 4) // less than half TTL remaining
s.mu.Unlock()
r := httptest.NewRequest("GET", "/api/status", nil)
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "token"})
if !s.validSession(r) {
t.Fatal("expected session valid")
}
s.mu.Lock()
exp := s.sessions["token"]
s.mu.Unlock()
if remaining := time.Until(exp); remaining < sessionTTL/2 {
t.Fatalf("expected sliding expiry to refresh TTL, remaining %v", remaining)
}
}
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")
}
}