package app import ( "bytes" "context" "errors" "fmt" "net/netip" "os/exec" "sort" "strings" "time" "github.com/cenkalti/backoff/v4" ) // --------------------------------------------------------------------- // nft update helpers // --------------------------------------------------------------------- // EN: NFT set update strategy with interval compression and two execution modes: // EN: atomic transaction first, then chunked fallback with per-IP recovery. // RU: Стратегия обновления NFT-набора с компрессией интервалов и двумя режимами: // RU: сначала атомарная транзакция, затем chunked fallback с поштучным восстановлением. func nftLog(format string, args ...any) { appendTraceLine("routes", fmt.Sprintf(format, args...)) } // --------------------------------------------------------------------- // interval compression // --------------------------------------------------------------------- // compressIPIntervals убирает: // - дубликаты строк // - подсети, целиком покрытые более широкими подсетями // - одиночные IP, попадающие в уже имеющиеся подсети func compressIPIntervals(ips []string) []string { // чтобы не гонять дубликаты строк seen := make(map[string]struct{}) type prefixItem struct { p netip.Prefix raw string } type addrItem struct { a netip.Addr raw string } var prefixes []prefixItem var addrs []addrItem for _, s := range ips { s = strings.TrimSpace(s) if s == "" { continue } if _, ok := seen[s]; ok { continue } seen[s] = struct{}{} if strings.Contains(s, "/") { p, err := netip.ParsePrefix(s) if err != nil { // если формат кривой — просто пропускаем continue } prefixes = append(prefixes, prefixItem{p: p, raw: s}) } else { a, err := netip.ParseAddr(s) if err != nil { continue } addrs = append(addrs, addrItem{a: a, raw: s}) } } // 1) Убираем подсети, полностью покрытые более крупными подсетями. // // Сначала сортируем по: // - адресу // - длине префикса (меньший Bits = более широкая сеть) — раньше sort.Slice(prefixes, func(i, j int) bool { ai := prefixes[i].p.Addr() aj := prefixes[j].p.Addr() if ai == aj { return prefixes[i].p.Bits() < prefixes[j].p.Bits() } return ai.Less(aj) }) var keptPrefixes []prefixItem for _, pi := range prefixes { covered := false for _, kp := range keptPrefixes { // если более крупная сеть kp уже покрывает эту — пропускаем if kp.p.Bits() <= pi.p.Bits() && kp.p.Contains(pi.p.Addr()) { covered = true break } } if !covered { keptPrefixes = append(keptPrefixes, pi) } } var keptAddrs []addrItem for _, ai := range addrs { inNet := false for _, kp := range keptPrefixes { if kp.p.Contains(ai.a) { inNet = true break } } if !inNet { keptAddrs = append(keptAddrs, ai) } } // 3) Собираем финальный список строк out := make([]string, 0, len(keptPrefixes)+len(keptAddrs)) for _, ai := range keptAddrs { out = append(out, ai.raw) } for _, pi := range keptPrefixes { out = append(out, pi.raw) } return out } // --------------------------------------------------------------------- // smart update strategy // --------------------------------------------------------------------- // умный апдейтер: сначала atomic, при фейле – fallback на chunked func nftUpdateIPsSmart(ctx context.Context, ips []string, progressCb ProgressCallback) error { return nftUpdateSetIPsSmart(ctx, "agvpn4", ips, progressCb) } // nftUpdateSetIPsSmart — тот же апдейтер, но для произвольного nft set. func nftUpdateSetIPsSmart(ctx context.Context, setName string, ips []string, progressCb ProgressCallback) error { setName = strings.TrimSpace(setName) if setName == "" { setName = "agvpn4" } if len(ips) == 0 { if progressCb != nil { progressCb(100, "nothing to update") } return nil } // Сжимаем IP / подсети, убираем пересечения и дубликаты origCount := len(ips) ips = compressIPIntervals(ips) if len(ips) != origCount { nftLog( "compress(%s): %d -> %d IP elements (removed %d covered/duplicate entries)", setName, origCount, len(ips), origCount-len(ips), ) } if len(ips) == 0 { if progressCb != nil { progressCb(100, "nothing to update after compression") } return nil } nftLog("nftUpdateSetIPsSmart(%s): start, ips=%d", setName, len(ips)) // 1) atomic транзакция через nft -f - if err := nftAtomicUpdateWithProgress(ctx, setName, ips, progressCb); err == nil { return nil } else { // если контекст умер – дальше не идём if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { nftLog("atomic update cancelled (%s): %v", setName, err) return err } nftLog("atomic nft update failed (%s): %v; falling back to chunked mode", setName, err) if progressCb != nil { progressCb(0, "Falling back to non-atomic update") } } // 2) fallback: flush + chunked с поштучным фолбэком return nftChunkedUpdateWithFallback(ctx, setName, ips, progressCb) } // --------------------------------------------------------------------- // atomic updater // --------------------------------------------------------------------- // атомарный апдейт через один nft-транзакционный скрипт func nftAtomicUpdateWithProgress(ctx context.Context, setName string, ips []string, progressCb ProgressCallback) error { if len(ips) == 0 { if progressCb != nil { progressCb(100, "nothing to update") } return nil } sort.Strings(ips) // стабильность total := len(ips) chunkSize := 500 // старт bo := backoff.NewExponentialBackOff() bo.InitialInterval = 500 * time.Millisecond bo.MaxInterval = 10 * time.Second bo.MaxElapsedTime = 2 * time.Minute return backoff.Retry(func() error { select { case <-ctx.Done(): if progressCb != nil { progressCb(0, "Cancelled by context") } return ctx.Err() default: } var script strings.Builder script.WriteString("flush set inet agvpn " + setName + "\n") processed := 0 chunksTotal := (len(ips) + chunkSize - 1) / chunkSize for i := 0; i < len(ips); i += chunkSize { end := i + chunkSize if end > len(ips) { end = len(ips) } chunk := ips[i:end] script.WriteString("add element inet agvpn " + setName + " { ") script.WriteString(strings.Join(chunk, ", ")) script.WriteString(" }\n") processed += len(chunk) if progressCb != nil { percent := processed * 100 / total progressCb(percent, fmt.Sprintf( "Preparing chunk %d/%d (%d/%d IPs)", i/chunkSize+1, chunksTotal, processed, total, )) } } if progressCb != nil { progressCb(90, "Executing nft transaction...") } cmd := exec.CommandContext(ctx, "nft", "-f", "-") cmd.Stdin = strings.NewReader(script.String()) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr err := cmd.Run() if err == nil { nftLog("nft atomic transaction success (%s): %d IPs added", setName, len(ips)) if progressCb != nil { progressCb(100, "Update complete") } return nil } errStr := stderr.String() nftLog("nft atomic transaction failed (%s): err=%v, stderr=%q", setName, err, errStr) // Ошибки, требующие уменьшения чанка if strings.Contains(errStr, "too many elements") || strings.Contains(errStr, "out of memory") || strings.Contains(errStr, "interval overlaps") || strings.Contains(errStr, "conflicting intervals") { newSize := chunkSize / 2 if newSize < 100 { newSize = 100 } if newSize == chunkSize { // дальше делить некуда — Permanent → fallback return backoff.Permanent(fmt.Errorf("atomic nft cannot shrink further: %w", err)) } nftLog("reducing atomic chunk size from %d to %d and retrying", chunkSize, newSize) chunkSize = newSize if progressCb != nil { progressCb(0, fmt.Sprintf("Retrying atomic with smaller chunks (%d IPs)", chunkSize)) } return fmt.Errorf("retry atomic with smaller chunks") } // Другие ошибки — Permanent (переход к chunked) return backoff.Permanent(fmt.Errorf("nft atomic transaction failed: %w", err)) }, bo) } // --------------------------------------------------------------------- // chunked fallback updater // --------------------------------------------------------------------- // nftChunkedUpdateWithFallback — fallback-режим: flush + чанки + поштучно при ошибках func nftChunkedUpdateWithFallback(ctx context.Context, setName string, ips []string, progressCb ProgressCallback) error { if len(ips) == 0 { if progressCb != nil { progressCb(100, "nothing to update") } return nil } sort.Strings(ips) total := len(ips) chunkSize := 500 // flush _, stderr, _, err := runCommandTimeout(10*time.Second, "nft", "flush", "set", "inet", "agvpn", setName) if err != nil { return fmt.Errorf("flush set failed: %v (%s)", err, stderr) } processed := 0 for i := 0; i < len(ips); i += chunkSize { select { case <-ctx.Done(): if progressCb != nil { progressCb(0, "Cancelled during chunked update") } return ctx.Err() default: } end := i + chunkSize if end > len(ips) { end = len(ips) } chunk := ips[i:end] cmdArgs := []string{ "nft", "add", "element", "inet", "agvpn", setName, "{ " + strings.Join(chunk, ", ") + " }", } cmdName := cmdArgs[0] cmdRest := cmdArgs[1:] _, stderr, _, err := runCommandTimeout(15*time.Second, cmdName, cmdRest...) if err != nil { // типичные ошибки → поштучно if strings.Contains(stderr, "interval overlaps") || strings.Contains(stderr, "too many elements") || strings.Contains(stderr, "out of memory") || strings.Contains(stderr, "conflicting intervals") { nftLog("chunk failed (%d IPs), fallback per-ip", len(chunk)) if progressCb != nil { progressCb(processed*100/total, fmt.Sprintf("Chunk failed -> adding %d IPs one by one", len(chunk))) } for _, ip := range chunk { select { case <-ctx.Done(): return ctx.Err() default: } _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "element", "inet", "agvpn", setName, "{ "+ip+" }") } } else { return fmt.Errorf("nft chunk add failed: %v (%s)", err, stderr) } } processed += len(chunk) if progressCb != nil { percent := processed * 100 / total progressCb(percent, fmt.Sprintf("Added %d/%d IPs", processed, total)) } } if progressCb != nil { progressCb(100, "chunked update complete") } nftLog("nft chunked update success (%s): %d IPs", setName, len(ips)) return nil }