307 lines
7.6 KiB
Go
307 lines
7.6 KiB
Go
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)
|
|
}
|