feat: add country-locking functionality for outbound traffic and optimize status UI interaction in shell scripts

This commit is contained in:
baoweise
2026-05-29 13:42:01 +08:00
parent 71aaa5418c
commit 5123566b1c
2 changed files with 132 additions and 40 deletions
+16 -20
View File
@@ -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)
+116 -20
View File
@@ -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"""<!doctype html>
<input type="password" id="settings_new_password" class="input-field" placeholder="留空则不修改">
</div>
</div>
<div style="border-bottom: 1px solid rgba(255,255,255,0.05); padding-bottom: 16px; margin-bottom: 16px;">
<div style="font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-secondary); font-weight: 600; margin-bottom: 12px;">出站IP路由设置 (锁定出站国家)</div>
<div class="form-group" style="margin-bottom: 12px; display: flex; align-items: center; gap: 8px;">
<input type="checkbox" id="settings_enable_force" style="width: 16px; height: 16px; cursor: pointer; margin: 0;" onchange="toggleForceCountrySelect(this.checked)">
<label class="form-label" for="settings_enable_force" style="margin-bottom: 0; cursor: pointer; user-select: none;">启用强制锁定出站国家/地区</label>
</div>
<div class="form-group" style="margin-bottom: 12px;">
<label class="form-label" for="settings_force_country">锁定国家</label>
<select id="settings_force_country" class="input-field" style="background: rgba(255, 255, 255, 0.03); border: 1px solid var(--border-color); color: var(--text-primary); outline: none; cursor: pointer; width: 100%; height: 40px; border-radius: 8px; padding: 0 12px;" disabled>
<option value="">正在加载节点国家...</option>
</select>
</div>
<div style="font-size: 12px; color: var(--warning); line-height: 1.4; padding: 8px 12px; background: rgba(245, 158, 11, 0.1); border: 1px solid rgba(245, 158, 11, 0.2); border-radius: 6px; margin-top: 8px;">
⚠️ <strong>注意</strong>:强制指向特定国家的出站IP在没有获取到该国最新可用节点的情况下可能导致代理连接失效。
</div>
</div>
<div style="margin-bottom: 24px;">
<div style="font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-secondary); font-weight: 600; margin-bottom: 12px;">安全验证 (必须输入当前账号密码)</div>
@@ -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 = '<option value="">请选择要锁定的国家...</option>';
countries.forEach(c => {
html += `<option value="${esc(c)}">${esc(c)} (${countMap[c]}个节点)</option>`;
});
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