feat(admin): rule hit stats, config console, restart, and session persistence

Rule listings gain hit counts, last-seen timestamps, and sort by rule
text, hits, or recency; per-rule and rule-miss stats are tracked in
bounded maps fed by router observers. The config console exposes
whitelisted editable fields (immediate for log level and DNS upstreams,
restart-mode for the rest), persisted as pointer overrides. The restart
endpoint closes listeners and execs the process in place (same PID).
Session persistence is best-effort with configurable options, and admin
state/session config gained disable and cookie-secure switches.
This commit is contained in:
wweir
2026-08-09 19:20:26 +08:00
parent 0aed36f698
commit 0f67081022
24 changed files with 2185 additions and 253 deletions
+77 -60
View File
@@ -7,6 +7,7 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
@@ -47,47 +48,56 @@ func (s *Server) loadSessions() {
}
}
// persistLocked writes the current sessions to disk atomically with 0600
// permissions. It must be called with s.mu held. IO errors degrade to an
// in-memory session store, never to a failed request.
func (s *Server) persistLocked() {
// persistLocked writes the supplied session snapshot to disk atomically with
// 0600 permissions. It must be called with s.mu held. IO errors are returned
// so callers can keep memory and durable state consistent.
func (s *Server) persistLocked(sessions map[string]time.Time) error {
if s.sessionFile == "" {
return
return nil
}
data, err := json.Marshal(s.sessions)
data, err := json.Marshal(sessions)
if err != nil {
slog.Warn("marshal sessions", "error", err)
return
return fmt.Errorf("marshal sessions for %s: %w", s.sessionFile, err)
}
dir := filepath.Dir(s.sessionFile)
if err := os.MkdirAll(dir, 0o700); err != nil {
slog.Warn("create session dir", "error", err)
return
return fmt.Errorf("create session directory %s: %w", dir, err)
}
tmp, err := os.CreateTemp(dir, ".sessions-*")
tmp, err := os.CreateTemp(dir, ".sessions-*.tmp")
if err != nil {
slog.Warn("create session temp file", "error", err)
return
return fmt.Errorf("create session temp file in %s: %w", dir, err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
defer func() { _ = os.Remove(tmpName) }()
if _, err := tmp.Write(data); err != nil {
tmp.Close()
slog.Warn("write session temp file", "error", err)
return
_ = tmp.Close()
return fmt.Errorf("write session temp file %s: %w", tmpName, err)
}
if err := tmp.Chmod(0o600); err != nil {
tmp.Close()
slog.Warn("chmod session temp file", "error", err)
return
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return fmt.Errorf("fsync session temp file %s: %w", tmpName, err)
}
if err := tmp.Close(); err != nil {
slog.Warn("close session temp file", "error", err)
return
return fmt.Errorf("close session temp file %s: %w", tmpName, err)
}
if err := os.Chmod(tmpName, 0o600); err != nil {
return fmt.Errorf("chmod session temp file %s: %w", tmpName, err)
}
if err := os.Rename(tmpName, s.sessionFile); err != nil {
slog.Warn("rename session file", "error", err)
return
return fmt.Errorf("rename session file %s: %w", s.sessionFile, err)
}
if d, err := os.Open(dir); err == nil {
_ = d.Sync()
_ = d.Close()
}
return nil
}
func purgeExpired(sessions map[string]time.Time, now time.Time) {
for token, exp := range sessions {
if now.After(exp) {
delete(sessions, token)
}
}
}
@@ -108,20 +118,28 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleSession(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(sessionCookieName); err == nil {
s.mu.Lock()
delete(s.sessions, c.Value)
s.persistLocked()
if err := s.persistLocked(s.sessions); err != nil {
slog.Error("persist admin logout session", "error", err)
}
s.mu.Unlock()
}
http.SetCookie(w, s.sessionCookie("", -1, r.TLS != nil))
http.SetCookie(w, s.sessionCookie("", -1, s.cookieSecure(r)))
w.WriteHeader(http.StatusNoContent)
}
// sessionCookie builds the session cookie. The Secure flag is only set when
// the admin server runs over TLS; the default loopback HTTP listener would
// otherwise never receive the cookie.
// sessionCookie builds the session cookie. The Secure flag is set when the
// listener uses TLS or when an explicitly configured TLS-terminating proxy
// protects the admin origin.
func (s *Server) sessionCookie(value string, maxAge int, secure bool) *http.Cookie {
return &http.Cookie{
Name: sessionCookieName,
@@ -134,6 +152,10 @@ func (s *Server) sessionCookie(value string, maxAge int, secure bool) *http.Cook
}
}
func (s *Server) cookieSecure(r *http.Request) bool {
return r.TLS != nil || s.opts.CookieSecure
}
func (s *Server) issueSession(w http.ResponseWriter, r *http.Request) bool {
var token [32]byte
if _, err := rand.Read(token[:]); err != nil {
@@ -145,65 +167,60 @@ func (s *Server) issueSession(w http.ResponseWriter, r *http.Request) bool {
s.mu.Lock()
if len(s.sessions) >= maxSessionPurge {
s.purgeExpiredLocked()
purgeExpired(s.sessions, time.Now())
}
s.sessions[value] = time.Now().Add(sessionTTL)
s.persistLocked()
if err := s.persistLocked(s.sessions); err != nil {
slog.Error("persist admin login session", "error", err)
}
s.mu.Unlock()
http.SetCookie(w, s.sessionCookie(value, int(sessionCookieMaxAge.Seconds()), r.TLS != nil))
http.SetCookie(w, s.sessionCookie(value, int(sessionCookieMaxAge.Seconds()), s.cookieSecure(r)))
return true
}
func (s *Server) purgeExpiredLocked() {
now := time.Now()
purged := false
for token, exp := range s.sessions {
if now.After(exp) {
delete(s.sessions, token)
purged = true
}
}
if purged {
s.persistLocked()
}
}
func (s *Server) validSession(r *http.Request) bool {
// validSession returns the token, whether it is valid, and whether its
// server-side expiration was renewed. Persistence failures retain the prior
// expiry so a later request can retry the renewal.
func (s *Server) validSession(r *http.Request) (string, bool, bool) {
c, err := r.Cookie(sessionCookieName)
if err != nil {
return false
return "", false, false
}
s.mu.Lock()
defer s.mu.Unlock()
exp, ok := s.sessions[c.Value]
if !ok {
return false
return "", false, false
}
now := time.Now()
if now.After(exp) {
delete(s.sessions, c.Value)
s.persistLocked()
return false
if err := s.persistLocked(s.sessions); err != nil {
slog.Warn("persist expired admin session cleanup", "error", err)
}
return "", false, false
}
// Sliding expiry: refresh a session that is more than halfway through
// its TTL so an active user is never logged out mid-session, while an
// inactive session still lapses. Persisting only on refresh bounds the
// disk writes to a couple per session per TTL.
if exp.Sub(now) < sessionTTL/2 {
s.sessions[c.Value] = now.Add(sessionTTL)
s.persistLocked()
if err := s.persistLocked(s.sessions); err != nil {
slog.Warn("renew admin session", "error", err)
}
return c.Value, true, true
}
return true
return c.Value, true, false
}
// auth rejects requests without a valid session cookie.
// auth rejects requests without a valid session cookie and renews the
// browser's Max-Age for every authenticated HTTP response.
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !s.validSession(r) {
token, valid, _ := s.validSession(r)
if !valid {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
http.SetCookie(w, s.sessionCookie(token, int(sessionCookieMaxAge.Seconds()), s.cookieSecure(r)))
next(w, r)
}
}
+104 -12
View File
@@ -6,7 +6,10 @@ import (
"log/slog"
"net"
"net/http"
"strconv"
"strings"
"github.com/sower-proxy/sower/pkg/upstreamtls"
)
// Apply modes for config fields.
@@ -31,16 +34,19 @@ const (
)
// ConfigField is one rendered config entry. Secret fields never carry a
// Value; Configured reports whether one is set.
// Value; Configured reports whether one is set. Type/Options let the console
// render a constrained choice (e.g. an enum select) instead of free text.
type ConfigField struct {
Key string `json:"key"`
Value string `json:"value,omitempty"`
Editable bool `json:"editable"`
ApplyMode string `json:"applyMode"`
Source string `json:"source"`
Constraint string `json:"constraint,omitempty"`
Secret bool `json:"secret,omitempty"`
Configured bool `json:"configured,omitempty"`
Key string `json:"key"`
Value string `json:"value,omitempty"`
Editable bool `json:"editable"`
ApplyMode string `json:"applyMode"`
Source string `json:"source"`
Constraint string `json:"constraint,omitempty"`
Secret bool `json:"secret,omitempty"`
Configured bool `json:"configured,omitempty"`
Type string `json:"type,omitempty"`
Options []string `json:"options,omitempty"`
}
// ConfigSection groups related fields for display.
@@ -58,15 +64,48 @@ type ConfigView struct {
// ConfigChanges is the whitelisted PATCH payload. A nil pointer leaves the
// override unchanged; a non-nil empty string clears it, reverting the field
// to the file/flag configuration.
// to the file/flag configuration. List fields (rule sources) follow the
// same rule with slices.
type ConfigChanges struct {
LogLevel *string `json:"log_level"`
DNSUpstream *string `json:"dns_upstream"`
DNSFallback *string `json:"dns_fallback"`
RemoteType *string `json:"remote_type"`
RemoteAddr *string `json:"remote_addr"`
RemoteTLSServerName *string `json:"remote_tls_server_name"`
RemoteTLSClientHello *string `json:"remote_tls_client_hello"`
RemoteTLSInsecureSkipVerify *string `json:"remote_tls_insecure_skip_verify"`
DNSServe *string `json:"dns_serve"`
DNSServe6 *string `json:"dns_serve6"`
Socks5Addr *string `json:"socks5_addr"`
AdminSessionFile *string `json:"admin_session_file"`
AdminDisableSessionPersistence *string `json:"admin_disable_session_persistence"`
AdminCookieSecure *string `json:"admin_cookie_secure"`
AdminStateFile *string `json:"admin_state_file"`
RouterBlockFile *string `json:"router_block_file"`
RouterBlockFilePrefix *string `json:"router_block_file_prefix"`
RouterBlockFileSkipRules *[]string `json:"router_block_file_skip_rules"`
RouterBlockRules *[]string `json:"router_block_rules"`
RouterDirectFile *string `json:"router_direct_file"`
RouterDirectFilePrefix *string `json:"router_direct_file_prefix"`
RouterDirectFileSkipRules *[]string `json:"router_direct_file_skip_rules"`
RouterDirectRules *[]string `json:"router_direct_rules"`
RouterProxyFile *string `json:"router_proxy_file"`
RouterProxyFilePrefix *string `json:"router_proxy_file_prefix"`
RouterProxyFileSkipRules *[]string `json:"router_proxy_file_skip_rules"`
RouterProxyRules *[]string `json:"router_proxy_rules"`
RouterCountryMMDB *string `json:"router_country_mmdb"`
RouterCountryFile *string `json:"router_country_file"`
RouterCountryRules *[]string `json:"router_country_rules"`
}
// Validate checks all supplied values. Clearing an override (empty string)
// is always valid.
// Validate checks all supplied values. Clearing an override (empty string
// or empty list) is always valid.
func (c ConfigChanges) Validate() error {
if c.LogLevel != nil && *c.LogLevel != "" {
var lv slog.Level
@@ -80,6 +119,44 @@ func (c ConfigChanges) Validate() error {
if c.DNSFallback != nil && *c.DNSFallback != "" && net.ParseIP(*c.DNSFallback) == nil {
return fmt.Errorf("invalid dns_fallback %q", *c.DNSFallback)
}
if c.DNSServe != nil && *c.DNSServe != "" && net.ParseIP(*c.DNSServe) == nil {
return fmt.Errorf("invalid dns_serve %q", *c.DNSServe)
}
if c.DNSServe6 != nil && *c.DNSServe6 != "" && net.ParseIP(*c.DNSServe6) == nil {
return fmt.Errorf("invalid dns_serve6 %q", *c.DNSServe6)
}
if c.RemoteType != nil && *c.RemoteType != "" {
switch *c.RemoteType {
case "sower", "socks5":
default:
return fmt.Errorf("invalid remote_type %q", *c.RemoteType)
}
}
if c.RemoteTLSClientHello != nil && *c.RemoteTLSClientHello != "" {
if err := upstreamtls.ValidateClientHello(*c.RemoteTLSClientHello); err != nil {
return fmt.Errorf("invalid remote_tls_client_hello %q", *c.RemoteTLSClientHello)
}
}
if c.RemoteTLSInsecureSkipVerify != nil && *c.RemoteTLSInsecureSkipVerify != "" {
if _, err := strconv.ParseBool(*c.RemoteTLSInsecureSkipVerify); err != nil {
return fmt.Errorf("invalid remote_tls_insecure_skip_verify %q", *c.RemoteTLSInsecureSkipVerify)
}
}
if c.Socks5Addr != nil && *c.Socks5Addr != "" {
if _, _, err := net.SplitHostPort(*c.Socks5Addr); err != nil {
return fmt.Errorf("invalid socks5_addr %q: %w", *c.Socks5Addr, err)
}
}
if c.AdminDisableSessionPersistence != nil && *c.AdminDisableSessionPersistence != "" {
if _, err := strconv.ParseBool(*c.AdminDisableSessionPersistence); err != nil {
return fmt.Errorf("invalid admin_disable_session_persistence %q", *c.AdminDisableSessionPersistence)
}
}
if c.AdminCookieSecure != nil && *c.AdminCookieSecure != "" {
if _, err := strconv.ParseBool(*c.AdminCookieSecure); err != nil {
return fmt.Errorf("invalid admin_cookie_secure %q", *c.AdminCookieSecure)
}
}
return nil
}
@@ -124,3 +201,18 @@ func (s *Server) handleConfigPatch(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, view)
}
// handleRestart triggers a process restart. The callback is fire-and-forget
// from the handler's perspective: it must return before the process exits so
// the 202 response is delivered first.
func (s *Server) handleRestart(w http.ResponseWriter, r *http.Request) {
if s.opts.Restart == nil {
writeError(w, http.StatusNotImplemented, "restart unavailable")
return
}
if err := s.opts.Restart(); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusAccepted, map[string]string{"status": "restarting"})
}
+92 -1
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"strconv"
"strings"
"time"
)
type rulesRequest struct {
@@ -13,6 +14,55 @@ type rulesRequest struct {
Rules []string `json:"rules"`
}
// RuleSort selects the ordering of a rule listing.
type RuleSort string
const (
RuleSortDefault RuleSort = "" // retained rule order (file order)
RuleSortRule RuleSort = "rule" // alphabetical by rule text
RuleSortHits RuleSort = "hits" // by hit count
RuleSortLastSeen RuleSort = "last_seen" // by most recent hit
)
func (s RuleSort) valid() bool {
switch s {
case RuleSortDefault, RuleSortRule, RuleSortHits, RuleSortLastSeen:
return true
default:
return false
}
}
// SortDir selects the direction of a rule listing sort.
type SortDir string
const (
SortDirAsc SortDir = "asc"
SortDirDesc SortDir = "desc"
)
func (d SortDir) valid() bool {
return d == SortDirAsc || d == SortDirDesc
}
// defaultDir resolves the natural direction for a sort field: text sorts
// ascending, recency and magnitude sorts descending.
func defaultDir(s RuleSort) SortDir {
if s == RuleSortRule {
return SortDirAsc
}
return SortDirDesc
}
// RuleEntry is one retained rule in a listing, with its hit count and most
// recent hit time. Count is zero and LastSeen nil for rules that never
// matched a routed connection.
type RuleEntry struct {
Rule string `json:"rule"`
Count uint64 `json:"count"`
LastSeen *time.Time `json:"lastSeen,omitempty"`
}
// CategoryTest reports whether one rule category matched a tested domain and
// which retained rule did so.
type CategoryTest struct {
@@ -31,6 +81,39 @@ type DomainTest struct {
Note string `json:"note,omitempty"`
}
// RuleHit aggregates block decisions attributed to one rule.
type RuleHit struct {
Rule string `json:"rule"`
Count uint64 `json:"count"`
LastSeen time.Time `json:"lastSeen"`
}
// RuleMissProvider exposes per-domain access stats for connections that
// matched no block/direct/proxy rule. Rule holds the domain.
type RuleMissProvider interface {
// RuleMiss returns up to limit domains, ordered by connection count
// descending when byCount is true, or by most recent access otherwise.
RuleMiss(byCount bool, limit int) []RuleHit
}
// handleRuleMiss serves per-domain access stats for connections that
// matched no rule, ordered by count (default) or by most recent access.
func (s *Server) handleRuleMiss(w http.ResponseWriter, r *http.Request) {
provider, ok := s.opts.Rules.(RuleMissProvider)
if !ok {
writeError(w, http.StatusNotFound, "rule miss stats unavailable")
return
}
byCount := r.URL.Query().Get("sort") != "recent"
limit := 0
if raw := r.URL.Query().Get("limit"); raw != "" {
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
limit = n
}
}
writeJSON(w, http.StatusOK, provider.RuleMiss(byCount, limit))
}
// handleRulesTest reports which rules match the queried domain and the
// resulting route decision, without performing any live detection.
func (s *Server) handleRulesTest(w http.ResponseWriter, r *http.Request) {
@@ -65,7 +148,15 @@ func (s *Server) handleRulesList(w http.ResponseWriter, r *http.Request) {
if limit > maxPageSize {
limit = maxPageSize
}
rules, total, err := s.opts.Rules.RuleSearch(category, q, offset, limit)
sortBy := RuleSort(r.URL.Query().Get("sort"))
if !sortBy.valid() {
sortBy = RuleSortDefault
}
dir := SortDir(r.URL.Query().Get("dir"))
if !dir.valid() {
dir = defaultDir(sortBy)
}
rules, total, err := s.opts.Rules.RuleSearch(category, q, offset, limit, sortBy, dir)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
+24 -13
View File
@@ -60,8 +60,9 @@ func (c Category) valid() bool {
// RuleManager is the rule operations surface the admin server needs. The
// concrete adapter lives in cmd/sower; tests inject a fake.
type RuleManager interface {
RuleList(category Category) ([]string, error)
RuleSearch(category Category, q string, offset, limit int) ([]string, uint64, error)
// RuleSearch returns a page of retained rules for the category, filtered
// by q and ordered by sortBy in direction dir (see RuleSort constants).
RuleSearch(category Category, q string, offset, limit int, sortBy RuleSort, dir SortDir) ([]RuleEntry, uint64, error)
RuleAdd(category Category, rules ...string) error
RuleRemove(category Category, rule string) (bool, error)
// RuleRemoveMany removes a batch atomically from the persisted state and
@@ -79,14 +80,19 @@ type RuleManager interface {
// Options configures the admin server.
type Options struct {
Password string
Version string
Date string
Rules RuleManager
Stats *Stats
SessionFile string
Password string
Version string
Date string
Rules RuleManager
Stats *Stats
SessionFile string
CookieSecure bool
// Config enables the config display/edit endpoints when non-nil.
Config ConfigManager
// Restart triggers a process restart when non-nil; the endpoint returns
// 501 when nil. The callback must return before the process exits so the
// HTTP response can be delivered.
Restart func() error
}
// Server serves the admin API and the embedded frontend on one listener.
@@ -115,6 +121,7 @@ func NewServer(opts Options) *Server {
mux := http.NewServeMux()
mux.HandleFunc("POST /api/session", s.handleLogin)
mux.HandleFunc("GET /api/session", s.mutateGuard(s.auth(s.handleSession)))
mux.HandleFunc("DELETE /api/session", s.handleLogout)
mux.HandleFunc("GET /api/status", s.mutateGuard(s.auth(s.handleStatus)))
mux.HandleFunc("GET /api/rules", s.mutateGuard(s.auth(s.handleRulesList)))
@@ -123,6 +130,7 @@ func NewServer(opts Options) *Server {
mux.HandleFunc("GET /api/rules/changes", s.mutateGuard(s.auth(s.handleRulesChanges)))
mux.HandleFunc("POST /api/rules/reset", s.mutateGuard(s.auth(s.handleRulesReset)))
mux.HandleFunc("GET /api/rules/test", s.mutateGuard(s.auth(s.handleRulesTest)))
mux.HandleFunc("GET /api/rules/miss", s.mutateGuard(s.auth(s.handleRuleMiss)))
mux.HandleFunc("GET /api/traffic", s.mutateGuard(s.auth(s.handleTraffic)))
mux.HandleFunc("GET /api/totals", s.mutateGuard(s.auth(s.handleTotals)))
mux.HandleFunc("GET /api/history", s.mutateGuard(s.auth(s.handleHistory)))
@@ -131,6 +139,9 @@ func NewServer(opts Options) *Server {
mux.HandleFunc("GET /api/config", s.mutateGuard(s.auth(s.handleConfigGet)))
mux.HandleFunc("PATCH /api/config", s.mutateGuard(s.auth(s.handleConfigPatch)))
}
if s.opts.Restart != nil {
mux.HandleFunc("POST /api/restart", s.mutateGuard(s.auth(s.handleRestart)))
}
if s.opts.Stats != nil {
mux.HandleFunc("GET /metrics", s.handleMetrics)
}
@@ -237,11 +248,11 @@ func statStart(stats *Stats) time.Time {
}
type rulesResponse struct {
Category Category `json:"category"`
Rules []string `json:"rules"`
Total uint64 `json:"total"`
Offset int `json:"offset"`
Limit int `json:"limit"`
Category Category `json:"category"`
Rules []RuleEntry `json:"rules"`
Total uint64 `json:"total"`
Offset int `json:"offset"`
Limit int `json:"limit"`
}
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
+494 -31
View File
@@ -11,6 +11,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"testing"
@@ -19,6 +20,28 @@ import (
type fakeRules struct {
lists map[Category][]string
hits []RuleHit
miss []RuleHit
}
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 {
@@ -29,22 +52,58 @@ func newFakeRules() *fakeRules {
}}
}
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) {
func (f *fakeRules) RuleSearch(c Category, q string, offset, limit int, sortBy RuleSort, dir SortDir) ([]RuleEntry, uint64, error) {
all := f.lists[c]
matched := make([]string, 0, len(all))
matched := make([]RuleEntry, 0, len(all))
q = strings.ToLower(q)
for _, rule := range all {
if q == "" || strings.Contains(strings.ToLower(rule), q) {
matched = append(matched, rule)
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 []string{}, total, nil
return []RuleEntry{}, total, nil
}
end := offset + limit
if end > len(matched) {
@@ -179,13 +238,13 @@ func (f *fakeConfig) ConfigView() ConfigView {
func (f *fakeConfig) ApplyConfigChanges(changes ConfigChanges, revision uint64) (ConfigView, error) {
overrides := f.state.ConfigOverrides()
if changes.LogLevel != nil {
overrides.LogLevel = *changes.LogLevel
overrides.LogLevel = changes.LogLevel
}
if changes.DNSUpstream != nil {
overrides.DNSUpstream = *changes.DNSUpstream
overrides.DNSUpstream = changes.DNSUpstream
}
if changes.DNSFallback != nil {
overrides.DNSFallback = *changes.DNSFallback
overrides.DNSFallback = changes.DNSFallback
}
if _, err := f.state.ApplyConfig(overrides, revision); err != nil {
return ConfigView{}, err
@@ -303,6 +362,36 @@ func TestAPIRequiresSession(t *testing.T) {
}
}
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")
@@ -324,6 +413,7 @@ func TestStatusEndpoint(t *testing.T) {
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, "")
@@ -350,7 +440,7 @@ func TestRulesCRUD(t *testing.T) {
}
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" {
if rules, _ := body["rules"].([]any); len(rules) != 1 || ruleOf(rules[0]) != "**.cn" {
t.Fatalf("unexpected rules after remove: %v", rules)
}
}
@@ -371,7 +461,8 @@ func TestRulesListPaginationAndSearch(t *testing.T) {
// 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" {
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 {
@@ -388,7 +479,7 @@ func TestRulesListPaginationAndSearch(t *testing.T) {
// 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" {
if rules, _ := body["rules"].([]any); len(rules) != 1 || ruleOf(rules[0]) != "a.cn" {
t.Fatalf("unexpected search page: %v", rules)
}
@@ -400,6 +491,117 @@ func TestRulesListPaginationAndSearch(t *testing.T) {
}
}
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")
@@ -637,6 +839,81 @@ func TestRulesTestEndpoint(t *testing.T) {
}
}
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)
@@ -841,11 +1118,12 @@ func TestSessionSurvivesRestart(t *testing.T) {
ts1 := newServer()
cookie := login(t, ts1, "secret")
// simulate a process restart: a brand-new server sharing the file
// 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/status", cookie, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected session to survive restart, got %d", resp.StatusCode)
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()
@@ -870,24 +1148,148 @@ func TestSessionSurvivesRestart(t *testing.T) {
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()
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)
r := httptest.NewRequest("GET", "/api/status", nil)
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "token"})
if !s.validSession(r) {
t.Fatal("expected session valid")
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")
}
s.mu.Lock()
exp := s.sessions["token"]
s.mu.Unlock()
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()
@@ -975,6 +1377,67 @@ func TestStreamEndpointPushesEvents(t *testing.T) {
}
}
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})
@@ -1094,7 +1557,7 @@ func TestConfigPatchValidationAndRevision(t *testing.T) {
if rev, _ := body["revision"].(float64); rev != 1 {
t.Fatalf("expected revision 1 after patch, got %v", body["revision"])
}
if got := fake.state.ConfigOverrides(); got.LogLevel != "debug" || got.DNSUpstream != "1.1.1.1" {
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)
}
@@ -1105,7 +1568,7 @@ func TestConfigPatchValidationAndRevision(t *testing.T) {
t.Fatalf("expected 200 for clearing override, got %d", resp.StatusCode)
}
resp.Body.Close()
if got := fake.state.ConfigOverrides(); got.DNSUpstream != "" {
if got := fake.state.ConfigOverrides(); got.DNSUpstream == nil || *got.DNSUpstream != "" {
t.Fatalf("override not cleared: %+v", got)
}
}
+40 -5
View File
@@ -23,12 +23,47 @@ type RuleDelta struct {
}
// ConfigOverrides holds the whitelisted config fields editable through the
// admin console. Empty fields do not override the file/flag configuration.
// Secrets must never be added here.
// admin console. Pointer fields distinguish "not submitted" (nil, keep the
// previous override) from an explicit change: a non-empty value sets the
// override, and a non-nil empty value/empty list clears it so the
// file/flag configuration takes over again. Secrets must never be added
// here. List fields (rule sources) hold the full inline lists.
type ConfigOverrides struct {
LogLevel string `json:"log_level,omitempty"`
DNSUpstream string `json:"dns_upstream,omitempty"`
DNSFallback string `json:"dns_fallback,omitempty"`
LogLevel *string `json:"log_level,omitempty"`
DNSUpstream *string `json:"dns_upstream,omitempty"`
DNSFallback *string `json:"dns_fallback,omitempty"`
RemoteType *string `json:"remote_type,omitempty"`
RemoteAddr *string `json:"remote_addr,omitempty"`
RemoteTLSServerName *string `json:"remote_tls_server_name,omitempty"`
RemoteTLSClientHello *string `json:"remote_tls_client_hello,omitempty"`
RemoteTLSInsecureSkipVerify *string `json:"remote_tls_insecure_skip_verify,omitempty"`
DNSServe *string `json:"dns_serve,omitempty"`
DNSServe6 *string `json:"dns_serve6,omitempty"`
Socks5Addr *string `json:"socks5_addr,omitempty"`
AdminSessionFile *string `json:"admin_session_file,omitempty"`
AdminDisableSessionPersistence *string `json:"admin_disable_session_persistence,omitempty"`
AdminCookieSecure *string `json:"admin_cookie_secure,omitempty"`
AdminStateFile *string `json:"admin_state_file,omitempty"`
RouterBlockFile *string `json:"router_block_file,omitempty"`
RouterBlockFilePrefix *string `json:"router_block_file_prefix,omitempty"`
RouterBlockFileSkipRules *[]string `json:"router_block_file_skip_rules,omitempty"`
RouterBlockRules *[]string `json:"router_block_rules,omitempty"`
RouterDirectFile *string `json:"router_direct_file,omitempty"`
RouterDirectFilePrefix *string `json:"router_direct_file_prefix,omitempty"`
RouterDirectFileSkipRules *[]string `json:"router_direct_file_skip_rules,omitempty"`
RouterDirectRules *[]string `json:"router_direct_rules,omitempty"`
RouterProxyFile *string `json:"router_proxy_file,omitempty"`
RouterProxyFilePrefix *string `json:"router_proxy_file_prefix,omitempty"`
RouterProxyFileSkipRules *[]string `json:"router_proxy_file_skip_rules,omitempty"`
RouterProxyRules *[]string `json:"router_proxy_rules,omitempty"`
RouterCountryMMDB *string `json:"router_country_mmdb,omitempty"`
RouterCountryFile *string `json:"router_country_file,omitempty"`
RouterCountryRules *[]string `json:"router_country_rules,omitempty"`
}
// State is the on-disk admin state document. Revision bumps on every
+5 -4
View File
@@ -219,21 +219,22 @@ func TestStateStoreApplyConfigRevision(t *testing.T) {
t.Parallel()
path := stateFilePath(t)
st := LoadStateStore(path)
strPtr := func(s string) *string { return &s }
if _, err := st.ApplyConfig(ConfigOverrides{LogLevel: "debug"}, 1); !errors.Is(err, ErrRevisionMismatch) {
if _, err := st.ApplyConfig(ConfigOverrides{LogLevel: strPtr("debug")}, 1); !errors.Is(err, ErrRevisionMismatch) {
t.Fatalf("stale revision must fail: %v", err)
}
rev, err := st.ApplyConfig(ConfigOverrides{LogLevel: "debug"}, 0)
rev, err := st.ApplyConfig(ConfigOverrides{LogLevel: strPtr("debug")}, 0)
if err != nil || rev != 1 {
t.Fatalf("apply config: rev=%d err=%v", rev, err)
}
if got := st.ConfigOverrides(); got.LogLevel != "debug" {
if got := st.ConfigOverrides(); got.LogLevel == nil || *got.LogLevel != "debug" {
t.Fatalf("unexpected overrides: %+v", got)
}
// Overrides survive a reload.
st2 := LoadStateStore(path)
if got := st2.ConfigOverrides(); got.LogLevel != "debug" {
if got := st2.ConfigOverrides(); got.LogLevel == nil || *got.LogLevel != "debug" {
t.Fatalf("overrides not persisted: %+v", got)
}
if st2.Revision() != 1 {
+11 -4
View File
@@ -70,9 +70,11 @@ func (s *Server) streamTrafficSnapshot(sort DomainSort, source Source, client st
// handleStream pushes status, traffic snapshots, and history to the console.
// The connection carries the same sort/source/client filters as /api/traffic
// and revalidates the session on each tick, closing with an auth event when
// it lapses. The initial payload is sent immediately so the page renders
// without waiting for a tick.
// and revalidates the session on each tick. When a sliding renewal is due it
// asks the browser to refresh the cookie through /api/session, then closes so
// EventSource reconnects with the renewed cookie. Expiry sends an auth event.
// The initial payload is sent immediately so the page renders without waiting
// for a tick.
func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
if s.opts.Stats == nil {
writeError(w, http.StatusInternalServerError, "stats unavailable")
@@ -120,10 +122,15 @@ func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
case <-r.Context().Done():
return
case <-ticker.C:
if !s.validSession(r) {
_, valid, renewed := s.validSession(r)
if !valid {
send("auth", map[string]any{"status": http.StatusUnauthorized})
return
}
if renewed {
send("renew", map[string]any{})
return
}
send("status", s.statusPayload())
send("traffic", s.streamTrafficSnapshot(sort, source, client))
case <-historyTicker.C: