traffic: add persistent app profiles (api+gui)

This commit is contained in:
beckline
2026-02-15 20:56:57 +03:00
parent 70c5eea935
commit b040b9e7d7
7 changed files with 743 additions and 5 deletions

View File

@@ -12,11 +12,12 @@ import "embed"
// ---------------------------------------------------------------------
const (
stateDir = "/var/lib/selective-vpn"
statusFilePath = stateDir + "/status.json"
dnsModePath = stateDir + "/dns-mode.json"
trafficModePath = stateDir + "/traffic-mode.json"
trafficAppMarksPath = stateDir + "/traffic-appmarks.json"
stateDir = "/var/lib/selective-vpn"
statusFilePath = stateDir + "/status.json"
dnsModePath = stateDir + "/dns-mode.json"
trafficModePath = stateDir + "/traffic-mode.json"
trafficAppMarksPath = stateDir + "/traffic-appmarks.json"
trafficAppProfilesPath = stateDir + "/traffic-app-profiles.json"
traceLogPath = stateDir + "/trace.log"
smartdnsLogPath = stateDir + "/smartdns.log"

View File

@@ -148,6 +148,8 @@ func Run() {
mux.HandleFunc("/api/v1/traffic/candidates", handleTrafficCandidates)
// per-app runtime marks (systemd scope / cgroup -> fwmark)
mux.HandleFunc("/api/v1/traffic/appmarks", handleTrafficAppMarks)
// persistent app profiles (saved launch configs)
mux.HandleFunc("/api/v1/traffic/app-profiles", handleTrafficAppProfiles)
// trace: хвост + JSON + append для GUI
mux.HandleFunc("/api/v1/trace", handleTraceTailPlain)

View File

@@ -0,0 +1,306 @@
package app
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// ---------------------------------------------------------------------
// traffic app profiles (persistent app configs)
// ---------------------------------------------------------------------
//
// EN: App profiles are persistent configs that describe *what* to launch and
// EN: how to route it. They are separate from runtime marks, because runtime
// EN: marks are tied to a конкретный systemd unit/cgroup.
// RU: App profiles - это постоянные конфиги, которые описывают *что* запускать
// RU: и как маршрутизировать. Они отдельно от runtime marks, потому что marks
// RU: привязаны к конкретному systemd unit/cgroup.
const (
trafficAppProfilesDefaultTTLSec = 24 * 60 * 60
)
var trafficAppProfilesMu sync.Mutex
type trafficAppProfilesState struct {
Version int `json:"version"`
UpdatedAt string `json:"updated_at"`
Profiles []TrafficAppProfile `json:"profiles,omitempty"`
}
func handleTrafficAppProfiles(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
profiles := listTrafficAppProfiles()
if profiles == nil {
profiles = []TrafficAppProfile{}
}
writeJSON(w, http.StatusOK, TrafficAppProfilesResponse{Profiles: profiles, Message: "ok"})
case http.MethodPost:
var body TrafficAppProfileUpsertRequest
if r.Body != nil {
defer r.Body.Close()
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil && err != io.EOF {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
}
prof, err := upsertTrafficAppProfile(body)
if err != nil {
writeJSON(w, http.StatusOK, TrafficAppProfilesResponse{Profiles: nil, Message: err.Error()})
return
}
events.push("traffic_profiles_changed", map[string]any{"id": prof.ID, "target": prof.Target})
writeJSON(w, http.StatusOK, TrafficAppProfilesResponse{Profiles: []TrafficAppProfile{prof}, Message: "saved"})
case http.MethodDelete:
id := strings.TrimSpace(r.URL.Query().Get("id"))
if id == "" {
http.Error(w, "missing id", http.StatusBadRequest)
return
}
ok, msg := deleteTrafficAppProfile(id)
events.push("traffic_profiles_changed", map[string]any{"id": id, "deleted": ok})
writeJSON(w, http.StatusOK, map[string]any{"ok": ok, "message": msg})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func listTrafficAppProfiles() []TrafficAppProfile {
trafficAppProfilesMu.Lock()
defer trafficAppProfilesMu.Unlock()
st := loadTrafficAppProfilesState()
out := append([]TrafficAppProfile(nil), st.Profiles...)
sort.Slice(out, func(i, j int) bool {
// Newest first.
return out[i].UpdatedAt > out[j].UpdatedAt
})
return out
}
func upsertTrafficAppProfile(req TrafficAppProfileUpsertRequest) (TrafficAppProfile, error) {
trafficAppProfilesMu.Lock()
defer trafficAppProfilesMu.Unlock()
st := loadTrafficAppProfilesState()
target := strings.ToLower(strings.TrimSpace(req.Target))
if target == "" {
target = "vpn"
}
if target != "vpn" && target != "direct" {
return TrafficAppProfile{}, fmt.Errorf("target must be vpn|direct")
}
cmd := strings.TrimSpace(req.Command)
if cmd == "" {
return TrafficAppProfile{}, fmt.Errorf("missing command")
}
appKey := strings.TrimSpace(req.AppKey)
if appKey == "" {
fields := strings.Fields(cmd)
if len(fields) > 0 {
appKey = strings.TrimSpace(fields[0])
}
}
if appKey == "" {
return TrafficAppProfile{}, fmt.Errorf("cannot infer app_key")
}
id := strings.TrimSpace(req.ID)
if id == "" {
// If profile for same app_key+target exists, update it.
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 = deriveTrafficAppProfileID(appKey, target, st.Profiles)
}
if id == "" {
return TrafficAppProfile{}, 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 = trafficAppProfilesDefaultTTLSec
}
vpnProfile := strings.TrimSpace(req.VPNProfile)
now := time.Now().UTC().Format(time.RFC3339)
prof := TrafficAppProfile{
ID: id,
Name: name,
AppKey: appKey,
Command: cmd,
Target: target,
TTLSec: ttl,
VPNProfile: vpnProfile,
UpdatedAt: now,
}
// Upsert.
updated := false
for i := range st.Profiles {
if strings.TrimSpace(st.Profiles[i].ID) != id {
continue
}
// Keep created_at stable.
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 := saveTrafficAppProfilesState(st); err != nil {
return TrafficAppProfile{}, err
}
return prof, nil
}
func deleteTrafficAppProfile(id string) (bool, string) {
trafficAppProfilesMu.Lock()
defer trafficAppProfilesMu.Unlock()
id = strings.TrimSpace(id)
if id == "" {
return false, "empty id"
}
st := loadTrafficAppProfilesState()
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 := saveTrafficAppProfilesState(st); err != nil {
return false, err.Error()
}
return true, "deleted"
}
func deriveTrafficAppProfileID(appKey string, target string, existing []TrafficAppProfile) string {
base := filepath.Base(strings.TrimSpace(appKey))
if base == "" || base == "/" || base == "." {
base = "app"
}
base = sanitizeID(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 sanitizeID(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
}
}
out := strings.Trim(b.String(), "-")
return out
}
func loadTrafficAppProfilesState() trafficAppProfilesState {
st := trafficAppProfilesState{Version: 1}
data, err := os.ReadFile(trafficAppProfilesPath)
if err != nil {
return st
}
if err := json.Unmarshal(data, &st); err != nil {
return trafficAppProfilesState{Version: 1}
}
if st.Version == 0 {
st.Version = 1
}
if st.Profiles == nil {
st.Profiles = nil
}
return st
}
func saveTrafficAppProfilesState(st trafficAppProfilesState) 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(trafficAppProfilesPath), 0o755); err != nil {
return err
}
tmp := trafficAppProfilesPath + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return os.Rename(tmp, trafficAppProfilesPath)
}

View File

@@ -227,6 +227,39 @@ type TrafficAppMarksStatusResponse struct {
Message string `json:"message,omitempty"`
}
// ---------------------------------------------------------------------
// traffic app profiles (persistent app launcher configs)
// ---------------------------------------------------------------------
// EN: Persistent per-app launcher profile (separate from runtime marks).
// RU: Постоянный профиль запуска приложения (отдельно от runtime marks).
type TrafficAppProfile struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
AppKey string `json:"app_key,omitempty"`
Command string `json:"command,omitempty"`
Target string `json:"target,omitempty"` // vpn|direct
TTLSec int `json:"ttl_sec,omitempty"`
VPNProfile string `json:"vpn_profile,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
type TrafficAppProfilesResponse struct {
Profiles []TrafficAppProfile `json:"profiles"`
Message string `json:"message,omitempty"`
}
type TrafficAppProfileUpsertRequest struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
AppKey string `json:"app_key,omitempty"`
Command string `json:"command,omitempty"`
Target string `json:"target,omitempty"` // vpn|direct
TTLSec int `json:"ttl_sec,omitempty"`
VPNProfile string `json:"vpn_profile,omitempty"`
}
type SystemdState struct {
State string `json:"state"`
}