package app import ( "encoding/json" "fmt" "io" "log" "net/http" "os" "strings" "syscall" "time" ) // --------------------------------------------------------------------- // 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 { return withRoutesOpLock("routes clear", routesClearUnlocked) } func routesClearUnlocked() 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") iface := strings.TrimSpace(cacheMeta.Iface) if iface == "" { iface, _ = resolveTrafficIface(loadTrafficModeState().PreferredIface) } _ = writeStatusSnapshot(0, iface) 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 } func withRoutesOpLock(opName string, fn func() cmdResult) cmdResult { lock, err := os.OpenFile(lockFile, os.O_CREATE|os.O_RDWR, 0o644) if err != nil { return cmdResult{ OK: false, Message: fmt.Sprintf("%s lock open error: %v", opName, err), } } defer lock.Close() if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { return cmdResult{ OK: false, Message: fmt.Sprintf("%s skipped: routes operation already running", opName), } } defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) return fn() } func writeStatusSnapshot(ipCount int, iface string) error { if ipCount < 0 { ipCount = 0 } iface = strings.TrimSpace(iface) if iface == "" { iface = "-" } st := Status{ Timestamp: time.Now().UTC().Format(time.RFC3339), IPCount: ipCount, DomainCount: countDomainsFromMap(lastIPsMapPath), Iface: iface, Table: routesTableName(), Mark: MARK, } data, err := json.MarshalIndent(st, "", " ") if err != nil { return err } return os.WriteFile(statusFilePath, data, 0o644) } // --------------------------------------------------------------------- // 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", }) }