package app import ( "fmt" "log" "os" "regexp" "strings" "time" ) // --------------------------------------------------------------------- // autoloop // --------------------------------------------------------------------- // EN: Long-running VPN autoloop worker that keeps the tunnel connected, // EN: updates login/license state, enforces policy route defaults, and emits events. // RU: Долгоживущий воркер VPN autoloop, который поддерживает соединение, // RU: обновляет login/license state, чинит policy route и публикует события. func runAutoloop(iface, table string, mtu int, stateDirPath, defaultLoc string) { locFile := stateDirPath + "/adguard-location.txt" logFile := stateDirPath + "/adguard-autoloop.log" loginStateFile := stateDirPath + "/adguard-login.json" licenseTTL := 3600 * time.Second statusTimeout := 8 * time.Second connectTimeout := 25 * time.Second disconnectTimeout := 8 * time.Second licenseTimeout := 10 * time.Second lastLicense := time.Time{} _ = os.MkdirAll(stateDirPath, 0o755) log.Printf("autoloop: start iface=%s table=%s mtu=%d", iface, table, mtu) logLine := func(msg string) { line := fmt.Sprintf("%s autoloop: %s\n", time.Now().Format(time.RFC3339), msg) f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err == nil { defer f.Close() _, _ = f.WriteString(line) } fmt.Print(line) } writeLoginState := func(state, email, msg string) { ts := time.Now().Format(time.RFC3339) payload := fmt.Sprintf(`{"ts":"%s","state":"%s","email":"%s","msg":"%s"}`, ts, escapeJSON(state), escapeJSON(email), escapeJSON(msg)) _ = os.WriteFile(loginStateFile, []byte(payload), 0o644) } getLocation := func() string { if data, err := os.ReadFile(locFile); err == nil { for _, ln := range strings.Split(string(data), "\n") { t := strings.TrimSpace(ln) if t != "" && !strings.HasPrefix(t, "#") { return t } } } return defaultLoc } isConnected := func(out string) bool { low := strings.ToLower(out) return strings.Contains(low, "vpn is connected") || strings.Contains(low, "connected to") || strings.Contains(low, "after connect: connected") } fixPolicy := func() { _, stderr, _, err := runCommandTimeout(5*time.Second, "ip", "-4", "route", "replace", "default", "dev", iface, "table", table, "mtu", fmt.Sprintf("%d", mtu), ) if err != nil { logLine("route: FAILED to set default dev " + iface + " table " + table + ": " + stderr) } else { logLine("route: default dev " + iface + " table " + table + " mtu " + fmt.Sprintf("%d", mtu) + " OK") } } var emailRe = regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+`) parseEmail := func(text string) string { return emailRe.FindString(text) } isLoginRequired := func(t string) bool { low := strings.ToLower(t) return strings.Contains(low, "please log in") || strings.Contains(low, "not logged in") || strings.Contains(low, "login required") || strings.Contains(low, "sign in") } updateLoginStateFromText := func(text string) { if isLoginRequired(text) { writeLoginState("no_login", "", "NOT LOGGED IN") logLine("login: NO (detected from output)") return } if em := parseEmail(text); em != "" { writeLoginState("ok", em, "logged in") logLine("login: OK email=" + em) return } low := strings.ToLower(text) if strings.Contains(low, "not logged in") || strings.Contains(low, "expired") || strings.Contains(low, "no active license") { writeLoginState("no_login", "", "NOT LOGGED IN (license)") logLine("login: NO (license says not logged in)") return } if strings.Contains(low, "license") && (strings.Contains(low, "active") || strings.Contains(low, "valid")) { writeLoginState("ok", "", "logged in (license ok)") logLine("login: OK (license ok, email not found)") return } } updateLicense := func() { now := time.Now() if !lastLicense.IsZero() && now.Sub(lastLicense) < licenseTTL { return } lastLicense = now out, _, _, _ := runCommandTimeout(licenseTimeout, adgvpnCLI, "license") out = stripANSI(out) updateLoginStateFromText(out) } writeLoginState("unknown", "", "not checked yet") updateLicense() for { statusOut, _, exitCode, err := runCommandTimeout(statusTimeout, adgvpnCLI, "status") statusOut = stripANSI(statusOut) if err != nil { logLine(fmt.Sprintf("status: ERROR exit=%d err=%v raw=%q", exitCode, err, statusOut)) } if isConnected(statusOut) { logLine("status: CONNECTED; raw: " + statusOut) fixPolicy() updateLicense() events.push("autoloop_status_changed", map[string]string{ "status_word": "CONNECTED", "raw_text": statusOut, }) time.Sleep(20 * time.Second) continue } logLine("status: DISCONNECTED; raw: " + statusOut) events.push("autoloop_status_changed", map[string]string{ "status_word": "DISCONNECTED", "raw_text": statusOut, }) updateLoginStateFromText(statusOut) loc := getLocation() logLine("reconnecting to " + loc) _, _, _, _ = runCommandTimeout(disconnectTimeout, adgvpnCLI, "disconnect") connectOut, _, _, _ := runCommandTimeout(connectTimeout, adgvpnCLI, "connect", "-l", loc, "--log-to-file") connectOut = stripANSI(connectOut) logLine("connect raw: " + connectOut) updateLoginStateFromText(connectOut) statusAfter, _, _, _ := runCommandTimeout(statusTimeout, adgvpnCLI, "status") statusAfter = stripANSI(statusAfter) if isConnected(statusAfter) { logLine("after connect: CONNECTED; raw: " + statusAfter) fixPolicy() updateLicense() events.push("autoloop_status_changed", map[string]string{ "status_word": "CONNECTED", "raw_text": statusAfter, }) time.Sleep(20 * time.Second) continue } logLine("after connect: STILL DISCONNECTED; raw: " + statusAfter) time.Sleep(10 * time.Second) } } // --------------------------------------------------------------------- // autoloop helpers // --------------------------------------------------------------------- func escapeJSON(s string) string { s = strings.ReplaceAll(s, `\`, `\\`) s = strings.ReplaceAll(s, `"`, `\\"`) s = strings.ReplaceAll(s, "\n", "\\n") s = strings.ReplaceAll(s, "\r", "") return s }