Skip to content

Commit a23b05b

Browse files
committed
feat(matchscope): add authenticated domain submission APIs
1 parent 26ae92c commit a23b05b

13 files changed

Lines changed: 1335 additions & 54 deletions

Dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ RUN addgroup -g 1000 appuser && \
6868
chown -R appuser:appuser /app
6969
USER appuser
7070

71+
# Documentation only; listeners remain disabled unless explicitly configured.
72+
EXPOSE 8765 7654
73+
7174
HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \
7275
CMD ["python", "-m", "src.healthcheck"]
7376

README.md

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,21 @@ docker compose logs -f rule-bot
123123
| `ALLOWED_GROUP_IDS` | 群组模式允许的群组 ID,逗号分隔 ||
124124
| `ANNOUNCEMENT_GROUP_ID` | 私聊成功提交后的群组播报目标 ID ||
125125
| `ADMIN_USER_IDS` | 管理员 Telegram 用户 ID,逗号分隔 ||
126+
| `MATCHSCOPE_PRIVATE_API_ENABLED` | 启用 MatchScope 私用入口 | `false` |
127+
| `MATCHSCOPE_PRIVATE_API_HOST` | 私用入口监听地址 | `0.0.0.0` |
128+
| `MATCHSCOPE_PRIVATE_API_PORT` | 私用入口端口 | `8765` |
129+
| `MATCHSCOPE_PRIVATE_API_PATH` | 私用入口随机路径(启用时必需) ||
130+
| `MATCHSCOPE_PRIVATE_API_TOKEN[_FILE]` | 私用入口静态 Token 或其文件 ||
131+
| `MATCHSCOPE_PRIVATE_RATE_LIMIT_PER_HOUR` | 私用入口每小时请求/添加上限 | `1000` |
132+
| `MATCHSCOPE_PUBLIC_API_ENABLED` | 启用 MatchScope 社区入口 | `false` |
133+
| `MATCHSCOPE_PUBLIC_API_HOST` | 社区入口监听地址 | `0.0.0.0` |
134+
| `MATCHSCOPE_PUBLIC_API_PORT` | 社区入口端口 | `7654` |
135+
| `MATCHSCOPE_PUBLIC_API_PATH` | 社区入口随机路径(启用时必需) ||
136+
| `MATCHSCOPE_PUBLIC_BASE_URL` | 机器人展示给用户的公开基础 URL ||
137+
| `MATCHSCOPE_TOKEN_SIGNING_KEY[_FILE]` | 社区 Token HMAC 签名密钥或其文件 ||
138+
| `MATCHSCOPE_TOKEN_TTL_DAYS` | 社区 Token 有效天数 | `90` |
139+
| `MATCHSCOPE_TOKEN_DATABASE` | 社区 Token 状态数据库路径 | 数据目录下 `matchscope_tokens.sqlite3` |
140+
| `MATCHSCOPE_PUBLIC_RATE_LIMIT_PER_HOUR` | 每个社区 Token 每小时请求/添加上限 | `50` |
126141
| `TZ` | 时区 | `Asia/Shanghai` |
127142
| `DNS_CACHE_TTL` | DNS A 记录缓存秒数 | `60` |
128143
| `DNS_CACHE_SIZE` | DNS A 记录缓存上限 | `1024` |
@@ -187,6 +202,36 @@ docker compose logs -f rule-bot
187202

