feat: initial open-source release with cleaned codebase

This commit is contained in:
baoweise
2026-05-28 22:27:14 +08:00
commit 7e7fcecfd0
7 changed files with 5272 additions and 0 deletions
+422
View File
@@ -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", "")