platform: modularize api/gui, add docs-tests-web foundation, and refresh root config
This commit is contained in:
59
selective-vpn-api/app/trafficappmarks/cgroup.go
Normal file
59
selective-vpn-api/app/trafficappmarks/cgroup.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package trafficappmarks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func ResolveCgroupV2PathForNft(input string, cgroupRootPath string) (rel string, level int, inodeID uint64, abs string, err error) {
|
||||
raw := strings.TrimSpace(input)
|
||||
if raw == "" {
|
||||
return "", 0, 0, "", fmt.Errorf("empty cgroup")
|
||||
}
|
||||
|
||||
rel = NormalizeCgroupRelOnly(raw)
|
||||
if rel == "" {
|
||||
return "", 0, 0, raw, fmt.Errorf("invalid cgroup path: %s", raw)
|
||||
}
|
||||
|
||||
inodeID, err = CgroupDirInode(cgroupRootPath, rel)
|
||||
if err != nil {
|
||||
return "", 0, 0, raw, err
|
||||
}
|
||||
|
||||
level = strings.Count(rel, "/") + 1
|
||||
abs = "/" + rel
|
||||
return rel, level, inodeID, abs, nil
|
||||
}
|
||||
|
||||
func NormalizeCgroupRelOnly(raw string) string {
|
||||
rel := strings.TrimSpace(raw)
|
||||
rel = strings.TrimPrefix(rel, "/")
|
||||
rel = filepath.Clean(rel)
|
||||
if rel == "." || rel == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(rel, "..") || strings.Contains(rel, "../") {
|
||||
return ""
|
||||
}
|
||||
return rel
|
||||
}
|
||||
|
||||
func CgroupDirInode(cgroupRootPath, rel string) (uint64, error) {
|
||||
full := filepath.Join(cgroupRootPath, strings.TrimPrefix(rel, "/"))
|
||||
fi, err := os.Stat(full)
|
||||
if err != nil || fi == nil || !fi.IsDir() {
|
||||
return 0, fmt.Errorf("cgroup not found: %s", "/"+strings.TrimPrefix(rel, "/"))
|
||||
}
|
||||
st, ok := fi.Sys().(*syscall.Stat_t)
|
||||
if !ok || st == nil {
|
||||
return 0, fmt.Errorf("cannot stat cgroup: %s", "/"+strings.TrimPrefix(rel, "/"))
|
||||
}
|
||||
if st.Ino == 0 {
|
||||
return 0, fmt.Errorf("invalid cgroup inode id: %s", "/"+strings.TrimPrefix(rel, "/"))
|
||||
}
|
||||
return st.Ino, nil
|
||||
}
|
||||
337
selective-vpn-api/app/trafficappmarks/nft.go
Normal file
337
selective-vpn-api/app/trafficappmarks/nft.go
Normal file
@@ -0,0 +1,337 @@
|
||||
package trafficappmarks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RunCommandFunc func(timeout time.Duration, name string, args ...string) (stdout string, stderr string, code int, err error)
|
||||
|
||||
type NFTConfig struct {
|
||||
Table string
|
||||
Chain string
|
||||
GuardChain string
|
||||
LocalBypassSet string
|
||||
MarkApp string
|
||||
MarkDirect string
|
||||
MarkCommentPrefix string
|
||||
GuardCommentPrefix string
|
||||
GuardEnabled bool
|
||||
}
|
||||
|
||||
func EnsureBase(cfg NFTConfig, run RunCommandFunc) error {
|
||||
if run == nil {
|
||||
return fmt.Errorf("run command func is nil")
|
||||
}
|
||||
_, _, _, _ = run(5*time.Second, "nft", "add", "table", "inet", cfg.Table)
|
||||
_, _, _, _ = run(5*time.Second, "nft", "add", "chain", "inet", cfg.Table, "output", "{", "type", "route", "hook", "output", "priority", "mangle;", "policy", "accept;", "}")
|
||||
_, _, _, _ = run(5*time.Second, "nft", "add", "chain", "inet", cfg.Table, cfg.GuardChain, "{", "type", "filter", "hook", "output", "priority", "filter;", "policy", "accept;", "}")
|
||||
_, _, _, _ = run(5*time.Second, "nft", "add", "chain", "inet", cfg.Table, cfg.Chain)
|
||||
_, _, _, _ = run(5*time.Second, "nft", "add", "set", "inet", cfg.Table, cfg.LocalBypassSet, "{", "type", "ipv4_addr", ";", "flags", "interval", ";", "}")
|
||||
|
||||
out, _, _, _ := run(5*time.Second, "nft", "list", "chain", "inet", cfg.Table, "output")
|
||||
if !strings.Contains(out, "jump "+cfg.Chain) {
|
||||
_, _, _, _ = run(5*time.Second, "nft", "insert", "rule", "inet", cfg.Table, "output", "jump", cfg.Chain)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func AppMarkComment(prefix string, target string, id uint64) string {
|
||||
return fmt.Sprintf("%s:%s:%d", prefix, target, id)
|
||||
}
|
||||
|
||||
func AppGuardComment(prefix string, target string, id uint64) string {
|
||||
return fmt.Sprintf("%s:%s:%d", prefix, target, id)
|
||||
}
|
||||
|
||||
func UpdateLocalBypassSet(cfg NFTConfig, vpnIface string, bypassCIDRs []string, run RunCommandFunc) error {
|
||||
if run == nil {
|
||||
return fmt.Errorf("run command func is nil")
|
||||
}
|
||||
if strings.TrimSpace(cfg.Table) == "" || strings.TrimSpace(cfg.LocalBypassSet) == "" {
|
||||
return fmt.Errorf("invalid nft config for local bypass set")
|
||||
}
|
||||
|
||||
_, _, _, _ = run(5*time.Second, "nft", "flush", "set", "inet", cfg.Table, cfg.LocalBypassSet)
|
||||
|
||||
elems := []string{"127.0.0.0/8"}
|
||||
for _, dst := range bypassCIDRs {
|
||||
val := strings.TrimSpace(dst)
|
||||
if val == "" || val == "default" {
|
||||
continue
|
||||
}
|
||||
elems = append(elems, val)
|
||||
}
|
||||
elems = CompactIPv4IntervalElements(elems)
|
||||
|
||||
for _, e := range elems {
|
||||
_, out, code, err := run(
|
||||
5*time.Second,
|
||||
"nft", "add", "element", "inet", cfg.Table, cfg.LocalBypassSet,
|
||||
"{", e, "}",
|
||||
)
|
||||
if err != nil || code != 0 {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("nft add element exited with %d", code)
|
||||
}
|
||||
return fmt.Errorf("failed to update %s: %w (%s)", cfg.LocalBypassSet, err, strings.TrimSpace(out))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func InsertAppMarkRule(cfg NFTConfig, target string, rel string, level int, id uint64, vpnIface string, bypassCIDRs []string, run RunCommandFunc) error {
|
||||
if run == nil {
|
||||
return fmt.Errorf("run command func is nil")
|
||||
}
|
||||
|
||||
target = strings.ToLower(strings.TrimSpace(target))
|
||||
mark := cfg.MarkDirect
|
||||
if target == "vpn" {
|
||||
mark = cfg.MarkApp
|
||||
}
|
||||
|
||||
comment := AppMarkComment(cfg.MarkCommentPrefix, target, id)
|
||||
pathLit := fmt.Sprintf("\"%s\"", rel)
|
||||
commentLit := fmt.Sprintf("\"%s\"", comment)
|
||||
|
||||
if target == "vpn" && cfg.GuardEnabled {
|
||||
iface := strings.TrimSpace(vpnIface)
|
||||
if iface == "" {
|
||||
return fmt.Errorf("vpn interface required for app guard")
|
||||
}
|
||||
if err := UpdateLocalBypassSet(cfg, iface, bypassCIDRs, run); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
guardComment := AppGuardComment(cfg.GuardCommentPrefix, target, id)
|
||||
guardCommentLit := fmt.Sprintf("\"%s\"", guardComment)
|
||||
|
||||
_, out, code, err := run(
|
||||
5*time.Second,
|
||||
"nft", "insert", "rule", "inet", cfg.Table, cfg.GuardChain,
|
||||
"socket", "cgroupv2", "level", strconv.Itoa(level), pathLit,
|
||||
"meta", "mark", cfg.MarkApp,
|
||||
"oifname", "!=", iface,
|
||||
"ip", "daddr", "!=", "@"+cfg.LocalBypassSet,
|
||||
"drop",
|
||||
"comment", guardCommentLit,
|
||||
)
|
||||
if err != nil || code != 0 {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("nft insert guard(v4) exited with %d", code)
|
||||
}
|
||||
return fmt.Errorf("nft insert app guard(v4) failed: %w (%s)", err, strings.TrimSpace(out))
|
||||
}
|
||||
|
||||
_, out, code, err = run(
|
||||
5*time.Second,
|
||||
"nft", "insert", "rule", "inet", cfg.Table, cfg.GuardChain,
|
||||
"socket", "cgroupv2", "level", strconv.Itoa(level), pathLit,
|
||||
"meta", "mark", cfg.MarkApp,
|
||||
"oifname", "!=", iface,
|
||||
"meta", "nfproto", "ipv6",
|
||||
"drop",
|
||||
"comment", guardCommentLit,
|
||||
)
|
||||
if err != nil || code != 0 {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("nft insert guard(v6) exited with %d", code)
|
||||
}
|
||||
return fmt.Errorf("nft insert app guard(v6) failed: %w (%s)", err, strings.TrimSpace(out))
|
||||
}
|
||||
}
|
||||
|
||||
_, out, code, err := run(
|
||||
5*time.Second,
|
||||
"nft", "insert", "rule", "inet", cfg.Table, cfg.Chain,
|
||||
"socket", "cgroupv2", "level", strconv.Itoa(level), pathLit,
|
||||
"meta", "mark", "set", mark,
|
||||
"accept",
|
||||
"comment", commentLit,
|
||||
)
|
||||
if err != nil || code != 0 {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("nft insert rule exited with %d", code)
|
||||
}
|
||||
_ = DeleteAppMarkRule(cfg, target, id, run)
|
||||
return fmt.Errorf("nft insert appmark rule failed: %w (%s)", err, strings.TrimSpace(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DeleteAppMarkRule(cfg NFTConfig, target string, id uint64, run RunCommandFunc) error {
|
||||
if run == nil {
|
||||
return fmt.Errorf("run command func is nil")
|
||||
}
|
||||
comments := []string{
|
||||
AppMarkComment(cfg.MarkCommentPrefix, target, id),
|
||||
AppGuardComment(cfg.GuardCommentPrefix, target, id),
|
||||
}
|
||||
chains := []string{cfg.Chain, cfg.GuardChain}
|
||||
for _, chain := range chains {
|
||||
if strings.TrimSpace(chain) == "" {
|
||||
continue
|
||||
}
|
||||
out, _, _, _ := run(5*time.Second, "nft", "-a", "list", "chain", "inet", cfg.Table, chain)
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
match := false
|
||||
for _, comment := range comments {
|
||||
if strings.Contains(line, comment) {
|
||||
match = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !match {
|
||||
continue
|
||||
}
|
||||
h := ParseNftHandle(line)
|
||||
if h <= 0 {
|
||||
continue
|
||||
}
|
||||
_, _, _, _ = run(5*time.Second, "nft", "delete", "rule", "inet", cfg.Table, chain, "handle", strconv.Itoa(h))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func HasAppMarkRule(cfg NFTConfig, target string, id uint64, run RunCommandFunc) bool {
|
||||
if run == nil {
|
||||
return false
|
||||
}
|
||||
markComment := AppMarkComment(cfg.MarkCommentPrefix, target, id)
|
||||
guardComment := AppGuardComment(cfg.GuardCommentPrefix, target, id)
|
||||
|
||||
hasMark := false
|
||||
out, _, _, _ := run(5*time.Second, "nft", "-a", "list", "chain", "inet", cfg.Table, cfg.Chain)
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if strings.Contains(line, markComment) {
|
||||
hasMark = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasMark {
|
||||
return false
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(target), "vpn") {
|
||||
if !cfg.GuardEnabled {
|
||||
return true
|
||||
}
|
||||
out, _, _, _ = run(5*time.Second, "nft", "-a", "list", "chain", "inet", cfg.Table, cfg.GuardChain)
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if strings.Contains(line, guardComment) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func CleanupLegacyRules(cfg NFTConfig, run RunCommandFunc) error {
|
||||
if run == nil {
|
||||
return fmt.Errorf("run command func is nil")
|
||||
}
|
||||
out, _, _, _ := run(5*time.Second, "nft", "-a", "list", "chain", "inet", cfg.Table, cfg.Chain)
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
l := strings.ToLower(line)
|
||||
if !strings.Contains(l, "meta cgroup") {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(l, "svpn_cg_") {
|
||||
continue
|
||||
}
|
||||
h := ParseNftHandle(line)
|
||||
if h <= 0 {
|
||||
continue
|
||||
}
|
||||
_, _, _, _ = run(5*time.Second, "nft", "delete", "rule", "inet", cfg.Table, cfg.Chain, "handle", strconv.Itoa(h))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ClearManagedRules(cfg NFTConfig, chain string, run RunCommandFunc) {
|
||||
if run == nil {
|
||||
return
|
||||
}
|
||||
out, _, _, _ := run(5*time.Second, "nft", "-a", "list", "chain", "inet", cfg.Table, chain)
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
l := strings.ToLower(line)
|
||||
if !strings.Contains(l, strings.ToLower(cfg.MarkCommentPrefix)) &&
|
||||
!strings.Contains(l, strings.ToLower(cfg.GuardCommentPrefix)) {
|
||||
continue
|
||||
}
|
||||
h := ParseNftHandle(line)
|
||||
if h <= 0 {
|
||||
continue
|
||||
}
|
||||
_, _, _, _ = run(5*time.Second, "nft", "delete", "rule", "inet", cfg.Table, chain, "handle", strconv.Itoa(h))
|
||||
}
|
||||
}
|
||||
|
||||
func ParseNftHandle(line string) int {
|
||||
fields := strings.Fields(line)
|
||||
for i := 0; i < len(fields)-1; i++ {
|
||||
if fields[i] == "handle" {
|
||||
n, _ := strconv.Atoi(fields[i+1])
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func CompactIPv4IntervalElements(raw []string) []string {
|
||||
pfxs := make([]netip.Prefix, 0, len(raw))
|
||||
for _, v := range raw {
|
||||
s := strings.TrimSpace(v)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(s, "/") {
|
||||
p, err := netip.ParsePrefix(s)
|
||||
if err != nil || !p.Addr().Is4() {
|
||||
continue
|
||||
}
|
||||
pfxs = append(pfxs, p.Masked())
|
||||
continue
|
||||
}
|
||||
a, err := netip.ParseAddr(s)
|
||||
if err != nil || !a.Is4() {
|
||||
continue
|
||||
}
|
||||
pfxs = append(pfxs, netip.PrefixFrom(a, 32))
|
||||
}
|
||||
|
||||
sort.Slice(pfxs, func(i, j int) bool {
|
||||
ib, jb := pfxs[i].Bits(), pfxs[j].Bits()
|
||||
if ib != jb {
|
||||
return ib < jb
|
||||
}
|
||||
return pfxs[i].Addr().Less(pfxs[j].Addr())
|
||||
})
|
||||
|
||||
out := make([]netip.Prefix, 0, len(pfxs))
|
||||
for _, p := range pfxs {
|
||||
covered := false
|
||||
for _, ex := range out {
|
||||
if ex.Contains(p.Addr()) {
|
||||
covered = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if covered {
|
||||
continue
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
|
||||
res := make([]string, 0, len(out))
|
||||
for _, p := range out {
|
||||
res = append(res, p.String())
|
||||
}
|
||||
return res
|
||||
}
|
||||
205
selective-vpn-api/app/trafficappmarks/store.go
Normal file
205
selective-vpn-api/app/trafficappmarks/store.go
Normal file
@@ -0,0 +1,205 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user