Files
wweir 0f67081022 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.
2026-08-09 19:20:26 +08:00

263 lines
7.4 KiB
Go

package admin
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
)
type rulesRequest struct {
Category Category `json:"category"`
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 {
Category Category `json:"category"`
Matched bool `json:"matched"`
Rule string `json:"rule"`
}
// DomainTest is the result of testing a domain against the rule sets. Route
// is block, direct, or proxy when a rule decides it, or auto when no rule
// matched and the connection would fall through to detection / proxy.
type DomainTest struct {
Domain string `json:"domain"`
Route string `json:"route"`
Matches []CategoryTest `json:"matches"`
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) {
domain := strings.TrimSpace(r.URL.Query().Get("domain"))
if domain == "" {
writeError(w, http.StatusBadRequest, "domain is required")
return
}
res, err := s.opts.Rules.TestDomain(domain)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, res)
}
func (s *Server) handleRulesList(w http.ResponseWriter, r *http.Request) {
category := Category(r.URL.Query().Get("category"))
if !category.valid() {
writeError(w, http.StatusBadRequest, "invalid category")
return
}
q := r.URL.Query().Get("q")
offset, err := strconv.Atoi(r.URL.Query().Get("offset"))
if err != nil || offset < 0 {
offset = 0
}
limit, err := strconv.Atoi(r.URL.Query().Get("limit"))
if err != nil || limit <= 0 {
limit = defaultPageSize
}
if limit > maxPageSize {
limit = maxPageSize
}
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
}
writeJSON(w, http.StatusOK, rulesResponse{
Category: category,
Rules: rules,
Total: total,
Offset: offset,
Limit: limit,
})
}
func (s *Server) handleRulesAdd(w http.ResponseWriter, r *http.Request) {
var req rulesRequest
if !decodeJSON(w, r, &req) {
return
}
validated, err := validateRules(req)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
req.Rules = validated
if err := s.opts.Rules.RuleAdd(req.Category, req.Rules...); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleRulesRemove(w http.ResponseWriter, r *http.Request) {
var req rulesRequest
if !decodeJSON(w, r, &req) {
return
}
validated, err := validateRules(req)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
req.Rules = validated
if _, err := s.opts.Rules.RuleRemoveMany(req.Category, req.Rules...); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleRulesChanges returns the persisted rule deltas relative to the boot
// baseline, so the console can show what was customized and offer a reset.
func (s *Server) handleRulesChanges(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.opts.Rules.RuleChanges())
}
// handleRulesReset clears the deltas of one category — or of all categories
// when the body omits category — and rebuilds the runtime rule sets from the
// boot baseline.
func (s *Server) handleRulesReset(w http.ResponseWriter, r *http.Request) {
var req struct {
Category Category `json:"category"`
}
if !decodeJSON(w, r, &req) {
return
}
if req.Category != "" && !req.Category.valid() {
writeError(w, http.StatusBadRequest, "invalid category")
return
}
if err := s.opts.Rules.RuleReset(req.Category); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
func validateRules(req rulesRequest) ([]string, error) {
if !req.Category.valid() {
return nil, errors.New("invalid category")
}
if len(req.Rules) == 0 {
return nil, errors.New("no rules provided")
}
if len(req.Rules) > maxRulesPerBatch {
return nil, fmt.Errorf("too many rules, max %d", maxRulesPerBatch)
}
out := make([]string, 0, len(req.Rules))
for _, rule := range req.Rules {
rule = strings.TrimSpace(rule)
if rule == "" {
return nil, errors.New("empty rule")
}
if len(rule) > maxRuleLength {
return nil, fmt.Errorf("rule too long, max %d bytes", maxRuleLength)
}
if strings.ContainsAny(rule, "\r\n") {
return nil, errors.New("rule must not contain line breaks")
}
out = append(out, rule)
}
return out, nil
}