188203
> 通过 @userinfobot 获取你的 Telegram 用户 ID。
189204
205+
### MatchScope 接入
206+
207+
两个入口互相独立,且都默认关闭,因此旧部署不增加任何参数即可平滑升级:
208+
209+
- `8765` 私用入口使用部署者生成的高强度静态 Token,适合自己的 MatchScope。
210+
- `7654` 社区入口使用 Rule-Bot 签发的用户 Token。群成员在机器人私聊主菜单点击“MatchScope 接入”即可自行申请、重新签发或吊销,管理员无需维护用户 Token 清单。
211+
212+
两个入口都只接受精确随机路径上的 `POST application/json`,请求格式固定为:
213+
214+
```json
215+
{"version":1,"domain":"example.com"}
216+
```
217+
218+
鉴权使用 `Authorization: Bearer <token>`。随机路径只用于降低扫描噪声,Token 才是真正的安全边界;入口没有首页、接口文档、健康检查或 CORS。Rule-Bot 会忽略客户端提供的来源、文件路径或提交信息,并让域名经过与 Telegram 添加相同的规范化、`.cn`、规则重复、GeoSite、DNS/NS 和归属地校验。
219+
220+
社区入口必须同时配置 `REQUIRED_GROUP_ID/NAME/LINK`。申请或重新签发时会实时检查群成员身份;重新签发立即废止旧 Token,吊销也会立即生效。签发状态保存在 `MATCHSCOPE_TOKEN_DATABASE`(默认 `/app/data/matchscope_tokens.sqlite3`),原始 Token 不入库,因此启用社区入口时必须持久化 `/app/data`
221+
222+
建议将端口只绑定到宿主机回环地址:
223+
224+
```yaml
225+
ports:
226+
- "127.0.0.1:8765:8765"
227+
- "127.0.0.1:7654:7654"
228+
volumes:
229+
- ./data:/app/data
230+
- ./secrets:/run/secrets/rule-bot:ro
231+
```
232+
233+
随后可让两个 Cloudflare Tunnel hostname 分别指向 `http://127.0.0.1:8765` 与 `http://127.0.0.1:7654`,从而绑定不同域名并在公网侧使用 HTTPS。若不使用反代,也可成对配置各入口的 `*_TLS_CERT_FILE` 与 `*_TLS_KEY_FILE`,由 Rule-Bot 直接提供最基础的 TLS 1.2+ 服务。
234+
190235
## 📌 规则逻辑(简版)
191236

192237
1) 解析域名并提取二级域名
@@ -200,7 +245,7 @@ docker compose logs -f rule-bot
200245

201246
- `latest`:唯一发布标签,对应 `master` 分支通过测试后的多架构镜像
202247

203-
镜像内置事件循环心跳健康检查部署者不需要增加端口、环境变量或额外配置。
248+
镜像内置事件循环心跳健康检查。未启用 MatchScope 接入时,部署者不需要增加端口、环境变量或额外配置。
204249

205250
## 🧩 常见问题
206251

docker-compose.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@ services:
33
image: aethersailor/rule-bot:latest
44
container_name: rule-bot
55
restart: unless-stopped
6+
# MatchScope 接入默认关闭;启用后建议只发布到宿主机回环地址,再由反代或 Cloudflare Tunnel 暴露。
7+
# ports:
8+
# - "127.0.0.1:8765:8765" # 私用入口
9+
# - "127.0.0.1:7654:7654" # 社区入口
10+
# volumes:
11+
# - ./data:/app/data
12+
# - ./secrets:/run/secrets/rule-bot:ro
613
environment:
714
# ========== 请在下方填入您的配置 ==========
815
# Telegram Bot Token (从 @BotFather 获取)
@@ -65,6 +72,31 @@ services:
6572
# 管理员可以强制添加被系统检测拒绝的域名
6673
# 留空则关闭此功能
6774
# - ADMIN_USER_IDS=123456789,987654321
75+
76+
# MatchScope 私用入口(可选,默认关闭)
77+
# 路径应使用随机值;Token 建议保存在只读文件,不要直接写入 Compose。
78+
# - MATCHSCOPE_PRIVATE_API_ENABLED=true
79+
# - MATCHSCOPE_PRIVATE_API_PORT=8765
80+
# - MATCHSCOPE_PRIVATE_API_PATH=/api/v1/matchscope/replace-with-random-path
81+
# - MATCHSCOPE_PRIVATE_API_TOKEN_FILE=/run/secrets/rule-bot/private-token
82+
# - MATCHSCOPE_PRIVATE_RATE_LIMIT_PER_HOUR=1000
83+
84+
# MatchScope 社区入口(可选,默认关闭)
85+
# 开启后用户可在机器人私聊菜单自行签发、更新和吊销独立 Token。
86+
# 必须同时启用上方 REQUIRED_GROUP_*,并持久化 /app/data。
87+
# - MATCHSCOPE_PUBLIC_API_ENABLED=true
88+
# - MATCHSCOPE_PUBLIC_API_PORT=7654
89+
# - MATCHSCOPE_PUBLIC_API_PATH=/api/v1/matchscope/replace-with-another-random-path
90+
# - MATCHSCOPE_PUBLIC_BASE_URL=https://rule-bot.example.com
91+
# - MATCHSCOPE_TOKEN_SIGNING_KEY_FILE=/run/secrets/rule-bot/signing-key
92+
# - MATCHSCOPE_TOKEN_TTL_DAYS=90
93+
# - MATCHSCOPE_PUBLIC_RATE_LIMIT_PER_HOUR=50
94+
95+
# 可选:直接由 Rule-Bot 终止 TLS。使用反代或 Cloudflare Tunnel 时无需配置。
96+
# - MATCHSCOPE_PRIVATE_API_TLS_CERT_FILE=/run/secrets/rule-bot/private.crt
97+
# - MATCHSCOPE_PRIVATE_API_TLS_KEY_FILE=/run/secrets/rule-bot/private.key
98+
# - MATCHSCOPE_PUBLIC_API_TLS_CERT_FILE=/run/secrets/rule-bot/public.crt
99+
# - MATCHSCOPE_PUBLIC_API_TLS_KEY_FILE=/run/secrets/rule-bot/public.key
68100
# ========================================
69101

