platform: modularize api/gui, add docs-tests-web foundation, and refresh root config
This commit is contained in:
115
selective-vpn-api/app/trafficmode/apply.go
Normal file
115
selective-vpn-api/app/trafficmode/apply.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package trafficmode
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type RunCommandSimpleFunc func(name string, args ...string) (stdout string, stderr string, code int, err error)
|
||||
|
||||
type OverrideConfig struct {
|
||||
RoutesTableName string
|
||||
RulePerKindLimit int
|
||||
PrefManagedMin int
|
||||
PrefManagedMax int
|
||||
PrefDirectSubnetBase int
|
||||
PrefDirectUIDBase int
|
||||
PrefVPNSubnetBase int
|
||||
PrefVPNUIDBase int
|
||||
}
|
||||
|
||||
type EffectiveOverrides struct {
|
||||
VPNSubnets []string
|
||||
VPNUIDs []string
|
||||
DirectSubnets []string
|
||||
DirectUIDs []string
|
||||
}
|
||||
|
||||
func RemoveRulesForTable(cfg OverrideConfig, run RunCommandSimpleFunc) {
|
||||
if run == nil {
|
||||
return
|
||||
}
|
||||
out, _, _, _ := run("ip", "rule", "show")
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
pref := strings.TrimSuffix(fields[0], ":")
|
||||
if pref == "" {
|
||||
continue
|
||||
}
|
||||
prefNum, _ := strconv.Atoi(pref)
|
||||
low := strings.ToLower(line)
|
||||
managed := prefNum >= cfg.PrefManagedMin && prefNum <= cfg.PrefManagedMax
|
||||
legacy := strings.Contains(low, "lookup "+cfg.RoutesTableName)
|
||||
if !managed && !legacy {
|
||||
continue
|
||||
}
|
||||
_, _, _, _ = run("ip", "rule", "del", "pref", pref)
|
||||
}
|
||||
}
|
||||
|
||||
func ApplyRule(pref int, run RunCommandSimpleFunc, args ...string) error {
|
||||
if run == nil {
|
||||
return fmt.Errorf("run command func is nil")
|
||||
}
|
||||
if pref <= 0 {
|
||||
return fmt.Errorf("invalid pref: %d", pref)
|
||||
}
|
||||
cmd := []string{"rule", "add"}
|
||||
cmd = append(cmd, args...)
|
||||
cmd = append(cmd, "pref", PrefStr(pref))
|
||||
_, _, code, err := run("ip", cmd...)
|
||||
if err != nil || code != 0 {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("ip %s exited with %d", strings.Join(cmd, " "), code)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ApplyOverrides(cfg OverrideConfig, e EffectiveOverrides, applyRule func(pref int, args ...string) error) (int, error) {
|
||||
applied := 0
|
||||
if applyRule == nil {
|
||||
return 0, fmt.Errorf("applyRule callback is nil")
|
||||
}
|
||||
if len(e.DirectSubnets) > cfg.RulePerKindLimit ||
|
||||
len(e.DirectUIDs) > cfg.RulePerKindLimit ||
|
||||
len(e.VPNSubnets) > cfg.RulePerKindLimit ||
|
||||
len(e.VPNUIDs) > cfg.RulePerKindLimit {
|
||||
return 0, fmt.Errorf("override list too large (max %d entries per kind)", cfg.RulePerKindLimit)
|
||||
}
|
||||
|
||||
for i, cidr := range e.DirectSubnets {
|
||||
if err := applyRule(cfg.PrefDirectSubnetBase+i, "from", cidr, "lookup", "main"); err != nil {
|
||||
return applied, err
|
||||
}
|
||||
applied++
|
||||
}
|
||||
for i, uidr := range e.DirectUIDs {
|
||||
if err := applyRule(cfg.PrefDirectUIDBase+i, "uidrange", uidr, "lookup", "main"); err != nil {
|
||||
return applied, err
|
||||
}
|
||||
applied++
|
||||
}
|
||||
for i, cidr := range e.VPNSubnets {
|
||||
if err := applyRule(cfg.PrefVPNSubnetBase+i, "from", cidr, "lookup", cfg.RoutesTableName); err != nil {
|
||||
return applied, err
|
||||
}
|
||||
applied++
|
||||
}
|
||||
for i, uidr := range e.VPNUIDs {
|
||||
if err := applyRule(cfg.PrefVPNUIDBase+i, "uidrange", uidr, "lookup", cfg.RoutesTableName); err != nil {
|
||||
return applied, err
|
||||
}
|
||||
applied++
|
||||
}
|
||||
return applied, nil
|
||||
}
|
||||
128
selective-vpn-api/app/trafficmode/autolocal.go
Normal file
128
selective-vpn-api/app/trafficmode/autolocal.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package trafficmode
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var cgnatPrefix = netip.MustParsePrefix("100.64.0.0/10")
|
||||
|
||||
type AutoLocalRoute struct {
|
||||
Dst string
|
||||
Dev string
|
||||
}
|
||||
|
||||
func ParseAutoBypassRoutes(mainRoutes string, vpnIface string, isVPNLikeIface func(string) bool) []AutoLocalRoute {
|
||||
vpnIface = strings.TrimSpace(vpnIface)
|
||||
seen := map[string]struct{}{}
|
||||
routes := make([]AutoLocalRoute, 0, 8)
|
||||
|
||||
add := func(dst, dev string) {
|
||||
dst = strings.TrimSpace(dst)
|
||||
dev = strings.TrimSpace(dev)
|
||||
if dst == "" || dev == "" {
|
||||
return
|
||||
}
|
||||
key := dst + "|" + dev
|
||||
if _, ok := seen[key]; ok {
|
||||
return
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
routes = append(routes, AutoLocalRoute{Dst: dst, Dev: dev})
|
||||
}
|
||||
|
||||
for _, raw := range strings.Split(mainRoutes, "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if RouteLineIsLinkDown(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 vpnIface != "" && dev == vpnIface {
|
||||
continue
|
||||
}
|
||||
if isVPNLikeIface != nil && isVPNLikeIface(dev) {
|
||||
continue
|
||||
}
|
||||
|
||||
isScopeLink := strings.Contains(" "+line+" ", " scope link ")
|
||||
if isScopeLink || IsContainerIface(dev) || IsAutoBypassDestination(dst) {
|
||||
add(dst, dev)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(routes, func(i, j int) bool {
|
||||
if routes[i].Dev == routes[j].Dev {
|
||||
return routes[i].Dst < routes[j].Dst
|
||||
}
|
||||
return routes[i].Dev < routes[j].Dev
|
||||
})
|
||||
return routes
|
||||
}
|
||||
|
||||
func ParseRouteDevice(fields []string) string {
|
||||
for i := 0; i+1 < len(fields); i++ {
|
||||
if fields[i] == "dev" {
|
||||
return strings.TrimSpace(fields[i+1])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func IsContainerIface(iface string) bool {
|
||||
l := strings.ToLower(strings.TrimSpace(iface))
|
||||
return strings.HasPrefix(l, "docker") ||
|
||||
strings.HasPrefix(l, "br-") ||
|
||||
strings.HasPrefix(l, "veth") ||
|
||||
strings.HasPrefix(l, "svh") ||
|
||||
strings.HasPrefix(l, "svn") ||
|
||||
strings.HasPrefix(l, "cni")
|
||||
}
|
||||
|
||||
func RouteLineIsLinkDown(line string) bool {
|
||||
l := " " + strings.ToLower(strings.TrimSpace(line)) + " "
|
||||
return strings.Contains(l, " linkdown ")
|
||||
}
|
||||
|
||||
func IsPrivateLikeAddr(a netip.Addr) bool {
|
||||
if !a.Is4() {
|
||||
return false
|
||||
}
|
||||
if a.IsPrivate() || a.IsLoopback() || a.IsLinkLocalUnicast() {
|
||||
return true
|
||||
}
|
||||
return cgnatPrefix.Contains(a)
|
||||
}
|
||||
|
||||
func IsAutoBypassDestination(dst string) bool {
|
||||
dst = strings.TrimSpace(dst)
|
||||
if dst == "" || dst == "default" {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(dst, "/") {
|
||||
pfx, err := netip.ParsePrefix(dst)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return IsPrivateLikeAddr(pfx.Addr())
|
||||
}
|
||||
addr, err := netip.ParseAddr(dst)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return IsPrivateLikeAddr(addr)
|
||||
}
|
||||
163
selective-vpn-api/app/trafficmode/cgroup.go
Normal file
163
selective-vpn-api/app/trafficmode/cgroup.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package trafficmode
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func CgroupCandidates(entry string, cgroupRootPath string) []string {
|
||||
v := strings.TrimSpace(entry)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
vc := filepath.Clean(v)
|
||||
vals := []string{}
|
||||
if filepath.IsAbs(vc) {
|
||||
if strings.HasPrefix(vc, cgroupRootPath) {
|
||||
vals = append(vals, vc)
|
||||
} else {
|
||||
vals = append(vals, filepath.Join(cgroupRootPath, strings.TrimPrefix(vc, "/")))
|
||||
}
|
||||
} else {
|
||||
vals = append(vals,
|
||||
filepath.Join(cgroupRootPath, strings.TrimPrefix(vc, "/")),
|
||||
filepath.Join(cgroupRootPath, "system.slice", strings.TrimPrefix(vc, "/")),
|
||||
filepath.Join(cgroupRootPath, "user.slice", strings.TrimPrefix(vc, "/")),
|
||||
)
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]string, 0, len(vals))
|
||||
for _, p := range vals {
|
||||
cp := filepath.Clean(p)
|
||||
if cp == "." || cp == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[cp]; ok {
|
||||
continue
|
||||
}
|
||||
seen[cp] = struct{}{}
|
||||
out = append(out, cp)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ResolveCgroupPath(entry string, cgroupRootPath string) (string, string) {
|
||||
for _, cand := range CgroupCandidates(entry, cgroupRootPath) {
|
||||
fi, err := os.Stat(cand)
|
||||
if err != nil || !fi.IsDir() {
|
||||
continue
|
||||
}
|
||||
return cand, ""
|
||||
}
|
||||
return "", "cgroup not found: " + strings.TrimSpace(entry)
|
||||
}
|
||||
|
||||
func CollectPIDsFromCgroup(root string) (map[int]struct{}, string) {
|
||||
const (
|
||||
maxDirs = 5000
|
||||
maxPIDs = 50000
|
||||
)
|
||||
|
||||
pids := map[int]struct{}{}
|
||||
dirs := 0
|
||||
warn := ""
|
||||
|
||||
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil || d == nil || !d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
dirs++
|
||||
if dirs > maxDirs {
|
||||
warn = "cgroup scan truncated by directory limit"
|
||||
return filepath.SkipDir
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(path, "cgroup.procs"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
for _, ln := range strings.Split(string(data), "\n") {
|
||||
ln = strings.TrimSpace(ln)
|
||||
if ln == "" {
|
||||
continue
|
||||
}
|
||||
pid, err := strconv.Atoi(ln)
|
||||
if err != nil || pid <= 0 {
|
||||
continue
|
||||
}
|
||||
pids[pid] = struct{}{}
|
||||
if len(pids) > maxPIDs {
|
||||
warn = "cgroup scan truncated by pid limit"
|
||||
return filepath.SkipDir
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return pids, warn
|
||||
}
|
||||
|
||||
func UIDRangeForPID(pid int) (string, bool) {
|
||||
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid))
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
for _, ln := range strings.Split(string(data), "\n") {
|
||||
ln = strings.TrimSpace(ln)
|
||||
if !strings.HasPrefix(ln, "Uid:") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(ln)
|
||||
if len(fields) < 2 {
|
||||
return "", false
|
||||
}
|
||||
v, ok := NormalizeUIDToken(fields[1])
|
||||
return v, ok
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func ResolveCgroupUIDRanges(entries []string, cgroupRootPath string) ([]string, string) {
|
||||
var uids []string
|
||||
var warnings []string
|
||||
|
||||
for _, entry := range NormalizeCgroupList(entries) {
|
||||
root, warn := ResolveCgroupPath(entry, cgroupRootPath)
|
||||
if root == "" {
|
||||
if warn != "" {
|
||||
warnings = append(warnings, warn)
|
||||
}
|
||||
continue
|
||||
}
|
||||
pids, scanWarn := CollectPIDsFromCgroup(root)
|
||||
if scanWarn != "" {
|
||||
warnings = append(warnings, scanWarn)
|
||||
}
|
||||
if len(pids) == 0 {
|
||||
warnings = append(warnings, "cgroup has no processes: "+entry)
|
||||
continue
|
||||
}
|
||||
for pid := range pids {
|
||||
uidRange, ok := UIDRangeForPID(pid)
|
||||
if !ok || uidRange == "" {
|
||||
continue
|
||||
}
|
||||
uids = append(uids, uidRange)
|
||||
}
|
||||
}
|
||||
seenWarn := map[string]struct{}{}
|
||||
uniqWarn := make([]string, 0, len(warnings))
|
||||
for _, w := range warnings {
|
||||
ww := strings.TrimSpace(w)
|
||||
if ww == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seenWarn[ww]; ok {
|
||||
continue
|
||||
}
|
||||
seenWarn[ww] = struct{}{}
|
||||
uniqWarn = append(uniqWarn, ww)
|
||||
}
|
||||
return NormalizeUIDList(uids), strings.Join(uniqWarn, "; ")
|
||||
}
|
||||
134
selective-vpn-api/app/trafficmode/ingress.go
Normal file
134
selective-vpn-api/app/trafficmode/ingress.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package trafficmode
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RunCommandTimeoutFunc func(timeout time.Duration, name string, args ...string) (stdout string, stderr string, code int, err error)
|
||||
|
||||
type IngressBypassConfig struct {
|
||||
TableName string
|
||||
PreroutingChain string
|
||||
OutputChain string
|
||||
MarkIngress string
|
||||
CaptureComment string
|
||||
RestoreComment string
|
||||
}
|
||||
|
||||
func NftObjectMissing(stdout, stderr string) bool {
|
||||
text := strings.ToLower(strings.TrimSpace(stdout + " " + stderr))
|
||||
return strings.Contains(text, "no such file") || strings.Contains(text, "not found")
|
||||
}
|
||||
|
||||
func EnsureIngressReplyBypassChains(cfg IngressBypassConfig, run RunCommandTimeoutFunc) {
|
||||
if run == nil {
|
||||
return
|
||||
}
|
||||
_, _, _, _ = run(5*time.Second, "nft", "add", "table", "inet", cfg.TableName)
|
||||
_, _, _, _ = run(
|
||||
5*time.Second,
|
||||
"nft", "add", "chain", "inet", cfg.TableName, cfg.PreroutingChain,
|
||||
"{", "type", "filter", "hook", "prerouting", "priority", "mangle;", "policy", "accept;", "}",
|
||||
)
|
||||
_, _, _, _ = run(
|
||||
5*time.Second,
|
||||
"nft", "add", "chain", "inet", cfg.TableName, cfg.OutputChain,
|
||||
"{", "type", "route", "hook", "output", "priority", "mangle;", "policy", "accept;", "}",
|
||||
)
|
||||
}
|
||||
|
||||
func FlushIngressReplyBypassChains(cfg IngressBypassConfig, run RunCommandTimeoutFunc) error {
|
||||
if run == nil {
|
||||
return fmt.Errorf("run command func is nil")
|
||||
}
|
||||
for _, chain := range []string{cfg.PreroutingChain, cfg.OutputChain} {
|
||||
out, errOut, code, err := run(5*time.Second, "nft", "flush", "chain", "inet", cfg.TableName, chain)
|
||||
if err == nil && code == 0 {
|
||||
continue
|
||||
}
|
||||
if NftObjectMissing(out, errOut) {
|
||||
continue
|
||||
}
|
||||
if err == nil {
|
||||
err = fmt.Errorf("nft flush chain exited with %d", code)
|
||||
}
|
||||
return fmt.Errorf("flush %s failed: %w (%s %s)", chain, err, strings.TrimSpace(out), strings.TrimSpace(errOut))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func EnableIngressReplyBypass(cfg IngressBypassConfig, vpnIface string, run RunCommandTimeoutFunc) error {
|
||||
if run == nil {
|
||||
return fmt.Errorf("run command func is nil")
|
||||
}
|
||||
vpnIface = strings.TrimSpace(vpnIface)
|
||||
if vpnIface == "" {
|
||||
return fmt.Errorf("empty vpn iface for ingress bypass")
|
||||
}
|
||||
|
||||
EnsureIngressReplyBypassChains(cfg, run)
|
||||
if err := FlushIngressReplyBypassChains(cfg, run); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
addRule := func(chain string, args ...string) error {
|
||||
out, errOut, code, err := run(5*time.Second, "nft", append([]string{"add", "rule", "inet", cfg.TableName, chain}, args...)...)
|
||||
if err != nil || code != 0 {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("nft add rule exited with %d", code)
|
||||
}
|
||||
return fmt.Errorf("nft add rule %s failed: %w (%s %s)", chain, err, strings.TrimSpace(out), strings.TrimSpace(errOut))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := addRule(
|
||||
cfg.PreroutingChain,
|
||||
"iifname", "!=", "lo",
|
||||
"iifname", "!=", vpnIface,
|
||||
"fib", "daddr", "type", "local",
|
||||
"ct", "state", "new",
|
||||
"ct", "mark", "set", cfg.MarkIngress,
|
||||
"comment", cfg.CaptureComment,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := addRule(
|
||||
cfg.PreroutingChain,
|
||||
"ct", "mark", cfg.MarkIngress,
|
||||
"meta", "mark", "set", cfg.MarkIngress,
|
||||
"comment", cfg.RestoreComment,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := addRule(
|
||||
cfg.OutputChain,
|
||||
"ct", "mark", cfg.MarkIngress,
|
||||
"meta", "mark", "set", cfg.MarkIngress,
|
||||
"comment", cfg.RestoreComment,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DisableIngressReplyBypass(cfg IngressBypassConfig, run RunCommandTimeoutFunc) error {
|
||||
EnsureIngressReplyBypassChains(cfg, run)
|
||||
return FlushIngressReplyBypassChains(cfg, run)
|
||||
}
|
||||
|
||||
func IngressReplyNftActive(cfg IngressBypassConfig, run RunCommandTimeoutFunc) bool {
|
||||
if run == nil {
|
||||
return false
|
||||
}
|
||||
outPre, _, codePre, _ := run(5*time.Second, "nft", "-a", "list", "chain", "inet", cfg.TableName, cfg.PreroutingChain)
|
||||
outOut, _, codeOut, _ := run(5*time.Second, "nft", "-a", "list", "chain", "inet", cfg.TableName, cfg.OutputChain)
|
||||
if codePre != 0 || codeOut != 0 {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(outPre, cfg.CaptureComment) &&
|
||||
strings.Contains(outPre, cfg.RestoreComment) &&
|
||||
strings.Contains(outOut, cfg.RestoreComment)
|
||||
}
|
||||
156
selective-vpn-api/app/trafficmode/interfaces.go
Normal file
156
selective-vpn-api/app/trafficmode/interfaces.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package trafficmode
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func NormalizePreferredIface(raw string) string {
|
||||
v := strings.TrimSpace(raw)
|
||||
l := strings.ToLower(v)
|
||||
if l == "" || l == "auto" || l == "-" || l == "default" {
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func IfaceExists(iface string, run RunCommandSimpleFunc) bool {
|
||||
iface = strings.TrimSpace(iface)
|
||||
if iface == "" || run == nil {
|
||||
return false
|
||||
}
|
||||
_, _, code, _ := run("ip", "link", "show", iface)
|
||||
return code == 0
|
||||
}
|
||||
|
||||
func ParseUpIfaces(ipLinkOutput string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
outIfaces := make([]string, 0, 8)
|
||||
for _, line := range strings.Split(ipLinkOutput, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, ":", 3)
|
||||
if len(parts) < 3 {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(parts[1])
|
||||
name = strings.SplitN(name, "@", 2)[0]
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || name == "lo" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[name]; ok {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
outIfaces = append(outIfaces, name)
|
||||
}
|
||||
return outIfaces
|
||||
}
|
||||
|
||||
func ListUpIfaces(run RunCommandSimpleFunc) []string {
|
||||
if run == nil {
|
||||
return nil
|
||||
}
|
||||
out, _, code, _ := run("ip", "-o", "link", "show", "up")
|
||||
if code != 0 {
|
||||
return nil
|
||||
}
|
||||
return ParseUpIfaces(out)
|
||||
}
|
||||
|
||||
func IsVPNLikeIface(iface string) bool {
|
||||
l := strings.ToLower(strings.TrimSpace(iface))
|
||||
return strings.HasPrefix(l, "tun") ||
|
||||
strings.HasPrefix(l, "wg") ||
|
||||
strings.HasPrefix(l, "ppp") ||
|
||||
strings.HasPrefix(l, "tap") ||
|
||||
strings.HasPrefix(l, "utun") ||
|
||||
strings.HasPrefix(l, "vpn")
|
||||
}
|
||||
|
||||
func ListSelectableIfaces(up []string, preferred string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
var vpnLike []string
|
||||
var other []string
|
||||
|
||||
add := func(dst *[]string, iface string) {
|
||||
iface = strings.TrimSpace(iface)
|
||||
if iface == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[iface]; ok {
|
||||
return
|
||||
}
|
||||
seen[iface] = struct{}{}
|
||||
*dst = append(*dst, iface)
|
||||
}
|
||||
|
||||
for _, iface := range up {
|
||||
if IsVPNLikeIface(iface) {
|
||||
add(&vpnLike, iface)
|
||||
}
|
||||
}
|
||||
for _, iface := range up {
|
||||
if !IsVPNLikeIface(iface) {
|
||||
add(&other, iface)
|
||||
}
|
||||
}
|
||||
sort.Strings(vpnLike)
|
||||
sort.Strings(other)
|
||||
|
||||
selected := make([]string, 0, len(vpnLike)+len(other)+1)
|
||||
selected = append(selected, vpnLike...)
|
||||
selected = append(selected, other...)
|
||||
|
||||
pref := NormalizePreferredIface(preferred)
|
||||
if pref != "" {
|
||||
if _, ok := seen[pref]; !ok {
|
||||
selected = append([]string{pref}, selected...)
|
||||
}
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
func ResolveTrafficIface(
|
||||
preferred string,
|
||||
ifaceExists func(string) bool,
|
||||
statusIface func() string,
|
||||
listUp func() []string,
|
||||
) (string, string) {
|
||||
exists := ifaceExists
|
||||
if exists == nil {
|
||||
exists = func(string) bool { return false }
|
||||
}
|
||||
status := statusIface
|
||||
if status == nil {
|
||||
status = func() string { return "" }
|
||||
}
|
||||
upIfaces := listUp
|
||||
if upIfaces == nil {
|
||||
upIfaces = func() []string { return nil }
|
||||
}
|
||||
|
||||
pref := NormalizePreferredIface(preferred)
|
||||
if pref != "" && exists(pref) {
|
||||
return pref, "preferred"
|
||||
}
|
||||
|
||||
statusResolved := strings.TrimSpace(status())
|
||||
if statusResolved != "" && exists(statusResolved) {
|
||||
return statusResolved, "status"
|
||||
}
|
||||
|
||||
for _, iface := range upIfaces() {
|
||||
if IsVPNLikeIface(iface) {
|
||||
return iface, "auto-vpn-like"
|
||||
}
|
||||
}
|
||||
|
||||
if pref != "" {
|
||||
return "", "preferred-not-found"
|
||||
}
|
||||
return "", "iface-not-found"
|
||||
}
|
||||
122
selective-vpn-api/app/trafficmode/normalize.go
Normal file
122
selective-vpn-api/app/trafficmode/normalize.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package trafficmode
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func TokenizeList(raw []string) []string {
|
||||
repl := strings.NewReplacer(",", " ", ";", " ", "\n", " ", "\t", " ")
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, line := range raw {
|
||||
for _, tok := range strings.Fields(repl.Replace(line)) {
|
||||
val := strings.TrimSpace(tok)
|
||||
if val != "" {
|
||||
out = append(out, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func NormalizeSubnetList(raw []string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, tok := range TokenizeList(raw) {
|
||||
var cidr string
|
||||
if strings.Contains(tok, "/") {
|
||||
pfx, err := netip.ParsePrefix(tok)
|
||||
if err != nil || !pfx.Addr().Is4() {
|
||||
continue
|
||||
}
|
||||
cidr = pfx.Masked().String()
|
||||
} else {
|
||||
ip, err := netip.ParseAddr(tok)
|
||||
if err != nil || !ip.Is4() {
|
||||
continue
|
||||
}
|
||||
cidr = netip.PrefixFrom(ip, 32).String()
|
||||
}
|
||||
if _, ok := seen[cidr]; ok {
|
||||
continue
|
||||
}
|
||||
seen[cidr] = struct{}{}
|
||||
out = append(out, cidr)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func NormalizeUIDToken(tok string) (string, bool) {
|
||||
t := strings.TrimSpace(tok)
|
||||
if t == "" {
|
||||
return "", false
|
||||
}
|
||||
parseOne := func(s string) (uint64, bool) {
|
||||
n, err := strconv.ParseUint(strings.TrimSpace(s), 10, 32)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
if strings.Contains(t, "-") {
|
||||
parts := strings.SplitN(t, "-", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", false
|
||||
}
|
||||
start, okA := parseOne(parts[0])
|
||||
end, okB := parseOne(parts[1])
|
||||
if !okA || !okB || end < start {
|
||||
return "", false
|
||||
}
|
||||
return fmt.Sprintf("%d-%d", start, end), true
|
||||
}
|
||||
n, ok := parseOne(t)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return fmt.Sprintf("%d-%d", n, n), true
|
||||
}
|
||||
|
||||
func NormalizeUIDList(raw []string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, tok := range TokenizeList(raw) {
|
||||
v, ok := NormalizeUIDToken(tok)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[v]; exists {
|
||||
continue
|
||||
}
|
||||
seen[v] = struct{}{}
|
||||
out = append(out, v)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func NormalizeCgroupList(raw []string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, tok := range TokenizeList(raw) {
|
||||
v := strings.TrimSpace(tok)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
v = strings.TrimSuffix(v, "/")
|
||||
if v == "" {
|
||||
v = "/"
|
||||
}
|
||||
if _, exists := seen[v]; exists {
|
||||
continue
|
||||
}
|
||||
seen[v] = struct{}{}
|
||||
out = append(out, v)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
116
selective-vpn-api/app/trafficmode/rules.go
Normal file
116
selective-vpn-api/app/trafficmode/rules.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package trafficmode
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type RunCommandFunc func(name string, args ...string) (stdout string, stderr string, code int, err error)
|
||||
|
||||
type RulesConfig struct {
|
||||
RoutesTableName string
|
||||
Mark string
|
||||
MarkIngress string
|
||||
PrefSelective int
|
||||
PrefFull int
|
||||
PrefMarkIngressReply int
|
||||
ModeFull string
|
||||
ModeSelective string
|
||||
ModeDirect string
|
||||
}
|
||||
|
||||
type RulesState struct {
|
||||
Mark bool
|
||||
Full bool
|
||||
IngressReply bool
|
||||
}
|
||||
|
||||
func PrefStr(v int) string {
|
||||
return strconv.Itoa(v)
|
||||
}
|
||||
|
||||
func ReadRules(cfg RulesConfig, run RunCommandFunc) RulesState {
|
||||
if run == nil {
|
||||
return RulesState{}
|
||||
}
|
||||
out, _, _, _ := run("ip", "rule", "show")
|
||||
var st RulesState
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
l := strings.ToLower(strings.TrimSpace(line))
|
||||
if l == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(l)
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
prefRaw := strings.TrimSuffix(fields[0], ":")
|
||||
pref, _ := strconv.Atoi(prefRaw)
|
||||
switch pref {
|
||||
case cfg.PrefSelective:
|
||||
if strings.Contains(l, "lookup "+cfg.RoutesTableName) {
|
||||
st.Mark = true
|
||||
}
|
||||
case cfg.PrefFull:
|
||||
if strings.Contains(l, "lookup "+cfg.RoutesTableName) {
|
||||
st.Full = true
|
||||
}
|
||||
case cfg.PrefMarkIngressReply:
|
||||
if strings.Contains(l, "fwmark "+strings.ToLower(cfg.MarkIngress)) && strings.Contains(l, "lookup main") {
|
||||
st.IngressReply = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func DetectAppliedMode(cfg RulesConfig, rules RulesState) string {
|
||||
if rules.Full {
|
||||
return cfg.ModeFull
|
||||
}
|
||||
if rules.Mark {
|
||||
return cfg.ModeSelective
|
||||
}
|
||||
return cfg.ModeDirect
|
||||
}
|
||||
|
||||
func ProbeMode(cfg RulesConfig, mode string, iface string, run RunCommandFunc) (bool, string) {
|
||||
if run == nil {
|
||||
return false, "run command func is nil"
|
||||
}
|
||||
mode = strings.ToLower(strings.TrimSpace(mode))
|
||||
iface = strings.TrimSpace(iface)
|
||||
|
||||
args := []string{"-4", "route", "get", "1.1.1.1"}
|
||||
if mode == strings.ToLower(cfg.ModeSelective) {
|
||||
args = append(args, "mark", cfg.Mark)
|
||||
}
|
||||
|
||||
out, _, code, err := run("ip", args...)
|
||||
if err != nil || code != 0 {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("ip route get exited with %d", code)
|
||||
}
|
||||
return false, err.Error()
|
||||
}
|
||||
|
||||
text := strings.ToLower(out)
|
||||
switch mode {
|
||||
case strings.ToLower(cfg.ModeDirect):
|
||||
if strings.Contains(text, " table "+strings.ToLower(cfg.RoutesTableName)) {
|
||||
return false, "route probe still uses agvpn table"
|
||||
}
|
||||
return true, "route probe direct path ok"
|
||||
case strings.ToLower(cfg.ModeFull), strings.ToLower(cfg.ModeSelective):
|
||||
if iface == "" {
|
||||
return false, "route probe has empty iface"
|
||||
}
|
||||
if !strings.Contains(text, "dev "+strings.ToLower(iface)) {
|
||||
return false, fmt.Sprintf("route probe mismatch: expected dev %s", iface)
|
||||
}
|
||||
return true, "route probe vpn path ok"
|
||||
default:
|
||||
return false, "route probe unknown mode"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user