mirror of
https://github.com/wweir/sower.git
synced 2026-10-19 16:14:33 +00:00
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.
263 lines
7.4 KiB
Go
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
|
|
}
|