From 5123566b1c6e282ecda2c5955148af797b3874a5 Mon Sep 17 00:00:00 2001 From: baoweise Date: Fri, 29 May 2026 13:42:01 +0800 Subject: [PATCH] feat: add country-locking functionality for outbound traffic and optimize status UI interaction in shell scripts --- install.sh | 36 ++++++------ vpngate_manager.py | 136 ++++++++++++++++++++++++++++++++++++++------- 2 files changed, 132 insertions(+), 40 deletions(-) diff --git a/install.sh b/install.sh index dd55c8c..60e155d 100644 --- a/install.sh +++ b/install.sh @@ -756,18 +756,17 @@ def main(): elif cmd == "status": print("\033[?1049h\033[?25l\033[H\033[J", end="", flush=True) try: - last_state = None while True: - current_state = get_status_state() - if current_state != last_state: - print("\033[H", end="") - print_status() - print_line("\n\033[1;33m提示: 正在实时监控状态,自动更新。按任意键或 Ctrl+C 退出...\033[0m") - print("\033[J", end="", flush=True) - last_state = current_state - key = getch_timeout(1.5) - if key is not None: + print("\033[H", end="") + print_status() + print_line("\n\033[1;33m提示: 当前为静态页面。按 [回车键/Enter] 手动刷新状态,按 [q] 或 [Ctrl+C] 退出...\033[0m") + print("\033[J", end="", flush=True) + + key = getch() + if key in ('q', 'Q', '\x03'): break + if key in ('\r', '\n', '\x0a', '\x0d'): + continue except KeyboardInterrupt: pass finally: @@ -804,10 +803,9 @@ def main(): # Enter alternate buffer and hide cursor print("\033[?1049h\033[?25l\033[H\033[J", end="", flush=True) try: - last_state = None + need_redraw = True while True: - current_state = get_status_state() - if current_state != last_state: + if need_redraw: print("\033[H", end="") print_status() @@ -823,18 +821,16 @@ def main(): print_line(f" {green}[{key}]{reset} {name}") print_line(f" {green}[0]{reset} {options['0'][0]}") print_line("=======================================================") + print_line("提示: 当前为静态页面。按 [回车键/Enter] 手动刷新状态。") print("请直接输入数字键 [0-9] 快速选择执行:\033[K", end="", flush=True) print("\033[J", end="", flush=True) - last_state = current_state + need_redraw = False try: - key = getch_timeout(1.0) + key = getch() except KeyboardInterrupt: break - if key is None: - continue - if key == '\x03' or key == 'q' or key == 'Q': break @@ -842,7 +838,7 @@ def main(): break if key in ('\r', '\n', '\x0a', '\x0d'): - last_state = None + need_redraw = True continue if key in options: @@ -865,7 +861,7 @@ def main(): # Re-enter alternate buffer and hide cursor print("\033[?1049h\033[?25l\033[H\033[J", end="", flush=True) - last_state = None + need_redraw = True finally: # Exit alternate buffer and show cursor on exit print("\033[?1049l\033[?25h", end="", flush=True) diff --git a/vpngate_manager.py b/vpngate_manager.py index 68c5d73..ee703a2 100644 --- a/vpngate_manager.py +++ b/vpngate_manager.py @@ -218,6 +218,8 @@ def get_state() -> dict[str, Any]: state["username"] = ui_cfg.get("username", "admin") state["port"] = ui_cfg.get("port", 8787) state["secret_path"] = ui_cfg.get("secret_path", "EJsW2EeBo9lY") + state["enable_force_country"] = ui_cfg.get("enable_force_country", False) + state["force_country"] = ui_cfg.get("force_country", "") return state @@ -776,11 +778,19 @@ def auto_switch_node(attempt: int = 0) -> None: # Find the next best available node with lock: nodes = read_json(NODES_FILE, []) + ui_cfg = load_ui_config() + enable_force = ui_cfg.get("enable_force_country", False) + target_country = ui_cfg.get("force_country", "") + candidates = [ n for n in nodes if n.get("probe_status") == "available" and not n.get("active") ] + + if enable_force and target_country: + candidates = [n for n in candidates if n.get("country") == target_country] + candidates.sort(key=lambda n: (parse_int(n.get("latency_ms")) or 999999, -parse_int(n.get("score")))) if candidates: @@ -993,7 +1003,14 @@ def maintain_valid_nodes(force: bool = False) -> str: with lock: merged = read_json(NODES_FILE, []) if not active_openvpn_running(): + ui_cfg = load_ui_config() + enable_force = ui_cfg.get("enable_force_country", False) + target_country = ui_cfg.get("force_country", "") + available_candidates = [n for n in merged if n.get("probe_status") == "available"] + if enable_force and target_country: + available_candidates = [n for n in available_candidates if n.get("country") == target_country] + if available_candidates: auto_switch_node() @@ -2306,6 +2323,26 @@ INDEX_HTML = r""" + +
+
出站IP路由设置 (锁定出站国家)
+ +
+ + +
+ +
+ + +
+ +
+ ⚠️ 注意:强制指向特定国家的出站IP在没有获取到该国最新可用节点的情况下可能导致代理连接失效。 +
+
安全验证 (必须输入当前账号密码)
@@ -2942,6 +2979,34 @@ if (adminBtn && adminDropdown) { }); } +function toggleForceCountrySelect(enabled) { + $("settings_force_country").disabled = !enabled; +} + +function populateSettingsCountries() { + const select = $("settings_force_country"); + const countMap = {}; + nodes.forEach(n => { + if (n.country) { + countMap[n.country] = (countMap[n.country] || 0) + 1; + } + }); + + const countries = Object.keys(countMap).sort(); + let html = ''; + countries.forEach(c => { + html += ``; + }); + select.innerHTML = html; + + if (state) { + const enabled = state.enable_force_country || false; + $("settings_enable_force").checked = enabled; + select.value = state.force_country || ""; + toggleForceCountrySelect(enabled); + } +} + function openSettingsModal() { $("settings_error").style.display = "none"; $("settings_success").style.display = "none"; @@ -2952,6 +3017,8 @@ function openSettingsModal() { $("settings_suffix").value = state.secret_path || "EJsW2EeBo9lY"; } + populateSettingsCountries(); + $("settings_modal").style.display = "flex"; $("admin_dropdown").style.display = "none"; } @@ -2975,6 +3042,8 @@ async function saveSettings(e) { const newPassword = $("settings_new_password").value.trim(); const currUsername = $("settings_curr_username").value.trim(); const currPassword = $("settings_curr_password").value.trim(); + const enableForce = $("settings_enable_force").checked; + const forceCountry = $("settings_force_country").value; if (isNaN(port) || port < 1 || port > 65535) { errorDivEl.textContent = "端口范围必须在 1 至 65535 之间"; @@ -2988,6 +3057,12 @@ async function saveSettings(e) { return; } + if (enableForce && !forceCountry) { + errorDivEl.textContent = "请选择一个要锁定的目标国家"; + errorDivEl.style.display = "block"; + return; + } + submitBtn.disabled = true; submitBtn.textContent = "正在保存..."; @@ -3001,23 +3076,34 @@ async function saveSettings(e) { new_username: newUsername, new_password: newPassword, curr_username: currUsername, - curr_password: currPassword + curr_password: currPassword, + enable_force_country: enableForce, + force_country: forceCountry }) }); const data = await res.json(); if (res.ok && data.ok) { - successDiv.textContent = "保存成功!页面将在 4 秒内自动跳转至新地址..."; - successDiv.style.display = "block"; - - const inputs = $("settings_form").querySelectorAll("input, button"); - inputs.forEach(el => el.disabled = true); - - setTimeout(() => { - const protocol = window.location.protocol; - const host = window.location.hostname; - window.location.href = `${protocol}//${host}:${port}/${suffix}/`; - }, 4000); + if (data.restart_needed) { + successDiv.textContent = "保存成功!端口/安全路径已变更,页面将在 4 秒内自动跳转..."; + successDiv.style.display = "block"; + + const inputs = $("settings_form").querySelectorAll("input, button, select"); + inputs.forEach(el => el.disabled = true); + + setTimeout(() => { + const protocol = window.location.protocol; + const host = window.location.hostname; + window.location.href = `${protocol}//${host}:${port}/${suffix}/`; + }, 4000); + } else { + successDiv.textContent = "配置保存成功,已即时生效!"; + successDiv.style.display = "block"; + setTimeout(() => { + closeSettingsModal(); + load(); + }, 1500); + } } else { errorDivEl.textContent = data.error || "保存失败,请检查输入"; errorDivEl.style.display = "block"; @@ -3411,6 +3497,8 @@ class Handler(BaseHTTPRequestHandler): new_suffix = str(payload.get("secret_path") or "").strip() new_username = str(payload.get("new_username") or "").strip() new_password = str(payload.get("new_password") or "").strip() + enable_force_country = bool(payload.get("enable_force_country")) + force_country = str(payload.get("force_country") or "").strip() if not curr_username or not curr_password: self.send_json({"ok": False, "error": "请输入当前账号和密码进行安全验证"}, HTTPStatus.FORBIDDEN) @@ -3419,6 +3507,8 @@ class Handler(BaseHTTPRequestHandler): ui_cfg = load_ui_config() expected_uname = ui_cfg.get("username", "admin") expected_pwd = ui_cfg.get("password", "") + expected_port = ui_cfg.get("port", 8787) + expected_suffix = ui_cfg.get("secret_path", "EJsW2EeBo9lY") if curr_username != expected_uname or curr_password != expected_pwd: self.send_json({"ok": False, "error": "当前账号或密码不正确"}, HTTPStatus.FORBIDDEN) @@ -3438,6 +3528,8 @@ class Handler(BaseHTTPRequestHandler): ui_cfg["port"] = new_port_int ui_cfg["secret_path"] = new_suffix + ui_cfg["enable_force_country"] = enable_force_country + ui_cfg["force_country"] = force_country if new_username: ui_cfg["username"] = new_username if new_password: @@ -3448,14 +3540,18 @@ class Handler(BaseHTTPRequestHandler): DATA_DIR.mkdir(exist_ok=True, parents=True) auth_file.write_text(json.dumps(ui_cfg, ensure_ascii=False, indent=2), encoding="utf-8") - self.send_json({"ok": True, "message": "配置更新成功,系统将在 2 秒内重启..."}) - - def restart_server(): - time.sleep(2) - print("[系统] 管理后台配置更新,进程即将退出以触发自动重启...", flush=True) - os._exit(0) - - threading.Thread(target=restart_server, daemon=True).start() + restart_needed = (new_port_int != expected_port or new_suffix != expected_suffix) + if restart_needed: + self.send_json({"ok": True, "restart_needed": True, "message": "配置更新成功,系统及网页路径变更,将在 2 秒内重启..."}) + + def restart_server(): + time.sleep(2) + print("[系统] 管理后台配置更新,进程即将退出以触发自动重启...", flush=True) + os._exit(0) + + threading.Thread(target=restart_server, daemon=True).start() + else: + self.send_json({"ok": True, "restart_needed": False, "message": "配置更新成功,已即时生效!"}) except Exception as exc: self.send_json({"ok": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR) return