1440 lines
41 KiB
Go
1440 lines
41 KiB
Go
package app
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/netip"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
trafficRulePrefMarkDirect = 11500
|
|
trafficRulePrefMarkIngressReply = 11505
|
|
trafficRulePrefMarkAppVPN = 11510
|
|
trafficRulePrefDirectSubnetStart = 11600
|
|
trafficRulePrefDirectUIDStart = 11680
|
|
trafficRulePrefVPNSubnetStart = 11720
|
|
trafficRulePrefVPNUIDStart = 11800
|
|
trafficRulePrefFull = 11900
|
|
trafficRulePrefSelective = 12000
|
|
trafficRulePrefManagedMin = 11500
|
|
trafficRulePrefManagedMax = 12099
|
|
trafficRulePerKindLimit = 70
|
|
trafficAutoLocalDefault = true
|
|
trafficIngressReplyDefault = false
|
|
|
|
trafficIngressPreroutingChain = "prerouting_ingress_reply"
|
|
trafficIngressOutputChain = "output_ingress_reply"
|
|
|
|
trafficIngressCaptureComment = "svpn_ingress_reply_capture"
|
|
trafficIngressRestoreComment = "svpn_ingress_reply_restore"
|
|
)
|
|
|
|
var cgnatPrefix = netip.MustParsePrefix("100.64.0.0/10")
|
|
|
|
const cgroupRootPath = "/sys/fs/cgroup"
|
|
|
|
// ---------------------------------------------------------------------
|
|
// traffic mode (selective / full_tunnel / direct)
|
|
// ---------------------------------------------------------------------
|
|
|
|
// EN: Controls route-policy behavior independently from DNS mode.
|
|
// EN: Uses a persisted desired state with runtime verification and rollback.
|
|
// RU: Управляет policy routing независимо от DNS-режима.
|
|
// RU: Использует сохраненное desired-state, runtime-проверку и откат.
|
|
|
|
func normalizeTrafficMode(raw TrafficMode) TrafficMode {
|
|
switch strings.ToLower(strings.TrimSpace(string(raw))) {
|
|
case string(TrafficModeFullTunnel):
|
|
return TrafficModeFullTunnel
|
|
case string(TrafficModeDirect):
|
|
return TrafficModeDirect
|
|
case string(TrafficModeSelective):
|
|
return TrafficModeSelective
|
|
default:
|
|
return TrafficModeSelective
|
|
}
|
|
}
|
|
|
|
func normalizePreferredIface(raw string) string {
|
|
v := strings.TrimSpace(raw)
|
|
l := strings.ToLower(v)
|
|
if l == "" || l == "auto" || l == "-" || l == "default" {
|
|
return ""
|
|
}
|
|
return v
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func normalizeTrafficModeState(st TrafficModeState) TrafficModeState {
|
|
st.Mode = normalizeTrafficMode(st.Mode)
|
|
st.PreferredIface = normalizePreferredIface(st.PreferredIface)
|
|
st.ForceVPNSubnets = normalizeSubnetList(st.ForceVPNSubnets)
|
|
st.ForceVPNUIDs = normalizeUIDList(st.ForceVPNUIDs)
|
|
st.ForceVPNCGroups = normalizeCgroupList(st.ForceVPNCGroups)
|
|
st.ForceDirectSubnets = normalizeSubnetList(st.ForceDirectSubnets)
|
|
st.ForceDirectUIDs = normalizeUIDList(st.ForceDirectUIDs)
|
|
st.ForceDirectCGroups = normalizeCgroupList(st.ForceDirectCGroups)
|
|
return st
|
|
}
|
|
|
|
func loadTrafficModeState() TrafficModeState {
|
|
data, err := os.ReadFile(trafficModePath)
|
|
if err != nil {
|
|
return inferTrafficModeState()
|
|
}
|
|
|
|
type diskState struct {
|
|
Mode TrafficMode `json:"mode"`
|
|
PreferredIface string `json:"preferred_iface,omitempty"`
|
|
AutoLocalBypass *bool `json:"auto_local_bypass,omitempty"`
|
|
IngressReplyBypass *bool `json:"ingress_reply_bypass,omitempty"`
|
|
ForceVPNSubnets []string `json:"force_vpn_subnets,omitempty"`
|
|
ForceVPNUIDs []string `json:"force_vpn_uids,omitempty"`
|
|
ForceVPNCGroups []string `json:"force_vpn_cgroups,omitempty"`
|
|
ForceDirectSubnets []string `json:"force_direct_subnets,omitempty"`
|
|
ForceDirectUIDs []string `json:"force_direct_uids,omitempty"`
|
|
ForceDirectCGroups []string `json:"force_direct_cgroups,omitempty"`
|
|
}
|
|
var raw diskState
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
return inferTrafficModeState()
|
|
}
|
|
st := TrafficModeState{
|
|
Mode: raw.Mode,
|
|
PreferredIface: raw.PreferredIface,
|
|
AutoLocalBypass: trafficAutoLocalDefault,
|
|
IngressReplyBypass: trafficIngressReplyDefault,
|
|
ForceVPNSubnets: append([]string(nil), raw.ForceVPNSubnets...),
|
|
ForceVPNUIDs: append([]string(nil), raw.ForceVPNUIDs...),
|
|
ForceVPNCGroups: append([]string(nil), raw.ForceVPNCGroups...),
|
|
ForceDirectSubnets: append([]string(nil), raw.ForceDirectSubnets...),
|
|
ForceDirectUIDs: append([]string(nil), raw.ForceDirectUIDs...),
|
|
ForceDirectCGroups: append([]string(nil), raw.ForceDirectCGroups...),
|
|
}
|
|
if raw.AutoLocalBypass != nil {
|
|
st.AutoLocalBypass = *raw.AutoLocalBypass
|
|
}
|
|
if raw.IngressReplyBypass != nil {
|
|
st.IngressReplyBypass = *raw.IngressReplyBypass
|
|
}
|
|
return normalizeTrafficModeState(st)
|
|
}
|
|
|
|
func saveTrafficModeState(st TrafficModeState) error {
|
|
st = normalizeTrafficModeState(st)
|
|
st.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
|
|
|
data, err := json.MarshalIndent(st, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(stateDir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
tmp := trafficModePath + ".tmp"
|
|
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, trafficModePath)
|
|
}
|
|
|
|
func inferTrafficModeState() TrafficModeState {
|
|
rules := readTrafficRules()
|
|
mode := detectAppliedTrafficMode(rules)
|
|
iface, _ := resolveTrafficIface("")
|
|
return normalizeTrafficModeState(TrafficModeState{
|
|
Mode: mode,
|
|
PreferredIface: iface,
|
|
AutoLocalBypass: trafficAutoLocalDefault,
|
|
IngressReplyBypass: trafficIngressReplyDefault,
|
|
ForceVPNSubnets: nil,
|
|
ForceVPNUIDs: nil,
|
|
ForceVPNCGroups: nil,
|
|
ForceDirectSubnets: nil,
|
|
ForceDirectUIDs: nil,
|
|
ForceDirectCGroups: nil,
|
|
})
|
|
}
|
|
|
|
func ensureRoutesTableEntry() {
|
|
data, _ := os.ReadFile("/etc/iproute2/rt_tables")
|
|
want := fmt.Sprintf("%s %s", routesTableNum(), routesTableName())
|
|
if strings.Contains(string(data), "\n"+want) || strings.HasPrefix(string(data), want) {
|
|
return
|
|
}
|
|
f, err := os.OpenFile("/etc/iproute2/rt_tables", os.O_APPEND|os.O_WRONLY, 0o644)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer f.Close()
|
|
_, _ = fmt.Fprintf(f, "%s\n", want)
|
|
}
|
|
|
|
func ifaceExists(iface string) bool {
|
|
iface = strings.TrimSpace(iface)
|
|
if iface == "" {
|
|
return false
|
|
}
|
|
_, _, code, _ := runCommand("ip", "link", "show", iface)
|
|
return code == 0
|
|
}
|
|
|
|
func statusIfaceFromFile() string {
|
|
data, err := os.ReadFile(statusFilePath)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
var st Status
|
|
if json.Unmarshal(data, &st) != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(st.Iface)
|
|
}
|
|
|
|
func listUpIfaces() []string {
|
|
out, _, code, _ := runCommand("ip", "-o", "link", "show", "up")
|
|
if code != 0 {
|
|
return nil
|
|
}
|
|
seen := map[string]struct{}{}
|
|
var outIfaces []string
|
|
for _, line := range strings.Split(out, "\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 listSelectableIfaces(preferred string) []string {
|
|
up := listUpIfaces()
|
|
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 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 resolveTrafficIface(preferred string) (string, string) {
|
|
pref := normalizePreferredIface(preferred)
|
|
if pref != "" && ifaceExists(pref) {
|
|
return pref, "preferred"
|
|
}
|
|
|
|
statusIface := statusIfaceFromFile()
|
|
if statusIface != "" && ifaceExists(statusIface) {
|
|
return statusIface, "status"
|
|
}
|
|
|
|
for _, iface := range listUpIfaces() {
|
|
if isVPNLikeIface(iface) {
|
|
return iface, "auto-vpn-like"
|
|
}
|
|
}
|
|
|
|
if pref != "" {
|
|
return "", "preferred-not-found"
|
|
}
|
|
return "", "iface-not-found"
|
|
}
|
|
|
|
type autoLocalRoute struct {
|
|
Dst string
|
|
Dev string
|
|
}
|
|
|
|
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, "cni")
|
|
}
|
|
|
|
func isPrivateLikeAddr(a netip.Addr) bool {
|
|
if !a.Is4() {
|
|
return false
|
|
}
|
|
if a.IsPrivate() || a.IsLoopback() || a.IsLinkLocalUnicast() {
|
|
return true
|
|
}
|
|
// Carrier-grade NAT block.
|
|
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)
|
|
}
|
|
|
|
func detectAutoLocalBypassRoutes(vpnIface string) []autoLocalRoute {
|
|
vpnIface = strings.TrimSpace(vpnIface)
|
|
out, _, code, _ := runCommand("ip", "-4", "route", "show", "table", "main")
|
|
if code != 0 {
|
|
return nil
|
|
}
|
|
|
|
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(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 vpnIface != "" && dev == vpnIface {
|
|
continue
|
|
}
|
|
if 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 applyAutoLocalBypass(vpnIface string) {
|
|
for _, rt := range detectAutoLocalBypassRoutes(vpnIface) {
|
|
_, _, _, _ = runCommand(
|
|
"ip", "-4", "route", "replace",
|
|
rt.Dst, "dev", rt.Dev, "table", routesTableName(),
|
|
)
|
|
}
|
|
}
|
|
|
|
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() {
|
|
_, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "table", "inet", routesTableName())
|
|
_, _, _, _ = runCommandTimeout(
|
|
5*time.Second,
|
|
"nft", "add", "chain", "inet", routesTableName(), trafficIngressPreroutingChain,
|
|
"{", "type", "filter", "hook", "prerouting", "priority", "mangle;", "policy", "accept;", "}",
|
|
)
|
|
_, _, _, _ = runCommandTimeout(
|
|
5*time.Second,
|
|
"nft", "add", "chain", "inet", routesTableName(), trafficIngressOutputChain,
|
|
"{", "type", "route", "hook", "output", "priority", "mangle;", "policy", "accept;", "}",
|
|
)
|
|
}
|
|
|
|
func flushIngressReplyBypassChains() error {
|
|
for _, chain := range []string{trafficIngressPreroutingChain, trafficIngressOutputChain} {
|
|
out, errOut, code, err := runCommandTimeout(5*time.Second, "nft", "flush", "chain", "inet", routesTableName(), 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(vpnIface string) error {
|
|
vpnIface = strings.TrimSpace(vpnIface)
|
|
if vpnIface == "" {
|
|
return fmt.Errorf("empty vpn iface for ingress bypass")
|
|
}
|
|
|
|
ensureIngressReplyBypassChains()
|
|
if err := flushIngressReplyBypassChains(); err != nil {
|
|
return err
|
|
}
|
|
|
|
addRule := func(chain string, args ...string) error {
|
|
out, errOut, code, err := runCommandTimeout(5*time.Second, "nft", append([]string{"add", "rule", "inet", routesTableName(), 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
|
|
}
|
|
|
|
// EN: Mark inbound NEW connections (except loopback/VPN iface) so reply path can stay direct in full tunnel.
|
|
// RU: Помечаем входящие NEW-соединения (кроме loopback/VPN iface), чтобы ответ шел напрямую в full tunnel.
|
|
if err := addRule(
|
|
trafficIngressPreroutingChain,
|
|
"iifname", "!=", "lo",
|
|
"iifname", "!=", vpnIface,
|
|
"fib", "daddr", "type", "local",
|
|
"ct", "state", "new",
|
|
"ct", "mark", "set", MARK_INGRESS,
|
|
"comment", trafficIngressCaptureComment,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
// EN: Restore fwmark from ct mark in prerouting for forwarded reply traffic.
|
|
// RU: Восстанавливаем fwmark из ct mark в prerouting для forwarded-ответов.
|
|
if err := addRule(
|
|
trafficIngressPreroutingChain,
|
|
"ct", "mark", MARK_INGRESS,
|
|
"meta", "mark", "set", MARK_INGRESS,
|
|
"comment", trafficIngressRestoreComment,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
// EN: Restore fwmark from ct mark in output for local-process replies.
|
|
// RU: Восстанавливаем fwmark из ct mark в output для ответов локальных процессов.
|
|
if err := addRule(
|
|
trafficIngressOutputChain,
|
|
"ct", "mark", MARK_INGRESS,
|
|
"meta", "mark", "set", MARK_INGRESS,
|
|
"comment", trafficIngressRestoreComment,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func disableIngressReplyBypass() error {
|
|
ensureIngressReplyBypassChains()
|
|
return flushIngressReplyBypassChains()
|
|
}
|
|
|
|
func ingressReplyNftActive() bool {
|
|
outPre, _, codePre, _ := runCommandTimeout(5*time.Second, "nft", "-a", "list", "chain", "inet", routesTableName(), trafficIngressPreroutingChain)
|
|
outOut, _, codeOut, _ := runCommandTimeout(5*time.Second, "nft", "-a", "list", "chain", "inet", routesTableName(), trafficIngressOutputChain)
|
|
if codePre != 0 || codeOut != 0 {
|
|
return false
|
|
}
|
|
return strings.Contains(outPre, trafficIngressCaptureComment) &&
|
|
strings.Contains(outPre, trafficIngressRestoreComment) &&
|
|
strings.Contains(outOut, trafficIngressRestoreComment)
|
|
}
|
|
|
|
func prefStr(v int) string {
|
|
return strconv.Itoa(v)
|
|
}
|
|
|
|
func removeTrafficRulesForTable() {
|
|
out, _, _, _ := runCommand("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 >= trafficRulePrefManagedMin && prefNum <= trafficRulePrefManagedMax
|
|
legacy := strings.Contains(low, "lookup "+routesTableName())
|
|
if !managed && !legacy {
|
|
continue
|
|
}
|
|
_, _, _, _ = runCommand("ip", "rule", "del", "pref", pref)
|
|
}
|
|
}
|
|
|
|
func cgroupCandidates(entry 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) (string, string) {
|
|
for _, cand := range cgroupCandidates(entry) {
|
|
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) ([]string, string) {
|
|
var uids []string
|
|
var warnings []string
|
|
|
|
for _, entry := range normalizeCgroupList(entries) {
|
|
root, warn := resolveCgroupPath(entry)
|
|
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, "; ")
|
|
}
|
|
|
|
type effectiveTrafficOverrides struct {
|
|
VPNSubnets []string
|
|
VPNUIDs []string
|
|
DirectSubnets []string
|
|
DirectUIDs []string
|
|
CgroupResolvedUIDs int
|
|
CgroupWarning string
|
|
}
|
|
|
|
func buildEffectiveOverrides(st TrafficModeState) effectiveTrafficOverrides {
|
|
st = normalizeTrafficModeState(st)
|
|
e := effectiveTrafficOverrides{
|
|
VPNSubnets: append([]string(nil), st.ForceVPNSubnets...),
|
|
VPNUIDs: append([]string(nil), st.ForceVPNUIDs...),
|
|
DirectSubnets: append([]string(nil), st.ForceDirectSubnets...),
|
|
DirectUIDs: append([]string(nil), st.ForceDirectUIDs...),
|
|
}
|
|
|
|
vpnUIDsFromCG, warnVPN := resolveCgroupUIDRanges(st.ForceVPNCGroups)
|
|
directUIDsFromCG, warnDirect := resolveCgroupUIDRanges(st.ForceDirectCGroups)
|
|
e.CgroupResolvedUIDs = len(vpnUIDsFromCG) + len(directUIDsFromCG)
|
|
e.VPNUIDs = normalizeUIDList(append(e.VPNUIDs, vpnUIDsFromCG...))
|
|
e.DirectUIDs = normalizeUIDList(append(e.DirectUIDs, directUIDsFromCG...))
|
|
warns := make([]string, 0, 2)
|
|
if strings.TrimSpace(warnVPN) != "" {
|
|
warns = append(warns, strings.TrimSpace(warnVPN))
|
|
}
|
|
if strings.TrimSpace(warnDirect) != "" {
|
|
warns = append(warns, strings.TrimSpace(warnDirect))
|
|
}
|
|
e.CgroupWarning = strings.Join(warns, "; ")
|
|
return e
|
|
}
|
|
|
|
func applyRule(pref int, args ...string) error {
|
|
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 := runCommand("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 applyTrafficOverrides(e effectiveTrafficOverrides) (int, error) {
|
|
applied := 0
|
|
if len(e.DirectSubnets) > trafficRulePerKindLimit ||
|
|
len(e.DirectUIDs) > trafficRulePerKindLimit ||
|
|
len(e.VPNSubnets) > trafficRulePerKindLimit ||
|
|
len(e.VPNUIDs) > trafficRulePerKindLimit {
|
|
return 0, fmt.Errorf("override list too large (max %d entries per kind)", trafficRulePerKindLimit)
|
|
}
|
|
|
|
for i, cidr := range e.DirectSubnets {
|
|
if err := applyRule(trafficRulePrefDirectSubnetStart+i, "from", cidr, "lookup", "main"); err != nil {
|
|
return applied, err
|
|
}
|
|
applied++
|
|
}
|
|
for i, uidr := range e.DirectUIDs {
|
|
if err := applyRule(trafficRulePrefDirectUIDStart+i, "uidrange", uidr, "lookup", "main"); err != nil {
|
|
return applied, err
|
|
}
|
|
applied++
|
|
}
|
|
for i, cidr := range e.VPNSubnets {
|
|
if err := applyRule(trafficRulePrefVPNSubnetStart+i, "from", cidr, "lookup", routesTableName()); err != nil {
|
|
return applied, err
|
|
}
|
|
applied++
|
|
}
|
|
for i, uidr := range e.VPNUIDs {
|
|
if err := applyRule(trafficRulePrefVPNUIDStart+i, "uidrange", uidr, "lookup", routesTableName()); err != nil {
|
|
return applied, err
|
|
}
|
|
applied++
|
|
}
|
|
return applied, nil
|
|
}
|
|
|
|
func ensureTrafficRouteBase(iface string, autoLocalBypass bool) error {
|
|
iface = strings.TrimSpace(iface)
|
|
if iface == "" {
|
|
return fmt.Errorf("empty interface")
|
|
}
|
|
if !ifaceExists(iface) {
|
|
return fmt.Errorf("interface not found: %s", iface)
|
|
}
|
|
|
|
ensureRoutesTableEntry()
|
|
|
|
if _, _, code, err := runCommand("ip", "-4", "route", "replace", "default", "dev", iface, "table", routesTableName(), "mtu", policyRouteMTU); err != nil || code != 0 {
|
|
if err == nil {
|
|
err = fmt.Errorf("ip route replace default exited with %d", code)
|
|
}
|
|
return err
|
|
}
|
|
|
|
if autoLocalBypass {
|
|
applyAutoLocalBypass(iface)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func applyTrafficMode(st TrafficModeState, iface string) error {
|
|
st = normalizeTrafficModeState(st)
|
|
eff := buildEffectiveOverrides(st)
|
|
advancedActive := st.Mode == TrafficModeFullTunnel
|
|
autoLocalActive := advancedActive && st.AutoLocalBypass
|
|
ingressReplyActive := advancedActive && st.IngressReplyBypass
|
|
|
|
removeTrafficRulesForTable()
|
|
|
|
// EN: Ensure the policy table name exists even in direct mode so mark-based rules can be installed.
|
|
// RU: Гарантируем наличие имени policy-table даже в direct режиме, чтобы можно было ставить mark-правила.
|
|
ensureRoutesTableEntry()
|
|
if err := disableIngressReplyBypass(); err != nil {
|
|
return err
|
|
}
|
|
|
|
needVPNTable := st.Mode != TrafficModeDirect || len(eff.VPNSubnets) > 0 || len(eff.VPNUIDs) > 0
|
|
if needVPNTable {
|
|
if err := ensureTrafficRouteBase(iface, autoLocalActive); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if _, err := applyTrafficOverrides(eff); err != nil {
|
|
return err
|
|
}
|
|
|
|
// EN: Mark-based per-app routing support (cgroup-based marking in nftables).
|
|
// EN: These rules are safe even when no packets are marked with MARK_APP/MARK_DIRECT.
|
|
// RU: Поддержка per-app маршрутизации по mark (cgroup-based marking в nftables).
|
|
// RU: Эти правила безопасны, если пакеты не помечаются MARK_APP/MARK_DIRECT.
|
|
if err := applyRule(trafficRulePrefMarkDirect, "fwmark", MARK_DIRECT, "lookup", "main"); err != nil {
|
|
return err
|
|
}
|
|
if ingressReplyActive {
|
|
if err := applyRule(trafficRulePrefMarkIngressReply, "fwmark", MARK_INGRESS, "lookup", "main"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := applyRule(trafficRulePrefMarkAppVPN, "fwmark", MARK_APP, "lookup", routesTableName()); err != nil {
|
|
return err
|
|
}
|
|
|
|
switch st.Mode {
|
|
case TrafficModeFullTunnel:
|
|
if err := applyRule(trafficRulePrefFull, "lookup", routesTableName()); err != nil {
|
|
return err
|
|
}
|
|
case TrafficModeSelective:
|
|
if err := applyRule(trafficRulePrefSelective, "fwmark", MARK, "lookup", routesTableName()); err != nil {
|
|
return err
|
|
}
|
|
case TrafficModeDirect:
|
|
// direct mode relies only on optional direct/vpn overrides.
|
|
default:
|
|
return fmt.Errorf("unknown traffic mode: %s", st.Mode)
|
|
}
|
|
if ingressReplyActive {
|
|
if err := enableIngressReplyBypass(iface); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if err := restoreAppMarksFromState(); err != nil {
|
|
appendTraceLine("traffic", fmt.Sprintf("appmarks restore warning: %v", err))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type trafficRulesState struct {
|
|
Mark bool
|
|
Full bool
|
|
IngressReply bool
|
|
}
|
|
|
|
func readTrafficRules() trafficRulesState {
|
|
out, _, _, _ := runCommand("ip", "rule", "show")
|
|
var st trafficRulesState
|
|
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 trafficRulePrefSelective:
|
|
if strings.Contains(l, "lookup "+routesTableName()) {
|
|
st.Mark = true
|
|
}
|
|
case trafficRulePrefFull:
|
|
if strings.Contains(l, "lookup "+routesTableName()) {
|
|
st.Full = true
|
|
}
|
|
case trafficRulePrefMarkIngressReply:
|
|
if strings.Contains(l, "fwmark "+strings.ToLower(MARK_INGRESS)) && strings.Contains(l, "lookup main") {
|
|
st.IngressReply = true
|
|
}
|
|
}
|
|
}
|
|
return st
|
|
}
|
|
|
|
func detectAppliedTrafficMode(rules trafficRulesState) TrafficMode {
|
|
if rules.Full {
|
|
return TrafficModeFullTunnel
|
|
}
|
|
if rules.Mark {
|
|
return TrafficModeSelective
|
|
}
|
|
return TrafficModeDirect
|
|
}
|
|
|
|
func probeTrafficMode(mode TrafficMode, iface string) (bool, string) {
|
|
mode = normalizeTrafficMode(mode)
|
|
iface = strings.TrimSpace(iface)
|
|
|
|
args := []string{"-4", "route", "get", "1.1.1.1"}
|
|
if mode == TrafficModeSelective {
|
|
args = append(args, "mark", MARK)
|
|
}
|
|
|
|
out, _, code, err := runCommand("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 TrafficModeDirect:
|
|
// direct mode must not be forced through agvpn rule table.
|
|
if strings.Contains(text, " table "+strings.ToLower(routesTableName())) {
|
|
return false, "route probe still uses agvpn table"
|
|
}
|
|
return true, "route probe direct path ok"
|
|
case TrafficModeFullTunnel, TrafficModeSelective:
|
|
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"
|
|
}
|
|
}
|
|
|
|
func evaluateTrafficMode(st TrafficModeState) TrafficModeStatusResponse {
|
|
st = normalizeTrafficModeState(st)
|
|
eff := buildEffectiveOverrides(st)
|
|
advancedActive := st.Mode == TrafficModeFullTunnel
|
|
autoLocalActive := advancedActive && st.AutoLocalBypass
|
|
ingressDesired := st.IngressReplyBypass
|
|
ingressExpected := advancedActive && ingressDesired
|
|
hasVPN := len(eff.VPNSubnets) > 0 || len(eff.VPNUIDs) > 0
|
|
iface, reason := resolveTrafficIface(st.PreferredIface)
|
|
rules := readTrafficRules()
|
|
applied := detectAppliedTrafficMode(rules)
|
|
ingressNft := false
|
|
if rules.IngressReply || st.Mode == TrafficModeFullTunnel || st.IngressReplyBypass {
|
|
ingressNft = ingressReplyNftActive()
|
|
}
|
|
bypassCandidates := 0
|
|
if autoLocalActive && (st.Mode != TrafficModeDirect || hasVPN) {
|
|
bypassCandidates = len(detectAutoLocalBypassRoutes(iface))
|
|
}
|
|
|
|
overridesApplied := len(eff.VPNSubnets) + len(eff.VPNUIDs) + len(eff.DirectSubnets) + len(eff.DirectUIDs)
|
|
|
|
tableDefault := false
|
|
if iface != "" && (st.Mode != TrafficModeDirect || hasVPN) {
|
|
ok, _ := checkPolicyRoute(iface, routesTableName())
|
|
tableDefault = ok
|
|
}
|
|
|
|
res := TrafficModeStatusResponse{
|
|
Mode: st.Mode,
|
|
DesiredMode: st.Mode,
|
|
AppliedMode: applied,
|
|
PreferredIface: st.PreferredIface,
|
|
AdvancedActive: advancedActive,
|
|
AutoLocalBypass: st.AutoLocalBypass,
|
|
AutoLocalActive: autoLocalActive,
|
|
IngressReplyBypass: ingressDesired,
|
|
IngressReplyActive: rules.IngressReply && ingressNft,
|
|
BypassCandidates: bypassCandidates,
|
|
ForceVPNSubnets: append([]string(nil), st.ForceVPNSubnets...),
|
|
ForceVPNUIDs: append([]string(nil), st.ForceVPNUIDs...),
|
|
ForceVPNCGroups: append([]string(nil), st.ForceVPNCGroups...),
|
|
ForceDirectSubnets: append([]string(nil), st.ForceDirectSubnets...),
|
|
ForceDirectUIDs: append([]string(nil), st.ForceDirectUIDs...),
|
|
ForceDirectCGroups: append([]string(nil), st.ForceDirectCGroups...),
|
|
OverridesApplied: overridesApplied,
|
|
CgroupResolvedUIDs: eff.CgroupResolvedUIDs,
|
|
CgroupWarning: eff.CgroupWarning,
|
|
ActiveIface: iface,
|
|
IfaceReason: reason,
|
|
RuleMark: rules.Mark,
|
|
RuleFull: rules.Full,
|
|
IngressRulePresent: rules.IngressReply,
|
|
IngressNftActive: ingressNft,
|
|
TableDefault: tableDefault,
|
|
}
|
|
|
|
res.ProbeOK, res.ProbeMessage = probeTrafficMode(st.Mode, iface)
|
|
|
|
switch st.Mode {
|
|
case TrafficModeDirect:
|
|
// direct mode can still be healthy when vpn overrides exist
|
|
// (base full/selective rules must be absent).
|
|
if hasVPN {
|
|
res.Healthy = !rules.Mark && !rules.Full && !rules.IngressReply && !ingressNft && tableDefault && iface != "" && res.ProbeOK
|
|
} else {
|
|
res.Healthy = !rules.Mark && !rules.Full && !rules.IngressReply && !ingressNft && res.ProbeOK
|
|
}
|
|
case TrafficModeFullTunnel:
|
|
if ingressExpected {
|
|
res.Healthy = rules.Full && !rules.Mark && rules.IngressReply && ingressNft && tableDefault && iface != "" && res.ProbeOK
|
|
} else {
|
|
res.Healthy = rules.Full && !rules.Mark && !rules.IngressReply && !ingressNft && tableDefault && iface != "" && res.ProbeOK
|
|
}
|
|
case TrafficModeSelective:
|
|
res.Healthy = rules.Mark && !rules.Full && !rules.IngressReply && !ingressNft && tableDefault && iface != "" && res.ProbeOK
|
|
default:
|
|
res.Healthy = false
|
|
}
|
|
|
|
if res.Healthy {
|
|
res.Message = "traffic mode applied"
|
|
return res
|
|
}
|
|
if iface == "" && (st.Mode != TrafficModeDirect || hasVPN) {
|
|
res.Message = "vpn interface not found"
|
|
return res
|
|
}
|
|
if st.Mode != applied {
|
|
res.Message = fmt.Sprintf("desired=%s applied=%s mismatch", st.Mode, applied)
|
|
return res
|
|
}
|
|
if (st.Mode != TrafficModeDirect || hasVPN) && !tableDefault {
|
|
res.Message = "policy table default route is missing"
|
|
return res
|
|
}
|
|
if !res.ProbeOK {
|
|
res.Message = res.ProbeMessage
|
|
return res
|
|
}
|
|
if rules.Mark && rules.Full {
|
|
res.Message = "conflicting traffic rules detected"
|
|
return res
|
|
}
|
|
if ingressExpected && (!rules.IngressReply || !ingressNft) {
|
|
res.Message = "ingress-reply bypass rule is not active"
|
|
return res
|
|
}
|
|
if !ingressExpected && (rules.IngressReply || ingressNft) {
|
|
res.Message = "stale ingress-reply bypass rule is active"
|
|
return res
|
|
}
|
|
res.Message = "traffic mode check failed"
|
|
return res
|
|
}
|
|
|
|
func handleTrafficInterfaces(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
st := loadTrafficModeState()
|
|
active, reason := resolveTrafficIface(st.PreferredIface)
|
|
resp := TrafficInterfacesResponse{
|
|
Interfaces: listSelectableIfaces(st.PreferredIface),
|
|
PreferredIface: normalizePreferredIface(st.PreferredIface),
|
|
ActiveIface: active,
|
|
IfaceReason: reason,
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func handleTrafficModeTest(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet && r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
st := loadTrafficModeState()
|
|
writeJSON(w, http.StatusOK, evaluateTrafficMode(st))
|
|
}
|
|
|
|
func acquireTrafficApplyLock() (*os.File, *TrafficModeStatusResponse) {
|
|
lock, err := os.OpenFile(lockFile, os.O_CREATE|os.O_RDWR, 0o644)
|
|
if err != nil {
|
|
msg := evaluateTrafficMode(loadTrafficModeState())
|
|
msg.Message = "traffic lock open failed: " + err.Error()
|
|
return nil, &msg
|
|
}
|
|
if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
|
_ = lock.Close()
|
|
msg := evaluateTrafficMode(loadTrafficModeState())
|
|
msg.Message = "traffic apply skipped: routes operation already running"
|
|
return nil, &msg
|
|
}
|
|
return lock, nil
|
|
}
|
|
|
|
func handleTrafficAdvancedReset(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
lock, lockMsg := acquireTrafficApplyLock()
|
|
if lockMsg != nil {
|
|
writeJSON(w, http.StatusOK, *lockMsg)
|
|
return
|
|
}
|
|
defer func() {
|
|
_ = syscall.Flock(int(lock.Fd()), syscall.LOCK_UN)
|
|
_ = lock.Close()
|
|
}()
|
|
|
|
prev := normalizeTrafficModeState(loadTrafficModeState())
|
|
next := prev
|
|
next.AutoLocalBypass = false
|
|
next.IngressReplyBypass = false
|
|
|
|
nextIface, _ := resolveTrafficIface(next.PreferredIface)
|
|
if err := applyTrafficMode(next, nextIface); err != nil {
|
|
prevIface, _ := resolveTrafficIface(prev.PreferredIface)
|
|
_ = applyTrafficMode(prev, prevIface)
|
|
msg := evaluateTrafficMode(prev)
|
|
msg.Message = "advanced reset failed, rolled back: " + err.Error()
|
|
writeJSON(w, http.StatusOK, msg)
|
|
return
|
|
}
|
|
|
|
if err := saveTrafficModeState(next); err != nil {
|
|
prevIface, _ := resolveTrafficIface(prev.PreferredIface)
|
|
_ = applyTrafficMode(prev, prevIface)
|
|
_ = saveTrafficModeState(prev)
|
|
msg := evaluateTrafficMode(prev)
|
|
msg.Message = "advanced reset save failed, rolled back: " + err.Error()
|
|
writeJSON(w, http.StatusOK, msg)
|
|
return
|
|
}
|
|
|
|
res := evaluateTrafficMode(next)
|
|
if !res.Healthy {
|
|
prevIface, _ := resolveTrafficIface(prev.PreferredIface)
|
|
_ = applyTrafficMode(prev, prevIface)
|
|
_ = saveTrafficModeState(prev)
|
|
rolled := evaluateTrafficMode(prev)
|
|
rolled.Message = "advanced reset verification failed, rolled back: " + res.Message
|
|
writeJSON(w, http.StatusOK, rolled)
|
|
return
|
|
}
|
|
|
|
events.push("traffic_advanced_reset", map[string]any{
|
|
"mode": res.Mode,
|
|
"applied": res.AppliedMode,
|
|
"active_iface": res.ActiveIface,
|
|
"healthy": res.Healthy,
|
|
"auto_local": res.AutoLocalBypass,
|
|
"ingress_reply": res.IngressReplyBypass,
|
|
"advanced_active": res.AdvancedActive,
|
|
})
|
|
res.Message = "advanced bypass reset"
|
|
writeJSON(w, http.StatusOK, res)
|
|
}
|
|
|
|
func handleTrafficMode(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
st := loadTrafficModeState()
|
|
writeJSON(w, http.StatusOK, evaluateTrafficMode(st))
|
|
case http.MethodPost:
|
|
lock, lockMsg := acquireTrafficApplyLock()
|
|
if lockMsg != nil {
|
|
writeJSON(w, http.StatusOK, *lockMsg)
|
|
return
|
|
}
|
|
defer func() {
|
|
_ = syscall.Flock(int(lock.Fd()), syscall.LOCK_UN)
|
|
_ = lock.Close()
|
|
}()
|
|
|
|
prev := loadTrafficModeState()
|
|
next := prev
|
|
|
|
var body TrafficModeRequest
|
|
if r.Body != nil {
|
|
defer r.Body.Close()
|
|
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
|
|
http.Error(w, "bad json", http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
|
|
if strings.TrimSpace(string(body.Mode)) != "" {
|
|
next.Mode = normalizeTrafficMode(body.Mode)
|
|
}
|
|
if body.PreferredIface != nil {
|
|
next.PreferredIface = normalizePreferredIface(*body.PreferredIface)
|
|
}
|
|
if body.AutoLocalBypass != nil {
|
|
next.AutoLocalBypass = *body.AutoLocalBypass
|
|
}
|
|
if body.IngressReplyBypass != nil {
|
|
next.IngressReplyBypass = *body.IngressReplyBypass
|
|
}
|
|
if body.ForceVPNSubnets != nil {
|
|
next.ForceVPNSubnets = append([]string(nil), (*body.ForceVPNSubnets)...)
|
|
}
|
|
if body.ForceVPNUIDs != nil {
|
|
next.ForceVPNUIDs = append([]string(nil), (*body.ForceVPNUIDs)...)
|
|
}
|
|
if body.ForceVPNCGroups != nil {
|
|
next.ForceVPNCGroups = append([]string(nil), (*body.ForceVPNCGroups)...)
|
|
}
|
|
if body.ForceDirectSubnets != nil {
|
|
next.ForceDirectSubnets = append([]string(nil), (*body.ForceDirectSubnets)...)
|
|
}
|
|
if body.ForceDirectUIDs != nil {
|
|
next.ForceDirectUIDs = append([]string(nil), (*body.ForceDirectUIDs)...)
|
|
}
|
|
if body.ForceDirectCGroups != nil {
|
|
next.ForceDirectCGroups = append([]string(nil), (*body.ForceDirectCGroups)...)
|
|
}
|
|
|
|
next = normalizeTrafficModeState(next)
|
|
prev = normalizeTrafficModeState(prev)
|
|
|
|
nextIface, _ := resolveTrafficIface(next.PreferredIface)
|
|
if err := applyTrafficMode(next, nextIface); err != nil {
|
|
prevIface, _ := resolveTrafficIface(prev.PreferredIface)
|
|
_ = applyTrafficMode(prev, prevIface)
|
|
msg := evaluateTrafficMode(prev)
|
|
msg.Message = "apply failed, rolled back: " + err.Error()
|
|
writeJSON(w, http.StatusOK, msg)
|
|
return
|
|
}
|
|
|
|
if err := saveTrafficModeState(next); err != nil {
|
|
prevIface, _ := resolveTrafficIface(prev.PreferredIface)
|
|
_ = applyTrafficMode(prev, prevIface)
|
|
_ = saveTrafficModeState(prev)
|
|
rolled := evaluateTrafficMode(prev)
|
|
rolled.Message = "state save failed, rolled back: " + err.Error()
|
|
writeJSON(w, http.StatusOK, rolled)
|
|
return
|
|
}
|
|
|
|
res := evaluateTrafficMode(next)
|
|
if !res.Healthy {
|
|
prevIface, _ := resolveTrafficIface(prev.PreferredIface)
|
|
_ = applyTrafficMode(prev, prevIface)
|
|
_ = saveTrafficModeState(prev)
|
|
rolled := evaluateTrafficMode(prev)
|
|
rolled.Message = "verification failed, rolled back: " + res.Message
|
|
writeJSON(w, http.StatusOK, rolled)
|
|
return
|
|
}
|
|
|
|
events.push("traffic_mode_changed", map[string]any{
|
|
"mode": res.Mode,
|
|
"applied": res.AppliedMode,
|
|
"active_iface": res.ActiveIface,
|
|
"healthy": res.Healthy,
|
|
"advanced_active": res.AdvancedActive,
|
|
"auto_local_bypass": res.AutoLocalBypass,
|
|
"auto_local_active": res.AutoLocalActive,
|
|
"ingress_reply": res.IngressReplyBypass,
|
|
"ingress_active": res.IngressReplyActive,
|
|
"overrides_applied": res.OverridesApplied,
|
|
})
|
|
writeJSON(w, http.StatusOK, res)
|
|
default:
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|