70102
# ========== 系统配置 ==========

src/bot.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from .healthcheck import HEALTH_PATH
1919
from .update_processor import PerUserUpdateProcessor
2020
from .utils.metrics import EXPORTER
21+
from .services.matchscope_api import MatchScopeAPIServer
2122

2223

2324
class RuleBot:
@@ -29,6 +30,7 @@ def __init__(self, config: Config, data_manager: DataManager):
2930
self.app: Optional[Application] = None
3031
self.handler_manager = None # 延迟初始化
3132
self.group_handler = None # 群组处理器
33+
self.matchscope_api = None
3234
self._metrics_task = None
3335
self._heartbeat_task = None
3436

@@ -49,6 +51,9 @@ def _polling_error_handler(error):
4951
async def stop(self):
5052
"""停止机器人"""
5153
logger.info("正在停止机器人...")
54+
if self.matchscope_api:
55+
await self.matchscope_api.stop()
56+
self.matchscope_api = None
5257
if self.handler_manager:
5358
await self.handler_manager.stop()
5459
if self._metrics_task:
@@ -97,6 +102,13 @@ async def start(self, stop_event: Optional[asyncio.Event] = None):
97102

98103
# 初始化处理器管理器(需要 app 实例)
99104
self.handler_manager = HandlerManager(self.config, self.data_manager, self.app)
105+
if (
106+
self.config.MATCHSCOPE_PRIVATE_API_ENABLED
107+
or self.config.MATCHSCOPE_PUBLIC_API_ENABLED
108+
):
109+
self.matchscope_api = MatchScopeAPIServer(
110+
self.config, self.handler_manager
111+
)
100112

101113
# 初始化群组处理器
102114
self.group_handler = GroupHandler(self.config, self.data_manager, self.handler_manager)
@@ -109,6 +121,8 @@ async def start(self, stop_event: Optional[asyncio.Event] = None):
109121

