mirror of
https://github.com/wweir/sower.git
synced 2026-10-20 08:16:35 +00:00
513 lines
15 KiB
Go
513 lines
15 KiB
Go
package admin
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const stateVersion = 1
|
|
|
|
// RuleDelta records admin-side rule changes relative to the boot baseline:
|
|
// Add holds rules absent from the baseline, Remove holds tombstoned baseline
|
|
// rules. Both lists are deduplicated and a rule never appears in both.
|
|
type RuleDelta struct {
|
|
Add []string `json:"add"`
|
|
Remove []string `json:"remove"`
|
|
}
|
|
|
|
// ConfigOverrides holds the whitelisted config fields editable through the
|
|
// 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"`
|
|
DNSReverse *string `json:"dns_reverse,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
|
|
// mutation and drives optimistic concurrency for config PATCHes.
|
|
type State struct {
|
|
Version int `json:"version"`
|
|
Revision uint64 `json:"revision"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
Rules map[Category]*RuleDelta `json:"rules"`
|
|
Config ConfigOverrides `json:"config"`
|
|
}
|
|
|
|
// RuleChangeSet is the API view of the current rule deltas.
|
|
type RuleChangeSet struct {
|
|
Persistent bool `json:"persistent"`
|
|
Revision uint64 `json:"revision"`
|
|
Rules map[Category]RuleDelta `json:"rules"`
|
|
}
|
|
|
|
// ErrRevisionMismatch is returned when a config PATCH carries a stale
|
|
// revision, i.e. another change landed after the caller loaded the view.
|
|
var ErrRevisionMismatch = errors.New("state revision mismatch")
|
|
|
|
// StateStore serializes all admin state mutations behind one mutex and
|
|
// persists them atomically (tmp file + fsync + rename, 0600). Mutations are
|
|
// applied to a cloned candidate first and only committed after a successful
|
|
// write, so a persist failure leaves the previous state intact and callers
|
|
// must not apply the corresponding runtime change.
|
|
type StateStore struct {
|
|
mu sync.Mutex
|
|
path string
|
|
state State
|
|
baseline map[Category]map[string]struct{}
|
|
}
|
|
|
|
// LoadStateStore opens the state file at path. An empty path yields an
|
|
// in-memory-only store (mutations work, nothing is written). A missing file
|
|
// starts empty; a corrupt or unsupported file is quarantined aside and
|
|
// starts empty as well, so a bad state file never blocks startup.
|
|
func LoadStateStore(path string) *StateStore {
|
|
st := &StateStore{path: path, state: newState()}
|
|
if path == "" {
|
|
return st
|
|
}
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
slog.Warn("read admin state", "file", path, "error", err)
|
|
}
|
|
return st
|
|
}
|
|
|
|
var s State
|
|
if err := json.Unmarshal(data, &s); err != nil || s.Version != stateVersion || !validStateCategories(s.Rules) {
|
|
st.quarantine()
|
|
return st
|
|
}
|
|
st.state = normalizeState(s)
|
|
return st
|
|
}
|
|
|
|
func newState() State {
|
|
return State{Version: stateVersion, Rules: make(map[Category]*RuleDelta)}
|
|
}
|
|
|
|
// normalizeState repairs load-time inconsistencies: nil maps, duplicate
|
|
// delta entries, and rules present in both Add and Remove (Add wins: the
|
|
// most recent mutation appended it there).
|
|
func normalizeState(s State) State {
|
|
if s.Rules == nil {
|
|
s.Rules = make(map[Category]*RuleDelta)
|
|
}
|
|
for cat, d := range s.Rules {
|
|
if d == nil {
|
|
delete(s.Rules, cat)
|
|
continue
|
|
}
|
|
d.Add = dedupeStrings(d.Add)
|
|
d.Remove = dedupeStrings(d.Remove)
|
|
keep := make(map[string]struct{}, len(d.Add))
|
|
for _, r := range d.Add {
|
|
keep[r] = struct{}{}
|
|
}
|
|
d.Remove = slices.DeleteFunc(d.Remove, func(r string) bool {
|
|
_, ok := keep[r]
|
|
return ok
|
|
})
|
|
if len(d.Add) == 0 && len(d.Remove) == 0 {
|
|
delete(s.Rules, cat)
|
|
}
|
|
}
|
|
return s
|
|
}
|
|
|
|
func validStateCategories(rules map[Category]*RuleDelta) bool {
|
|
for cat := range rules {
|
|
if !cat.valid() {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func dedupeStrings(in []string) []string {
|
|
seen := make(map[string]struct{}, len(in))
|
|
out := in[:0]
|
|
for _, s := range in {
|
|
if _, ok := seen[s]; ok {
|
|
continue
|
|
}
|
|
seen[s] = struct{}{}
|
|
out = append(out, s)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Persistent reports whether mutations are written to disk.
|
|
func (st *StateStore) Persistent() bool { return st.path != "" }
|
|
|
|
// quarantine renames a corrupt state file aside so the next start does not
|
|
// trip over it again.
|
|
func (st *StateStore) quarantine() {
|
|
dst := fmt.Sprintf("%s.corrupt-%d", st.path, time.Now().Unix())
|
|
if err := os.Rename(st.path, dst); err != nil {
|
|
slog.Warn("quarantine corrupt admin state", "file", st.path, "error", err)
|
|
return
|
|
}
|
|
slog.Warn("quarantined corrupt admin state, starting empty", "file", st.path, "moved_to", dst)
|
|
}
|
|
|
|
// SetBaseline records the boot rule lists (file + inline config) per
|
|
// category and garbage-collects stale delta entries: tombstones whose rule
|
|
// left the baseline and additions that entered it. GC does not bump the
|
|
// revision; a persist failure keeps the cleaned in-memory state anyway
|
|
// because the dropped entries were no-ops.
|
|
func (st *StateStore) SetBaseline(baseline map[Category][]string) {
|
|
sets := make(map[Category]map[string]struct{}, len(baseline))
|
|
for cat, rules := range baseline {
|
|
m := make(map[string]struct{}, len(rules))
|
|
for _, r := range rules {
|
|
m[r] = struct{}{}
|
|
}
|
|
sets[cat] = m
|
|
}
|
|
|
|
st.mu.Lock()
|
|
defer st.mu.Unlock()
|
|
st.baseline = sets
|
|
|
|
cand := st.cloneLocked()
|
|
changed := false
|
|
for cat, d := range cand.Rules {
|
|
base := sets[cat]
|
|
adds := slices.DeleteFunc(slices.Clone(d.Add), func(r string) bool {
|
|
_, ok := base[r]
|
|
return ok
|
|
})
|
|
rems := slices.DeleteFunc(slices.Clone(d.Remove), func(r string) bool {
|
|
_, ok := base[r]
|
|
return !ok
|
|
})
|
|
if !slices.Equal(adds, d.Add) || !slices.Equal(rems, d.Remove) {
|
|
changed = true
|
|
}
|
|
d.Add, d.Remove = adds, rems
|
|
if len(d.Add) == 0 && len(d.Remove) == 0 {
|
|
delete(cand.Rules, cat)
|
|
}
|
|
}
|
|
if !changed {
|
|
return
|
|
}
|
|
cand.UpdatedAt = time.Now()
|
|
if err := st.persistLocked(cand); err != nil {
|
|
slog.Warn("persist admin state GC", "error", err)
|
|
}
|
|
st.state = cand
|
|
}
|
|
|
|
// ConfigOverrides returns the current whitelisted config overrides.
|
|
func (st *StateStore) ConfigOverrides() ConfigOverrides {
|
|
st.mu.Lock()
|
|
defer st.mu.Unlock()
|
|
return st.state.Config
|
|
}
|
|
|
|
// ApplyConfig replaces the config overrides and persists, guarded by the
|
|
// caller's revision. It returns the new revision.
|
|
func (st *StateStore) ApplyConfig(o ConfigOverrides, revision uint64) (uint64, error) {
|
|
st.mu.Lock()
|
|
defer st.mu.Unlock()
|
|
if revision != st.state.Revision {
|
|
return 0, ErrRevisionMismatch
|
|
}
|
|
cand := st.cloneLocked()
|
|
cand.Config = o
|
|
cand.bump()
|
|
if err := st.persistLocked(cand); err != nil {
|
|
return 0, err
|
|
}
|
|
st.state = cand
|
|
return cand.Revision, nil
|
|
}
|
|
|
|
// Revision returns the current state revision.
|
|
func (st *StateStore) Revision() uint64 {
|
|
st.mu.Lock()
|
|
defer st.mu.Unlock()
|
|
return st.state.Revision
|
|
}
|
|
|
|
// RuleAdd records rules as additions (or restores tombstoned baseline rules)
|
|
// and persists. It returns the rules that must enter the runtime rule set;
|
|
// rules already effective are dropped from the result. A nil result with nil
|
|
// error means the call was a no-op.
|
|
func (st *StateStore) RuleAdd(category Category, rules ...string) ([]string, error) {
|
|
st.mu.Lock()
|
|
defer st.mu.Unlock()
|
|
|
|
cand := st.cloneLocked()
|
|
d := cand.delta(category)
|
|
var runtimeAdd []string
|
|
for _, rule := range rules {
|
|
_, inBase := st.baseline[category][rule]
|
|
switch {
|
|
case inBase && slices.Contains(d.Remove, rule):
|
|
d.Remove = removeString(d.Remove, rule)
|
|
runtimeAdd = append(runtimeAdd, rule)
|
|
case inBase, slices.Contains(d.Add, rule):
|
|
// already effective
|
|
default:
|
|
d.Add = append(d.Add, rule)
|
|
runtimeAdd = append(runtimeAdd, rule)
|
|
}
|
|
}
|
|
if len(runtimeAdd) == 0 {
|
|
return nil, nil
|
|
}
|
|
if len(d.Add) == 0 && len(d.Remove) == 0 {
|
|
delete(cand.Rules, category)
|
|
}
|
|
cand.bump()
|
|
if err := st.persistLocked(cand); err != nil {
|
|
return nil, err
|
|
}
|
|
st.state = cand
|
|
return runtimeAdd, nil
|
|
}
|
|
|
|
// RuleRemove records one rule removal and persists it as one candidate
|
|
// mutation. It is the single-rule convenience wrapper for RuleRemoveBatch.
|
|
func (st *StateStore) RuleRemove(category Category, rule string) (bool, error) {
|
|
removed, err := st.RuleRemoveBatch(category, rule)
|
|
return len(removed) > 0, err
|
|
}
|
|
|
|
// RuleRemoveBatch applies all effective removals to one candidate state and
|
|
// persists once. Unknown rules and duplicates are ignored. The returned list
|
|
// is the exact runtime work that may be applied after persistence succeeds.
|
|
func (st *StateStore) RuleRemoveBatch(category Category, rules ...string) ([]string, error) {
|
|
st.mu.Lock()
|
|
defer st.mu.Unlock()
|
|
|
|
cand := st.cloneLocked()
|
|
d := cand.delta(category)
|
|
removed := make([]string, 0, len(rules))
|
|
seen := make(map[string]struct{}, len(rules))
|
|
for _, rule := range rules {
|
|
if _, ok := seen[rule]; ok {
|
|
continue
|
|
}
|
|
seen[rule] = struct{}{}
|
|
_, inBase := st.baseline[category][rule]
|
|
switch {
|
|
case slices.Contains(d.Add, rule):
|
|
d.Add = removeString(d.Add, rule)
|
|
removed = append(removed, rule)
|
|
case inBase && !slices.Contains(d.Remove, rule):
|
|
d.Remove = append(d.Remove, rule)
|
|
removed = append(removed, rule)
|
|
}
|
|
}
|
|
if len(removed) == 0 {
|
|
return nil, nil
|
|
}
|
|
if len(d.Add) == 0 && len(d.Remove) == 0 {
|
|
delete(cand.Rules, category)
|
|
}
|
|
cand.bump()
|
|
if err := st.persistLocked(cand); err != nil {
|
|
return nil, err
|
|
}
|
|
st.state = cand
|
|
return removed, nil
|
|
}
|
|
|
|
// RuleReset clears the deltas of the given categories and persists. Clearing
|
|
// a category that has no delta is a no-op.
|
|
func (st *StateStore) RuleReset(categories ...Category) error {
|
|
st.mu.Lock()
|
|
defer st.mu.Unlock()
|
|
|
|
cand := st.cloneLocked()
|
|
changed := false
|
|
for _, cat := range categories {
|
|
if _, ok := cand.Rules[cat]; ok {
|
|
delete(cand.Rules, cat)
|
|
changed = true
|
|
}
|
|
}
|
|
if !changed {
|
|
return nil
|
|
}
|
|
cand.bump()
|
|
if err := st.persistLocked(cand); err != nil {
|
|
return err
|
|
}
|
|
st.state = cand
|
|
return nil
|
|
}
|
|
|
|
// Changes returns a snapshot of the current rule deltas. All three
|
|
// categories are always present so clients can render unconditionally.
|
|
func (st *StateStore) Changes() RuleChangeSet {
|
|
st.mu.Lock()
|
|
defer st.mu.Unlock()
|
|
|
|
out := RuleChangeSet{
|
|
Persistent: st.path != "",
|
|
Revision: st.state.Revision,
|
|
Rules: make(map[Category]RuleDelta, 3),
|
|
}
|
|
for _, cat := range []Category{CategoryBlock, CategoryDirect, CategoryProxy} {
|
|
if d, ok := st.state.Rules[cat]; ok {
|
|
out.Rules[cat] = RuleDelta{
|
|
Add: nonNilStrings(d.Add),
|
|
Remove: nonNilStrings(d.Remove),
|
|
}
|
|
} else {
|
|
out.Rules[cat] = RuleDelta{Add: []string{}, Remove: []string{}}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// nonNilStrings clones a slice, mapping nil to an empty slice so API
|
|
// responses never carry JSON null where an array is expected.
|
|
func nonNilStrings(in []string) []string {
|
|
if out := slices.Clone(in); out != nil {
|
|
return out
|
|
}
|
|
return []string{}
|
|
}
|
|
|
|
// Delta returns a copy of one category's delta for runtime reconstruction.
|
|
func (st *StateStore) Delta(category Category) RuleDelta {
|
|
st.mu.Lock()
|
|
defer st.mu.Unlock()
|
|
if d, ok := st.state.Rules[category]; ok {
|
|
return RuleDelta{Add: nonNilStrings(d.Add), Remove: nonNilStrings(d.Remove)}
|
|
}
|
|
return RuleDelta{}
|
|
}
|
|
|
|
func (st *StateStore) cloneLocked() State {
|
|
cand := st.state
|
|
cand.Rules = make(map[Category]*RuleDelta, len(st.state.Rules))
|
|
for cat, d := range st.state.Rules {
|
|
cand.Rules[cat] = &RuleDelta{
|
|
Add: slices.Clone(d.Add),
|
|
Remove: slices.Clone(d.Remove),
|
|
}
|
|
}
|
|
return cand
|
|
}
|
|
|
|
func (s *State) delta(category Category) *RuleDelta {
|
|
d, ok := s.Rules[category]
|
|
if !ok {
|
|
d = &RuleDelta{}
|
|
s.Rules[category] = d
|
|
}
|
|
return d
|
|
}
|
|
|
|
func (s *State) bump() {
|
|
s.Revision++
|
|
s.UpdatedAt = time.Now()
|
|
}
|
|
|
|
func removeString(list []string, s string) []string {
|
|
if i := slices.Index(list, s); i >= 0 {
|
|
return slices.Delete(list, i, i+1)
|
|
}
|
|
return list
|
|
}
|
|
|
|
// persistLocked writes the state atomically: a temp file in the same
|
|
// directory, fsync, rename, then a directory fsync so the rename itself is
|
|
// durable. It is a no-op for in-memory stores.
|
|
func (st *StateStore) persistLocked(s State) error {
|
|
if st.path == "" {
|
|
return nil
|
|
}
|
|
data, err := json.MarshalIndent(s, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshal admin state: %w", err)
|
|
}
|
|
|
|
dir := filepath.Dir(st.path)
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
return fmt.Errorf("create admin state dir: %w", err)
|
|
}
|
|
tmp, err := os.CreateTemp(dir, ".admin-state-*.tmp")
|
|
if err != nil {
|
|
return fmt.Errorf("create admin state temp file: %w", err)
|
|
}
|
|
tmpName := tmp.Name()
|
|
defer func() { _ = os.Remove(tmpName) }()
|
|
|
|
if _, err := tmp.Write(data); err != nil {
|
|
_ = tmp.Close()
|
|
return fmt.Errorf("write admin state temp file: %w", err)
|
|
}
|
|
if err := tmp.Sync(); err != nil {
|
|
_ = tmp.Close()
|
|
return fmt.Errorf("fsync admin state temp file: %w", err)
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return fmt.Errorf("close admin state temp file: %w", err)
|
|
}
|
|
if err := os.Chmod(tmpName, 0o600); err != nil {
|
|
return fmt.Errorf("chmod admin state temp file: %w", err)
|
|
}
|
|
if err := os.Rename(tmpName, st.path); err != nil {
|
|
return fmt.Errorf("rename admin state file: %w", err)
|
|
}
|
|
|
|
if d, err := os.Open(dir); err == nil {
|
|
_ = d.Sync()
|
|
_ = d.Close()
|
|
}
|
|
return nil
|
|
}
|