platform: modularize api/gui, add docs-tests-web foundation, and refresh root config
This commit is contained in:
@@ -2,224 +2,55 @@ package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
trafficcandidatespkg "selective-vpn-api/app/trafficcandidates"
|
||||
"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
|
||||
}
|
||||
|
||||
payload := trafficcandidatespkg.Collect(
|
||||
time.Now().UTC(),
|
||||
trafficcandidatespkg.Deps{
|
||||
RunCommand: runCommand,
|
||||
ParseRouteDevice: parseRouteDevice,
|
||||
IsVPNLikeIface: isVPNLikeIface,
|
||||
IsContainerIface: isContainerIface,
|
||||
IsAutoBypassDestination: isAutoBypassDestination,
|
||||
},
|
||||
)
|
||||
|
||||
resp := TrafficCandidatesResponse{
|
||||
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Subnets: trafficCandidateSubnets(),
|
||||
Units: trafficCandidateUnits(),
|
||||
UIDs: trafficCandidateUIDs(),
|
||||
GeneratedAt: payload.GeneratedAt,
|
||||
Subnets: make([]TrafficCandidateSubnet, 0, len(payload.Subnets)),
|
||||
Units: make([]TrafficCandidateUnit, 0, len(payload.Units)),
|
||||
UIDs: make([]TrafficCandidateUID, 0, len(payload.UIDs)),
|
||||
}
|
||||
for _, it := range payload.Subnets {
|
||||
resp.Subnets = append(resp.Subnets, TrafficCandidateSubnet{
|
||||
CIDR: it.CIDR,
|
||||
Dev: it.Dev,
|
||||
Kind: it.Kind,
|
||||
LinkDown: it.LinkDown,
|
||||
})
|
||||
}
|
||||
for _, it := range payload.Units {
|
||||
resp.Units = append(resp.Units, TrafficCandidateUnit{
|
||||
Unit: it.Unit,
|
||||
Description: it.Description,
|
||||
Cgroup: it.Cgroup,
|
||||
})
|
||||
}
|
||||
for _, it := range payload.UIDs {
|
||||
resp.UIDs = append(resp.UIDs, TrafficCandidateUID{
|
||||
UID: it.UID,
|
||||
User: it.User,
|
||||
Examples: it.Examples,
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user