From 87d8d4ccc4dbb999fde6d72e65a7f0c592c4b1a8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 20 Oct 2026 11:19:28 +0000 Subject: [PATCH 1/2] fix: make the classroom relay correct and safe on a LAN Error responses now return real status codes, pip and npm package links stay on the teacher machine, and the proxy refuses private targets and disallowed ports. Cached replies are no longer shared across users, CONNECT tunnels use blocking bidirectional copies, and offline tests cover the classroom flows. Co-authored-by: LoveLH --- .github/workflows/test.yml | 27 + README.md | 188 +-- classrelay.py | 2547 +++++++++++++++++++++++++++++------- start.bat | 2 +- start.sh | 2 +- test_relay.py | 111 -- tests/conftest.py | 4 + tests/test_relay.py | 1242 ++++++++++++++++++ 8 files changed, 3485 insertions(+), 638 deletions(-) create mode 100644 .github/workflows/test.yml delete mode 100644 test_relay.py create mode 100644 tests/conftest.py create mode 100644 tests/test_relay.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..b8dc010 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,27 @@ +name: tests + +on: + push: + pull_request: + +jobs: + test: + name: ${{ matrix.os }} / Python ${{ matrix.python }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + python: ["3.9", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Install pytest + run: python -m pip install pytest + - name: Run offline tests + run: python -m pytest -q diff --git a/README.md b/README.md index 76fa141..ca1a3db 100644 --- a/README.md +++ b/README.md @@ -2,143 +2,179 @@ > **专为在校教师与机房教学打造的轻量级局域网依赖中继与缓存代理服务器** > -> 解决痛点:机房网络管制/断网教学环境下,学生机无法访问外网,但教学实验(Python、Node.js、嵌入式 STM32/ESP32 PlatformIO 等)急需拉取外部库与包依赖。 +> 解决痛点:机房网络管制或断网教学时,学生机访问不了外网,但 Python、Node.js、嵌入式 PlatformIO / ESP32 实验又需要拉包。教师机联网,学生机只访问教师机。 --- -## 🌟 核心特性 - -- **零依赖单文件**:纯 Python 标准库实现,教师机无需安装任何第三方 pip 包或重量级反代工具(如 Nginx、Squid)。 -- **多协议全支持**: - - **PyPI / pip 镜像**:提供 `/pypi/` 镜像端点,自动中继国内清华等高速镜像源。 - - **NPM Registry 镜像**:提供 `/npm/` 镜像端点,中继 npmmirror 镜像源。 - - **Docker Hub 镜像加速**:提供 `/v2/` 镜像端点,中继 Docker Registry 镜像源。 - - **嵌入式/通用开发工具链**:支持 PlatformIO、Espressif 等工具链资产中继与 HTTP/HTTPS CONNECT 正向代理隧道。 -- **本地高效持久化缓存**:同一个包只要一位同学拉取过一次,教师机自动落盘缓存,其余所有同学直接局域网秒级下载(节约教师机出口带宽)。 -- **学生机一键配置 / 一键还原**: - - Windows: `irm http://<教师机IP>:8080/setup/windows | iex` - - Linux / macOS: `curl -fsSL http://<教师机IP>:8080/setup/bash | bash` - - 课后一键还原学生机系统默认配置,不破坏机房原有环境。 -- **实时 Web 控制台**:内置暗色 Dashboard,直观查看教师机局域网 IP、缓存命中率、请求次数与磁盘占用。 +## 核心特性 + +- **零依赖单文件**:只使用 Python 标准库。教师机不用装 Nginx、Squid,也不用装第三方 pip 包。运行方式仍是 `python classrelay.py`。 +- **镜像中继**: + - **PyPI / pip**:`/pypi/`,默认上游清华 simple 源。简单索引里的相对包链接会改写成教师机上的绝对地址,包文件走缓存。 + - **npm**:`/npm/`,默认上游 npmmirror。packument 里的 `dist.tarball` 会改写成教师机绝对地址,tarball 走缓存。 + - **Docker Registry v2(实验性)**:`/v2/`,默认上游 daocloud。支持 pull 所需的 HEAD/GET、Accept 内容协商,以及把 `WWW-Authenticate` 的 realm 改回教师机再代拉 token。不含 push、blob mount、Range 断点续传。 + - **PlatformIO / Espressif**:`/platformio/`、`/espressif/`,以及 HTTP/HTTPS 代理隧道。 +- **磁盘缓存**:索引默认缓存 10 分钟,安装包默认 7 天,总容量默认 1 GB,超出后按最旧优先删除。同一个包并发下载时只向上游要一次,写完才对其他同学可见。 +- **学生机配置脚本**:控制台给出 Windows / Linux / macOS 命令,以及脚本 SHA256。bash 会把代理写进 `~/.bashrc` 和 `~/.zshrc` 的标记块;还原脚本会删掉标记,并执行 `npm config delete strict-ssl`。镜像是 HTTP,脚本不会再设置 `strict-ssl false`。 +- **Web 控制台**:显示学生机这次实际连到的地址、缓存命中和占用。 + +--- + +## 安全说明 + +默认绑定 `0.0.0.0`,同一局域网里的人都能连上。程序做了这些限制: + +- **CONNECT 默认只允许 443**,明文正向代理默认只允许 **80 和 443**。 +- **拒绝本机、回环、链路本地、RFC1918 私网、运营商级 NAT(100.64/10)、组播、文档地址和云元数据地址**。判断发生在 DNS 解析之后;一个域名只要有任何一条记录落在这些地址上,整次请求都会被拒绝。 +- **正向代理不缓存**。带 `Cookie` 的镜像请求不缓存;响应里有 `Set-Cookie`、`Cache-Control: private/no-store/no-cache` 时也不缓存。 +- **可选 HTTP Basic 认证**(`--auth 用户:密码`)。pip、npm、git 使用 `http://用户:密码@教师机:端口` 这种标准写法。默认关闭,方便教室里零配置开用。口令比较使用 `hmac.compare_digest`,访问日志会抹掉 URL 里的用户名密码。开启后,控制台和脚本也要带认证;Docker 客户端往往把 `Authorization` 留给仓库自己的 Bearer token,和本程序的 Basic 认证不好同时使用,需要认证时优先用 pip/npm/代理,把 Docker 留在关闭认证的网络里。 +- **仍可按域名收紧代理**:重复传入 `--allow-host github.com` 这类后缀后,CONNECT 和正向代理只放行这些域名。镜像路由走的是教师机配置的上游,不受这份名单限制。 +- `--allow-private` 会重新允许访问教师机本机和内网,等于关掉上面的地址防护,只在你清楚风险时使用。 + +学生机仍然可以通过教师机访问**公网** 80/443。这是 PlatformIO、git clone 需要的能力。如果机房不希望学生任意上网,请加上 `--allow-host`。 + +一键脚本通过明文 HTTP 下发。同网段的人可以冒充教师机。请先下载脚本,对照控制台上的 SHA256,确认内容后再执行,不要把 `curl | bash` 或 `irm | iex` 当成日常习惯。`curl | bash` 也不能给当前终端设置环境变量;脚本会写入 shell 启动文件,并提示新开一个终端或 `source ~/.bashrc`。 --- -## 🚀 快速开始 +## 快速开始 + +### 1. 教师机启动 -### 1. 教师机一键启动 +需要 Python 3.8+(持续在 3.9 和 3.13 上测试): -确保教师机已联网并安装了 Python 3.7+: +#### Windows + +双击 `start.bat`,或: -#### Windows 教师机 -双击运行 `start.bat` 或在终端运行: ```powershell python classrelay.py ``` -#### Linux / macOS 教师机 +#### Linux / macOS + ```bash chmod +x start.sh ./start.sh ``` -启动后终端将打印教师机内网 IP 与配置入口,例如: +启动后终端会打印地址,例如: + ```text ============================================================ - ClassRelay v1.0.0 教师机依赖中继与代理服务已启动 + ClassRelay v1.2.0 教师机依赖中继与代理服务已启动 ============================================================ Web 控制台与配置入口: http://192.168.1.100:8080/ PyPI 镜像中继: http://192.168.1.100:8080/pypi/ NPM 镜像中继: http://192.168.1.100:8080/npm/ - PlatformIO 镜像中继: http://192.168.1.100:8080/platformio/ + Docker Hub 镜像中继: http://192.168.1.100:8080/v2/ (实验性) HTTP/HTTPS 代理隧道: http://192.168.1.100:8080 ============================================================ ``` ---- +多网卡时,以学生机浏览器里看到的「本次连接地址」为准。启动横幅里的 IP 只是教师机自己探测到的一块网卡。 -### 2. 学生机配置 +建议在有口令的课堂上: -> 假设教师机局域网 IP 为 `192.168.1.100`,端口为 `8080`。 +```bash +python classrelay.py --auth teacher:classroom-pass +``` -#### 方案 A:一键命令配置(推荐) +学生机则使用 `http://teacher:classroom-pass@192.168.1.100:8080`。用户名和密码不要包含空格或控制字符。 -- **Windows 学生机 (PowerShell)**: - ```powershell - irm http://192.168.1.100:8080/setup/windows | iex - ``` -- **Linux / macOS 学生机 (Bash)**: - ```bash - curl -fsSL http://192.168.1.100:8080/setup/bash | bash - ``` +### 2. 学生机配置 + +下面假设教师机地址是 `192.168.1.100`,端口 `8080`,且没有开启认证。开了认证就把 `http://192.168.1.100:8080` 换成 `http://用户:密码@192.168.1.100:8080`。 -#### 方案 B:独立工具配置 +更稳妥的做法是打开 `http://192.168.1.100:8080/`,按页面上的 SHA256 核对后再执行。 + +- **pip** -- **pip (Python)**: ```bash pip config set global.index-url http://192.168.1.100:8080/pypi/ pip config set global.trusted-host 192.168.1.100 ``` -- **npm (Node.js)**: + +- **npm**(HTTP 镜像,不要关 `strict-ssl`) + ```bash npm config set registry http://192.168.1.100:8080/npm/ - npm config set strict-ssl false ``` -- **Docker Hub (容器镜像加速)**: - 在学生机 `/etc/docker/daemon.json`(或 Docker Desktop 设置)中添加: - ```json - { - "registry-mirrors": ["http://192.168.1.100:8080"], - "insecure-registries": ["192.168.1.100:8080"] - } - ``` - 然后重启 Docker 服务:`sudo systemctl restart docker`。 -- **PlatformIO / ESP32 / STM32 (终端代理)**: - ```bash - # Windows PowerShell - $env:HTTP_PROXY="http://192.168.1.100:8080" - $env:HTTPS_PROXY="http://192.168.1.100:8080" - # Linux / macOS +- **代理**(PlatformIO、git、其他 HTTPS 工具) + + ```bash export HTTP_PROXY=http://192.168.1.100:8080 export HTTPS_PROXY=http://192.168.1.100:8080 - ``` -- **Git**: - ```bash git config --global http.proxy http://192.168.1.100:8080 git config --global https.proxy http://192.168.1.100:8080 ``` ---- +- **Docker(实验性)** -### 3. 课后一键还原学生机网络配置 + ```json + { + "registry-mirrors": ["http://192.168.1.100:8080"], + "insecure-registries": ["192.168.1.100:8080"] + } + ``` -课程结束后,执行还原脚本即可清除机房代理与镜像设定: + 然后重启 Docker。只保证常见的公开镜像 pull 流程,不保证所有客户端特性。 -- **Windows**: - ```powershell - irm http://192.168.1.100:8080/setup/unwindows | iex - ``` -- **Linux / macOS**: - ```bash - curl -fsSL http://192.168.1.100:8080/setup/unbash | bash - ``` +### 3. 课后还原 + +- Windows:`http://<教师机>:8080/setup/unwindows` +- Linux / macOS:`http://<教师机>:8080/setup/unbash` + +还原会去掉 pip / npm / git 代理,删除 shell 启动文件里的 ClassRelay 标记,并执行 `npm config delete strict-ssl`。当前已经打开的终端要关掉重开,环境变量才会消失。 --- -## 🛠️ 命令行参数 +## 命令行参数 ```text usage: classrelay.py [-h] [--port PORT] [--host HOST] [--cache-dir CACHE_DIR] + [--auth AUTH] [--max-cache-mb MAX_CACHE_MB] + [--index-ttl INDEX_TTL] [--asset-ttl ASSET_TTL] + [--allow-private] [--allow-host ALLOW_HOST] + [--connect-port CONNECT_PORT] [--proxy-port PROXY_PORT] + [--max-connections MAX_CONNECTIONS] + [--tunnel-idle TUNNEL_IDLE] +``` + +| 选项 | 含义 | +| --- | --- | +| `--port` / `-p` | 监听端口,默认 8080 | +| `--host` / `-H` | 监听地址,默认 `0.0.0.0` | +| `--cache-dir` / `-c` | 缓存目录,默认 `~/.classrelay/cache` | +| `--auth user:password` | 可选 HTTP Basic。默认关闭 | +| `--max-cache-mb` | 缓存上限,默认 1024。`0` 表示不落盘 | +| `--index-ttl` | 索引缓存秒数,默认 600 | +| `--asset-ttl` | 安装包缓存秒数,默认 7 天 | +| `--allow-private` | 允许代理访问本机和内网。默认关闭 | +| `--allow-host` | 限制代理目标域名后缀,可重复,也可用逗号分隔 | +| `--connect-port` | 在 443 之外额外允许的 CONNECT 端口 | +| `--proxy-port` | 在 80/443 之外额外允许的明文代理端口 | +| `--max-connections` | 同时处理的连接数,默认 64,超出返回 503 | +| `--tunnel-idle` | CONNECT 空闲断开秒数,默认 600。`0` 表示不限制 | + +原有的 `--port`、`--host`、`--cache-dir` 用法不变。 -选项: - -h, --help 显示帮助信息 - --port PORT, -p PORT 监听端口 (默认: 8080) - --host HOST, -H HOST 监听地址 (默认: 0.0.0.0 全局绑定) - --cache-dir CACHE_DIR 本地缓存路径 (默认: ~/.classrelay/cache) +--- + +## 开发与测试 + +测试使用本地假上游,不访问外网: + +```bash +python -m pip install pytest +python -m pytest -q ``` +GitHub Actions 会在 Linux 和 Windows 上,用 Python 3.9 和 3.13 跑同一套测试。运行时仍然只有标准库,pytest 只在测试时需要。 + --- -## 📄 开源许可证 +## 开源许可证 MIT License. diff --git a/classrelay.py b/classrelay.py index d249983..00c01cc 100644 --- a/classrelay.py +++ b/classrelay.py @@ -1,32 +1,59 @@ """ ClassRelay (师生局域网依赖中继/代理分发服务) -Single-file zero-dependency LAN caching & forward proxy for classroom teaching. -Supports: HTTP/HTTPS CONNECT forward proxy, PyPI/pip mirror proxy, NPM registry mirror proxy, -PlatformIO/ESP32/STM32 toolchain asset cache, and Web Dashboard + one-click student config scripts. + +Single-file, stdlib-only caching relay for classroom labs. The teacher machine +is online; student machines fetch pip, npm, Docker, and PlatformIO/Espressif +assets through it. Also serves an HTTP/HTTPS proxy, a small web console, and +one-click setup scripts. + +Security defaults: +- CONNECT is limited to port 443, and the plain HTTP proxy to ports 80/443. +- Loopback, link-local, private, CGNAT, multicast, and documentation targets + are refused after DNS resolution, unless --allow-private is set. +- The forward proxy does not cache. Mirror cache entries never store + Set-Cookie, and a request that carries Cookie is not cached. +- Optional HTTP Basic auth (--auth user:pass). Leave it off for a simple lab. """ +from __future__ import annotations + +import argparse +import base64 +import hashlib +import hmac +import html import http.server -import http.client -import socketserver -import urllib.request -import urllib.error -import urllib.parse -import threading -import select -import socket +import ipaddress import json -import time +import logging import os +import re +import secrets +import socket +import socketserver +import ssl import sys -import argparse -import hashlib -from typing import Tuple, Optional, Dict, Any +import tempfile +import threading +import time +import urllib.parse +import zlib +from typing import Callable, Dict, Iterable, Iterator, List, Optional, Sequence, Tuple +VERSION = "1.2.0" DEFAULT_PORT = 8080 DEFAULT_CACHE_DIR = os.path.expanduser("~/.classrelay/cache") -VERSION = "1.0.0" +DEFAULT_MAX_CACHE_BYTES = 1024 * 1024 * 1024 +DEFAULT_INDEX_TTL = 10 * 60 +DEFAULT_ASSET_TTL = 7 * 24 * 3600 +DEFAULT_TUNNEL_IDLE = 600 +DEFAULT_MAX_CONNECTIONS = 64 +MAX_INDEX_BYTES = 16 * 1024 * 1024 +CONNECT_TIMEOUT = 15 +READ_TIMEOUT = 120 +DNS_TIMEOUT = 8 +MAX_REDIRECTS = 5 -# Default upstream mappings UPSTREAMS = { "pypi": "https://pypi.tuna.tsinghua.edu.cn/simple", "npm": "https://registry.npmmirror.com", @@ -36,40 +63,924 @@ "espressif": "https://dl.espressif.com", } +# Hosts whose package-file URLs should be rewritten back through the relay. +PYPI_FILE_HOSTS = { + "files.pythonhosted.org", + "pypi.org", + "pypi.python.org", + "pypi.tuna.tsinghua.edu.cn", + "mirrors.aliyun.com", + "mirrors.cloud.tencent.com", + "mirror.baidu.com", +} + +ASSET_SUFFIXES = ( + ".whl", + ".tar.gz", + ".zip", + ".tgz", + ".egg", + ".bz2", + ".xz", + ".msi", + ".exe", + ".apk", +) + +HOP_BY_HOP = { + "connection", + "proxy-connection", + "keep-alive", + "transfer-encoding", + "te", + "trailer", + "upgrade", + "proxy-authenticate", + "proxy-authorization", +} + +DROP_QUERY_KEYS = { + "token", + "auth", + "password", + "access_token", + "auth_token", +} -def get_local_ips(): - """Retrieve non-loopback local IPv4 addresses.""" - ips = [] +_BLOCK_V4 = [ + ipaddress.ip_network(item) + for item in ( + "0.0.0.0/8", + "10.0.0.0/8", + "100.64.0.0/10", + "127.0.0.0/8", + "169.254.0.0/16", + "172.16.0.0/12", + "192.0.0.0/24", + "192.0.2.0/24", + "192.168.0.0/16", + "198.18.0.0/15", + "198.51.100.0/24", + "203.0.113.0/24", + "224.0.0.0/4", + "240.0.0.0/4", + ) +] +_BLOCK_V6 = [ + ipaddress.ip_network(item) + for item in ( + "::/128", + "::1/128", + "fc00::/7", + "fe80::/10", + "fec0::/10", + "ff00::/8", + "2001:db8::/32", + ) +] + +_USERINFO_RE = re.compile(r"(https?://)([^/\s:@]+):([^/\s@]+)@") +_HREF_RE = re.compile(r"""href\s*=\s*(['"])(.*?)\1""", re.IGNORECASE) +_REALM_RE = re.compile(r'realm="([^"]*)"', re.IGNORECASE) +_MARK_START = "# >>> ClassRelay >>>" +_MARK_END = "# <<< ClassRelay <<<" + +LOG = logging.getLogger("classrelay") + + +class PolicyError(Exception): + def __init__(self, status: int, reason: str): + super().__init__(reason) + self.status = status + self.reason = reason + + +class UpstreamError(Exception): + pass + + +def configure_logging() -> None: + if LOG.handlers: + return + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s")) + LOG.addHandler(handler) + LOG.setLevel(logging.INFO) + LOG.propagate = False + + +def redact_text(text: str) -> str: + return _USERINFO_RE.sub(r"\1***:***@", text) + + +def is_blocked_ip(ip: str) -> bool: + """True for addresses a student proxy must not reach.""" try: - # Connect to public DNS address without sending data - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.connect(("223.5.5.5", 80)) - primary_ip = s.getsockname()[0] - s.close() - ips.append(primary_ip) - except Exception: - pass + addr = ipaddress.ip_address(ip.split("%", 1)[0]) + except ValueError: + return True + if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None: + addr = addr.ipv4_mapped + if ( + addr.is_private + or addr.is_loopback + or addr.is_link_local + or addr.is_multicast + or addr.is_reserved + or addr.is_unspecified + ): + return True + nets = _BLOCK_V4 if isinstance(addr, ipaddress.IPv4Address) else _BLOCK_V6 + return any(addr in net for net in nets) + + +def resolve_addresses(host: str, port: int): + """DNS lookup. Tests monkeypatch this.""" + return socket.getaddrinfo(host, port, type=socket.SOCK_STREAM) + + +def _resolve_with_timeout(host: str, port: int, timeout: float = DNS_TIMEOUT): + box: Dict[str, object] = {} + + def run() -> None: + try: + box["infos"] = resolve_addresses(host, port) + except Exception as exc: # noqa: BLE001 - propagated to the caller + box["err"] = exc + + thread = threading.Thread(target=run, daemon=True) + thread.start() + thread.join(timeout) + if thread.is_alive(): + raise PolicyError(504, "DNS timeout") + if "err" in box: + err = box["err"] + if isinstance(err, socket.gaierror): + raise UpstreamError("DNS resolution failed") from err + raise err + return box.get("infos") or [] + + +def resolve_destination(host: str, port: int, *, private_ok: bool): + """Resolve host and refuse the whole name if any address is blocked. + + Connecting later uses these results, so a DNS rebinding between the check + and the TCP connect does not apply to CONNECT or to this client. + """ + if not host or len(host) > 255: + raise PolicyError(400, "Bad target host") + infos = _resolve_with_timeout(host, port) + if not infos: + raise UpstreamError("DNS resolution failed") + for info in infos: + ip = str(info[4][0]).split("%", 1)[0] + if is_blocked_ip(ip) and not private_ok: + raise PolicyError(403, "Private or non-public address blocked") + return infos + + +def host_permitted(host: str, suffixes: Sequence[str]) -> bool: + if not suffixes: + return True + name = host.lower().rstrip(".") + if name.startswith("[") and name.endswith("]"): + name = name[1:-1] + for suffix in suffixes: + item = suffix.lower().strip().lstrip(".") + if not item: + continue + if name == item or name.endswith("." + item): + return True + return False + + +def parse_connect_target(path: str) -> Tuple[str, int]: + raw = path.strip() + if not raw or any(ch in raw for ch in " \r\n\t\\/"): + raise ValueError("bad target") + if raw.startswith("["): + end = raw.find("]") + if end <= 1: + raise ValueError("bad ipv6") + host = raw[1:end] + rest = raw[end + 1 :] + if not rest.startswith(":") or rest.count(":") != 1: + raise ValueError("bad port") + port_s = rest[1:] + else: + if raw.count(":") != 1: + raise ValueError("bad target") + host, port_s = raw.split(":", 1) + if not host or not port_s.isdigit(): + raise ValueError("bad port") + port = int(port_s) + if not 1 <= port <= 65535: + raise ValueError("bad port") + return host, port + + +def strip_userinfo(url: str) -> str: + parts = urllib.parse.urlsplit(url) + if parts.username is None and parts.password is None: + return url + host = parts.hostname or "" + if ":" in host: + host = f"[{host}]" + netloc = host + if parts.port: + netloc = f"{host}:{parts.port}" + return urllib.parse.urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment)) + + +def sanitize_query(query: str) -> str: + pairs = urllib.parse.parse_qsl(query, keep_blank_values=True) + kept = [(key, value) for key, value in pairs if key.lower() not in DROP_QUERY_KEYS] + return urllib.parse.urlencode(kept) + + +def endpoint_of(url: str) -> Optional[Tuple[str, int]]: + parts = urllib.parse.urlsplit(url) + if not parts.hostname or parts.scheme not in ("http", "https"): + return None + port = parts.port or (443 if parts.scheme == "https" else 80) + return parts.hostname.lower(), port + + +def origin_of(url: str) -> str: + parts = urllib.parse.urlsplit(url) + return urllib.parse.urlunsplit((parts.scheme, parts.netloc, "", "", "")) + + +def simple_root(pypi_upstream: str) -> str: + parts = urllib.parse.urlsplit(pypi_upstream) + path = parts.path.rstrip("/") + if path.endswith("/simple"): + path = path[: -len("/simple")] + return urllib.parse.urlunsplit((parts.scheme, parts.netloc, path, "", "")) + + +def join_upstream(base: str, rest: str, query: str) -> str: + if rest in ("", "/"): + suffix = "/" + else: + if not rest.startswith("/"): + rest = "/" + rest + decoded = urllib.parse.unquote(rest) + if "\\" in decoded or "\x00" in decoded or decoded.startswith("//"): + raise ValueError("bad path") + if any(part == ".." for part in decoded.split("/")): + raise ValueError("bad path") + suffix = rest + base_parts = urllib.parse.urlsplit(base) + base_path = base_parts.path.rstrip("/") + path = (base_path + suffix) if suffix != "/" else (base_path + "/") + safe_query = sanitize_query(query) + url = urllib.parse.urlunsplit((base_parts.scheme, base_parts.netloc, path, safe_query, "")) + if urllib.parse.urlsplit(url).hostname != base_parts.hostname: + raise ValueError("bad host") + return url + + +def basic_matches(header_value: str, user: str, password: str) -> bool: + if not header_value: + return False + scheme, _, blob = header_value.strip().partition(" ") + if scheme.lower() != "basic" or not blob or any(ch in blob for ch in "\r\n"): + return False + try: + raw = base64.b64decode(blob, validate=True) + text = raw.decode("utf-8") + except (ValueError, UnicodeDecodeError): + return False + got_user, sep, got_pass = text.partition(":") + if not sep: + return False + user_ok = hmac.compare_digest( + hashlib.sha256(got_user.encode("utf-8")).digest(), + hashlib.sha256(user.encode("utf-8")).digest(), + ) + pass_ok = hmac.compare_digest( + hashlib.sha256(got_pass.encode("utf-8")).digest(), + hashlib.sha256(password.encode("utf-8")).digest(), + ) + return user_ok and pass_ok + + +def seal_url(url: str, secret: bytes) -> str: + mac = hmac.new(secret, url.encode("utf-8"), hashlib.sha256).hexdigest()[:32] + payload = f"{mac}:{url}".encode("utf-8") + return base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=") + + +def unseal_url(token: str, secret: bytes) -> Optional[str]: + if not token or len(token) > 4096: + return None + pad = "=" * (-len(token) % 4) + try: + raw = base64.urlsafe_b64decode(token + pad).decode("utf-8") + except (ValueError, UnicodeDecodeError): + return None + mac, sep, url = raw.partition(":") + if not sep or not url: + return None + expect = hmac.new(secret, url.encode("utf-8"), hashlib.sha256).hexdigest()[:32] + if not hmac.compare_digest(mac, expect): + return None + return url + + +def sh_quote(value: str) -> str: + return "'" + value.replace("'", "'\\''") + "'" + + +def ps_quote(value: str) -> str: + return "'" + value.replace("'", "''") + "'" + +def ascii_reason(reason: str) -> str: + cleaned = "".join(ch if 32 <= ord(ch) < 127 else " " for ch in (reason or "")) + cleaned = " ".join(cleaned.split()) + return cleaned[:80] or "OK" + + +def header_values(headers: Sequence[Tuple[str, str]], name: str) -> List[str]: + lowered = name.lower() + return [value for key, value in headers if key.lower() == lowered] + + +def header_value(headers: Sequence[Tuple[str, str]], name: str) -> Optional[str]: + values = header_values(headers, name) + return values[-1] if values else None + + +def get_local_ips() -> List[str]: + """Non-loopback IPv4 addresses, best-effort and computed once at startup.""" + ips: List[str] = [] try: - host_ips = socket.gethostbyname_ex(socket.gethostname())[2] - for ip in host_ips: - if not ip.startswith("127.") and ip not in ips: - ips.append(ip) + probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + probe.settimeout(1) + probe.connect(("223.5.5.5", 80)) + primary = probe.getsockname()[0] + finally: + probe.close() + if primary and not primary.startswith("127."): + ips.append(primary) except Exception: pass + box: Dict[str, object] = {} + def lookup() -> None: + try: + box["ips"] = socket.gethostbyname_ex(socket.gethostname())[2] + except Exception as exc: + box["err"] = exc + + thread = threading.Thread(target=lookup, daemon=True) + thread.start() + thread.join(2) + for ip in box.get("ips") or []: + if isinstance(ip, str) and not ip.startswith("127.") and ip not in ips: + ips.append(ip) if not ips: ips.append("127.0.0.1") return ips +def format_host(hostname: str, port: int, scheme: str) -> str: + host = f"[{hostname}]" if ":" in hostname else hostname + default = 443 if scheme == "https" else 80 + if port != default: + return f"{host}:{port}" + return host + + +def _connect_addrs(infos, timeout: float) -> socket.socket: + last: Optional[BaseException] = None + for family, socktype, proto, _canon, sockaddr in infos: + sock = socket.socket(family, socktype, proto) + sock.settimeout(timeout) + try: + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + except OSError: + pass + try: + sock.connect(sockaddr) + return sock + except OSError as exc: + last = exc + sock.close() + raise UpstreamError(f"connect failed: {last}") + + +def _read_response_head(fp) -> Tuple[int, str, List[Tuple[str, str]]]: + status_line = fp.readline(8192) + if not status_line: + raise UpstreamError("empty upstream response") + if len(status_line) >= 8192: + raise UpstreamError("upstream status line too long") + try: + text = status_line.decode("iso-8859-1").rstrip("\r\n") + parts = text.split(" ", 2) + if len(parts) < 2: + raise ValueError("short status") + code = int(parts[1]) + reason = parts[2] if len(parts) > 2 else "" + except (ValueError, UnicodeError) as exc: + raise UpstreamError("bad upstream status line") from exc + headers: List[Tuple[str, str]] = [] + total = 0 + while True: + line = fp.readline(65536) + if not line: + raise UpstreamError("truncated upstream headers") + total += len(line) + if total > 65536: + raise UpstreamError("upstream headers too large") + if line in (b"\r\n", b"\n"): + break + if line[:1] in (b" ", b"\t") and headers: + key, value = headers[-1] + headers[-1] = (key, value + " " + line.decode("iso-8859-1").strip()) + continue + decoded = line.decode("iso-8859-1").rstrip("\r\n") + if ":" not in decoded: + continue + name, value = decoded.split(":", 1) + headers.append((name.strip(), value.strip())) + lengths = header_values(headers, "content-length") + if len({item.strip() for item in lengths}) > 1: + raise UpstreamError("conflicting content-length") + return code, reason, headers + + +def _iter_length(fp, content_length: str) -> Iterator[bytes]: + try: + remaining = int(content_length) + except ValueError as exc: + raise UpstreamError("bad content-length") from exc + if remaining < 0: + raise UpstreamError("bad content-length") + while remaining: + chunk = fp.read(min(65536, remaining)) + if not chunk: + raise UpstreamError("short upstream body") + remaining -= len(chunk) + yield chunk + + +def _iter_eof(fp) -> Iterator[bytes]: + while True: + chunk = fp.read(65536) + if not chunk: + break + yield chunk + + +def _iter_chunked(fp) -> Iterator[bytes]: + while True: + line = fp.readline(8192) + if not line: + raise UpstreamError("truncated chunk") + try: + size = int(line.split(b";", 1)[0].strip(), 16) + except ValueError as exc: + raise UpstreamError("bad chunk size") from exc + if size < 0 or size > 2 * 1024 * 1024 * 1024: + raise UpstreamError("bad chunk size") + if size == 0: + while True: + trailer = fp.readline(8192) + if trailer in (b"\r\n", b"\n", b""): + break + return + remaining = size + while remaining: + chunk = fp.read(min(65536, remaining)) + if not chunk: + raise UpstreamError("short chunk") + remaining -= len(chunk) + yield chunk + delim = fp.readline(8) + if delim not in (b"\r\n", b"\n"): + raise UpstreamError("bad chunk delimiter") + + +def _iter_gzip(stream: Iterable[bytes]) -> Iterator[bytes]: + decoder = zlib.decompressobj(16 + zlib.MAX_WBITS) + for chunk in stream: + data = decoder.decompress(chunk) + if data: + yield data + tail = decoder.flush() + if tail: + yield tail + + +class UpstreamResponse: + def __init__(self, status: int, reason: str, headers: List[Tuple[str, str]], fp, sock, head: bool): + self.status = status + self.reason = reason + self.headers = headers + self._fp = fp + self._sock = sock + self._head = head + self.decoded_gzip = False + self._consumed = False + + def iter_body(self) -> Iterator[bytes]: + if self._head or self._consumed: + return + self._consumed = True + transfer = (header_value(self.headers, "transfer-encoding") or "").lower() + if "chunked" in transfer: + stream: Iterable[bytes] = _iter_chunked(self._fp) + else: + length = header_value(self.headers, "content-length") + if length is not None: + stream = _iter_length(self._fp, length) + else: + stream = _iter_eof(self._fp) + encoding = (header_value(self.headers, "content-encoding") or "").lower() + if "gzip" in encoding: + self.decoded_gzip = True + yield from _iter_gzip(stream) + else: + yield from stream + + def read_all(self, limit: int) -> bytes: + chunks: List[bytes] = [] + total = 0 + for chunk in self.iter_body(): + total += len(chunk) + if total > limit: + raise UpstreamError("upstream body too large") + chunks.append(chunk) + return b"".join(chunks) + + def close(self) -> None: + for closer in (self._fp, self._sock): + try: + if closer is not None: + closer.close() + except Exception: + pass + self._fp = None + self._sock = None + + def __enter__(self) -> "UpstreamResponse": + return self + + def __exit__(self, *_args) -> None: + self.close() + + +def fetch( + url: str, + *, + method: str = "GET", + headers: Optional[Dict[str, str]] = None, + allow_private: bool = False, + trusted_endpoints: Iterable[Tuple[str, int]] = (), + allow_hosts: Sequence[str] = (), + check_allow_hosts: bool = False, + allowed_ports: Optional[Iterable[int]] = None, + timeout: float = CONNECT_TIMEOUT, +) -> UpstreamResponse: + """Fetch a URL with pinned DNS, redirect checks, and no hop-by-hop leakage.""" + request_headers = {key: value for key, value in (headers or {}).items() if key.lower() != "host"} + trusted = {(host.lower(), port) for host, port in trusted_endpoints} + ports = set(allowed_ports) if allowed_ports is not None else None + current = url + current_method = method.upper() + for _hop in range(MAX_REDIRECTS + 1): + if any(ch in current for ch in "\r\n\x00"): + raise PolicyError(400, "Bad URL") + current = strip_userinfo(current) + parts = urllib.parse.urlsplit(current) + if parts.scheme not in ("http", "https"): + raise PolicyError(400, "Unsupported URL scheme") + host = parts.hostname + if not host: + raise PolicyError(400, "Missing host") + try: + port = parts.port or (443 if parts.scheme == "https" else 80) + except ValueError as exc: + raise PolicyError(400, "Bad port") from exc + if ports is not None and port not in ports: + raise PolicyError(403, "Port not allowed") + if check_allow_hosts and not host_permitted(host, allow_hosts): + raise PolicyError(403, "Host not in allowlist") + private_ok = allow_private or (host.lower(), port) in trusted + infos = resolve_destination(host, port, private_ok=private_ok) + sock = _connect_addrs(infos, timeout) + sock.settimeout(READ_TIMEOUT) + try: + if parts.scheme == "https": + context = ssl.create_default_context() + sock = context.wrap_socket(sock, server_hostname=host) + fp = sock.makefile("rb") + path = parts.path or "/" + if parts.query: + path += "?" + parts.query + if any(ch in path for ch in "\r\n\x00"): + raise PolicyError(400, "Bad URL") + lines = [ + f"{current_method} {path} HTTP/1.1", + f"Host: {format_host(host, port, parts.scheme)}", + ] + for key, value in request_headers.items(): + if any(ch in key + value for ch in "\r\n\x00"): + continue + lines.append(f"{key}: {value}") + if not any(key.lower() == "connection" for key in request_headers): + lines.append("Connection: close") + sock.sendall(("\r\n".join(lines) + "\r\n\r\n").encode("ascii", "strict")) + status, reason, resp_headers = _read_response_head(fp) + if status in (301, 302, 303, 307, 308): + location = header_value(resp_headers, "location") + fp.close() + sock.close() + if not location: + raise UpstreamError("redirect without location") + next_url = urllib.parse.urljoin(current, location.strip()) + next_host = urllib.parse.urlsplit(next_url).hostname + if next_host and host and next_host.lower() != host.lower(): + request_headers = { + key: value + for key, value in request_headers.items() + if key.lower() != "authorization" + } + if status == 303: + current_method = "GET" + current = next_url + continue + return UpstreamResponse(status, reason, resp_headers, fp, sock, current_method == "HEAD") + except Exception: + try: + sock.close() + except Exception: + pass + raise + raise UpstreamError("too many redirects") + + +def map_pypi_url( + url: str, + *, + page_url: str, + upstream_hosts: Iterable[str], + relay_base: str, +) -> Optional[str]: + if not url or url.startswith(("#", "mailto:", "javascript:")): + return None + joined = url + if joined.startswith("//"): + joined = "https:" + joined + absolute = urllib.parse.urljoin(page_url, joined) + parts = urllib.parse.urlsplit(absolute) + host = (parts.hostname or "").lower() + if not host: + return None + known = {item.lower() for item in upstream_hosts} + known.update(PYPI_FILE_HOSTS) + if host not in known: + return None + path = parts.path or "" + query = f"?{parts.query}" if parts.query else "" + frag = f"#{parts.fragment}" if parts.fragment else "" + pkg_at = path.find("/packages/") + if pkg_at != -1: + return f"{relay_base}/pypi{path[pkg_at:]}{query}{frag}" + if path.startswith("/simple/"): + return f"{relay_base}/pypi{path[len('/simple'):]}{query}{frag}" + return None + + +def rewrite_pypi_html(body: bytes, *, page_url: str, upstream_hosts: Iterable[str], relay_base: str) -> bytes: + text = body.decode("utf-8", errors="replace") + + def replace(match: "re.Match[str]") -> str: + quote = match.group(1) + href = html.unescape(match.group(2)) + mapped = map_pypi_url(href, page_url=page_url, upstream_hosts=upstream_hosts, relay_base=relay_base) + if not mapped: + return match.group(0) + return f"href={quote}{mapped}{quote}" + + return _HREF_RE.sub(replace, text).encode("utf-8") + + +def rewrite_pypi_json(body: bytes, *, page_url: str, upstream_hosts: Iterable[str], relay_base: str) -> bytes: + try: + payload = json.loads(body.decode("utf-8")) + except (UnicodeError, json.JSONDecodeError): + return body + files = payload.get("files") if isinstance(payload, dict) else None + if not isinstance(files, list): + return body + for item in files: + if isinstance(item, dict) and isinstance(item.get("url"), str): + mapped = map_pypi_url( + item["url"], page_url=page_url, upstream_hosts=upstream_hosts, relay_base=relay_base + ) + if mapped: + item["url"] = mapped + return json.dumps(payload).encode("utf-8") + + +def rewrite_npm_json(body: bytes, *, relay_base: str) -> bytes: + try: + payload = json.loads(body.decode("utf-8")) + except (UnicodeError, json.JSONDecodeError): + return body + + def map_tarball(url: str) -> Optional[str]: + if not url.startswith(("http://", "https://")): + return None + parts = urllib.parse.urlsplit(url) + if not parts.path or parts.path.startswith("/npm/"): + return None + query = f"?{parts.query}" if parts.query else "" + return f"{relay_base}/npm{parts.path}{query}" + + def walk(node) -> None: + if isinstance(node, dict): + for key, value in list(node.items()): + if key == "tarball" and isinstance(value, str): + mapped = map_tarball(value) + if mapped: + node[key] = mapped + else: + walk(value) + elif isinstance(node, list): + for item in node: + walk(item) + + walk(payload) + return json.dumps(payload).encode("utf-8") + + +def rewrite_www_authenticate(value: str, *, relay_base: str, secret: bytes) -> str: + def replace(match: "re.Match[str]") -> str: + realm = match.group(1) + if not realm.startswith(("http://", "https://")): + return match.group(0) + sealed = seal_url(realm, secret) + return f'realm="{relay_base}/_classrelay/docker-token/{sealed}"' + + return _REALM_RE.sub(replace, value) + + +def render_setup_scripts(origin: str, ip: str) -> Dict[str, str]: + """Student setup/restore scripts. Registry URLs are plain HTTP, so npm strict-ssl is left alone.""" + target = origin.rstrip("/") + pip_index = target + "/pypi/" + npm_registry = target + "/npm/" + q_target = sh_quote(target) + q_ip = sh_quote(ip) + q_pip = sh_quote(pip_index) + q_npm = sh_quote(npm_registry) + ps_target = ps_quote(target) + ps_ip = ps_quote(ip) + ps_pip = ps_quote(pip_index) + ps_npm = ps_quote(npm_registry) + block = "\n".join( + [ + _MARK_START, + f"export HTTP_PROXY={q_target}", + f"export HTTPS_PROXY={q_target}", + f"export http_proxy={q_target}", + f"export https_proxy={q_target}", + _MARK_END, + ] + ) + remove_fn = f"""remove_classrelay_block() {{ + file="$1" + [ -f "$file" ] || return 0 + tmp=$(mktemp) + awk ' + $0 == "{_MARK_START}" {{ skip = 1; next }} + $0 == "{_MARK_END}" {{ skip = 0; next }} + skip != 1 {{ print }} + ' "$file" > "$tmp" && mv "$tmp" "$file" +}}""" + bash = f"""#!/usr/bin/env bash +# ClassRelay student setup. +# Served over plain HTTP. Download and read it before you run it. +# curl|bash cannot export variables into the shell that launched it. +{remove_fn} +append_classrelay_block() {{ + file="$1" + touch "$file" + remove_classrelay_block "$file" + cat >> "$file" <<'EOF' +{block} +EOF +}} +append_classrelay_block "$HOME/.bashrc" +append_classrelay_block "$HOME/.zshrc" +pip config set global.index-url {q_pip} || true +pip config set global.trusted-host {q_ip} || true +npm config set registry {q_npm} || true +npm config delete strict-ssl >/dev/null 2>&1 || true +git config --global http.proxy {q_target} || true +git config --global https.proxy {q_target} || true +sourced=0 +if [ -n "${{BASH_SOURCE-}}" ] && [ "${{BASH_SOURCE[0]}}" != "$0" ]; then + sourced=1 +fi +if [ -n "${{ZSH_EVAL_CONTEXT-}}" ]; then + case $ZSH_EVAL_CONTEXT in *:file) sourced=1 ;; esac +fi +if [ "$sourced" -eq 1 ]; then + export HTTP_PROXY={q_target} + export HTTPS_PROXY={q_target} + export http_proxy={q_target} + export https_proxy={q_target} + echo ">>> Applied proxy variables to the current shell." +else + echo ">>> Saved proxy variables to ~/.bashrc and ~/.zshrc." + echo ">>> curl|bash cannot change the current shell. Open a new terminal, or run: source ~/.bashrc" +fi +echo ">>> [SUCCESS] Student configuration complete." +""" + unbash = f"""#!/usr/bin/env bash +# ClassRelay student restore. +{remove_fn} +remove_classrelay_block "$HOME/.bashrc" +remove_classrelay_block "$HOME/.zshrc" +pip config unset global.index-url || true +pip config unset global.trusted-host || true +npm config delete registry || true +npm config delete strict-ssl || true +git config --global --unset http.proxy || true +git config --global --unset https.proxy || true +sourced=0 +if [ -n "${{BASH_SOURCE-}}" ] && [ "${{BASH_SOURCE[0]}}" != "$0" ]; then + sourced=1 +fi +if [ "$sourced" -eq 1 ]; then + unset HTTP_PROXY HTTPS_PROXY http_proxy https_proxy +fi +echo ">>> Open a new terminal so exported proxy variables are cleared." +echo ">>> [SUCCESS] Defaults restored." +""" + windows = f"""# ClassRelay student setup for Windows. +# Served over plain HTTP. Download and read it before you run it. +$target = {ps_target} +$ip = {ps_ip} +Write-Host ">>> Configuring student workstation for $target" -ForegroundColor Cyan +pip config set global.index-url {ps_pip} +pip config set global.trusted-host {ps_ip} +npm config set registry {ps_npm} +npm config delete strict-ssl +[System.Environment]::SetEnvironmentVariable('HTTP_PROXY', $target, 'User') +[System.Environment]::SetEnvironmentVariable('HTTPS_PROXY', $target, 'User') +$env:HTTP_PROXY = $target +$env:HTTPS_PROXY = $target +git config --global http.proxy $target +git config --global https.proxy $target +Write-Host ">>> [SUCCESS] Student configuration complete. Open a new terminal so proxy variables apply." -ForegroundColor Green +""" + unwindows = """# ClassRelay student restore for Windows. +Write-Host ">>> Restoring student workstation defaults..." -ForegroundColor Yellow +pip config unset global.index-url +pip config unset global.trusted-host +npm config delete registry +npm config delete strict-ssl +[System.Environment]::SetEnvironmentVariable('HTTP_PROXY', $null, 'User') +[System.Environment]::SetEnvironmentVariable('HTTPS_PROXY', $null, 'User') +$env:HTTP_PROXY = '' +$env:HTTPS_PROXY = '' +git config --global --unset http.proxy +git config --global --unset https.proxy +Write-Host ">>> [SUCCESS] Defaults restored. Open a new terminal so proxy variables are cleared." -ForegroundColor Green +""" + return {"bash": bash, "unbash": unbash, "windows": windows, "unwindows": unwindows} + + +class CacheEntry: + def __init__(self, status: int, headers: List[Tuple[str, str]], path: str, size: int, kind: str): + self.status = status + self.headers = headers + self.path = path + self.size = size + self.kind = kind + + class CacheManager: - """Disk-backed caching layer for requested HTTP assets.""" + """Disk cache with TTL, a size cap, atomic publish, and singleflight.""" - def __init__(self, cache_dir: str): + def __init__(self, cache_dir: str, max_bytes: int, index_ttl: int, asset_ttl: int): self.cache_dir = cache_dir - os.makedirs(os.path.join(cache_dir, "meta"), exist_ok=True) - os.makedirs(os.path.join(cache_dir, "data"), exist_ok=True) + self.data_dir = os.path.join(cache_dir, "data") + self.meta_dir = os.path.join(cache_dir, "meta") + self.max_bytes = max_bytes + self.index_ttl = index_ttl + self.asset_ttl = asset_ttl + os.makedirs(self.data_dir, exist_ok=True) + os.makedirs(self.meta_dir, exist_ok=True) self.stats = { "hits": 0, "misses": 0, @@ -77,169 +988,523 @@ def __init__(self, cache_dir: str): "start_time": time.time(), } self.lock = threading.Lock() - self._calc_existing_cache_size() + self._flight_lock = threading.Lock() + self._flight: Dict[str, threading.Event] = {} + self.index: Dict[str, Dict[str, object]] = {} + self._load_existing() - def _calc_existing_cache_size(self): + def _load_existing(self) -> None: + referenced = set() total = 0 - data_dir = os.path.join(self.cache_dir, "data") try: - for entry in os.scandir(data_dir): - if entry.is_file(): - total += entry.stat().st_size - except Exception: - pass + metas = list(os.scandir(self.meta_dir)) + except OSError: + metas = [] + for entry in metas: + if not entry.is_file() or not entry.name.endswith(".json"): + continue + key = entry.name[: -len(".json")] + try: + with open(entry.path, "r", encoding="utf-8") as handle: + meta = json.load(handle) + filename = meta["file"] + size = int(meta["size"]) + path = os.path.join(self.data_dir, filename) + if os.path.getsize(path) != size: + continue + except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError): + continue + self.index[key] = { + "file": filename, + "size": size, + "timestamp": float(meta.get("timestamp", 0)), + } + referenced.add(filename) + total += size self.stats["cached_bytes"] = total + try: + for entry in os.scandir(self.data_dir): + if entry.is_file() and entry.name not in referenced: + try: + os.remove(entry.path) + except OSError: + pass + except OSError: + pass - def _url_to_hash(self, url: str) -> str: - return hashlib.sha256(url.encode("utf-8")).hexdigest() - - def get(self, url: str) -> Optional[Tuple[bytes, Dict[str, str], int]]: - url_hash = self._url_to_hash(url) - data_path = os.path.join(self.cache_dir, "data", url_hash) - meta_path = os.path.join(self.cache_dir, "meta", url_hash + ".json") + def note_miss(self) -> None: + with self.lock: + self.stats["misses"] += 1 - if os.path.exists(data_path) and os.path.exists(meta_path): + def lookup(self, key: str) -> Optional[CacheEntry]: + meta_path = os.path.join(self.meta_dir, key + ".json") + try: + with open(meta_path, "r", encoding="utf-8") as handle: + meta = json.load(handle) + filename = meta["file"] + size = int(meta["size"]) + kind = meta.get("kind", "asset") + timestamp = float(meta.get("timestamp", 0)) + path = os.path.join(self.data_dir, filename) + if os.path.getsize(path) != size: + return None + except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError): + return None + ttl = self.index_ttl if kind == "index" else self.asset_ttl + if ttl >= 0 and (time.time() - timestamp) > ttl: + return None + headers = [(str(item[0]), str(item[1])) for item in meta.get("headers", []) if len(item) == 2] + with self.lock: + self.stats["hits"] += 1 + return CacheEntry(int(meta.get("status", 200)), headers, path, size, str(kind)) + + def lead(self, key: str) -> bool: + with self._flight_lock: + if key in self._flight: + return False + self._flight[key] = threading.Event() + return True + + def follow(self, key: str) -> None: + with self._flight_lock: + event = self._flight.get(key) + if event is not None: + event.wait() + + def finish(self, key: str) -> None: + with self._flight_lock: + event = self._flight.pop(key, None) + if event is not None: + event.set() + + def new_temp(self) -> str: + fd, path = tempfile.mkstemp(prefix=".part-", dir=self.data_dir) + os.close(fd) + return path + + def abort(self, path: Optional[str]) -> None: + if path: try: - with open(meta_path, "r", encoding="utf-8") as f: - meta = json.load(f) - with open(data_path, "rb") as f: - data = f.read() - with self.lock: - self.stats["hits"] += 1 - return data, meta.get("headers", {}), meta.get("status", 200) - except Exception: + os.remove(path) + except OSError: pass + def commit( + self, + key: str, + tmp_path: str, + *, + url: str, + headers: Sequence[Tuple[str, str]], + status: int, + kind: str, + ) -> bool: + try: + size = os.path.getsize(tmp_path) + except OSError: + return False + if self.max_bytes >= 0 and size > self.max_bytes: + self.abort(tmp_path) + return False + filename = f"{key}-{secrets.token_hex(8)}" + final = os.path.join(self.data_dir, filename) + try: + with open(tmp_path, "rb") as handle: + os.fsync(handle.fileno()) + os.replace(tmp_path, final) + except OSError as exc: + LOG.warning("cache write failed: %s", exc) + self.abort(tmp_path) + return False + stored_headers = [ + [key_name, value] + for key_name, value in headers + if key_name.lower() not in HOP_BY_HOP + and key_name.lower() not in {"content-length", "set-cookie", "content-encoding"} + and "\r" not in value + and "\n" not in value + ] + meta = { + "url": url, + "file": filename, + "headers": stored_headers, + "status": status, + "size": size, + "kind": kind, + "timestamp": time.time(), + } + try: + _atomic_write_json(os.path.join(self.meta_dir, key + ".json"), meta) + except OSError as exc: + LOG.warning("cache meta write failed: %s", exc) + try: + os.remove(final) + except OSError: + pass + return False + old_files: List[str] = [] with self.lock: - self.stats["misses"] += 1 - return None + prev = self.index.get(key) + self.index[key] = {"file": filename, "size": size, "timestamp": meta["timestamp"]} + self.stats["cached_bytes"] += size + if prev: + self.stats["cached_bytes"] -= int(prev["size"]) + old_files.append(str(prev["file"])) + for victim in self._victims_locked(protect=filename): + old_files.append(str(victim["file"])) + for name in old_files: + self._unlink(name) + return True + + def _victims_locked(self, protect: str) -> List[Dict[str, object]]: + victims = [] + if self.max_bytes < 0: + return victims + while self.stats["cached_bytes"] > self.max_bytes and len(self.index) > 1: + oldest_key = None + oldest = None + for key, item in self.index.items(): + if item["file"] == protect: + continue + if oldest is None or float(item["timestamp"]) < float(oldest["timestamp"]): + oldest_key = key + oldest = item + if oldest is None: + break + victims.append({"key": oldest_key, "file": oldest["file"], "size": int(oldest["size"])}) + self.stats["cached_bytes"] -= int(oldest["size"]) + self.index.pop(oldest_key, None) + return victims + + def _unlink(self, filename: str) -> None: + try: + os.remove(os.path.join(self.data_dir, filename)) + except OSError: + pass - def put(self, url: str, data: bytes, headers: Dict[str, str], status: int = 200): - # Cache successful GET payloads only - if status != 200 or len(data) == 0: - return - url_hash = self._url_to_hash(url) - data_path = os.path.join(self.cache_dir, "data", url_hash) - meta_path = os.path.join(self.cache_dir, "meta", url_hash + ".json") +def _atomic_write_json(path: str, payload: dict) -> None: + directory = os.path.dirname(path) + fd, tmp = tempfile.mkstemp(prefix=".meta-", dir=directory) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(payload, handle) + handle.flush() + os.fsync(handle.fileno()) + for _attempt in range(8): + try: + os.replace(tmp, path) + return + except PermissionError: + time.sleep(0.05) + os.replace(tmp, path) + except Exception: try: - # ponytail: in-memory string headers; sufficient for standard pip/npm packages - clean_headers = { - k: v for k, v in headers.items() if k.lower() not in ["transfer-encoding", "connection"] - } - with open(data_path, "wb") as f: - f.write(data) - with open(meta_path, "w", encoding="utf-8") as f: - json.dump({"url": url, "headers": clean_headers, "status": status, "timestamp": time.time()}, f) - with self.lock: - self.stats["cached_bytes"] += len(data) - except Exception as e: - sys.stderr.write(f"[Cache Error] Failed to write cache: {e}\n") + os.remove(tmp) + except OSError: + pass + raise -class ClassRelayHandler(http.server.BaseHTTPRequestHandler): - """Handles HTTP CONNECT proxy, REST mirror endpoints, Web UI and client scripts.""" +def _cache_control_blocks(value: str) -> bool: + lowered = value.lower() + return any(flag in lowered for flag in ("private", "no-store", "no-cache")) + + +def response_cacheable(status: int, headers: Sequence[Tuple[str, str]], request_ok: bool) -> bool: + if not request_ok or status != 200: + return False + if header_values(headers, "set-cookie"): + return False + cache_control = header_value(headers, "cache-control") or "" + if _cache_control_blocks(cache_control): + return False + vary = (header_value(headers, "vary") or "").lower() + if vary.strip() == "*" or "cookie" in vary: + return False + return True + +def classify_kind(path: str, content_type: str) -> str: + lower_path = path.lower() + if lower_path.endswith(ASSET_SUFFIXES) or "/blobs/" in lower_path: + return "asset" + content = content_type.lower() + if "json" in content or "html" in content or content.startswith("text/"): + return "index" + return "asset" + + +def rewrite_mode(mirror: str, path: str, content_type: str) -> Optional[str]: + lower_path = path.lower().split("?", 1)[0] + if lower_path.endswith(ASSET_SUFFIXES): + return None + content = content_type.lower() + if mirror == "pypi" and "/packages/" not in lower_path and ("html" in content or "json" in content): + return "pypi-json" if "json" in content else "pypi-html" + if mirror == "npm" and "json" in content: + return "npm-json" + return None + + +class ClassRelayHandler(http.server.BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" server_version = f"ClassRelay/{VERSION}" - def log_message(self, format, *args): - # Terse custom logging - sys.stdout.write(f"[{self.log_date_time_string()}] {self.client_address[0]} {args[0]} {args[1]} {args[2]}\n") + def log_message(self, fmt: str, *args) -> None: + try: + message = fmt % args if args else str(fmt) + except Exception: + message = str(fmt) + LOG.info("%s %s", self.client_address[0], redact_text(message)) + + def handle_one_request(self) -> None: + self._started = False + try: + super().handle_one_request() + except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, TimeoutError): + self.close_connection = True + except Exception: + LOG.exception("request failed") + self.close_connection = True + if not getattr(self, "_started", False): + try: + self.send_error(500, "Internal Server Error") + except Exception: + pass + + def send_response(self, code: int, message: Optional[str] = None) -> None: + self._started = True + super().send_response(code, message) @property def cache(self) -> CacheManager: - return self.server.cache_manager - - def do_CONNECT(self): - """Standard HTTP forward proxy via CONNECT tunneling (HTTPS).""" - # Format: host:port - host, port_str = self.path.split(":") if ":" in self.path else (self.path, "443") - port = int(port_str) + return self.server.cache + + def _check_auth(self, proxy: bool) -> bool: + auth = self.server.auth + if not auth: + return True + if proxy: + presented = ( + self.headers.get("Proxy-Authorization", ""), + self.headers.get("Authorization", ""), + ) + else: + presented = ( + self.headers.get("Authorization", ""), + self.headers.get("Proxy-Authorization", ""), + ) + return any(basic_matches(value, auth[0], auth[1]) for value in presented) + def _challenge(self, proxy: bool) -> None: + if proxy: + self.send_response(407, "Proxy Authentication Required") + self.send_header("Proxy-Authenticate", 'Basic realm="ClassRelay"') + else: + self.send_response(401, "Unauthorized") + self.send_header("WWW-Authenticate", 'Basic realm="ClassRelay"') + body = b"Authentication required. Use http://user:pass@host:port\n" + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") + self.send_header("Cache-Control", "no-store") + self.end_headers() + if self.command != "HEAD": + self.wfile.write(body) + + def _listen_endpoint(self) -> Tuple[str, int]: + sockname = self.connection.getsockname() + ip = str(sockname[0]) + port = int(sockname[1]) + if ip.startswith("::ffff:"): + ip = ip.split("::ffff:", 1)[1] + if ip in ("0.0.0.0", "::"): + ip = (self.server.local_ips or ["127.0.0.1"])[0] + return ip, port + + def _origin(self, with_auth: bool) -> Tuple[str, str, int]: + ip, port = self._listen_endpoint() + host = f"[{ip}]" if ":" in ip else ip + auth = self.server.auth + if with_auth and auth: + user = urllib.parse.quote(auth[0], safe="") + password = urllib.parse.quote(auth[1], safe="") + return f"http://{user}:{password}@{host}:{port}", ip, port + return f"http://{host}:{port}", ip, port + + def do_CONNECT(self) -> None: + self.close_connection = True + if not self._check_auth(proxy=True): + self._challenge(proxy=True) + return try: - target_sock = socket.create_connection((host, port), timeout=15) + host, port = parse_connect_target(self.path) + except ValueError: + self.send_error(400, "Bad CONNECT target") + return + if port not in self.server.connect_ports: + self.send_error(403, "Port not allowed") + return + if not host_permitted(host, self.server.allow_hosts): + self.send_error(403, "Host not in allowlist") + return + remote = None + established = False + try: + private_ok = bool(self.server.allow_private) + infos = resolve_destination(host, port, private_ok=private_ok) + remote = _connect_addrs(infos, CONNECT_TIMEOUT) self.send_response(200, "Connection Established") self.end_headers() + established = True + try: + self.wfile.flush() + except Exception: + return + self._tunnel(self.connection, remote) + except PolicyError as exc: + if not established: + self.send_error(exc.status, exc.reason) + except Exception as exc: + LOG.info("CONNECT failed: %s", redact_text(str(exc))) + if not established: + try: + self.send_error(502, "Bad Gateway") + except Exception: + pass + finally: + if remote is not None: + try: + remote.close() + except Exception: + pass - # Bidirectional pipe between client socket and remote server socket - conns = [self.connection, target_sock] - self.connection.setblocking(False) - target_sock.setblocking(False) + def _tunnel(self, client: socket.socket, remote: socket.socket) -> None: + idle = self.server.tunnel_idle + if idle and idle > 0: + client.settimeout(idle) + remote.settimeout(idle) + else: + client.settimeout(None) + remote.settimeout(None) - while True: - rlist, _, xlist = select.select(conns, [], conns, 60) - if xlist or not rlist: - break - for s in rlist: - other = target_sock if s is self.connection else self.connection - data = s.recv(65536) - if not data: - return - other.sendall(data) - except Exception as e: - self.send_error(502, f"Bad Gateway CONNECT failed: {e}") - finally: + def pump(src: socket.socket, dst: socket.socket) -> None: try: - target_sock.close() + while True: + data = src.recv(65536) + if not data: + break + dst.sendall(data) except Exception: pass + finally: + try: + dst.shutdown(socket.SHUT_WR) + except Exception: + pass - def do_GET(self): - """Route to Dashboard, Scripts, Mirrors, or standard forward HTTP proxy.""" - # 1. Forward proxy handling if path starts with http:// or https:// - if self.path.startswith("http://") or self.path.startswith("https://"): - self._handle_forward_http() - return + left = threading.Thread(target=pump, args=(client, remote), daemon=True) + right = threading.Thread(target=pump, args=(remote, client), daemon=True) + left.start() + right.start() + left.join() + right.join() - parsed = urllib.parse.urlparse(self.path) - path = parsed.path + def do_HEAD(self) -> None: + self.do_GET() - # 2. Web UI Dashboard - if path in ["", "/", "/index.html"]: + def do_GET(self) -> None: + proxy = self.path.startswith("http://") or self.path.startswith("https://") + if not self._check_auth(proxy=proxy): + self._challenge(proxy=proxy) + return + if proxy: + self._handle_forward() + return + parsed = urllib.parse.urlparse(self.path) + path = parsed.path or "/" + if "\\" in path or path.startswith("//") or "\x00" in path: + self.send_error(400, "Bad path") + return + if path in ("", "/", "/index.html"): self._serve_dashboard() return - - # 3. Status API if path == "/api/status": self._serve_api_status() return - - # 4. One-click setup scripts if path.startswith("/setup/"): self._serve_setup_script(path) return - - # 5. Mirror routes: /pypi/, /npm/, /v2/ (Docker Registry v2), /platformio/, /espressif/ - if path.startswith("/pypi/"): - self._proxy_mirror(path[len("/pypi"):], UPSTREAMS["pypi"], "pypi") - return - elif path.startswith("/npm/"): - self._proxy_mirror(path[len("/npm"):], UPSTREAMS["npm"], "npm") - return - elif path.startswith("/v2/") or path == "/v2": - self._proxy_mirror(path, UPSTREAMS["docker"], "docker") + if path.startswith("/_classrelay/docker-token/"): + self._serve_docker_token(path, parsed.query) return - elif path.startswith("/docker/"): - self._proxy_mirror(path[len("/docker"):], UPSTREAMS["docker"], "docker") + try: + upstream, mirror, trusted = self._mirror_target(path, parsed.query) + except KeyError: + self.send_error(404, "Endpoint not found on ClassRelay") return - elif path.startswith("/platformio/"): - self._proxy_mirror(path[len("/platformio"):], UPSTREAMS["platformio"], "platformio") + except ValueError: + self.send_error(400, "Bad mirror path") return - elif path.startswith("/espressif/"): - self._proxy_mirror(path[len("/espressif"):], UPSTREAMS["espressif"], "espressif") + if upstream is None: + self.send_error(404, "Endpoint not found on ClassRelay") return - - # Fallback 404 - self.send_error(404, "Endpoint not found on ClassRelay") - - def _serve_dashboard(self): - host = self.headers.get("Host", f"127.0.0.1:{DEFAULT_PORT}") - server_ip = host.split(":")[0] - port = host.split(":")[1] if ":" in host else str(DEFAULT_PORT) - ips = get_local_ips() - display_ip = ips[0] if ips else server_ip - - html = f""" + self._proxy_mirror(upstream, mirror, trusted) + + def _mirror_target(self, path: str, query: str): + upstreams = self.server.upstreams + if path == "/pypi": + path = "/pypi/" + if path == "/npm": + path = "/npm/" + if path == "/pypi" or path.startswith("/pypi/"): + rest = path[len("/pypi") :] or "/" + base_url = upstreams["pypi"] + if rest.startswith("/packages/"): + base_url = simple_root(base_url) + elif rest.startswith("/simple/"): + base_url = simple_root(base_url) + upstream = join_upstream(base_url, rest, query) + return upstream, "pypi", endpoint_of(base_url) + if path.startswith("/npm/"): + base_url = upstreams["npm"] + upstream = join_upstream(base_url, path[len("/npm") :], query) + return upstream, "npm", endpoint_of(base_url) + if path == "/v2" or path.startswith("/v2/"): + base_url = origin_of(upstreams["docker"]) + upstream = join_upstream(base_url, path if path != "/v2" else "/v2/", query) + return upstream, "docker", endpoint_of(upstreams["docker"]) + if path.startswith("/docker/"): + base_url = origin_of(upstreams["docker"]) + upstream = join_upstream(base_url, path[len("/docker") :], query) + return upstream, "docker", endpoint_of(upstreams["docker"]) + if path.startswith("/platformio/"): + base_url = upstreams["platformio"] + upstream = join_upstream(base_url, path[len("/platformio") :], query) + return upstream, "platformio", endpoint_of(base_url) + if path.startswith("/espressif/"): + base_url = upstreams["espressif"] + upstream = join_upstream(base_url, path[len("/espressif") :], query) + return upstream, "espressif", endpoint_of(base_url) + return None, "", None + + def _serve_dashboard(self) -> None: + origin, _ip, port = self._origin(with_auth=True) + _bare, bare_ip, _port = self._origin(with_auth=False) + scripts = render_setup_scripts(origin, bare_ip) + hashes = {name: hashlib.sha256(body.encode("utf-8")).hexdigest() for name, body in scripts.items()} + display = html.escape(origin, quote=True) + safe_ip = html.escape(bare_ip, quote=True) + others = [] + for candidate in self.server.local_ips or []: + if candidate != bare_ip: + others.append(html.escape(candidate, quote=True)) + other_html = "".join(f'http://{item}:{port}' for item in others) + auth_note = "" + if self.server.auth: + auth_note = "

已开启 HTTP Basic 认证。下面的命令含密码,只应在已登录的控制台中查看。

" + html_doc = f""" @@ -257,10 +1522,10 @@ def _serve_dashboard(self): .card h3 {{ margin-top: 0; font-size: 16px; border-bottom: 1px solid var(--border); padding-bottom: 8px; }} pre {{ background: var(--code-bg); padding: 12px; border-radius: 6px; overflow-x: auto; font-size: 13px; border: 1px solid var(--border); }} code {{ color: #7dd3fc; }} - .cmd-box {{ position: relative; margin-bottom: 12px; }} .stat-val {{ font-size: 28px; font-weight: bold; color: var(--accent); margin: 8px 0; }} .ip-list {{ display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }} .ip-item {{ background: #0369a1; padding: 3px 8px; border-radius: 4px; font-size: 13px; }} + .warn {{ color: #fcd34d; font-size: 13px; }} @@ -272,87 +1537,69 @@ def _serve_dashboard(self): v{VERSION} 运行中 -
-

中继服务器网络地址

-

教师机内网 IP(学生机请确保能 ping 通):

-
- {"".join([f'http://{ip}:{port}' for ip in ips])} -
+

本次连接地址

+

学生机这次连到的教师机地址(优先使用它,而不是猜测网卡):

+
{display}
+

地址 {safe_ip} ,端口 {port}

+ {f'

本机其他网卡(仅供参考):

{other_html}
' if other_html else ''}

缓存与命中统计

-
-
-
-
加载统计中...
-
+
-
+
加载统计中...
-
-

一键学生机配置命令 (Windows / Linux / macOS)

-

在学生机打开终端或 PowerShell 执行对应命令,自动将 pip、npm 与 HTTP 代理指向教师机:

- -

Windows (PowerShell 管理员或普通终端):

-
irm http://{display_ip}:{port}/setup/windows | iex
- -

Linux / macOS (Bash / Terminal):

-
curl -fsSL http://{display_ip}:{port}/setup/bash | bash
- -

课后一键还原学生机网络配置 (恢复官方默认):

-
# Windows:
-irm http://{display_ip}:{port}/setup/unwindows | iex
-
-# Linux/macOS:
-curl -fsSL http://{display_ip}:{port}/setup/unbash | bash
+

一键配置前请先看

+

脚本通过明文 HTTP 下发。同网段的人可以伪造教师机。请先把脚本下载下来核对 SHA256,再执行,不要习惯性直接 curl | bash / irm | iex。

+ {auth_note} +

Windows

+
irm {display}/setup/windows -OutFile classrelay-setup.ps1
+# SHA256 {hashes['windows']}
+# 核对后再执行:
+# powershell -File .\\classrelay-setup.ps1
+

Linux / macOS

+
curl -fsSL {display}/setup/bash -o classrelay-setup.sh
+# SHA256 {hashes['bash']}
+# less classrelay-setup.sh
+# bash classrelay-setup.sh
+# 当前终端若要立刻生效: source ~/.bashrc
+

课后还原

+
# Windows  SHA256 {hashes['unwindows']}
+irm {display}/setup/unwindows -OutFile classrelay-restore.ps1
+# Linux/macOS  SHA256 {hashes['unbash']}
+curl -fsSL {display}/setup/unbash -o classrelay-restore.sh
-
-

Python / pip 独立配置

-

直接使用教师机镜像或全局代理:

-
pip config set global.index-url http://{display_ip}:{port}/pypi/
-pip config set global.trusted-host {display_ip}
+

Python / pip

+
pip config set global.index-url {display}/pypi/
+pip config set global.trusted-host {safe_ip}
-
-

Node.js / npm 独立配置

-

指定教师机作为 npm Registry:

-
npm config set registry http://{display_ip}:{port}/npm/
-npm config set strict-ssl false
+

Node.js / npm

+

镜像本身是 HTTP,不需要关闭 strict-ssl。

+
npm config set registry {display}/npm/
-
-

Docker / 镜像加速配置

-

将教师机配置为 Docker Registry Mirror (/etc/docker/daemon.json):

+

Docker(实验性)

+

支持 Registry v2 的 HEAD/GET、Accept 协商和 token realm 回写。不含 push、Range 断点续传。

{{
-  "registry-mirrors": ["http://{display_ip}:{port}"],
-  "insecure-registries": ["{display_ip}:{port}"]
+  "registry-mirrors": ["{display}"],
+  "insecure-registries": ["{safe_ip}:{port}"]
 }}
-
-

嵌入式开发 / PlatformIO / ESP32

-

在学生机环境变量中设置 HTTP 代理:

-
# Windows PowerShell
-$env:HTTP_PROXY="http://{display_ip}:{port}"
-$env:HTTPS_PROXY="http://{display_ip}:{port}"
-
-# Linux / Mac
-export HTTP_PROXY=http://{display_ip}:{port}
-export HTTPS_PROXY=http://{display_ip}:{port}
-
- -
-

Git 依赖拉取代理

-

让 student git clone 通过教师机 CONNECT 隧道:

-
git config --global http.proxy http://{display_ip}:{port}
-git config --global https.proxy http://{display_ip}:{port}
+

PlatformIO / Git 代理

+
export HTTP_PROXY={display}
+export HTTPS_PROXY={display}
+git config --global http.proxy {display}
+git config --global https.proxy {display}
- """ - encoded = html.encode("utf-8") - self.send_response(200) - self.send_header("Content-Type", "text/html; charset=utf-8") - self.send_header("Content-Length", str(len(encoded))) - self.end_headers() - self.wfile.write(encoded) - - def _serve_api_status(self): - stats = dict(self.cache.stats) - encoded = json.dumps(stats).encode("utf-8") - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(encoded))) - self.end_headers() - self.wfile.write(encoded) - - def _serve_setup_script(self, path: str): - host = self.headers.get("Host", f"127.0.0.1:{DEFAULT_PORT}") - server_ip = host.split(":")[0] - port = host.split(":")[1] if ":" in host else str(DEFAULT_PORT) - ips = get_local_ips() - target_ip = ips[0] if ips else server_ip - - if path.endswith("/windows"): - script = f"""# ClassRelay One-click student setup for Windows -$target = "http://{target_ip}:{port}" -$ip = "{target_ip}" -Write-Host ">>> Configuring student workstation to use ClassRelay Teacher Proxy: $target" -ForegroundColor Cyan - -# 1. Pip config -pip config set global.index-url "$target/pypi/" -pip config set global.trusted-host "$ip" - -# 2. NPM config -npm config set registry "$target/npm/" -npm config set strict-ssl false - -# 3. Environment variable for current session & system -[System.Environment]::SetEnvironmentVariable('HTTP_PROXY', $target, 'User') -[System.Environment]::SetEnvironmentVariable('HTTPS_PROXY', $target, 'User') -$env:HTTP_PROXY = $target -$env:HTTPS_PROXY = $target - -# 4. Git config -git config --global http.proxy $target -git config --global https.proxy $target - -Write-Host ">>> [SUCCESS] Student configuration complete! You can now run pip install / npm install / platformio." -ForegroundColor Green -""" - elif path.endswith("/unwindows"): - script = """# ClassRelay Restore default settings -Write-Host ">>> Restoring student workstation configuration to defaults..." -ForegroundColor Yellow -pip config unset global.index-url -pip config unset global.trusted-host -npm config delete registry -npm config set strict-ssl true -[System.Environment]::SetEnvironmentVariable('HTTP_PROXY', $null, 'User') -[System.Environment]::SetEnvironmentVariable('HTTPS_PROXY', $null, 'User') -$env:HTTP_PROXY = '' -$env:HTTPS_PROXY = '' -git config --global --unset http.proxy -git config --global --unset https.proxy -Write-Host ">>> [SUCCESS] Defaults restored." -ForegroundColor Green -""" - elif path.endswith("/bash"): - script = f"""#!/usr/bin/env bash -# ClassRelay One-click student setup for Linux/macOS -TARGET="http://{target_ip}:{port}" -IP="{target_ip}" -echo ">>> Configuring student workstation to use ClassRelay Teacher Proxy: $TARGET" - -pip config set global.index-url "$TARGET/pypi/" || true -pip config set global.trusted-host "$IP" || true -npm config set registry "$TARGET/npm/" || true -npm config set strict-ssl false || true -git config --global http.proxy "$TARGET" || true -git config --global https.proxy "$TARGET" || true - -export HTTP_PROXY="$TARGET" -export HTTPS_PROXY="$TARGET" -export http_proxy="$TARGET" -export https_proxy="$TARGET" - -echo ">>> [SUCCESS] Student configuration complete!" -""" - else: # unbash - script = """#!/usr/bin/env bash -# ClassRelay Restore defaults for Linux/macOS -echo ">>> Restoring defaults..." -pip config unset global.index-url || true -pip config unset global.trusted-host || true -npm config delete registry || true -git config --global --unset http.proxy || true -git config --global --unset https.proxy || true -unset HTTP_PROXY HTTPS_PROXY http_proxy https_proxy -echo ">>> [SUCCESS] Defaults restored." -""" - encoded = script.encode("utf-8") - self.send_response(200) - self.send_header("Content-Type", "text/plain; charset=utf-8") - self.send_header("Content-Length", str(len(encoded))) - self.end_headers() - self.wfile.write(encoded) - - def _proxy_mirror(self, subpath: str, upstream_base: str, mirror_type: str): - """Proxy and cache mirror requests to upstream mirrors (PyPI, npm, PlatformIO, etc.).""" - # Strip leading slash if present - subpath = subpath.lstrip("/") - upstream_url = f"{upstream_base.rstrip('/')}/{subpath}" - if self.path.find("?") != -1: - query = self.path[self.path.find("?") :] - upstream_url += query - - # Check Cache - cached = self.cache.get(upstream_url) - if cached: - data, headers, status = cached - self.send_response(status) - for k, v in headers.items(): - self.send_header(k, v) - self.send_header("X-ClassRelay-Cache", "HIT") - self.send_header("Content-Length", str(len(data))) - self.end_headers() - try: - self.wfile.write(data) - except (BrokenPipeError, ConnectionResetError): - pass + self._send_bytes(200, "OK", [("Content-Type", "text/html; charset=utf-8"), ("Cache-Control", "no-store")], html_doc.encode("utf-8"), None) + + def _serve_api_status(self) -> None: + with self.cache.lock: + payload = dict(self.cache.stats) + body = json.dumps(payload).encode("utf-8") + self._send_bytes(200, "OK", [("Content-Type", "application/json"), ("Cache-Control", "no-store")], body, None) + + def _serve_setup_script(self, path: str) -> None: + name = path[len("/setup/") :].strip("/") + if name not in {"windows", "unwindows", "bash", "unbash"}: + self.send_error(404, "Endpoint not found on ClassRelay") + return + origin, _ip, _port = self._origin(with_auth=True) + _bare, bare_ip, _bare_port = self._origin(with_auth=False) + script = render_setup_scripts(origin, bare_ip)[name] + body = script.encode("utf-8") + digest = hashlib.sha256(body).hexdigest() + self._send_bytes( + 200, + "OK", + [ + ("Content-Type", "text/plain; charset=utf-8"), + ("Cache-Control", "no-store"), + ("X-ClassRelay-SHA256", digest), + ], + body, + None, + ) + + def _serve_docker_token(self, path: str, query: str) -> None: + token = path[len("/_classrelay/docker-token/") :] + url = unseal_url(token, self.server.secret) + if not url: + self.send_error(400, "Bad token endpoint") + return + safe_query = sanitize_query(query) + if safe_query: + parts = urllib.parse.urlsplit(url) + merged = parts.query + ("&" if parts.query and safe_query else "") + safe_query + url = urllib.parse.urlunsplit((parts.scheme, parts.netloc, parts.path, merged, "")) + docker_ep = endpoint_of(self.server.upstreams["docker"]) + trusted = (docker_ep,) if docker_ep else () + self._proxy_direct(url, trusted_endpoints=trusted, mirror="docker") + + def _handle_forward(self) -> None: + try: + parts = urllib.parse.urlsplit(self.path) + if parts.scheme not in ("http", "https") or not parts.hostname: + raise ValueError("bad url") + except ValueError: + self.send_error(400, "Bad proxy URL") + return + headers = self._filtered_client_headers(drop_authorization=False) + self._proxy_direct( + strip_userinfo(self.path), + trusted_endpoints=(), + mirror="forward", + request_headers=headers, + allow_private=bool(self.server.allow_private), + check_allow_hosts=True, + allowed_ports=self.server.proxy_ports, + cache_label="BYPASS", + ) + + def _filtered_client_headers(self, *, drop_authorization: bool) -> Dict[str, str]: + hop = set(HOP_BY_HOP) + connection = self.headers.get("Connection", "") + for token in connection.split(","): + name = token.strip().lower() + if name: + hop.add(name) + out: Dict[str, str] = {} + for key in self.headers: + lowered = key.lower() + if lowered in hop or lowered in {"host", "cookie"} and drop_authorization: + continue + if lowered in hop or lowered == "host": + continue + if drop_authorization and lowered in {"authorization", "cookie"}: + continue + if lowered == "proxy-authorization": + continue + value = self.headers.get(key, "") + if any(ch in value for ch in "\r\n\x00"): + continue + out[key] = value + out["Accept-Encoding"] = "identity" + out["Connection"] = "close" + out.setdefault("User-Agent", f"ClassRelay/{VERSION}") + return out + + def _mirror_headers(self, mirror: str, upstream_url: str) -> Dict[str, str]: + path = urllib.parse.urlsplit(upstream_url).path.lower() + headers = { + "User-Agent": f"ClassRelay/{VERSION}", + "Accept-Encoding": "identity", + "Connection": "close", + "Accept": "*/*", + } + if mirror == "pypi" and "/packages/" not in path: + headers["Accept"] = "application/vnd.pypi.simple.v1+html, text/html;q=0.9" + elif mirror == "npm" and not path.endswith(".tgz"): + headers["Accept"] = "application/vnd.npm.install-v1+json, application/json;q=0.9" + elif mirror == "docker": + accept = self.headers.get("Accept") + if accept and not any(ch in accept for ch in "\r\n"): + headers["Accept"] = accept + auth = self.headers.get("Authorization", "") + if auth.lower().startswith("bearer ") and not any(ch in auth for ch in "\r\n"): + headers["Authorization"] = auth + elif ( + auth.lower().startswith("basic ") + and not any(ch in auth for ch in "\r\n") + and not (self.server.auth and basic_matches(auth, self.server.auth[0], self.server.auth[1])) + ): + headers["Authorization"] = auth + range_header = self.headers.get("Range") + if range_header and not any(ch in range_header for ch in "\r\n"): + headers["Range"] = range_header + return headers + + def _proxy_mirror(self, upstream_url: str, mirror: str, trusted: Optional[Tuple[str, int]]) -> None: + request_headers = self._mirror_headers(mirror, upstream_url) + has_cookie = bool(self.headers.get("Cookie")) + has_range = "Range" in request_headers + request_ok = not has_cookie and not has_range and self.command != "HEAD" + variant = "" + upstream_path = urllib.parse.urlsplit(upstream_url).path + if mirror == "docker" and "/manifests/" in upstream_path: + variant = request_headers.get("Accept", "") + auth_fp = "" + forwarded_auth = request_headers.get("Authorization", "") + if forwarded_auth: + auth_fp = hashlib.sha256(forwarded_auth.encode("utf-8")).hexdigest()[:16] + # Rewritten index HTML/JSON embeds the address the student connected to. + # A second NIC must not reuse that body. Package bytes do not. + lower_path = upstream_path.lower() + base_tag = "" + if mirror in {"pypi", "npm"} and "/packages/" not in lower_path and not lower_path.endswith(ASSET_SUFFIXES): + base_tag = self._origin(with_auth=False)[0] + key = hashlib.sha256( + f"{upstream_url}\n{variant}\n{auth_fp}\n{base_tag}".encode("utf-8") + ).hexdigest() + if request_ok and self._serve_cached(key): return + leader = False + if request_ok: + leader = self.cache.lead(key) + if not leader: + self.cache.follow(key) + if self._serve_cached(key): + return + leader = self.cache.lead(key) + if not leader: + self.cache.follow(key) + if self._serve_cached(key): + return + self.send_error(502, "Upstream fetch failed") + return + released = False + + def release() -> None: + nonlocal released + if leader and not released: + self.cache.finish(key) + released = True - # Fetch from upstream with teacher machine connection try: - req = urllib.request.Request( + self.cache.note_miss() + trusted_eps = (trusted,) if trusted else () + with fetch( upstream_url, - headers={"User-Agent": "ClassRelay-Teacher-Proxy/1.0", "Accept": "*/*"}, + method="HEAD" if self.command == "HEAD" else "GET", + headers=request_headers, + allow_private=bool(self.server.allow_private), + trusted_endpoints=trusted_eps, + ) as resp: + self._deliver_mirror( + resp, + upstream_url=upstream_url, + mirror=mirror, + key=key, + request_ok=request_ok, + release=release, + ) + except PolicyError as exc: + release() + if not getattr(self, "_started", False): + self.send_error(exc.status, exc.reason) + except Exception as exc: + release() + LOG.info("mirror error: %s", redact_text(str(exc))) + if not getattr(self, "_started", False): + self.send_error(502, "Bad Gateway") + finally: + release() + + def _deliver_mirror( + self, + resp: UpstreamResponse, + *, + upstream_url: str, + mirror: str, + key: str, + request_ok: bool, + release: Callable[[], None], + ) -> None: + headers = list(resp.headers) + if mirror == "docker": + relay_base = self._origin(with_auth=False)[0] + headers = [ + ( + name, + rewrite_www_authenticate(value, relay_base=relay_base, secret=self.server.secret) + if name.lower() == "www-authenticate" + else value, + ) + for name, value in headers + ] + if self.command == "HEAD": + release() + self._send_upstream_head(resp.status, resp.reason, headers) + return + content_type = header_value(headers, "content-type") or "" + mode = rewrite_mode(mirror, urllib.parse.urlsplit(upstream_url).path, content_type) + cacheable = response_cacheable(resp.status, headers, request_ok) + if not cacheable: + release() + if mode and resp.status == 200: + body = resp.read_all(MAX_INDEX_BYTES) + relay_base = self._origin(with_auth=False)[0] + upstream_hosts = {urllib.parse.urlsplit(upstream_url).hostname or ""} + if mode == "pypi-html": + body = rewrite_pypi_html( + body, page_url=upstream_url, upstream_hosts=upstream_hosts, relay_base=relay_base + ) + elif mode == "pypi-json": + body = rewrite_pypi_json( + body, page_url=upstream_url, upstream_hosts=upstream_hosts, relay_base=relay_base + ) + elif mode == "npm-json": + body = rewrite_npm_json(body, relay_base=relay_base) + if cacheable: + self._commit_bytes(key, upstream_url, headers, resp.status, body, content_type) + self._send_bytes(resp.status, resp.reason, headers, body, "MISS") + return + self._stream_upstream( + resp, + headers, + cache_key=key if cacheable else None, + upstream_url=upstream_url, + content_type=content_type, + cache_label="MISS", + ) + + def _proxy_direct( + self, + url: str, + *, + trusted_endpoints, + mirror: str, + request_headers: Optional[Dict[str, str]] = None, + allow_private: Optional[bool] = None, + check_allow_hosts: bool = False, + allowed_ports=None, + cache_label: str = "BYPASS", + ) -> None: + headers = request_headers + if headers is None and mirror == "docker": + headers = { + "User-Agent": f"ClassRelay/{VERSION}", + "Accept": self.headers.get("Accept", "*/*"), + "Accept-Encoding": "identity", + "Connection": "close", + } + auth = self.headers.get("Authorization", "") + if auth.lower().startswith(("bearer ", "basic ")) and not any(ch in auth for ch in "\r\n"): + if not (self.server.auth and basic_matches(auth, self.server.auth[0], self.server.auth[1])): + headers["Authorization"] = auth + try: + with fetch( + url, + method="HEAD" if self.command == "HEAD" else "GET", + headers=headers, + allow_private=bool(self.server.allow_private if allow_private is None else allow_private), + trusted_endpoints=trusted_endpoints, + allow_hosts=self.server.allow_hosts, + check_allow_hosts=check_allow_hosts, + allowed_ports=allowed_ports, + ) as resp: + out_headers = list(resp.headers) + if self.command == "HEAD": + self._send_upstream_head(resp.status, resp.reason, out_headers, cache_label=cache_label) + return + self._stream_upstream( + resp, + out_headers, + cache_key=None, + upstream_url=url, + content_type=header_value(out_headers, "content-type") or "", + cache_label=cache_label, + ) + except PolicyError as exc: + if not getattr(self, "_started", False): + self.send_error(exc.status, exc.reason) + except Exception as exc: + LOG.info("proxy error: %s", redact_text(str(exc))) + if not getattr(self, "_started", False): + self.send_error(502, "Bad Gateway") + + def _commit_bytes(self, key, url, headers, status, body: bytes, content_type: str) -> None: + tmp = self.cache.new_temp() + try: + with open(tmp, "wb") as handle: + handle.write(body) + self.cache.commit( + key, + tmp, + url=url, + headers=self._stored_headers(headers), + status=status, + kind=classify_kind(urllib.parse.urlsplit(url).path, content_type), ) - with urllib.request.urlopen(req, timeout=30) as resp: - data = resp.read() - status = resp.getcode() - resp_headers = dict(resp.info()) - - # If PyPI simple index, rewrite URLs to point to teacher mirror if relative - if mirror_type == "pypi" and "text/html" in resp_headers.get("Content-Type", ""): - # Replace relative or direct package URLs to maintain caching - pass + except Exception: + self.cache.abort(tmp) + raise - self.cache.put(upstream_url, data, resp_headers, status) - - self.send_response(status) - for k, v in resp_headers.items(): - if k.lower() not in ["transfer-encoding", "content-length"]: - self.send_header(k, v) - self.send_header("X-ClassRelay-Cache", "MISS") - self.send_header("Content-Length", str(len(data))) - self.end_headers() - self.wfile.write(data) - except urllib.error.HTTPError as e: - try: - self.send_response(e.code) - for k, v in dict(e.headers).items(): - if k.lower() not in ["transfer-encoding", "content-length"]: - self.send_header(k, v) - self.end_headers() - self.wfile.write(e.read()) - except (BrokenPipeError, ConnectionResetError): - pass - except (BrokenPipeError, ConnectionResetError): - pass - except Exception as e: - try: - self.send_error(502, f"Failed to fetch upstream {upstream_url}: {e}") - except (BrokenPipeError, ConnectionResetError): - pass + def _stored_headers(self, headers: Sequence[Tuple[str, str]]) -> List[Tuple[str, str]]: + return [(key, value) for key, value in headers if key.lower() != "set-cookie"] - def _handle_forward_http(self): - """Standard HTTP forward proxy.""" - url = self.path - cached = self.cache.get(url) - if cached: - data, headers, status = cached - self.send_response(status) - for k, v in headers.items(): - self.send_header(k, v) + def _serve_cached(self, key: str) -> bool: + entry = self.cache.lookup(key) + if entry is None: + return False + try: + self.send_response(entry.status, "OK") + self._write_safe_headers(entry.headers, decoded_gzip=False) self.send_header("X-ClassRelay-Cache", "HIT") - self.send_header("Content-Length", str(len(data))) + self.send_header("Content-Length", str(entry.size)) self.end_headers() - self.wfile.write(data) - return + if self.command == "HEAD": + return True + with open(entry.path, "rb") as handle: + while True: + chunk = handle.read(65536) + if not chunk: + break + self.wfile.write(chunk) + self.wfile.flush() + except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError): + self.close_connection = True + return True + + def _send_bytes(self, status: int, reason: str, headers, body: bytes, cache_label: Optional[str]) -> None: + self.send_response(status, ascii_reason(reason)) + self._write_safe_headers(headers, decoded_gzip=True) + if cache_label: + self.send_header("X-ClassRelay-Cache", cache_label) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + if self.command != "HEAD": + try: + self.wfile.write(body) + self.wfile.flush() + except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError): + self.close_connection = True + + def _send_upstream_head(self, status: int, reason: str, headers, cache_label: str = "MISS") -> None: + self.send_response(status, ascii_reason(reason)) + self._write_safe_headers(headers, decoded_gzip=False, keep_length=True) + self.send_header("X-ClassRelay-Cache", cache_label) + self.end_headers() + def _write_safe_headers(self, headers, *, decoded_gzip: bool, keep_length: bool = False) -> None: + skip = set(HOP_BY_HOP) + skip.add("content-length") + if decoded_gzip: + skip.add("content-encoding") + connection = header_value(headers, "connection") or "" + for token in connection.split(","): + name = token.strip().lower() + if name: + skip.add(name) + if keep_length: + skip.discard("content-length") + for key, value in headers: + if key.lower() in skip: + continue + if any(ch in value for ch in "\r\n\x00"): + continue + try: + self.send_header(key, value) + except UnicodeEncodeError: + continue + + def _stream_upstream( + self, + resp: UpstreamResponse, + headers, + *, + cache_key: Optional[str], + upstream_url: str, + content_type: str, + cache_label: str, + ) -> None: + tmp = self.cache.new_temp() if cache_key else None + tmp_handle = open(tmp, "wb") if tmp else None + stored = 0 + keep_store = cache_key is not None + client_ok = True + finished_body = False try: - req = urllib.request.Request(url, headers={k: v for k, v in self.headers.items() if k.lower() != "host"}) - with urllib.request.urlopen(req, timeout=30) as resp: - data = resp.read() - status = resp.getcode() - resp_headers = dict(resp.info()) - - self.cache.put(url, data, resp_headers, status) - - self.send_response(status) - for k, v in resp_headers.items(): - if k.lower() not in ["transfer-encoding", "content-length"]: - self.send_header(k, v) - self.send_header("X-ClassRelay-Cache", "MISS") - self.send_header("Content-Length", str(len(data))) - self.end_headers() - self.wfile.write(data) - except Exception as e: - self.send_error(502, f"Forward proxy error: {e}") + self.send_response(resp.status, ascii_reason(resp.reason)) + self._write_safe_headers(headers, decoded_gzip=True) + self.send_header("X-ClassRelay-Cache", cache_label) + self.send_header("Transfer-Encoding", "chunked") + self.end_headers() + for chunk in resp.iter_body(): + if not chunk: + continue + if keep_store and tmp_handle is not None: + if self.cache.max_bytes >= 0 and stored + len(chunk) > self.cache.max_bytes: + keep_store = False + tmp_handle.close() + tmp_handle = None + self.cache.abort(tmp) + tmp = None + else: + tmp_handle.write(chunk) + stored += len(chunk) + if client_ok and self.command != "HEAD": + try: + self.wfile.write(f"{len(chunk):X}\r\n".encode("ascii")) + self.wfile.write(chunk) + self.wfile.write(b"\r\n") + self.wfile.flush() + except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError): + client_ok = False + self.close_connection = True + if not keep_store: + break + else: + finished_body = True + if client_ok and finished_body and self.command != "HEAD": + try: + self.wfile.write(b"0\r\n\r\n") + self.wfile.flush() + except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError): + self.close_connection = True + finally: + if tmp_handle is not None: + try: + tmp_handle.close() + except OSError: + pass + if finished_body and keep_store and tmp and cache_key and resp.status == 200: + self.cache.commit( + cache_key, + tmp, + url=upstream_url, + headers=self._stored_headers(headers), + status=resp.status, + kind=classify_kind(urllib.parse.urlsplit(upstream_url).path, content_type), + ) + else: + self.cache.abort(tmp) class ThreadedHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer): daemon_threads = True + block_on_close = False allow_reuse_address = True + request_queue_size = 128 + + def __init__( + self, + server_address, + request_handler_class, + cache_dir: str, + auth: Optional[Tuple[str, str]] = None, + upstreams: Optional[Dict[str, str]] = None, + allow_private: bool = False, + allow_hosts: Sequence[str] = (), + connect_ports: Optional[Iterable[int]] = None, + proxy_ports: Optional[Iterable[int]] = None, + max_cache_bytes: int = DEFAULT_MAX_CACHE_BYTES, + index_ttl: int = DEFAULT_INDEX_TTL, + asset_ttl: int = DEFAULT_ASSET_TTL, + max_connections: int = DEFAULT_MAX_CONNECTIONS, + tunnel_idle: int = DEFAULT_TUNNEL_IDLE, + local_ips: Optional[Sequence[str]] = None, + ): + self.auth = auth + self.upstreams = dict(UPSTREAMS) + if upstreams: + self.upstreams.update(upstreams) + self.allow_private = allow_private + self.allow_hosts = tuple(allow_hosts) + self.connect_ports = frozenset(connect_ports or (443,)) + self.proxy_ports = frozenset(proxy_ports or (80, 443)) + self.tunnel_idle = tunnel_idle + self.max_connections = max(1, int(max_connections)) + self.local_ips = list(local_ips) if local_ips is not None else get_local_ips() + self.secret = secrets.token_bytes(32) + self._sem = threading.BoundedSemaphore(self.max_connections) + super().__init__(server_address, request_handler_class) + self.cache = CacheManager(cache_dir, max_cache_bytes, index_ttl, asset_ttl) + + def process_request(self, request, client_address) -> None: + if not self._sem.acquire(blocking=False): + try: + body = b"Too many connections" + message = ( + b"HTTP/1.1 503 Service Unavailable\r\n" + b"Connection: close\r\n" + b"Content-Length: " + str(len(body)).encode("ascii") + b"\r\n" + b"\r\n" + body + ) + request.sendall(message) + except Exception: + pass + request.close() + return + try: + super().process_request(request, client_address) + except Exception: + self._sem.release() + raise - def __init__(self, server_address, RequestHandlerClass, cache_dir: str): - super().__init__(server_address, RequestHandlerClass) - self.cache_manager = CacheManager(cache_dir) - - -def main(): + def process_request_thread(self, request, client_address) -> None: + try: + super().process_request_thread(request, client_address) + finally: + self._sem.release() + + +def _split_multi(values: Optional[Sequence[str]]) -> Tuple[str, ...]: + if not values: + return () + out = [] + for value in values: + for part in value.split(","): + item = part.strip().lower().lstrip(".") + if item: + out.append(item) + return tuple(out) + + +def _validate_auth(raw: str) -> Tuple[str, str]: + if ":" not in raw: + raise SystemExit("--auth 需要 user:password 形式") + user, password = raw.split(":", 1) + if not user or not password: + raise SystemExit("--auth 的用户名和密码都不能为空") + for label, value in (("用户名", user), ("密码", password)): + if any(ch.isspace() or ord(ch) < 32 for ch in value): + raise SystemExit(f"--auth {label}不能包含空白或控制字符") + return user, password + + +def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="ClassRelay - 局域网机房依赖中继与缓存分发服务") parser.add_argument("--port", "-p", type=int, default=DEFAULT_PORT, help=f"监听端口 (默认: {DEFAULT_PORT})") parser.add_argument("--host", "-H", type=str, default="0.0.0.0", help="监听地址 (默认: 0.0.0.0 局域网全绑定)") parser.add_argument("--cache-dir", "-c", type=str, default=DEFAULT_CACHE_DIR, help="本地缓存目录") - args = parser.parse_args() - - ips = get_local_ips() - print("=" * 60) - print(f" ClassRelay v{VERSION} 教师机依赖中继与代理服务已启动") - print("=" * 60) - print(f" Web 控制台与配置入口: http://{ips[0]}:{args.port}/") - print(f" PyPI 镜像中继: http://{ips[0]}:{args.port}/pypi/") - print(f" NPM 镜像中继: http://{ips[0]}:{args.port}/npm/") - print(f" Docker Hub 镜像中继: http://{ips[0]}:{args.port}/v2/") - print(f" PlatformIO 镜像中继: http://{ips[0]}:{args.port}/platformio/") - print(f" HTTP/HTTPS 代理隧道: http://{ips[0]}:{args.port}") - print(f" 本地缓存目录: {args.cache_dir}") - print("=" * 60) - print(" 学生机一键配置命令 (Windows PowerShell):") - print(f" irm http://{ips[0]}:{args.port}/setup/windows | iex") - print(" 学生机一键配置命令 (Linux/macOS Bash):") - print(f" curl -fsSL http://{ips[0]}:{args.port}/setup/bash | bash") - print("=" * 60) - + parser.add_argument( + "--auth", + default=None, + help="可选 HTTP Basic 认证,形式 user:password。pip/npm 使用 http://user:pass@教师机:端口 。默认关闭。", + ) + parser.add_argument( + "--max-cache-mb", + type=int, + default=DEFAULT_MAX_CACHE_BYTES // (1024 * 1024), + help="缓存上限(MB)。0 表示不落盘。默认 1024。", + ) + parser.add_argument("--index-ttl", type=int, default=DEFAULT_INDEX_TTL, help="索引/元数据缓存秒数,默认 600。") + parser.add_argument("--asset-ttl", type=int, default=DEFAULT_ASSET_TTL, help="安装包缓存秒数,默认 7 天。") + parser.add_argument( + "--allow-private", + action="store_true", + help="允许代理访问本机和内网地址。默认关闭。仅在你清楚风险时使用。", + ) + parser.add_argument( + "--allow-host", + action="append", + default=None, + help="限制 CONNECT/正向代理的目标域名后缀,可重复。默认不限制公网域名。", + ) + parser.add_argument("--connect-port", action="append", type=int, default=None, help="额外允许的 CONNECT 端口。默认仅 443。") + parser.add_argument("--proxy-port", action="append", type=int, default=None, help="额外允许的明文代理端口。默认 80 和 443。") + parser.add_argument("--max-connections", type=int, default=DEFAULT_MAX_CONNECTIONS, help="同时处理的连接数上限,默认 64。") + parser.add_argument("--tunnel-idle", type=int, default=DEFAULT_TUNNEL_IDLE, help="CONNECT 隧道空闲秒数,默认 600。0 表示不限制。") + return parser + + +def main(argv: Optional[Sequence[str]] = None) -> None: + configure_logging() + parser = build_parser() + args = parser.parse_args(argv) + auth = _validate_auth(args.auth) if args.auth else None + connect_ports = {443} + if args.connect_port: + connect_ports.update(args.connect_port) + proxy_ports = {80, 443} + if args.proxy_port: + proxy_ports.update(args.proxy_port) + local_ips = get_local_ips() + print("=" * 60, flush=True) + print(f" ClassRelay v{VERSION} 教师机依赖中继与代理服务已启动", flush=True) + print("=" * 60, flush=True) + print(f" Web 控制台与配置入口: http://{local_ips[0]}:{args.port}/", flush=True) + print(f" PyPI 镜像中继: http://{local_ips[0]}:{args.port}/pypi/", flush=True) + print(f" NPM 镜像中继: http://{local_ips[0]}:{args.port}/npm/", flush=True) + print(f" Docker Hub 镜像中继: http://{local_ips[0]}:{args.port}/v2/ (实验性)", flush=True) + print(f" PlatformIO 镜像中继: http://{local_ips[0]}:{args.port}/platformio/", flush=True) + print(f" HTTP/HTTPS 代理隧道: http://{local_ips[0]}:{args.port}", flush=True) + print(f" 本地缓存目录: {args.cache_dir}", flush=True) + print(" 代理默认拒绝本机、内网、链路本地和非常用端口。", flush=True) + if auth: + print(f" HTTP Basic 认证已开启,用户名 {auth[0]}。密码不会写在这里。", flush=True) + else: + print(" 未开启认证。课堂外网可控时建议加上 --auth user:password。", flush=True) + print("=" * 60, flush=True) + print(" 学生机一键配置命令 (Windows PowerShell):", flush=True) + print(f" irm http://{local_ips[0]}:{args.port}/setup/windows | iex", flush=True) + print(" 学生机一键配置命令 (Linux/macOS Bash):", flush=True) + print(f" curl -fsSL http://{local_ips[0]}:{args.port}/setup/bash | bash", flush=True) + print(" 更稳妥的做法是先下载脚本、核对控制台上的 SHA256,再执行。", flush=True) + print("=" * 60, flush=True) try: - server = ThreadedHTTPServer((args.host, args.port), ClassRelayHandler, cache_dir=args.cache_dir) - except OSError as e: - print(f"\n[错误] 无法监听 {args.host}:{args.port} —— {e}") - if "in use" in str(e).lower() or e.errno in (98, 10048): - print(" 端口已被其他程序占用(常见:上次未完全退出、Tomcat / 其他代理软件占用了 8080)。") - print(" 解决办法二选一:") - print(f" 1) 换一个端口启动:python classrelay.py --port 8081") - print(f" 2) 关闭占用 {args.port} 端口的程序后重试。") - print(" 提示:本服务已开启地址复用,关闭后通常需等待几十秒才能重新绑定。") + server = ThreadedHTTPServer( + (args.host, args.port), + ClassRelayHandler, + cache_dir=args.cache_dir, + auth=auth, + allow_private=args.allow_private, + allow_hosts=_split_multi(args.allow_host), + connect_ports=connect_ports, + proxy_ports=proxy_ports, + max_cache_bytes=args.max_cache_mb * 1024 * 1024, + index_ttl=args.index_ttl, + asset_ttl=args.asset_ttl, + max_connections=args.max_connections, + tunnel_idle=args.tunnel_idle, + local_ips=local_ips, + ) + except OSError as exc: + print(f"\n[错误] 无法监听 {args.host}:{args.port} —— {exc}", flush=True) + if "in use" in str(exc).lower() or getattr(exc, "errno", None) in (98, 10048): + print(" 端口已被其他程序占用(常见:上次未完全退出、Tomcat / 其他代理软件占用了 8080)。", flush=True) + print(" 解决办法二选一:", flush=True) + print(" 1) 换一个端口启动:python classrelay.py --port 8081", flush=True) + print(f" 2) 关闭占用 {args.port} 端口的程序后重试。", flush=True) else: - print(" 请检查端口是否被防火墙/权限限制,或改用其他端口:python classrelay.py --port 8081") + print(" 请检查端口是否被防火墙/权限限制,或改用其他端口:python classrelay.py --port 8081", flush=True) sys.exit(1) - try: server.serve_forever() except KeyboardInterrupt: - print("\n正在停止 ClassRelay...") + print("\n正在停止 ClassRelay...", flush=True) server.shutdown() + server.server_close() if __name__ == "__main__": diff --git a/start.bat b/start.bat index c2afe01..77a8df0 100644 --- a/start.bat +++ b/start.bat @@ -26,7 +26,7 @@ if not defined PYCMD ( ) echo 使用 Python 命令: %PYCMD% -%PYCMD% classrelay.py %* +%PYCMD% -u classrelay.py %* if %errorlevel% neq 0 ( pause ) diff --git a/start.sh b/start.sh index 18a4b07..f20364a 100755 --- a/start.sh +++ b/start.sh @@ -9,4 +9,4 @@ if ! command -v python3 &> /dev/null; then exit 1 fi -python3 classrelay.py "$@" +python3 -u classrelay.py "$@" diff --git a/test_relay.py b/test_relay.py deleted file mode 100644 index 727f21f..0000000 --- a/test_relay.py +++ /dev/null @@ -1,111 +0,0 @@ -""" -Self-check and verification test for ClassRelay. -Validates Web Dashboard, status API, setup scripts, PyPI mirror, NPM mirror, and HTTP CONNECT forwarding. -""" - -import threading -import time -import urllib.request -import urllib.parse -import urllib.error -import json -import os -import shutil -import socket -from classrelay import ThreadedHTTPServer, ClassRelayHandler - -TEST_PORT = 18989 -TEST_CACHE_DIR = "/tmp/classrelay_test_cache" - - -def run_tests(): - if os.path.exists(TEST_CACHE_DIR): - shutil.rmtree(TEST_CACHE_DIR) - - server = ThreadedHTTPServer(("127.0.0.1", TEST_PORT), ClassRelayHandler, cache_dir=TEST_CACHE_DIR) - t = threading.Thread(target=server.serve_forever, daemon=True) - t.start() - time.sleep(0.5) - - base_url = f"http://127.0.0.1:{TEST_PORT}" - - print("[1] Test Web Dashboard...") - with urllib.request.urlopen(f"{base_url}/") as resp: - assert resp.status == 200 - html = resp.read().decode("utf-8") - assert "ClassRelay" in html - assert "irm http://" in html - print(" -> Dashboard OK") - - print("[2] Test API Status...") - with urllib.request.urlopen(f"{base_url}/api/status") as resp: - assert resp.status == 200 - data = json.loads(resp.read().decode("utf-8")) - assert "hits" in data - assert "misses" in data - print(" -> API Status OK") - - print("[3] Test Setup Scripts...") - for script_name in ["windows", "unwindows", "bash", "unbash"]: - with urllib.request.urlopen(f"{base_url}/setup/{script_name}") as resp: - assert resp.status == 200 - content = resp.read().decode("utf-8") - assert len(content) > 10 - print(" -> Setup Scripts OK") - - print("[4] Test PyPI Mirror Proxy & Cache...") - pypi_url = f"{base_url}/pypi/requests/" - with urllib.request.urlopen(pypi_url) as resp: - assert resp.status == 200 - assert resp.headers.get("X-ClassRelay-Cache") == "MISS" - content = resp.read() - assert len(content) > 0 - - # Repeat to test cache hit - with urllib.request.urlopen(pypi_url) as resp: - assert resp.status == 200 - assert resp.headers.get("X-ClassRelay-Cache") == "HIT" - print(" -> PyPI Proxy & Cache OK") - - print("[5] Test NPM Mirror Proxy & Cache...") - npm_url = f"{base_url}/npm/vue" - with urllib.request.urlopen(npm_url) as resp: - assert resp.status == 200 - assert resp.headers.get("X-ClassRelay-Cache") == "MISS" - data = json.loads(resp.read().decode("utf-8")) - assert "name" in data and data["name"] == "vue" - - with urllib.request.urlopen(npm_url) as resp: - assert resp.status == 200 - assert resp.headers.get("X-ClassRelay-Cache") == "HIT" - print(" -> NPM Proxy & Cache OK") - - print("[6] Test Docker Hub Mirror Endpoint...") - docker_v2_url = f"{base_url}/v2/" - try: - urllib.request.urlopen(docker_v2_url) - except urllib.error.HTTPError as e: - # Docker Registry v2 standard returns 401 Unauthorized with Www-Authenticate header - assert e.code == 401 - assert "Bearer" in e.headers.get("www-authenticate", "") or "realm" in e.headers.get("www-authenticate", "") - print(" -> Docker Hub Proxy Endpoint OK") - - print("[7] Test CONNECT Tunneling Proxy...") - # Test raw CONNECT handshake - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - s.connect(("127.0.0.1", TEST_PORT)) - connect_cmd = b"CONNECT pypi.org:443 HTTP/1.1\r\nHost: pypi.org:443\r\n\r\n" - s.sendall(connect_cmd) - resp_line = s.recv(1024) - assert b"200 Connection Established" in resp_line - s.close() - print(" -> CONNECT Tunneling OK") - - server.shutdown() - if os.path.exists(TEST_CACHE_DIR): - shutil.rmtree(TEST_CACHE_DIR) - print("\nALL VERIFICATION TESTS PASSED SUCCESSFULLY.") - - -if __name__ == "__main__": - run_tests() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5801bea --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,4 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) diff --git a/tests/test_relay.py b/tests/test_relay.py new file mode 100644 index 0000000..e6a0298 --- /dev/null +++ b/tests/test_relay.py @@ -0,0 +1,1242 @@ +"""Offline tests for ClassRelay. Upstream services are local fakes.""" + +import base64 +import hashlib +import io +import json +import logging +import os +import shutil +import socket +import socketserver +import subprocess +import sys +import tarfile +import tempfile +import threading +import time +import urllib.error +import urllib.parse +import urllib.request +import zipfile +from pathlib import Path + +import pytest + +import classrelay + + +class UpstreamHandler(classrelay.http.server.BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, fmt, *args): + return + + def do_GET(self): + self._respond(head=False) + + def do_HEAD(self): + self._respond(head=True) + + def _respond(self, head: bool) -> None: + server = self.server + with server.lock: + server.hits[self.path] = server.hits.get(self.path, 0) + 1 + server.seen.append( + ( + self.command, + self.path, + {key.lower(): value for key, value in self.headers.items()}, + ) + ) + status, headers, body = server.router(self) + body = body or b"" + self.send_response(status) + for key, value in headers: + self.send_header(key, value) + if not any(key.lower() == "content-length" for key, _value in headers): + self.send_header("Content-Length", str(len(body))) + self.end_headers() + if not head: + self.wfile.write(body) + + +class ThreadedServer(socketserver.ThreadingMixIn, classrelay.http.server.HTTPServer): + daemon_threads = True + block_on_close = False + allow_reuse_address = True + + +@pytest.fixture +def cleanup(): + closers = [] + yield closers + for close in reversed(closers): + try: + close() + except Exception: + pass + + +def serve_forever(httpd, cleanup): + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + cleanup.append(lambda: (httpd.shutdown(), httpd.server_close())) + return httpd + + +def start_upstream(router, cleanup): + httpd = ThreadedServer(("127.0.0.1", 0), UpstreamHandler) + httpd.router = router + httpd.hits = {} + httpd.seen = [] + httpd.lock = threading.Lock() + return serve_forever(httpd, cleanup) + + +def start_relay(tmp_path, cleanup, upstreams=None, **kwargs): + cache = tmp_path / f"cache-{time.time_ns()}" + kwargs.setdefault("local_ips", ["127.0.0.1"]) + httpd = classrelay.ThreadedHTTPServer( + ("127.0.0.1", 0), + classrelay.ClassRelayHandler, + cache_dir=str(cache), + upstreams=upstreams, + **kwargs, + ) + return serve_forever(httpd, cleanup) + + +def port_of(httpd) -> int: + return int(httpd.server_address[1]) + + +def fetch(url, headers=None, method="GET", timeout=10): + request = urllib.request.Request(url, headers=headers or {}, method=method) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + return response.status, response.headers, response.read() + except urllib.error.HTTPError as exc: + return exc.code, exc.headers, exc.read() + + +def basic(user, password) -> str: + token = base64.b64encode(f"{user}:{password}".encode()).decode("ascii") + return f"Basic {token}" + + +def connect_status(relay_port: int, payload: bytes, timeout=3): + sock = socket.create_connection(("127.0.0.1", relay_port), timeout=timeout) + sock.settimeout(timeout) + sock.sendall(payload) + data = b"" + while b"\r\n\r\n" not in data: + chunk = sock.recv(4096) + if not chunk: + break + data += chunk + line = data.split(b"\r\n", 1)[0].decode("iso-8859-1", "replace") + code = int(line.split(" ", 2)[1]) + return code, data, sock + + +def make_wheel() -> bytes: + files = { + "classrelay_local_demo/__init__.py": b'MARKER = "classrelay-local-demo-ok"\n', + "classrelay_local_demo-1.0.0.dist-info/METADATA": ( + "Metadata-Version: 2.1\n" + "Name: classrelay-local-demo\n" + "Version: 1.0.0\n" + "Summary: ClassRelay offline test package\n" + ).encode(), + "classrelay_local_demo-1.0.0.dist-info/WHEEL": ( + "Wheel-Version: 1.0\n" + "Generator: classrelay-test\n" + "Root-Is-Purelib: true\n" + "Tag: py3-none-any\n" + ).encode(), + } + lines = [] + for path, data in files.items(): + digest = base64.urlsafe_b64encode(hashlib.sha256(data).digest()).decode().rstrip("=") + lines.append(f"{path},sha256={digest},{len(data)}") + lines.append("classrelay_local_demo-1.0.0.dist-info/RECORD,,") + files["classrelay_local_demo-1.0.0.dist-info/RECORD"] = ("\n".join(lines) + "\n").encode() + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for path, data in files.items(): + archive.writestr(path, data) + return buffer.getvalue() + + +def make_npm_tarball() -> bytes: + package = {"name": "classrelay-local-pkg", "version": "1.0.0", "main": "index.js"} + index = 'module.exports = "classrelay-local-pkg-ok";\n' + raw = io.BytesIO() + with tarfile.open(fileobj=raw, mode="w") as archive: + for name, data in ( + ("package/package.json", json.dumps(package).encode()), + ("package/index.js", index.encode()), + ): + info = tarfile.TarInfo(name) + info.size = len(data) + info.mtime = 0 + archive.addfile(info, io.BytesIO(data)) + import gzip + + compressed = io.BytesIO() + with gzip.GzipFile(fileobj=compressed, mode="wb", mtime=0) as handle: + handle.write(raw.getvalue()) + return compressed.getvalue() + + +def simple_html(wheel: bytes, filename: str) -> bytes: + digest = hashlib.sha256(wheel).hexdigest() + href = f"../../packages/ab/cd/{filename}#sha256={digest}" + page = ( + "" + '' + "" + "

Links for classrelay-local-demo

" + f'{filename}' + "" + ) + return page.encode() + + +def npm_packument(tarball: bytes) -> bytes: + sha1 = hashlib.sha1(tarball).hexdigest() + integrity = "sha512-" + base64.b64encode(hashlib.sha512(tarball).digest()).decode() + payload = { + "name": "classrelay-local-pkg", + "dist-tags": {"latest": "1.0.0"}, + "versions": { + "1.0.0": { + "name": "classrelay-local-pkg", + "version": "1.0.0", + "dist": { + "tarball": "https://registry.npmmirror.com/classrelay-local-pkg/-/classrelay-local-pkg-1.0.0.tgz", + "shasum": sha1, + "integrity": integrity, + }, + } + }, + } + return json.dumps(payload).encode() + + +# --- policy and rewriters ------------------------------------------------- + + +@pytest.mark.parametrize( + "ip", + [ + "127.0.0.1", + "10.1.2.3", + "172.16.0.1", + "192.168.1.1", + "169.254.169.254", + "0.0.0.0", + "100.64.1.1", + "192.0.2.1", + "224.0.0.1", + "255.255.255.255", + "::1", + "fe80::1", + "fc00::1", + "2001:db8::1", + "::ffff:127.0.0.1", + ], +) +def test_blocked_addresses(ip): + assert classrelay.is_blocked_ip(ip) + + +@pytest.mark.parametrize("ip", ["8.8.8.8", "1.1.1.1", "2001:4860:4860::8888"]) +def test_public_addresses_allowed(ip): + assert not classrelay.is_blocked_ip(ip) + + +def test_decimal_and_odd_literals_resolve_to_loopback(): + for host in ("2130706433", "0x7f000001", "127.1"): + infos = classrelay.resolve_destination(host, 80, private_ok=True) + ips = {item[4][0].split("%", 1)[0] for item in infos} + assert "127.0.0.1" in ips + with pytest.raises(classrelay.PolicyError) as raised: + classrelay.resolve_destination(host, 80, private_ok=False) + assert raised.value.status == 403 + + +def test_mixed_dns_answers_are_blocked(monkeypatch): + def fake(host, port): + return [ + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("8.8.8.8", port)), + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", port)), + ] + + monkeypatch.setattr(classrelay, "resolve_addresses", fake) + with pytest.raises(classrelay.PolicyError) as raised: + classrelay.resolve_destination("example.com", 443, private_ok=False) + assert raised.value.status == 403 + + +def test_public_resolution_is_allowed(monkeypatch): + def fake(host, port): + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("8.8.8.8", port))] + + monkeypatch.setattr(classrelay, "resolve_addresses", fake) + infos = classrelay.resolve_destination("pypi.org", 443, private_ok=False) + assert infos[0][4][0] == "8.8.8.8" + + +def test_pypi_relative_href_becomes_absolute(): + page = "https://pypi.example/simple/demo/" + html = b'demo' + rewritten = classrelay.rewrite_pypi_html( + html, page_url=page, upstream_hosts={"pypi.example"}, relay_base="http://10.0.0.8:8080" + ).decode() + assert 'href="http://10.0.0.8:8080/pypi/packages/ab/cd/demo-1.0.0-py3-none-any.whl#sha256=abc"' in rewritten + + +def test_npm_tarball_becomes_absolute(): + body = json.dumps({"versions": {"1.0.0": {"dist": {"tarball": "https://registry.npmmirror.com/left-pad/-/left-pad-1.0.0.tgz"}}}}).encode() + rewritten = json.loads(classrelay.rewrite_npm_json(body, relay_base="http://10.0.0.8:8080")) + assert rewritten["versions"]["1.0.0"]["dist"]["tarball"] == "http://10.0.0.8:8080/npm/left-pad/-/left-pad-1.0.0.tgz" + + +def test_seal_roundtrip_and_reject_forged(): + secret = b"s" * 32 + token = classrelay.seal_url("https://auth.example/token", secret) + assert classrelay.unseal_url(token, secret) == "https://auth.example/token" + forged = token[:-1] + ("A" if token[-1] != "A" else "B") + assert classrelay.unseal_url(forged, secret) is None + + +def test_query_token_is_stripped(): + assert "token" not in classrelay.sanitize_query("token=secret&service=reg") + assert "service=reg" in classrelay.sanitize_query("token=secret&service=reg") + + +def test_basic_compare_rejects_mismatch(): + good = basic("teacher", "s3cret") + assert classrelay.basic_matches(good, "teacher", "s3cret") + assert not classrelay.basic_matches(basic("teacher", "nope"), "teacher", "s3cret") + assert not classrelay.basic_matches("Bearer abc", "teacher", "s3cret") + + +def test_redact_userinfo(): + assert "s3cret" not in classrelay.redact_text('GET http://teacher:s3cret@127.0.0.1/ HTTP/1.1') + + +def test_setup_scripts_do_not_disable_strict_ssl(): + scripts = classrelay.render_setup_scripts("http://10.0.0.5:8080", "10.0.0.5") + assert "strict-ssl false" not in scripts["bash"] + assert "strict-ssl false" not in scripts["windows"] + assert "npm config delete strict-ssl" in scripts["unbash"] + assert "npm config delete strict-ssl" in scripts["unwindows"] + assert "# >>> ClassRelay >>>" in scripts["bash"] + assert "source ~/.bashrc" in scripts["bash"] + assert ".bashrc" in scripts["unbash"] + + +def test_cache_ttl_size_and_partial_file(tmp_path): + cache = classrelay.CacheManager(str(tmp_path), max_bytes=1000, index_ttl=30, asset_ttl=30) + tmp = cache.new_temp() + with open(tmp, "wb") as handle: + handle.write(b"hello") + assert cache.commit( + "idx", + tmp, + url="http://example/simple/demo/", + headers=[("Content-Type", "text/html"), ("Set-Cookie", "a=b")], + status=200, + kind="index", + ) + entry = cache.lookup("idx") + assert entry is not None + assert entry.size == 5 + assert all(key.lower() != "set-cookie" for key, _value in entry.headers) + meta_path = tmp_path / "meta" / "idx.json" + meta = json.loads(meta_path.read_text(encoding="utf-8")) + meta["timestamp"] = time.time() - 999 + meta_path.write_text(json.dumps(meta), encoding="utf-8") + assert cache.lookup("idx") is None + meta["timestamp"] = time.time() + meta["size"] = 999 + meta_path.write_text(json.dumps(meta), encoding="utf-8") + assert cache.lookup("idx") is None + + def store(key, size): + path = cache.new_temp() + with open(path, "wb") as handle: + handle.write(b"z" * size) + cache.commit( + key, + path, + url="http://example/" + key, + headers=[("Content-Type", "application/octet-stream")], + status=200, + kind="asset", + ) + + store("a", 700) + store("b", 700) + assert cache.stats["cached_bytes"] == 700 + assert "a" not in cache.index + assert "b" in cache.index + store("b", 200) + assert cache.stats["cached_bytes"] == 200 + + +# --- HTTP behaviour ------------------------------------------------------- + + +def test_error_responses_keep_the_connection(tmp_path, cleanup): + relay = start_relay(tmp_path, cleanup, upstreams={"pypi": "http://127.0.0.1:1/simple"}) + base = f"http://127.0.0.1:{port_of(relay)}" + status, _headers, body = fetch(base + "/nope") + assert status == 404 + assert b"404" in body + status, _headers, body = fetch(base + "/", method="POST") + assert status == 501 + assert body + status, _headers, body = fetch(base + "/pypi/missing/") + assert status == 502 + assert body + + +def test_connect_policy_and_malformed_targets(tmp_path, cleanup, monkeypatch): + def fail_dns(host, port): + raise AssertionError(f"DNS should not run for {host}") + + monkeypatch.setattr(classrelay, "resolve_addresses", fail_dns) + relay = start_relay(tmp_path, cleanup, allow_hosts=("pypi.org",)) + relay_port = port_of(relay) + code, data, sock = connect_status(relay_port, b"CONNECT example.com:22 HTTP/1.1\r\nHost: example.com:22\r\n\r\n") + sock.close() + assert code == 403 + assert b"Port not allowed" in data + code, _data, sock = connect_status(relay_port, b"CONNECT other.example:443 HTTP/1.1\r\nHost: other.example:443\r\n\r\n") + sock.close() + assert code == 403 + + monkeypatch.setattr(classrelay, "resolve_addresses", classrelay.socket.getaddrinfo) + relay2 = start_relay(tmp_path, cleanup) + relay_port = port_of(relay2) + for payload in ( + b"CONNECT host:abc HTTP/1.1\r\nHost: host:abc\r\n\r\n", + b"CONNECT just-a-host HTTP/1.1\r\nHost: just-a-host\r\n\r\n", + b"CONNECT 127.0.0.1:443 HTTP/1.1\r\nHost: 127.0.0.1:443\r\n\r\n", + b"CONNECT [::1]:443 HTTP/1.1\r\nHost: [::1]:443\r\n\r\n", + b"CONNECT 10.1.2.3:443 HTTP/1.1\r\nHost: 10.1.2.3:443\r\n\r\n", + b"CONNECT 169.254.169.254:443 HTTP/1.1\r\nHost: 169.254.169.254:443\r\n\r\n", + b"CONNECT 2130706433:443 HTTP/1.1\r\nHost: 2130706433:443\r\n\r\n", + ): + code, data, sock = connect_status(relay_port, payload) + sock.close() + assert code in (400, 403), payload + assert data.startswith(b"HTTP/1.1") + + status, _headers, body = fetch(f"http://127.0.0.1:{relay_port}", headers={}, method="GET") + # absolute-form is what a proxy client sends; a normal GET is the dashboard. + assert status == 200 + proxy = urllib.request.ProxyHandler({"http": f"http://127.0.0.1:{relay_port}"}) + opener = urllib.request.build_opener(proxy) + request = urllib.request.Request("http://10.1.2.3/") + with pytest.raises(urllib.error.HTTPError) as raised: + opener.open(request, timeout=5) + assert raised.value.code == 403 + request = urllib.request.Request("http://example.com:8080/") + with pytest.raises(urllib.error.HTTPError) as raised: + opener.open(request, timeout=5) + assert raised.value.code == 403 + + +def test_large_connect_transfer(tmp_path, cleanup): + payload = os.urandom(2 * 1024 * 1024) + received = {} + + def accept_loop(listener): + try: + conn, _addr = listener.accept() + except OSError: + return + try: + conn.settimeout(10) + # Pause so the proxy has to block in sendall instead of failing a non-blocking write. + time.sleep(0.3) + data = b"" + while len(data) < len(payload): + chunk = conn.recv(65536) + if not chunk: + break + data += chunk + received["data"] = data + conn.sendall(hashlib.sha256(data).digest()) + finally: + conn.close() + + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + listener.settimeout(5) + cleanup.append(listener.close) + threading.Thread(target=accept_loop, args=(listener,), daemon=True).start() + target_port = listener.getsockname()[1] + relay = start_relay( + tmp_path, + cleanup, + allow_private=True, + tunnel_idle=30, + connect_ports={443, target_port}, + ) + code, _data, sock = connect_status( + port_of(relay), + f"CONNECT 127.0.0.1:{target_port} HTTP/1.1\r\nHost: 127.0.0.1:{target_port}\r\n\r\n".encode(), + ) + assert code == 200 + sock.settimeout(10) + sock.sendall(payload) + digest = b"" + while len(digest) < 32: + chunk = sock.recv(32 - len(digest)) + if not chunk: + break + digest += chunk + sock.close() + assert received["data"] == payload + assert digest == hashlib.sha256(payload).digest() + + +def test_tunnel_idle_closes(tmp_path, cleanup): + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + listener.settimeout(5) + cleanup.append(listener.close) + + def accept_one(): + conn, _addr = listener.accept() + conn.settimeout(5) + try: + while conn.recv(1024): + pass + except OSError: + pass + finally: + conn.close() + + threading.Thread(target=accept_one, daemon=True).start() + relay = start_relay( + tmp_path, + cleanup, + allow_private=True, + tunnel_idle=1, + connect_ports={443, listener.getsockname()[1]}, + ) + code, _data, sock = connect_status( + port_of(relay), + f"CONNECT 127.0.0.1:{listener.getsockname()[1]} HTTP/1.1\r\n\r\n".encode(), + timeout=3, + ) + assert code == 200 + sock.settimeout(4) + assert sock.recv(16) == b"" + sock.close() + + +def test_auth_covers_origin_and_connect(tmp_path, cleanup): + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + listener.settimeout(5) + cleanup.append(listener.close) + hits = {"n": 0} + + def accept_one(): + try: + conn, _addr = listener.accept() + hits["n"] += 1 + conn.close() + except OSError: + pass + + relay = start_relay( + tmp_path, + cleanup, + auth=("teacher", "s3cret"), + allow_private=True, + connect_ports={443, listener.getsockname()[1], 9}, + ) + relay_port = port_of(relay) + base = f"http://127.0.0.1:{relay_port}" + status, headers, _body = fetch(base + "/pypi/demo/") + assert status == 401 + assert "basic" in headers.get("WWW-Authenticate", "").lower() + code, data, sock = connect_status(relay_port, b"CONNECT 127.0.0.1:9 HTTP/1.1\r\n\r\n") + sock.close() + assert code == 407 + assert b"Proxy-Authenticate" in data + status, _headers, _body = fetch(base + "/", headers={"Authorization": basic("teacher", "nope")}) + assert status == 401 + status, _headers, body = fetch(base + "/setup/bash", headers={"Authorization": basic("teacher", "s3cret")}) + assert status == 200 + assert b"teacher:s3cret@" in body or b"teacher:s3cret" in body + threading.Thread(target=accept_one, daemon=True).start() + time.sleep(0.05) + code, _data, sock = connect_status( + relay_port, + ( + "CONNECT 127.0.0.1:%d HTTP/1.1\r\nProxy-Authorization: %s\r\n\r\n" + % (listener.getsockname()[1], basic("teacher", "s3cret")) + ).encode(), + ) + assert code == 200 + sock.close() + deadline = time.time() + 2 + while hits["n"] == 0 and time.time() < deadline: + time.sleep(0.02) + assert hits["n"] == 1 + + +def test_password_not_logged_or_forwarded(tmp_path, cleanup): + seen = {} + + def router(handler): + seen["path"] = handler.path + seen["headers"] = {key.lower(): value for key, value in handler.headers.items()} + return 200, [("Content-Type", "text/html; charset=utf-8")], b"ok" + + upstream = start_upstream(router, cleanup) + relay = start_relay( + tmp_path, + cleanup, + upstreams={"pypi": f"http://127.0.0.1:{port_of(upstream)}/simple"}, + auth=("teacher", "s3cret"), + ) + stream = io.StringIO() + log_handler = logging.StreamHandler(stream) + classrelay.LOG.addHandler(log_handler) + try: + status, _headers, body = fetch( + f"http://127.0.0.1:{port_of(relay)}/pypi/demo/?token=s3cret", + headers={ + "Authorization": basic("teacher", "s3cret"), + "Cookie": "session=s3cret", + "Proxy-Connection": "Keep-Alive", + }, + ) + finally: + classrelay.LOG.removeHandler(log_handler) + assert status == 200 + assert b"ok" in body + assert "token" not in seen["path"] + blob = " ".join(seen["headers"].values()).lower() + assert "s3cret" not in blob + assert "cookie" not in seen["headers"] + assert "proxy-connection" not in seen["headers"] + assert "s3cret" not in stream.getvalue() + + +def test_forward_proxy_does_not_share_cookies(tmp_path, cleanup): + def router(handler): + cookie = handler.headers.get("Cookie", "") + who = "alice" if "alice" in cookie else "anon" + body = f"secret-for:{who}".encode() + return 200, [("Content-Type", "text/plain"), ("Set-Cookie", f"sess={who}")], body + + upstream = start_upstream(router, cleanup) + relay = start_relay( + tmp_path, + cleanup, + allow_private=True, + proxy_ports={80, 443, port_of(upstream)}, + ) + proxy = urllib.request.ProxyHandler({"http": f"http://127.0.0.1:{port_of(relay)}"}) + opener = urllib.request.build_opener(proxy) + url = f"http://127.0.0.1:{port_of(upstream)}/me" + first = opener.open(urllib.request.Request(url, headers={"Cookie": "user=alice", "Proxy-Connection": "Keep-Alive"}), timeout=5) + first_body = first.read() + first_set_cookie = first.headers.get("Set-Cookie") + first_cache = first.headers.get("X-ClassRelay-Cache") + second = opener.open(urllib.request.Request(url), timeout=5) + second_body = second.read() + assert first_body == b"secret-for:alice" + assert second_body == b"secret-for:anon" + assert "alice" in (first_set_cookie or "") + assert "alice" not in (second.headers.get("Set-Cookie") or "") + assert first_cache == "BYPASS" + assert second.headers.get("X-ClassRelay-Cache") == "BYPASS" + assert upstream.hits[ "/me"] == 2 + forwarded = " ".join(value.lower() for _path, _count in [] ) + assert all("proxy-connection" not in item[2] for item in upstream.seen) + assert any("cookie" in item[2] for item in upstream.seen) + _ = forwarded + + +def test_redirect_to_loopback_is_blocked(tmp_path, cleanup): + secret_hits = {"n": 0} + secret = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + secret.bind(("127.0.0.1", 0)) + secret.listen(5) + secret.settimeout(0.2) + cleanup.append(secret.close) + + def sniff(): + while True: + try: + conn, _addr = secret.accept() + except OSError: + return + secret_hits["n"] += 1 + conn.close() + + threading.Thread(target=sniff, daemon=True).start() + + def router(handler): + location = f"http://127.0.0.1:{secret.getsockname()[1]}/secret" + return 302, [("Location", location)], b"" + + upstream = start_upstream(router, cleanup) + relay = start_relay( + tmp_path, + cleanup, + upstreams={"pypi": f"http://127.0.0.1:{port_of(upstream)}/simple"}, + ) + status, _headers, _body = fetch(f"http://127.0.0.1:{port_of(relay)}/pypi/packages/ab/cd/demo.whl") + assert status == 403 + time.sleep(0.3) + assert secret_hits["n"] == 0 + + +def test_chunked_upstream_and_cache(tmp_path, cleanup): + body = b"chunked-payload-xyz" + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind(("127.0.0.1", 0)) + server.listen(16) + server.settimeout(5) + cleanup.append(server.close) + hits = {"n": 0} + + def loop(): + while True: + try: + conn, _addr = server.accept() + except OSError: + return + threading.Thread(target=handle, args=(conn,), daemon=True).start() + + def handle(conn): + try: + data = b"" + conn.settimeout(5) + while b"\r\n\r\n" not in data: + chunk = conn.recv(4096) + if not chunk: + return + data += chunk + hits["n"] += 1 + parts = [body[index : index + 5] for index in range(0, len(body), 5)] + conn.sendall( + b"HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n" + ) + for part in parts: + conn.sendall(f"{len(part):X}\r\n".encode() + part + b"\r\n") + conn.sendall(b"0\r\n\r\n") + finally: + conn.close() + + threading.Thread(target=loop, daemon=True).start() + relay = start_relay( + tmp_path, + cleanup, + upstreams={"espressif": f"http://127.0.0.1:{server.getsockname()[1]}"}, + ) + url = f"http://127.0.0.1:{port_of(relay)}/espressif/toolchain.bin" + status, headers, first = fetch(url) + assert status == 200 + assert first == body + assert headers.get("X-ClassRelay-Cache") == "MISS" + status, headers, second = fetch(url) + assert status == 200 + assert second == body + assert headers.get("X-ClassRelay-Cache") == "HIT" + assert hits["n"] == 1 + + +def test_singleflight_and_no_partial_cache(tmp_path, cleanup): + started = threading.Event() + release = threading.Event() + + def router(handler): + started.set() + assert release.wait(5) + return 200, [("Content-Type", "application/octet-stream")], b"package-bytes" * 100 + + upstream = start_upstream(router, cleanup) + relay = start_relay( + tmp_path, + cleanup, + upstreams={"espressif": f"http://127.0.0.1:{port_of(upstream)}"}, + ) + url = f"http://127.0.0.1:{port_of(relay)}/espressif/big.bin" + errors = [] + bodies = [] + + def worker(): + try: + status, _headers, body = fetch(url, timeout=8) + if status != 200: + errors.append(status) + bodies.append(body) + except Exception as exc: # noqa: BLE001 + errors.append(exc) + + threads = [threading.Thread(target=worker) for _index in range(6)] + for thread in threads: + thread.start() + assert started.wait(5) + time.sleep(0.15) + meta_dir = Path(relay.cache.meta_dir) + assert list(meta_dir.glob("*.json")) == [] + assert sum(upstream.hits.values()) == 1 + release.set() + for thread in threads: + thread.join(8) + assert errors == [] + assert len(bodies) == 6 + assert len(set(bodies)) == 1 + assert sum(upstream.hits.values()) == 1 + status, headers, body = fetch(url) + assert headers.get("X-ClassRelay-Cache") == "HIT" + assert body == bodies[0] + assert sum(upstream.hits.values()) == 1 + + +def test_connection_limit(tmp_path, cleanup): + started = threading.Event() + release = threading.Event() + + def router(handler): + started.set() + release.wait(5) + return 200, [("Content-Type", "text/plain")], b"ok" + + upstream = start_upstream(router, cleanup) + relay = start_relay( + tmp_path, + cleanup, + upstreams={"espressif": f"http://127.0.0.1:{port_of(upstream)}"}, + max_connections=1, + ) + url = f"http://127.0.0.1:{port_of(relay)}/espressif/slow" + result = {} + + def first(): + result["first"] = fetch(url, timeout=8) + + thread = threading.Thread(target=first) + thread.start() + assert started.wait(5) + status, _headers, body = fetch(url, timeout=3) + assert status == 503 + assert b"Too many" in body + release.set() + thread.join(5) + assert result["first"][0] == 200 + + +def test_dashboard_uses_connected_address_and_hashes(tmp_path, cleanup): + relay = start_relay(tmp_path, cleanup, local_ips=["10.9.9.9"]) + base = f"http://127.0.0.1:{port_of(relay)}" + status, _headers, page = fetch(base + "/", headers={"Host": "evil.example:8080"}) + assert status == 200 + text = page.decode() + assert "127.0.0.1" in text + assert "evil.example" not in text + assert "明文 HTTP" in text + assert "strict-ssl false" not in text + assert "实验性" in text + status, headers, script = fetch(base + "/setup/bash", headers={"Host": "[::1]:8080"}) + assert status == 200 + assert b"127.0.0.1" in script + assert b"evil" not in script + assert b"[::1]" not in script + digest = hashlib.sha256(script).hexdigest() + assert headers.get("X-ClassRelay-SHA256") == digest + assert digest in text + assert b"strict-ssl false" not in script + status, _headers, restore = fetch(base + "/setup/unbash") + assert b"npm config delete strict-ssl" in restore + status, _headers, windows = fetch(base + "/setup/windows") + assert b"strict-ssl false" not in windows + status, _headers, unwindows = fetch(base + "/setup/unwindows") + assert b"npm config delete strict-ssl" in unwindows + + +def test_bash_setup_persists_and_restore_removes_it(tmp_path, cleanup): + bash = shutil.which("bash") + if bash is None or os.name == "nt": + pytest.skip("bash setup execution is exercised on Linux/macOS") + relay = start_relay(tmp_path, cleanup) + script = fetch(f"http://127.0.0.1:{port_of(relay)}/setup/bash")[2] + restore = fetch(f"http://127.0.0.1:{port_of(relay)}/setup/unbash")[2] + home = tmp_path / "home" + home.mkdir() + env = { + "HOME": str(home), + "USERPROFILE": str(home), + "PATH": os.environ.get("PATH", ""), + "TMPDIR": str(tmp_path), + "LANG": "C", + } + subprocess.run([bash], input=script, env=env, check=True, timeout=30) + bashrc = (home / ".bashrc").read_text(encoding="utf-8") + assert bashrc.count("# >>> ClassRelay >>>") == 1 + assert "HTTP_PROXY=" in bashrc + subprocess.run([bash], input=script, env=env, check=True, timeout=30) + bashrc = (home / ".bashrc").read_text(encoding="utf-8") + assert bashrc.count("# >>> ClassRelay >>>") == 1 + subprocess.run([bash], input=restore, env=env, check=True, timeout=30) + text = (home / ".bashrc").read_text(encoding="utf-8") if (home / ".bashrc").exists() else "" + assert "ClassRelay" not in text + + +def _pypi_router(wheel: bytes, html: bytes, filename: str): + def router(handler): + path = urllib.parse.urlsplit(handler.path).path + if path.rstrip("/") in ("/simple/classrelay-local-demo", "/simple/classrelay_local_demo"): + return 200, [("Content-Type", "text/html; charset=utf-8")], html + if path == f"/packages/ab/cd/{filename}": + return 200, [("Content-Type", "application/octet-stream")], wheel + return 404, [("Content-Type", "text/plain")], b"missing" + return router + + +def test_rewritten_index_is_not_shared_across_addresses(tmp_path, cleanup, monkeypatch): + filename = "classrelay_local_demo-1.0.0-py3-none-any.whl" + wheel = make_wheel() + html = simple_html(wheel, filename) + upstream = start_upstream(_pypi_router(wheel, html, filename), cleanup) + relay = start_relay( + tmp_path, + cleanup, + upstreams={"pypi": f"http://127.0.0.1:{port_of(upstream)}/simple"}, + ) + current = {"origin": "http://10.1.1.1:8080", "ip": "10.1.1.1"} + + def fake_origin(self, with_auth=False): + return current["origin"], current["ip"], 8080 + + monkeypatch.setattr(classrelay.ClassRelayHandler, "_origin", fake_origin) + url = f"http://127.0.0.1:{port_of(relay)}/pypi/classrelay-local-demo/" + _status, _headers, first = fetch(url) + assert b"http://10.1.1.1:8080/pypi/packages/" in first + current["origin"] = "http://10.2.2.2:8080" + current["ip"] = "10.2.2.2" + _status, headers, second = fetch(url) + assert b"http://10.2.2.2:8080/pypi/packages/" in second + assert headers.get("X-ClassRelay-Cache") == "MISS" + assert sum(upstream.hits.values()) == 2 + _status, headers, third = fetch(url) + assert headers.get("X-ClassRelay-Cache") == "HIT" + assert third == second + assert sum(upstream.hits.values()) == 2 + + +def test_pip_install_through_relay(tmp_path, cleanup): + filename = "classrelay_local_demo-1.0.0-py3-none-any.whl" + wheel = make_wheel() + html = simple_html(wheel, filename) + upstream = start_upstream(_pypi_router(wheel, html, filename), cleanup) + relay = start_relay( + tmp_path, + cleanup, + upstreams={"pypi": f"http://127.0.0.1:{port_of(upstream)}/simple"}, + ) + base = f"http://127.0.0.1:{port_of(relay)}" + status, headers, page = fetch(base + "/pypi/classrelay-local-demo/") + assert status == 200 + assert headers.get("X-ClassRelay-Cache") == "MISS" + assert f"{base}/pypi/packages/ab/cd/{filename}#sha256=".encode() in page + assert b"../../packages/" not in page + status, headers, package = fetch(base + f"/pypi/packages/ab/cd/{filename}") + assert status == 200 + assert package == wheel + assert headers.get("X-ClassRelay-Cache") == "MISS" + status, headers, package = fetch(base + f"/pypi/packages/ab/cd/{filename}") + assert package == wheel + assert headers.get("X-ClassRelay-Cache") == "HIT" + + # /pypi/simple/... must use the simple root, not the path itself (branch bug B-4). + status, _headers, page = fetch(base + "/pypi/simple/classrelay-local-demo/") + assert status == 200 + assert b"/pypi/packages/" in page + + target = tmp_path / "py-site" + env = os.environ.copy() + for key in list(env): + if key.lower() in {"http_proxy", "https_proxy", "all_proxy", "pip_index_url", "pip_extra_index_url"}: + env.pop(key, None) + env["PIP_DISABLE_PIP_VERSION_CHECK"] = "1" + command = [ + sys.executable, + "-m", + "pip", + "install", + "--index-url", + base + "/pypi/", + "--trusted-host", + "127.0.0.1", + "--target", + str(target), + "--no-deps", + "--no-cache-dir", + "--disable-pip-version-check", + "classrelay-local-demo==1.0.0", + ] + completed = subprocess.run(command, env=env, capture_output=True, text=True, timeout=90) + assert completed.returncode == 0, completed.stderr + "\n" + completed.stdout + installed = (target / "classrelay_local_demo" / "__init__.py").read_text(encoding="utf-8") + assert "classrelay-local-demo-ok" in installed + wheel_hits = upstream.hits.get(f"/packages/ab/cd/{filename}", 0) + assert wheel_hits == 1 + + other = tmp_path / "py-site-2" + command[command.index(str(target))] = str(other) + completed = subprocess.run(command, env=env, capture_output=True, text=True, timeout=90) + assert completed.returncode == 0, completed.stderr + assert upstream.hits.get(f"/packages/ab/cd/{filename}", 0) == 1 + + +def test_pip_install_with_basic_auth(tmp_path, cleanup): + filename = "classrelay_local_demo-1.0.0-py3-none-any.whl" + wheel = make_wheel() + html = simple_html(wheel, filename) + upstream = start_upstream(_pypi_router(wheel, html, filename), cleanup) + relay = start_relay( + tmp_path, + cleanup, + upstreams={"pypi": f"http://127.0.0.1:{port_of(upstream)}/simple"}, + auth=("teacher", "s3cret"), + ) + base = f"http://teacher:s3cret@127.0.0.1:{port_of(relay)}" + target = tmp_path / "auth-site" + env = os.environ.copy() + for key in list(env): + if "proxy" in key.lower() or key.lower() in {"pip_index_url", "pip_extra_index_url"}: + env.pop(key, None) + env["PIP_DISABLE_PIP_VERSION_CHECK"] = "1" + completed = subprocess.run( + [ + sys.executable, + "-m", + "pip", + "install", + "--index-url", + base + "/pypi/", + "--trusted-host", + "127.0.0.1", + "--target", + str(target), + "--no-deps", + "--no-cache-dir", + "--disable-pip-version-check", + "classrelay-local-demo==1.0.0", + ], + env=env, + capture_output=True, + text=True, + timeout=90, + ) + assert completed.returncode == 0, completed.stderr + "\n" + completed.stdout + assert "classrelay-local-demo-ok" in (target / "classrelay_local_demo" / "__init__.py").read_text(encoding="utf-8") + blob = " ".join(item[2].get("authorization", "") for item in upstream.seen) + assert "s3cret" not in blob + + +def test_npm_install_through_relay(tmp_path, cleanup): + tarball = make_npm_tarball() + packument = npm_packument(tarball) + + def router(handler): + path = urllib.parse.urlsplit(handler.path).path.rstrip("/") + if path == "/classrelay-local-pkg": + return 200, [("Content-Type", "application/json")], packument + if path == "/classrelay-local-pkg/-/classrelay-local-pkg-1.0.0.tgz": + return 200, [("Content-Type", "application/octet-stream")], tarball + return 404, [("Content-Type", "text/plain")], b"missing" + + upstream = start_upstream(router, cleanup) + relay = start_relay( + tmp_path, + cleanup, + upstreams={"npm": f"http://127.0.0.1:{port_of(upstream)}"}, + ) + base = f"http://127.0.0.1:{port_of(relay)}" + status, headers, body = fetch(base + "/npm/classrelay-local-pkg") + assert status == 200 + assert headers.get("X-ClassRelay-Cache") == "MISS" + meta = json.loads(body) + tarball_url = meta["versions"]["1.0.0"]["dist"]["tarball"] + assert tarball_url == base + "/npm/classrelay-local-pkg/-/classrelay-local-pkg-1.0.0.tgz" + status, headers, payload = fetch(tarball_url) + assert status == 200 + assert payload == tarball + status, headers, payload = fetch(tarball_url) + assert headers.get("X-ClassRelay-Cache") == "HIT" + assert payload == tarball + assert upstream.hits["/classrelay-local-pkg/-/classrelay-local-pkg-1.0.0.tgz"] == 1 + + npm = shutil.which("npm") + if npm is None: + pytest.skip("npm is not installed") + prefix = tmp_path / "npm-site" + prefix.mkdir() + env = os.environ.copy() + for key in list(env): + if "proxy" in key.lower(): + env.pop(key, None) + env.update( + { + "npm_config_registry": base + "/npm/", + "npm_config_audit": "false", + "npm_config_fund": "false", + "npm_config_update_notifier": "false", + "npm_config_ignore_scripts": "true", + "npm_config_fetch_retries": "0", + "npm_config_cache": str(tmp_path / "npm-cache"), + } + ) + completed = subprocess.run( + [npm, "install", "classrelay-local-pkg", "--ignore-scripts", "--no-audit", "--no-fund", "--prefix", str(prefix)], + env=env, + capture_output=True, + text=True, + timeout=90, + cwd=str(prefix), + ) + assert completed.returncode == 0, completed.stderr + "\n" + completed.stdout + installed = prefix / "node_modules" / "classrelay-local-pkg" / "index.js" + assert "classrelay-local-pkg-ok" in installed.read_text(encoding="utf-8") + assert upstream.hits["/classrelay-local-pkg/-/classrelay-local-pkg-1.0.0.tgz"] == 1 + + +def test_docker_registry_flow(tmp_path, cleanup): + def router(handler): + path = urllib.parse.urlsplit(handler.path).path + if path in ("/v2", "/v2/"): + realm = f"http://127.0.0.1:{port_of(handler.server)}/auth/token" + challenge = f'Bearer realm="{realm}",service="reg",scope="repository:demo:pull"' + return 401, [("WWW-Authenticate", challenge), ("Content-Type", "application/json")], b"{}" + if path == "/auth/token": + return 200, [("Content-Type", "application/json")], b'{"token":"tok123","access_token":"tok123"}' + if path == "/v2/evil": + challenge = 'Bearer realm="http://169.254.169.254/latest",service="reg"' + return 401, [("WWW-Authenticate", challenge)], b"" + if path == "/v2/demo/manifests/latest": + media = handler.headers.get("Accept", "application/json") + body = json.dumps({"schemaVersion": 2, "mediaType": media}).encode() + return 200, [("Content-Type", media), ("Docker-Content-Digest", "sha256:dead")], body + if path == "/v2/demo/blobs/sha256:abc": + return 200, [("Content-Type", "application/octet-stream")], b"layer-bytes" + return 404, [("Content-Type", "text/plain")], b"nope" + + upstream = start_upstream(router, cleanup) + # router closes over port via handler.server; fill it after bind by using the live port. + # The /v2/ realm uses handler.server inside the request, so it is available. + relay = start_relay( + tmp_path, + cleanup, + upstreams={"docker": f"http://127.0.0.1:{port_of(upstream)}"}, + ) + base = f"http://127.0.0.1:{port_of(relay)}" + status, headers, _body = fetch(base + "/v2/") + assert status == 401 + challenge = headers.get("WWW-Authenticate", "") + assert "/_classrelay/docker-token/" in challenge + assert "169.254" not in challenge + realm = challenge.split('realm="', 1)[1].split('"', 1)[0] + status, _headers, token_body = fetch(realm + "?service=reg&scope=repository:demo:pull") + assert status == 200 + assert json.loads(token_body)["token"] == "tok123" + forged = realm.rstrip("/")[:-1] + ("A" if not realm.endswith("A") else "B") + status, _headers, _body = fetch(forged) + assert status == 400 + + accept_v2 = "application/vnd.docker.distribution.manifest.v2+json" + accept_oci = "application/vnd.oci.image.manifest.v1+json" + status, headers, body = fetch( + base + "/v2/demo/manifests/latest", + headers={"Accept": accept_v2, "Authorization": "Bearer tok123"}, + ) + assert status == 200 + assert json.loads(body)["mediaType"] == accept_v2 + assert headers.get("Docker-Content-Digest") == "sha256:dead" + manifest_seen = [item for item in upstream.seen if "/manifests/" in item[1] and item[0] == "GET"] + assert manifest_seen[-1][2].get("accept") == accept_v2 + assert manifest_seen[-1][2].get("authorization") == "Bearer tok123" + status, _headers, other = fetch( + base + "/v2/demo/manifests/latest", + headers={"Accept": accept_oci, "Authorization": "Bearer tok123"}, + ) + assert json.loads(other)["mediaType"] == accept_oci + assert sum(1 for item in upstream.seen if item[0] == "GET" and "/manifests/" in item[1]) == 2 + + status, headers, head_body = fetch(base + "/v2/demo/manifests/latest", method="HEAD", headers={"Accept": accept_v2}) + assert status == 200 + assert head_body == b"" + assert headers.get("Docker-Content-Digest") == "sha256:dead" + + status, headers, blob = fetch(base + "/v2/demo/blobs/sha256:abc") + assert blob == b"layer-bytes" + assert headers.get("X-ClassRelay-Cache") == "MISS" + status, headers, blob = fetch(base + "/v2/demo/blobs/sha256:abc") + assert blob == b"layer-bytes" + assert headers.get("X-ClassRelay-Cache") == "HIT" + assert upstream.hits["/v2/demo/blobs/sha256:abc"] == 1 + + status, headers, _body = fetch(base + "/v2/evil") + assert status == 401 + evil = headers.get("WWW-Authenticate", "") + evil_realm = evil.split('realm="', 1)[1].split('"', 1)[0] + status, _headers, _body = fetch(evil_realm) + assert status == 403 + + +def test_cli_help_stays_compatible(): + completed = subprocess.run( + [sys.executable, str(Path(__file__).resolve().parents[1] / "classrelay.py"), "--help"], + capture_output=True, + text=True, + timeout=20, + ) + assert completed.returncode == 0 + assert "--port" in completed.stdout + assert "--cache-dir" in completed.stdout + assert "--auth" in completed.stdout + with pytest.raises(SystemExit): + classrelay._validate_auth("only-user") + + +def test_mirror_set_cookie_is_not_cached(tmp_path, cleanup): + def router(handler): + return 200, [("Content-Type", "text/html"), ("Set-Cookie", "a=b"), ("Cache-Control", "private")], b"private" + + upstream = start_upstream(router, cleanup) + relay = start_relay( + tmp_path, + cleanup, + upstreams={"pypi": f"http://127.0.0.1:{port_of(upstream)}/simple"}, + ) + url = f"http://127.0.0.1:{port_of(relay)}/pypi/demo/" + status, headers, body = fetch(url) + assert status == 200 + assert b"private" in body + assert headers.get("Set-Cookie") == "a=b" + status, headers, _body = fetch(url) + assert headers.get("X-ClassRelay-Cache") != "HIT" + assert sum(upstream.hits.values()) == 2 From 8716c0abeac7b76e92b15769d907f505ea9d71bb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 20 Oct 2026 11:35:11 +0000 Subject: [PATCH 2/2] fix: keep cache, SSRF checks, and 503 replies working on Windows Windows fsync rejects a read-only handle, so cache commits never landed and later fetches stayed MISS. Closing a full socket also aborted the 503, and getaddrinfo does not parse inet_aton literals. Help text now uses UTF-8 so --help survives a cp1252 console. Co-authored-by: LoveLH --- .github/workflows/test.yml | 7 +- README.md | 2 +- classrelay.py | 135 +++++++++++++++++++++++++++++++++---- tests/test_relay.py | 32 ++++++++- 4 files changed, 157 insertions(+), 19 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b8dc010..451b34f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,13 +14,14 @@ jobs: os: [ubuntu-latest, windows-latest] python: ["3.9", "3.13"] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python }} - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: "22" + package-manager-cache: false - name: Install pytest run: python -m pip install pytest - name: Run offline tests diff --git a/README.md b/README.md index ca1a3db..98469ce 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ 默认绑定 `0.0.0.0`,同一局域网里的人都能连上。程序做了这些限制: - **CONNECT 默认只允许 443**,明文正向代理默认只允许 **80 和 443**。 -- **拒绝本机、回环、链路本地、RFC1918 私网、运营商级 NAT(100.64/10)、组播、文档地址和云元数据地址**。判断发生在 DNS 解析之后;一个域名只要有任何一条记录落在这些地址上,整次请求都会被拒绝。 +- **拒绝本机、回环、链路本地、RFC1918 私网、运营商级 NAT(100.64/10)、组播、文档地址和云元数据地址**。十进制、十六进制和缩写 IPv4(如 `2130706433`、`0x7f000001`、`127.1`)会先规范成点分地址再判断。其余名字在 DNS 之后判断;一个域名只要有任何一条记录落在这些地址上,整次请求都会被拒绝。 - **正向代理不缓存**。带 `Cookie` 的镜像请求不缓存;响应里有 `Set-Cookie`、`Cache-Control: private/no-store/no-cache` 时也不缓存。 - **可选 HTTP Basic 认证**(`--auth 用户:密码`)。pip、npm、git 使用 `http://用户:密码@教师机:端口` 这种标准写法。默认关闭,方便教室里零配置开用。口令比较使用 `hmac.compare_digest`,访问日志会抹掉 URL 里的用户名密码。开启后,控制台和脚本也要带认证;Docker 客户端往往把 `Authorization` 留给仓库自己的 Bearer token,和本程序的 Basic 认证不好同时使用,需要认证时优先用 pip/npm/代理,把 Docker 留在关闭认证的网络里。 - **仍可按域名收紧代理**:重复传入 `--allow-host github.com` 这类后缀后,CONNECT 和正向代理只放行这些域名。镜像路由走的是教师机配置的上游,不受这份名单限制。 diff --git a/classrelay.py b/classrelay.py index 00c01cc..a64ec67 100644 --- a/classrelay.py +++ b/classrelay.py @@ -194,6 +194,59 @@ def is_blocked_ip(ip: str) -> bool: return any(addr in net for net in nets) +def _parse_ipv4_component(text: str) -> Optional[int]: + """One inet_aton component: decimal, 0-octal, or 0x-hex. None if it is not a number.""" + if not text: + return None + try: + if text[:2].lower() == "0x": + digits = text[2:] + if not digits or any(ch not in "0123456789abcdefABCDEF" for ch in digits): + return None + return int(text, 16) + if text[0] == "0" and len(text) > 1: + if any(ch not in "01234567" for ch in text): + return None + return int(text, 8) + if not text.isdigit(): + return None + return int(text, 10) + except ValueError: + return None + + +def normalize_ipv4_literal(host: str) -> Optional[str]: + """Dotted quad for inet_aton forms, or None when host is a name. + + glibc getaddrinfo accepts 2130706433, 0x7f000001, and 127.1 as 127.0.0.1. + Windows does not, and a failed lookup used to become 502 instead of 403. + """ + parts = host.split(".") + if not 1 <= len(parts) <= 4: + return None + values: List[int] = [] + for part in parts: + value = _parse_ipv4_component(part) + if value is None: + return None + values.append(value) + last_max = (1 << (8 * (5 - len(values)))) - 1 + if values[-1] > last_max: + return None + for value in values[:-1]: + if value > 0xFF: + return None + if len(values) == 1: + number = values[0] + elif len(values) == 2: + number = (values[0] << 24) | values[1] + elif len(values) == 3: + number = (values[0] << 24) | (values[1] << 16) | values[2] + else: + number = (values[0] << 24) | (values[1] << 16) | (values[2] << 8) | values[3] + return ".".join(str((number >> shift) & 0xFF) for shift in (24, 16, 8, 0)) + + def resolve_addresses(host: str, port: int): """DNS lookup. Tests monkeypatch this.""" return socket.getaddrinfo(host, port, type=socket.SOCK_STREAM) @@ -229,6 +282,11 @@ def resolve_destination(host: str, port: int, *, private_ok: bool): """ if not host or len(host) > 255: raise PolicyError(400, "Bad target host") + literal = normalize_ipv4_literal(host) + if literal is not None: + host = literal + if is_blocked_ip(host) and not private_ok: + raise PolicyError(403, "Private or non-public address blocked") infos = _resolve_with_timeout(host, port) if not infos: raise UpstreamError("DNS resolution failed") @@ -1109,8 +1167,7 @@ def commit( filename = f"{key}-{secrets.token_hex(8)}" final = os.path.join(self.data_dir, filename) try: - with open(tmp_path, "rb") as handle: - os.fsync(handle.fileno()) + _fsync_path(tmp_path) os.replace(tmp_path, final) except OSError as exc: LOG.warning("cache write failed: %s", exc) @@ -1183,6 +1240,22 @@ def _unlink(self, filename: str) -> None: pass +def _fsync_path(path: str) -> None: + """Force a finished temp file to disk. + + os.fsync on a read-only handle raises Errno 9 on Windows, so the handle + has to be opened for write even though the bytes are already complete. + """ + flags = os.O_RDWR + if hasattr(os, "O_BINARY"): + flags |= os.O_BINARY + fd = os.open(path, flags) + try: + os.fsync(fd) + finally: + os.close(fd) + + def _atomic_write_json(path: str, payload: dict) -> None: directory = os.path.dirname(path) fd, tmp = tempfile.mkstemp(prefix=".meta-", dir=directory) @@ -2128,18 +2201,7 @@ def __init__( def process_request(self, request, client_address) -> None: if not self._sem.acquire(blocking=False): - try: - body = b"Too many connections" - message = ( - b"HTTP/1.1 503 Service Unavailable\r\n" - b"Connection: close\r\n" - b"Content-Length: " + str(len(body)).encode("ascii") + b"\r\n" - b"\r\n" + body - ) - request.sendall(message) - except Exception: - pass - request.close() + _reject_busy(request) return try: super().process_request(request, client_address) @@ -2178,6 +2240,50 @@ def _validate_auth(raw: str) -> Tuple[str, str]: return user, password +def _reject_busy(request: socket.socket) -> None: + """Reply 503, then close without aborting the connection. + + Windows sends RST if a socket is closed while the request is still unread, + so the client sees WinError 10053 instead of the status line. + """ + try: + request.settimeout(1.0) + pending = b"" + while b"\r\n\r\n" not in pending and len(pending) < 65536: + chunk = request.recv(4096) + if not chunk: + break + pending += chunk + body = b"Too many connections" + message = ( + b"HTTP/1.1 503 Service Unavailable\r\n" + b"Connection: close\r\n" + b"Content-Length: " + str(len(body)).encode("ascii") + b"\r\n" + b"\r\n" + body + ) + request.sendall(message) + try: + request.shutdown(socket.SHUT_WR) + except OSError: + pass + except OSError: + pass + finally: + request.close() + + +def _prefer_utf8_stdio() -> None: + """Help and the banner are Chinese. A cp1252 console otherwise exits on --help.""" + for stream in (sys.stdout, sys.stderr): + reconfigure = getattr(stream, "reconfigure", None) + if reconfigure is None: + continue + try: + reconfigure(encoding="utf-8", errors="replace") + except (OSError, ValueError): + pass + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="ClassRelay - 局域网机房依赖中继与缓存分发服务") parser.add_argument("--port", "-p", type=int, default=DEFAULT_PORT, help=f"监听端口 (默认: {DEFAULT_PORT})") @@ -2215,6 +2321,7 @@ def build_parser() -> argparse.ArgumentParser: def main(argv: Optional[Sequence[str]] = None) -> None: + _prefer_utf8_stdio() configure_logging() parser = build_parser() args = parser.parse_args(argv) diff --git a/tests/test_relay.py b/tests/test_relay.py index e6a0298..b1b754b 100644 --- a/tests/test_relay.py +++ b/tests/test_relay.py @@ -257,7 +257,35 @@ def test_public_addresses_allowed(ip): assert not classrelay.is_blocked_ip(ip) -def test_decimal_and_odd_literals_resolve_to_loopback(): +@pytest.mark.parametrize( + "host,ip", + [ + ("2130706433", "127.0.0.1"), + ("0x7f000001", "127.0.0.1"), + ("0X7F000001", "127.0.0.1"), + ("127.1", "127.0.0.1"), + ("127.0.1", "127.0.0.1"), + ("0177.0.0.1", "127.0.0.1"), + ("0x7f.0.0.1", "127.0.0.1"), + ("134744072", "8.8.8.8"), + ("8.8.8.8", "8.8.8.8"), + ], +) +def test_ipv4_literal_normalization(host, ip): + assert classrelay.normalize_ipv4_literal(host) == ip + + +@pytest.mark.parametrize("host", ["example.com", "localhost", "", "08", "999.1.1.1", "127.0.0.1.extra"]) +def test_non_ipv4_literals_stay_names(host): + assert classrelay.normalize_ipv4_literal(host) is None + + +def test_decimal_and_odd_literals_resolve_to_loopback(monkeypatch): + def fake(host, port): + assert host == "127.0.0.1" + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (host, port))] + + monkeypatch.setattr(classrelay, "resolve_addresses", fake) for host in ("2130706433", "0x7f000001", "127.1"): infos = classrelay.resolve_destination(host, 80, private_ok=True) ips = {item[4][0].split("%", 1)[0] for item in infos} @@ -1212,6 +1240,8 @@ def test_cli_help_stays_compatible(): [sys.executable, str(Path(__file__).resolve().parents[1] / "classrelay.py"), "--help"], capture_output=True, text=True, + encoding="utf-8", + errors="replace", timeout=20, ) assert completed.returncode == 0