Files
elmprodvpn/selective-vpn-api/app/traffic_candidates.go
beckline 10a10f44a8 baseline: api+gui traffic mode + candidates picker
Snapshot before app-launcher (cgroup/mark) work; ignore binaries/backups.
2026-02-14 15:52:20 +03:00

226 lines
4.9 KiB
Go

package app
import (
"net/http"
"sort"
"strconv"
"strings"
"time"
)
// ---------------------------------------------------------------------
// traffic candidates (subnets / systemd units / UIDs)
// ---------------------------------------------------------------------
// EN: Provides best-effort suggestions for traffic overrides UI.
// EN: This endpoint must never apply anything automatically.
// RU: Отдаёт подсказки для UI overrides.
// RU: Этот эндпоинт никогда не должен применять что-либо автоматически.
func handleTrafficCandidates(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
resp := TrafficCandidatesResponse{
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
Subnets: trafficCandidateSubnets(),
Units: trafficCandidateUnits(),
UIDs: trafficCandidateUIDs(),
}
writeJSON(w, http.StatusOK, resp)
}
func trafficCandidateSubnets() []TrafficCandidateSubnet {
out, _, code, _ := runCommand("ip", "-4", "route", "show", "table", "main")
if code != 0 {
return nil
}
seen := map[string]struct{}{}
items := make([]TrafficCandidateSubnet, 0, 24)
for _, raw := range strings.Split(out, "\n") {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
fields := strings.Fields(line)
if len(fields) == 0 {
continue
}
dst := strings.TrimSpace(fields[0])
if dst == "" || dst == "default" {
continue
}
dev := parseRouteDevice(fields)
if dev == "" || dev == "lo" {
continue
}
if isVPNLikeIface(dev) {
continue
}
isDocker := isContainerIface(dev)
isLocal := isAutoBypassDestination(dst)
if !isDocker && !isLocal {
// keep suggestions intentionally small: only local/LAN + container subnets
continue
}
kind := "lan"
if isDocker {
kind = "docker"
} else if strings.Contains(" "+strings.ToLower(line)+" ", " scope link ") {
kind = "link"
}
key := kind + "|" + dst + "|" + dev
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
items = append(items, TrafficCandidateSubnet{
CIDR: dst,
Dev: dev,
Kind: kind,
LinkDown: strings.Contains(strings.ToLower(line), " linkdown"),
})
}
sort.Slice(items, func(i, j int) bool {
if items[i].Kind != items[j].Kind {
return items[i].Kind < items[j].Kind
}
if items[i].Dev != items[j].Dev {
return items[i].Dev < items[j].Dev
}
return items[i].CIDR < items[j].CIDR
})
return items
}
func trafficCandidateUnits() []TrafficCandidateUnit {
stdout, _, code, _ := runCommand(
"systemctl",
"list-units",
"--type=service",
"--state=running",
"--no-legend",
"--no-pager",
"--plain",
)
if code != 0 {
return nil
}
seen := map[string]struct{}{}
items := make([]TrafficCandidateUnit, 0, 32)
for _, raw := range strings.Split(stdout, "\n") {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
fields := strings.Fields(line)
if len(fields) < 1 {
continue
}
unit := strings.TrimSpace(fields[0])
if unit == "" {
continue
}
if _, ok := seen[unit]; ok {
continue
}
seen[unit] = struct{}{}
desc := ""
// UNIT LOAD ACTIVE SUB DESCRIPTION
if len(fields) >= 5 {
desc = strings.Join(fields[4:], " ")
}
items = append(items, TrafficCandidateUnit{
Unit: unit,
Description: strings.TrimSpace(desc),
Cgroup: "system.slice/" + unit,
})
}
sort.Slice(items, func(i, j int) bool {
return items[i].Unit < items[j].Unit
})
return items
}
func trafficCandidateUIDs() []TrafficCandidateUID {
stdout, _, code, _ := runCommand("ps", "-eo", "uid,user,comm", "--no-headers")
if code != 0 {
return nil
}
type agg struct {
uid int
user string
comms map[string]struct{}
}
aggs := map[int]*agg{}
for _, raw := range strings.Split(stdout, "\n") {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
uidN, err := strconv.Atoi(strings.TrimSpace(fields[0]))
if err != nil || uidN < 0 {
continue
}
user := strings.TrimSpace(fields[1])
comm := ""
if len(fields) >= 3 {
comm = strings.TrimSpace(fields[2])
}
a := aggs[uidN]
if a == nil {
a = &agg{uid: uidN, user: user, comms: map[string]struct{}{}}
aggs[uidN] = a
}
if a.user == "" && user != "" {
a.user = user
}
if comm != "" {
a.comms[comm] = struct{}{}
}
}
items := make([]TrafficCandidateUID, 0, len(aggs))
for _, a := range aggs {
examples := make([]string, 0, len(a.comms))
for c := range a.comms {
examples = append(examples, c)
}
sort.Strings(examples)
if len(examples) > 3 {
examples = examples[:3]
}
items = append(items, TrafficCandidateUID{
UID: a.uid,
User: a.user,
Examples: examples,
})
}
sort.Slice(items, func(i, j int) bool {
return items[i].UID < items[j].UID
})
return items
}