Files
elmprodvpn/selective-vpn-api/app/vpn_handlers.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

372 lines
10 KiB
Go

package app
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"time"
)
// ---------------------------------------------------------------------
// VPN handlers / status / locations
// ---------------------------------------------------------------------
// EN: VPN-facing HTTP handlers for login state, logout, service/unit control,
// EN: autoloop status, locations, and location switching.
// RU: VPN-ориентированные HTTP-обработчики для login state, logout,
// RU: управления unit/service, статуса autoloop, списка локаций и смены локации.
func handleVPNLoginState(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
state := VPNLoginState{
State: "no_login",
Msg: "login state file not found",
Text: "AdGuard VPN: (no login data)",
Color: "gray30",
}
data, err := os.ReadFile(loginStatePath)
if err == nil {
var fileState VPNLoginState
if err := json.Unmarshal(data, &fileState); err == nil {
if fileState.State != "" {
state.State = fileState.State
}
if fileState.Email != "" {
state.Email = fileState.Email
}
if fileState.Msg != "" {
state.Msg = fileState.Msg
}
} else {
state.State = "error"
state.Msg = "invalid adguard-login.json: " + err.Error()
}
} else if !os.IsNotExist(err) {
state.State = "error"
state.Msg = err.Error()
}
// text/color для GUI
switch state.State {
case "ok":
if state.Email != "" {
state.Text = fmt.Sprintf("AdGuard VPN: logged in as %s", state.Email)
} else {
state.Text = "AdGuard VPN: logged in"
}
state.Color = "green4"
case "no_login":
state.Text = "AdGuard VPN: (no login data)"
state.Color = "gray30"
default:
state.Text = "AdGuard VPN: " + state.State
state.Color = "orange3"
}
writeJSON(w, http.StatusOK, state)
}
// ---------------------------------------------------------------------
// logout
// ---------------------------------------------------------------------
func handleVPNLogout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
appendTraceLine("login", "logout")
stdout, stderr, exitCode, err := runCommand(adgvpnCLI, "logout")
res := cmdResult{
OK: err == nil && exitCode == 0,
ExitCode: exitCode,
Stdout: stdout,
Stderr: stderr,
}
if err != nil {
res.Message = err.Error()
} else {
res.Message = "logout done"
}
// refresh login state
_, _, _, _ = runCommand("systemctl", "restart", adgvpnUnit)
writeJSON(w, http.StatusOK, res)
}
// ---------------------------------------------------------------------
// systemd state
// ---------------------------------------------------------------------
func handleSystemdState(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
unit := strings.TrimSpace(r.URL.Query().Get("unit"))
if unit == "" {
http.Error(w, "unit required", http.StatusBadRequest)
return
}
stdout, _, _, err := runCommand("systemctl", "is-active", unit)
st := strings.TrimSpace(stdout)
if err != nil || st == "" {
st = "unknown"
}
writeJSON(w, http.StatusOK, SystemdState{State: st})
}
// ---------------------------------------------------------------------
// AdGuard autoloop / status parse
// ---------------------------------------------------------------------
// аккуратный разбор лога autoloop: игнорим "route:", смотрим status
func parseAutoloopStatus(lines []string) (word, raw string) {
for i := len(lines) - 1; i >= 0; i-- {
line := strings.TrimSpace(lines[i])
if line == "" {
continue
}
if idx := strings.Index(line, "autoloop:"); idx >= 0 {
line = strings.TrimSpace(line[idx+len("autoloop:"):])
}
lower := strings.ToLower(line)
// route: default dev ... - нам неинтересно
if strings.HasPrefix(lower, "route: ") {
continue
}
switch {
case strings.Contains(lower, "status: connected"),
strings.Contains(lower, "after connect: connected"):
return "CONNECTED", line
case strings.Contains(lower, "status: reconnecting"):
return "RECONNECTING", line
case strings.Contains(lower, "status: disconnected"),
strings.Contains(lower, "still disconnected"):
return "DISCONNECTED", line
case strings.Contains(lower, "timeout"),
strings.Contains(lower, "failed"):
return "ERROR", line
}
}
return "unknown", ""
}
// ---------------------------------------------------------------------
// /api/v1/vpn/autoloop-status
// ---------------------------------------------------------------------
func handleVPNAutoloopStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
lines := tailFile(autoloopLogPath, 200)
word, raw := parseAutoloopStatus(lines)
writeJSON(w, http.StatusOK, map[string]any{
"raw_text": raw,
"status_word": word,
})
}
// ---------------------------------------------------------------------
// /api/v1/vpn/status
// ---------------------------------------------------------------------
func handleVPNStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// desired location
loc := ""
if data, err := os.ReadFile(desiredLocation); err == nil {
loc = strings.TrimSpace(string(data))
}
// unit state
stdout, _, _, err := runCommand("systemctl", "is-active", adgvpnUnit)
unitState := strings.TrimSpace(stdout)
if err != nil || unitState == "" {
unitState = "unknown"
}
// автолуп
lines := tailFile(autoloopLogPath, 200)
word, raw := parseAutoloopStatus(lines)
writeJSON(w, http.StatusOK, map[string]any{
"desired_location": loc,
"status_word": word,
"raw_text": raw,
"unit_state": unitState,
})
}
// ---------------------------------------------------------------------
// /api/v1/vpn/autoconnect
// ---------------------------------------------------------------------
func handleVPNAutoconnect(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body struct {
Action string `json:"action"`
}
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))
var cmd []string
switch action {
case "start":
cmd = []string{"systemctl", "start", adgvpnUnit}
case "stop":
cmd = []string{"systemctl", "stop", adgvpnUnit}
default:
http.Error(w, "unknown action", http.StatusBadRequest)
return
}
stdout, stderr, exitCode, err := runCommand(cmd[0], cmd[1:]...)
res := cmdResult{
OK: err == nil && exitCode == 0,
ExitCode: exitCode,
Stdout: stdout,
Stderr: stderr,
}
if err != nil {
res.Message = err.Error()
}
writeJSON(w, http.StatusOK, res)
}
// ---------------------------------------------------------------------
// /api/v1/vpn/locations
// ---------------------------------------------------------------------
func handleVPNListLocations(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Жесткий таймаут на list-locations, чтобы не клинить HTTP
const locationsTimeout = 7 * time.Second
start := time.Now()
stdout, _, exitCode, err := runCommandTimeout(locationsTimeout, adgvpnCLI, "list-locations")
log.Printf("list-locations took %s (exit=%d, err=%v)", time.Since(start), exitCode, err)
if err != nil || exitCode != 0 {
writeJSON(w, http.StatusOK, map[string]any{
"locations": []any{},
"error": fmt.Sprintf("list-locations failed: %v (exit=%d)", err, exitCode),
})
return
}
stdout = stripANSI(stdout)
var locations []map[string]string
for _, ln := range strings.Split(stdout, "\n") {
line := strings.TrimSpace(ln)
if line == "" {
continue
}
if strings.HasPrefix(line, "ISO ") {
continue
}
if strings.HasPrefix(line, "VPN ") || strings.HasPrefix(line, "You can connect") {
continue
}
parts := strings.Fields(line)
if len(parts) < 4 {
continue
}
iso := parts[0]
ping := parts[len(parts)-1]
if len(iso) != 2 {
continue
}
okPing := true
for _, ch := range ping {
if ch < '0' || ch > '9' {
okPing = false
break
}
}
if !okPing {
continue
}
name := strings.Join(parts[1:len(parts)-1], " ")
label := fmt.Sprintf("%s %s (%s ms)", iso, name, ping)
locations = append(locations, map[string]string{
"label": label,
"iso": iso,
})
}
writeJSON(w, http.StatusOK, map[string]any{
"locations": locations,
})
}
// ---------------------------------------------------------------------
// /api/v1/vpn/location
// ---------------------------------------------------------------------
func handleVPNSetLocation(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body struct {
ISO string `json:"iso"`
}
if r.Body != nil {
defer r.Body.Close()
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
}
val := strings.TrimSpace(body.ISO)
if val == "" {
http.Error(w, "iso is required", http.StatusBadRequest)
return
}
_ = os.MkdirAll(stateDir, 0o755)
if err := os.WriteFile(desiredLocation, []byte(val+"\n"), 0o644); err != nil {
http.Error(w, "write error", http.StatusInternalServerError)
return
}
// как старый GUI: сразу рестартуем автоконнект
_, _, _, _ = runCommand("systemctl", "restart", adgvpnUnit)
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"iso": val,
})
}