diff --git a/README.md b/README.md index 8f3da5b..bef70ce 100644 --- a/README.md +++ b/README.md @@ -130,8 +130,11 @@ Open (the bare host also serves it): * **rebind device identity** — see below * live tail of the gateway log, auto-refresh -If `api_keys` is set, the panel's JSON API requires it (the page itself stays -public so you can enter the key); the key is kept in `localStorage`. +If `api_keys` is set, `/` and `/panel` are a login form until you sign in with +that key. The browser keeps an HttpOnly session cookie — not the key, and not +in `localStorage`. That cookie unlocks `/api/panel/*` only. Model clients still +send `Authorization: Bearer` or `x-api-key`; a panel session is rejected on +`/v1/*`. With no `api_keys`, the panel stays open for localhost use. ## Account identity (device fingerprint) @@ -207,7 +210,7 @@ Environment variables override it: | `LOOMY_API_KEYS` | comma-separated gateway keys (`[]` = no auth) | | `LOOMY_DEFAULT_MODEL` | fallback model | | `LOOMY_ACCOUNTS_FILE` / `LOOMY_LOG_DIR` | state locations | -| `LOOMY_PROXY` | e.g. `http://127.0.0.1:7877` (default: direct) | +| `LOOMY_PROXY` | `socks5://`, `http://`, or `https://host:port` (default: direct). SOCKS5 resolves DNS at the proxy. The panel can change it at runtime. | | `LOOMY_STRATEGY` | `balance` / `round_robin` / `lru` | ### Protecting the gateway @@ -217,7 +220,8 @@ Environment variables override it: ``` Clients then send `Authorization: Bearer sk-local-whatever` or -`x-api-key: sk-local-whatever`. `/health` stays public for probes. +`x-api-key: sk-local-whatever`. The same key is what you type into the panel +login page. `/health` stays public for probes. ## Docker @@ -339,11 +343,14 @@ By default the gateway binds `127.0.0.1` — only your machine can reach it. To share it on your LAN or the internet: 1. **Set an API key first.** Without `api_keys`, anyone who can reach the port - spends your account's points: + spends your account's points, and the panel has no login page: ```json { "api_keys": ["sk-something-long-and-random"] } ``` + + Opening the site then shows a login form. The key is checked and discarded; + it is not stored in the browser. 2. Then `LOOMY_HOST=0.0.0.0` (or `"host": "0.0.0.0"` in `config.json`). 3. Put a reverse proxy with TLS in front if it's public. Caddy example: diff --git a/README.zh-CN.md b/README.zh-CN.md index dc22488..d35dc32 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -121,8 +121,11 @@ export ANTHROPIC_API_KEY=随便填 * **重绑设备标识**(见下一节) * 实时日志尾巴,可自动刷新 -如果配置了 `api_keys`,面板的接口就需要这个 Key(页面本身保持公开,方便你填 Key), -Key 存在浏览器 localStorage 里。 +如果配置了 `api_keys`,打开 `/` 或 `/panel` 会先看到登录页,用这把 Key 登录。 +浏览器只保存一个 HttpOnly 会话 Cookie,不保存 Key 本身,也不再写入 +`localStorage`。这张 Cookie 只能访问 `/api/panel/*`。模型客户端仍然发送 +`Authorization: Bearer` 或 `x-api-key`;面板会话访问 `/v1/*` 会被拒绝。 +不配置 `api_keys` 时面板保持开放,只适合本机使用。 ## 账号设备标识(指纹) @@ -193,7 +196,7 @@ Key 存在浏览器 localStorage 里。 | `LOOMY_API_KEYS` | 网关自己的 Key,逗号分隔(`[]` = 不鉴权) | | `LOOMY_DEFAULT_MODEL` | 兜底模型 | | `LOOMY_ACCOUNTS_FILE` / `LOOMY_LOG_DIR` | 状态文件位置 | -| `LOOMY_PROXY` | 如 `http://127.0.0.1:7877`(默认直连) | +| `LOOMY_PROXY` | `socks5://`、`http://` 或 `https://主机:端口`(默认直连)。SOCKS5 由代理解析域名。面板里也能改,立即生效。 | | `LOOMY_STRATEGY` | `balance` / `round_robin` / `lru` | ### 给网关加把锁 @@ -203,7 +206,7 @@ Key 存在浏览器 localStorage 里。 ``` 客户端带 `Authorization: Bearer sk-local-whatever` 或 `x-api-key: sk-local-whatever`。 -`/health` 始终公开,方便探活。 +面板登录页填的也是这把 Key。`/health` 始终公开,方便探活。 ## Docker @@ -321,11 +324,13 @@ docker compose logs -f 默认只监听 `127.0.0.1`,只有本机能访问。要在局域网/公网共享: -1. **先设 API Key**。不设的话,谁能连上这个端口就能花你账号的积分: +1. **先设 API Key**。不设的话,谁能连上这个端口就能花你账号的积分,面板也没有登录页: ```json { "api_keys": ["sk-换成一串又长又随机的字符串"] } ``` + + 设好之后打开站点会先进入登录页。Key 只用来核对,不会留在浏览器里。 2. 再设 `LOOMY_HOST=0.0.0.0`(或 config.json 里 `"host": "0.0.0.0"`)。 3. 公网的话,前面挂一个带 TLS 的反代。Caddy 示例: diff --git a/config.example.json b/config.example.json index 96d476b..54c1e29 100644 --- a/config.example.json +++ b/config.example.json @@ -13,10 +13,10 @@ "access_key_secret": "", "loomy_version": "0.9.38", - "_proxy": "empty = direct connection (recommended for these domestic endpoints)", + "_proxy": "empty = direct. socks5://, http://, or https://user:pass@host:port. SOCKS5 resolves names at the proxy. Editable from the panel.", "proxy": "", - "_api_keys": "empty list = the gateway needs no key. Set e.g. [\"sk-abc\"] to require Bearer/x-api-key.", + "_api_keys": "empty list = no auth (fine on localhost). Set e.g. [\"sk-abc\"] to require Bearer/x-api-key on the model API and a login page on /panel. The panel session cookie is not accepted on /v1.", "api_keys": [], "default_model": "deepseek-v4-flash-0731", diff --git a/loomy2api/config.py b/loomy2api/config.py index 7d16163..f9a6091 100644 --- a/loomy2api/config.py +++ b/loomy2api/config.py @@ -30,7 +30,7 @@ "access_key_id": "", # empty → use the client-shipped constant "access_key_secret": "", "loomy_version": C.DEFAULT_LOOMY_VERSION, - "proxy": "", # "" = direct; e.g. http://127.0.0.1:7877 + "proxy": "", # "" = direct; http:// https:// or socks5://host:port # gateway ------------------------------------------------------------- "api_keys": [], # [] = no auth; otherwise Bearer / x-api-key @@ -132,4 +132,7 @@ def load_config(path: str | os.PathLike | None = None) -> Config: if value: cfg[cfg_key] = _coerce(cfg, cfg_key, value) + # Remember where to write panel edits back. Missing file is fine. + cfg.config_path = config_path + cfg.proxy_from_env = bool(os.environ.get("LOOMY_PROXY")) return cfg diff --git a/loomy2api/httpc.py b/loomy2api/httpc.py index 0cb9855..0808ccc 100644 --- a/loomy2api/httpc.py +++ b/loomy2api/httpc.py @@ -4,64 +4,477 @@ the opposite of what you want here: the upstreams are domestic Chinese endpoints, so the default is *direct* and a proxy must be configured on purpose. + +``proxy`` accepts ``http://``, ``https://`` and ``socks5://`` (``socks5h://`` +is accepted and treated the same). SOCKS5 always asks the proxy to resolve +the name. An HTTPS proxy is a TLS-wrapped HTTP proxy: connect to it with +TLS, CONNECT the target, then TLS again when the target itself is HTTPS. """ from __future__ import annotations +import base64 +import io import json +import socket import ssl -from http.client import HTTPConnection, HTTPSConnection +from http.client import HTTPConnection from typing import Dict, Optional, Tuple -from urllib.parse import urlparse +from urllib.parse import quote, unquote, urlparse -__all__ = ["request", "open_stream", "HttpResult"] +__all__ = [ + "ProxyError", "request", "open_stream", "json_body", + "normalize_proxy", "proxy_view", "compose_proxy", +] HttpResult = Tuple[int, Dict[str, str], bytes] +_PROXY_SCHEMES = {"http", "https", "socks5", "socks5h"} + + +class ProxyError(OSError): + """The configured proxy could not be used.""" + + +class _Endpoint: + def __init__(self, scheme: str, host: str, port: int, + username: str = "", password: str = ""): + self.scheme = scheme + self.host = host + self.port = port + self.username = username + self.password = password + + +class _ReadyConnection(HTTPConnection): + """An ``HTTPConnection`` whose socket is already the target tunnel.""" + + def __init__(self, host: str, port: int, sock: socket.socket, timeout: float): + super().__init__(host, port, timeout=timeout) + self.sock = sock + + def connect(self) -> None: # already connected + return + + +class _HttpProxyConnection(HTTPConnection): + """Speak HTTP to a proxy while the Host header names the real target.""" + + def __init__(self, proxy_host: str, proxy_port: int, + target_host: str, target_port: int, timeout: float): + super().__init__(target_host, target_port, timeout=timeout) + self._proxy_addr = (proxy_host, proxy_port) + + def connect(self) -> None: + self.sock = socket.create_connection(self._proxy_addr, self.timeout) + self.sock.settimeout(self.timeout) + + +def _default_proxy_port(scheme: str) -> int: + if scheme in ("socks5", "socks5h"): + return 1080 + if scheme == "https": + return 443 + return 80 + + +def _format_host(host: str) -> str: + return f"[{host}]" if ":" in host else host + + +def parse_proxy(value: str) -> Optional[_Endpoint]: + """Parse a proxy URL. Empty means direct. Raises ``ValueError`` when bad.""" + raw = (value or "").strip() + if not raw: + return None + parsed = urlparse(raw) + scheme = (parsed.scheme or "").lower() + if scheme not in _PROXY_SCHEMES: + raise ValueError( + "代理只支持 http://、https://、socks5:// / " + "proxy scheme must be http, https, or socks5") + host = parsed.hostname or "" + if not host: + raise ValueError("代理缺少主机 / proxy host is required") + try: + port = parsed.port + except ValueError as exc: + raise ValueError("代理端口无效 / invalid proxy port") from exc + port = port or _default_proxy_port(scheme) + if not 1 <= port <= 65535: + raise ValueError("代理端口无效 / invalid proxy port") + username = unquote(parsed.username) if parsed.username else "" + password = unquote(parsed.password) if parsed.password else "" + canonical = "socks5" if scheme == "socks5h" else scheme + return _Endpoint(canonical, host, port, username, password) + + +def normalize_proxy(value: str) -> str: + """Canonical proxy URL, or ``\"\"`` for a direct connection.""" + endpoint = parse_proxy(value) + if endpoint is None: + return "" + return _format_endpoint(endpoint) + + +def compose_proxy(*, scheme: str, host: str = "", port=None, + username: str = "", password: Optional[str] = None, + previous: str = "") -> str: + """Build a proxy URL from the panel form. + + ``password is None`` keeps the previous password when the username did + not change. An empty string clears it. + """ + scheme = (scheme or "").strip().lower() + if scheme in ("", "direct", "none", "off"): + return "" + if scheme == "socks5h": + scheme = "socks5" + if scheme not in ("http", "https", "socks5"): + raise ValueError( + "代理只支持 http、https、socks5 / " + "proxy scheme must be http, https, or socks5") + host = (host or "").strip().strip("[]") + if not host: + raise ValueError("代理缺少主机 / proxy host is required") + if port in ("", None): + port_num = _default_proxy_port(scheme) + else: + try: + port_num = int(port) + except (TypeError, ValueError) as exc: + raise ValueError("代理端口无效 / invalid proxy port") from exc + if not 1 <= port_num <= 65535: + raise ValueError("代理端口无效 / invalid proxy port") + username = username or "" + if password is None: + previous_ep = parse_proxy(previous) if previous else None + if (previous_ep and previous_ep.username == username + and previous_ep.password): + password = previous_ep.password + else: + password = "" + return _format_endpoint(_Endpoint(scheme, host, port_num, username, password or "")) + + +def proxy_view(value: str) -> Dict[str, object]: + """Panel-safe description. Never includes the password.""" + try: + endpoint = parse_proxy(value) + except ValueError: + endpoint = None + if endpoint is None: + return { + "enabled": False, "scheme": "direct", "host": "", "port": "", + "username": "", "has_password": False, "masked": "", + } + auth = "" + if endpoint.username: + auth = quote(endpoint.username, safe="") + (":***@" if endpoint.password else "@") + masked = (f"{endpoint.scheme}://{auth}{_format_host(endpoint.host)}:{endpoint.port}") + return { + "enabled": True, + "scheme": endpoint.scheme, + "host": endpoint.host, + "port": endpoint.port, + "username": endpoint.username, + "has_password": bool(endpoint.password), + "masked": masked, + } + + +def _format_endpoint(endpoint: _Endpoint) -> str: + auth = "" + if endpoint.username or endpoint.password: + user = quote(endpoint.username, safe="") + if endpoint.password: + auth = f"{user}:{quote(endpoint.password, safe='')}@" + else: + auth = f"{user}@" + return (f"{endpoint.scheme}://{auth}" + f"{_format_host(endpoint.host)}:{endpoint.port}") + def _split(url: str): - u = urlparse(url) - if u.scheme not in ("http", "https"): - raise ValueError(f"unsupported URL scheme: {url}") - return u - - -def _connect(url: str, timeout: float, proxy: str = "") -> HTTPConnection: - """Open a connection, honouring ``proxy`` (``http://host:port``).""" - u = _split(url) - if proxy: - p = _split(proxy) - if u.scheme == "https": - # CONNECT tunnel - conn = HTTPSConnection(u.hostname, u.port or 443, timeout=timeout, - context=ssl.create_default_context()) - conn.set_tunnel(u.hostname, u.port or 443, - headers={"Proxy-Connection": "keep-alive"}) - return conn - return HTTPConnection(p.hostname, p.port or 80, timeout=timeout) - if u.scheme == "https": - return HTTPSConnection(u.hostname, u.port or 443, timeout=timeout, - context=ssl.create_default_context()) - return HTTPConnection(u.hostname, u.port or 80, timeout=timeout) + parsed = urlparse(url) + if parsed.scheme not in ("http", "https") or not parsed.hostname: + raise ValueError(f"unsupported URL: {url}") + return parsed + + +def _target(url: str) -> _Endpoint: + parsed = _split(url) + port = parsed.port or (443 if parsed.scheme == "https" else 80) + return _Endpoint(parsed.scheme, parsed.hostname, port) def _path_with_query(url: str) -> str: - u = _split(url) - return u.path + (f"?{u.query}" if u.query else "") + parsed = _split(url) + path = parsed.path or "/" + return path + (f"?{parsed.query}" if parsed.query else "") + + +def _recv_exact(sock: socket.socket, size: int) -> bytes: + buf = b"" + while len(buf) < size: + chunk = sock.recv(size - len(buf)) + if not chunk: + raise ProxyError("代理提前断开连接 / proxy closed the connection") + buf += chunk + return buf + + +def _proxy_basic(username: str, password: str) -> Dict[str, str]: + if not username and not password: + return {} + token = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("ascii") + return {"Proxy-Authorization": f"Basic {token}"} + + +def _read_http_head(sock: socket.socket) -> bytes: + buf = b"" + while b"\r\n\r\n" not in buf: + chunk = sock.recv(4096) + if not chunk: + break + buf += chunk + if len(buf) > 65536: + break + return buf + + +def _http_connect(sock: socket.socket, host: str, port: int, + username: str = "", password: str = "") -> None: + lines = [ + f"CONNECT {_format_host(host)}:{port} HTTP/1.1", + f"Host: {_format_host(host)}:{port}", + ] + basic = _proxy_basic(username, password) + if basic: + lines.append(f"Proxy-Authorization: {basic['Proxy-Authorization']}") + sock.sendall(("\r\n".join(lines) + "\r\n\r\n").encode("ascii")) + head = _read_http_head(sock) + status = head.split(b"\r\n", 1)[0] + parts = status.split() + if len(parts) < 2 or parts[1] != b"200": + shown = status.decode("utf-8", "replace")[:120] + raise ProxyError(f"CONNECT 被拒绝 / CONNECT rejected: {shown}") + + +def _socks5_connect(sock: socket.socket, host: str, port: int, + username: str = "", password: str = "") -> None: + host_bytes = host.encode("idna") + if len(host_bytes) > 255: + raise ProxyError("SOCKS5 主机名过长 / SOCKS5 hostname is too long") + if username or password: + sock.sendall(b"\x05\x02\x00\x02") + else: + sock.sendall(b"\x05\x01\x00") + version, method = _recv_exact(sock, 2) + if version != 5: + raise ProxyError("不是 SOCKS5 代理 / not a SOCKS5 proxy") + if method == 2: + user = username.encode("utf-8") + passwd = password.encode("utf-8") + if len(user) > 255 or len(passwd) > 255: + raise ProxyError("SOCKS5 账号过长 / SOCKS5 credentials are too long") + sock.sendall(bytes((1, len(user))) + user + bytes((len(passwd),)) + passwd) + auth_ver, auth_status = _recv_exact(sock, 2) + if auth_ver != 1 or auth_status != 0: + raise ProxyError("SOCKS5 认证失败 / SOCKS5 authentication failed") + elif method != 0: + raise ProxyError("SOCKS5 代理不接受这种认证 / SOCKS5 auth method rejected") + request = b"\x05\x01\x00\x03" + bytes((len(host_bytes),)) + host_bytes + port.to_bytes(2, "big") + sock.sendall(request) + reply = _recv_exact(sock, 4) + if reply[1] != 0: + raise ProxyError(f"SOCKS5 连接失败 / SOCKS5 connect failed (code {reply[1]})") + atyp = reply[3] + if atyp == 1: + _recv_exact(sock, 6) + elif atyp == 4: + _recv_exact(sock, 18) + elif atyp == 3: + length = _recv_exact(sock, 1)[0] + _recv_exact(sock, length + 2) + else: + raise ProxyError("SOCKS5 应答无法识别 / unrecognised SOCKS5 reply") + + +def _tls(sock: socket.socket, server_hostname: str) -> ssl.SSLSocket: + context = ssl.create_default_context() + return context.wrap_socket(sock, server_hostname=server_hostname) + + +class _Reader(io.RawIOBase): + def __init__(self, stream: "_OverTLS"): + self.stream = stream + + def readable(self) -> bool: + return True + + def readinto(self, buf) -> int: + data = self.stream.recv(len(buf)) + if not data: + return 0 + n = len(data) + buf[:n] = data + return n + + +class _OverTLS: + """TLS written through an existing stream. + + Used when the proxy itself is already TLS. ``SSLContext.wrap_socket`` + would handshake on the raw file descriptor and corrupt that outer TLS, + so this layer uses a memory BIO instead. + """ + + def __init__(self, transport: socket.socket, server_hostname: str): + self._transport = transport + self._in = ssl.MemoryBIO() + self._out = ssl.MemoryBIO() + context = ssl.create_default_context() + self._ssl = context.wrap_bio( + self._in, self._out, server_hostname=server_hostname) + self._handshake() + + def _flush(self) -> None: + data = self._out.read() + if data: + self._transport.sendall(data) + + def _fill(self) -> None: + self._flush() + chunk = self._transport.recv(16384) + if chunk: + self._in.write(chunk) + else: + self._in.write_eof() + + def _handshake(self) -> None: + while True: + try: + self._ssl.do_handshake() + self._flush() + return + except ssl.SSLWantReadError: + self._fill() + + def sendall(self, data: bytes) -> None: + view = memoryview(data) + sent = 0 + while sent < len(view): + try: + written = self._ssl.write(view[sent:]) + except ssl.SSLWantReadError: + self._fill() + continue + sent += written + self._flush() + + def recv(self, size: int) -> bytes: + while True: + try: + data = self._ssl.read(size) + except ssl.SSLWantReadError: + self._fill() + continue + return data or b"" + + def makefile(self, mode: str = "rb", buffering: int = -1, *args, **kwargs): + if "b" not in mode: + raise ValueError(mode) + return io.BufferedReader(_Reader(self)) + + def settimeout(self, timeout: Optional[float]) -> None: + self._transport.settimeout(timeout) + + def close(self) -> None: + try: + self._transport.close() + except OSError: + pass + + def shutdown(self, how: int) -> None: + try: + self._transport.shutdown(how) + except OSError: + pass + + +def _open(url: str, timeout: float, proxy: str) -> Tuple[HTTPConnection, str]: + """Return ``(connection, request-target)`` for one URL.""" + target = _target(url) + path = _path_with_query(url) + endpoint = parse_proxy(proxy) + if endpoint is None: + if target.scheme == "https": + conn = _ReadyConnection( + target.host, target.port, + _tls(_tcp(target.host, target.port, timeout), target.host), + timeout) + return conn, path + return HTTPConnection(target.host, target.port, timeout=timeout), path + + if endpoint.scheme == "socks5": + try: + sock = _tcp(endpoint.host, endpoint.port, timeout) + except OSError as exc: + raise ProxyError( + f"无法连接 SOCKS5 {endpoint.host}:{endpoint.port} / " + f"cannot connect: {exc}") from exc + _socks5_connect(sock, target.host, target.port, endpoint.username, endpoint.password) + if target.scheme == "https": + sock = _tls(sock, target.host) + return _ReadyConnection(target.host, target.port, sock, timeout), path + + # http or https proxy + try: + sock = _tcp(endpoint.host, endpoint.port, timeout) + except OSError as exc: + raise ProxyError( + f"无法连接代理 {endpoint.host}:{endpoint.port} / " + f"cannot connect: {exc}") from exc + if endpoint.scheme == "https": + sock = _tls(sock, endpoint.host) + if target.scheme == "https": + _http_connect(sock, target.host, target.port, endpoint.username, endpoint.password) + if endpoint.scheme == "https": + sock = _OverTLS(sock, target.host) + else: + sock = _tls(sock, target.host) + return _ReadyConnection(target.host, target.port, sock, timeout), path + + conn = _HttpProxyConnection(endpoint.host, endpoint.port, + target.host, target.port, timeout) + # The socket is opened lazily. Stash auth for the caller to add. + conn.proxy_auth = _proxy_basic(endpoint.username, endpoint.password) # type: ignore[attr-defined] + absolute = f"http://{_format_host(target.host)}:{target.port}{path}" + return conn, absolute + + +def _tcp(host: str, port: int, timeout: float) -> socket.socket: + sock = socket.create_connection((host, port), timeout) + sock.settimeout(timeout) + return sock -def _proxy_scheme_ok(url: str, proxy: str) -> bool: # pragma: no cover - helper - return bool(proxy) +def _merge_headers(conn: HTTPConnection, headers: Optional[dict]) -> Dict[str, str]: + merged = dict(headers or {}) + auth = getattr(conn, "proxy_auth", None) or {} + for key, value in auth.items(): + merged.setdefault(key, value) + return merged def request(url: str, *, method: str = "GET", headers: Optional[dict] = None, body: Optional[bytes] = None, timeout: float = 30, proxy: str = "") -> HttpResult: """Blocking request → ``(status, headers, body)``.""" - conn = _connect(url, timeout, proxy) + conn, target = _open(url, timeout, proxy) try: - conn.request(method, _path_with_query(url), body=body, - headers=headers or {}) + conn.request(method, target, body=body, headers=_merge_headers(conn, headers)) resp = conn.getresponse() data = resp.read() hdrs = {k: v for k, v in resp.getheaders()} @@ -77,10 +490,9 @@ def open_stream(url: str, *, method: str = "POST", headers: Optional[dict] = Non The caller owns both objects and must close the connection. """ - conn = _connect(url, timeout, proxy) + conn, target = _open(url, timeout, proxy) try: - conn.request(method, _path_with_query(url), body=body, - headers=headers or {}) + conn.request(method, target, body=body, headers=_merge_headers(conn, headers)) resp = conn.getresponse() except Exception: conn.close() diff --git a/loomy2api/panel.py b/loomy2api/panel.py index 6b78511..e4f417c 100644 --- a/loomy2api/panel.py +++ b/loomy2api/panel.py @@ -1,15 +1,20 @@ """Web control panel: HTML page + JSON API. The page is a single self-contained HTML file (no CDN, no build step) served at -``/panel``; the JSON API lives under ``/api/panel/*`` and reuses the gateway's -API-key gate when ``api_keys`` is configured. +``/panel``. When ``api_keys`` is set, anonymous visitors get ``login.html`` +instead, and the JSON API accepts either the gateway key or the panel session +cookie. Endpoints --------- -``GET /panel`` the page +``GET /panel`` the page, or the login form +``POST /api/panel/login`` trade an API key for a session cookie +``POST /api/panel/logout`` drop that cookie +``GET /api/panel/session`` ``{auth_required, authenticated}`` only ``GET /api/panel/state`` accounts + quota + totals (``?refresh=1`` forces a quota refresh) ``POST /api/panel/refresh`` refresh every account's quota +``POST /api/panel/proxy`` set the upstream proxy (socks5/http/https) ``POST /api/panel/accounts`` add an account (optionally log in now) ``POST /api/panel/accounts/update`` patch loginid / password / enabled ``POST /api/panel/accounts/remove`` delete an account @@ -20,17 +25,21 @@ from __future__ import annotations +import json +import os import time from pathlib import Path from typing import Any, Dict, Optional from .account import Account, AccountError +from .httpc import compose_proxy, normalize_proxy, proxy_view from .pool import PoolError __all__ = ["Panel"] WEB_DIR = Path(__file__).resolve().parent / "web" PANEL_HTML = WEB_DIR / "index.html" +LOGIN_HTML = WEB_DIR / "login.html" #: Hidden phone number for the panel: 138****0000 def mask_phone(value: str) -> str: @@ -59,6 +68,12 @@ def html(self) -> bytes: except OSError: # pragma: no cover return b"

