206 lines
4.5 KiB
Go
206 lines
4.5 KiB
Go
package trafficappmarks
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type State struct {
|
|
Version int `json:"version"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
Items []Item `json:"items,omitempty"`
|
|
}
|
|
|
|
type Item struct {
|
|
ID uint64 `json:"id"`
|
|
Target string `json:"target"` // vpn|direct
|
|
Cgroup string `json:"cgroup"` // absolute path ("/user.slice/..."), informational
|
|
CgroupRel string `json:"cgroup_rel"`
|
|
Level int `json:"level"`
|
|
Unit string `json:"unit,omitempty"`
|
|
Command string `json:"command,omitempty"`
|
|
AppKey string `json:"app_key,omitempty"`
|
|
AddedAt string `json:"added_at"`
|
|
ExpiresAt string `json:"expires_at"`
|
|
}
|
|
|
|
func LoadState(statePath string, canonicalizeAppKey func(appKey, command string) string) State {
|
|
st := State{Version: 1}
|
|
data, err := os.ReadFile(statePath)
|
|
if err != nil {
|
|
return st
|
|
}
|
|
if err := json.Unmarshal(data, &st); err != nil {
|
|
return State{Version: 1}
|
|
}
|
|
if st.Version == 0 {
|
|
st.Version = 1
|
|
}
|
|
|
|
changed := false
|
|
for i := range st.Items {
|
|
st.Items[i].Target = strings.ToLower(strings.TrimSpace(st.Items[i].Target))
|
|
if canonicalizeAppKey != nil {
|
|
canon := canonicalizeAppKey(st.Items[i].AppKey, st.Items[i].Command)
|
|
if canon != "" && strings.TrimSpace(st.Items[i].AppKey) != canon {
|
|
st.Items[i].AppKey = canon
|
|
changed = true
|
|
}
|
|
}
|
|
}
|
|
if deduped, dedupChanged := DedupeItems(st.Items, canonicalizeAppKey); dedupChanged {
|
|
st.Items = deduped
|
|
changed = true
|
|
}
|
|
if changed {
|
|
_ = SaveState(statePath, st)
|
|
}
|
|
return st
|
|
}
|
|
|
|
func SaveState(statePath string, st State) error {
|
|
st.Version = 1
|
|
st.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
|
|
|
data, err := json.MarshalIndent(st, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(statePath), 0o755); err != nil {
|
|
return err
|
|
}
|
|
tmp := statePath + ".tmp"
|
|
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, statePath)
|
|
}
|
|
|
|
func DedupeItems(in []Item, canonicalizeAppKey func(appKey, command string) string) ([]Item, bool) {
|
|
if len(in) <= 1 {
|
|
return in, false
|
|
}
|
|
out := make([]Item, 0, len(in))
|
|
byTargetID := map[string]int{}
|
|
byTargetApp := map[string]int{}
|
|
changed := false
|
|
|
|
for _, raw := range in {
|
|
it := raw
|
|
it.Target = strings.ToLower(strings.TrimSpace(it.Target))
|
|
if it.Target != "vpn" && it.Target != "direct" {
|
|
changed = true
|
|
continue
|
|
}
|
|
if canonicalizeAppKey != nil {
|
|
it.AppKey = canonicalizeAppKey(it.AppKey, it.Command)
|
|
}
|
|
|
|
if it.ID > 0 {
|
|
idKey := fmt.Sprintf("%s:%d", it.Target, it.ID)
|
|
if idx, ok := byTargetID[idKey]; ok {
|
|
if preferItem(it, out[idx]) {
|
|
out[idx] = it
|
|
}
|
|
changed = true
|
|
continue
|
|
}
|
|
byTargetID[idKey] = len(out)
|
|
}
|
|
|
|
if it.AppKey != "" {
|
|
appKey := it.Target + "|" + it.AppKey
|
|
if idx, ok := byTargetApp[appKey]; ok {
|
|
if preferItem(it, out[idx]) {
|
|
out[idx] = it
|
|
}
|
|
changed = true
|
|
continue
|
|
}
|
|
byTargetApp[appKey] = len(out)
|
|
}
|
|
|
|
out = append(out, it)
|
|
}
|
|
return out, changed
|
|
}
|
|
|
|
func UpsertItem(items []Item, next Item) []Item {
|
|
out := items[:0]
|
|
for _, it := range items {
|
|
if strings.ToLower(strings.TrimSpace(it.Target)) == strings.ToLower(strings.TrimSpace(next.Target)) && it.ID == next.ID {
|
|
continue
|
|
}
|
|
out = append(out, it)
|
|
}
|
|
out = append(out, next)
|
|
return out
|
|
}
|
|
|
|
func IsAllDigits(s string) bool {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return false
|
|
}
|
|
for i := 0; i < len(s); i++ {
|
|
ch := s[i]
|
|
if ch < '0' || ch > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func PruneExpired(st *State, now time.Time, deleteRule func(target string, id uint64)) (changed bool) {
|
|
if st == nil {
|
|
return false
|
|
}
|
|
kept := st.Items[:0]
|
|
for _, it := range st.Items {
|
|
expRaw := strings.TrimSpace(it.ExpiresAt)
|
|
if expRaw == "" {
|
|
kept = append(kept, it)
|
|
continue
|
|
}
|
|
exp, err := time.Parse(time.RFC3339, expRaw)
|
|
if err != nil {
|
|
it.ExpiresAt = ""
|
|
kept = append(kept, it)
|
|
changed = true
|
|
continue
|
|
}
|
|
if !exp.After(now) {
|
|
if deleteRule != nil {
|
|
deleteRule(strings.ToLower(strings.TrimSpace(it.Target)), it.ID)
|
|
}
|
|
changed = true
|
|
continue
|
|
}
|
|
kept = append(kept, it)
|
|
}
|
|
st.Items = kept
|
|
return changed
|
|
}
|
|
|
|
func preferItem(cand, cur Item) bool {
|
|
ca := strings.TrimSpace(cand.AddedAt)
|
|
oa := strings.TrimSpace(cur.AddedAt)
|
|
if ca != oa {
|
|
if ca == "" {
|
|
return false
|
|
}
|
|
if oa == "" {
|
|
return true
|
|
}
|
|
return ca > oa
|
|
}
|
|
if strings.TrimSpace(cand.Command) != "" && strings.TrimSpace(cur.Command) == "" {
|
|
return true
|
|
}
|
|
return false
|
|
}
|