package app import ( "bufio" "encoding/json" "fmt" "io" "net/http" "os" "os/exec" "regexp" "strconv" "strings" "sync" "time" "github.com/creack/pty" ) // --------------------------------------------------------------------- // AdGuard VPN interactive login session (PTY) // --------------------------------------------------------------------- // EN: Interactive AdGuard VPN login session over PTY. // EN: This file contains session state machine, PTY reader/parser, and HTTP API // EN: endpoints to start/poll/control/cancel login flow. // RU: Интерактивная PTY-сессия логина AdGuard VPN. // RU: Файл содержит state machine, PTY reader/parser и HTTP API для // RU: старта/опроса/управления/остановки login-процесса. // --------------------------------------------------------------------- // login session API models // --------------------------------------------------------------------- type LoginSessionStartResp struct { OK bool `json:"ok"` Phase string `json:"phase"` Level string `json:"level"` PID int `json:"pid,omitempty"` Email string `json:"email,omitempty"` Error string `json:"error,omitempty"` } type LoginSessionStateResp struct { OK bool `json:"ok"` Phase string `json:"phase"` Level string `json:"level"` Alive bool `json:"alive"` URL string `json:"url,omitempty"` Email string `json:"email,omitempty"` Cursor int64 `json:"cursor"` Lines []string `json:"lines"` CanOpen bool `json:"can_open"` CanCheck bool `json:"can_check"` CanCancel bool `json:"can_cancel"` Error string `json:"error,omitempty"` } type LoginSessionActionReq struct { Action string `json:"action"` } type loginLine struct { N int64 Line string } // --------------------------------------------------------------------- // login session manager // --------------------------------------------------------------------- type loginSessionManager struct { mu sync.Mutex cmd *exec.Cmd pty *os.File phase string level string alive bool url string email string lines []loginLine max int lastN int64 lastAutoCheck time.Time reURL *regexp.Regexp reEmail *regexp.Regexp reNextCheck *regexp.Regexp } var loginMgr = newLoginSessionManager(defaultTraceTailMax) // --------------------------------------------------------------------- // EN: `newLoginSessionManager` creates a new instance for login session manager. // RU: `newLoginSessionManager` - создает новый экземпляр для login session manager. // --------------------------------------------------------------------- func newLoginSessionManager(max int) *loginSessionManager { return &loginSessionManager{ phase: "idle", level: "yellow", alive: false, max: max, reURL: regexp.MustCompile(`(https?://\S+)`), reEmail: regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+`), reNextCheck: regexp.MustCompile(`(?i)^Next check in \d+s$`), } } // --------------------------------------------------------------------- // EN: `setPhaseLocked` sets phase locked to the requested value. // RU: `setPhaseLocked` - устанавливает phase locked в требуемое значение. // --------------------------------------------------------------------- func (m *loginSessionManager) setPhaseLocked(phase, level string) { m.phase = phase m.level = level } // --------------------------------------------------------------------- // EN: `resetLocked` contains core logic for reset locked. // RU: `resetLocked` - содержит основную логику для reset locked. // --------------------------------------------------------------------- func (m *loginSessionManager) resetLocked() { m.lines = nil m.lastN = 0 m.url = "" m.email = "" m.lastAutoCheck = time.Time{} } // --------------------------------------------------------------------- // EN: `appendLineLocked` appends or adds line locked to an existing state. // RU: `appendLineLocked` - добавляет line locked в текущее состояние. // --------------------------------------------------------------------- func (m *loginSessionManager) appendLineLocked(line string) { m.lastN++ m.lines = append(m.lines, loginLine{N: m.lastN, Line: line}) if len(m.lines) > m.max { m.lines = m.lines[len(m.lines)-m.max:] } } // --------------------------------------------------------------------- // EN: `linesSinceLocked` contains core logic for lines since locked. // RU: `linesSinceLocked` - содержит основную логику для lines since locked. // --------------------------------------------------------------------- func (m *loginSessionManager) linesSinceLocked(since int64) (out []string) { for _, it := range m.lines { if it.N > since { out = append(out, it.Line) } } return out } // --------------------------------------------------------------------- // EN: `sendKeyLocked` sends key locked to a downstream process. // RU: `sendKeyLocked` - отправляет key locked в нижележащий процесс. // --------------------------------------------------------------------- func (m *loginSessionManager) sendKeyLocked(key string) error { if !m.alive || m.pty == nil { return fmt.Errorf("login session not alive") } _, err := m.pty.Write([]byte(key + "\n")) return err } // --------------------------------------------------------------------- // EN: `stopLocked` stops locked and cleans up resources. // RU: `stopLocked` - останавливает locked и освобождает ресурсы. // --------------------------------------------------------------------- func (m *loginSessionManager) stopLocked(hard bool) { if m.cmd == nil { m.setPhaseLocked("idle", "yellow") m.alive = false m.url = "" return } // мягкий cancel _ = m.sendKeyLocked("x") deadline := time.Now().Add(1200 * time.Millisecond) for time.Now().Before(deadline) { if m.cmd == nil || m.cmd.Process == nil { break } time.Sleep(80 * time.Millisecond) } if hard && m.cmd != nil && m.cmd.Process != nil { _ = m.cmd.Process.Signal(os.Interrupt) time.Sleep(150 * time.Millisecond) _ = m.cmd.Process.Kill() } if m.pty != nil { _ = m.pty.Close() m.pty = nil } m.cmd = nil m.alive = false m.setPhaseLocked("idle", "yellow") m.url = "" } // --------------------------------------------------------------------- // EN: `setAlreadyLoggedLocked` sets already logged locked to the requested value. // RU: `setAlreadyLoggedLocked` - устанавливает already logged locked в требуемое значение. // --------------------------------------------------------------------- func (m *loginSessionManager) setAlreadyLoggedLocked(email string) { // без запуска процесса m.stopLocked(true) m.resetLocked() m.email = email m.alive = false m.setPhaseLocked("already_logged", "green") if email != "" { m.appendLineLocked("Already logged in as " + email) } else { m.appendLineLocked("Already logged in") } } // --------------------------------------------------------------------- // EN: `startPTY` starts pty and initializes required state. // RU: `startPTY` - запускает pty и инициализирует нужное состояние. // --------------------------------------------------------------------- func (m *loginSessionManager) startPTY() (pid int, err error) { // caller must hold lock m.stopLocked(true) m.resetLocked() m.setPhaseLocked("starting", "yellow") cmd := exec.Command(adgvpnCLI, "login") ptmx, err := pty.Start(cmd) if err != nil { m.setPhaseLocked("failed", "red") return 0, err } m.cmd = cmd m.pty = ptmx m.alive = true pid = 0 if cmd.Process != nil { pid = cmd.Process.Pid } go m.readerLoop(cmd, ptmx) return pid, nil } // --------------------------------------------------------------------- // EN: `readerLoop` reads er loop from input data. // RU: `readerLoop` - читает er loop из входных данных. // --------------------------------------------------------------------- func (m *loginSessionManager) readerLoop(cmd *exec.Cmd, ptmx *os.File) { sc := bufio.NewScanner(ptmx) buf := make([]byte, 0, 64*1024) sc.Buffer(buf, 1024*1024) for sc.Scan() { line := strings.TrimRight(sc.Text(), "\r\n") line = strings.TrimSpace(line) if line == "" { continue } m.mu.Lock() low := strings.ToLower(line) // URL if m.url == "" { if mm := m.reURL.FindStringSubmatch(line); len(mm) > 1 { m.url = mm[1] m.setPhaseLocked("waiting_browser", "yellow") } } // already logged / current user if strings.Contains(low, "already logged in") || strings.Contains(low, "current user is") { if em := m.reEmail.FindStringSubmatch(line); len(em) > 0 { m.email = em[0] } m.setPhaseLocked("already_logged", "green") } // success / fail if strings.Contains(low, "successfully logged in") { m.setPhaseLocked("success", "green") if em := m.reEmail.FindStringSubmatch(line); len(em) > 0 { m.email = em[0] } } if strings.Contains(low, "failed to log in") { m.setPhaseLocked("failed", "red") } // auto-check trigger if m.reNextCheck.MatchString(line) { m.setPhaseLocked("checking", "yellow") now := time.Now() if m.lastAutoCheck.IsZero() || now.Sub(m.lastAutoCheck) > 1200*time.Millisecond { _ = m.sendKeyLocked("s") m.lastAutoCheck = now } m.appendLineLocked(line) m.mu.Unlock() continue } m.appendLineLocked(line) m.mu.Unlock() } _ = ptmx.Close() err := cmd.Wait() m.mu.Lock() defer m.mu.Unlock() m.alive = false switch m.phase { case "success", "failed", "cancelled", "already_logged": // keep default: if err != nil { m.setPhaseLocked("failed", "red") } else { m.setPhaseLocked("exited", "yellow") } } m.cmd = nil m.pty = nil } // --------------------------------------------------------------------- // login state helper // --------------------------------------------------------------------- func loginStateAlreadyLogged() (bool, string) { data, err := os.ReadFile(loginStatePath) if err != nil { return false, "" } var st VPNLoginState if err := json.Unmarshal(data, &st); err != nil { return false, "" } if strings.TrimSpace(st.State) == "ok" { return true, strings.TrimSpace(st.Email) } return false, "" } // --------------------------------------------------------------------- // login session API // --------------------------------------------------------------------- func handleVPNLoginSessionStart(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } // если уже залогинен (по adguard-login.json) — сразу возвращаем green if ok, email := loginStateAlreadyLogged(); ok { appendTraceLine("login", fmt.Sprintf("session/start: already_logged email=%s", email)) loginMgr.mu.Lock() loginMgr.setAlreadyLoggedLocked(email) loginMgr.mu.Unlock() writeJSON(w, http.StatusOK, LoginSessionStartResp{ OK: true, Phase: "already_logged", Level: "green", Email: email, }) return } loginMgr.mu.Lock() pid, err := loginMgr.startPTY() phase := loginMgr.phase level := loginMgr.level loginMgr.mu.Unlock() if err == nil { appendTraceLine("login", fmt.Sprintf("session/start: pid=%d", pid)) } else { appendTraceLine("login", fmt.Sprintf("session/start: failed: %v", err)) } if err != nil { writeJSON(w, http.StatusOK, LoginSessionStartResp{ OK: false, Phase: "failed", Level: "red", Error: err.Error(), }) return } writeJSON(w, http.StatusOK, LoginSessionStartResp{ OK: true, Phase: phase, Level: level, PID: pid, }) } // GET /api/v1/vpn/login/session/state // --------------------------------------------------------------------- // EN: `handleVPNLoginSessionState` is an HTTP handler for vpn login session state. // RU: `handleVPNLoginSessionState` - HTTP-обработчик для vpn login session state. // --------------------------------------------------------------------- func handleVPNLoginSessionState(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } sinceStr := strings.TrimSpace(r.URL.Query().Get("since")) var since int64 if sinceStr != "" { if v, err := strconv.ParseInt(sinceStr, 10, 64); err == nil && v >= 0 { since = v } } loginMgr.mu.Lock() lines := loginMgr.linesSinceLocked(since) phase := loginMgr.phase level := loginMgr.level alive := loginMgr.alive url := loginMgr.url email := loginMgr.email cursor := loginMgr.lastN loginMgr.mu.Unlock() can := alive && phase != "success" && phase != "already_logged" && phase != "failed" && phase != "cancelled" writeJSON(w, http.StatusOK, LoginSessionStateResp{ OK: true, Phase: phase, Level: level, Alive: alive, URL: url, Email: email, Cursor: cursor, Lines: lines, CanOpen: can, CanCheck: can, CanCancel: can, }) } // POST /api/v1/vpn/login/session/action // --------------------------------------------------------------------- // EN: `handleVPNLoginSessionAction` is an HTTP handler for vpn login session action. // RU: `handleVPNLoginSessionAction` - HTTP-обработчик для vpn login session action. // --------------------------------------------------------------------- func handleVPNLoginSessionAction(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var body LoginSessionActionReq if r.Body != nil { defer r.Body.Close() _ = json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body) } action := strings.ToLower(strings.TrimSpace(body.Action)) if action == "" { http.Error(w, "action required", http.StatusBadRequest) return } loginMgr.mu.Lock() defer loginMgr.mu.Unlock() if !loginMgr.alive { writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": "login session not alive"}) return } switch action { case "open": appendTraceLine("login", "session/action: open") _ = loginMgr.sendKeyLocked("b") loginMgr.setPhaseLocked("waiting_browser", "yellow") case "check": appendTraceLine("login", "session/action: check") _ = loginMgr.sendKeyLocked("s") loginMgr.setPhaseLocked("checking", "yellow") case "cancel": appendTraceLine("login", "session/action: cancel") _ = loginMgr.sendKeyLocked("x") loginMgr.setPhaseLocked("cancelled", "red") default: http.Error(w, "unknown action (open|check|cancel)", http.StatusBadRequest) return } writeJSON(w, http.StatusOK, map[string]any{ "ok": true, "phase": loginMgr.phase, "level": loginMgr.level, }) } // POST /api/v1/vpn/login/session/stop // --------------------------------------------------------------------- // EN: `handleVPNLoginSessionStop` is an HTTP handler for vpn login session stop. // RU: `handleVPNLoginSessionStop` - HTTP-обработчик для vpn login session stop. // --------------------------------------------------------------------- func handleVPNLoginSessionStop(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } loginMgr.mu.Lock() appendTraceLine("login", "session/stop") loginMgr.stopLocked(true) loginMgr.mu.Unlock() writeJSON(w, http.StatusOK, map[string]any{"ok": true}) }