372 lines
10 KiB
Go
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,
|
|
})
|
|
}
|