Files
elmprodvpn/selective-vpn-api/app/vpn_login_session.go
beckline 10a10f44a8 baseline: api+gui traffic mode + candidates picker
Snapshot before app-launcher (cgroup/mark) work; ignore binaries/backups.
2026-02-14 15:52:20 +03:00

540 lines
15 KiB
Go

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})
}