baseline: api+gui traffic mode + candidates picker
Snapshot before app-launcher (cgroup/mark) work; ignore binaries/backups.
This commit is contained in:
204
selective-vpn-api/app/autoloop.go
Normal file
204
selective-vpn-api/app/autoloop.go
Normal file
@@ -0,0 +1,204 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user