Add VPN admin tab with subscription proxy via cp-vpn for selected sources.

Persist settings in CA, expose /admin/vpn and /internal/vpn, and route Telegram (and optional web/nlp) traffic through mihomo SOCKS when enabled.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-16 23:41:33 +03:00
co-authored by Cursor
parent 8ab606747f
commit 5811ecb134
25 changed files with 1146 additions and 18 deletions
+204
View File
@@ -0,0 +1,204 @@
"""Poll CA /internal/vpn and keep mihomo config in sync for subscription mode."""
from __future__ import annotations
import json
import logging
import os
import signal
import subprocess
import sys
import time
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
logger = logging.getLogger("cp-vpn")
CA_API_URL = os.getenv("CA_API_URL", "http://ca-api:8000").rstrip("/")
INTERNAL_TOKEN = os.getenv("INTERNAL_TOKEN", "dev-internal-token")
POLL_SECONDS = int(os.getenv("VPN_POLL_SECONDS", "15"))
CONFIG_DIR = Path(os.getenv("MIHOMO_CONFIG_DIR", "/etc/mihomo"))
CONFIG_PATH = CONFIG_DIR / "config.yaml"
MIHOMO_BIN = os.getenv("MIHOMO_BIN", "/usr/local/bin/mihomo")
MIXED_PORT = int(os.getenv("VPN_SOCKS_PORT", "1080"))
CONTROLLER = "127.0.0.1:9090"
_mihomo_proc: subprocess.Popen | None = None
_last_fingerprint: str | None = None
def fetch_vpn() -> dict:
req = Request(
f"{CA_API_URL}/internal/vpn",
headers={"X-Internal-Token": INTERNAL_TOKEN},
method="GET",
)
with urlopen(req, timeout=15) as resp:
return json.loads(resp.read().decode("utf-8"))
def fingerprint(cfg: dict) -> str:
return json.dumps(
{
"enabled": cfg.get("enabled"),
"mode": cfg.get("mode"),
"subscription_url": cfg.get("subscription_url"),
"subscription_interval_seconds": cfg.get("subscription_interval_seconds"),
},
sort_keys=True,
)
def write_direct_config() -> None:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
(CONFIG_DIR / "providers").mkdir(parents=True, exist_ok=True)
CONFIG_PATH.write_text(
f"""# managed by cp-vpn controller — DIRECT (subscription inactive)
mixed-port: {MIXED_PORT}
allow-lan: true
bind-address: "*"
mode: direct
log-level: warning
external-controller: {CONTROLLER}
""",
encoding="utf-8",
)
def write_subscription_config(url: str, interval: int) -> None:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
(CONFIG_DIR / "providers").mkdir(parents=True, exist_ok=True)
# YAML: quote URL; escape double quotes in URL if any
safe_url = url.replace("\\", "\\\\").replace('"', '\\"')
CONFIG_PATH.write_text(
f"""# managed by cp-vpn controller — subscription
mixed-port: {MIXED_PORT}
allow-lan: true
bind-address: "*"
mode: rule
log-level: info
external-controller: {CONTROLLER}
proxy-providers:
sub:
type: http
url: "{safe_url}"
interval: {interval}
path: ./providers/sub.yaml
health-check:
enable: true
url: https://www.gstatic.com/generate_204
interval: 600
proxy-groups:
- name: PROXY
type: select
use:
- sub
rules:
- MATCH,PROXY
""",
encoding="utf-8",
)
def start_mihomo() -> None:
global _mihomo_proc
if _mihomo_proc is not None and _mihomo_proc.poll() is None:
return
logger.info("Starting mihomo (%s)", MIHOMO_BIN)
_mihomo_proc = subprocess.Popen(
[MIHOMO_BIN, "-d", str(CONFIG_DIR)],
stdout=sys.stdout,
stderr=sys.stderr,
)
def stop_mihomo() -> None:
global _mihomo_proc
if _mihomo_proc is None:
return
if _mihomo_proc.poll() is None:
logger.info("Stopping mihomo")
_mihomo_proc.send_signal(signal.SIGTERM)
try:
_mihomo_proc.wait(timeout=10)
except subprocess.TimeoutExpired:
_mihomo_proc.kill()
_mihomo_proc = None
def reload_mihomo() -> None:
"""Force reload via external controller; fall back to process restart."""
body = json.dumps({"path": str(CONFIG_PATH)}).encode("utf-8")
req = Request(
f"http://{CONTROLLER}/configs?force=true",
data=body,
headers={"Content-Type": "application/json"},
method="PUT",
)
try:
with urlopen(req, timeout=10) as resp:
resp.read()
logger.info("Mihomo config reloaded")
return
except (HTTPError, URLError, OSError) as exc:
logger.warning("Reload via API failed (%s); restarting mihomo", exc)
stop_mihomo()
start_mihomo()
def apply_config(cfg: dict) -> None:
global _last_fingerprint
fp = fingerprint(cfg)
if fp == _last_fingerprint and _mihomo_proc is not None and _mihomo_proc.poll() is None:
return
enabled = bool(cfg.get("enabled"))
mode = (cfg.get("mode") or "").strip()
url = (cfg.get("subscription_url") or "").strip()
interval = int(cfg.get("subscription_interval_seconds") or 3600)
if enabled and mode == "subscription" and url:
logger.info("Applying subscription config (interval=%ss)", interval)
write_subscription_config(url, interval)
else:
logger.info("Applying DIRECT config (enabled=%s mode=%s)", enabled, mode)
write_direct_config()
already_running = _mihomo_proc is not None and _mihomo_proc.poll() is None
start_mihomo()
if already_running or _last_fingerprint is not None:
time.sleep(1)
reload_mihomo()
_last_fingerprint = fp
def main() -> None:
global _mihomo_proc
logger.info(
"cp-vpn controller started (poll=%ss api=%s)",
POLL_SECONDS,
CA_API_URL,
)
write_direct_config()
start_mihomo()
while True:
try:
cfg = fetch_vpn()
apply_config(cfg)
except Exception:
logger.exception("Failed to sync VPN config")
if _mihomo_proc is not None and _mihomo_proc.poll() is not None:
logger.warning("mihomo exited with code %s; restarting", _mihomo_proc.returncode)
_mihomo_proc = None
start_mihomo()
time.sleep(POLL_SECONDS)
if __name__ == "__main__":
main()