540 lines
15 KiB
Go
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})
|
|
}
|