package app import ( "context" "encoding/json" "fmt" "io/fs" "net" "os" "os/user" "sort" "strconv" "strings" "time" ) // --------------------------------------------------------------------- // основной routesUpdate // --------------------------------------------------------------------- // EN: Core selective-routes orchestration pipeline. // EN: This unit prepares policy routing, nftables objects, domain expansion, // EN: resolver execution, status artifacts, and GUI-facing progress events. // RU: Основной orchestration-пайплайн selective-routes. // RU: Модуль готовит policy routing, nftables-объекты, расширение доменов, // RU: запуск резолвера, статусные артефакты и события прогресса для GUI. // --------------------------------------------------------------------- // EN: `routesUpdate` contains core logic for routes update. // RU: `routesUpdate` - содержит основную логику для routes update. // --------------------------------------------------------------------- func routesUpdate(iface string) cmdResult { logp := func(format string, args ...any) { appendTraceLine("routes", fmt.Sprintf(format, args...)) } heartbeat := func() { _ = os.WriteFile(heartbeatFile, []byte{}, 0o644) } res := cmdResult{OK: false} iface = normalizePreferredIface(iface) if iface == "" { iface, _ = resolveTrafficIface(loadTrafficModeState().PreferredIface) } if iface == "" { logp("no active vpn iface, exit 0") res.OK = true res.Message = "interface not found, skipped" return res } // ----------------------------------------------------------------- // preflight // ----------------------------------------------------------------- // ensure dirs _ = os.MkdirAll(stateDir, 0o755) _ = os.MkdirAll(domainDir, 0o755) _ = os.MkdirAll("/etc/selective-vpn", 0o755) heartbeat() // wait iface up up := false for i := 0; i < 30; i++ { if _, _, code, _ := runCommandTimeout(3*time.Second, "ip", "link", "show", iface); code == 0 { up = true break } time.Sleep(1 * time.Second) heartbeat() } if !up { logp("no %s, exit 0", iface) res.OK = true res.Message = "interface not found, skipped" return res } // wait DNS (like wait-for-dns.sh) if err := waitDNS(15, 1*time.Second); err != nil { logp("dns not ready: %v", err) res.Message = "dns not ready" return res } // ----------------------------------------------------------------- // policy routing setup // ----------------------------------------------------------------- // rt_tables entry ensureRoutesTableEntry() // ip rules: remove old rules pointing to table if out, _, _, _ := runCommandTimeout(5*time.Second, "ip", "rule", "show"); out != "" { for _, line := range strings.Split(out, "\n") { if !strings.Contains(line, "lookup "+routesTableName()) { continue } fields := strings.Fields(line) if len(fields) == 0 { continue } pref := strings.TrimSuffix(fields[0], ":") if pref == "" { continue } _, _, _, _ = runCommandTimeout(5*time.Second, "ip", "rule", "del", "pref", pref) } } // clean table and set default route _, _, _, _ = runCommandTimeout(5*time.Second, "ip", "route", "flush", "table", routesTableName()) _, _, _, _ = runCommandTimeout(5*time.Second, "ip", "-4", "route", "replace", "default", "dev", iface, "table", routesTableName(), "mtu", policyRouteMTU) // apply traffic mode rules (selective/full_tunnel/direct) over fresh table. trafficState := loadTrafficModeState() trafficIface, trafficIfaceReason := resolveTrafficIface(trafficState.PreferredIface) if trafficIface == "" { trafficIface = iface trafficIfaceReason = "routes-update-iface" } if err := applyTrafficMode(trafficState, trafficIface); err != nil { logp("traffic mode apply failed: mode=%s iface=%s err=%v", trafficState.Mode, iface, err) res.Message = fmt.Sprintf("traffic mode apply failed: %v", err) return res } trafficEval := evaluateTrafficMode(trafficState) logp( "traffic mode: desired=%s applied=%s healthy=%t iface=%s reason=%s", trafficEval.DesiredMode, trafficEval.AppliedMode, trafficEval.Healthy, trafficEval.ActiveIface, trafficEval.Message+" (apply_iface_source="+trafficIfaceReason+")", ) // ensure default exists if out, _, _, _ := runCommandTimeout(5*time.Second, "ip", "route", "show", "table", routesTableName()); !strings.Contains(out, "default dev "+iface) { _, _, _, _ = runCommandTimeout(5*time.Second, "ip", "-4", "route", "replace", "default", "dev", iface, "table", routesTableName(), "mtu", policyRouteMTU) } heartbeat() // ----------------------------------------------------------------- // nft base objects // ----------------------------------------------------------------- // nft setup _, _, _, _ = 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", ";", "}") // EN: Per-app routing support (cgroup-mark sets). Output chain jumps into: // EN: - output_apps: app-scoped marks (MARK_DIRECT / MARK_APP) // EN: - output_ips: selective domain IP sets (MARK) // RU: Поддержка per-app (cgroup-mark sets). Output chain прыгает в: // RU: - output_apps: per-app marks (MARK_DIRECT / MARK_APP) // RU: - output_ips: селективные доменные IP сеты (MARK) _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "set", "inet", "agvpn", "svpn_cg_vpn", "{", "typeof", "meta", "cgroup", ";", "flags", "timeout", ";", "}") _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "set", "inet", "agvpn", "svpn_cg_direct", "{", "typeof", "meta", "cgroup", ";", "flags", "timeout", ";", "}") _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "chain", "inet", "agvpn", "output", "{", "type", "route", "hook", "output", "priority", "mangle;", "policy", "accept;", "}") _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "chain", "inet", "agvpn", "output_apps") _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "chain", "inet", "agvpn", "output_ips") // Base chain: stable jumps only. _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "flush", "chain", "inet", "agvpn", "output") _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "rule", "inet", "agvpn", "output", "jump", "output_apps") _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "rule", "inet", "agvpn", "output", "jump", "output_ips") // App chain: mark + accept to stop further evaluation in this base chain. _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "flush", "chain", "inet", "agvpn", "output_apps") _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "rule", "inet", "agvpn", "output_apps", "meta", "cgroup", "@svpn_cg_direct", "meta", "mark", "set", MARK_DIRECT, "accept") _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "rule", "inet", "agvpn", "output_apps", "meta", "cgroup", "@svpn_cg_vpn", "meta", "mark", "set", MARK_APP, "accept") // Domain chain: selective IP sets (resolver output). _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "flush", "chain", "inet", "agvpn", "output_ips") _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "rule", "inet", "agvpn", "output_ips", "ip", "daddr", "@agvpn4", "meta", "mark", "set", MARK) _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "rule", "inet", "agvpn", "output_ips", "ip", "daddr", "@agvpn_dyn4", "meta", "mark", "set", MARK) _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "chain", "inet", "agvpn", "prerouting", "{", "type", "filter", "hook", "prerouting", "priority", "mangle;", "policy", "accept;", "}") _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "flush", "chain", "inet", "agvpn", "prerouting") _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "rule", "inet", "agvpn", "prerouting", "iifname", "!=", iface, "ip", "daddr", "@agvpn4", "meta", "mark", "set", MARK) _, _, _, _ = runCommandTimeout(5*time.Second, "nft", "add", "rule", "inet", "agvpn", "prerouting", "iifname", "!=", iface, "ip", "daddr", "@agvpn_dyn4", "meta", "mark", "set", MARK) heartbeat() // ----------------------------------------------------------------- // domains + resolver // ----------------------------------------------------------------- // domain lists bases := loadList(domainDir + "/bases.txt") subs := loadList(domainDir + "/subs.txt") wildcards := loadSmartDNSWildcardDomains(logp) wildcardBasesAdded := 0 for _, d := range wildcards { d = strings.TrimSpace(d) if d == "" { continue } bases = append(bases, d) wildcardBasesAdded++ } subsPerBaseLimit := envInt("RESOLVE_SUBS_PER_BASE_LIMIT", 0) if subsPerBaseLimit < 0 { subsPerBaseLimit = 0 } hardCap := envInt("RESOLVE_DOMAINS_HARD_CAP", 0) if hardCap < 0 { hardCap = 0 } domainSet := make(map[string]struct{}) expandedAdded := 0 twitterAdded := 0 for _, d := range bases { domainSet[d] = struct{}{} if !isGoogleLike(d) { limit := len(subs) if subsPerBaseLimit > 0 && subsPerBaseLimit < limit { limit = subsPerBaseLimit } for i := 0; i < limit; i++ { fqdn := subs[i] + "." + d if _, ok := domainSet[fqdn]; !ok { expandedAdded++ } domainSet[fqdn] = struct{}{} } } } for _, spec := range twitterSpecial { fqdn := spec + ".twitter.com" if _, ok := domainSet[fqdn]; !ok { twitterAdded++ } domainSet[fqdn] = struct{}{} } domains := make([]string, 0, len(domainSet)) for d := range domainSet { if d != "" { domains = append(domains, d) } } sort.Strings(domains) totalBeforeCap := len(domains) if hardCap > 0 && len(domains) > hardCap { domains = domains[:hardCap] logp("domain cap applied: before=%d after=%d hard_cap=%d", totalBeforeCap, len(domains), hardCap) } logp( "domains expanded: bases=%d subs_total=%d subs_per_base_limit=%d expanded_added=%d twitter_added=%d total_before_cap=%d total_used=%d", len(bases), len(subs), subsPerBaseLimit, expandedAdded, twitterAdded, totalBeforeCap, len(domains), ) if wildcardBasesAdded > 0 { logp("domains wildcard seed added: %d base domains from smartdns.conf state", wildcardBasesAdded) } domTmp, _ := os.CreateTemp(stateDir, "domains-*.txt") defer os.Remove(domTmp.Name()) for _, d := range domains { _, _ = domTmp.WriteString(d + "\n") } domTmp.Close() ipTmp, _ := os.CreateTemp(stateDir, "ips-*.txt") ipTmp.Close() ipMapTmp, _ := os.CreateTemp(stateDir, "ipmap-*.txt") ipMapTmp.Close() ipDirectTmp, _ := os.CreateTemp(stateDir, "ips-direct-*.txt") ipDirectTmp.Close() ipDynTmp, _ := os.CreateTemp(stateDir, "ips-dyn-*.txt") ipDynTmp.Close() ipMapDirectTmp, _ := os.CreateTemp(stateDir, "ipmap-direct-*.txt") ipMapDirectTmp.Close() ipMapDynTmp, _ := os.CreateTemp(stateDir, "ipmap-dyn-*.txt") ipMapDynTmp.Close() heartbeat() logp("using Go resolver for domains -> IPs") mode := loadDNSMode() runtimeEnabled := smartDNSRuntimeEnabled() wildcardSource := wildcardFillSource(runtimeEnabled) logp("resolver mode=%s smartdns_addr=%s wildcards=%d", mode.Mode, mode.SmartDNSAddr, len(wildcards)) logp("wildcard source baseline: %s (runtime_nftset=%t)", wildcardSource, runtimeEnabled) resolveOpts := ResolverOpts{ DomainsPath: domTmp.Name(), MetaPath: domainDir + "/meta-special.txt", StaticPath: staticIPsFile, CachePath: stateDir + "/domain-cache.json", PtrCachePath: stateDir + "/ptr-cache.json", TraceLog: traceLogPath, TTL: envInt("RESOLVE_TTL", 24*3600), Workers: envInt("RESOLVE_JOBS", 40), DNSConfigPath: dnsUpstreamsConf, ViaSmartDNS: mode.ViaSmartDNS, // legacy fallback for older clients/state Mode: mode.Mode, SmartDNSAddr: mode.SmartDNSAddr, SmartDNSWildcards: wildcards, } resJob, err := runResolverJob(resolveOpts, logp) if err != nil { logp("Go resolver FAILED: %v", err) res.Message = fmt.Sprintf("resolver failed: %v", err) return res } if err := writeLines(ipTmp.Name(), resJob.IPs); err != nil { logp("write ips failed: %v", err) res.Message = fmt.Sprintf("write ips failed: %v", err) return res } if err := writeMapPairs(ipMapTmp.Name(), resJob.IPMap); err != nil { logp("write ip_map failed: %v", err) res.Message = fmt.Sprintf("write ip_map failed: %v", err) return res } if err := writeLines(ipDirectTmp.Name(), resJob.DirectIPs); err != nil { logp("write direct ips failed: %v", err) res.Message = fmt.Sprintf("write direct ips failed: %v", err) return res } if err := writeLines(ipDynTmp.Name(), resJob.WildcardIPs); err != nil { logp("write wildcard ips failed: %v", err) res.Message = fmt.Sprintf("write wildcard ips failed: %v", err) return res } if err := writeMapPairs(ipMapDirectTmp.Name(), resJob.DirectIPMap); err != nil { logp("write direct ip_map failed: %v", err) res.Message = fmt.Sprintf("write direct ip_map failed: %v", err) return res } if err := writeMapPairs(ipMapDynTmp.Name(), resJob.WildcardIPMap); err != nil { logp("write wildcard ip_map failed: %v", err) res.Message = fmt.Sprintf("write wildcard ip_map failed: %v", err) return res } saveJSON(resJob.DomainCache, resolveOpts.CachePath) saveJSON(resJob.PtrCache, resolveOpts.PtrCachePath) heartbeat() ipCount := len(resJob.IPs) directIPCount := len(resJob.DirectIPs) wildcardIPCount := len(resJob.WildcardIPs) domainCount := countDomainsFromPairs(resJob.IPMap) // ----------------------------------------------------------------- // nft population // ----------------------------------------------------------------- // nft load через умный апдейтер ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() progressCb := func(percent int, msg string) { logp("NFT progress: %d%% - %s", percent, msg) heartbeat() events.push("routes_nft_progress", map[string]any{ "percent": percent, "message": msg, }) } progressRange := func(start, end int, prefix string) ProgressCallback { if progressCb == nil { return nil } if end < start { end = start } return func(percent int, msg string) { if percent < 0 { percent = 0 } if percent > 100 { percent = 100 } scaled := start + (end-start)*percent/100 if strings.TrimSpace(msg) == "" { msg = "updating" } progressCb(scaled, prefix+": "+msg) } } if err := nftUpdateSetIPsSmart(ctx, "agvpn4", resJob.DirectIPs, progressRange(0, 50, "agvpn4")); err != nil { logp("nft set update failed for agvpn4: %v", err) res.Message = fmt.Sprintf("nft update failed for agvpn4: %v", err) return res } if err := nftUpdateSetIPsSmart(ctx, "agvpn_dyn4", resJob.WildcardIPs, progressRange(50, 100, "agvpn_dyn4")); err != nil { logp("nft set update failed for agvpn_dyn4: %v", err) res.Message = fmt.Sprintf("nft update failed for agvpn_dyn4: %v", err) return res } logp("summary: domains=%d, unique_ips=%d direct_ips=%d wildcard_ips=%d", len(domains), ipCount, directIPCount, wildcardIPCount) logp("updated agvpn4 with %d IPs (direct + static)", directIPCount) logp("updated agvpn_dyn4 with %d IPs (wildcard, source=%s)", wildcardIPCount, wildcardSource) logWildcardSmartDNSTrace(mode, wildcardSource, resJob.WildcardIPMap, wildcardIPCount) // ----------------------------------------------------------------- // artifacts + status // ----------------------------------------------------------------- // copy artifacts _ = copyFile(ipTmp.Name(), lastIPsPath) _ = copyFile(ipMapTmp.Name(), lastIPsMapPath) _ = copyFile(ipDirectTmp.Name(), lastIPsDirect) _ = copyFile(ipDynTmp.Name(), lastIPsDyn) _ = copyFile(ipMapDirectTmp.Name(), lastIPsMapDirect) _ = copyFile(ipMapDynTmp.Name(), lastIPsMapDyn) now := time.Now().Format(time.RFC3339) status := Status{ Timestamp: now, IPCount: ipCount, DomainCount: domainCount, Iface: iface, Table: routesTableName(), Mark: MARK, } statusData, _ := json.MarshalIndent(status, "", " ") _ = os.WriteFile(statusFilePath, statusData, 0o644) chownDev( traceLogPath, ipTmp.Name(), ipMapTmp.Name(), ipDirectTmp.Name(), ipDynTmp.Name(), ipMapDirectTmp.Name(), ipMapDynTmp.Name(), lastIPsPath, lastIPsMapPath, lastIPsDirect, lastIPsDyn, lastIPsMapDirect, lastIPsMapDyn, statusFilePath, heartbeatFile, ) chmodPaths( 0o644, ipTmp.Name(), ipMapTmp.Name(), ipDirectTmp.Name(), ipDynTmp.Name(), ipMapDirectTmp.Name(), ipMapDynTmp.Name(), lastIPsPath, lastIPsMapPath, lastIPsDirect, lastIPsDyn, lastIPsMapDirect, lastIPsMapDyn, statusFilePath, heartbeatFile, ) _ = os.Chmod(traceLogPath, 0o666) _ = os.Chmod(stateDir, 0o755) heartbeat() res.OK = true res.Message = fmt.Sprintf("update done: domains=%d unique_ips=%d direct_ips=%d wildcard_ips=%d", len(domains), ipCount, directIPCount, wildcardIPCount) res.ExitCode = ipCount return res } // --------------------------------------------------------------------- // routesUpdate helpers: table / list / counters // --------------------------------------------------------------------- func routesTableName() string { return "agvpn" } // --------------------------------------------------------------------- // EN: `routesTableNum` contains core logic for routes table num. // RU: `routesTableNum` - содержит основную логику для routes table num. // --------------------------------------------------------------------- func routesTableNum() string { return "666" } // --------------------------------------------------------------------- // EN: `loadList` loads list from storage or config. // RU: `loadList` - загружает list из хранилища или конфига. // --------------------------------------------------------------------- func loadList(path string) []string { data, err := os.ReadFile(path) if err != nil { return nil } var out []string for _, ln := range strings.Split(string(data), "\n") { ln = strings.TrimSpace(strings.SplitN(ln, "#", 2)[0]) if ln == "" { continue } out = append(out, ln) } return out } // --------------------------------------------------------------------- // EN: `loadSmartDNSWildcardDomains` loads SmartDNS wildcard domains from canonical API state. // RU: `loadSmartDNSWildcardDomains` - загружает wildcard-домены SmartDNS из каноничного API-состояния. // --------------------------------------------------------------------- func loadSmartDNSWildcardDomains(logf func(string, ...any)) []string { out, source := loadSmartDNSWildcardDomainsState(logf) sort.Strings(out) if logf != nil { logf("smartdns wildcards loaded: source=%s count=%d", source, len(out)) } return out } // --------------------------------------------------------------------- // EN: `isGoogleLike` checks whether google like is true. // RU: `isGoogleLike` - проверяет, является ли google like истинным условием. // --------------------------------------------------------------------- func isGoogleLike(d string) bool { low := strings.ToLower(d) for _, base := range googleLikeDomains { if low == base || strings.HasSuffix(low, "."+base) { return true } } return false } // --------------------------------------------------------------------- // EN: `readNonEmptyLines` reads non empty lines from input data. // RU: `readNonEmptyLines` - читает non empty lines из входных данных. // --------------------------------------------------------------------- func readNonEmptyLines(path string) []string { data, err := os.ReadFile(path) if err != nil { return nil } var out []string for _, ln := range strings.Split(string(data), "\n") { ln = strings.TrimSpace(ln) if ln != "" { out = append(out, ln) } } return out } func writeLines(path string, lines []string) error { if len(lines) == 0 { return os.WriteFile(path, []byte{}, 0o644) } return os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644) } func writeMapPairs(path string, pairs [][2]string) error { if len(pairs) == 0 { return os.WriteFile(path, []byte{}, 0o644) } lines := make([]string, 0, len(pairs)) for _, p := range pairs { lines = append(lines, p[0]+"\t"+p[1]) } return os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644) } func countDomainsFromPairs(pairs [][2]string) int { seen := make(map[string]struct{}) for _, p := range pairs { if len(p) < 2 { continue } d := strings.TrimSpace(p[1]) if d == "" || strings.HasPrefix(d, "[") { continue } seen[d] = struct{}{} } return len(seen) } func wildcardHostIPMap(pairs [][2]string) map[string][]string { hostToIPs := make(map[string]map[string]struct{}) for _, p := range pairs { if len(p) < 2 { continue } ip := strings.TrimSpace(p[0]) host := strings.TrimSpace(p[1]) if ip == "" || host == "" || strings.HasPrefix(host, "[") { continue } ips := hostToIPs[host] if ips == nil { ips = map[string]struct{}{} hostToIPs[host] = ips } ips[ip] = struct{}{} } out := make(map[string][]string, len(hostToIPs)) for host, ipset := range hostToIPs { ips := make([]string, 0, len(ipset)) for ip := range ipset { ips = append(ips, ip) } sort.Strings(ips) out[host] = ips } return out } func logWildcardSmartDNSTrace(mode DNSMode, source string, pairs [][2]string, wildcardIPCount int) { lowMode := strings.ToLower(strings.TrimSpace(string(mode.Mode))) if lowMode != string(DNSModeHybridWildcard) && lowMode != string(DNSModeSmartDNS) { return } hostMap := wildcardHostIPMap(pairs) hosts := make([]string, 0, len(hostMap)) for host := range hostMap { hosts = append(hosts, host) } sort.Strings(hosts) appendTraceLineTo( smartdnsLogPath, "smartdns", fmt.Sprintf("wildcard sync: mode=%s source=%s domains=%d ips=%d", mode.Mode, source, len(hosts), wildcardIPCount), ) const maxHostsLog = 200 for i, host := range hosts { if i >= maxHostsLog { appendTraceLineTo( smartdnsLogPath, "smartdns", fmt.Sprintf("wildcard sync: +%d domains omitted", len(hosts)-maxHostsLog), ) return } appendTraceLineTo( smartdnsLogPath, "smartdns", fmt.Sprintf("wildcard add: %s -> %s", host, strings.Join(hostMap[host], ", ")), ) } } // --------------------------------------------------------------------- // EN: `countDomainsFromMap` counts items for domains from map. // RU: `countDomainsFromMap` - считает элементы для domains from map. // --------------------------------------------------------------------- func countDomainsFromMap(path string) int { data, err := os.ReadFile(path) if err != nil { return 0 } seen := make(map[string]struct{}) for _, ln := range strings.Split(string(data), "\n") { ln = strings.TrimSpace(ln) if ln == "" { continue } fields := strings.Fields(ln) if len(fields) < 2 { continue } d := fields[1] if strings.HasPrefix(d, "[") { continue } seen[d] = struct{}{} } return len(seen) } // --------------------------------------------------------------------- // filesystem helpers // --------------------------------------------------------------------- func copyFile(src, dst string) error { data, err := os.ReadFile(src) if err != nil { return err } return os.WriteFile(dst, data, 0o644) } // --------------------------------------------------------------------- // EN: `chownDev` contains core logic for chown dev. // RU: `chownDev` - содержит основную логику для chown dev. // --------------------------------------------------------------------- func chownDev(paths ...string) { usr, err := user.Lookup("dev") if err != nil { return } uid, _ := strconv.Atoi(usr.Uid) gid, _ := strconv.Atoi(usr.Gid) for _, p := range paths { _ = os.Chown(p, uid, gid) } } // --------------------------------------------------------------------- // EN: `chmodPaths` contains core logic for chmod paths. // RU: `chmodPaths` - содержит основную логику для chmod paths. // --------------------------------------------------------------------- func chmodPaths(mode fs.FileMode, paths ...string) { for _, p := range paths { _ = os.Chmod(p, mode) } } // --------------------------------------------------------------------- // readiness helpers // --------------------------------------------------------------------- func waitDNS(attempts int, delay time.Duration) error { target := "openai.com" for i := 0; i < attempts; i++ { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) _, err := net.DefaultResolver.LookupHost(ctx, target) cancel() if err == nil { return nil } time.Sleep(delay) } return fmt.Errorf("dns lookup failed after %d attempts", attempts) }