mirror of
https://github.com/wweir/sower.git
synced 2026-10-19 16:14:33 +00:00
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:
+77
-60
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user