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@home
+
+_✨ 开源的文件分发主控,并尝试兼容 OpenBMCLAPI 客户端 ✨_
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## 📖 介绍
+
+基于 [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 0000000..bdc74a5
Binary files /dev/null and b/requirements.txt differ
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