platform: modularize api/gui, add docs-tests-web foundation, and refresh root config
This commit is contained in:
178
selective-vpn-api/app/trafficprofiles/appkey.go
Normal file
178
selective-vpn-api/app/trafficprofiles/appkey.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package trafficprofiles
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func CanonicalizeAppKey(appKey string, command string) string {
|
||||
key := strings.TrimSpace(appKey)
|
||||
cmd := strings.TrimSpace(command)
|
||||
|
||||
fields := SplitCommandTokens(cmd)
|
||||
if len(fields) == 0 && key != "" {
|
||||
fields = []string{key}
|
||||
}
|
||||
|
||||
primary := key
|
||||
if len(fields) > 0 {
|
||||
primary = fields[0]
|
||||
}
|
||||
primary = stripOuterQuotes(strings.TrimSpace(primary))
|
||||
if primary == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
base := strings.ToLower(filepath.Base(primary))
|
||||
clean := make([]string, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
f = stripOuterQuotes(strings.TrimSpace(f))
|
||||
if f == "" {
|
||||
continue
|
||||
}
|
||||
clean = append(clean, f)
|
||||
}
|
||||
|
||||
switch base {
|
||||
case "flatpak":
|
||||
if id := extractRunTarget(clean); id != "" {
|
||||
return "flatpak:" + strings.ToLower(strings.TrimSpace(id))
|
||||
}
|
||||
return "flatpak"
|
||||
case "snap":
|
||||
if name := extractRunTarget(clean); name != "" {
|
||||
return "snap:" + strings.ToLower(strings.TrimSpace(name))
|
||||
}
|
||||
return "snap"
|
||||
case "gtk-launch":
|
||||
if len(clean) >= 2 {
|
||||
id := strings.TrimSpace(clean[1])
|
||||
if id != "" && !strings.HasPrefix(id, "-") {
|
||||
return "desktop:" + strings.ToLower(id)
|
||||
}
|
||||
}
|
||||
case "env":
|
||||
for i := 1; i < len(clean); i++ {
|
||||
tok := strings.TrimSpace(clean[i])
|
||||
if tok == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(tok, "-") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(tok, "=") {
|
||||
continue
|
||||
}
|
||||
return CanonicalizeAppKey(tok, strings.Join(clean[i:], " "))
|
||||
}
|
||||
return "env"
|
||||
}
|
||||
|
||||
if strings.Contains(primary, "/") {
|
||||
b := filepath.Base(primary)
|
||||
if b != "" && b != "." && b != "/" {
|
||||
return strings.ToLower(strings.TrimSpace(b))
|
||||
}
|
||||
}
|
||||
|
||||
return strings.ToLower(strings.TrimSpace(primary))
|
||||
}
|
||||
|
||||
func stripOuterQuotes(s string) string {
|
||||
in := strings.TrimSpace(s)
|
||||
if len(in) >= 2 {
|
||||
if (in[0] == '"' && in[len(in)-1] == '"') || (in[0] == '\'' && in[len(in)-1] == '\'') {
|
||||
return strings.TrimSpace(in[1 : len(in)-1])
|
||||
}
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
func extractRunTarget(fields []string) string {
|
||||
if len(fields) == 0 {
|
||||
return ""
|
||||
}
|
||||
idx := -1
|
||||
for i := 0; i < len(fields); i++ {
|
||||
if strings.TrimSpace(fields[i]) == "run" {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
for j := idx + 1; j < len(fields); j++ {
|
||||
tok := strings.TrimSpace(fields[j])
|
||||
if tok == "" {
|
||||
continue
|
||||
}
|
||||
if tok == "--" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(tok, "-") {
|
||||
continue
|
||||
}
|
||||
return tok
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func SplitCommandTokens(raw string) []string {
|
||||
s := strings.TrimSpace(raw)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]string, 0, 8)
|
||||
var cur strings.Builder
|
||||
inSingle := false
|
||||
inDouble := false
|
||||
escaped := false
|
||||
|
||||
flush := func() {
|
||||
if cur.Len() == 0 {
|
||||
return
|
||||
}
|
||||
out = append(out, cur.String())
|
||||
cur.Reset()
|
||||
}
|
||||
|
||||
for _, r := range s {
|
||||
if escaped {
|
||||
cur.WriteRune(r)
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
switch r {
|
||||
case '\\':
|
||||
if inSingle {
|
||||
cur.WriteRune(r)
|
||||
} else {
|
||||
escaped = true
|
||||
}
|
||||
case '\'':
|
||||
if inDouble {
|
||||
cur.WriteRune(r)
|
||||
} else {
|
||||
inSingle = !inSingle
|
||||
}
|
||||
case '"':
|
||||
if inSingle {
|
||||
cur.WriteRune(r)
|
||||
} else {
|
||||
inDouble = !inDouble
|
||||
}
|
||||
case ' ', '\t', '\n', '\r':
|
||||
if inSingle || inDouble {
|
||||
cur.WriteRune(r)
|
||||
} else {
|
||||
flush()
|
||||
}
|
||||
default:
|
||||
cur.WriteRune(r)
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
411
selective-vpn-api/app/trafficprofiles/store.go
Normal file
411
selective-vpn-api/app/trafficprofiles/store.go
Normal file
@@ -0,0 +1,411 @@
|
||||
package trafficprofiles
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Profile struct {
|
||||
ID string
|
||||
Name string
|
||||
AppKey string
|
||||
Command string
|
||||
Target string
|
||||
TTLSec int
|
||||
VPNProfile string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type UpsertRequest struct {
|
||||
ID string
|
||||
Name string
|
||||
AppKey string
|
||||
Command string
|
||||
Target string
|
||||
TTLSec int
|
||||
VPNProfile string
|
||||
}
|
||||
|
||||
type Deps struct {
|
||||
CanonicalizeAppKey func(appKey, command string) string
|
||||
SanitizeID func(string) string
|
||||
DefaultTTLSec int
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
mu sync.Mutex
|
||||
|
||||
statePath string
|
||||
canonicalizeAppKey func(appKey, command string) string
|
||||
sanitizeID func(string) string
|
||||
defaultTTLSec int
|
||||
}
|
||||
|
||||
type state struct {
|
||||
Version int `json:"version"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Profiles []Profile `json:"profiles,omitempty"`
|
||||
}
|
||||
|
||||
func NewStore(statePath string, deps Deps) *Store {
|
||||
canon := deps.CanonicalizeAppKey
|
||||
if canon == nil {
|
||||
canon = func(appKey, _ string) string { return strings.TrimSpace(appKey) }
|
||||
}
|
||||
sanitize := deps.SanitizeID
|
||||
if sanitize == nil {
|
||||
sanitize = defaultSanitizeID
|
||||
}
|
||||
return &Store{
|
||||
statePath: strings.TrimSpace(statePath),
|
||||
canonicalizeAppKey: canon,
|
||||
sanitizeID: sanitize,
|
||||
defaultTTLSec: deps.DefaultTTLSec,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) List() []Profile {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
st := s.loadStateLocked()
|
||||
out := append([]Profile(nil), st.Profiles...)
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return out[i].UpdatedAt > out[j].UpdatedAt
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) Upsert(req UpsertRequest) (Profile, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
st := s.loadStateLocked()
|
||||
|
||||
target := strings.ToLower(strings.TrimSpace(req.Target))
|
||||
if target == "" {
|
||||
target = "vpn"
|
||||
}
|
||||
if target != "vpn" && target != "direct" {
|
||||
return Profile{}, fmt.Errorf("target must be vpn|direct")
|
||||
}
|
||||
|
||||
cmd := strings.TrimSpace(req.Command)
|
||||
if cmd == "" {
|
||||
return Profile{}, fmt.Errorf("missing command")
|
||||
}
|
||||
|
||||
appKey := strings.TrimSpace(req.AppKey)
|
||||
if appKey == "" {
|
||||
fields := strings.Fields(cmd)
|
||||
if len(fields) > 0 {
|
||||
appKey = strings.TrimSpace(fields[0])
|
||||
}
|
||||
}
|
||||
appKey = s.canonicalizeAppKey(appKey, cmd)
|
||||
if appKey == "" {
|
||||
return Profile{}, fmt.Errorf("cannot infer app_key")
|
||||
}
|
||||
|
||||
id := strings.TrimSpace(req.ID)
|
||||
if id == "" {
|
||||
for _, p := range st.Profiles {
|
||||
if strings.TrimSpace(p.AppKey) == appKey && strings.ToLower(strings.TrimSpace(p.Target)) == target {
|
||||
id = strings.TrimSpace(p.ID)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
id = deriveProfileID(appKey, target, st.Profiles, s.sanitizeID)
|
||||
}
|
||||
if id == "" {
|
||||
return Profile{}, fmt.Errorf("cannot derive profile id")
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
name = filepath.Base(appKey)
|
||||
if name == "" || name == "/" || name == "." {
|
||||
name = id
|
||||
}
|
||||
}
|
||||
|
||||
ttl := req.TTLSec
|
||||
if ttl <= 0 {
|
||||
ttl = s.defaultTTLSec
|
||||
}
|
||||
|
||||
vpnProfile := strings.TrimSpace(req.VPNProfile)
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
prof := Profile{
|
||||
ID: id,
|
||||
Name: name,
|
||||
AppKey: appKey,
|
||||
Command: cmd,
|
||||
Target: target,
|
||||
TTLSec: ttl,
|
||||
VPNProfile: vpnProfile,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
updated := false
|
||||
for i := range st.Profiles {
|
||||
if strings.TrimSpace(st.Profiles[i].ID) != id {
|
||||
continue
|
||||
}
|
||||
prof.CreatedAt = strings.TrimSpace(st.Profiles[i].CreatedAt)
|
||||
if prof.CreatedAt == "" {
|
||||
prof.CreatedAt = now
|
||||
}
|
||||
st.Profiles[i] = prof
|
||||
updated = true
|
||||
break
|
||||
}
|
||||
if !updated {
|
||||
prof.CreatedAt = now
|
||||
st.Profiles = append(st.Profiles, prof)
|
||||
}
|
||||
|
||||
if err := s.saveStateLocked(st); err != nil {
|
||||
return Profile{}, err
|
||||
}
|
||||
return prof, nil
|
||||
}
|
||||
|
||||
func (s *Store) Delete(id string) (bool, string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return false, "empty id"
|
||||
}
|
||||
|
||||
st := s.loadStateLocked()
|
||||
kept := st.Profiles[:0]
|
||||
found := false
|
||||
for _, p := range st.Profiles {
|
||||
if strings.TrimSpace(p.ID) == id {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
kept = append(kept, p)
|
||||
}
|
||||
st.Profiles = kept
|
||||
|
||||
if !found {
|
||||
return true, "not found"
|
||||
}
|
||||
if err := s.saveStateLocked(st); err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
return true, "deleted"
|
||||
}
|
||||
|
||||
func Dedupe(in []Profile, canonicalize func(appKey, command string) string) ([]Profile, bool) {
|
||||
if canonicalize == nil {
|
||||
canonicalize = func(appKey, _ string) string { return strings.TrimSpace(appKey) }
|
||||
}
|
||||
if len(in) <= 1 {
|
||||
return in, false
|
||||
}
|
||||
|
||||
out := make([]Profile, 0, len(in))
|
||||
byID := map[string]int{}
|
||||
byAppTarget := map[string]int{}
|
||||
changed := false
|
||||
|
||||
for _, raw := range in {
|
||||
p := raw
|
||||
p.ID = strings.TrimSpace(p.ID)
|
||||
p.Target = strings.ToLower(strings.TrimSpace(p.Target))
|
||||
p.AppKey = canonicalize(p.AppKey, p.Command)
|
||||
|
||||
if p.ID == "" {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
if p.Target != "vpn" && p.Target != "direct" {
|
||||
p.Target = "vpn"
|
||||
changed = true
|
||||
}
|
||||
|
||||
if idx, ok := byID[p.ID]; ok {
|
||||
if preferProfile(p, out[idx]) {
|
||||
out[idx] = p
|
||||
}
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
|
||||
if p.AppKey != "" {
|
||||
key := p.Target + "|" + p.AppKey
|
||||
if idx, ok := byAppTarget[key]; ok {
|
||||
if preferProfile(p, out[idx]) {
|
||||
byID[p.ID] = idx
|
||||
out[idx] = p
|
||||
}
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
byAppTarget[key] = len(out)
|
||||
}
|
||||
|
||||
byID[p.ID] = len(out)
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, changed
|
||||
}
|
||||
|
||||
func (s *Store) loadStateLocked() state {
|
||||
st := state{Version: 1}
|
||||
if strings.TrimSpace(s.statePath) == "" {
|
||||
return st
|
||||
}
|
||||
data, err := os.ReadFile(s.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
|
||||
}
|
||||
if st.Profiles == nil {
|
||||
st.Profiles = nil
|
||||
}
|
||||
|
||||
changed := false
|
||||
for i := range st.Profiles {
|
||||
canon := s.canonicalizeAppKey(st.Profiles[i].AppKey, st.Profiles[i].Command)
|
||||
if canon != "" && strings.TrimSpace(st.Profiles[i].AppKey) != canon {
|
||||
st.Profiles[i].AppKey = canon
|
||||
changed = true
|
||||
}
|
||||
st.Profiles[i].Target = strings.ToLower(strings.TrimSpace(st.Profiles[i].Target))
|
||||
}
|
||||
if deduped, dedupChanged := Dedupe(st.Profiles, s.canonicalizeAppKey); dedupChanged {
|
||||
st.Profiles = deduped
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
_ = s.saveStateLocked(st)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func (s *Store) saveStateLocked(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 strings.TrimSpace(s.statePath) == "" {
|
||||
return fmt.Errorf("state path is empty")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(s.statePath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := s.statePath + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, s.statePath)
|
||||
}
|
||||
|
||||
func deriveProfileID(appKey string, target string, existing []Profile, sanitize func(string) string) string {
|
||||
if sanitize == nil {
|
||||
sanitize = defaultSanitizeID
|
||||
}
|
||||
base := filepath.Base(strings.TrimSpace(appKey))
|
||||
if base == "" || base == "/" || base == "." {
|
||||
base = "app"
|
||||
}
|
||||
base = sanitize(base)
|
||||
if base == "" {
|
||||
base = "app"
|
||||
}
|
||||
|
||||
idBase := base + "-" + strings.ToLower(strings.TrimSpace(target))
|
||||
id := idBase
|
||||
|
||||
used := map[string]struct{}{}
|
||||
for _, p := range existing {
|
||||
used[strings.TrimSpace(p.ID)] = struct{}{}
|
||||
}
|
||||
if _, ok := used[id]; !ok {
|
||||
return id
|
||||
}
|
||||
for i := 2; i < 1000; i++ {
|
||||
cand := fmt.Sprintf("%s-%d", idBase, i)
|
||||
if _, ok := used[cand]; !ok {
|
||||
return cand
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func preferProfile(cand, cur Profile) bool {
|
||||
cu := strings.TrimSpace(cand.UpdatedAt)
|
||||
ou := strings.TrimSpace(cur.UpdatedAt)
|
||||
if cu != ou {
|
||||
if cu == "" {
|
||||
return false
|
||||
}
|
||||
if ou == "" {
|
||||
return true
|
||||
}
|
||||
return cu > ou
|
||||
}
|
||||
|
||||
cc := strings.TrimSpace(cand.CreatedAt)
|
||||
oc := strings.TrimSpace(cur.CreatedAt)
|
||||
if cc != oc {
|
||||
if cc == "" {
|
||||
return false
|
||||
}
|
||||
if oc == "" {
|
||||
return true
|
||||
}
|
||||
return cc > oc
|
||||
}
|
||||
|
||||
if strings.TrimSpace(cand.Command) != "" && strings.TrimSpace(cur.Command) == "" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func defaultSanitizeID(s string) string {
|
||||
in := strings.ToLower(strings.TrimSpace(s))
|
||||
var b strings.Builder
|
||||
b.Grow(len(in))
|
||||
lastDash := false
|
||||
for i := 0; i < len(in); i++ {
|
||||
ch := in[i]
|
||||
isAZ := ch >= 'a' && ch <= 'z'
|
||||
is09 := ch >= '0' && ch <= '9'
|
||||
if isAZ || is09 {
|
||||
b.WriteByte(ch)
|
||||
lastDash = false
|
||||
continue
|
||||
}
|
||||
if !lastDash {
|
||||
b.WriteByte('-')
|
||||
lastDash = true
|
||||
}
|
||||
}
|
||||
return strings.Trim(b.String(), "-")
|
||||
}
|
||||
Reference in New Issue
Block a user