loomy2api

panel asset missing

" + def login_html(self) -> bytes: + try: + return LOGIN_HTML.read_bytes() + except OSError: # pragma: no cover + return b"

loomy2api

login asset missing

" + def _account_view(self, acc: Account) -> Dict[str, Any]: view = acc.public_dict() view["loginid_masked"] = mask_phone(acc.loginid) @@ -115,6 +130,8 @@ def state(self, *, refresh: bool = False) -> Dict[str, Any]: "models": len(self.gw.catalogue()), "auth_required": bool(self.gw.cfg.api_keys), "quota_refresh_minutes": self.gw.cfg.get("quota_refresh_minutes"), + "proxy": proxy_view(str(self.gw.cfg.get("proxy") or "")), + "proxy_from_env": bool(getattr(self.gw.cfg, "proxy_from_env", False)), }, "accounts": accounts, } @@ -125,6 +142,55 @@ def refresh(self, payload: Dict[str, Any]) -> Dict[str, Any]: self._refresh_all(only_stale=False) return self.state() + def set_proxy(self, payload: Dict[str, Any]) -> Dict[str, Any]: + """Point upstream traffic at a SOCKS5, HTTP, or HTTPS proxy. + + Takes either ``{"proxy": "socks5://host:1080"}`` or the panel fields + ``scheme`` / ``host`` / ``port`` / ``username`` / ``password``. + ``scheme: "direct"`` clears it. A missing password keeps the previous + one. The value is applied immediately and written back to config.json. + """ + current = str(self.gw.cfg.get("proxy") or "") + if payload.get("scheme") or "host" in payload: + url = compose_proxy( + scheme=str(payload.get("scheme") or "direct"), + host=str(payload.get("host") or ""), + port=payload.get("port"), + username=str(payload.get("username") or ""), + password=payload.get("password") if "password" in payload else None, + previous=current, + ) + elif "proxy" in payload: + url = normalize_proxy(str(payload.get("proxy") or "")) + else: + raise ValueError("缺少代理配置 / proxy settings are required") + self.gw.cfg["proxy"] = url + self._persist_proxy(url) + shown = proxy_view(url)["masked"] or "直连" + self.log(f"出口代理已更新:{shown}") + return {"ok": True, "proxy": proxy_view(url), "state": self.state()} + + def _persist_proxy(self, url: str) -> None: + path = getattr(self.gw.cfg, "config_path", None) + if not path: + return + path = Path(path) + data: Dict[str, Any] = {} + if path.exists(): + try: + loaded = json.loads(path.read_text(encoding="utf-8")) + except Exception as exc: # noqa: BLE001 + raise ValueError(f"config.json 无法读取 / cannot read config.json: {exc}") from exc + if not isinstance(loaded, dict): + raise ValueError("config.json 不是对象 / config.json is not an object") + data = loaded + data["proxy"] = url + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(path.name + ".tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + os.chmod(tmp, 0o600) + os.replace(tmp, path) + def add_account(self, payload: Dict[str, Any]) -> Dict[str, Any]: name = str(payload.get("name") or "").strip() loginid = str(payload.get("loginid") or "").strip() diff --git a/loomy2api/panel_auth.py b/loomy2api/panel_auth.py new file mode 100644 index 0000000..33eaf0e --- /dev/null +++ b/loomy2api/panel_auth.py @@ -0,0 +1,156 @@ +"""Panel login sessions. + +The model API keeps using ``Authorization: Bearer`` / ``x-api-key``. The +control panel does not: when ``api_keys`` is set, ``/panel`` is a login form +and a successful login plants an HttpOnly cookie. The cookie is a random +session id, never the API key, and it is accepted only by ``/api/panel/*``. +""" + +from __future__ import annotations + +import hashlib +import hmac +import secrets +import threading +import time +from typing import Dict, Iterable, Tuple + +__all__ = ["PanelSessions", "api_key_ok"] + +COOKIE = "loomy_panel" + + +def api_key_ok(presented: str, keys: Iterable[str]) -> bool: + """True when ``presented`` matches one configured key. + + Both sides are hashed first so the comparison is fixed-length. An empty + key list never matches. + """ + presented = presented or "" + given = hashlib.sha256(presented.encode("utf-8")).digest() + ok = False + seen = False + for key in keys: + if not key: + continue + seen = True + candidate = hashlib.sha256(str(key).encode("utf-8")).digest() + ok = hmac.compare_digest(given, candidate) or ok + return seen and ok + + +def set_cookie(token: str, *, secure: bool, max_age: int) -> str: + parts = [ + f"{COOKIE}={token}", + "HttpOnly", + "Path=/", + "SameSite=Lax", + f"Max-Age={max_age}", + ] + if secure: + parts.append("Secure") + return "; ".join(parts) + + +def clear_cookie(*, secure: bool) -> str: + parts = [ + f"{COOKIE}=", + "HttpOnly", + "Path=/", + "SameSite=Lax", + "Max-Age=0", + ] + if secure: + parts.append("Secure") + return "; ".join(parts) + + +class PanelSessions: + """In-memory panel sessions for the single gateway process. + + A restart drops every session, which logs the panel out. That is + deliberate: the session store is not written next to ``accounts.json``. + """ + + COOKIE = COOKIE + TTL = 12 * 3600 + MAX = 64 + FAIL_LIMIT = 8 + LOCK_SECONDS = 60 + + def __init__(self) -> None: + self._lock = threading.Lock() + self._tokens: Dict[str, float] = {} + self._fails: Dict[str, Tuple[int, float]] = {} + + def issue(self) -> str: + token = secrets.token_urlsafe(32) + now = time.time() + with self._lock: + self._purge(now) + if len(self._tokens) >= self.MAX: + oldest = min(self._tokens, key=self._tokens.get) + self._tokens.pop(oldest, None) + self._tokens[token] = now + self.TTL + return token + + def valid(self, token: str) -> bool: + if not token: + return False + now = time.time() + want = hashlib.sha256(token.encode("utf-8")).digest() + with self._lock: + self._purge(now) + matched = None + for saved, expiry in self._tokens.items(): + have = hashlib.sha256(saved.encode("utf-8")).digest() + if hmac.compare_digest(want, have) and expiry >= now: + matched = saved + break + if matched is None: + return False + self._tokens[matched] = now + self.TTL + return True + + def revoke(self, token: str) -> None: + if not token: + return + want = hashlib.sha256(token.encode("utf-8")).digest() + with self._lock: + for saved in list(self._tokens): + have = hashlib.sha256(saved.encode("utf-8")).digest() + if hmac.compare_digest(want, have): + self._tokens.pop(saved, None) + return + + def locked(self, ip: str) -> bool: + now = time.time() + with self._lock: + count, until = self._fails.get(ip, (0, 0.0)) + if until and until <= now: + self._fails.pop(ip, None) + return False + return until > now + + def note_failure(self, ip: str) -> bool: + """Record a bad password. Return True when ``ip`` is locked out.""" + now = time.time() + with self._lock: + count, until = self._fails.get(ip, (0, 0.0)) + if until > now: + return True + if until: + count = 0 + count += 1 + locked = count >= self.FAIL_LIMIT + self._fails[ip] = (count, now + self.LOCK_SECONDS if locked else 0.0) + return locked + + def clear_failures(self, ip: str) -> None: + with self._lock: + self._fails.pop(ip, None) + + def _purge(self, now: float) -> None: + dead = [token for token, expiry in self._tokens.items() if expiry < now] + for token in dead: + self._tokens.pop(token, None) diff --git a/loomy2api/server.py b/loomy2api/server.py index b312b9d..4b2a9b5 100644 --- a/loomy2api/server.py +++ b/loomy2api/server.py @@ -16,6 +16,7 @@ from . import constants as C from . import anthropic as anth from .panel import Panel +from .panel_auth import PanelSessions, api_key_ok, clear_cookie, set_cookie from .pool import AccountPool, PoolError from .upstream import ModelGateway, sse_usage, human_usage @@ -54,6 +55,7 @@ def __init__(self, cfg, log: Optional[Logger] = None): self.pool = AccountPool(cfg, logger=self.log) self.models_client = ModelGateway(cfg) self.panel = Panel(self) + self.sessions = PanelSessions() self.models: list = [] self._models_lock = threading.Lock() @@ -103,7 +105,10 @@ def resolve_model(self, requested: str) -> str: def start(self) -> None: self.pool.bootstrap() self.pool.start() - self.refresh_models() + # Catalogue fetch uses the upstream timeout and can sit in a TLS + # handshake for a long time. Do not block the listening socket on it. + threading.Thread(target=self.refresh_models, name="model-catalogue", + daemon=True).start() # ------------------------------------------------------------------ handler @@ -204,14 +209,51 @@ def _path(self) -> str: # --------------------------------------------------------------- auth + def _presented_key(self) -> str: + auth = self.headers.get("Authorization") or "" + token = auth[7:].strip() if auth.lower().startswith("bearer ") else "" + return token or (self.headers.get("x-api-key") or "").strip() + def _authorized(self) -> bool: + """Bearer / x-api-key gate for the model API. Panel cookies do not pass.""" keys = self.gateway.cfg.api_keys if not keys: return True - auth = self.headers.get("Authorization") or "" - token = auth[7:].strip() if auth.lower().startswith("bearer ") else "" - token = token or (self.headers.get("x-api-key") or "").strip() - if token in keys: + if api_key_ok(self._presented_key(), keys): + return True + self._error(401, "无效的 API Key / invalid API key", "authentication_error") + return False + + def _panel_cookie(self) -> str: + raw = self.headers.get("Cookie") or "" + for part in raw.split(";"): + name, _, value = part.strip().partition("=") + if name == PanelSessions.COOKIE: + return value.strip() + return "" + + def _cookie_secure(self) -> bool: + proto = (self.headers.get("X-Forwarded-Proto") or "").split(",")[0].strip().lower() + return proto == "https" + + def _client_ip(self) -> str: + peer = self.client_address[0] if self.client_address else "" + # Behind the local reverse proxy every peer is loopback; the real + # client is the first X-Forwarded-For hop Caddy (or similar) set. + if peer in ("127.0.0.1", "::1"): + forwarded = (self.headers.get("X-Forwarded-For") or "").split(",")[0].strip() + if forwarded: + return forwarded + return peer or "unknown" + + def _panel_authorized(self) -> bool: + """Panel JSON: gateway key or the login session. Not the model API.""" + keys = self.gateway.cfg.api_keys + if not keys: + return True + if api_key_ok(self._presented_key(), keys): + return True + if self.gateway.sessions.valid(self._panel_cookie()): return True self._error(401, "无效的 API Key / invalid API key", "authentication_error") return False @@ -233,9 +275,14 @@ def do_GET(self): # noqa: N802 path = self._path() if path in ("/v1/health", "/health"): return self._health() + if path == "/api/panel/session": + return self._panel_session() if path in ("/panel", "/", "/index.html"): return self._panel_page() - if not self._authorized(): + if path.startswith("/api/panel/"): + if not self._panel_authorized(): + return + elif not self._authorized(): return try: if path == "/v1/models": @@ -261,7 +308,14 @@ def do_GET(self): # noqa: N802 def do_POST(self): # noqa: N802 path = self._path() - if not self._authorized(): + if path == "/api/panel/login": + return self._panel_login() + if path == "/api/panel/logout": + return self._panel_logout() + if path.startswith("/api/panel/"): + if not self._panel_authorized(): + return + elif not self._authorized(): return try: if path == "/v1/chat/completions": @@ -294,7 +348,11 @@ def do_POST(self): # noqa: N802 # ------------------------------------------------------------- panel def _panel_page(self) -> None: - body = self.gateway.panel.html() + signed_in = self.gateway.sessions.valid(self._panel_cookie()) + if self.gateway.cfg.api_keys and not signed_in: + body = self.gateway.panel.login_html() + else: + body = self.gateway.panel.html() self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(body))) @@ -302,6 +360,50 @@ def _panel_page(self) -> None: self.end_headers() self.wfile.write(body) + def _panel_session(self) -> None: + required = bool(self.gateway.cfg.api_keys) + signed_in = self.gateway.sessions.valid(self._panel_cookie()) + return self._json(200, { + "ok": True, + "auth_required": required, + "authenticated": (not required) or signed_in, + }) + + def _panel_login(self) -> None: + if not self.gateway.cfg.api_keys: + return self._error(400, "面板未开启登录 / panel login is not configured") + ip = self._client_ip() + sessions = self.gateway.sessions + if sessions.locked(ip): + return self._error(429, "尝试过于频繁,请稍后再试 / too many attempts", + "rate_limit_error") + try: + payload = self._read_json() + except ValueError as exc: + return self._error(400, str(exc)) + presented = str(payload.get("api_key") or payload.get("key") or "").strip() + if not api_key_ok(presented, self.gateway.cfg.api_keys): + if sessions.note_failure(ip): + return self._error(429, "尝试过于频繁,请稍后再试 / too many attempts", + "rate_limit_error") + return self._error(401, "无效的 API Key / invalid API key", + "authentication_error") + sessions.clear_failures(ip) + token = sessions.issue() + return self._json(200, {"ok": True}, extra={ + "Set-Cookie": set_cookie(token, secure=self._cookie_secure(), + max_age=sessions.TTL), + "Cache-Control": "no-store", + }) + + def _panel_logout(self) -> None: + token = self._panel_cookie() + self.gateway.sessions.revoke(token) + return self._json(200, {"ok": True}, extra={ + "Set-Cookie": clear_cookie(secure=self._cookie_secure()), + "Cache-Control": "no-store", + }) + def _panel_api(self, path: str) -> None: payload = self._read_json() panel = self.gateway.panel @@ -312,6 +414,7 @@ def _panel_api(self, path: str) -> None: "/api/panel/accounts/renew": panel.renew_account, "/api/panel/accounts/identity": panel.identity, "/api/panel/refresh": lambda _p: panel.refresh(_p), + "/api/panel/proxy": panel.set_proxy, } handler = handlers.get(path) if handler is None: diff --git a/loomy2api/web/index.html b/loomy2api/web/index.html index ff5c2d7..4f18c43 100644 --- a/loomy2api/web/index.html +++ b/loomy2api/web/index.html @@ -74,12 +74,11 @@

loomy2api控制面板 / control panel

- - +
@@ -114,6 +113,28 @@

添加账号 add account

密码需要先在讯飞账号中心设置一次;添加成功后会自动为本账号随机生成一套设备标识(devid / 校园设备号)并绑定,之后每次续期都用同一套。
+
+

出口代理 upstream proxy

+
+
+ +
+
+
+
+
+
+
+
当前直连
+ +
只改变本网关访问讯飞上游的出口,国内用户打开这个面板不受影响。SOCKS5 由代理解析域名。也可用环境变量 LOOMY_PROXY(socks5://、http://、https://)。
+
+

运行日志 recent log

@@ -136,7 +157,7 @@

运行日志 recent log

+ + diff --git a/tests/test_httpc.py b/tests/test_httpc.py new file mode 100644 index 0000000..f67c377 --- /dev/null +++ b/tests/test_httpc.py @@ -0,0 +1,299 @@ +"""Proxy transport: direct, HTTP CONNECT, HTTPS proxy, and SOCKS5.""" + +from __future__ import annotations + +import os +import socket +import ssl +import subprocess +import tempfile +import threading +import unittest +from pathlib import Path +from urllib.parse import urlparse + +from loomy2api.httpc import ( + ProxyError, compose_proxy, normalize_proxy, proxy_view, request, +) + + +def _recv_headers(conn: socket.socket) -> bytes: + data = b"" + conn.settimeout(3) + while b"\r\n\r\n" not in data: + chunk = conn.recv(4096) + if not chunk: + break + data += chunk + return data + + +def _pong(conn: socket.socket) -> None: + _recv_headers(conn) + conn.sendall( + b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\nConnection: close\r\n\r\npong") + conn.close() + + +def _splice(left: socket.socket, right: socket.socket) -> None: + def pump(src, dst): + try: + while True: + data = src.recv(65536) + if not data: + break + dst.sendall(data) + except OSError: + pass + finally: + try: + dst.shutdown(socket.SHUT_WR) + except OSError: + pass + + threads = [ + threading.Thread(target=pump, args=(left, right), daemon=True), + threading.Thread(target=pump, args=(right, left), daemon=True), + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=5) + + +class _Server: + def __init__(self, handler): + self._sock = socket.socket() + self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self._sock.bind(("127.0.0.1", 0)) + self._sock.listen(16) + self._sock.settimeout(0.3) + self.port = self._sock.getsockname()[1] + self._stop = threading.Event() + self._thread = threading.Thread(target=self._run, args=(handler,), daemon=True) + self._thread.start() + + def _run(self, handler): + while not self._stop.is_set(): + try: + conn, _addr = self._sock.accept() + except socket.timeout: + continue + threading.Thread(target=self._guard, args=(handler, conn), daemon=True).start() + + @staticmethod + def _guard(handler, conn): + try: + handler(conn) + except OSError: + try: + conn.close() + except OSError: + pass + + def close(self): + self._stop.set() + self._sock.close() + self._thread.join(timeout=2) + + +def _http_origin(conn: socket.socket) -> None: + _pong(conn) + + +def _http_proxy(conn: socket.socket) -> None: + head = _recv_headers(conn) + line = head.split(b"\r\n", 1)[0].decode("ascii", "replace") + parts = line.split(" ") + if len(parts) < 3: + conn.close() + return + method, target = parts[0], parts[1] + if method == "CONNECT": + host, port = target.rsplit(":", 1) + remote = socket.create_connection((host.strip("[]"), int(port)), timeout=3) + conn.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n") + _splice(conn, remote) + return + parsed = urlparse(target) + remote = socket.create_connection((parsed.hostname, parsed.port), timeout=3) + remote.sendall(head) + _splice(conn, remote) + + +def _socks5(conn: socket.socket, *, user: str = "", password: str = "") -> None: + version, nmethods = conn.recv(1)[0], None + # recv may return just one byte; read exactly. + rest = b"" + need = 1 + got = conn.recv(1) + nmethods = got[0] + methods = b"" + while len(methods) < nmethods: + methods += conn.recv(nmethods - len(methods)) + if user: + if 2 not in methods: + conn.sendall(b"\x05\xff") + conn.close() + return + conn.sendall(b"\x05\x02") + auth = conn.recv(2) + ulen = auth[1] + uname = conn.recv(ulen) + plen = conn.recv(1)[0] + passwd = conn.recv(plen) + if uname.decode() != user or passwd.decode() != password: + conn.sendall(b"\x01\x01") + conn.close() + return + conn.sendall(b"\x01\x00") + else: + conn.sendall(b"\x05\x00") + hdr = b"" + while len(hdr) < 4: + hdr += conn.recv(4 - len(hdr)) + atyp = hdr[3] + if atyp == 3: + length = conn.recv(1)[0] + host = b"" + while len(host) < length: + host += conn.recv(length - len(host)) + host = host.decode() + elif atyp == 1: + host = socket.inet_ntoa(conn.recv(4)) + else: + conn.close() + return + port_bytes = b"" + while len(port_bytes) < 2: + port_bytes += conn.recv(2 - len(port_bytes)) + port = int.from_bytes(port_bytes, "big") + try: + remote = socket.create_connection((host, port), timeout=3) + except OSError: + conn.sendall(b"\x05\x05\x00\x01" + b"\x00" * 6) + conn.close() + return + conn.sendall(b"\x05\x00\x00\x01" + b"\x00" * 6) + _splice(conn, remote) + + +class ProxyTransportTest(unittest.TestCase): + def test_direct_http(self): + origin = _Server(_http_origin) + self.addCleanup(origin.close) + status, _hdrs, body = request(f"http://127.0.0.1:{origin.port}/hi", timeout=5) + self.assertEqual(status, 200) + self.assertEqual(body, b"pong") + + def test_socks5_forwards_http(self): + origin = _Server(_http_origin) + proxy = _Server(_socks5) + self.addCleanup(origin.close) + self.addCleanup(proxy.close) + status, _hdrs, body = request( + f"http://127.0.0.1:{origin.port}/hi", + proxy=f"socks5://127.0.0.1:{proxy.port}", timeout=5) + self.assertEqual(status, 200) + self.assertEqual(body, b"pong") + + def test_socks5_password(self): + origin = _Server(_http_origin) + proxy = _Server(lambda conn: _socks5(conn, user="alice", password="s3cret")) + self.addCleanup(origin.close) + self.addCleanup(proxy.close) + status, _hdrs, body = request( + f"http://127.0.0.1:{origin.port}/hi", + proxy=f"socks5://alice:s3cret@127.0.0.1:{proxy.port}", timeout=5) + self.assertEqual(body, b"pong") + self.assertEqual(status, 200) + with self.assertRaises(ProxyError): + request(f"http://127.0.0.1:{origin.port}/hi", + proxy=f"socks5://alice:nope@127.0.0.1:{proxy.port}", timeout=5) + + def test_http_proxy_absolute_form(self): + origin = _Server(_http_origin) + proxy = _Server(_http_proxy) + self.addCleanup(origin.close) + self.addCleanup(proxy.close) + status, _hdrs, body = request( + f"http://127.0.0.1:{origin.port}/hi", + proxy=f"http://127.0.0.1:{proxy.port}", timeout=5) + self.assertEqual(status, 200) + self.assertEqual(body, b"pong") + + def test_normalize_and_compose(self): + self.assertEqual(normalize_proxy(""), "") + self.assertEqual(normalize_proxy("socks5h://10.0.0.8:1080"), + "socks5://10.0.0.8:1080") + url = compose_proxy(scheme="socks5", host="10.0.0.8", port=1080, + username="u", password="p@ss") + self.assertEqual(url, "socks5://u:p%40ss@10.0.0.8:1080") + again = compose_proxy(scheme="https", host="10.0.0.9", port=443, + username="u", password=None, previous=url) + self.assertIn("p%40ss", again) + view = proxy_view(url) + self.assertNotIn("p@ss", str(view)) + self.assertEqual(view["masked"], "socks5://u:***@10.0.0.8:1080") + self.assertTrue(view["has_password"]) + with self.assertRaises(ValueError): + normalize_proxy("file:///tmp/x") + self.assertEqual(compose_proxy(scheme="direct"), "") + + +class HttpsProxyTest(unittest.TestCase): + def setUp(self): + openssl = subprocess.run(["openssl", "version"], capture_output=True) + if openssl.returncode != 0: + self.skipTest("openssl is required to mint a test certificate") + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + cert = Path(self.tmp.name) / "cert.pem" + key = Path(self.tmp.name) / "key.pem" + minted = subprocess.run([ + "openssl", "req", "-x509", "-newkey", "rsa:2048", + "-keyout", str(key), "-out", str(cert), "-days", "1", "-nodes", + "-subj", "/CN=127.0.0.1", + "-addext", "subjectAltName=IP:127.0.0.1", + ], capture_output=True) + if minted.returncode != 0: + self.skipTest(minted.stderr.decode("utf-8", "replace")[:200]) + self.cert = cert + self.key = key + + def _tls_server(self, handler): + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(self.cert, self.key) + + def wrapped(conn): + try: + tls = context.wrap_socket(conn, server_side=True) + except ssl.SSLError: + conn.close() + return + handler(tls) + + return _Server(wrapped) + + def test_https_proxy_connects_to_https_origin(self): + origin = self._tls_server(_pong) + proxy = self._tls_server(_http_proxy) + self.addCleanup(origin.close) + self.addCleanup(proxy.close) + previous = os.environ.get("SSL_CERT_FILE") + os.environ["SSL_CERT_FILE"] = str(self.cert) + try: + status, _hdrs, body = request( + f"https://127.0.0.1:{origin.port}/hi", + proxy=f"https://127.0.0.1:{proxy.port}", timeout=5) + finally: + if previous is None: + os.environ.pop("SSL_CERT_FILE", None) + else: + os.environ["SSL_CERT_FILE"] = previous + self.assertEqual(status, 200) + self.assertEqual(body, b"pong") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_panel.py b/tests/test_panel.py index ccf194e..4c716e2 100644 --- a/tests/test_panel.py +++ b/tests/test_panel.py @@ -222,6 +222,42 @@ def test_unknown_panel_endpoint_404(self): self.post("/api/panel/nonsense", {}) self.assertEqual(ctx.exception.code, 404) + def test_panel_saves_socks5_proxy_without_leaking_the_password(self): + cfg_path = self.dir / "config.json" + cfg_path.write_text('{"strategy": "lru", "_comment": "keep"}\n', encoding="utf-8") + status, payload = self.post("/api/panel/proxy", { + "proxy": "socks5://alice:s3cret@10.1.1.1:1080", + }) + self.assertEqual(status, 200) + view = payload["proxy"] + self.assertEqual(view["scheme"], "socks5") + self.assertEqual(view["masked"], "socks5://alice:***@10.1.1.1:1080") + self.assertNotIn("s3cret", json.dumps(payload)) + self.assertEqual(self.gateway.cfg["proxy"], + "socks5://alice:s3cret@10.1.1.1:1080") + saved = json.loads(cfg_path.read_text(encoding="utf-8")) + self.assertEqual(saved["_comment"], "keep") + self.assertEqual(saved["strategy"], "lru") + self.assertIn("s3cret", saved["proxy"]) + + status, payload = self.post("/api/panel/proxy", { + "scheme": "https", "host": "10.2.2.2", "port": 443, "username": "alice", + }) + self.assertEqual(payload["proxy"]["scheme"], "https") + self.assertTrue(payload["proxy"]["has_password"]) + self.assertNotIn("s3cret", json.dumps(payload)) + self.assertIn("s3cret", self.gateway.cfg["proxy"]) + + self.post("/api/panel/proxy", {"scheme": "direct"}) + self.assertEqual(self.gateway.cfg["proxy"], "") + _s, state = self.get_json("/api/panel/state") + self.assertFalse(state["config"]["proxy"]["enabled"]) + + def test_proxy_scheme_is_rejected(self): + with self.assertRaises(urllib.error.HTTPError) as ctx: + self.post("/api/panel/proxy", {"proxy": "file:///tmp/x"}) + self.assertEqual(ctx.exception.code, 400) + def test_logs_endpoint(self): self.gateway.log("panel test line") _s, payload = self.get_json("/api/panel/logs?lines=20") @@ -245,15 +281,130 @@ def test_state_with_key(self): self.assertTrue(payload["ok"]) self.assertTrue(payload["config"]["auth_required"]) + def test_proxy_requires_key(self): + with self.assertRaises(urllib.error.HTTPError) as ctx: + self.post("/api/panel/proxy", {"scheme": "direct"}) + self.assertEqual(ctx.exception.code, 401) + def test_writes_require_key(self): with self.assertRaises(urllib.error.HTTPError) as ctx: self.post("/api/panel/accounts/remove", {"name": "a"}) self.assertEqual(ctx.exception.code, 401) self.assertIsNotNone(self.gateway.pool.get("a")) - def test_page_itself_stays_public(self): - status, _response = self.get("/panel") + def _login(self, key="panel-key", headers=None): + request = urllib.request.Request( + self.base + "/api/panel/login", + data=json.dumps({"api_key": key}).encode(), + headers={"Content-Type": "application/json", **(headers or {})}, + method="POST") + response = urllib.request.urlopen(request, timeout=30) + set_cookie = response.headers.get("Set-Cookie") or "" + response.read() + return set_cookie + + def test_anonymous_page_is_login_only(self): + status, response = self.get("/panel") + body = response.read().decode("utf-8") + self.assertEqual(status, 200) + self.assertIn("登录", body) + self.assertNotIn("添加账号", body) + self.assertNotIn("/api/panel/state", body) + self.assertNotIn("localStorage.setItem", body) + + status, response = self.get("/") + body = response.read().decode("utf-8") + self.assertEqual(status, 200) + self.assertIn("登录", body) + self.assertNotIn("添加账号", body) + + def test_session_endpoint_is_anonymous_and_empty(self): + _s, payload = self.get_json("/api/panel/session") + self.assertEqual(payload, { + "ok": True, "auth_required": True, "authenticated": False, + }) + + def test_login_sets_httponly_cookie_and_unlocks_panel(self): + set_cookie = self._login() + parts = [part.strip() for part in set_cookie.split(";")] + self.assertTrue(parts[0].startswith("loomy_panel=")) + self.assertIn("HttpOnly", parts) + self.assertIn("SameSite=Lax", parts) + self.assertNotIn("Secure", parts) + self.assertNotIn("panel-key", parts[0]) + cookie = set_cookie.split(";", 1)[0] + + status, response = self.get("/panel", headers={"Cookie": cookie}) + body = response.read().decode("utf-8") + self.assertEqual(status, 200) + self.assertIn("添加账号", body) + self.assertNotIn("localStorage.setItem", body) + + _s, payload = self.get_json("/api/panel/state", headers={"Cookie": cookie}) + self.assertTrue(payload["ok"]) + self.assertEqual(payload["accounts"][0]["name"], "a") + + status, payload = self.post( + "/api/panel/accounts/remove", {"name": "a"}, + headers={"Cookie": cookie}) + self.assertEqual(status, 200) + self.assertTrue(payload["ok"]) + + def test_login_cookie_is_secure_behind_https_proxy(self): + set_cookie = self._login(headers={"X-Forwarded-Proto": "https"}) + parts = [part.strip() for part in set_cookie.split(";")] + self.assertIn("Secure", parts) + + def test_panel_cookie_does_not_authorize_model_api(self): + cookie = self._login().split(";", 1)[0] + with self.assertRaises(urllib.error.HTTPError) as ctx: + self.get_json("/v1/models", headers={"Cookie": cookie}) + self.assertEqual(ctx.exception.code, 401) + + def test_wrong_key_rejected(self): + with self.assertRaises(urllib.error.HTTPError) as ctx: + self._login(key="nope") + self.assertEqual(ctx.exception.code, 401) + + def test_login_locks_out_after_repeated_failures(self): + codes = [] + for _ in range(8): + try: + self._login(key="nope") + codes.append(200) + except urllib.error.HTTPError as exc: + codes.append(exc.code) + exc.read() + self.assertEqual(codes, [401] * 7 + [429]) + + def test_logout_revokes_the_session(self): + cookie = self._login().split(";", 1)[0] + request = urllib.request.Request( + self.base + "/api/panel/logout", data=b"{}", method="POST", + headers={"Cookie": cookie, "Content-Type": "application/json"}) + response = urllib.request.urlopen(request, timeout=30) + cleared = response.headers.get("Set-Cookie") or "" + response.read() + self.assertIn("Max-Age=0", cleared) + with self.assertRaises(urllib.error.HTTPError) as ctx: + self.get_json("/api/panel/state", headers={"Cookie": cookie}) + self.assertEqual(ctx.exception.code, 401) + status, response = self.get("/panel", headers={"Cookie": cookie}) + body = response.read().decode("utf-8") self.assertEqual(status, 200) + self.assertNotIn("添加账号", body) + + +class TestPanelLoginDisabled(PanelHarness, unittest.TestCase): + def test_login_is_rejected_when_no_api_key_is_configured(self): + with self.assertRaises(urllib.error.HTTPError) as ctx: + self.post("/api/panel/login", {"api_key": "anything"}) + self.assertEqual(ctx.exception.code, 400) + + def test_open_panel_session_reports_auth_off(self): + _s, payload = self.get_json("/api/panel/session") + self.assertEqual(payload["auth_required"], False) + self.assertEqual(payload["authenticated"], True) if __name__ == "__main__":