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