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

406 lines
12 KiB
Go

package app
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"syscall"
)
// ---------------------------------------------------------------------
// routes handlers
// ---------------------------------------------------------------------
// EN: HTTP handlers for selective routing control plane operations:
// EN: status, systemd service/timer control, route cleanup, policy fix, and async update trigger.
// RU: HTTP-обработчики control-plane для селективной маршрутизации:
// RU: статус, управление service/timer через systemd, очистка, фиксация policy route и запуск обновления.
func handleGetStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
data, err := os.ReadFile(statusFilePath)
if err != nil {
if os.IsNotExist(err) {
http.Error(w, "status file not found", http.StatusNotFound)
return
}
http.Error(w, "failed to read status file", http.StatusInternalServerError)
return
}
var st Status
if err := json.Unmarshal(data, &st); err != nil {
http.Error(w, "invalid status.json", http.StatusInternalServerError)
return
}
if st.Iface != "" && st.Iface != "-" && st.Table != "" && st.Table != "-" {
ok, err := checkPolicyRoute(st.Iface, st.Table)
if err != nil {
log.Printf("checkPolicyRoute error: %v", err)
} else {
st.PolicyRouteOK = &ok
st.RouteOK = &ok
}
}
writeJSON(w, http.StatusOK, st)
}
// ---------------------------------------------------------------------
// routes service
// ---------------------------------------------------------------------
func makeCmdHandler(name string, args ...string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
stdout, stderr, code, err := runCommand(name, args...)
res := cmdResult{
OK: err == nil && code == 0,
ExitCode: code,
Stdout: stdout,
Stderr: stderr,
}
if err != nil {
res.Message = err.Error()
}
writeJSON(w, http.StatusOK, res)
}
}
func runRoutesServiceAction(action string) cmdResult {
action = strings.ToLower(strings.TrimSpace(action))
unit := routesServiceUnitName()
if unit == "" {
return cmdResult{
OK: false,
Message: "routes service unit unresolved: set preferred iface or SELECTIVE_VPN_ROUTES_UNIT",
}
}
var args []string
switch action {
case "start", "stop", "restart":
args = []string{"systemctl", action, unit}
default:
return cmdResult{
OK: false,
Message: "unknown action (expected start|stop|restart)",
}
}
stdout, stderr, exitCode, err := runCommand(args[0], args[1:]...)
res := cmdResult{
OK: err == nil && exitCode == 0,
ExitCode: exitCode,
Stdout: stdout,
Stderr: stderr,
}
if err != nil {
res.Message = err.Error()
} else {
res.Message = fmt.Sprintf("%s done (%s)", action, unit)
}
return res
}
func makeRoutesServiceActionHandler(action string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
res := runRoutesServiceAction(action)
writeJSON(w, http.StatusOK, res)
}
}
// POST /api/v1/routes/service { "action": "start|stop|restart" }
// ---------------------------------------------------------------------
// EN: `handleRoutesService` is an HTTP handler for routes service.
// RU: `handleRoutesService` - HTTP-обработчик для routes service.
// ---------------------------------------------------------------------
func handleRoutesService(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)
}
res := runRoutesServiceAction(body.Action)
if strings.Contains(res.Message, "unknown action") {
writeJSON(w, http.StatusBadRequest, res)
return
}
writeJSON(w, http.StatusOK, res)
}
// ---------------------------------------------------------------------
// routes timer
// ---------------------------------------------------------------------
// старый toggle (используем из GUI, если что)
func handleRoutesTimerToggle(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
enabled := isTimerEnabled()
res := runRoutesTimerSet(!enabled)
writeJSON(w, http.StatusOK, res)
}
// новый API: GET → {enabled:bool}, POST {enabled:bool}
// ---------------------------------------------------------------------
// EN: `handleRoutesTimer` is an HTTP handler for routes timer.
// RU: `handleRoutesTimer` - HTTP-обработчик для routes timer.
// ---------------------------------------------------------------------
func handleRoutesTimer(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
enabled := isTimerEnabled()
writeJSON(w, http.StatusOK, map[string]any{
"enabled": enabled,
})
case http.MethodPost:
var body struct {
Enabled bool `json:"enabled"`
}
if r.Body != nil {
defer r.Body.Close()
_ = json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body)
}
res := runRoutesTimerSet(body.Enabled)
writeJSON(w, http.StatusOK, res)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// ---------------------------------------------------------------------
// EN: `isTimerEnabled` checks whether timer enabled is true.
// RU: `isTimerEnabled` - проверяет, является ли timer enabled истинным условием.
// ---------------------------------------------------------------------
func isTimerEnabled() bool {
unit := routesTimerUnitName()
if unit == "" {
return false
}
_, _, code, _ := runCommand("systemctl", "is-enabled", unit)
return code == 0
}
func runRoutesTimerSet(enabled bool) cmdResult {
unit := routesTimerUnitName()
if unit == "" {
return cmdResult{
OK: false,
Message: "routes timer unit unresolved: set preferred iface or SELECTIVE_VPN_ROUTES_TIMER",
}
}
cmd := []string{"systemctl", "disable", "--now", unit}
msg := "routes timer disabled"
if enabled {
cmd = []string{"systemctl", "enable", "--now", unit}
msg = "routes timer enabled"
}
stdout, stderr, exitCode, err := runCommand(cmd[0], cmd[1:]...)
res := cmdResult{
OK: err == nil && exitCode == 0,
Message: fmt.Sprintf("%s (%s)", msg, unit),
ExitCode: exitCode,
Stdout: stdout,
Stderr: stderr,
}
if err != nil {
res.Message = fmt.Sprintf("%s (%s): %v", msg, unit, err)
}
return res
}
// ---------------------------------------------------------------------
// rollback / clear
// ---------------------------------------------------------------------
func handleRoutesClear(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
res := routesClear()
writeJSON(w, http.StatusOK, res)
}
func handleRoutesCacheRestore(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
res := restoreRoutesFromCache()
writeJSON(w, http.StatusOK, res)
}
// ---------------------------------------------------------------------
// EN: `routesClear` contains core logic for routes clear.
// RU: `routesClear` - содержит основную логику для routes clear.
// ---------------------------------------------------------------------
func routesClear() cmdResult {
cacheMeta, cacheErr := saveRoutesClearCache()
stdout, stderr, _, err := runCommand("ip", "rule", "show")
if err == nil && stdout != "" {
removeTrafficRulesForTable()
}
_, _, _, _ = runCommand("ip", "route", "flush", "table", routesTableName())
_, _, _, _ = runCommand("nft", "flush", "set", "inet", "agvpn", "agvpn4")
_, _, _, _ = runCommand("nft", "flush", "set", "inet", "agvpn", "agvpn_dyn4")
res := cmdResult{
OK: true,
Message: "routes cleared",
ExitCode: 0,
Stdout: stdout,
Stderr: stderr,
}
if cacheErr != nil {
res.Message = fmt.Sprintf("%s (cache warning: %v)", res.Message, cacheErr)
} else {
res.Message = fmt.Sprintf(
"%s (cache saved: agvpn4=%d agvpn_dyn4=%d routes=%d iface=%s at=%s)",
res.Message,
cacheMeta.IPCount,
cacheMeta.DynIPCount,
cacheMeta.RouteCount,
ifaceOrDash(cacheMeta.Iface),
cacheMeta.CreatedAt,
)
}
return res
}
// ---------------------------------------------------------------------
// policy route
// ---------------------------------------------------------------------
func handleFixPolicyRoute(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
data, err := os.ReadFile(statusFilePath)
if err != nil {
http.Error(w, "status.json missing", http.StatusBadRequest)
return
}
var st Status
if err := json.Unmarshal(data, &st); err != nil {
http.Error(w, "invalid status.json", http.StatusBadRequest)
return
}
iface := strings.TrimSpace(st.Iface)
table := strings.TrimSpace(st.Table)
if iface == "" || iface == "-" || table == "" || table == "-" {
http.Error(w, "iface/table unknown in status.json", http.StatusBadRequest)
return
}
stdout, stderr, exitCode, err := runCommand(
"ip", "-4", "route", "replace",
"default", "dev", iface, "table", table, "mtu", policyRouteMTU,
)
ok := err == nil && exitCode == 0
res := cmdResult{
OK: ok,
ExitCode: exitCode,
Stdout: stdout,
Stderr: stderr,
}
if ok {
res.Message = fmt.Sprintf("policy route fixed: default dev %s table %s", iface, table)
} else if err != nil {
res.Message = err.Error()
}
writeJSON(w, http.StatusOK, res)
}
// ---------------------------------------------------------------------
// routes update (Go port of update-selective-routes2.sh)
// ---------------------------------------------------------------------
func handleRoutesUpdate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body struct {
Iface string `json:"iface"`
}
if r.Body != nil {
defer r.Body.Close()
_ = json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body)
}
iface := strings.TrimSpace(body.Iface)
iface = normalizePreferredIface(iface)
if iface == "" {
iface, _ = resolveTrafficIface(loadTrafficModeState().PreferredIface)
}
lock, err := os.OpenFile(lockFile, os.O_CREATE|os.O_RDWR, 0o644)
if err != nil {
http.Error(w, "lock open error", http.StatusInternalServerError)
return
}
if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
writeJSON(w, http.StatusOK, map[string]any{
"ok": false,
"message": "routes update already running",
})
lock.Close()
return
}
go func(iface string, lockFile *os.File) {
defer syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN)
defer lockFile.Close()
res := routesUpdate(iface)
evKind := "routes_update_done"
if !res.OK {
evKind = "routes_update_error"
}
events.push(evKind, map[string]any{
"ok": res.OK,
"message": res.Message,
"ip_cnt": res.ExitCode, // reuse exitCode to pass ip_count if set
})
}(iface, lock)
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"message": "routes update started",
})
}