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