package app import ( "context" "encoding/json" "fmt" "os" "sort" "strings" "time" ) // --------------------------------------------------------------------- // routes clear cache (safe clear / fast restore) // --------------------------------------------------------------------- // EN: Snapshot data persisted before routes clear to support fast restore // EN: without running full domain resolve again. // RU: Снимок данных, который сохраняется перед routes clear для быстрого // RU: восстановления без повторного полного резолва доменов. type routesClearCacheMeta struct { CreatedAt string `json:"created_at"` Iface string `json:"iface,omitempty"` RouteCount int `json:"route_count"` IPCount int `json:"ip_count"` DynIPCount int `json:"dyn_ip_count"` HasIPMap bool `json:"has_ip_map"` } func saveRoutesClearCache() (routesClearCacheMeta, error) { if err := os.MkdirAll(stateDir, 0o755); err != nil { return routesClearCacheMeta{}, err } routes, err := readCurrentRoutesTableLines() if err != nil { return routesClearCacheMeta{}, err } if err := writeLinesFile(routesCacheRT, routes); err != nil { return routesClearCacheMeta{}, err } var warns []string ipCount, err := snapshotNftSetToFile("agvpn4", routesCacheIPs) if err != nil { warns = append(warns, fmt.Sprintf("agvpn4 snapshot failed: %v", err)) _ = cacheCopyOrEmpty(stateDir+"/last-ips.txt", routesCacheIPs) ipCount = len(readNonEmptyLines(routesCacheIPs)) } dynIPCount, err := snapshotNftSetToFile("agvpn_dyn4", routesCacheDyn) if err != nil { warns = append(warns, fmt.Sprintf("agvpn_dyn4 snapshot failed: %v", err)) _ = os.WriteFile(routesCacheDyn, []byte{}, 0o644) dynIPCount = 0 } if err := cacheCopyOrEmpty(stateDir+"/last-ips-map.txt", routesCacheMap); err != nil { warns = append(warns, fmt.Sprintf("last-ips-map cache copy failed: %v", err)) } meta := routesClearCacheMeta{ CreatedAt: time.Now().UTC().Format(time.RFC3339), Iface: detectIfaceFromRoutes(routes), RouteCount: len(routes), IPCount: ipCount, DynIPCount: dynIPCount, HasIPMap: fileExists(routesCacheMap), } data, err := json.MarshalIndent(meta, "", " ") if err != nil { return routesClearCacheMeta{}, err } if err := os.WriteFile(routesCacheMeta, data, 0o644); err != nil { return routesClearCacheMeta{}, err } if len(warns) > 0 { return meta, fmt.Errorf("%s", strings.Join(warns, "; ")) } return meta, nil } func restoreRoutesFromCache() cmdResult { meta, err := loadRoutesClearCacheMeta() if err != nil { return cmdResult{ OK: false, Message: fmt.Sprintf("routes cache missing: %v", err), } } ips := readNonEmptyLines(routesCacheIPs) dynIPs := readNonEmptyLines(routesCacheDyn) routeLines, _ := readLinesFile(routesCacheRT) ensureRoutesTableEntry() removeTrafficRulesForTable() _, _, _, _ = runCommandTimeout(5*time.Second, "ip", "route", "flush", "table", routesTableName()) ignoredRoutes := 0 for _, ln := range routeLines { if err := restoreRouteLine(ln); err != nil { if shouldIgnoreRestoreRouteError(ln, err) { ignoredRoutes++ appendTraceLine("routes", fmt.Sprintf("restore route skipped (%q): %v", ln, err)) continue } return cmdResult{ OK: false, Message: fmt.Sprintf("restore route failed (%q): %v", ln, err), } } } if ignoredRoutes > 0 { appendTraceLine("routes", fmt.Sprintf("restore route: skipped non-critical routes=%d", ignoredRoutes)) } if len(routeLines) == 0 && strings.TrimSpace(meta.Iface) != "" { _, _, _, _ = runCommandTimeout( 5*time.Second, "ip", "-4", "route", "replace", "default", "dev", meta.Iface, "table", routesTableName(), "mtu", policyRouteMTU, ) } _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "table", "inet", "agvpn") _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "set", "inet", "agvpn", "agvpn4", "{", "type", "ipv4_addr", ";", "flags", "interval", ";", "}") _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "set", "inet", "agvpn", "agvpn_dyn4", "{", "type", "ipv4_addr", ";", "flags", "interval", ";", "}") _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "flush", "set", "inet", "agvpn", "agvpn4") _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "flush", "set", "inet", "agvpn", "agvpn_dyn4") ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() if len(ips) > 0 { if err := nftUpdateSetIPsSmart(ctx, "agvpn4", ips, nil); err != nil { return cmdResult{ OK: false, Message: fmt.Sprintf("restore nft cache failed for agvpn4: %v", err), } } } if len(dynIPs) > 0 { if err := nftUpdateSetIPsSmart(ctx, "agvpn_dyn4", dynIPs, nil); err != nil { return cmdResult{ OK: false, Message: fmt.Sprintf("restore nft cache failed for agvpn_dyn4: %v", err), } } } traffic := loadTrafficModeState() iface := strings.TrimSpace(meta.Iface) if iface == "" { iface = detectIfaceFromRoutes(routeLines) } if iface == "" { iface, _ = resolveTrafficIface(traffic.PreferredIface) } if iface != "" { if err := applyTrafficMode(traffic, iface); err != nil { return cmdResult{ OK: false, Message: fmt.Sprintf("cache restored, but traffic mode apply failed: %v", err), } } } _ = cacheCopyOrEmpty(routesCacheIPs, stateDir+"/last-ips.txt") if fileExists(routesCacheMap) { _ = cacheCopyOrEmpty(routesCacheMap, stateDir+"/last-ips-map.txt") } return cmdResult{ OK: true, Message: fmt.Sprintf( "routes restored from cache: agvpn4=%d agvpn_dyn4=%d routes=%d iface=%s", len(ips), len(dynIPs), len(routeLines), ifaceOrDash(iface), ), } } func readCurrentRoutesTableLines() ([]string, error) { out, _, code, err := runCommandTimeout(5*time.Second, "ip", "-4", "route", "show", "table", routesTableName()) if err != nil && code != 0 { return nil, err } lines := make([]string, 0, 32) for _, raw := range strings.Split(out, "\n") { ln := strings.TrimSpace(raw) if ln == "" { continue } lines = append(lines, ln) } return lines, nil } func writeLinesFile(path string, lines []string) error { if len(lines) == 0 { return os.WriteFile(path, []byte{}, 0o644) } payload := strings.Join(lines, "\n") if !strings.HasSuffix(payload, "\n") { payload += "\n" } return os.WriteFile(path, []byte(payload), 0o644) } func readLinesFile(path string) ([]string, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } lines := make([]string, 0, 64) for _, raw := range strings.Split(string(data), "\n") { ln := strings.TrimSpace(raw) if ln == "" { continue } lines = append(lines, ln) } return lines, nil } func detectIfaceFromRoutes(lines []string) string { for _, ln := range lines { fields := strings.Fields(ln) for i := 0; i+1 < len(fields); i++ { if fields[i] == "dev" { return strings.TrimSpace(fields[i+1]) } } } return "" } func restoreRouteLine(line string) error { fields := strings.Fields(strings.TrimSpace(line)) if len(fields) == 0 { return nil } args := []string{"-4", "route", "replace"} args = append(args, fields...) hasTable := false for i := 0; i+1 < len(fields); i++ { if fields[i] == "table" { hasTable = true break } } if !hasTable { args = append(args, "table", routesTableName()) } _, _, code, err := runCommandTimeout(5*time.Second, "ip", args...) if err != nil || code != 0 { if err == nil { err = fmt.Errorf("exit code %d", code) } return err } return nil } func shouldIgnoreRestoreRouteError(line string, err error) bool { ln := strings.ToLower(strings.TrimSpace(line)) if strings.Contains(ln, " linkdown") { return true } dev := routeLineDevice(ln) if dev != "" && !strings.HasPrefix(ln, "default ") && !ifaceExists(dev) { return true } msg := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", err))) if strings.HasPrefix(ln, "default ") { return false } if strings.Contains(msg, "cannot find device") || strings.Contains(msg, "no such device") || strings.Contains(msg, "network is down") { return true } return false } func routeLineDevice(line string) string { fields := strings.Fields(strings.TrimSpace(line)) for i := 0; i+1 < len(fields); i++ { if fields[i] == "dev" { return strings.TrimSpace(fields[i+1]) } } return "" } func cacheCopyOrEmpty(src, dst string) error { if err := copyFile(src, dst); err == nil { return nil } return os.WriteFile(dst, []byte{}, 0o644) } func snapshotNftSetToFile(setName, dst string) (int, error) { elems, err := readNftSetElements(setName) if err != nil { return 0, err } if err := writeLinesFile(dst, elems); err != nil { return 0, err } return len(elems), nil } func readNftSetElements(setName string) ([]string, error) { out, stderr, code, err := runCommandTimeout( 8*time.Second, "nft", "list", "set", "inet", "agvpn", setName, ) if err != nil || code != 0 { msg := strings.ToLower(strings.TrimSpace(out + " " + stderr)) if strings.Contains(msg, "no such file") || strings.Contains(msg, "not found") || strings.Contains(msg, "does not exist") { return nil, nil } if err != nil { return nil, fmt.Errorf("nft list set %s failed: %w", setName, err) } return nil, fmt.Errorf("nft list set %s failed: %s", setName, strings.TrimSpace(stderr)) } return parseNftSetElementsText(out), nil } func parseNftSetElementsText(raw string) []string { idx := strings.Index(raw, "elements =") if idx < 0 { return nil } chunk := raw[idx:] open := strings.Index(chunk, "{") if open < 0 { return nil } body := chunk[open+1:] closeIdx := strings.Index(body, "}") if closeIdx >= 0 { body = body[:closeIdx] } body = strings.ReplaceAll(body, "\r", " ") body = strings.ReplaceAll(body, "\n", " ") seen := map[string]struct{}{} out := make([]string, 0, 1024) for _, tok := range strings.Split(body, ",") { val := strings.TrimSpace(tok) if val == "" { continue } if _, ok := seen[val]; ok { continue } seen[val] = struct{}{} out = append(out, val) } sort.Strings(out) return out } func loadRoutesClearCacheMeta() (routesClearCacheMeta, error) { data, err := os.ReadFile(routesCacheMeta) if err != nil { return routesClearCacheMeta{}, err } var meta routesClearCacheMeta if err := json.Unmarshal(data, &meta); err != nil { return routesClearCacheMeta{}, err } return meta, nil } func fileExists(path string) bool { info, err := os.Stat(path) if err != nil { return false } return !info.IsDir() } func ifaceOrDash(iface string) string { if strings.TrimSpace(iface) == "" { return "-" } return iface }