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]) } } appKey = canonicalizeAppKey(appKey, cmd) 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 } // EN: Best-effort migration: normalize app keys to canonical form. // RU: Best-effort миграция: нормализуем app_key в канонический вид. changed := false for i := range st.Profiles { canon := 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 } } if changed { _ = saveTrafficAppProfilesState(st) } 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) }