Files

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
}