mirror of
https://github.com/baoweise-bot/aimili-vpngate.git
synced 2026-09-07 00:36:49 +08:00
feat: add granular routing controls, connection toggling, and optimize node testing and sorting logic
This commit is contained in:
+210
-60
@@ -78,8 +78,8 @@ import vpn_utils
|
|||||||
import proxy_server
|
import proxy_server
|
||||||
|
|
||||||
API_URL = "https://www.vpngate.net/api/iphone/"
|
API_URL = "https://www.vpngate.net/api/iphone/"
|
||||||
FETCH_INTERVAL_SECONDS = int(os.environ.get("FETCH_INTERVAL_SECONDS", "960"))
|
FETCH_INTERVAL_SECONDS = int(os.environ.get("FETCH_INTERVAL_SECONDS", "1260"))
|
||||||
CHECK_INTERVAL_SECONDS = int(os.environ.get("CHECK_INTERVAL_SECONDS", "960"))
|
CHECK_INTERVAL_SECONDS = int(os.environ.get("CHECK_INTERVAL_SECONDS", "1260"))
|
||||||
TARGET_VALID_NODES = int(os.environ.get("TARGET_VALID_NODES", "3"))
|
TARGET_VALID_NODES = int(os.environ.get("TARGET_VALID_NODES", "3"))
|
||||||
MAX_SCAN_ROWS = int(os.environ.get("MAX_SCAN_ROWS", "300"))
|
MAX_SCAN_ROWS = int(os.environ.get("MAX_SCAN_ROWS", "300"))
|
||||||
OPENVPN_TEST_TIMEOUT_SECONDS = int(os.environ.get("OPENVPN_TEST_TIMEOUT_SECONDS", "35"))
|
OPENVPN_TEST_TIMEOUT_SECONDS = int(os.environ.get("OPENVPN_TEST_TIMEOUT_SECONDS", "35"))
|
||||||
@@ -171,7 +171,12 @@ def load_ui_config() -> dict[str, Any]:
|
|||||||
"secret_path": "EJsW2EeBo9lY",
|
"secret_path": "EJsW2EeBo9lY",
|
||||||
"password": "",
|
"password": "",
|
||||||
"host": "::",
|
"host": "::",
|
||||||
"port": 8787
|
"port": 8787,
|
||||||
|
"routing_mode": "auto",
|
||||||
|
"force_country": "",
|
||||||
|
"routing_ip_type": "all",
|
||||||
|
"connection_enabled": True,
|
||||||
|
"fixed_node_id": ""
|
||||||
}
|
}
|
||||||
updated = False
|
updated = False
|
||||||
if auth_file.exists():
|
if auth_file.exists():
|
||||||
@@ -179,6 +184,9 @@ def load_ui_config() -> dict[str, Any]:
|
|||||||
data = json.loads(auth_file.read_text(encoding="utf-8"))
|
data = json.loads(auth_file.read_text(encoding="utf-8"))
|
||||||
for key, val in data.items():
|
for key, val in data.items():
|
||||||
config[key] = val
|
config[key] = val
|
||||||
|
for key in ["routing_mode", "force_country", "routing_ip_type", "connection_enabled", "fixed_node_id"]:
|
||||||
|
if key not in data:
|
||||||
|
updated = True
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -292,6 +300,9 @@ def get_state() -> dict[str, Any]:
|
|||||||
state["proxy_port"] = ui_cfg.get("proxy_port", 7928)
|
state["proxy_port"] = ui_cfg.get("proxy_port", 7928)
|
||||||
state["routing_mode"] = ui_cfg.get("routing_mode", "auto")
|
state["routing_mode"] = ui_cfg.get("routing_mode", "auto")
|
||||||
state["force_country"] = ui_cfg.get("force_country", "")
|
state["force_country"] = ui_cfg.get("force_country", "")
|
||||||
|
state["routing_ip_type"] = ui_cfg.get("routing_ip_type", "all")
|
||||||
|
state["connection_enabled"] = ui_cfg.get("connection_enabled", True)
|
||||||
|
state["fixed_node_id"] = ui_cfg.get("fixed_node_id", "")
|
||||||
|
|
||||||
return state
|
return state
|
||||||
|
|
||||||
@@ -846,7 +857,11 @@ def active_openvpn_running() -> bool:
|
|||||||
def sort_all_nodes(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
def sort_all_nodes(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
available_nodes = sorted(
|
available_nodes = sorted(
|
||||||
[n for n in nodes if n.get("probe_status") == "available" or n.get("active")],
|
[n for n in nodes if n.get("probe_status") == "available" or n.get("active")],
|
||||||
key=lambda n: (parse_int(n.get("latency_ms")) or 999999, -parse_int(n.get("score")))
|
key=lambda n: (
|
||||||
|
0 if n.get("ip_type") in ("residential", "mobile") else 1,
|
||||||
|
parse_int(n.get("latency_ms")) or 999999,
|
||||||
|
-parse_int(n.get("score"))
|
||||||
|
)
|
||||||
)
|
)
|
||||||
untested_nodes = sorted(
|
untested_nodes = sorted(
|
||||||
[n for n in nodes if n.get("probe_status") == "not_checked" and not n.get("active")],
|
[n for n in nodes if n.get("probe_status") == "not_checked" and not n.get("active")],
|
||||||
@@ -1008,7 +1023,8 @@ def test_multiple_nodes(node_ids: list[str]) -> list[dict[str, Any]]:
|
|||||||
return temp_node
|
return temp_node
|
||||||
|
|
||||||
updated_nodes_map = {}
|
updated_nodes_map = {}
|
||||||
with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, len(to_test))) as executor:
|
max_workers = min(80, max(1, len(to_test)))
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||||
futures = {executor.submit(test_worker, (idx, n)): n["id"] for idx, n in enumerate(to_test)}
|
futures = {executor.submit(test_worker, (idx, n)): n["id"] for idx, n in enumerate(to_test)}
|
||||||
for future in concurrent.futures.as_completed(futures):
|
for future in concurrent.futures.as_completed(futures):
|
||||||
nid = futures[future]
|
nid = futures[future]
|
||||||
@@ -1040,20 +1056,16 @@ def auto_switch_node(attempt: int = 0) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
ui_cfg = load_ui_config()
|
ui_cfg = load_ui_config()
|
||||||
|
connection_enabled = ui_cfg.get("connection_enabled", True)
|
||||||
|
if not connection_enabled:
|
||||||
|
print("[自动切换] 连接已禁用,不进行自动切换。", flush=True)
|
||||||
|
return
|
||||||
|
|
||||||
routing_mode = ui_cfg.get("routing_mode", "auto")
|
routing_mode = ui_cfg.get("routing_mode", "auto")
|
||||||
target_country = ui_cfg.get("force_country", "")
|
target_country = ui_cfg.get("force_country", "")
|
||||||
|
|
||||||
if routing_mode == "fixed_ip":
|
if routing_mode == "fixed_ip":
|
||||||
print("[自动切换] 当前处于固定 IP 模式,不进行自动切换。", flush=True)
|
print("[自动切换] 当前处于固定 IP 模式,不进行自动连接或切换。", flush=True)
|
||||||
if active_openvpn_node_id:
|
|
||||||
if not active_openvpn_running():
|
|
||||||
print(f"[自动切换] 固定 IP 模式检测到连接已断开,尝试重新连接原节点: {active_openvpn_node_id}", flush=True)
|
|
||||||
def reconnect_bg():
|
|
||||||
try:
|
|
||||||
connect_node(active_openvpn_node_id)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[自动切换] 重新连接固定节点失败: {e}", flush=True)
|
|
||||||
threading.Thread(target=reconnect_bg, daemon=True).start()
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Find the next best available node
|
# Find the next best available node
|
||||||
@@ -1068,6 +1080,13 @@ def auto_switch_node(attempt: int = 0) -> None:
|
|||||||
if routing_mode == "fixed_region" and target_country:
|
if routing_mode == "fixed_region" and target_country:
|
||||||
candidates = [n for n in candidates if n.get("country") == target_country]
|
candidates = [n for n in candidates if n.get("country") == target_country]
|
||||||
|
|
||||||
|
# Apply routing_ip_type filter
|
||||||
|
routing_ip_type = ui_cfg.get("routing_ip_type", "all")
|
||||||
|
if routing_ip_type == "residential":
|
||||||
|
candidates = [n for n in candidates if n.get("ip_type") in ("residential", "mobile")]
|
||||||
|
elif routing_ip_type == "hosting":
|
||||||
|
candidates = [n for n in candidates if n.get("ip_type") == "hosting"]
|
||||||
|
|
||||||
candidates.sort(key=lambda n: (parse_int(n.get("latency_ms")) or 999999, -parse_int(n.get("score"))))
|
candidates.sort(key=lambda n: (parse_int(n.get("latency_ms")) or 999999, -parse_int(n.get("score"))))
|
||||||
|
|
||||||
if candidates:
|
if candidates:
|
||||||
@@ -1117,6 +1136,16 @@ def connect_node(node_id: str) -> str:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
log_to_json("INFO", "VPN", f"开始连接节点: {node_id}")
|
log_to_json("INFO", "VPN", f"开始连接节点: {node_id}")
|
||||||
|
|
||||||
|
ui_cfg = load_ui_config()
|
||||||
|
ui_cfg["connection_enabled"] = True
|
||||||
|
if ui_cfg.get("routing_mode") == "fixed_ip":
|
||||||
|
ui_cfg["fixed_node_id"] = node_id
|
||||||
|
auth_file = DATA_DIR / "ui_auth.json"
|
||||||
|
with lock:
|
||||||
|
DATA_DIR.mkdir(exist_ok=True, parents=True)
|
||||||
|
auth_file.write_text(json.dumps(ui_cfg, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
nodes = read_json(NODES_FILE, [])
|
nodes = read_json(NODES_FILE, [])
|
||||||
node = next((item for item in nodes if item.get("id") == node_id), None)
|
node = next((item for item in nodes if item.get("id") == node_id), None)
|
||||||
if not node:
|
if not node:
|
||||||
@@ -1215,16 +1244,20 @@ def maintain_valid_nodes(force: bool = False) -> str:
|
|||||||
with lock:
|
with lock:
|
||||||
stop_active_openvpn()
|
stop_active_openvpn()
|
||||||
elif not active_openvpn_running():
|
elif not active_openvpn_running():
|
||||||
has_active_id = False
|
ui_cfg = load_ui_config()
|
||||||
with lock:
|
routing_mode = ui_cfg.get("routing_mode", "auto")
|
||||||
if active_openvpn_node_id:
|
connection_enabled = ui_cfg.get("connection_enabled", True)
|
||||||
has_active_id = True
|
if connection_enabled and routing_mode != "fixed_ip":
|
||||||
stop_active_openvpn()
|
has_active_id = False
|
||||||
if has_active_id:
|
with lock:
|
||||||
print("[维护线程] 检测到当前 OpenVPN 进程已意外退出,准备自动切换节点", flush=True)
|
if active_openvpn_node_id:
|
||||||
is_connecting = False
|
has_active_id = True
|
||||||
auto_switch_node()
|
stop_active_openvpn()
|
||||||
is_connecting = True
|
if has_active_id:
|
||||||
|
print("[维护线程] 检测到当前 OpenVPN 进程已意外退出,准备自动切换节点", flush=True)
|
||||||
|
is_connecting = False
|
||||||
|
auto_switch_node()
|
||||||
|
is_connecting = True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
set_state(is_connecting=True, last_check_message="正在拉取最新的免费 VPN 节点列表...")
|
set_state(is_connecting=True, last_check_message="正在拉取最新的免费 VPN 节点列表...")
|
||||||
@@ -1273,22 +1306,14 @@ def maintain_valid_nodes(force: bool = False) -> str:
|
|||||||
|
|
||||||
write_json(NODES_FILE, merged)
|
write_json(NODES_FILE, merged)
|
||||||
|
|
||||||
# Test the first 10 non-active nodes from the new list
|
# Test all non-active nodes from the list
|
||||||
with lock:
|
with lock:
|
||||||
current_nodes = read_json(NODES_FILE, [])
|
current_nodes = read_json(NODES_FILE, [])
|
||||||
ui_cfg = load_ui_config()
|
to_test = [n for n in current_nodes if not n.get("active")]
|
||||||
routing_mode = ui_cfg.get("routing_mode", "auto")
|
|
||||||
target_country = ui_cfg.get("force_country", "")
|
|
||||||
|
|
||||||
if routing_mode == "fixed_region" and target_country:
|
|
||||||
to_test = [n for n in current_nodes if not n.get("active") and n.get("country") == target_country][:10]
|
|
||||||
else:
|
|
||||||
to_test = [n for n in current_nodes if not n.get("active")][:10]
|
|
||||||
|
|
||||||
to_test_ids = [n["id"] for n in to_test]
|
to_test_ids = [n["id"] for n in to_test]
|
||||||
|
|
||||||
print(f"[维护线程] 正在检测新获取列表的前 10 个节点: {to_test_ids}", flush=True)
|
print(f"[维护线程] 正在并发检测列表中所有节点,共 {len(to_test_ids)} 个...", flush=True)
|
||||||
set_state(is_connecting=True, last_check_message="正在并发检测筛选可用节点,这可能需要 5-30 秒...")
|
set_state(is_connecting=True, last_check_message="正在并发检测所有节点可用性...")
|
||||||
test_multiple_nodes(to_test_ids)
|
test_multiple_nodes(to_test_ids)
|
||||||
|
|
||||||
is_connecting = False
|
is_connecting = False
|
||||||
@@ -1297,19 +1322,25 @@ def maintain_valid_nodes(force: bool = False) -> str:
|
|||||||
merged = read_json(NODES_FILE, [])
|
merged = read_json(NODES_FILE, [])
|
||||||
if not active_openvpn_running():
|
if not active_openvpn_running():
|
||||||
ui_cfg = load_ui_config()
|
ui_cfg = load_ui_config()
|
||||||
routing_mode = ui_cfg.get("routing_mode", "auto")
|
connection_enabled = ui_cfg.get("connection_enabled", True)
|
||||||
target_country = ui_cfg.get("force_country", "")
|
if connection_enabled:
|
||||||
|
routing_mode = ui_cfg.get("routing_mode", "auto")
|
||||||
if routing_mode == "fixed_ip":
|
target_country = ui_cfg.get("force_country", "")
|
||||||
if active_openvpn_node_id:
|
|
||||||
auto_switch_node()
|
|
||||||
else:
|
|
||||||
available_candidates = [n for n in merged if n.get("probe_status") == "available"]
|
|
||||||
if routing_mode == "fixed_region" and target_country:
|
|
||||||
available_candidates = [n for n in available_candidates if n.get("country") == target_country]
|
|
||||||
|
|
||||||
if available_candidates:
|
if routing_mode != "fixed_ip":
|
||||||
auto_switch_node()
|
available_candidates = [n for n in merged if n.get("probe_status") == "available"]
|
||||||
|
if routing_mode == "fixed_region" and target_country:
|
||||||
|
available_candidates = [n for n in available_candidates if n.get("country") == target_country]
|
||||||
|
|
||||||
|
# Apply routing_ip_type filter for auto-connect
|
||||||
|
routing_ip_type = ui_cfg.get("routing_ip_type", "all")
|
||||||
|
if routing_ip_type == "residential":
|
||||||
|
available_candidates = [n for n in available_candidates if n.get("ip_type") in ("residential", "mobile")]
|
||||||
|
elif routing_ip_type == "hosting":
|
||||||
|
available_candidates = [n for n in available_candidates if n.get("ip_type") == "hosting"]
|
||||||
|
|
||||||
|
if available_candidates:
|
||||||
|
auto_switch_node()
|
||||||
|
|
||||||
valid_nodes_count = len([n for n in merged if n.get("probe_status") == "available"])
|
valid_nodes_count = len([n for n in merged if n.get("probe_status") == "available"])
|
||||||
message = f"Fetched {len(candidates)} nodes. Tested first 10 nodes."
|
message = f"Fetched {len(candidates)} nodes. Tested first 10 nodes."
|
||||||
@@ -2507,6 +2538,20 @@ INDEX_HTML = r"""<!doctype html>
|
|||||||
<div id="status" class="status" style="display: none;"><span class="status-dot"></span>服务加载中...</div>
|
<div id="status" class="status" style="display: none;"><span class="status-dot"></span>服务加载中...</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
|
<div class="routing-select-wrapper" style="display: inline-flex; align-items: center; gap: 6px; background: rgba(255,255,255,0.06); border: 1px solid var(--border-color); padding: 0 12px; border-radius: 8px; font-size: 13px; height: 38px;">
|
||||||
|
<label for="header_routing_country" style="color: var(--text-secondary); font-weight: 500; white-space: nowrap;">出站国家:</label>
|
||||||
|
<select id="header_routing_country" style="background: transparent; border: none; color: var(--text-primary); outline: none; cursor: pointer; font-size: 13px; font-weight: 600; padding: 0;">
|
||||||
|
<option value="">智能路由 / 所有</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="routing-select-wrapper" style="display: inline-flex; align-items: center; gap: 6px; background: rgba(255,255,255,0.06); border: 1px solid var(--border-color); padding: 0 12px; border-radius: 8px; font-size: 13px; height: 38px;">
|
||||||
|
<label for="header_routing_ip_type" style="color: var(--text-secondary); font-weight: 500; white-space: nowrap;">IP类型:</label>
|
||||||
|
<select id="header_routing_ip_type" style="background: transparent; border: none; color: var(--text-primary); outline: none; cursor: pointer; font-size: 13px; font-weight: 600; padding: 0;">
|
||||||
|
<option value="all">所有IP类型</option>
|
||||||
|
<option value="residential">仅静态住宅IP</option>
|
||||||
|
<option value="hosting">仅机房IP</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="dropdown">
|
<div class="dropdown">
|
||||||
<button id="github_btn" class="btn-primary" style="background: rgba(255, 255, 255, 0.08); border: 1px solid var(--border-color); color: var(--text-primary);">
|
<button id="github_btn" class="btn-primary" style="background: rgba(255, 255, 255, 0.08); border: 1px solid var(--border-color); color: var(--text-primary);">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" style="vertical-align: middle; margin-right: 4px;"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" style="vertical-align: middle; margin-right: 4px;"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
|
||||||
@@ -2570,6 +2615,11 @@ INDEX_HTML = r"""<!doctype html>
|
|||||||
<select id="country_filter">
|
<select id="country_filter">
|
||||||
<option value="">所有国家</option>
|
<option value="">所有国家</option>
|
||||||
</select>
|
</select>
|
||||||
|
<select id="ip_type_filter">
|
||||||
|
<option value="">所有IP类型</option>
|
||||||
|
<option value="residential">住宅IP</option>
|
||||||
|
<option value="hosting">机房IP</option>
|
||||||
|
</select>
|
||||||
<input id="search" placeholder="输入国家、位置、IP、ASN、运营主体等过滤节点..." />
|
<input id="search" placeholder="输入国家、位置、IP、ASN、运营主体等过滤节点..." />
|
||||||
<button id="btn_batch_test" class="btn-primary" style="height: 42px; padding: 0 20px; font-weight: 600; background: var(--primary-gradient);">
|
<button id="btn_batch_test" class="btn-primary" style="height: 42px; padding: 0 20px; font-weight: 600; background: var(--primary-gradient);">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" style="width:16px; height:16px;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" style="width:16px; height:16px;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||||
@@ -2597,7 +2647,7 @@ INDEX_HTML = r"""<!doctype html>
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 分页控制栏 -->
|
<!-- 分页控制栏 -->
|
||||||
<div class="pagination-container" style="padding: 16px; display: flex; justify-content: space-between; align-items: center; border-top: 1px solid var(--border-color); flex-wrap: wrap; gap: 12px;">
|
<div class="pagination-container" style="padding: 16px; display: none; justify-content: space-between; align-items: center; border-top: 1px solid var(--border-color); flex-wrap: wrap; gap: 12px;">
|
||||||
<div style="font-size: 13px; color: var(--text-secondary);">
|
<div style="font-size: 13px; color: var(--text-secondary);">
|
||||||
显示第 <span id="page_start" style="color: var(--text-primary); font-weight:600;">0</span> - <span id="page_end" style="color: var(--text-primary); font-weight:600;">0</span> 条,共 <span id="filtered_count" style="color: var(--text-primary); font-weight:600;">0</span> 条备选节点
|
显示第 <span id="page_start" style="color: var(--text-primary); font-weight:600;">0</span> - <span id="page_end" style="color: var(--text-primary); font-weight:600;">0</span> 条,共 <span id="filtered_count" style="color: var(--text-primary); font-weight:600;">0</span> 条备选节点
|
||||||
</div>
|
</div>
|
||||||
@@ -2870,7 +2920,7 @@ INDEX_HTML = r"""<!doctype html>
|
|||||||
<script>
|
<script>
|
||||||
let nodes=[], state={}, testingNodeIds = new Set();
|
let nodes=[], state={}, testingNodeIds = new Set();
|
||||||
let currentPage = 1;
|
let currentPage = 1;
|
||||||
const pageSize = 11;
|
const pageSize = 99999;
|
||||||
let currentPageNodes = [];
|
let currentPageNodes = [];
|
||||||
|
|
||||||
const $=id=>document.getElementById(id);
|
const $=id=>document.getElementById(id);
|
||||||
@@ -2995,11 +3045,20 @@ function updateCountryFilter() {
|
|||||||
function getFilteredNodes() {
|
function getFilteredNodes() {
|
||||||
const q = $("search").value.toLowerCase();
|
const q = $("search").value.toLowerCase();
|
||||||
const selectedCountry = $("country_filter").value;
|
const selectedCountry = $("country_filter").value;
|
||||||
|
const selectedIpType = $("ip_type_filter").value;
|
||||||
return nodes.filter(n => {
|
return nodes.filter(n => {
|
||||||
if (!n) return false;
|
if (!n) return false;
|
||||||
if (selectedCountry && n.country !== selectedCountry) {
|
if (selectedCountry && n.country !== selectedCountry) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (selectedIpType) {
|
||||||
|
if (selectedIpType === "residential" && !["residential", "mobile"].includes(n.ip_type)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (selectedIpType === "hosting" && n.ip_type !== "hosting") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
const searchStr = [
|
const searchStr = [
|
||||||
n.country || "", n.country_short || "", n.ip || "", n.remote_host || "", n.proto || "",
|
n.country || "", n.country_short || "", n.ip || "", n.remote_host || "", n.proto || "",
|
||||||
translateQuality(n.quality), translateIpType(n.ip_type), n.location || "", n.owner || "", n.as_name || ""
|
translateQuality(n.quality), translateIpType(n.ip_type), n.location || "", n.owner || "", n.as_name || ""
|
||||||
@@ -3396,6 +3455,79 @@ $("btn_batch_test").onclick = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function updateHeaderRoutingControls() {
|
||||||
|
const selectCountry = $("header_routing_country");
|
||||||
|
const selectIpType = $("header_routing_ip_type");
|
||||||
|
if (!selectCountry || !selectIpType) return;
|
||||||
|
|
||||||
|
// 1. Countries list
|
||||||
|
const countries = Array.from(new Set(nodes.map(n => n.country).filter(Boolean))).sort();
|
||||||
|
const currentOptions = Array.from(selectCountry.options).map(o => o.value).filter(v => v && v !== "fixed_ip_mode");
|
||||||
|
|
||||||
|
const rebuild = JSON.stringify(countries) !== JSON.stringify(currentOptions);
|
||||||
|
if (rebuild) {
|
||||||
|
selectCountry.innerHTML = '<option value="">智能路由 / 所有</option>' +
|
||||||
|
countries.map(c => `<option value="${esc(c)}">${esc(c)}</option>`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Set value
|
||||||
|
if (state.routing_mode === "fixed_ip") {
|
||||||
|
if (!selectCountry.querySelector('option[value="fixed_ip_mode"]')) {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = "fixed_ip_mode";
|
||||||
|
opt.textContent = "固定 IP 模式";
|
||||||
|
opt.disabled = true;
|
||||||
|
selectCountry.appendChild(opt);
|
||||||
|
}
|
||||||
|
selectCountry.value = "fixed_ip_mode";
|
||||||
|
} else if (state.routing_mode === "fixed_region") {
|
||||||
|
selectCountry.value = state.force_country || "";
|
||||||
|
} else {
|
||||||
|
selectCountry.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
selectIpType.value = state.routing_ip_type || "all";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveHeaderRouting() {
|
||||||
|
const selectCountry = $("header_routing_country");
|
||||||
|
const selectIpType = $("header_routing_ip_type");
|
||||||
|
|
||||||
|
let routingMode = "auto";
|
||||||
|
let forceCountry = selectCountry.value;
|
||||||
|
|
||||||
|
if (forceCountry === "fixed_ip_mode") {
|
||||||
|
routingMode = "fixed_ip";
|
||||||
|
forceCountry = state.force_country || "";
|
||||||
|
} else if (forceCountry) {
|
||||||
|
routingMode = "fixed_region";
|
||||||
|
} else {
|
||||||
|
routingMode = "auto";
|
||||||
|
}
|
||||||
|
|
||||||
|
const routingIpType = selectIpType.value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("./api/update_routing", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
routing_mode: routingMode,
|
||||||
|
force_country: forceCountry,
|
||||||
|
routing_ip_type: routingIpType
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.ok) {
|
||||||
|
load();
|
||||||
|
} else {
|
||||||
|
alert("更新路由失败: " + result.error);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert("更新出站路由网络请求失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function load(){
|
async function load(){
|
||||||
const r=await fetch("./api/nodes");
|
const r=await fetch("./api/nodes");
|
||||||
const d=await r.json();
|
const d=await r.json();
|
||||||
@@ -3404,6 +3536,7 @@ async function load(){
|
|||||||
|
|
||||||
stableSortNodes();
|
stableSortNodes();
|
||||||
updateCountryFilter();
|
updateCountryFilter();
|
||||||
|
updateHeaderRoutingControls();
|
||||||
render();
|
render();
|
||||||
|
|
||||||
if (state.is_connecting) {
|
if (state.is_connecting) {
|
||||||
@@ -3413,6 +3546,9 @@ async function load(){
|
|||||||
|
|
||||||
$("search").oninput=()=>{ currentPage = 1; render(); };
|
$("search").oninput=()=>{ currentPage = 1; render(); };
|
||||||
$("country_filter").onchange=()=>{ currentPage = 1; render(); };
|
$("country_filter").onchange=()=>{ currentPage = 1; render(); };
|
||||||
|
$("ip_type_filter").onchange=()=>{ currentPage = 1; render(); };
|
||||||
|
$("header_routing_country").onchange = saveHeaderRouting;
|
||||||
|
$("header_routing_ip_type").onchange = saveHeaderRouting;
|
||||||
|
|
||||||
$("refresh").onclick=async()=>{
|
$("refresh").onclick=async()=>{
|
||||||
$("refresh").disabled=true;
|
$("refresh").disabled=true;
|
||||||
@@ -4066,15 +4202,17 @@ def background_proxy_checker() -> None:
|
|||||||
|
|
||||||
# If we intended to have an active VPN node but proxy failed, trigger auto-switch
|
# If we intended to have an active VPN node but proxy failed, trigger auto-switch
|
||||||
if active_openvpn_node_id:
|
if active_openvpn_node_id:
|
||||||
with lock:
|
ui_cfg = load_ui_config()
|
||||||
nodes = read_json(NODES_FILE, [])
|
routing_mode = ui_cfg.get("routing_mode", "auto")
|
||||||
active_node = next((n for n in nodes if n.get("id") == active_openvpn_node_id), None)
|
if routing_mode != "fixed_ip":
|
||||||
if active_node:
|
with lock:
|
||||||
mark_blacklisted(active_node, f"代理连通性检测失败: {error_msg}")
|
nodes = read_json(NODES_FILE, [])
|
||||||
active_node["probe_status"] = "unavailable"
|
active_node = next((n for n in nodes if n.get("id") == active_openvpn_node_id), None)
|
||||||
write_json(NODES_FILE, nodes)
|
if active_node:
|
||||||
|
mark_blacklisted(active_node, f"代理连通性检测失败: {error_msg}")
|
||||||
auto_switch_node()
|
active_node["probe_status"] = "unavailable"
|
||||||
|
write_json(NODES_FILE, nodes)
|
||||||
|
auto_switch_node()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[错误] 代理后台检测发生异常: {e}", flush=True)
|
print(f"[错误] 代理后台检测发生异常: {e}", flush=True)
|
||||||
log_to_json("ERROR", "Proxy", f"检测守护线程发生异常: {e}")
|
log_to_json("ERROR", "Proxy", f"检测守护线程发生异常: {e}")
|
||||||
@@ -4508,14 +4646,19 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
payload = json.loads(self.rfile.read(length).decode("utf-8") or "{}")
|
payload = json.loads(self.rfile.read(length).decode("utf-8") or "{}")
|
||||||
routing_mode = str(payload.get("routing_mode") or "auto").strip()
|
routing_mode = str(payload.get("routing_mode") or "auto").strip()
|
||||||
force_country = str(payload.get("force_country") or "").strip()
|
force_country = str(payload.get("force_country") or "").strip()
|
||||||
|
routing_ip_type = str(payload.get("routing_ip_type") or "all").strip()
|
||||||
|
|
||||||
if routing_mode not in ("auto", "fixed_ip", "fixed_region"):
|
if routing_mode not in ("auto", "fixed_ip", "fixed_region"):
|
||||||
self.send_json({"ok": False, "error": "无效的路由配置模式"}, HTTPStatus.BAD_REQUEST)
|
self.send_json({"ok": False, "error": "无效的路由配置模式"}, HTTPStatus.BAD_REQUEST)
|
||||||
return
|
return
|
||||||
|
if routing_ip_type not in ("all", "residential", "hosting"):
|
||||||
|
self.send_json({"ok": False, "error": "无效的IP出站类型过滤"}, HTTPStatus.BAD_REQUEST)
|
||||||
|
return
|
||||||
|
|
||||||
ui_cfg = load_ui_config()
|
ui_cfg = load_ui_config()
|
||||||
ui_cfg["routing_mode"] = routing_mode
|
ui_cfg["routing_mode"] = routing_mode
|
||||||
ui_cfg["force_country"] = force_country
|
ui_cfg["force_country"] = force_country
|
||||||
|
ui_cfg["routing_ip_type"] = routing_ip_type
|
||||||
ui_cfg.pop("enable_force_country", None)
|
ui_cfg.pop("enable_force_country", None)
|
||||||
|
|
||||||
auth_file = DATA_DIR / "ui_auth.json"
|
auth_file = DATA_DIR / "ui_auth.json"
|
||||||
@@ -4550,6 +4693,13 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_json({"ok": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
|
self.send_json({"ok": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||||
elif effective_path == "/api/disconnect":
|
elif effective_path == "/api/disconnect":
|
||||||
try:
|
try:
|
||||||
|
ui_cfg = load_ui_config()
|
||||||
|
ui_cfg["connection_enabled"] = False
|
||||||
|
auth_file = DATA_DIR / "ui_auth.json"
|
||||||
|
with lock:
|
||||||
|
DATA_DIR.mkdir(exist_ok=True, parents=True)
|
||||||
|
auth_file.write_text(json.dumps(ui_cfg, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
stop_active_openvpn()
|
stop_active_openvpn()
|
||||||
with lock:
|
with lock:
|
||||||
nodes = read_json(NODES_FILE, [])
|
nodes = read_json(NODES_FILE, [])
|
||||||
|
|||||||
Reference in New Issue
Block a user