mirror of
https://github.com/baoweise-bot/aimili-vpngate.git
synced 2026-09-05 07:36:42 +08:00
feat: initial open-source release with cleaned codebase
This commit is contained in:
+422
@@ -0,0 +1,422 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parent
|
||||
DATA_DIR = ROOT_DIR / "vpngate_data"
|
||||
IP_CACHE_FILE = DATA_DIR / "ip_cache.json"
|
||||
|
||||
ip_cache_lock = threading.RLock()
|
||||
|
||||
COUNTRY_TRANSLATIONS = {
|
||||
"Japan": "日本",
|
||||
"Korea Republic of": "韩国",
|
||||
"Korea": "韩国",
|
||||
"Republic of Korea": "韩国",
|
||||
"Thailand": "泰国",
|
||||
"United States": "美国",
|
||||
"United Kingdom": "英国",
|
||||
"Russian Federation": "俄罗斯",
|
||||
"Russian": "俄罗斯",
|
||||
"Viet Nam": "越南",
|
||||
"Vietnam": "越南",
|
||||
"China": "中国",
|
||||
"Taiwan": "台湾",
|
||||
"Taiwan Province of China": "台湾",
|
||||
"Hong Kong": "香港",
|
||||
"Singapore": "新加坡",
|
||||
"Malaysia": "马来西亚",
|
||||
"Indonesia": "印度尼西亚",
|
||||
"India": "印度",
|
||||
"Philippines": "菲律宾",
|
||||
"Australia": "澳大利亚",
|
||||
"New Zealand": "新西兰",
|
||||
"Canada": "加拿大",
|
||||
"Ukraine": "乌克兰",
|
||||
"France": "法国",
|
||||
"Germany": "德国",
|
||||
"Netherlands": "荷兰",
|
||||
"Sweden": "瑞典",
|
||||
"Norway": "挪威",
|
||||
"Spain": "西班牙",
|
||||
"Turkey": "土耳其",
|
||||
"South Africa": "南非",
|
||||
"Brazil": "巴西",
|
||||
"Argentina": "阿根廷",
|
||||
"Chile": "智利",
|
||||
"Mexico": "墨西哥",
|
||||
"Egypt": "埃及",
|
||||
"Romania": "罗马尼亚",
|
||||
"Poland": "波兰",
|
||||
"Kazakhstan": "哈萨克斯坦",
|
||||
"Georgia": "格鲁吉亚",
|
||||
"Mongolia": "蒙古",
|
||||
"Saudi Arabia": "沙特阿拉伯",
|
||||
"Iran": "伊朗",
|
||||
"Iraq": "伊拉克",
|
||||
"Colombia": "哥伦比亚",
|
||||
"Cambodia": "柬埔寨",
|
||||
"Ireland": "爱尔兰",
|
||||
"Italy": "意大利",
|
||||
"Switzerland": "瑞士",
|
||||
"Belgium": "比利时",
|
||||
"Austria": "奥地利",
|
||||
"Denmark": "丹麦",
|
||||
"Finland": "芬兰",
|
||||
"Portugal": "葡萄牙",
|
||||
"Greece": "希腊",
|
||||
"Czech Republic": "捷克",
|
||||
"Hungary": "匈牙利",
|
||||
"Israel": "以色列",
|
||||
"United Arab Emirates": "阿联酋",
|
||||
"UAE": "阿联酋",
|
||||
"Macao": "澳门",
|
||||
"Macau": "澳门",
|
||||
"Iceland": "冰岛",
|
||||
"Luxembourg": "卢森堡",
|
||||
}
|
||||
|
||||
def get_upstream_proxy() -> tuple[str | None, str | None, int | None]:
|
||||
"""
|
||||
Returns (proxy_type, host, port) from environment variables.
|
||||
proxy_type is 'socks' or 'http'.
|
||||
"""
|
||||
socks_env = os.environ.get("OPENVPN_UPSTREAM_SOCKS")
|
||||
if socks_env:
|
||||
if "://" in socks_env:
|
||||
parsed = urllib.parse.urlsplit(socks_env)
|
||||
if parsed.hostname and parsed.port:
|
||||
return "socks", parsed.hostname, parsed.port
|
||||
else:
|
||||
parts = socks_env.split(":")
|
||||
if len(parts) == 2:
|
||||
return "socks", parts[0], int(parts[1])
|
||||
elif len(parts) == 1:
|
||||
return "socks", parts[0], 10808
|
||||
|
||||
http_env = os.environ.get("OPENVPN_UPSTREAM_HTTP")
|
||||
if http_env:
|
||||
if "://" in http_env:
|
||||
parsed = urllib.parse.urlsplit(http_env)
|
||||
if parsed.hostname and parsed.port:
|
||||
return "http", parsed.hostname, parsed.port
|
||||
else:
|
||||
parts = http_env.split(":")
|
||||
if len(parts) == 2:
|
||||
return "http", parts[0], int(parts[1])
|
||||
elif len(parts) == 1:
|
||||
return "http", parts[0], 10808
|
||||
|
||||
for env_name in ["http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY"]:
|
||||
val = os.environ.get(env_name)
|
||||
if not val:
|
||||
continue
|
||||
if "://" in val:
|
||||
parsed = urllib.parse.urlsplit(val)
|
||||
ptype = "socks" if parsed.scheme.startswith("socks") else "http"
|
||||
if parsed.hostname and parsed.port:
|
||||
return ptype, parsed.hostname, parsed.port
|
||||
else:
|
||||
parts = val.split(":")
|
||||
if len(parts) == 2:
|
||||
return "http", parts[0], int(parts[1])
|
||||
return None, None, None
|
||||
|
||||
def is_config_tcp(config_text: str) -> bool:
|
||||
try:
|
||||
for line in config_text.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith(("#", ";")):
|
||||
continue
|
||||
parts = line.split()
|
||||
if parts[0].lower() == "proto" and len(parts) >= 2:
|
||||
if "tcp" in parts[1].lower():
|
||||
return True
|
||||
elif parts[0].lower() == "remote" and len(parts) >= 4:
|
||||
if "tcp" in parts[3].lower():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
def parse_remote(config_text: str, fallback_ip: str = "") -> tuple[str, int, str]:
|
||||
remote_host = fallback_ip
|
||||
remote_port = 0
|
||||
proto = "unknown"
|
||||
for raw_line in config_text.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith(("#", ";")):
|
||||
continue
|
||||
parts = line.split()
|
||||
if parts[0].lower() == "proto" and len(parts) >= 2:
|
||||
proto = parts[1].lower()
|
||||
elif parts[0].lower() == "remote" and len(parts) >= 3:
|
||||
remote_host = parts[1]
|
||||
remote_port = int(parts[2]) if parts[2].isdigit() else 0
|
||||
return remote_host, remote_port, proto
|
||||
|
||||
def get_physical_interface() -> str | None:
|
||||
try:
|
||||
res = subprocess.run(["ip", "route"], capture_output=True, text=True, timeout=2)
|
||||
if res.returncode == 0:
|
||||
routes = []
|
||||
for line in res.stdout.splitlines():
|
||||
if line.startswith("default via"):
|
||||
parts = line.split()
|
||||
try:
|
||||
gw = parts[2]
|
||||
dev = parts[parts.index("dev") + 1]
|
||||
metric = 0
|
||||
if "metric" in parts:
|
||||
metric = int(parts[parts.index("metric") + 1])
|
||||
routes.append((gw, dev, metric))
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
if routes:
|
||||
routes.sort(key=lambda x: x[2], reverse=True)
|
||||
for gw, dev, metric in routes:
|
||||
if not dev.startswith(("tun", "tap", "wg", "ppp")):
|
||||
return dev
|
||||
return routes[0][1]
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def tcp_latency_ms(host: str, port: int, dev: str | None = None) -> int:
|
||||
started = time.time()
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
s.settimeout(5)
|
||||
if dev:
|
||||
try:
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_BINDTODEVICE, dev.encode("utf-8"))
|
||||
except OSError:
|
||||
pass
|
||||
s.connect((host, port))
|
||||
return max(1, int((time.time() - started) * 1000))
|
||||
except OSError:
|
||||
return 0
|
||||
finally:
|
||||
try:
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def ping_latency_ms(host: str, port: int, fallback_ping: int = 0) -> int:
|
||||
dev = get_physical_interface()
|
||||
# 1. Try ping with interface binding
|
||||
if dev:
|
||||
try:
|
||||
cmd = ["ping", "-c", "1", "-W", "2", "-I", dev, host]
|
||||
res = subprocess.run(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=2
|
||||
)
|
||||
if res.returncode == 0:
|
||||
match = re.search(r"time=([\d.]+)\s*ms", res.stdout)
|
||||
if match:
|
||||
val = int(float(match.group(1)))
|
||||
if val > 0:
|
||||
return val
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Try ping without interface binding
|
||||
try:
|
||||
cmd = ["ping", "-c", "1", "-W", "2", host]
|
||||
res = subprocess.run(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=2
|
||||
)
|
||||
if res.returncode == 0:
|
||||
match = re.search(r"time=([\d.]+)\s*ms", res.stdout)
|
||||
if match:
|
||||
val = int(float(match.group(1)))
|
||||
if val > 0:
|
||||
return val
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. Try TCP latency check
|
||||
tcp_val = tcp_latency_ms(host, port, dev)
|
||||
if tcp_val > 0:
|
||||
return tcp_val
|
||||
|
||||
# 4. Fallback
|
||||
if fallback_ping > 0:
|
||||
return fallback_ping
|
||||
return 0
|
||||
|
||||
def check_and_fix_dns() -> None:
|
||||
"""
|
||||
Checks if DNS resolution is broken in WSL.
|
||||
If names fail but direct IP connections work, appends public DNS nameservers to /etc/resolv.conf.
|
||||
"""
|
||||
try:
|
||||
socket.gethostbyname("www.vpngate.net")
|
||||
return
|
||||
except socket.gaierror:
|
||||
pass
|
||||
|
||||
network_ok = False
|
||||
for ip in ["8.8.8.8", "1.1.1.1"]:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
s.settimeout(2)
|
||||
s.connect((ip, 53))
|
||||
network_ok = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not network_ok:
|
||||
return
|
||||
|
||||
resolv_file = Path("/etc/resolv.conf")
|
||||
if resolv_file.exists():
|
||||
try:
|
||||
content = resolv_file.read_text(encoding="utf-8", errors="replace")
|
||||
if "nameserver 1.1.1.1" not in content and "nameserver 8.8.8.8" not in content:
|
||||
print("[dns_heal] Resolving names failed, but IP network is OK. Appending public DNS to /etc/resolv.conf...", flush=True)
|
||||
with open("/etc/resolv.conf", "a", encoding="utf-8") as f:
|
||||
f.write("\nnameserver 1.1.1.1\nnameserver 8.8.8.8\n")
|
||||
except Exception as e:
|
||||
print(f"[dns_heal] Failed to write DNS fallback: {e}", flush=True)
|
||||
|
||||
def load_ip_cache() -> dict[str, dict[str, Any]]:
|
||||
with ip_cache_lock:
|
||||
try:
|
||||
if IP_CACHE_FILE.exists():
|
||||
return json.loads(IP_CACHE_FILE.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
def save_ip_cache(cache: dict[str, dict[str, Any]]) -> None:
|
||||
with ip_cache_lock:
|
||||
try:
|
||||
DATA_DIR.mkdir(exist_ok=True)
|
||||
IP_CACHE_FILE.write_text(json.dumps(cache, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def enrich_ip_info(nodes: list[dict[str, Any]]) -> None:
|
||||
# 1. Read cache thread-safely
|
||||
with ip_cache_lock:
|
||||
cache = load_ip_cache()
|
||||
|
||||
ips_to_query = []
|
||||
now = time.time()
|
||||
|
||||
for node in nodes:
|
||||
ip = node.get("ip") or node.get("remote_host")
|
||||
if not ip:
|
||||
continue
|
||||
if ip in cache and now - cache[ip].get("cached_at", 0) < 7 * 24 * 3600:
|
||||
cached = cache[ip]
|
||||
node["owner"] = cached.get("owner", "")
|
||||
node["asn"] = cached.get("asn", "")
|
||||
node["as_name"] = cached.get("as_name", "")
|
||||
node["location"] = cached.get("location", "")
|
||||
node["ip_type"] = cached.get("ip_type", "")
|
||||
node["quality"] = cached.get("quality", "")
|
||||
else:
|
||||
if ip not in ips_to_query:
|
||||
ips_to_query.append(ip)
|
||||
|
||||
if not ips_to_query:
|
||||
return
|
||||
|
||||
# 2. Perform HTTP query outside lock
|
||||
new_entries = {}
|
||||
chunk_size = 100
|
||||
for i in range(0, len(ips_to_query), chunk_size):
|
||||
chunk = ips_to_query[i : i + chunk_size]
|
||||
payload = json.dumps(chunk).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
"http://ip-api.com/batch?lang=zh-CN&fields=status,message,query,country,regionName,city,isp,org,as,asname,proxy,hosting,mobile",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json", "User-Agent": "vpngate-manager/2.2"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
data = json.loads(response.read().decode("utf-8", errors="replace"))
|
||||
for item in data:
|
||||
if item.get("status") != "success":
|
||||
continue
|
||||
query_ip = item.get("query")
|
||||
if not query_ip:
|
||||
continue
|
||||
|
||||
ip_type = "residential"
|
||||
if item.get("mobile"):
|
||||
ip_type = "mobile"
|
||||
elif item.get("proxy"):
|
||||
ip_type = "proxy"
|
||||
elif item.get("hosting"):
|
||||
ip_type = "hosting"
|
||||
|
||||
quality = "normal"
|
||||
if item.get("proxy"):
|
||||
quality = "proxy"
|
||||
elif item.get("hosting"):
|
||||
quality = "datacenter"
|
||||
elif item.get("mobile"):
|
||||
quality = "mobile"
|
||||
|
||||
loc = " ".join(part for part in [item.get("country"), item.get("regionName"), item.get("city")] if part)
|
||||
|
||||
new_entries[query_ip] = {
|
||||
"owner": item.get("org") or item.get("isp") or "",
|
||||
"asn": item.get("as") or "",
|
||||
"as_name": item.get("asname") or "",
|
||||
"location": loc,
|
||||
"ip_type": ip_type,
|
||||
"quality": quality,
|
||||
"cached_at": now,
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"[enrich_ip_info] Query failed: {e}", flush=True)
|
||||
|
||||
if not new_entries:
|
||||
return
|
||||
|
||||
# 3. Save cache thread-safely (reload & update to avoid overwrite of concurrent queries)
|
||||
with ip_cache_lock:
|
||||
cache = load_ip_cache()
|
||||
cache.update(new_entries)
|
||||
save_ip_cache(cache)
|
||||
|
||||
# 4. Enrich nodes with newly queried info
|
||||
for node in nodes:
|
||||
ip = node.get("ip") or node.get("remote_host")
|
||||
if ip in new_entries:
|
||||
cached = new_entries[ip]
|
||||
node["owner"] = cached.get("owner", "")
|
||||
node["asn"] = cached.get("asn", "")
|
||||
node["as_name"] = cached.get("as_name", "")
|
||||
node["location"] = cached.get("location", "")
|
||||
node["ip_type"] = cached.get("ip_type", "")
|
||||
node["quality"] = cached.get("quality", "")
|
||||
Reference in New Issue
Block a user