Files
cellular-proxy/scripts/admin-api.py
T
Hermes 91939c7cc2 feat: UI/CLI 配置代理账密;armv7 仅保留整包
- 面板可设置 7890 账号密码(管理 API :9091)
- cpxy auth --user/--pass/--clear/--show
- Release armv7 整包已验证,已删除分片资产
2026-07-21 12:29:57 +00:00

194 lines
6.5 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""轻量管理 API:用面板 secret 鉴权,配置代理账号密码。
默认监听 127.0.0.1:9091UI 经同源时用相对路径 /cpxy-admin/*
(由 nginx 反代或浏览器直连时配置)。
为免引入 nginx,默认让 UI 调 http://host:9091(与 9090 同机)。
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse
INSTALL_DIR = Path(os.environ.get("INSTALL_DIR", "/opt/cellular-proxy"))
SETTINGS = Path(os.environ.get("CPXY_SETTINGS", str(INSTALL_DIR / "etc" / "settings.conf")))
LISTEN_HOST = os.environ.get("ADMIN_LISTEN_HOST", "0.0.0.0")
LISTEN_PORT = int(os.environ.get("ADMIN_LISTEN_PORT", "9091"))
APPLY_SCRIPT = Path(os.environ.get("CPXY_APPLY_AUTH", str(INSTALL_DIR / "scripts" / "apply-proxy-auth.sh")))
def load_settings(path: Path) -> dict:
data = {}
if not path.is_file():
return data
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
data[k.strip()] = v.strip()
return data
def mask(s: str) -> str:
if not s:
return ""
if len(s) <= 2:
return "***"
return s[0] + "***" + s[-1]
class Handler(BaseHTTPRequestHandler):
server_version = "cellular-proxy-admin/1.0"
def log_message(self, fmt, *args):
sys.stderr.write("[admin-api] " + (fmt % args) + "\n")
def _cors(self):
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
def _json(self, code: int, obj):
body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
self.send_response(code)
self._cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _auth_ok(self) -> bool:
conf = load_settings(SETTINGS)
secret = conf.get("PANEL_SECRET", "")
if not secret:
return False
auth = self.headers.get("Authorization", "")
if auth.startswith("Bearer "):
return auth[7:].strip() == secret
if auth.startswith("Bearer"):
return auth[6:].strip() == secret
# 兼容 query ?secret=
return False
def do_OPTIONS(self):
self.send_response(204)
self._cors()
self.end_headers()
def do_GET(self):
path = urlparse(self.path).path.rstrip("/") or "/"
if path in ("/health", "/cpxy-admin/health"):
self._json(200, {"ok": True})
return
if path not in ("/proxy-auth", "/cpxy-admin/proxy-auth"):
self._json(404, {"error": "not found"})
return
if not self._auth_ok():
self._json(401, {"message": "Unauthorized"})
return
conf = load_settings(SETTINGS)
user = conf.get("PROXY_USER", "")
pw = conf.get("PROXY_PASS", "")
self._json(
200,
{
"proxy_user": user,
"proxy_pass_set": bool(pw),
"proxy_pass_masked": mask(pw) if pw else "",
"auth_enabled": bool(user),
"port": conf.get("PROXY_MIXED_PORT", "7890"),
},
)
def do_POST(self):
path = urlparse(self.path).path.rstrip("/") or "/"
if path not in ("/proxy-auth", "/cpxy-admin/proxy-auth"):
self._json(404, {"error": "not found"})
return
if not self._auth_ok():
self._json(401, {"message": "Unauthorized"})
return
length = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(length) if length else b"{}"
try:
body = json.loads(raw.decode("utf-8") or "{}")
except json.JSONDecodeError:
self._json(400, {"error": "invalid json"})
return
clear = bool(body.get("clear"))
user = (body.get("user") or body.get("proxy_user") or "").strip()
password = body.get("pass") or body.get("password") or body.get("proxy_pass") or ""
if not APPLY_SCRIPT.is_file():
self._json(500, {"error": f"apply script missing: {APPLY_SCRIPT}"})
return
cmd = ["bash", str(APPLY_SCRIPT)]
if clear or (not user and not password):
cmd.append("--clear")
else:
if not user:
self._json(400, {"error": "user required (or set clear:true)"})
return
if password is None:
self._json(400, {"error": "pass required"})
return
# 基本字符约束,避免注入
if not re.fullmatch(r"[A-Za-z0-9_@./+\-]{1,64}", user):
self._json(400, {"error": "user 仅允许字母数字及 _@.+/-"})
return
if len(str(password)) > 128:
self._json(400, {"error": "password too long"})
return
cmd.extend(["--user", user, "--pass", str(password)])
try:
r = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=60,
env={**os.environ, "INSTALL_DIR": str(INSTALL_DIR)},
)
except subprocess.TimeoutExpired:
self._json(504, {"error": "apply timeout"})
return
out = (r.stdout or "") + (r.stderr or "")
if r.returncode != 0:
self._json(500, {"error": "apply failed", "detail": out[-2000:]})
return
conf = load_settings(SETTINGS)
self._json(
200,
{
"ok": True,
"proxy_user": conf.get("PROXY_USER", ""),
"proxy_pass_set": bool(conf.get("PROXY_PASS")),
"auth_enabled": bool(conf.get("PROXY_USER")),
"log": out[-1500:],
},
)
def main():
if not SETTINGS.is_file():
print(f"warn: settings not found yet: {SETTINGS}", file=sys.stderr)
httpd = ThreadingHTTPServer((LISTEN_HOST, LISTEN_PORT), Handler)
print(f"cellular-proxy admin API on {LISTEN_HOST}:{LISTEN_PORT}", flush=True)
httpd.serve_forever()
if __name__ == "__main__":
main()