commit 79c1cdde2f7c5cbea978a18c660caeba1aaee428 Author: Anonymous <> Date: Sat Feb 24 10:09:14 2024 +0800 initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5825b57 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ + +# for caddy +CLOUDFLARE_DNS_API_TOKEN=xxxx + +# for caddy and mirrors +BASE_DOMAIN=local.homeinfra.org + +# for aria2 and mirrors +RPC_SECRET=changeit + +# for all, if you want set proxy for all services +# aria2 need the lowercase one, FYI https://aria2.github.io/manual/en/html/aria2c.html#environment +# all_proxy= \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb1c13f --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ + +data/* +!data/cache/.gitkeep +caddy/caddy_data +.idea +.env +node_modules \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..53b9629 --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +
+ +# LightMirrors + +LightMirrors是一个开源的缓存镜像站服务,用于加速软件包下载和镜像拉取。 +目前支持DockerHub、PyPI、PyTorch、NPM等镜像缓存服务。 + + + + +[![GitHub](https://img.shields.io/github/stars/NoCLin/LightMirrors?style=social)](https://github.com/NoCLin/LightMirrors) +[![GitHub](https://img.shields.io/github/forks/NoCLin/LightMirrors?style=social)](https://github.com/NoCLin/LightMirrors) + +
+ + +--- + +## Quick Start + +### Prerequisites + +- docker + docker-compose. +- 一个域名,设置 `*.local.homeinfra.org` 的A记录指向您的服务器. +- 代理服务器(如有必要). +- 一个Cloudflare账户(非强制,也可以使用其他DNS服务,请自行修改Caddy) + +### Deployment + +修改 `.env` 文件,设置下列参数: + +- `BASE_DOMAIN`: 基础域名,如 `local.homeinfra.org`,镜像站将会使用 `*.local.homeinfra.org` 的子域名。 +- `CLOUDFLARE_DNS_API_TOKEN`,Cloudflare的API Token,用于管理DNS申请HTTPS证书。 +- `RPC_SECRET`:Aria2的RPC密钥。 +- `all_proxy`:代理服务器地址,如有必要。 + +```bash +docker-compose up +``` + +## Design + +LightMirrors依赖于三个组件: + +- aria2 + Aria2Ng : 下载器与管理UI。 +- mirrors: 镜像HTTP服务器。 +- caddy: HTTP网关。 + +## Test + +> 假设我们的域名为 local.homeinfra.org + +| subdomain | source | test command | +|-----------|---------------------------------|-----------------------------------------------------------------| +| pypi | https://pypi.org | pip3 download -i https://pypi.local.homeinfra.org/simple jinja2 | +| torch | https://download.pytorch.org | pip3 download -i https://pypi.local.homeinfra.org/whl/ torch | +| dockerhub | https://registry-1.docker.io/v2 | docker pull docker.local.homeinfra.org/alpine | + + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=NoCLin/LightMirrors&type=Date)](https://star-history.com/#NoCLin/LightMirrors&Date) + diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..0808b6a --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,3 @@ +- Test +- Publish to ghcr and dockerhub +- Configurable upstreams \ No newline at end of file diff --git a/aria2/Dockerfile b/aria2/Dockerfile new file mode 100644 index 0000000..cf72fe1 --- /dev/null +++ b/aria2/Dockerfile @@ -0,0 +1,13 @@ +FROM alpine:3.19.1 + +# Optimization for China +RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apk/repositories + +RUN apk update && apk add --no-cache aria2 + +ADD entrypoint.sh / +ADD aria2.conf / + +EXPOSE 6800 + +CMD ["/entrypoint.sh"] \ No newline at end of file diff --git a/aria2/aria2.conf b/aria2/aria2.conf new file mode 100644 index 0000000..04415b0 --- /dev/null +++ b/aria2/aria2.conf @@ -0,0 +1,54 @@ +dir=/app/cache +disk-cache=64M +file-allocation=none +no-file-allocation-limit=64M +continue=true +always-resume=false +max-resume-failure-tries=0 +remote-time=true +input-file=/data/aria2.session +save-session=/data/aria2.session +save-session-interval=1 +auto-save-interval=20 + +# remove .aria2 when download is done +# we leverage this to detech if download is done +force-save=false + +max-file-not-found=3 +max-tries=0 +retry-wait=10 +connect-timeout=10 +timeout=10 +max-concurrent-downloads=5 +max-connection-per-server=16 +split=64 +min-split-size=4M +piece-length=1M +allow-piece-length-change=true +lowest-speed-limit=0 +max-overall-download-limit=0 +max-download-limit=0 +disable-ipv6=true +http-accept-gzip=true +reuse-uri=false +no-netrc=true +allow-overwrite=true +auto-file-renaming=false +content-disposition-default-utf8=true + +enable-rpc=true +rpc-allow-origin-all=false +rpc-listen-all=true +rpc-listen-port=6800 +rpc-max-request-size=10M + +log=- +log-level=warn + +console-log-level=notice + +quiet=false + +summary-interval=0 + diff --git a/aria2/entrypoint.sh b/aria2/entrypoint.sh new file mode 100755 index 0000000..1528191 --- /dev/null +++ b/aria2/entrypoint.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +touch /data/aria2.session + +aria2c --conf-path=/aria2.conf --rpc-secret $RPC_SECRET \ No newline at end of file diff --git a/caddy/Caddyfile b/caddy/Caddyfile new file mode 100644 index 0000000..e4111c2 --- /dev/null +++ b/caddy/Caddyfile @@ -0,0 +1,15 @@ +*.{$BASE_DOMAIN} { + + tls { + dns cloudflare {env.CLOUDFLARE_DNS_API_TOKEN} + } + + reverse_proxy http://lightmirrors:8080 + + @aria2 host aria2.{$BASE_DOMAIN} + handle @aria2 { + root * /wwwroot + file_server + reverse_proxy /jsonrpc http://aria2:6800 + } +} \ No newline at end of file diff --git a/caddy/Dockerfile b/caddy/Dockerfile new file mode 100644 index 0000000..bf50fe3 --- /dev/null +++ b/caddy/Dockerfile @@ -0,0 +1,23 @@ +FROM caddy:2.7.6-builder-alpine AS builder + +# Optimization for China +RUN go env -w GOPROXY=https://goproxy.cn,direct + +RUN xcaddy build \ + --with github.com/caddy-dns/cloudflare + +FROM caddy:2.7.6-alpine + +COPY --from=builder /usr/bin/caddy /usr/bin/caddy + +RUN mkdir -p /wwwroot +WORKDIR /wwwroot + +# Optimization for China as the project is aimed at Chinese users +ADD https://hub.gitmirror.com/https://github.com/mayswind/AriaNg/releases/download/1.3.7/AriaNg-1.3.7.zip /wwwroot/ + +RUN unzip AriaNg-1.3.7.zip && rm AriaNg-1.3.7.zip + +ENTRYPOINT ["caddy"] + +CMD ["run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f63372f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,44 @@ +services: + lightmirrors: + image: lightmirrors/mirrors + build: ./mirrors + volumes: + - ./mirrors:/app + - ./data/cache:/app/cache + env_file: + - .env + networks: + - app + restart: unless-stopped + # for linux + extra_hosts: + - "host.docker.internal:host-gateway" + caddy: + image: lightmirrors/caddy + build: ./caddy + ports: + - "80:80" + - "443:443" + volumes: + - ./caddy/Caddyfile:/etc/caddy/Caddyfile + - ./caddy/caddy_data:/data/caddy + env_file: + - .env + networks: + - app + restart: unless-stopped + aria2: + image: lightmirrors/aria2 + build: ./aria2 + volumes: + - ./aria2/aria2.conf:/aria2.conf + - ./data/cache:/app/cache + - ./data/aria2:/data/ + networks: + - app + env_file: + - .env + restart: unless-stopped +networks: + app: + driver: bridge \ No newline at end of file diff --git a/mirrors/Dockerfile b/mirrors/Dockerfile new file mode 100644 index 0000000..aec3db5 --- /dev/null +++ b/mirrors/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.11-alpine + +ADD requirements.txt /app/requirements.txt + +RUN pip install -r /app/requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple/ + +WORKDIR /app + +EXPOSE 8080 + +CMD ["python", "server.py"] + + diff --git a/mirrors/aria2_api.py b/mirrors/aria2_api.py new file mode 100644 index 0000000..9b6ede4 --- /dev/null +++ b/mirrors/aria2_api.py @@ -0,0 +1,67 @@ +import json +import logging +import uuid + +import httpx + +from config import RPC_SECRET, ARIA2_RPC_URL + +logger = logging.getLogger(__name__) + + +async def send_request(method, params=None): + payload = { + 'jsonrpc': '2.0', + 'id': uuid.uuid4().hex, + 'method': method, + 'params': [f'token:{RPC_SECRET}'] + (params or []) + } + + # specify the internal API call don't use proxy + async with httpx.AsyncClient(mounts={ + "all://": httpx.AsyncHTTPTransport() + }) as client: + response = await client.post(ARIA2_RPC_URL, json=payload) + logger.info(f"aria2 request: {method} {params} -> {response.status_code} {response.text}") + try: + return response.json() + except json.JSONDecodeError as e: + logger.warning(f"aria2 response: {response.status_code} {response.text}") + raise e + + +async def add_download(url, save_dir='/app/cache'): + method = 'aria2.addUri' + params = [[url], + {'dir': save_dir, + 'header': [] + }] + response = await send_request(method, params) + return response['result'] + + +async def pause_download(gid): + method = 'aria2.pause' + params = [gid] + response = await send_request(method, params) + return response['result'] + + +async def resume_download(gid): + method = 'aria2.unpause' + params = [gid] + response = await send_request(method, params) + return response['result'] + + +async def get_status(gid): + method = 'aria2.tellStatus' + params = [gid] + response = await send_request(method, params) + return response['result'] + + +async def list_downloads(): + method = 'aria2.tellActive' + response = await send_request(method) + return response['result'] diff --git a/mirrors/config.py b/mirrors/config.py new file mode 100644 index 0000000..ef42740 --- /dev/null +++ b/mirrors/config.py @@ -0,0 +1,11 @@ +import os + +ARIA2_RPC_URL = os.environ.get("ARIA2_RPC_URL", 'http://aria2:6800/jsonrpc') +RPC_SECRET = os.environ.get("RPC_SECRET", '') +BASE_DOMAIN = os.environ.get("BASE_DOMAIN", '127.0.0.1.nip.io') + +PROXY = os.environ.get("PROXY", None) +CACHE_DIR = os.environ.get("CACHE_DIR", "/app/cache/") + +EXTERNAL_HOST_ARIA2 = f"aria2." + BASE_DOMAIN +EXTERNAL_URL_ARIA2 = f"https://" + EXTERNAL_HOST_ARIA2 diff --git a/mirrors/poetry.lock b/mirrors/poetry.lock new file mode 100644 index 0000000..c3dd002 --- /dev/null +++ b/mirrors/poetry.lock @@ -0,0 +1,344 @@ +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. + +[[package]] +name = "annotated-types" +version = "0.6.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +files = [ + {file = "annotated_types-0.6.0-py3-none-any.whl", hash = "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43"}, + {file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"}, +] + +[[package]] +name = "anyio" +version = "4.2.0" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.8" +files = [ + {file = "anyio-4.2.0-py3-none-any.whl", hash = "sha256:745843b39e829e108e518c489b31dc757de7d2131d53fac32bd8df268227bfee"}, + {file = "anyio-4.2.0.tar.gz", hash = "sha256:e1875bb4b4e2de1669f4bc7869b6d3f54231cdced71605e6e64c9be77e3be50f"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +sniffio = ">=1.1" +typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} + +[package.extras] +doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] +trio = ["trio (>=0.23)"] + +[[package]] +name = "certifi" +version = "2024.2.2" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"}, + {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"}, +] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.0" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, + {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "fastapi" +version = "0.109.0" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +optional = false +python-versions = ">=3.8" +files = [ + {file = "fastapi-0.109.0-py3-none-any.whl", hash = "sha256:8c77515984cd8e8cfeb58364f8cc7a28f0692088475e2614f7bf03275eba9093"}, + {file = "fastapi-0.109.0.tar.gz", hash = "sha256:b978095b9ee01a5cf49b19f4bc1ac9b8ca83aa076e770ef8fd9af09a2b88d191"}, +] + +[package.dependencies] +pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" +starlette = ">=0.35.0,<0.36.0" +typing-extensions = ">=4.8.0" + +[package.extras] +all = ["email-validator (>=2.0.0)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.5)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] + +[[package]] +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.7" +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + +[[package]] +name = "httpcore" +version = "1.0.2" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpcore-1.0.2-py3-none-any.whl", hash = "sha256:096cc05bca73b8e459a1fc3dcf585148f63e534eae4339559c9b8a8d6399acc7"}, + {file = "httpcore-1.0.2.tar.gz", hash = "sha256:9fc092e4799b26174648e54b74ed5f683132a464e95643b226e00c2ed2fa6535"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.13,<0.15" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<0.23.0)"] + +[[package]] +name = "httpx" +version = "0.26.0" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpx-0.26.0-py3-none-any.whl", hash = "sha256:8915f5a3627c4d47b73e8202457cb28f1266982d1159bd5779d86a80c0eab1cd"}, + {file = "httpx-0.26.0.tar.gz", hash = "sha256:451b55c30d5185ea6b23c2c793abf9bb237d2a7dfb901ced6ff69ad37ec1dfaf"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" +sniffio = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] + +[[package]] +name = "idna" +version = "3.6" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.5" +files = [ + {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, + {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, +] + +[[package]] +name = "pydantic" +version = "2.6.0" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic-2.6.0-py3-none-any.whl", hash = "sha256:1440966574e1b5b99cf75a13bec7b20e3512e8a61b894ae252f56275e2c465ae"}, + {file = "pydantic-2.6.0.tar.gz", hash = "sha256:ae887bd94eb404b09d86e4d12f93893bdca79d766e738528c6fa1c849f3c6bcf"}, +] + +[package.dependencies] +annotated-types = ">=0.4.0" +pydantic-core = "2.16.1" +typing-extensions = ">=4.6.1" + +[package.extras] +email = ["email-validator (>=2.0.0)"] + +[[package]] +name = "pydantic-core" +version = "2.16.1" +description = "" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic_core-2.16.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:300616102fb71241ff477a2cbbc847321dbec49428434a2f17f37528721c4948"}, + {file = "pydantic_core-2.16.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5511f962dd1b9b553e9534c3b9c6a4b0c9ded3d8c2be96e61d56f933feef9e1f"}, + {file = "pydantic_core-2.16.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98f0edee7ee9cc7f9221af2e1b95bd02810e1c7a6d115cfd82698803d385b28f"}, + {file = "pydantic_core-2.16.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9795f56aa6b2296f05ac79d8a424e94056730c0b860a62b0fdcfe6340b658cc8"}, + {file = "pydantic_core-2.16.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c45f62e4107ebd05166717ac58f6feb44471ed450d07fecd90e5f69d9bf03c48"}, + {file = "pydantic_core-2.16.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:462d599299c5971f03c676e2b63aa80fec5ebc572d89ce766cd11ca8bcb56f3f"}, + {file = "pydantic_core-2.16.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21ebaa4bf6386a3b22eec518da7d679c8363fb7fb70cf6972161e5542f470798"}, + {file = "pydantic_core-2.16.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:99f9a50b56713a598d33bc23a9912224fc5d7f9f292444e6664236ae471ddf17"}, + {file = "pydantic_core-2.16.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:8ec364e280db4235389b5e1e6ee924723c693cbc98e9d28dc1767041ff9bc388"}, + {file = "pydantic_core-2.16.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:653a5dfd00f601a0ed6654a8b877b18d65ac32c9d9997456e0ab240807be6cf7"}, + {file = "pydantic_core-2.16.1-cp310-none-win32.whl", hash = "sha256:1661c668c1bb67b7cec96914329d9ab66755911d093bb9063c4c8914188af6d4"}, + {file = "pydantic_core-2.16.1-cp310-none-win_amd64.whl", hash = "sha256:561be4e3e952c2f9056fba5267b99be4ec2afadc27261505d4992c50b33c513c"}, + {file = "pydantic_core-2.16.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:102569d371fadc40d8f8598a59379c37ec60164315884467052830b28cc4e9da"}, + {file = "pydantic_core-2.16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:735dceec50fa907a3c314b84ed609dec54b76a814aa14eb90da31d1d36873a5e"}, + {file = "pydantic_core-2.16.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e83ebbf020be727d6e0991c1b192a5c2e7113eb66e3def0cd0c62f9f266247e4"}, + {file = "pydantic_core-2.16.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:30a8259569fbeec49cfac7fda3ec8123486ef1b729225222f0d41d5f840b476f"}, + {file = "pydantic_core-2.16.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:920c4897e55e2881db6a6da151198e5001552c3777cd42b8a4c2f72eedc2ee91"}, + {file = "pydantic_core-2.16.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f5247a3d74355f8b1d780d0f3b32a23dd9f6d3ff43ef2037c6dcd249f35ecf4c"}, + {file = "pydantic_core-2.16.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d5bea8012df5bb6dda1e67d0563ac50b7f64a5d5858348b5c8cb5043811c19d"}, + {file = "pydantic_core-2.16.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ed3025a8a7e5a59817b7494686d449ebfbe301f3e757b852c8d0d1961d6be864"}, + {file = "pydantic_core-2.16.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:06f0d5a1d9e1b7932477c172cc720b3b23c18762ed7a8efa8398298a59d177c7"}, + {file = "pydantic_core-2.16.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:150ba5c86f502c040b822777e2e519b5625b47813bd05f9273a8ed169c97d9ae"}, + {file = "pydantic_core-2.16.1-cp311-none-win32.whl", hash = "sha256:d6cbdf12ef967a6aa401cf5cdf47850559e59eedad10e781471c960583f25aa1"}, + {file = "pydantic_core-2.16.1-cp311-none-win_amd64.whl", hash = "sha256:afa01d25769af33a8dac0d905d5c7bb2d73c7c3d5161b2dd6f8b5b5eea6a3c4c"}, + {file = "pydantic_core-2.16.1-cp311-none-win_arm64.whl", hash = "sha256:1a2fe7b00a49b51047334d84aafd7e39f80b7675cad0083678c58983662da89b"}, + {file = "pydantic_core-2.16.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0f478ec204772a5c8218e30eb813ca43e34005dff2eafa03931b3d8caef87d51"}, + {file = "pydantic_core-2.16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f1936ef138bed2165dd8573aa65e3095ef7c2b6247faccd0e15186aabdda7f66"}, + {file = "pydantic_core-2.16.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99d3a433ef5dc3021c9534a58a3686c88363c591974c16c54a01af7efd741f13"}, + {file = "pydantic_core-2.16.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd88f40f2294440d3f3c6308e50d96a0d3d0973d6f1a5732875d10f569acef49"}, + {file = "pydantic_core-2.16.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fac641bbfa43d5a1bed99d28aa1fded1984d31c670a95aac1bf1d36ac6ce137"}, + {file = "pydantic_core-2.16.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:72bf9308a82b75039b8c8edd2be2924c352eda5da14a920551a8b65d5ee89253"}, + {file = "pydantic_core-2.16.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb4363e6c9fc87365c2bc777a1f585a22f2f56642501885ffc7942138499bf54"}, + {file = "pydantic_core-2.16.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:20f724a023042588d0f4396bbbcf4cffd0ddd0ad3ed4f0d8e6d4ac4264bae81e"}, + {file = "pydantic_core-2.16.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:fb4370b15111905bf8b5ba2129b926af9470f014cb0493a67d23e9d7a48348e8"}, + {file = "pydantic_core-2.16.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:23632132f1fd608034f1a56cc3e484be00854db845b3a4a508834be5a6435a6f"}, + {file = "pydantic_core-2.16.1-cp312-none-win32.whl", hash = "sha256:b9f3e0bffad6e238f7acc20c393c1ed8fab4371e3b3bc311020dfa6020d99212"}, + {file = "pydantic_core-2.16.1-cp312-none-win_amd64.whl", hash = "sha256:a0b4cfe408cd84c53bab7d83e4209458de676a6ec5e9c623ae914ce1cb79b96f"}, + {file = "pydantic_core-2.16.1-cp312-none-win_arm64.whl", hash = "sha256:d195add190abccefc70ad0f9a0141ad7da53e16183048380e688b466702195dd"}, + {file = "pydantic_core-2.16.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:502c062a18d84452858f8aea1e520e12a4d5228fc3621ea5061409d666ea1706"}, + {file = "pydantic_core-2.16.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d8c032ccee90b37b44e05948b449a2d6baed7e614df3d3f47fe432c952c21b60"}, + {file = "pydantic_core-2.16.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:920f4633bee43d7a2818e1a1a788906df5a17b7ab6fe411220ed92b42940f818"}, + {file = "pydantic_core-2.16.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9f5d37ff01edcbace53a402e80793640c25798fb7208f105d87a25e6fcc9ea06"}, + {file = "pydantic_core-2.16.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:399166f24c33a0c5759ecc4801f040dbc87d412c1a6d6292b2349b4c505effc9"}, + {file = "pydantic_core-2.16.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ac89ccc39cd1d556cc72d6752f252dc869dde41c7c936e86beac5eb555041b66"}, + {file = "pydantic_core-2.16.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73802194f10c394c2bedce7a135ba1d8ba6cff23adf4217612bfc5cf060de34c"}, + {file = "pydantic_core-2.16.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8fa00fa24ffd8c31fac081bf7be7eb495be6d248db127f8776575a746fa55c95"}, + {file = "pydantic_core-2.16.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:601d3e42452cd4f2891c13fa8c70366d71851c1593ed42f57bf37f40f7dca3c8"}, + {file = "pydantic_core-2.16.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:07982b82d121ed3fc1c51faf6e8f57ff09b1325d2efccaa257dd8c0dd937acca"}, + {file = "pydantic_core-2.16.1-cp38-none-win32.whl", hash = "sha256:d0bf6f93a55d3fa7a079d811b29100b019784e2ee6bc06b0bb839538272a5610"}, + {file = "pydantic_core-2.16.1-cp38-none-win_amd64.whl", hash = "sha256:fbec2af0ebafa57eb82c18c304b37c86a8abddf7022955d1742b3d5471a6339e"}, + {file = "pydantic_core-2.16.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a497be217818c318d93f07e14502ef93d44e6a20c72b04c530611e45e54c2196"}, + {file = "pydantic_core-2.16.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:694a5e9f1f2c124a17ff2d0be613fd53ba0c26de588eb4bdab8bca855e550d95"}, + {file = "pydantic_core-2.16.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d4dfc66abea3ec6d9f83e837a8f8a7d9d3a76d25c9911735c76d6745950e62c"}, + {file = "pydantic_core-2.16.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8655f55fe68c4685673265a650ef71beb2d31871c049c8b80262026f23605ee3"}, + {file = "pydantic_core-2.16.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21e3298486c4ea4e4d5cc6fb69e06fb02a4e22089304308817035ac006a7f506"}, + {file = "pydantic_core-2.16.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71b4a48a7427f14679f0015b13c712863d28bb1ab700bd11776a5368135c7d60"}, + {file = "pydantic_core-2.16.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10dca874e35bb60ce4f9f6665bfbfad050dd7573596608aeb9e098621ac331dc"}, + {file = "pydantic_core-2.16.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fa496cd45cda0165d597e9d6f01e36c33c9508f75cf03c0a650018c5048f578e"}, + {file = "pydantic_core-2.16.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5317c04349472e683803da262c781c42c5628a9be73f4750ac7d13040efb5d2d"}, + {file = "pydantic_core-2.16.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:42c29d54ed4501a30cd71015bf982fa95e4a60117b44e1a200290ce687d3e640"}, + {file = "pydantic_core-2.16.1-cp39-none-win32.whl", hash = "sha256:ba07646f35e4e49376c9831130039d1b478fbfa1215ae62ad62d2ee63cf9c18f"}, + {file = "pydantic_core-2.16.1-cp39-none-win_amd64.whl", hash = "sha256:2133b0e412a47868a358713287ff9f9a328879da547dc88be67481cdac529118"}, + {file = "pydantic_core-2.16.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:d25ef0c33f22649b7a088035fd65ac1ce6464fa2876578df1adad9472f918a76"}, + {file = "pydantic_core-2.16.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:99c095457eea8550c9fa9a7a992e842aeae1429dab6b6b378710f62bfb70b394"}, + {file = "pydantic_core-2.16.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b49c604ace7a7aa8af31196abbf8f2193be605db6739ed905ecaf62af31ccae0"}, + {file = "pydantic_core-2.16.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c56da23034fe66221f2208c813d8aa509eea34d97328ce2add56e219c3a9f41c"}, + {file = "pydantic_core-2.16.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cebf8d56fee3b08ad40d332a807ecccd4153d3f1ba8231e111d9759f02edfd05"}, + {file = "pydantic_core-2.16.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:1ae8048cba95f382dba56766525abca438328455e35c283bb202964f41a780b0"}, + {file = "pydantic_core-2.16.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:780daad9e35b18d10d7219d24bfb30148ca2afc309928e1d4d53de86822593dc"}, + {file = "pydantic_core-2.16.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c94b5537bf6ce66e4d7830c6993152940a188600f6ae044435287753044a8fe2"}, + {file = "pydantic_core-2.16.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:adf28099d061a25fbcc6531febb7a091e027605385de9fe14dd6a97319d614cf"}, + {file = "pydantic_core-2.16.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:644904600c15816a1f9a1bafa6aab0d21db2788abcdf4e2a77951280473f33e1"}, + {file = "pydantic_core-2.16.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87bce04f09f0552b66fca0c4e10da78d17cb0e71c205864bab4e9595122cb9d9"}, + {file = "pydantic_core-2.16.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:877045a7969ace04d59516d5d6a7dee13106822f99a5d8df5e6822941f7bedc8"}, + {file = "pydantic_core-2.16.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9c46e556ee266ed3fb7b7a882b53df3c76b45e872fdab8d9cf49ae5e91147fd7"}, + {file = "pydantic_core-2.16.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4eebbd049008eb800f519578e944b8dc8e0f7d59a5abb5924cc2d4ed3a1834ff"}, + {file = "pydantic_core-2.16.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:c0be58529d43d38ae849a91932391eb93275a06b93b79a8ab828b012e916a206"}, + {file = "pydantic_core-2.16.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:b1fc07896fc1851558f532dffc8987e526b682ec73140886c831d773cef44b76"}, + {file = "pydantic_core-2.16.1.tar.gz", hash = "sha256:daff04257b49ab7f4b3f73f98283d3dbb1a65bf3500d55c7beac3c66c310fe34"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + +[[package]] +name = "sniffio" +version = "1.3.0" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +files = [ + {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, + {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, +] + +[[package]] +name = "starlette" +version = "0.35.1" +description = "The little ASGI library that shines." +optional = false +python-versions = ">=3.8" +files = [ + {file = "starlette-0.35.1-py3-none-any.whl", hash = "sha256:50bbbda9baa098e361f398fda0928062abbaf1f54f4fadcbe17c092a01eb9a25"}, + {file = "starlette-0.35.1.tar.gz", hash = "sha256:3e2639dac3520e4f58734ed22553f950d3f3cb1001cd2eaac4d57e8cdc5f66bc"}, +] + +[package.dependencies] +anyio = ">=3.4.0,<5" +typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} + +[package.extras] +full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart", "pyyaml"] + +[[package]] +name = "typing-extensions" +version = "4.9.0" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"}, + {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"}, +] + +[[package]] +name = "uvicorn" +version = "0.27.0.post1" +description = "The lightning-fast ASGI server." +optional = false +python-versions = ">=3.8" +files = [ + {file = "uvicorn-0.27.0.post1-py3-none-any.whl", hash = "sha256:4b85ba02b8a20429b9b205d015cbeb788a12da527f731811b643fd739ef90d5f"}, + {file = "uvicorn-0.27.0.post1.tar.gz", hash = "sha256:54898fcd80c13ff1cd28bf77b04ec9dbd8ff60c5259b499b4b12bb0917f22907"}, +] + +[package.dependencies] +click = ">=7.0" +h11 = ">=0.8" +typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} + +[package.extras] +standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.9" +content-hash = "3f85c224110f9432dab85aaffc90d271b06475dfb7ab97e89d8817d2afeab223" diff --git a/mirrors/proxy/cached.py b/mirrors/proxy/cached.py new file mode 100644 index 0000000..b527ba4 --- /dev/null +++ b/mirrors/proxy/cached.py @@ -0,0 +1,107 @@ +import logging +import os +import pathlib +import typing +from asyncio import sleep +from enum import Enum +from urllib.parse import urlparse, quote + +import httpx +from starlette.requests import Request +from starlette.responses import Response +from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR, HTTP_504_GATEWAY_TIMEOUT + +from aria2_api import add_download + +from config import CACHE_DIR, EXTERNAL_URL_ARIA2, PROXY + +logger = logging.getLogger(__name__) + + +def get_cache_file_and_folder(url: str) -> typing.Tuple[str, str]: + parsed_url = urlparse(url) + base_dir = pathlib.Path(CACHE_DIR) + assert parsed_url.path[0] == "/" + assert parsed_url.path[-1] != "/" + cache_file = (base_dir / parsed_url.hostname / parsed_url.path[1:]).resolve() + assert cache_file.is_relative_to(base_dir) + + return str(cache_file), os.path.dirname(cache_file) + + +class DownloadingStatus(Enum): + DOWNLOADING = 1 + DOWNLOADED = 2 + NOT_FOUND = 3 + + +def lookup_cache(url: str) -> DownloadingStatus: + cache_file, _ = get_cache_file_and_folder(url) + + cache_file_aria2 = f"{cache_file}.aria2" + if os.path.exists(cache_file_aria2): + return DownloadingStatus.DOWNLOADING + + if os.path.exists(cache_file): + assert not os.path.isdir(cache_file) + return DownloadingStatus.DOWNLOADED + return DownloadingStatus.NOT_FOUND + + +def make_cached_response(url): + cache_file, _ = get_cache_file_and_folder(url) + + assert os.path.exists(cache_file) + assert not os.path.isdir(cache_file) + with open(cache_file, "rb") as f: + content = f.read() + return Response(content=content, status_code=200) + + +async def get_url_content_length(url): + async with httpx.AsyncClient(proxy=PROXY, verify=False) as client: + head_response = await client.head(url) + content_len = (head_response.headers.get("content-length", None)) + return content_len + + +async def try_get_cache( + request: Request, + target_url: str, + download_wait_time: int = 60, + post_process: typing.Callable[[Request, Response], Response] = None, +) -> typing.Optional[Response]: + cache_status = lookup_cache(target_url) + if cache_status == DownloadingStatus.DOWNLOADED: + resp = make_cached_response(target_url) + if post_process: + resp = post_process(request, resp) + return resp + + if cache_status == DownloadingStatus.DOWNLOADING: + logger.info(f"Download is not finished, return 503 for {target_url}") + return Response(content=f"This file is downloading, view it at {EXTERNAL_URL_ARIA2}", + status_code=HTTP_504_GATEWAY_TIMEOUT) + + assert cache_status == DownloadingStatus.NOT_FOUND + + cache_file, cache_file_dir = get_cache_file_and_folder(target_url) + print("prepare to download", target_url, cache_file, cache_file_dir) + + processed_url = quote(target_url, safe='/:?=&') + + try: + await add_download(processed_url, save_dir=cache_file_dir) + except Exception as e: + logger.error(f"Download error, return 503500 for {target_url}", exc_info=e) + return Response(content=f"Failed to add download: {e}", status_code=HTTP_500_INTERNAL_SERVER_ERROR) + + # wait for download finished + for _ in range(download_wait_time): + await sleep(1) + cache_status = lookup_cache(target_url) + if cache_status == DownloadingStatus.DOWNLOADED: + return make_cached_response(target_url) + logger.info(f"Download is not finished, return 503 for {target_url}") + return Response(content=f"This file is downloading, view it at {EXTERNAL_URL_ARIA2}", + status_code=HTTP_504_GATEWAY_TIMEOUT) diff --git a/mirrors/proxy/direct.py b/mirrors/proxy/direct.py new file mode 100644 index 0000000..aa42145 --- /dev/null +++ b/mirrors/proxy/direct.py @@ -0,0 +1,47 @@ +import typing + +import httpx +import starlette.requests +import starlette.responses + +from config import PROXY + + +async def direct_proxy( + request: starlette.requests.Request, + target_url: str, + pre_process: typing.Callable[[starlette.requests.Request, httpx.Request], httpx.Request] = None, + post_process: typing.Callable[[starlette.requests.Request, httpx.Response], httpx.Response] = None, +) -> typing.Optional[starlette.responses.Response]: + async with httpx.AsyncClient(proxy=PROXY, verify=False) as client: + + headers = request.headers.mutablecopy() + for key in headers.keys(): + if key not in ["user-agent", "accept"]: + del headers[key] + + httpx_req = client.build_request(request.method, target_url, headers=headers, ) + + if pre_process: + httpx_req = pre_process(request, httpx_req) + upstream_response = await client.send(httpx_req) + + # TODO: move to post_process + if upstream_response.status_code == 307: + location = upstream_response.headers["location"] + print("catch redirect", location) + + headers = upstream_response.headers + cl = headers.pop("content-length", None) + ce = headers.pop("content-encoding", None) + # print(target_url, cl, ce) + content = upstream_response.content + response = starlette.responses.Response( + headers=headers, + content=content, + status_code=upstream_response.status_code) + + if post_process: + response = post_process(request, response) + + return response diff --git a/mirrors/pyproject.toml b/mirrors/pyproject.toml new file mode 100644 index 0000000..9c57e77 --- /dev/null +++ b/mirrors/pyproject.toml @@ -0,0 +1,17 @@ +[tool.poetry] +name = "light-mirrors" +version = "0.1.0" +description = "" +authors = ["'Anonymous' <'<>'>"] +readme = "README.md" + +[tool.poetry.dependencies] +python = "^3.9" +fastapi = "^0.109.0" +uvicorn = "^0.27.0.post1" +httpx = "^0.26.0" + + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/mirrors/requirements.txt b/mirrors/requirements.txt new file mode 100644 index 0000000..9649bbb --- /dev/null +++ b/mirrors/requirements.txt @@ -0,0 +1,128 @@ +annotated-types==0.6.0 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43 \ + --hash=sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d +anyio==4.2.0 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:745843b39e829e108e518c489b31dc757de7d2131d53fac32bd8df268227bfee \ + --hash=sha256:e1875bb4b4e2de1669f4bc7869b6d3f54231cdced71605e6e64c9be77e3be50f +certifi==2024.2.2 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f \ + --hash=sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1 +click==8.1.7 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28 \ + --hash=sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de +colorama==0.4.6 ; python_version >= "3.9" and python_version < "4.0" and platform_system == "Windows" \ + --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 +exceptiongroup==1.2.0 ; python_version >= "3.9" and python_version < "3.11" \ + --hash=sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14 \ + --hash=sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68 +fastapi==0.109.0 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:8c77515984cd8e8cfeb58364f8cc7a28f0692088475e2614f7bf03275eba9093 \ + --hash=sha256:b978095b9ee01a5cf49b19f4bc1ac9b8ca83aa076e770ef8fd9af09a2b88d191 +h11==0.14.0 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d \ + --hash=sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761 +httpcore==1.0.2 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:096cc05bca73b8e459a1fc3dcf585148f63e534eae4339559c9b8a8d6399acc7 \ + --hash=sha256:9fc092e4799b26174648e54b74ed5f683132a464e95643b226e00c2ed2fa6535 +httpx==0.26.0 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:451b55c30d5185ea6b23c2c793abf9bb237d2a7dfb901ced6ff69ad37ec1dfaf \ + --hash=sha256:8915f5a3627c4d47b73e8202457cb28f1266982d1159bd5779d86a80c0eab1cd +idna==3.6 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca \ + --hash=sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f +pydantic-core==2.16.1 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:06f0d5a1d9e1b7932477c172cc720b3b23c18762ed7a8efa8398298a59d177c7 \ + --hash=sha256:07982b82d121ed3fc1c51faf6e8f57ff09b1325d2efccaa257dd8c0dd937acca \ + --hash=sha256:0f478ec204772a5c8218e30eb813ca43e34005dff2eafa03931b3d8caef87d51 \ + --hash=sha256:102569d371fadc40d8f8598a59379c37ec60164315884467052830b28cc4e9da \ + --hash=sha256:10dca874e35bb60ce4f9f6665bfbfad050dd7573596608aeb9e098621ac331dc \ + --hash=sha256:150ba5c86f502c040b822777e2e519b5625b47813bd05f9273a8ed169c97d9ae \ + --hash=sha256:1661c668c1bb67b7cec96914329d9ab66755911d093bb9063c4c8914188af6d4 \ + --hash=sha256:1a2fe7b00a49b51047334d84aafd7e39f80b7675cad0083678c58983662da89b \ + --hash=sha256:1ae8048cba95f382dba56766525abca438328455e35c283bb202964f41a780b0 \ + --hash=sha256:20f724a023042588d0f4396bbbcf4cffd0ddd0ad3ed4f0d8e6d4ac4264bae81e \ + --hash=sha256:2133b0e412a47868a358713287ff9f9a328879da547dc88be67481cdac529118 \ + --hash=sha256:21e3298486c4ea4e4d5cc6fb69e06fb02a4e22089304308817035ac006a7f506 \ + --hash=sha256:21ebaa4bf6386a3b22eec518da7d679c8363fb7fb70cf6972161e5542f470798 \ + --hash=sha256:23632132f1fd608034f1a56cc3e484be00854db845b3a4a508834be5a6435a6f \ + --hash=sha256:2d5bea8012df5bb6dda1e67d0563ac50b7f64a5d5858348b5c8cb5043811c19d \ + --hash=sha256:300616102fb71241ff477a2cbbc847321dbec49428434a2f17f37528721c4948 \ + --hash=sha256:30a8259569fbeec49cfac7fda3ec8123486ef1b729225222f0d41d5f840b476f \ + --hash=sha256:399166f24c33a0c5759ecc4801f040dbc87d412c1a6d6292b2349b4c505effc9 \ + --hash=sha256:3fac641bbfa43d5a1bed99d28aa1fded1984d31c670a95aac1bf1d36ac6ce137 \ + --hash=sha256:42c29d54ed4501a30cd71015bf982fa95e4a60117b44e1a200290ce687d3e640 \ + --hash=sha256:462d599299c5971f03c676e2b63aa80fec5ebc572d89ce766cd11ca8bcb56f3f \ + --hash=sha256:4eebbd049008eb800f519578e944b8dc8e0f7d59a5abb5924cc2d4ed3a1834ff \ + --hash=sha256:502c062a18d84452858f8aea1e520e12a4d5228fc3621ea5061409d666ea1706 \ + --hash=sha256:5317c04349472e683803da262c781c42c5628a9be73f4750ac7d13040efb5d2d \ + --hash=sha256:5511f962dd1b9b553e9534c3b9c6a4b0c9ded3d8c2be96e61d56f933feef9e1f \ + --hash=sha256:561be4e3e952c2f9056fba5267b99be4ec2afadc27261505d4992c50b33c513c \ + --hash=sha256:601d3e42452cd4f2891c13fa8c70366d71851c1593ed42f57bf37f40f7dca3c8 \ + --hash=sha256:644904600c15816a1f9a1bafa6aab0d21db2788abcdf4e2a77951280473f33e1 \ + --hash=sha256:653a5dfd00f601a0ed6654a8b877b18d65ac32c9d9997456e0ab240807be6cf7 \ + --hash=sha256:694a5e9f1f2c124a17ff2d0be613fd53ba0c26de588eb4bdab8bca855e550d95 \ + --hash=sha256:71b4a48a7427f14679f0015b13c712863d28bb1ab700bd11776a5368135c7d60 \ + --hash=sha256:72bf9308a82b75039b8c8edd2be2924c352eda5da14a920551a8b65d5ee89253 \ + --hash=sha256:735dceec50fa907a3c314b84ed609dec54b76a814aa14eb90da31d1d36873a5e \ + --hash=sha256:73802194f10c394c2bedce7a135ba1d8ba6cff23adf4217612bfc5cf060de34c \ + --hash=sha256:780daad9e35b18d10d7219d24bfb30148ca2afc309928e1d4d53de86822593dc \ + --hash=sha256:8655f55fe68c4685673265a650ef71beb2d31871c049c8b80262026f23605ee3 \ + --hash=sha256:877045a7969ace04d59516d5d6a7dee13106822f99a5d8df5e6822941f7bedc8 \ + --hash=sha256:87bce04f09f0552b66fca0c4e10da78d17cb0e71c205864bab4e9595122cb9d9 \ + --hash=sha256:8d4dfc66abea3ec6d9f83e837a8f8a7d9d3a76d25c9911735c76d6745950e62c \ + --hash=sha256:8ec364e280db4235389b5e1e6ee924723c693cbc98e9d28dc1767041ff9bc388 \ + --hash=sha256:8fa00fa24ffd8c31fac081bf7be7eb495be6d248db127f8776575a746fa55c95 \ + --hash=sha256:920c4897e55e2881db6a6da151198e5001552c3777cd42b8a4c2f72eedc2ee91 \ + --hash=sha256:920f4633bee43d7a2818e1a1a788906df5a17b7ab6fe411220ed92b42940f818 \ + --hash=sha256:9795f56aa6b2296f05ac79d8a424e94056730c0b860a62b0fdcfe6340b658cc8 \ + --hash=sha256:98f0edee7ee9cc7f9221af2e1b95bd02810e1c7a6d115cfd82698803d385b28f \ + --hash=sha256:99c095457eea8550c9fa9a7a992e842aeae1429dab6b6b378710f62bfb70b394 \ + --hash=sha256:99d3a433ef5dc3021c9534a58a3686c88363c591974c16c54a01af7efd741f13 \ + --hash=sha256:99f9a50b56713a598d33bc23a9912224fc5d7f9f292444e6664236ae471ddf17 \ + --hash=sha256:9c46e556ee266ed3fb7b7a882b53df3c76b45e872fdab8d9cf49ae5e91147fd7 \ + --hash=sha256:9f5d37ff01edcbace53a402e80793640c25798fb7208f105d87a25e6fcc9ea06 \ + --hash=sha256:a0b4cfe408cd84c53bab7d83e4209458de676a6ec5e9c623ae914ce1cb79b96f \ + --hash=sha256:a497be217818c318d93f07e14502ef93d44e6a20c72b04c530611e45e54c2196 \ + --hash=sha256:ac89ccc39cd1d556cc72d6752f252dc869dde41c7c936e86beac5eb555041b66 \ + --hash=sha256:adf28099d061a25fbcc6531febb7a091e027605385de9fe14dd6a97319d614cf \ + --hash=sha256:afa01d25769af33a8dac0d905d5c7bb2d73c7c3d5161b2dd6f8b5b5eea6a3c4c \ + --hash=sha256:b1fc07896fc1851558f532dffc8987e526b682ec73140886c831d773cef44b76 \ + --hash=sha256:b49c604ace7a7aa8af31196abbf8f2193be605db6739ed905ecaf62af31ccae0 \ + --hash=sha256:b9f3e0bffad6e238f7acc20c393c1ed8fab4371e3b3bc311020dfa6020d99212 \ + --hash=sha256:ba07646f35e4e49376c9831130039d1b478fbfa1215ae62ad62d2ee63cf9c18f \ + --hash=sha256:bd88f40f2294440d3f3c6308e50d96a0d3d0973d6f1a5732875d10f569acef49 \ + --hash=sha256:c0be58529d43d38ae849a91932391eb93275a06b93b79a8ab828b012e916a206 \ + --hash=sha256:c45f62e4107ebd05166717ac58f6feb44471ed450d07fecd90e5f69d9bf03c48 \ + --hash=sha256:c56da23034fe66221f2208c813d8aa509eea34d97328ce2add56e219c3a9f41c \ + --hash=sha256:c94b5537bf6ce66e4d7830c6993152940a188600f6ae044435287753044a8fe2 \ + --hash=sha256:cebf8d56fee3b08ad40d332a807ecccd4153d3f1ba8231e111d9759f02edfd05 \ + --hash=sha256:d0bf6f93a55d3fa7a079d811b29100b019784e2ee6bc06b0bb839538272a5610 \ + --hash=sha256:d195add190abccefc70ad0f9a0141ad7da53e16183048380e688b466702195dd \ + --hash=sha256:d25ef0c33f22649b7a088035fd65ac1ce6464fa2876578df1adad9472f918a76 \ + --hash=sha256:d6cbdf12ef967a6aa401cf5cdf47850559e59eedad10e781471c960583f25aa1 \ + --hash=sha256:d8c032ccee90b37b44e05948b449a2d6baed7e614df3d3f47fe432c952c21b60 \ + --hash=sha256:daff04257b49ab7f4b3f73f98283d3dbb1a65bf3500d55c7beac3c66c310fe34 \ + --hash=sha256:e83ebbf020be727d6e0991c1b192a5c2e7113eb66e3def0cd0c62f9f266247e4 \ + --hash=sha256:ed3025a8a7e5a59817b7494686d449ebfbe301f3e757b852c8d0d1961d6be864 \ + --hash=sha256:f1936ef138bed2165dd8573aa65e3095ef7c2b6247faccd0e15186aabdda7f66 \ + --hash=sha256:f5247a3d74355f8b1d780d0f3b32a23dd9f6d3ff43ef2037c6dcd249f35ecf4c \ + --hash=sha256:fa496cd45cda0165d597e9d6f01e36c33c9508f75cf03c0a650018c5048f578e \ + --hash=sha256:fb4363e6c9fc87365c2bc777a1f585a22f2f56642501885ffc7942138499bf54 \ + --hash=sha256:fb4370b15111905bf8b5ba2129b926af9470f014cb0493a67d23e9d7a48348e8 \ + --hash=sha256:fbec2af0ebafa57eb82c18c304b37c86a8abddf7022955d1742b3d5471a6339e +pydantic==2.6.0 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:1440966574e1b5b99cf75a13bec7b20e3512e8a61b894ae252f56275e2c465ae \ + --hash=sha256:ae887bd94eb404b09d86e4d12f93893bdca79d766e738528c6fa1c849f3c6bcf +sniffio==1.3.0 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101 \ + --hash=sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384 +starlette==0.35.1 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:3e2639dac3520e4f58734ed22553f950d3f3cb1001cd2eaac4d57e8cdc5f66bc \ + --hash=sha256:50bbbda9baa098e361f398fda0928062abbaf1f54f4fadcbe17c092a01eb9a25 +typing-extensions==4.9.0 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783 \ + --hash=sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd +uvicorn==0.27.0.post1 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:4b85ba02b8a20429b9b205d015cbeb788a12da527f731811b643fd739ef90d5f \ + --hash=sha256:54898fcd80c13ff1cd28bf77b04ec9dbd8ff60c5259b499b4b12bb0917f22907 diff --git a/mirrors/server.py b/mirrors/server.py new file mode 100644 index 0000000..5c4a636 --- /dev/null +++ b/mirrors/server.py @@ -0,0 +1,58 @@ +import base64 +import signal +import urllib.parse + +import uvicorn +from fastapi import FastAPI +from starlette.requests import Request + +from config import BASE_DOMAIN, RPC_SECRET, EXTERNAL_URL_ARIA2, EXTERNAL_HOST_ARIA2 +from sites.docker import docker +from sites.npm import npm +from sites.pypi import pypi +from sites.torch import torch + +app = FastAPI() + + +@app.middleware("http") +async def capture_request(request: Request, call_next: callable): + hostname = request.url.hostname + if not hostname.endswith(f".{BASE_DOMAIN}"): + return await call_next(request) + + if hostname.startswith("pypi."): + return await pypi(request) + if hostname.startswith("torch."): + return await torch(request) + if hostname.startswith("docker."): + return await docker(request) + if hostname.startswith("npm."): + return await npm(request) + + return await call_next(request) + + +if __name__ == '__main__': + signal.signal(signal.SIGINT, signal.SIG_DFL) + port = 8080 + print(f"Server started at https://*.{BASE_DOMAIN})") + + for dn in ["pypi", "torch", "docker", "npm"]: + print(f" - https://{dn}.{BASE_DOMAIN}") + + aria2_secret = base64.b64encode(RPC_SECRET.encode()).decode() + + params = { + 'protocol': 'https', + 'host': EXTERNAL_HOST_ARIA2, + 'port': '443', + 'interface': 'jsonrpc', + 'secret': aria2_secret + } + + query_string = urllib.parse.urlencode(params) + aria2_url_with_auth = EXTERNAL_URL_ARIA2 + "/#!/settings/rpc/set?" + query_string + + print(f"Download manager (Aria2) at {aria2_url_with_auth}") + uvicorn.run(app="server:app", host="0.0.0.0", port=port, reload=True, proxy_headers=True, forwarded_allow_ips="*") diff --git a/mirrors/sites/docker.py b/mirrors/sites/docker.py new file mode 100644 index 0000000..4ab4c2c --- /dev/null +++ b/mirrors/sites/docker.py @@ -0,0 +1,100 @@ +import base64 +import json +import re +import time + +import httpx +from starlette.requests import Request +from starlette.responses import Response + +from proxy.direct import direct_proxy + +BASE_URL = "https://registry-1.docker.io" + +cached_token = { + +} + +# https://github.com/opencontainers/distribution-spec/blob/main/spec.md +name_regex = "[a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*(\/[a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*)*" +reference_regex = "[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}" + + +def try_extract_image_name(path): + pattern = rf"^/v2/(.*)/([a-zA-Z]+)/(.*)$" + match = re.search(pattern, path) + + if match: + assert len(match.groups()) == 3 + name, operation, reference = match.groups() + assert re.match(name_regex, name) + assert re.match(reference_regex, reference) + assert operation in ["manifests", "blobs"] + return name, operation, reference + + return None, None, None + + +def get_docker_token(name): + cached = cached_token.get(name, {}) + exp = cached.get("exp", 0) + + if exp > time.time(): + return cached.get("token", 0) + + url = "https://auth.docker.io/token" + params = { + "scope": f"repository:{name}:pull", + "service": "registry.docker.io", + } + + response = httpx.get(url, params=params, verify=False) + response.raise_for_status() + + token_data = response.json() + token = token_data["token"] + payload = (token.split(".")[1]) + padding = len(payload) % 4 + payload += "=" * padding + + payload = json.loads(base64.b64decode(payload)) + assert payload["iss"] == "auth.docker.io" + assert len(payload["access"]) > 0 + + cached_token[name] = { + "exp": payload["exp"], + "token": token + } + + return token + + +async def docker(request: Request): + path = request.url.path + print("[request]", request.method, request.url) + if not path.startswith("/v2/"): + return Response(content="Not Found", status_code=404) + + if path == "/v2/": + return Response(content="OK") + # return await direct_proxy(request, BASE_URL + '/v2/') + + name, operation, reference = try_extract_image_name(path) + + if not name: + return Response(content='404 Not Found', status_code=404) + + # support docker pull xxx which name without library + if '/' not in name: + name = f"library/{name}" + + target_url = BASE_URL + f"/v2/{name}/{operation}/{reference}" + + print('[PARSED]', path, name, operation, reference, target_url) + + def inject_token(req, httpx_req): + docker_token = get_docker_token(f"{name}") + httpx_req.headers["Authorization"] = f"Bearer {docker_token}" + return httpx_req + + return await direct_proxy(request, target_url, pre_process=inject_token) diff --git a/mirrors/sites/npm.py b/mirrors/sites/npm.py new file mode 100644 index 0000000..8912619 --- /dev/null +++ b/mirrors/sites/npm.py @@ -0,0 +1,11 @@ +from starlette.requests import Request + +from proxy.direct import direct_proxy + +BASE_URL = "https://registry.npmjs.org/" + + +async def npm(request: Request): + path = request.url.path + + return await direct_proxy(request, BASE_URL + path) diff --git a/mirrors/sites/pypi.py b/mirrors/sites/pypi.py new file mode 100644 index 0000000..a85f3e5 --- /dev/null +++ b/mirrors/sites/pypi.py @@ -0,0 +1,44 @@ +import re + +from starlette.requests import Request +from starlette.responses import Response + +from proxy.direct import direct_proxy +from proxy.cached import try_get_cache + +pypi_file_base_url = "https://files.pythonhosted.org" +pypi_base_url = "https://pypi.org" + + +def pypi_replace(request: Request, response: Response) -> Response: + is_detail_page = re.search(r"/simple/([^/]+)/", request.url.path) is not None + if not is_detail_page: + return response + + if is_detail_page: + mirror_url = f"{request.url.scheme}://{request.url.netloc}" + content = response.body + content = content.replace(pypi_file_base_url.encode(), mirror_url.encode()) + response.body = content + del response.headers["content-length"] + del response.headers["content-encoding"] + return response + + +async def pypi(request: Request) -> Response: + # TODO: a debug flag to show origin url + path = request.url.path + if path == "/simple": + path = "/simple/" + + if path.startswith("/simple/"): + target_url = pypi_base_url + path + elif path.startswith("/packages/"): + target_url = pypi_file_base_url + path + else: + return Response(content="Not Found", status_code=404) + + if path.endswith(".whl") or path.endswith(".tar.gz"): + return await try_get_cache(request, target_url) + + return await direct_proxy(request, target_url, post_process=pypi_replace) diff --git a/mirrors/sites/torch.py b/mirrors/sites/torch.py new file mode 100644 index 0000000..17a2c04 --- /dev/null +++ b/mirrors/sites/torch.py @@ -0,0 +1,24 @@ +from starlette.requests import Request +from starlette.responses import Response + +from proxy.cached import try_get_cache +from proxy.direct import direct_proxy + +BASE_URL = "https://download.pytorch.org" + + +async def torch(request: Request): + path = request.url.path + + if not path.startswith("/whl/"): + return Response(content="Not Found", status_code=404) + + if path == "/whl": + path = "/whl/" + + target_url = BASE_URL + path + + if path.endswith(".whl") or path.endswith(".tar.gz"): + return await try_get_cache(request, target_url) + + return await direct_proxy(request, target_url, )