fix: close VPS routing and installer audit issues

This commit is contained in:
Aimili
2026-08-27 20:08:49 +08:00
parent f190d746ef
commit 21ce55044d
7 changed files with 956 additions and 70 deletions
+165 -56
View File
@@ -88,6 +88,12 @@ def bounded_int(value: Any, default: int, min_value: int | None = None, max_valu
return default
return parsed
def ports_conflict(web_port: Any, proxy_port: Any) -> bool:
try:
return int(web_port) == int(proxy_port)
except (TypeError, ValueError):
return False
API_HTTPS_URL = os.environ.get("VPNGATE_API_HTTPS_URL", "https://www.vpngate.net/api/iphone/").strip()
API_HTTP_URL = os.environ.get("VPNGATE_API_HTTP_URL", "http://www.vpngate.net/api/iphone/").strip()
MIRROR_HTTPS_URL = os.environ.get(
@@ -98,6 +104,10 @@ MIRROR_HTTP_URL = os.environ.get(
"VPNGATE_MIRROR_HTTP_URL",
"http://baoweise-bot.github.io/aimili-vpngate/vpngate.csv",
).strip()
MIRROR_META_URL = os.environ.get(
"VPNGATE_MIRROR_META_URL",
"https://baoweise-bot.github.io/aimili-vpngate/vpngate.meta.json",
).strip()
# Kept as the primary URL for diagnostics and backwards-compatible state output.
API_URL = API_HTTPS_URL
FETCH_INTERVAL_SECONDS = env_int("FETCH_INTERVAL_SECONDS", 1260, 1)
@@ -189,6 +199,9 @@ IP_ENRICHMENT_FIELDS = (
"is_hosting",
"is_mobile",
"ip_type_reason",
"ip_type_confidence",
"ip_type_sources",
"geo_country_short",
)
class ConnectionCancelled(RuntimeError):
@@ -238,7 +251,17 @@ def write_json(path: Path, data: Any) -> None:
with lock:
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
if path.name == "ui_auth.json":
try:
tmp.chmod(0o600)
except OSError:
pass
tmp.replace(path)
if path.name == "ui_auth.json":
try:
path.chmod(0o600)
except OSError:
pass
def read_json(path: Path, default: Any) -> Any:
with lock:
@@ -310,6 +333,10 @@ def load_ui_config() -> dict[str, Any]:
}
updated = False
if auth_file.exists():
try:
auth_file.chmod(0o600)
except OSError:
pass
try:
data = json.loads(auth_file.read_text(encoding="utf-8"))
for key, val in data.items():
@@ -482,6 +509,8 @@ def get_state() -> dict[str, Any]:
state.setdefault("last_fetch_status", "not_started")
state.setdefault("last_check_message", "")
state.setdefault("pending_node_id", "")
state.setdefault("tunnel_ready", False)
state.setdefault("proxy_ready", bool(state.get("proxy_ok", False)))
state.setdefault("blacklisted_nodes", 0)
state["app_version"] = APP_VERSION
state["app_version_label"] = APP_VERSION_LABEL
@@ -523,6 +552,8 @@ def clear_active_connection_state(message: str) -> None:
pending_node_id="",
active_node_latency="无活动连接",
proxy_ok=False,
tunnel_ready=False,
proxy_ready=False,
proxy_ip="-",
proxy_latency_ms=0,
proxy_error=message,
@@ -949,7 +980,7 @@ def api_network_sources() -> list[tuple[str, str]]:
("official_https", API_HTTPS_URL),
("official_http", API_HTTP_URL),
("github_pages_https", MIRROR_HTTPS_URL),
("github_pages_http", MIRROR_HTTP_URL),
("github_pages_http_redirect_https", MIRROR_HTTP_URL),
]
sources: list[tuple[str, str]] = []
seen: set[str] = set()
@@ -989,6 +1020,25 @@ def cache_api_snapshot(text: str, source: str) -> None:
},
)
def read_mirror_freshness() -> tuple[float, str]:
if not MIRROR_META_URL:
return 0.0, ""
try:
raw = fetch_api_text_with_deadline(MIRROR_META_URL, True, deadline_seconds=2)
meta = json.loads(raw)
generated_at = float(meta.get("generated_at", 0) or 0)
if generated_at <= 0:
return 0.0, ""
age_seconds = max(0, int(time.time() - generated_at))
if age_seconds < 3600:
age_text = f"{max(1, age_seconds // 60)} 分钟"
else:
age_text = f"{age_seconds / 3600:.1f} 小时"
return generated_at, f"镜像生成于 {age_text}"
except Exception as exc:
print(f"[镜像元数据] 读取失败: {exc}", flush=True)
return 0.0, "镜像生成时间未知"
def rows_to_candidates(
rows: list[dict[str, str]],
blacklist: dict[str, dict[str, Any]],
@@ -1033,17 +1083,9 @@ def fetch_candidates() -> list[dict[str, Any]]:
load_ui_config().get("discovery_countries")
)
last_err: Exception | None = None
deadline_hosts: set[str] = set()
log_to_json("INFO", "Main", "开始按官方、GitHub Pages、本地缓存顺序拉取节点列表...")
for source_name, url in api_network_sources():
source_host = (urllib.parse.urlsplit(url).hostname or "").lower()
if url.startswith("http://") and source_host in deadline_hosts:
msg = f"跳过同主机慢速 HTTP 节点源 {source_name}: {url}"
print(f"[fetch_candidates] {msg}", flush=True)
log_to_json("WARNING", "Main", msg)
continue
try:
msg = f"尝试节点源 {source_name}: {url}"
print(f"[fetch_candidates] {msg}", flush=True)
@@ -1068,14 +1110,21 @@ def fetch_candidates() -> list[dict[str, Any]]:
if discovery_countries
else f"保留全部 {len(filtered_candidates)}"
)
mirror_generated_at = 0.0
mirror_freshness = ""
if source_name.startswith("github_pages"):
mirror_generated_at, mirror_freshness = read_mirror_freshness()
source_note = f"{mirror_freshness}" if mirror_freshness else ""
set_state(
last_fetch_at=time.time(),
last_fetch_status="ok",
last_fetch_source=source_name,
last_fetch_message=(
f"{source_name} 成功获取 {len(candidates)} 个候选节点,{scope_message}"
f"{source_name} 成功获取 {len(candidates)} 个候选节点,{scope_message}{source_note}"
),
mirror_generated_at=mirror_generated_at,
mirror_freshness=mirror_freshness,
blacklisted_nodes=len(blacklist),
)
log_to_json(
@@ -1086,8 +1135,6 @@ def fetch_candidates() -> list[dict[str, Any]]:
return filtered_candidates
except Exception as e:
last_err = e
if isinstance(e, SourceDeadlineExceeded) and url.startswith("https://"):
deadline_hosts.add(source_host)
print(f"[fetch_candidates] 节点源 {source_name} 失败: {e}", flush=True)
log_to_json("WARNING", "Main", f"节点源 {source_name} 失败: {e}")
@@ -1201,6 +1248,8 @@ def openvpn_command(config_file: str, route_nopull: bool, dev: str = "tun0") ->
"--auth-user-pass",
str(AUTH_FILE),
"--auth-nocache",
"--remote-cert-tls",
"server",
]
)
@@ -1559,6 +1608,17 @@ def stop_active_openvpn() -> None:
def active_openvpn_running() -> bool:
return active_openvpn_process is not None and active_openvpn_process.poll() is None
def connection_ready_for_ui(state: dict[str, Any] | None = None) -> bool:
current = get_state() if state is None else state
return bool(
active_openvpn_node_id
and active_openvpn_running()
and current.get("tunnel_ready")
and current.get("proxy_ready")
and current.get("proxy_ok")
and not current.get("is_connecting")
)
def sort_all_nodes(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
available_nodes = sorted(
[n for n in nodes if n.get("probe_status") == "available" or n.get("active")],
@@ -1634,7 +1694,7 @@ def apply_routing_filters(
if routing_mode == "fixed_region" and target_country:
candidates = [
n for n in candidates
if country_matches(n.get("country"), target_country)
if country_matches(n.get("country"), target_country, n.get("country_short"))
]
elif routing_mode == "favorites":
fav_ids = set(ui_cfg.get("favorite_node_ids", []))
@@ -1644,7 +1704,10 @@ def apply_routing_filters(
if routing_ip_type == "residential":
candidates = [
n for n in candidates
if n.get("ip_type") in ("residential", "mobile")
if (
n.get("ip_type") in ("residential", "mobile")
and n.get("ip_type_confidence") in ("medium", "high")
)
or (include_unknown_ip_type and not n.get("ip_type"))
]
elif routing_ip_type == "hosting":
@@ -1660,8 +1723,34 @@ def normalized_country_name(country: Any) -> str:
value = str(country or "").strip()
return vpn_utils.COUNTRY_TRANSLATIONS.get(value, value)
def country_matches(node_country: Any, target_country: Any) -> bool:
return bool(target_country) and normalized_country_name(node_country) == normalized_country_name(target_country)
def normalize_routing_country(value: Any, nodes: list[dict[str, Any]] | None = None) -> str:
target = str(value or "").strip()
if not target:
return ""
upper = target.upper()
if re.fullmatch(r"[A-Z]{2}", upper):
return upper
normalized_target = normalized_country_name(target).casefold()
for node in nodes if nodes is not None else read_nodes():
code = str(node.get("country_short") or "").strip().upper()
if not re.fullmatch(r"[A-Z]{2}", code):
continue
if normalized_country_name(node.get("country")).casefold() == normalized_target:
return code
return target
def country_matches(
node_country: Any,
target_country: Any,
node_country_short: Any = "",
) -> bool:
target = str(target_country or "").strip()
if not target:
return False
target_upper = target.upper()
if re.fullmatch(r"[A-Z]{2}", target_upper):
return str(node_country_short or "").strip().upper() == target_upper
return normalized_country_name(node_country).casefold() == normalized_country_name(target).casefold()
def probe_priority_key(node: dict[str, Any]) -> tuple[int, int, int, int]:
ping = parse_int(node.get("ping")) or 999999
@@ -1687,7 +1776,7 @@ def validate_node_allowed_by_routing(node: dict[str, Any], ui_cfg: dict[str, Any
if routing_mode == "fixed_region":
target_country = ui_cfg.get("force_country", "")
if target_country and not country_matches(node.get("country"), target_country):
if target_country and not country_matches(node.get("country"), target_country, node.get("country_short")):
raise RuntimeError(f"当前已锁定国家【{target_country}】,不能连接其他国家节点")
elif routing_mode == "favorites":
fav_ids = set(ui_cfg.get("favorite_node_ids", []))
@@ -1838,12 +1927,10 @@ def test_node_by_id(node_id: str) -> dict[str, Any]:
node["probe_message"] = message
node["probed_at"] = time.time()
if ok:
node["owner"] = temp_node["owner"]
node["asn"] = temp_node["asn"]
node["as_name"] = temp_node["as_name"]
node["location"] = temp_node["location"]
node["ip_type"] = temp_node["ip_type"]
node["quality"] = temp_node["quality"]
for field in IP_ENRICHMENT_FIELDS:
value = temp_node.get(field)
if value not in (None, ""):
node[field] = value
sorted_nodes = sort_all_nodes(nodes)
write_json(NODES_FILE, sorted_nodes)
@@ -1890,12 +1977,6 @@ def test_multiple_nodes(node_ids: list[str], target_available: int | None = None
"probe_status": "unavailable",
"probe_message": f"Failed to write configuration: {e}",
"probed_at": time.time(),
"owner": "",
"asn": "",
"as_name": "",
"location": "",
"ip_type": "",
"quality": "",
}
latency = vpn_utils.ping_latency_ms(h, p, fallback_ping)
@@ -1922,12 +2003,6 @@ def test_multiple_nodes(node_ids: list[str], target_available: int | None = None
"probe_status": "available" if ok else "unavailable",
"probe_message": message,
"probed_at": time.time(),
"owner": "",
"asn": "",
"as_name": "",
"location": "",
"ip_type": "",
"quality": "",
}
return temp_node
@@ -2133,6 +2208,9 @@ def connect_node(node_id: str) -> str:
set_state(
is_connecting=True,
pending_node_id=node_id,
tunnel_ready=False,
proxy_ready=False,
proxy_ok=False,
active_node_latency="正在连接",
last_check_message=f"正在初始化连接配置: {node_id}",
)
@@ -2238,6 +2316,7 @@ def connect_node(node_id: str) -> str:
raise ConnectionCancelled("连接操作已取消")
active_openvpn_process = process
active_openvpn_node_id = node_id
set_state(tunnel_ready=True, proxy_ready=False)
set_state(active_node_latency="配置路由", last_check_message="正在配置策略路由规则与流量转发...")
routing_ready = setup_policy_routing("tun0")
@@ -2295,6 +2374,8 @@ def connect_node(node_id: str) -> str:
last_check_message=f"Connected {node_id}",
active_node_latency=latency_str,
proxy_ok=True,
tunnel_ready=True,
proxy_ready=True,
proxy_ip=res["ip"],
proxy_latency_ms=res["latency_ms"],
proxy_error="",
@@ -4658,10 +4739,12 @@ const translateQuality = q => {
};
const translateIpType = t => {
const dict = {"residential": "住宅 IP", "hosting": "机房 IP", "mobile": "移动网", "proxy": "代理 IP"};
const dict = {"residential": "住宅 IP", "hosting": "机房 IP", "mobile": "移动网", "unknown": "未知", "proxy": "代理 IP"};
return dict[t] || t || "-";
};
const translateConfidence = value => ({high: "", medium: "", low: ""}[value] || "未知");
const translateCountry = c => {
const dict = {
"Japan": "日本",
@@ -4922,8 +5005,7 @@ function render(){
$("deployment_mode_label").textContent = `${modeLabel}部署 · 更新通道main`;
}
const activeNodeId = state.active_openvpn_node_id;
const activeNode = nodes.find(n => n && (n.active || n.id === activeNodeId));
const activeNode = nodes.find(n => n && n.active);
// Render separated Active Node Card
const activeCardContainer = $("active_node_card");
@@ -4953,7 +5035,9 @@ function render(){
} else if (activeNode) {
const latencyText = nodeLatencyHtml(activeNode);
const displayLocation = activeNode.location || translateCountry(activeNode.country) || "-";
const activeFlag = countryFlag(activeNode.country_short);
const declaredFlag = countryFlag(activeNode.country_short);
const locationFlag = countryFlag(activeNode.geo_country_short || activeNode.country_short);
const ipTypeTitle = `${translateIpType(activeNode.ip_type)} · 置信度${translateConfidence(activeNode.ip_type_confidence)} · 来源${(activeNode.ip_type_sources || []).join(" + ") || "未知"}`;
activeCardHtml = `
<div class="active-card">
<div class="active-card-info">
@@ -4963,16 +5047,16 @@ function render(){
<div class="active-card-details">
<div class="active-card-title">
<span class="badge available"><span class="badge-pulse"></span>已连接</span>
<strong>${esc(translateCountry(activeNode.country))} 节点</strong>
<strong>${declaredFlag ? `${esc(declaredFlag)} ` : ""}${esc(translateCountry(activeNode.country))} 节点</strong>
</div>
<div class="active-card-value mono" style="font-size: 20px; margin-top: 2px;">
${esc(activeNode.ip || activeNode.remote_host)}:${activeNode.remote_port || ""}
</div>
<div class="active-card-meta" style="margin-top: 4px;">
<span>物理位置: <strong>${activeFlag ? `${esc(activeFlag)} ` : ""}${esc(displayLocation)}</strong></span>
<span title="IP 情报源推测位置;节点申报国家见标题">物理位置: <strong>${locationFlag ? `${esc(locationFlag)} ` : ""}${esc(displayLocation)}</strong></span>
<span style="margin-left: 12px;">延时: <strong>${latencyText}</strong></span>
<span style="margin-left: 12px;">运营主体: <strong>${esc(activeNode.owner || activeNode.as_name || "-")}</strong></span>
<span style="margin-left: 12px;">IP 类型: <strong>${esc(translateIpType(activeNode.ip_type))}</strong></span>
<span style="margin-left: 12px;" title="${esc(ipTypeTitle)}">IP 类型: <strong>${esc(translateIpType(activeNode.ip_type))}</strong></span>
</div>
</div>
</div>
@@ -5091,7 +5175,11 @@ function render(){
const badgeText = isCurrentlyActive ? '<span class="badge-pulse"></span>已连接' : (isPending ? '<span class="badge-pulse"></span>切换中' : translateStatus(n.probe_status));
const latencyText = nodeLatencyHtml(n);
const displayLocation = n.location || translateCountry(n.country) || "-";
const flag = countryFlag(n.country_short);
const flag = countryFlag(n.geo_country_short || n.country_short);
const locationTitle = n.location
? `IP 推测位置${displayLocation}节点申报国家${translateCountry(n.country)}`
: `节点申报国家${translateCountry(n.country)}`;
const ipTypeTitle = `${translateIpType(n.ip_type)} · 置信度${translateConfidence(n.ip_type_confidence)} · 来源${(n.ip_type_sources || []).join(" + ") || "未知"}`;
const isTesting = testingNodeIds.has(n.id) || n.probe_status === "testing";
const testSpinner = `<svg style="animation: spin 1s linear infinite; width: 12px; height: 12px; display: inline-block; margin-right: 4px; vertical-align: middle;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-opacity="0.2" fill="none"></circle><path d="M4 12a8 8 0 018-8" stroke="currentColor" fill="none"></path></svg>`;
@@ -5116,9 +5204,9 @@ function render(){
<td><span class="badge ${badgeClass}">${badgeText}</span></td>
<td class="mono" style="white-space: nowrap; max-width: 220px; overflow: hidden; text-overflow: ellipsis;" title="${esc(n.ip||n.remote_host)}:${n.remote_port||""}">${esc(n.ip||n.remote_host)}:${n.remote_port||""}</td>
<td style="white-space: nowrap;">${latencyText}</td>
<td style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${esc(displayLocation)}">${flag ? `<span aria-hidden="true">${esc(flag)}</span> ` : ""}${esc(displayLocation)}</td>
<td style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${esc(locationTitle)}">${flag ? `<span aria-hidden="true">${esc(flag)}</span> ` : ""}${esc(displayLocation)}</td>
<td style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${esc(n.owner||n.as_name||"-")}">${esc(n.owner||n.as_name||"-")}</td>
<td style="white-space: nowrap; max-width: 110px; overflow: hidden; text-overflow: ellipsis;" title="${esc(translateIpType(n.ip_type))}">${esc(translateIpType(n.ip_type))}</td>
<td style="white-space: nowrap; max-width: 110px; overflow: hidden; text-overflow: ellipsis;" title="${esc(ipTypeTitle)}">${esc(translateIpType(n.ip_type))}</td>
<td>
<div class="table-actions">
${testBtn}
@@ -5717,21 +5805,30 @@ function populateRoutingCountries() {
if (!select) return;
const countMap = {};
nodes.forEach(n => {
const code = String(n.country_short || "").trim().toUpperCase();
const c = translateCountry(n.country);
if (c) {
countMap[c] = (countMap[c] || 0) + 1;
if (/^[A-Z]{2}$/.test(code) && c) {
const current = countMap[code] || {name: c, count: 0};
current.count += 1;
countMap[code] = current;
}
});
const countries = Object.keys(countMap).sort();
const countries = Object.keys(countMap).sort((a, b) => countMap[a].name.localeCompare(countMap[b].name, "zh-CN"));
let html = '<option value="">请选择要锁定的国家...</option>';
countries.forEach(c => {
html += `<option value="${esc(c)}">${esc(c)} (${countMap[c]}个节点)</option>`;
countries.forEach(code => {
html += `<option value="${esc(code)}">${esc(countryFlag(code))} ${esc(countMap[code].name)} (${countMap[code].count}个节点)</option>`;
});
select.innerHTML = html;
if (state) {
select.value = state.force_country ? translateCountry(state.force_country) : "";
const saved = String(state.force_country || "").trim();
if (/^[A-Za-z]{2}$/.test(saved)) {
select.value = saved.toUpperCase();
} else {
const legacy = countries.find(code => countMap[code].name === translateCountry(saved));
select.value = legacy || "";
}
}
}
@@ -6527,9 +6624,11 @@ class Handler(BaseHTTPRequestHandler):
elif effective_path == "/api/nodes":
global last_active_ping_time, last_active_latency, active_openvpn_node_id
nodes = read_nodes()
active_node = next((n for n in nodes if active_openvpn_node_id and n.get("id") == active_openvpn_node_id), None)
connection_state = get_state()
connection_ready = connection_ready_for_ui(connection_state)
active_node = next((n for n in nodes if connection_ready and n.get("id") == active_openvpn_node_id), None)
for n in nodes:
n["active"] = (active_openvpn_node_id and n.get("id") == active_openvpn_node_id)
n["active"] = bool(connection_ready and n.get("id") == active_openvpn_node_id)
if active_node:
ip = active_node.get("ip") or active_node.get("remote_host")
if ip:
@@ -6787,6 +6886,10 @@ class Handler(BaseHTTPRequestHandler):
expected_port = ui_cfg.get("port", 8787)
expected_suffix = ui_cfg.get("secret_path", "EJsW2EeBo9lY")
if ports_conflict(new_port_int, ui_cfg.get("proxy_port", 7928)):
self.send_json({"ok": False, "error": "网页管理端口不能与代理出站端口相同"}, HTTPStatus.BAD_REQUEST)
return
ui_cfg["username"] = new_username
if new_password:
ui_cfg["password"] = new_password
@@ -6823,7 +6926,7 @@ class Handler(BaseHTTPRequestHandler):
new_proxy_port = payload.get("proxy_port")
routing_mode = str(payload.get("routing_mode") or "auto").strip()
force_country = str(payload.get("force_country") or "").strip()
force_country = normalize_routing_country(payload.get("force_country"), read_nodes())
routing_ip_type = str(payload.get("routing_ip_type") or "all").strip()
try:
@@ -6848,7 +6951,7 @@ class Handler(BaseHTTPRequestHandler):
expected_proxy_port = ui_cfg.get("proxy_port", 7928)
fixed_node_id = current_fixed_node_id(ui_cfg) if routing_mode == "fixed_ip" else ""
if new_proxy_port_int == ui_cfg.get("port", 8787):
if ports_conflict(ui_cfg.get("port", 8787), new_proxy_port_int):
self.send_json({"ok": False, "error": "代理出站端口不能与网页管理端口相同"}, HTTPStatus.BAD_REQUEST)
return
if routing_mode == "fixed_ip" and not fixed_node_id:
@@ -6892,7 +6995,7 @@ class Handler(BaseHTTPRequestHandler):
try:
payload = self.read_json_body()
routing_mode = str(payload.get("routing_mode") or "auto").strip()
force_country = str(payload.get("force_country") or "").strip()
force_country = normalize_routing_country(payload.get("force_country"), read_nodes())
routing_ip_type = str(payload.get("routing_ip_type") or "all").strip()
fav_fail_fallback = False
@@ -7126,6 +7229,8 @@ class Handler(BaseHTTPRequestHandler):
if result["ok"]:
set_state(
proxy_ok=True,
tunnel_ready=active_openvpn_running(),
proxy_ready=active_openvpn_running(),
proxy_ip=result["ip"],
proxy_latency_ms=result["latency_ms"],
proxy_error=""
@@ -7133,6 +7238,7 @@ class Handler(BaseHTTPRequestHandler):
else:
set_state(
proxy_ok=False,
proxy_ready=False,
proxy_ip="-",
proxy_latency_ms=0,
proxy_error=result.get("error", "未知错误")
@@ -7187,6 +7293,9 @@ def main() -> None:
"last_fetch_source": "",
"last_check_message": "服务已启动,正在初始化网络并获取候选 VPN 节点...",
"is_connecting": True,
"tunnel_ready": False,
"proxy_ready": False,
"proxy_ok": False,
"pending_node_id": "",
"active_node_latency": "正在准备",
"blacklisted_nodes": 0,