110122
async with self.app:
111123
await self.handler_manager.start() # 显式启动服务(如 DNS Session)
124+
if self.matchscope_api:
125+
await self.matchscope_api.start()
112126
await self.app.start()
113127
self._metrics_task = EXPORTER.start()
114128
await self.app.updater.start_polling(

src/config.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44

55
import os
66
import re
7+
from pathlib import Path
78
from typing import Optional, Dict
9+
from urllib.parse import urlparse
810
from loguru import logger
911

1012

@@ -33,6 +35,65 @@ def __init__(self):
3335
# 数据目录(可选)
3436
self.DATA_DIR = os.getenv("DATA_DIR", "").strip()
3537

38+
# MatchScope HTTP ingress (disabled by default for seamless upgrades).
39+
self.MATCHSCOPE_PRIVATE_API_ENABLED = self._parse_bool_env(
40+
"MATCHSCOPE_PRIVATE_API_ENABLED", False
41+
)
42+
self.MATCHSCOPE_PRIVATE_API_HOST = os.getenv(
43+
"MATCHSCOPE_PRIVATE_API_HOST", "0.0.0.0"
44+
).strip()
45+
self.MATCHSCOPE_PRIVATE_API_PORT = self._parse_int_env(
46+
"MATCHSCOPE_PRIVATE_API_PORT", 8765, min_value=1, max_value=65535
47+
)
48+
self.MATCHSCOPE_PRIVATE_API_PATH = self._parse_api_path(
49+
"MATCHSCOPE_PRIVATE_API_PATH"
50+
)
51+
self.MATCHSCOPE_PRIVATE_API_TOKEN = self._read_secret(
52+
"MATCHSCOPE_PRIVATE_API_TOKEN"
53+
)
54+
self.MATCHSCOPE_PRIVATE_RATE_LIMIT_PER_HOUR = self._parse_int_env(
55+
"MATCHSCOPE_PRIVATE_RATE_LIMIT_PER_HOUR", 1000, min_value=1
56+
)
57+
self.MATCHSCOPE_PRIVATE_API_TLS_CERT_FILE = os.getenv(
58+
"MATCHSCOPE_PRIVATE_API_TLS_CERT_FILE", ""
59+
).strip()
60+
self.MATCHSCOPE_PRIVATE_API_TLS_KEY_FILE = os.getenv(
61+
"MATCHSCOPE_PRIVATE_API_TLS_KEY_FILE", ""
62+
).strip()
63+
64+
self.MATCHSCOPE_PUBLIC_API_ENABLED = self._parse_bool_env(
65+
"MATCHSCOPE_PUBLIC_API_ENABLED", False
66+
)
67+
self.MATCHSCOPE_PUBLIC_API_HOST = os.getenv(
68+
"MATCHSCOPE_PUBLIC_API_HOST", "0.0.0.0"
69+
).strip()
70+
self.MATCHSCOPE_PUBLIC_API_PORT = self._parse_int_env(
71+
"MATCHSCOPE_PUBLIC_API_PORT", 7654, min_value=1, max_value=65535
72+
)
73+
self.MATCHSCOPE_PUBLIC_API_PATH = self._parse_api_path(
74+
"MATCHSCOPE_PUBLIC_API_PATH"
75+
)
76+
self.MATCHSCOPE_PUBLIC_BASE_URL = os.getenv(
77+
"MATCHSCOPE_PUBLIC_BASE_URL", ""
78+
).strip().rstrip("/")
79+
self.MATCHSCOPE_PUBLIC_RATE_LIMIT_PER_HOUR = self._parse_int_env(
80+
"MATCHSCOPE_PUBLIC_RATE_LIMIT_PER_HOUR", 50, min_value=1
81+
)
82+
self.MATCHSCOPE_PUBLIC_API_TLS_CERT_FILE = os.getenv(
83+
"MATCHSCOPE_PUBLIC_API_TLS_CERT_FILE", ""
84+
).strip()
85+
self.MATCHSCOPE_PUBLIC_API_TLS_KEY_FILE = os.getenv(
86+
"MATCHSCOPE_PUBLIC_API_TLS_KEY_FILE", ""
87+
).strip()
88+
self.MATCHSCOPE_TOKEN_SIGNING_KEY = self._read_secret(
89+
"MATCHSCOPE_TOKEN_SIGNING_KEY"
90+
)
91+
self.MATCHSCOPE_TOKEN_TTL_DAYS = self._parse_int_env(
92+
"MATCHSCOPE_TOKEN_TTL_DAYS", 90, min_value=1, max_value=365
93+
)
94+
token_database = os.getenv("MATCHSCOPE_TOKEN_DATABASE", "").strip()
95+
self.MATCHSCOPE_TOKEN_DATABASE = Path(token_database) if token_database else None
96+
3697
# 性能与缓存配置
3798
self.DNS_CACHE_TTL = self._parse_int_env("DNS_CACHE_TTL", 60, min_value=0)
3899
self.DNS_CACHE_SIZE = self._parse_int_env("DNS_CACHE_SIZE", 1024, min_value=0)
@@ -64,6 +125,8 @@ def __init__(self):
64125
logger.warning(f"无效的 REQUIRED_GROUP_ID: {required_group_id_raw}")
65126
if self.REQUIRED_GROUP_ID and not self.GROUP_CHECK_ENABLED:
66127
logger.warning("群组验证已关闭:REQUIRED_GROUP_NAME 或 REQUIRED_GROUP_LINK 未配置")
128+
129+
self._validate_matchscope_config()
67130

68131
# 群组工作模式配置(允许机器人在这些群组中直接响应 @提及)
69132
# 支持逗号分隔的多个群组 ID,例如:-1001234567890,-1009876543210
@@ -134,6 +197,74 @@ def _get_env_required(self, key: str) -> str:
134197
raise ValueError(f"Required environment variable {key} is not set")
135198
return value
136199

200+
def _read_secret(self, key: str) -> str:
201+
"""Read a secret from KEY or KEY_FILE without logging its value."""
202+
value = os.getenv(key, "").strip()
203+
file_path = os.getenv(f"{key}_FILE", "").strip()
204+
if value and file_path:
205+
raise ValueError(f"{key} and {key}_FILE cannot both be set")
206+
if not file_path:
207+
return value
208+
try:
209+
return Path(file_path).read_text(encoding="utf-8").strip()
210+
except OSError as error:
211+
raise ValueError(f"Unable to read {key}_FILE") from error
212+
213+
def _parse_bool_env(self, key: str, default: bool) -> bool:
214+
raw = os.getenv(key, "").strip().lower()
215+
if not raw:
216+
return default
217+
if raw in {"1", "true", "yes", "on"}:
218+
return True
219+
if raw in {"0", "false", "no", "off"}:
220+
return False
221+
raise ValueError(f"Invalid boolean value for {key}")
222+
223+
def _parse_api_path(self, key: str) -> str:
224+
path = os.getenv(key, "").strip()
225+
if not path:
226+
return ""
227+
if not re.fullmatch(r"/[A-Za-z0-9/_-]{12,200}", path):
228+
raise ValueError(f"Invalid hidden API path for {key}")
229+
return path
230+
231+
def _validate_matchscope_config(self) -> None:
232+
if self.MATCHSCOPE_PRIVATE_API_ENABLED:
233+
if not self.MATCHSCOPE_PRIVATE_API_PATH:
234+
raise ValueError("MATCHSCOPE_PRIVATE_API_PATH is required")
235+
if len(self.MATCHSCOPE_PRIVATE_API_TOKEN) < 32:
236+
raise ValueError("MATCHSCOPE_PRIVATE_API_TOKEN must be at least 32 characters")
237+
if self.MATCHSCOPE_PUBLIC_API_ENABLED:
238+
if not self.MATCHSCOPE_PUBLIC_API_PATH:
239+
raise ValueError("MATCHSCOPE_PUBLIC_API_PATH is required")
240+
if len(self.MATCHSCOPE_TOKEN_SIGNING_KEY) < 32:
241+
raise ValueError("MATCHSCOPE_TOKEN_SIGNING_KEY must be at least 32 characters")
242+
parsed_base_url = urlparse(self.MATCHSCOPE_PUBLIC_BASE_URL)
243+
if (
244+
parsed_base_url.scheme not in {"http", "https"}
245+
or not parsed_base_url.netloc
246+
or parsed_base_url.username is not None
247+
or parsed_base_url.password is not None
248+
or parsed_base_url.query
249+
or parsed_base_url.fragment
250+
or parsed_base_url.path not in {"", "/"}
251+
):
252+
raise ValueError("MATCHSCOPE_PUBLIC_BASE_URL must be an HTTP(S) origin")
253+
if not self.GROUP_CHECK_ENABLED:
254+
raise ValueError("Public MatchScope API requires group membership verification")
255+
if (
256+
self.MATCHSCOPE_PRIVATE_API_ENABLED
257+
and self.MATCHSCOPE_PUBLIC_API_ENABLED
258+
and self.MATCHSCOPE_PRIVATE_API_HOST == self.MATCHSCOPE_PUBLIC_API_HOST
259+
and self.MATCHSCOPE_PRIVATE_API_PORT == self.MATCHSCOPE_PUBLIC_API_PORT
260+
):
261+
raise ValueError("Private and public MatchScope APIs cannot share one listener")
262+
for prefix in ("MATCHSCOPE_PRIVATE_API", "MATCHSCOPE_PUBLIC_API"):
263+
certificate = getattr(self, f"{prefix}_TLS_CERT_FILE")
264+
key = getattr(self, f"{prefix}_TLS_KEY_FILE")
265+
if bool(certificate) != bool(key):
266+
raise ValueError(f"{prefix}_TLS_CERT_FILE and TLS_KEY_FILE must be set together")
267+
137268
def _parse_group_ids(self, ids_str: str) -> list:
138269
"""解析群组 ID 列表
139270

0 commit comments

Comments
 (0)