From b25b7c41e96589ed9b14ed0ac1bf88c8bed77c4d Mon Sep 17 00:00:00 2001 From: Dongyanmio Date: Sat, 12 Oct 2024 23:06:08 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=81=A2=E5=A4=8D=E6=89=80?= =?UTF-8?q?=E6=9C=89=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 168 ++++++++++++++++++ .vscode/settings.json | 3 + LICENSE | 21 +++ README.md | 54 ++++++ core/__init__.py | 290 +++++++++++++++++++++++++++++++ core/config.py | 25 +++ core/const.py | 114 ++++++++++++ core/dns/cloudflare.py | 184 ++++++++++++++++++++ core/filesdb.py | 120 +++++++++++++ core/logger.py | 39 +++++ core/mdb.py | 130 ++++++++++++++ core/plugins.py | 11 ++ core/routes/agent.py | 81 +++++++++ core/routes/api/v0.py | 51 ++++++ core/routes/openbmclapi-agent.py | 0 core/routes/openbmclapi.py | 60 +++++++ core/routes/services.py | 33 ++++ core/sync.py | 34 ++++ core/types.py | 262 ++++++++++++++++++++++++++++ core/utils.py | 93 ++++++++++ main.py | 16 ++ renovate.json | 6 + requirements.txt | Bin 0 -> 1898 bytes test.py | 15 ++ todo.md | 4 + 25 files changed, 1814 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 LICENSE create mode 100644 README.md create mode 100644 core/__init__.py create mode 100644 core/config.py create mode 100644 core/const.py create mode 100644 core/dns/cloudflare.py create mode 100644 core/filesdb.py create mode 100644 core/logger.py create mode 100644 core/mdb.py create mode 100644 core/plugins.py create mode 100644 core/routes/agent.py create mode 100644 core/routes/api/v0.py create mode 100644 core/routes/openbmclapi-agent.py create mode 100644 core/routes/openbmclapi.py create mode 100644 core/routes/services.py create mode 100644 core/sync.py create mode 100644 core/types.py create mode 100644 core/utils.py create mode 100644 main.py create mode 100644 renovate.json create mode 100644 requirements.txt create mode 100644 test.py create mode 100644 todo.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f45a70 --- /dev/null +++ b/.gitignore @@ -0,0 +1,168 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ + +# iodine-at-home +config.yml +data/ +files/ +plugins/ \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..223c17d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python.analysis.typeCheckingMode": "off", +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6129d97 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Zero Nexis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7922015 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +> 我们不推荐您使用该项目,建议转向由 SALTWOOD 开发的 [Open93@Home](https://github.com/SaltWood-Studio/Open93AtHome-V3) 项目
Tips: 推荐搭配由 Mxmilu666 开发的 [93Home-Dash](https://github.com/Mxmilu666/93Home-Dash) 一起运行。 + +
+ iodine-at-home + +# iodine@home + +_✨ 开源的文件分发主控,并尝试兼容 OpenBMCLAPI 客户端 ✨_ + + + license + + + python + +
+ + + + + + + +## 📖 介绍 + +基于 [FastAPI](https://fastapi.tiangolo.com/) 和 [Socket.IO](https://socket.io/) 的 Python 文件分发主控,建立目的为复刻 OpenBMCLAPI 主控。 + +## 📚 文档 + +## 📄 许可证 +本项目采用 `MIT License` 协议开源 + +## 💡 特别鸣谢 + +[**bangbang93**](https://github.com/bangbang93) +- [OpenBMCLAPI](https://github.com/bangbang93/openbmclapi) - 使用其 API 完成本项目与 OpenBMCLAPI 客户端的兼容。 + +[**8Mi_Yile**](https://github.com/8MiYile) +- 各种逆天言论,使 [bangbang93HUB](https://github.com/Mxmilu666/bangbang93HUB) 能持续更新至今,并给予我创建项目的灵感。 + +[**SALTWOOD**](https://github.com/SALTWOOD) +- [93@Home](https://github.com/SaltWood-Studio/Open93AtHome) - 提供了创建该项目的灵感及参考。 +- [CSharp-OpenBMCLAPI](https://github.com/SaltWood-Studio/CSharp-OpenBMCLAPI) - 提供了 README 文件的参考。 +- 回答了我提过的许多弱智问题,推动了项目的实现。 +- 提供了原生实现 Avro 的部分代码。 + +[**tianxiu2b2t**](https://github.com/tianxiu2b2t) +- [python-openbmclapi](https://github.com/TTB-Network/python-openbmclapi) - 提供了原生实现 Avro 的部分代码。 + +[**Mxmilu666**](https://github.com/Mxmilu666) +- [bangbang93HUB](https://github.com/Mxmilu666/bangbang93HUB) - 提供了创建该项目的灵感。 + +[**群内的各位大佬们**](https://qm.qq.com/q/2OfvVrAwVG)(详细名单见贡献者列表) +- 参加我发出去的 Live Share,让项目更快得以实现。 \ No newline at end of file diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..cdcb4dc --- /dev/null +++ b/core/__init__.py @@ -0,0 +1,290 @@ +# 第三方库 +import re +import time +import pytz +import asyncio +import uvicorn +import importlib +from pluginbase import PluginBase +from fastapi import FastAPI, Response +from datetime import datetime, timezone +from datetime import datetime, timedelta +from contextlib import asynccontextmanager +from dateutil.relativedelta import relativedelta +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse + +import socketio +from socketio.asgi import ASGIApp + +# 本地库 +from core.mdb import cdb +import core.const as const +import core.utils as utils +from core.logger import logger +from core.config import config +from core.types import Cluster, oclm +from core.filesdb import FilesDB +from core.dns.cloudflare import cf_client + +# 路由库 +from core.routes.agent import router as agent_router +from core.routes.openbmclapi import router as openbmclapi_router +from core.routes.services import router as services_router +from core.routes.api.v0 import router as api_v0_router + +# 网页部分 +@asynccontextmanager +async def lifespan(app: FastAPI): + logger.info( + f"正在 {config.get('host')}:{config.get('port')} 上监听服务器..." + ) + yield + async with FilesDB() as db: + await db.close() + logger.success("主控退出成功。") + +app = FastAPI( + title="iodine@home", + summary="开源的文件分发主控,并尝试兼容 OpenBMCLAPI 客户端", + version="2.0.0", + license_info={ + "name": "The MIT License", + "url": "https://raw.githubusercontent.com/ZeroNexis/iodine-at-home/main/LICENSE", + }, + lifespan=lifespan +) + +app.include_router(agent_router, prefix="/openbmclapi-agent") +app.include_router(openbmclapi_router, prefix="/openbmclapi") +app.include_router(services_router) +app.include_router(api_v0_router, prefix="/api/v0") + +## 跨域设置 +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# 插件部分 +async def load_plugins(): + global app + plugin_base = PluginBase(package="plugin") + plugin_source = plugin_base.make_plugin_source(searchpath=["./plugins"]) + for plugin_name in plugin_source.list_plugins(): + logger.info(f"插件 {plugin_name} 加载中...") + plugin = importlib.import_module("plugins." + plugin_name) + logger.info(f"插件「{plugin.__NAME__}」加载成功!") + if hasattr(plugin, "__API__") and plugin.__API__: + if hasattr(plugin, "router"): + app.include_router(plugin.router, prefix=f"/{plugin.__NAMESPACE__}") + logger.success(f"已注册插件 API 路由:{plugin.__NAMESPACE__}, {plugin.router.routes}") + else: + logger.warning( + f"插件「{plugin.__NAME__}」未定义 Router ,无法加载该插件的路径!" + ) + await plugin.init() + + +# SocketIO 部分 +sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*") +socket = ASGIApp(sio) + +# 核心功能 +@app.middleware("http") +async def _(request,call_next): + start_time = datetime.now() + response = await call_next(request) + process_time = (datetime.now() - start_time).total_seconds() + response_size = len(response.body) if hasattr(response, 'body') else 0 + referer = request.headers.get('Referer') + user_agent = request.headers.get('user-agent', '-') + logger.info( + f"Serve {response.status_code} | {process_time:.2f}s | {response_size}B | " + f"{request.client.host} | {request.method} | {request.url.path} | \"{user_agent}\" | \"{referer}\"" + ) + return response + +## 节点端连接时 +@sio.on("connect") +async def on_connect(sid, *args): + token_pattern = r"'token': '(.*?)'" + token = re.search(token_pattern, str(args)).group(1) + if token.isspace(): + sio.disconnect(sid) + logger.debug(f"客户端 {sid} 连接失败: 缺少 token 令牌") + cluster = Cluster(utils.decode_jwt(token)["cluster_id"]) + if await cluster.initialize() == False: + sio.disconnect(sid) + logger.debug(f"客户端 {sid} 连接失败: 集群 {cluster.id} 不存在") + if cluster.secret == utils.decode_jwt(token)["cluster_secret"]: + await sio.save_session( + sid, + { + "cluster_id": cluster.id, + "cluster_secret": cluster.secret, + "token": token, + }, + ) + logger.debug(f"客户端 {sid} 连接成功: CLUSTER_ID = {cluster.id}") + await sio.emit( + "message", + "欢迎使用 iodine@home,本项目已在 https://github.com/ZeroNexis/iodine-at-home 开源,期待您的贡献与支持。", + sid, + ) + else: + sio.disconnect(sid) + logger.debug(f"节点 {sid} 连接失败: 认证出错") + + +## 当节点端退出连接时 +@sio.on("disconnect") +async def on_disconnect(sid, *args): + session = await sio.get_session(sid) + cluster = Cluster(str(session["cluster_id"])) + cluster_is_exist = await cluster.initialize() + if cluster_is_exist and oclm.include(cluster.id): + oclm.remove(cluster.id) + logger.debug(f"{sid} 异常断开连接,已从在线列表中删除") + else: + logger.debug(f"节点 {sid} 断开了连接") + + +## 节点请求证书时 +@sio.on("request-cert") +async def on_cluster_request_cert(sid, *args): + session = await sio.get_session(sid) + cluster = Cluster(str(session["cluster_id"])) + cluster_is_exist = await cluster.initialize() + if cluster_is_exist == False: + return [{"message": "错误: 节点似乎并不存在,请检查配置文件"}] + logger.debug(f"节点 {cluster.id} 请求证书") + if cluster.cert_fullchain != "" and cluster.cert_privkey != "" and cluster.cert_expiry != "" and cluster.cert_expiry > datetime.now(pytz.utc).strftime('%Y-%m-%dT%H:%M:%S+00:00'): + return [ + None, { + "_id": cluster.id, + "clusterId": cluster.id, + "cert": cluster.cert_fullchain, + "key": cluster.cert_privkey, + "expires": cluster.cert_expiry, + "__v": 0 + } + ] + else: + cert, key = await cf_client.get_certificate(f"{cluster.id}.{config.get('cluster-certificate.domain')}") + if cert == None or key == None: + return [{"message": "错误: 证书获取失败,请重新尝试。"}] + current_time = datetime.now(pytz.utc) + future_time = current_time + relativedelta(months=3) + formatted_time = future_time.astimezone(pytz.utc).strftime('%Y-%m-%dT%H:%M:%S+00:00') + await cluster.edit(cert_fullchain=cert, cert_privkey=key, cert_expiry=formatted_time) + return [ + None, { + "_id": cluster.id, + "clusterId": cluster.id, + "cert": cluster.cert_fullchain, + "key": cluster.cert_privkey, + "expires": cluster.cert_expiry, + "__v": 0 + } + ] + +## 节点启动时 +@sio.on("enable") +async def on_cluster_enable(sid, data: dict, *args): + # {'host': '127.0.0.1', 'port': 11451, 'version': '1.11.0', 'byoc': True, 'noFastEnable': True, 'flavor': {'runtime': 'python/3.12.2 python-openbmclapi/2.1.1', 'storage': 'file'}} + session = await sio.get_session(sid) + cluster = Cluster(str(session["cluster_id"])) + cluster_is_exist = await cluster.initialize() + if cluster_is_exist == False: + return [{"message": "错误: 节点似乎并不存在,请检查配置文件"}] + if oclm.include(cluster.id): + return [{"message": "错误: 节点已经在线,请检查配置文件"}] + host = data.get("host", data.get("ip")) + byoc = data.get("byoc", False) + if byoc == False: + all_records = await cf_client.get_all_records() + for record in all_records: + if cluster.id in record["name"]: + cf_id = record["id"] + break + else: + cf_id = None + if cf_id == None: + await cf_client.create_record(cluster.id, "A", data.get("host", data.get("ip"))) + else: + await cf_client.update_record(id, cluster.id, "A", data.get("host", data.get("ip"))) + host = f"{cluster.id}.{config.get('cluster-certificate.domain')}" + + await cluster.edit( + host=host, + port=data["port"], + version=data["version"], + runtime=data["flavor"]["runtime"], + ) + if data["version"] != const.latest_version: + await sio.emit( + "message", + f"当前版本已过时,推荐升级到 v{const.latest_version} 或以上版本。", + sid, + ) + time.sleep(1) + bandwidth = await utils.measure_cluster(10, cluster) + if bandwidth[0] and bandwidth[1] >= 10: + await cluster.edit(measureBandwidth=int(bandwidth[1])) + if cluster.trust < 0: + await sio.emit("message", "节点信任度过低,请保持稳定在线。", sid) + oclm.append(cluster.id) + logger.debug(f"节点 {cluster.id} 上线: 测量带宽 = {bandwidth[1]}Mbps") + return [None, True] + elif bandwidth[0] and bandwidth[1] < 10: + logger.debug(f"{cluster.id} 测速不合格: {bandwidth[1]}Mbps") + return [ + { + "message": f"错误: 测量带宽小于 10Mbps,(测量的带宽数值为 {bandwidth[1]}),请重试尝试上线" + } + ] + else: + logger.debug(f"{cluster.id} 测速失败: {bandwidth[1]}") + return [{"message": f"错误: {bandwidth[1]}"}] + +## 节点保活时 +@sio.on("keep-alive") +async def on_cluster_keep_alive(sid, data, *args): + session = await sio.get_session(sid) + cluster = Cluster(str(session["cluster_id"])) + cluster_is_exist = await cluster.initialize() + if cluster_is_exist == False or oclm.include(cluster.id) == False: + return [None, False] + logger.debug( + f"节点 {cluster.id} 保活成功: 次数 = {data["hits"]}, 数据量 = {utils.hum_convert(data['bytes'])}" + ) + return [None, datetime.now(timezone.utc).isoformat()] + +@sio.on("disable") ## 节点禁用时 +async def on_cluster_disable(sid, *args): + session = await sio.get_session(sid) + cluster = Cluster(str(session["cluster_id"])) + cluster_is_exist = await cluster.initialize() + if cluster_is_exist == False: + logger.debug("某节点尝试禁用集群失败: 节点不存在") + else: + try: + oclm.remove(cluster.id) + logger.debug(f"节点 {cluster.id} 禁用集群") + except ValueError: + logger.debug(f"节点 {cluster.id} 尝试禁用集群失败: 节点没有启用") + return [None, True] + +def init(): + logger.clear() + logger.info("加载中……") + try: + asyncio.run(load_plugins()) + app.mount("/", socket) + uvicorn.run(app, host=config.get('host'), port=config.get(path='port'), log_level='warning', access_log=False) + except Exception as e: + logger.error(e) diff --git a/core/config.py b/core/config.py new file mode 100644 index 0000000..840c4ad --- /dev/null +++ b/core/config.py @@ -0,0 +1,25 @@ +import yaml +from pathlib import Path + +class Config: + def __init__(self, config_file): + self.config_file = config_file + self.config = self.load_config() + + def load_config(self): + with open(self.config_file, "r", encoding="utf-8") as file: + config = yaml.safe_load(file) + return config + + def get(self, path: str, default=None): + keys = path.split(".") + data = self.config + try: + for key in keys: + data = data[key] + return data + except (KeyError, TypeError): + return default + + +config = Config(Path("./config.yml")) \ No newline at end of file diff --git a/core/const.py b/core/const.py new file mode 100644 index 0000000..256034f --- /dev/null +++ b/core/const.py @@ -0,0 +1,114 @@ +latest_version = "1.12.1" +user_agent = "iodine-ctrl/2.0.0" +jwt_iss = "iodine@home" + +# 随机 User-Agent 列表 +top_ua_list = [ + "Mozilla/4.0(compatible;MSIE7.0;WindowsNT5.1;AvantBrowser)", + "Mozilla/4.0(compatible;MSIE7.0;WindowsNT5.1;360SE)", + "Mozilla/4.0(compatible;MSIE7.0;WindowsNT5.1;Trident/4.0;SE2.XMetaSr1.0;SE2.XMetaSr1.0;.NETCLR2.0.50727;SE2.XMetaSr1.0)", + "Mozilla/4.0(compatible;MSIE7.0;WindowsNT5.1;TheWorld)", + "Mozilla/4.0(compatible;MSIE7.0;WindowsNT5.1;TencentTraveler4.0)", + "Opera/9.80(Macintosh;IntelMacOSX10.6.8;U;en)Presto/2.8.131Version/11.11", + "Mozilla/5.0(WindowsNT6.1;rv:2.0.1)Gecko/20100101Firefox/4.0.1", + "Mozilla/5.0(compatible;MSIE9.0;WindowsNT6.1;Trident/5.0", + "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/39.0.2171.95 Safari/537.36 OPR/26.0.1656.60", + "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/30.0.1599.101 Safari/537.36", + "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; QQDownload 732; .NET4.0C; .NET4.0E; LBBROWSER)", + "Mozilla/5.0 (Windows NT 5.1) AppleWebKit/535.11 (KHTML, like Gecko) Chrome/17.0.963.84 Safari/535.11 SE 2.X MetaSr 1.0", + "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/38.0.2125.122 UBrowser/4.0.3214.0 Safari/537.36", + "Mozilla/5.0 (X11; U; Linux x86_64; zh-CN; rv:1.9.2.10) Gecko/20100922 Ubuntu/10.10 (maverick) Firefox/3.6.10", + "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/535.1 (KHTML, like Gecko) Chrome/14.0.835.163 Safari/535.1", + "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; AcooBrowser; .NET CLR 1.1.4322; .NET CLR 2.0.50727)", + "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.0; Acoo Browser; SLCC1; .NET CLR 2.0.50727; Media Center PC 5.0; .NET CLR 3.0.04506)", + "Mozilla/4.0 (compatible; MSIE 7.0; AOL 9.5; AOLBuild 4337.35; Windows NT 5.1; .NET CLR 1.1.4322; .NET CLR 2.0.50727)", + "Mozilla/5.0 (Windows; U; MSIE 9.0; Windows NT 9.0; en-US)", + "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Win64; x64; Trident/5.0; .NET CLR 3.5.30729; .NET CLR 3.0.30729; .NET CLR 2.0.50727; Media Center PC 6.0)", + "Mozilla/5.0 (compatible; MSIE 8.0; Windows NT 6.0; Trident/4.0; WOW64; Trident/4.0; SLCC2; .NET CLR 2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30729; .NET CLR 1.0.3705; .NET CLR 1.1.4322)", + "Mozilla/4.0 (compatible; MSIE 7.0b; Windows NT 5.2; .NET CLR 1.1.4322; .NET CLR 2.0.50727; InfoPath.2; .NET CLR 3.0.04506.30)", + "Mozilla/5.0 (Windows; U; Windows NT 5.1; zh-CN) AppleWebKit/523.15 (KHTML, like Gecko, Safari/419.3) Arora/0.3 (Change: 287 c9dfb30)", + "Mozilla/5.0 (X11; U; Linux; en-US) AppleWebKit/527+ (KHTML, like Gecko, Safari/419.3) Arora/0.6", + "Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.8.1.2pre) Gecko/20070215 K-Ninja/2.1.1", + "Mozilla/5.0 (Windows; U; Windows NT 5.1; zh-CN; rv:1.9) Gecko/20080705 Firefox/3.0 Kapiko/3.0", + "Mozilla/5.0 (X11; Linux i686; U;) Gecko/20070322 Kazehakase/0.4.5", + "Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.9.0.8) Gecko Fedora/1.9.0.8-1.fc10 Kazehakase/0.5.6", + "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/535.11 (KHTML, like Gecko) Chrome/17.0.963.56 Safari/535.11", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_3) AppleWebKit/535.20 (KHTML, like Gecko) Chrome/19.0.1036.7 Safari/535.20", + "Opera/9.80 (Macintosh; Intel Mac OS X 10.6.8; U; fr) Presto/2.9.168 Version/11.52", + "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/39.0.2171.95 Safari/537.36 OPR/26.0.1656.60", + "Opera/8.0 (Windows NT 5.1; U; en)", + "Mozilla/5.0 (Windows NT 5.1; U; en; rv:1.8.1) Gecko/20061208 Firefox/2.0.0 Opera 9.50", + "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; en) Opera 9.50", + "Opera/9.80 (Macintosh; Intel Mac OS X 10.6.8; U; en) Presto/2.8.131 Version/11.11", + "Opera/9.80 (Windows NT 6.1; U; en) Presto/2.8.131 Version/11.11", + "Opera/9.80 (Android 2.3.4; Linux; Opera Mobi/build-1107180945; U; en-GB) Presto/2.8.149 Version/11.10", + "Mozilla/5.0 (Windows NT 6.1; WOW64; rv:34.0) Gecko/20100101 Firefox/34.0", + "Mozilla/5.0 (X11; U; Linux x86_64; zh-CN; rv:1.9.2.10) Gecko/20100922 Ubuntu/10.10 (maverick) Firefox/3.6.10", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.6; rv,2.0.1) Gecko/20100101 Firefox/4.0.1", + "Mozilla/5.0 (Windows NT 6.1; rv,2.0.1) Gecko/20100101 Firefox/4.0.1", + "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/534.57.2 (KHTML, like Gecko) Version/5.1.7 Safari/534.57.2", + "MAC: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.122 Safari/537.36", + "Windows: Mozilla/5.0 (Windows; U; Windows NT 6.1; en-us) AppleWebKit/534.50 (KHTML, like Gecko) Version/5.1 Safari/534.50", + "Mozilla/5.0 (iPhone; U; CPU iPhone OS 4_3_3 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8J2 Safari/6533.18.5", + "Mozilla/5.0 (iPhone; U; CPU iPhone OS 4_3_3 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8J2 Safari/6533.18.5", + "Mozilla/5.0 (iPad; U; CPU OS 4_3_3 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8J2 Safari/6533.18.5", + "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/39.0.2171.71 Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.64 Safari/537.11", + "Mozilla/5.0 (Windows; U; Windows NT 6.1; en-US) AppleWebKit/534.16 (KHTML, like Gecko) Chrome/10.0.648.133 Safari/534.16", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_0) AppleWebKit/535.11 (KHTML, like Gecko) Chrome/17.0.963.56 Safari/535.11", + "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/30.0.1599.101 Safari/537.36", + "Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; rv:11.0) like Gecko", + "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; 360SE)", + "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/536.11 (KHTML, like Gecko) Chrome/20.0.1132.11 TaoBrowser/2.0 Safari/536.11", + "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/21.0.1180.71 Safari/537.1 LBBROWSER", + "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0; SLCC2; .NET CLR 2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30729; Media Center PC 6.0; .NET4.0C; .NET4.0E; LBBROWSER)", + "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; QQDownload 732; .NET4.0C; .NET4.0E; LBBROWSER)" + "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0; SLCC2; .NET CLR 2.0.50727; .NET CLR 3.5.30729; .NET CLR 3.0.30729; Media Center PC 6.0; .NET4.0C; .NET4.0E; QQBrowser/7.0.3698.400)", + "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; QQDownload 732; .NET4.0C; .NET4.0E)", + "Mozilla/5.0 (Windows NT 5.1) AppleWebKit/535.11 (KHTML, like Gecko) Chrome/17.0.963.84 Safari/535.11 SE 2.X MetaSr 1.0", + "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; Trident/4.0; SV1; QQDownload 732; .NET4.0C; .NET4.0E; SE 2.X MetaSr 1.0)", + "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; Trident/4.0; SE 2.X MetaSr 1.0; SE 2.X MetaSr 1.0; .NET CLR 2.0.50727; SE 2.X MetaSr 1.0)", + "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Maxthon/4.4.3.4000 Chrome/30.0.1599.101 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_0) AppleWebKit/535.11 (KHTML, like Gecko) Chrome/17.0.963.56 Safari/535.11", + "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/38.0.2125.122 UBrowser/4.0.3214.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.87 UBrowser/6.2.4094.1 Safari/537.36", + "Mozilla/5.0 (iPhone; U; CPU iPhone OS 4_3_3 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8J2 Safari/6533.18.5", + "Mozilla/5.0 (iPod; U; CPU iPhone OS 4_3_3 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8J2 Safari/6533.18.5", + "Mozilla/5.0 (iPad; U; CPU OS 4_2_1 like Mac OS X; zh-cn) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8C148 Safari/6533.18.5", + "Mozilla/5.0 (iPad; U; CPU OS 4_3_3 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8J2 Safari/6533.18.5", + "Mozilla/5.0 (Linux; U; Android 2.2.1; zh-cn; HTC_Wildfire_A3333 Build/FRG83D) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1", + "Mozilla/5.0 (Linux; U; Android 2.3.7; en-us; Nexus One Build/FRF91) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1", + "MQQBrowser/26 Mozilla/5.0 (Linux; U; Android 2.3.7; zh-cn; MB200 Build/GRJ22; CyanogenMod-7) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1", + "Opera/9.80 (Android 2.3.4; Linux; Opera Mobi/build-1107180945; U; en-GB) Presto/2.8.149 Version/11.10", + "Mozilla/5.0 (Linux; U; Android 3.0; en-us; Xoom Build/HRI39) AppleWebKit/534.13 (KHTML, like Gecko) Version/4.0 Safari/534.13", + "Mozilla/5.0 (BlackBerry; U; BlackBerry 9800; en) AppleWebKit/534.1+ (KHTML, like Gecko) Version/6.0.0.337 Mobile Safari/534.1+", + "Mozilla/5.0 (hp-tablet; Linux; hpwOS/3.0.0; U; en-US) AppleWebKit/534.6 (KHTML, like Gecko) wOSBrowser/233.70 Safari/534.6 TouchPad/1.0", + "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0;", + "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.0)", + "Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 6.0; Trident/4.0)", + "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1)", + "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1)", + "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; The World)", + "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; TencentTraveler 4.0)", + "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; Avant Browser)", + "Mozilla/5.0 (Linux; U; Android 2.3.7; en-us; Nexus One Build/FRF91) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1", + "Mozilla/5.0 (SymbianOS/9.4; Series60/5.0 NokiaN97-1/20.0.019; Profile/MIDP-2.1 Configuration/CLDC-1.1) AppleWebKit/525 (KHTML, like Gecko) BrowserNG/7.1.18124", + "Mozilla/5.0 (compatible; MSIE 9.0; Windows Phone OS 7.5; Trident/5.0; IEMobile/9.0; HTC; Titan)", + "UCWEB7.0.2.37/28/999", + "NOKIA5700/ UCWEB7.0.2.37/28/999", + "Openwave/ UCWEB7.0.2.37/28/999", + "Openwave/ UCWEB7.0.2.37/28/999", + "Dalvik/2.1.0 (Linux; U; Android 12; SM-G9750 Build/SP1A.210812.016)", + # 友情客串 + "93@home-ctrl/1.1.5", + "bmclapi-ctrl/3.8.0", + "bmclapi-warden/0.2.1", + "openbmclapi-cluster/1.11.0", + "python-openbmclapi", + "go-openbmcapi", + "openbmclapi-cluster/1.11.0 (CSharp-OpenBMCLAPI; dotnet CLR v8.0.7; Microsoft Windows NT 10.0.22631.0, X64; zh-CN)", + "PCL2/2.8.3", + "HMCL/3.5.8.251 Java/17.0.12", + "BakaXL/3.0", + "FCL/1.1.7.4", +] diff --git a/core/dns/cloudflare.py b/core/dns/cloudflare.py new file mode 100644 index 0000000..cbf1024 --- /dev/null +++ b/core/dns/cloudflare.py @@ -0,0 +1,184 @@ +# 第三方库 +import re +import time +import acme +import acme.errors +import httpx +import josepy +import asyncio +import datetime +import cryptography +import acme.challenges +import acme.crypto_util +import cryptography.utils +import cryptography.x509 +from josepy.jwk import JWKRSA +from acme import client, messages +from acme import client as acme_client +from cryptography.x509.oid import NameOID +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.asymmetric import rsa + +# 本地库 +import core.const as const +from core.config import config + + +class CloudFlareAPI: + def __init__(self, email: str, api_token: str, zone_id: str): + self.email = email + self.api_token = api_token + self.zone_id = zone_id + self.headers = { + "Content-Type": "application/json", + "User-Agent": const.user_agent, + "Authorization": f"Bearer {self.api_token}", + } + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + pass + + async def get_all_records(self): + async with httpx.AsyncClient() as client: + data = await client.get( + f"https://api.cloudflare.com/client/v4/zones/{self.zone_id}/dns_records", + headers=self.headers, + ) + if data.status_code == 200: + result = list(data.json()["result"]) + return result + else: + return None + + async def create_record( + self, name: str, type: str, content: str, ttl: int = 60, proxied=False + ): + async with httpx.AsyncClient() as client: + data = await client.post( + f"https://api.cloudflare.com/client/v4/zones/{self.zone_id}/dns_records", + headers=self.headers, + json={ + "type": type, + "name": name, + "content": content, + "ttl": ttl, + "proxied": proxied, + }, + ) + if data.status_code == 200: + return data.json() + else: + return None + + async def delete_record(self, record_id: str): + async with httpx.AsyncClient() as client: + data = await client.delete( + f"https://api.cloudflare.com/client/v4/zones/{self.zone_id}/dns_records/{record_id}", + headers=self.headers, + ) + if data.status_code == 200: + return data.json() + else: + return None + + async def update_record( + self, + record_id: str, + name: str, + type: str, + content: str, + ttl: int = 60, + proxied=False, + ): + async with httpx.AsyncClient() as client: + data = await client.patch( + f"https://api.cloudflare.com/client/v4/zones/{self.zone_id}/dns_records/{record_id}" + ) + if data.status_code == 200: + return data.json() + else: + return None + + async def get_certificate(self, domain: str): + key = rsa.generate_private_key( + public_exponent=65537, key_size=4096 + ) # 生成一个 RSA 密钥对 + + ssl_key = rsa.generate_private_key( + public_exponent=65537, key_size=4096 + ) # 生成一个 RSA 密钥对 + + private_key_pem = ssl_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) # 将私钥转换为 PEM 格式 + + account_key = JWKRSA(key=key) # 创建一个 ACME 账户密钥对 + + net = acme_client.ClientNetwork( + account_key + ) # 创建一个网络对象,用于与 ACME 服务器通信 + + # 获取 ACME 目录 + directory = messages.Directory.from_json( + net.get("https://acme-v02.api.letsencrypt.org/directory").json() + ) + + client = acme_client.ClientV2(directory, net) # 创建一个 ACME 客户端 + + # 注册账号 + registration = client.new_account( + messages.NewRegistration.from_data( + email=config.get("cluster-certificate.email"), + terms_of_service_agreed=True, + ) + ) + + csr_pem = acme.crypto_util.make_csr(private_key_pem, [domain]) # 创建 CSR + + order = client.new_order(csr_pem) # 创建新订单 + order_authorizations = order.authorizations # 获取订单的授权列表 + for authorizations in order_authorizations: + challenges_list = authorizations.body.challenges + for challenge in challenges_list: + if isinstance(challenge.chall, acme.challenges.DNS01): + dns_challenge = challenge + dns_challenge_chall = challenge.chall + break + + validation = dns_challenge_chall.validation(account_key) + create_result = await self.create_record( + f"_acme-challenge.{domain}", "TXT", validation + ) + id = create_result["result"]["id"] + + # 等待一段时间让DNS记录传播 + time.sleep(30) # 可能需要更长的时间,取决于 DNS 提供商 + + # 使用ACME客户端回答DNS-01挑战 + response = dns_challenge_chall.response(account_key) + client.answer_challenge(dns_challenge, response) + deadline = datetime.datetime.now() + datetime.timedelta(seconds=60) + try: + finalize_order = client.poll_and_finalize(order, deadline) + + except acme.errors.ValidationError: + finalize_order = None + await self.delete_record(record_id=id) + break + if finalize_order is None: + return None, None + else: + return finalize_order.fullchain_pem, private_key_pem.decode() + + +cf_client = CloudFlareAPI( + config.get("cloudflare.email"), + config.get("cloudflare.api-token"), + config.get("cloudflare.zone-id"), +) \ No newline at end of file diff --git a/core/filesdb.py b/core/filesdb.py new file mode 100644 index 0000000..ee6a825 --- /dev/null +++ b/core/filesdb.py @@ -0,0 +1,120 @@ +import os +import atexit +import asyncio +import aiosqlite + + +class FilesDB: + def __init__(self): + self.conn = None + self.cursor = None + + async def __aenter__(self): + await self.connect() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.close() + + async def connect(self): + if not os.path.exists("./data/database.db"): + raise FileNotFoundError("数据库文件不存在") + self.conn = await aiosqlite.connect("./data/database.db") + self.cursor = await self.conn.cursor() + + async def close(self): + if self.conn: + await self.conn.close() + self.conn = None + self.cursor = None + + async def create_table(self): + await self.conn.execute( + """ + CREATE TABLE IF NOT EXISTS FILELIST + ( + HASH TEXT PRIMARY KEY, + PATH TEXT, + URL TEXT, + SIZE INTEGER, + MTIME INTEGER, + SOURCE TEXT + ) + """ + ) + await self.conn.commit() + await self.close() + + async def new_file( + self, + hash: str, + path: str = "", + url: str = "", + size: int = 0, + mtime: int = 0, + source: str = "local", + ): + await self.connect() + await self.conn.execute( + """ + INSERT INTO FILELIST ( + HASH, + PATH, + URL, + SIZE, + MTIME, + SOURCE + ) VALUES (?, ?, ?, ?, ?, ?) + """, + ( + hash, + path, + url, + size, + mtime, + source, + ), + ) + await self.conn.commit() + return True + + async def delete_file(self, hash: str): + await self.conn.execute( + """ + DELETE FROM FILELIST WHERE HASH = ? + """, + (hash,), + ) + await self.conn.commit() + return True + + async def delete_all(self): + await self.conn.execute( + """ + DELETE FROM FILELIST + """, + ) + await self.conn.commit() + return True + + async def find_one(self, key: str, value: str): + async with self.conn.execute( + f""" + SELECT * FROM FILELIST WHERE {key} = ? + """, + (value,), + ) as cursor: + row = await cursor.fetchone() + if row: + columns = [desc[0] for desc in cursor.description] + result = dict(zip(columns, row)) + else: + result = False + return result + + async def get_all(self): + async with self.conn.execute("SELECT * FROM FILELIST") as cursor: + rows = await cursor.fetchall() + columns = [desc[0] for desc in cursor.description] + result = [dict(zip(columns, row)) for row in rows] + return result \ No newline at end of file diff --git a/core/logger.py b/core/logger.py new file mode 100644 index 0000000..76023ec --- /dev/null +++ b/core/logger.py @@ -0,0 +1,39 @@ +import os +import sys +from pathlib import Path +from loguru import logger as Logger + +basic_logger_format = ( + "[{time:HH:mm:ss}][{level}] {message}" +) +logfile_logger_format = "[{time:YYYY-MM-DD HH:mm:ss}] [{level}][{name}:{function}:{line}]: {message}" + + +class LoggingLogger: + + def __init__(self): + self.log = Logger + self.log.remove() + self.log.add( + sys.stderr, format=basic_logger_format, level="DEBUG", colorize=True + ) + self.cur_handler = None + self.log.add( + Path("./logs/{time:YYYY-MM-DD}.log"), + format=logfile_logger_format, + retention="10 days", + encoding="utf-8", + ) + self.info = self.log.info + self.debug = self.log.debug + self.warning = self.log.warning + self.error = self.log.error + self.success = self.log.success + + def clear(self): + if os.name == "nt": + os.system("cls") + else: + os.system("clear") + +logger = LoggingLogger() diff --git a/core/mdb.py b/core/mdb.py new file mode 100644 index 0000000..46c500b --- /dev/null +++ b/core/mdb.py @@ -0,0 +1,130 @@ +# 第三方库 +import motor.motor_asyncio +from bson.objectid import ObjectId + +# 本地库 +from core.config import config + + +def to_objectId(id: str): + try: + return ObjectId(id) + except Exception: + return None + + +class Database: + def __init__( + self, + url: str, + db_name: str, + collection_name: str, + username=None, + password=None, + ): + uri = ( + f"mongodb://{username}:{password or ''}@{url}/" + if username + else f"mongodb://{url}/" + ) + self.client = motor.motor_asyncio.AsyncIOMotorClient(uri) + self.db = self.client[db_name] + self.collection_name = collection_name + + async def close(self): + self.client.close() + + async def collection(self, collection_name: str): + return self.db[collection_name] + + async def insert_one(self, data: dict): + collection = await self.collection(self.collection_name) + result = await collection.insert_one(data) + return result.inserted_id + + async def find_one(self, query: dict): + collection = await self.collection(self.collection_name) + return await collection.find_one(query) + + async def create_cluster( + self, + name: str = None, + secret: str = None, + bandwidth: int = None, + ): + return await self.insert_one( + { + "name": name, + "secret": secret, + "bandwidth": bandwidth, + "isBanned": False + } + ) + + async def delete_cluster(self, id: str): + collection = await self.collection(self.collection_name) + await collection.delete_one({"_id": to_objectId(id)}) + + async def find_cluster(self, id: str): + collection = await self.collection(self.collection_name) + result = await collection.find_one({"_id": to_objectId(id)}) + if result: + return [True, result] + return [False, None] + + async def get_all(self): + collection = await self.collection(self.collection_name) + cursor = collection.find() + return [doc async for doc in cursor] + + async def edit_cluster( + self, + id: str, + name: str = None, + secret: str = None, + bandwidth: int = None, + measureBandwidth: int = None, + trust: int = None, + isBanned: bool = None, + ban_reason: str = None, + host: str = None, + port: int = None, + version: str = None, + runtime: str = None, + cert_fullchain: str = None, + cert_privkey: str = None, + cert_expiry: str = None + ): + data = { + "name": name, + "secret": secret, + "bandwidth": bandwidth, + "measureBandwidth": measureBandwidth, + "trust": trust, + "isBanned": isBanned, + "ban_reason": ban_reason, + "host": host, + "port": port, + "version": version, + "runtime": runtime, + "cert_fullchain": cert_fullchain, + "cert_privkey": cert_privkey, + "cert_expiry": cert_expiry + } + valid_update_data = {k: v for k, v in data.items() if v is not None} + result = await self.db.clusters.update_one( + {"_id": to_objectId(id)}, + {"$set": valid_update_data}, + ) + if result.modified_count == 1: + return True + return False + + +cdb = Database( + config.get("mongodb.url"), + config.get("mongodb.db_name"), + "clusters", + config.get("mongodb.username"), + config.get("mongodb.password"), +) \ No newline at end of file diff --git a/core/plugins.py b/core/plugins.py new file mode 100644 index 0000000..5a89691 --- /dev/null +++ b/core/plugins.py @@ -0,0 +1,11 @@ +from pluginbase import PluginBase +from loguru import logger +import asyncio +plugin_base = PluginBase(package='iodine.plugins') +plugin_source = plugin_base.make_plugin_source(searchpath=['./plugins']) + +async def load_plugins(): + for plugin_name in plugin_source.list_plugins(): + plugin = plugin_source.load_plugin(plugin_name) + logger.info(f"加载插件: {plugin_name}") + asyncio.run(plugin.init()) \ No newline at end of file diff --git a/core/routes/agent.py b/core/routes/agent.py new file mode 100644 index 0000000..f8eaf63 --- /dev/null +++ b/core/routes/agent.py @@ -0,0 +1,81 @@ +# 第三方库 +import hmac +import time +import hashlib +from fastapi import APIRouter, Request, HTTPException, Response +from fastapi.responses import JSONResponse, PlainTextResponse + +router = APIRouter() + +# 本地库 +import core.utils as utils +import core.const as const +from core.types import Cluster + + +@router.get("/challenge", summary="颁发 challenge 码", tags=["nodes"]) # 颁发 challenge 验证码 +async def get_challenge(response: Response, clusterId: str | None = ""): + cluster = Cluster(clusterId) + if await cluster.initialize(): + return { + "challenge": utils.encode_jwt( + { + "cluster_id": clusterId, + "cluster_secret": cluster.secret, + "iss": const.jwt_iss, + "exp": int(time.time()) + 1000 * 60 * 5, + } + ) + } + else: + raise HTTPException(status_code=404, detail="节点未找到") + + +@router.post("/token", summary="颁发令牌", tags=["nodes"]) # 颁发令牌 +async def post_token(request: Request): + content_type = request.headers.get("content-type", "") + if "application/json" in content_type: + data = await request.json() + elif "application/x-www-form-urlencoded" in content_type: + data = await request.form() + data = dict(data) + elif "multipart/form-data" in content_type: + data = await request.form() + data = dict(data) + else: + raise HTTPException(status_code=400, detail="不支持的媒体类型") + + clusterId = data.get("clusterId") + challenge = data.get("challenge") + signature = data.get("signature") + + cluster = Cluster(clusterId) + if await cluster.initialize() == False: + raise HTTPException(status_code=404, detail="节点未找到") + h = hmac.new(cluster.secret.encode("utf-8"), digestmod=hashlib.sha256) + if challenge is not None and isinstance(challenge, str): + h.update(challenge.encode()) + else: + raise HTTPException(status_code=401, detail="验证错误") + if utils.decode_jwt(challenge)["cluster_id"] == clusterId and utils.decode_jwt( + challenge + )["exp"] > int(time.time()): + if str(h.hexdigest()) == signature: + ttl = 60 * 60 * 24 * 1000 + return JSONResponse( + { + "token": utils.encode_jwt( + { + "cluster_id": clusterId, + "cluster_secret": cluster.secret, + "iss": const.jwt_iss, + "exp": int(time.time()) + ttl, + } + ), + "ttl": ttl, + } + ) + else: + raise HTTPException(status_code=401, detail="错误的签名") + else: + raise HTTPException(status_code=401, detail="验证错误") diff --git a/core/routes/api/v0.py b/core/routes/api/v0.py new file mode 100644 index 0000000..cb0f416 --- /dev/null +++ b/core/routes/api/v0.py @@ -0,0 +1,51 @@ +# 第三方库 +from fastapi import APIRouter, Request, HTTPException, Response +from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse + +# 本地库 +from core.types import oclm +from core.mdb import cdb + + +router = APIRouter() + + +@router.get("/version") +async def get_version_data(): + return { + "name": "iodine-at-home", + "version": "1.12.1", + "description": "开源的文件分发主控,并尝试兼容 OpenBMCLAPI 客户端", + "repository": { + "type": "git", + "url": "git+https://github.com/ZeroNexis/iodine-at-home", + }, + "author": "Dongyanmio ", + "license": "MIT", + "bugs": {"url": "https://github.com/ZeroNexis/iodine-at-home/issues"}, + "homepage": "https://github.com/ZeroNexis/iodine-at-home#readme", + } + + +@router.get("/dashboard") +async def get_dashboard_data(): + return { + "currentNodes": len(oclm), + } + + +@router.get("/rank") +async def get_rank_data(): + result = [] + all_data = await cdb.get_all() + for data in all_data: + data["_id"] = str(data["_id"]) + if oclm.include(data["_id"]): + data["isEnabled"] = True + else: + data["isEnabled"] = False + rdata = { + k: v for k, v in data.items() if k in ["_id", "name", "isEnabled", "isBanned"] + } + result.append(rdata) + return result \ No newline at end of file diff --git a/core/routes/openbmclapi-agent.py b/core/routes/openbmclapi-agent.py new file mode 100644 index 0000000..e69de29 diff --git a/core/routes/openbmclapi.py b/core/routes/openbmclapi.py new file mode 100644 index 0000000..2d25e49 --- /dev/null +++ b/core/routes/openbmclapi.py @@ -0,0 +1,60 @@ +# 第三方库 +import pyzstd +from pathlib import Path +from fastapi import APIRouter, Request, HTTPException, Response +from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse + +# 本地库 +from core.types import Avro +from core.logger import logger +from core.filesdb import FilesDB + + +router = APIRouter() + + +@router.get("/configuration", summary="同步参数", tags=["nodes"]) # 同步参数 +def get_configuration(response: Response): + # TODO: 根据当前负载情况智能调整并发数 + return {"sync": {"source": "center", "concurrency": 1024}} + + +@router.get("/files", summary="文件列表", tags=["nodes"]) +async def get_filesList(): + async with FilesDB() as filesdb: + filelist = await filesdb.get_all() + avro = Avro() + avro.writeVarInt(len(filelist)) # 写入文件数量 + for file in filelist: + avro.writeString(f"/{file['SOURCE']}/{file['HASH']}") # 路径 + avro.writeString(file['HASH']) # 哈希 + avro.writeVarInt(file['SIZE']) # 文件大小 + avro.writeVarInt(file['MTIME']) # 修改时间 + avro.write(b"\x00") + result = pyzstd.compress(avro.io.getvalue()) + avro.io.close() + return HTMLResponse(content=result, media_type="application/octet-stream") + + +@router.get("/download/{hash}", summary="应急同步", tags=["nodes"]) +async def download_file_from_ctrl(hash: str): + raise HTTPException(404, detail="未找到该文件") + + +@router.post("/report", summary="上报异常", tags=["nodes"]) +async def post_report(request: Request): + content_type = request.headers.get("content-type", "") + if "application/json" in content_type: + data = await request.json() + elif "application/x-www-form-urlencoded" in content_type: + data = await request.form() + data = dict(data) + elif "multipart/form-data" in content_type: + data = await request.form() + data = dict(data) + else: + raise HTTPException(status_code=400, detail="不支持的媒体类型") + urls = data.get("urls") + error = data.get("error") + logger.warning(f"收到举报, 重定向记录: {urls},错误信息: {error}") + return Response(content="举报成功", status_code=200) diff --git a/core/routes/services.py b/core/routes/services.py new file mode 100644 index 0000000..8dbf39a --- /dev/null +++ b/core/routes/services.py @@ -0,0 +1,33 @@ +# 第三方库 +from pathlib import Path +from fastapi import APIRouter, Request, HTTPException, Response +from fastapi.responses import FileResponse, RedirectResponse + +# 本地库 +from random import choice +import core.utils as utils +from core.types import oclm, Cluster +from core.logger import logger +from core.filesdb import FilesDB + +router = APIRouter() + + +@router.get("/files/{path}", summary="通过 PATH 下载普通文件", tags=["public"]) +async def download_path_file(hash: str): + async with FilesDB() as filesdb: + filedata = await filesdb.find_one("PATH", hash) + + if filedata: + if len(oclm) == 0: + return RedirectResponse(filedata["URL"], 302) + else: + cluster = Cluster(choice(oclm.list)) + await cluster.initialize() + sign = utils.get_sign(filedata["HASH"], cluster.secret) + url = utils.get_url( + cluster.host, cluster.port, f"/download/{filedata['HASH']}", sign + ) + return RedirectResponse(url, 302) + else: + raise HTTPException(404, detail="未找到该文件") diff --git a/core/sync.py b/core/sync.py new file mode 100644 index 0000000..6463029 --- /dev/null +++ b/core/sync.py @@ -0,0 +1,34 @@ +from git.repo import Repo +from core.config import config +import os +import hashlib +download_path = os.path.join(config.get('download_path')) + +Repo.clone_from(url=config.get('git_repo.url'),to_path=download_path,branch=config.get('git_repo.branch')) + + +async def generate_filelist(): + global file_list + file_list = [] + for root, dirs, files in os.walk(config.get('download_path')): + for file in files: + file_path = os.path.join(root, file) + file_hash = get_file_hash(file_path) + file_entry = { + "_hash": file_hash, + "path": file_path, + "name": file + } + file_list.append(file_entry) + return file_list + +async def get_file_hash(file_path): + hash_md5 = hashlib.md5() + with open (file_path, "rb") as f: + for chunk in iter(lambda: f.read(4096), b""): + hash_md5.update(chunk) + return hash_md5.hexdigest() + +# async def write_to_db(): +# for i in file_list: +# await fdb.file.insert_one(i) \ No newline at end of file diff --git a/core/types.py b/core/types.py new file mode 100644 index 0000000..93568e9 --- /dev/null +++ b/core/types.py @@ -0,0 +1,262 @@ +import io +import heapq +from typing import Optional +from core.mdb import cdb + + +class Cluster: + def __init__(self, cluster_id: str): + self.id = cluster_id + + async def initialize(self): + data = await cdb.find_cluster(self.id) + if data[0]: + # 正常数据 + self.name = str(data[1]["name"]) + self.secret = str(data[1]["secret"]) + self.bandwidth = int(data[1]["bandwidth"]) + self.measureBandwidth = int(data[1].get("measureBandwidth", 0)) + self.trust = int(data[1].get("trust", 0)) + self.isBanned = bool(data[1].get("isBanned", False)) + self.ban_reason = str(data[1].get("ban_reason", "")) + self.host = str(data[1].get("host", "")) + self.port = int(data[1].get("port", 0)) + self.version = str(data[1].get("version", "")) + self.runtime = str(data[1].get("runtime", "")) + self.cert_fullchain = str(data[1].get("cert_fullchain", "")) + self.cert_privkey = str(data[1].get("cert_privkey", "")) + self.cert_expiry = str(data[1].get("cert_expiry", "")) + self.weight = self.trust + self.bandwidth + self.measureBandwidth + return True + else: + return False + + async def edit( + self, + name: str = None, + secret: str = None, + bandwidth: int = None, + measureBandwidth: int = None, + trust: int = None, + isBanned: bool = None, + ban_reason: str = None, + host: str = None, + port: int = None, + version: str = None, + runtime: str = None, + cert_fullchain: str = None, + cert_privkey: str = None, + cert_expiry: str = None + ): + result = await cdb.edit_cluster( + self.id, + name, + secret, + bandwidth, + measureBandwidth, + trust, + isBanned, + ban_reason, + host, + port, + version, + runtime, + cert_fullchain, + cert_privkey, + cert_expiry + ) + if result: + await self.initialize() + return result + + def json(self): + return { + "id": self.id, + "name": self.name, + "secret": self.secret, + "bandwidth": self.bandwidth, + "measureBandwidth": self.measureBandwidth, + "trust": self.trust, + "isBanned": self.isBanned, + "ban_reason": self.ban_reason, + "host": self.host, + "port": self.port, + "version": self.version, + "runtime": self.runtime, + } + + +class OCLManager: + def __init__(self): + self.list = [] + + def __len__(self): + return len(self.list) + + def append(self, cluster_id: str): + if cluster_id not in self.list: + self.list.append(cluster_id) + + def remove(self, cluster_id: str): + if cluster_id in self.list: + self.list.remove(cluster_id) + + def include(self, cluster_id: str): + return cluster_id in self.list + + +oclm = OCLManager() + + +# 本段修改自 TTB-Network/python-openbmclapi 中部分代码 +# 仓库链接: https://github.com/TTB-Network/python-openbmclapi +# 源代码使用 MIT License 协议开源 | Copyright (c) 2024 TTB-Network +class Avro: + def __init__(self, initial_bytes: bytes = b"", encoding: str = "utf-8") -> None: + self.io = io.BytesIO(initial_bytes) + self.encoding = encoding + + def read(self, __size: int = None): + return self.io.read(__size) + + def readIntegetr(self): + value = self.read(4) + return (value[0] << 24) + (value[1] << 16) + (value[2] << 8) + (value[3] << 0) + + def readBoolean(self): + return bool(int.from_bytes(self.read(1), byteorder="big")) + + def readShort(self): + value = self.read(2) + if value[0] | value[1] < 0: + raise EOFError() + return (value[0] << 8) + (value[1] << 0) + + def readLong(self) -> int: + value = list(self.read(8)) + value = ( + (value[0] << 56) + + ((value[1] & 255) << 48) + + ((value[2] & 255) << 40) + + ((value[3] & 255) << 32) + + ((value[4] & 255) << 24) + + ((value[5] & 255) << 16) + + ((value[6] & 255) << 8) + + ((value[7] & 255) << 0) + ) + return value - 2**64 if value > 2**63 - 1 else value + + def readVarInt(self) -> int: + b = ord(self.read(1)) + n = b & 0x7F + shift = 7 + while (b & 0x80) != 0: + b = ord(self.read(1)) + n |= (b & 0x7F) << shift + shift += 7 + return (n >> 1) ^ -(n & 1) + + def readString( + self, maximun: Optional[int] = None, encoding: Optional[str] = None + ) -> str: + return self.read( + self.readVarInt() + if maximun is None + else min(self.readVarInt(), max(maximun, 0)) + ).decode(encoding or self.encoding) + + def readBytes(self, length: int) -> bytes: + return self.read(length) + + def write(self, value: bytes | int): + if isinstance(value, bytes): + self.io.write(value) + else: + self.io.write((value + 256 if value < 0 else value).to_bytes(1, "big")) # type: ignore + + def writeBoolean(self, value: bool): + self.write(value.to_bytes(1, "big")) + + def writeShort(self, data: int): + self.write(((data >> 8) & 0xFF).to_bytes(1, "big")) + self.write(((data >> 0) & 0xFF).to_bytes(1, "big")) + + def writeInteger(self, data: int): + self.write(((data >> 24) & 0xFF).to_bytes(1, "big")) + self.write(((data >> 16) & 0xFF).to_bytes(1, "big")) + self.write(((data >> 8) & 0xFF).to_bytes(1, "big")) + self.write((data & 0xFF).to_bytes(1, "big")) + + def writeVarInt(self, value: int): + self.write(Avro.getVarInt(value)) + + def writeString(self, data: str, encoding: Optional[str] = None): + self.writeVarInt(len(data.encode(encoding or self.encoding))) + self.write(data.encode(encoding or self.encoding)) + + def writeLong(self, data: int): + data = data - 2**64 if data > 2**63 - 1 else data + self.write((data >> 56) & 0xFF) + self.write((data >> 48) & 0xFF) + self.write((data >> 40) & 0xFF) + self.write((data >> 32) & 0xFF) + self.write((data >> 24) & 0xFF) + self.write((data >> 16) & 0xFF) + self.write((data >> 8) & 0xFF) + self.write((data >> 0) & 0xFF) + + def __sizeof__(self) -> int: + return self.io.tell() + + def __len__(self) -> int: + return self.io.tell() + + @staticmethod + def getVarInt(data: int): + r: bytes = b"" + data = (data << 1) ^ (data >> 63) + while (data & ~0x7F) != 0: + r += ((data & 0x7F) | 0x80).to_bytes(1, "big") + data >>= 7 + r += data.to_bytes(1, "big") + return r + + +class WRRScheduler: + def __init__(self): + self.servers = {} + self.queue = [] + + def add_server(self, server, weight): + # 添加服务器及其权重到字典 + self.servers[server] = weight + # 向队列中添加多个条目,数量由权重决定 + for _ in range(weight): + heapq.heappush(self.queue, (-weight, server)) + + def remove_server(self, server): + # 移除所有与指定服务器相关的条目 + self.queue = [item for item in self.queue if item[1] != server] + # 从字典中删除服务器 + del self.servers[server] + + def update_weight(self, server, new_weight): + # 移除所有与指定服务器相关的条目 + self.queue = [item for item in self.queue if item[1] != server] + # 更新字典中的权重 + self.servers[server] = new_weight + # 添加新的条目 + for _ in range(new_weight): + heapq.heappush(self.queue, (-new_weight, server)) + + def next_server(self): + if not self.queue: + return None + # 弹出队列中的第一个元素 + weight, server = heapq.heappop(self.queue) + # 将权重减一后重新插入队列 + heapq.heappush(self.queue, (weight + 1, server)) + return server + + +wrrs = WRRScheduler() diff --git a/core/utils.py b/core/utils.py new file mode 100644 index 0000000..f46f1d4 --- /dev/null +++ b/core/utils.py @@ -0,0 +1,93 @@ +# 第三方库 +import jwt +import time +import httpx +import base64 +import hashlib +from random import choice + +# 本地库 +import core.const as const +from core.config import config +from core.logger import logger +from core.types import Cluster + + +# JWT 加密 +def encode_jwt(data, secret: str | None = config.get("jwt-secret")): + result = jwt.encode(data, secret, algorithm="HS256") + return result + + +# JWT 解密 +def decode_jwt(data, secret: str | None = config.get("jwt-secret")): + result = jwt.decode(data, secret, algorithms=["HS256"]) + return result + + +def hum_convert(value: int): + units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"] + size = value + for unit in units: + if (size / 1024) < 1: + return "%.2f%s" % (size, unit) + size = size / 1024 + return f"{value:.2f}" + + +def to_url_safe_base64_string(byte_data): + return base64.urlsafe_b64encode(byte_data).rstrip(b"=").decode("utf-8") + + +# 将整数转换为base36字符串 +def base36encode(number): + if not isinstance(number, int): + raise TypeError("number must be an integer") + if number < 0: + raise ValueError("number must be positive") + alphabet = "0123456789abcdefghijklmnopqrstuvwxyz" + base36 = "" + while number: + number, i = divmod(number, 36) + base36 = alphabet[i] + base36 + return base36 or alphabet[0] + + +# 获取节点 sign +def get_sign(path, secret): + try: + sha1 = hashlib.sha1() + except Exception as e: + logger.error(e) + return None + timestamp = int((time.time() + 5 * 60) * 1000) + e = base36encode(timestamp) + sign_data = (secret + path + e).encode("utf-8") + sha1.update(sign_data) + sign_bytes = sha1.digest() + sign = to_url_safe_base64_string(sign_bytes).replace("=", "") + return f"?s={sign}&e={e}" + + +# 获取节点mesure的url +def get_url(host: str, port: str, path: str, sign: str): + url = f"https://{host}:{port}{path}{sign}" + return url + + +# 对节点进行测速 +async def measure_cluster(size: int, cluster: Cluster): + path = f"/measure/{str(size)}" + sign = get_sign(path, cluster.secret) + url = get_url(cluster.host, cluster.port, path, sign) + try: + start_time = time.time() + async with httpx.AsyncClient() as client: + response = await client.get(url, headers={"User-Agent": const.user_agent}) + end_time = time.time() + elapsed_time = end_time - start_time + # 计算测速时间 + bandwidth = size / elapsed_time * 8 # 计算带宽 + return [True, bandwidth] + except Exception as e: + return [False, e] diff --git a/main.py b/main.py new file mode 100644 index 0000000..787285e --- /dev/null +++ b/main.py @@ -0,0 +1,16 @@ +import sys +import core +from core.logger import logger +import asyncio + +py_version = sys.version_info + +if py_version < (3, 11): + logger.info( + f"你使用的 Python 版本是 {py_version[0]}.{py_version[1]}.{py_version[2]},", + ) + logger.info("而该程序要求使用 3.11 版本及以上的 Python,请及时更换。") + sys.exit(1) + + +core.init() # 初始化 \ No newline at end of file diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..5db72dd --- /dev/null +++ b/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ] +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..bdc74a5756c7ed64f654b2a67b75021123673bcb GIT binary patch literal 1898 zcmZ8i+iufP5Zq@ZJ|$6NhlW1zfRLz!KtzCqc&ZZT=91W{FSNexMMZI8gntqHj&uqVjd=PrOy!oGB$wTAf=YA)!I6KzNv#?U}TZ2*U zU2Nb};y=|Xo#eAcrW$DMw{%ea)ZPF`GqFXU((5U_GWrvVyva;RJH$88d=sQ8Y_?_m5Ri9bFvuh-<(xb9FSk%+DM*u3hwa_Wb-l@m@)FP6MZt*vcy0 z2jH=Pob_1KbzXL3XDt+HJ6uknP>-;(6IF-XPv8k!wTR7;m;1vkF@uz0Kbbef-z6~U zAtY{P5^~UE4SRu1oEu`$e%rBl>2W_$Nm#6T<~l8a_sM?Rj{%a@AQEdTz`Jxi`#a3^ z6>K-~_>Sjuz@V2dNv!FceY6iyn$N9!X?vnQPQl1)<3(t_zQYSvV`O88nlOuG*vFEG z6y89aA!E(m(Eo@m2hStL``e(7sH?-68o1n6?s&|>-O$yPri!=(?(vQYZ2`u=?7e*n zYH32^1TpgB^{qWxJm@3-74klUH7{U;H+DaDy|p#EKP{BvEb@ZLZ|*y+Eurqb(MNb4 zfHtxx{9WO@zPjt_)I2ak`=svm(_I6Jm+s_@bSom~8eQRC<=s?I7_F@n58j)~d2p+> z7py$>+pB%n<>&V7>>fVP?#YWrRDP1L?vG}i*xXe9Qst;E?bM=OTDA5EHeC_jL*?yN gdV;-xcLI&1EPq3&T))59-n_89kJLUY$b1v~4i_@% literal 0 HcmV?d00001 diff --git a/test.py b/test.py new file mode 100644 index 0000000..f915e47 --- /dev/null +++ b/test.py @@ -0,0 +1,15 @@ +import pytz +from datetime import datetime, timedelta +from dateutil.relativedelta import relativedelta + +# 获取当前时间 +current_time = datetime.now(pytz.utc) + +# 加上三个月的时间 +future_time = current_time + relativedelta(months=3) + +# 将时间转换为指定格式的字符串 +formatted_time = future_time.astimezone(pytz.utc).strftime('%Y-%m-%dT%H:%M:%S+00:00') + +# 输出结果 +print(formatted_time) \ No newline at end of file diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..436495b --- /dev/null +++ b/todo.md @@ -0,0 +1,4 @@ +## 待办清单 +- [ ] refactor: 重构所有代码(正在进行中...) +- [ ] feat: 支持分片功能 +- [ ] feat: 增加插件 API \ No newline at end of file