Compare commits
9 Commits
b389b5e351
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28c49ce299 | ||
|
|
86cabce537 | ||
|
|
d5cfae22ea | ||
|
|
05e0355e14 | ||
|
|
333b66c6bd | ||
|
|
70b3568ccd | ||
|
|
6f71846cf0 | ||
|
|
d30018ed71 | ||
|
|
7c7c233a2a |
@@ -3,15 +3,6 @@ APP_ENV=prod
|
|||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
PORT=18080
|
PORT=18080
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
DATABASE_URL=sqlite:////app/data/app.db
|
|
||||||
|
|
||||||
# Docker 运行时数据目录,建议放在仓库外
|
|
||||||
APP_DATA_DIR=../sub-provider-data
|
|
||||||
|
|
||||||
# 管理面板鉴权;留空则不启用登录校验
|
|
||||||
ADMIN_TOKEN=
|
|
||||||
ADMIN_SESSION_SECRET=change-this-admin-session-secret
|
|
||||||
ADMIN_SESSION_MAX_AGE=86400
|
|
||||||
|
|
||||||
# 对外访问前缀,尽量改成足够长的随机字符串
|
# 对外访问前缀,尽量改成足够长的随机字符串
|
||||||
PUBLIC_PATH=change-me-random-hash-path
|
PUBLIC_PATH=change-me-random-hash-path
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -62,8 +62,6 @@ cover/
|
|||||||
local_settings.py
|
local_settings.py
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
db.sqlite3-journal
|
db.sqlite3-journal
|
||||||
data/
|
|
||||||
output/bundle-cache/
|
|
||||||
|
|
||||||
# Flask stuff:
|
# Flask stuff:
|
||||||
instance/
|
instance/
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
|||||||
|
|
||||||
COPY app /app/app
|
COPY app /app/app
|
||||||
COPY config /app/config
|
COPY config /app/config
|
||||||
COPY templates /app/templates
|
|
||||||
COPY .env.example /app/.env.example
|
COPY .env.example /app/.env.example
|
||||||
|
|
||||||
EXPOSE 18080
|
EXPOSE 18080
|
||||||
|
|||||||
145
README.md
145
README.md
@@ -40,11 +40,11 @@ sub-provider/
|
|||||||
rules.py
|
rules.py
|
||||||
subscriptions.py
|
subscriptions.py
|
||||||
config/
|
config/
|
||||||
app.yaml
|
conf/
|
||||||
clients.yaml
|
base.conf
|
||||||
policy-groups.yaml
|
profiles/
|
||||||
regions.yaml
|
default.conf
|
||||||
rules.yaml
|
lite.conf
|
||||||
sources.yaml
|
sources.yaml
|
||||||
rules/
|
rules/
|
||||||
reject.yaml
|
reject.yaml
|
||||||
@@ -72,49 +72,21 @@ cp .env.example .env
|
|||||||
|
|
||||||
2. 编辑 `.env`:
|
2. 编辑 `.env`:
|
||||||
|
|
||||||
- `DATABASE_URL` 默认可用 `sqlite:////app/data/app.db`
|
|
||||||
- `APP_DATA_DIR` 建议指向仓库外目录,例如 `../sub-provider-data`
|
|
||||||
- `PUBLIC_PATH` 改成足够长的随机字符串
|
- `PUBLIC_PATH` 改成足够长的随机字符串
|
||||||
- `PUBLIC_BASE_URL` 建议填写你反代后的最终访问地址,例如 `https://sub.example.com`
|
- `PUBLIC_BASE_URL` 建议填写你反代后的最终访问地址,例如 `https://sub.example.com`
|
||||||
- `ADMIN_TOKEN` 可选;配置后管理面板会启用登录鉴权,不配置则不校验登录
|
|
||||||
- `ADMIN_SESSION_SECRET` 用于签发管理面板 session,部署时应改成随机长字符串
|
|
||||||
- `AIRPORT_A_URL` / `AIRPORT_B_URL` / `AIRPORT_C_URL` 都可以直接填订阅地址,项目会自动判断是 YAML 还是 URI 订阅
|
- `AIRPORT_A_URL` / `AIRPORT_B_URL` / `AIRPORT_C_URL` 都可以直接填订阅地址,项目会自动判断是 YAML 还是 URI 订阅
|
||||||
- 也可以直接填本地文件路径,例如 `/app/data/sources/airport-b.txt` 或 `file:///app/data/sources/airport-b.txt`
|
- 也可以直接填本地文件路径,例如 `/app/data/sources/airport-b.txt` 或 `file:///app/data/sources/airport-b.txt`
|
||||||
- 允许把其中一个留空;留空时这个机场会自动跳过
|
- 允许把其中一个留空;留空时这个机场会自动跳过
|
||||||
|
|
||||||
3. 按需调整 `config/` 下拆分后的配置文件:
|
3. 启动:
|
||||||
|
|
||||||
- `app.yaml`:公共访问路径
|
|
||||||
- `sources.yaml`:机场源配置
|
|
||||||
- `regions.yaml`:地区自动组
|
|
||||||
- `policy-groups.yaml`:选择器组和业务策略组
|
|
||||||
- `rules.yaml`:规则绑定
|
|
||||||
- `clients.yaml`:客户端模板
|
|
||||||
|
|
||||||
4. 如需切换到数据库配置源,先执行一次导入:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m app.import_config --replace
|
|
||||||
```
|
|
||||||
|
|
||||||
导入完成后,运行时会优先读取数据库;当数据库内没有配置时,仍会回退到 `config/` 下的 YAML 文件。
|
|
||||||
|
|
||||||
说明:
|
|
||||||
|
|
||||||
- `app.db`、抓取缓存等运行态数据默认写入 `/app/data`
|
|
||||||
- `docker-compose` 已将 `/app/data` 挂载到 `APP_DATA_DIR`
|
|
||||||
- 建议把这个目录放在仓库外,避免 `git pull`、切分支时碰到本地状态文件
|
|
||||||
|
|
||||||
5. 启动:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
6. 访问检查:
|
4. 访问检查:
|
||||||
|
|
||||||
- 健康检查:`http://YOUR_HOST:18080/healthz`
|
- 健康检查:`http://YOUR_HOST:18080/healthz`
|
||||||
- 管理面板:`http://YOUR_HOST:18080/admin`
|
|
||||||
- 单 provider:
|
- 单 provider:
|
||||||
`https://YOUR_DOMAIN/<PUBLIC_PATH>/providers/airport-a.yaml`
|
`https://YOUR_DOMAIN/<PUBLIC_PATH>/providers/airport-a.yaml`
|
||||||
- merged provider:
|
- merged provider:
|
||||||
@@ -123,38 +95,14 @@ docker compose up -d --build
|
|||||||
`https://YOUR_DOMAIN/<PUBLIC_PATH>/clients/mihomo.yaml?sources=airport-a,airport-b,airport-c`
|
`https://YOUR_DOMAIN/<PUBLIC_PATH>/clients/mihomo.yaml?sources=airport-a,airport-b,airport-c`
|
||||||
- Stash 薄壳入口:
|
- Stash 薄壳入口:
|
||||||
`https://YOUR_DOMAIN/<PUBLIC_PATH>/clients/stash.yaml?sources=airport-a,airport-b,airport-c`
|
`https://YOUR_DOMAIN/<PUBLIC_PATH>/clients/stash.yaml?sources=airport-a,airport-b,airport-c`
|
||||||
|
- Conf 薄壳入口:
|
||||||
|
`https://YOUR_DOMAIN/<PUBLIC_PATH>/conf/clients/mihomo/default.yaml`
|
||||||
- Mihomo/OpenClash bundle:
|
- Mihomo/OpenClash bundle:
|
||||||
`https://YOUR_DOMAIN/<PUBLIC_PATH>/bundle/mihomo.yaml?sources=airport-a,airport-b,airport-c`
|
`https://YOUR_DOMAIN/<PUBLIC_PATH>/bundle/mihomo.yaml?sources=airport-a,airport-b,airport-c`
|
||||||
- Stash bundle:
|
- Stash bundle:
|
||||||
`https://YOUR_DOMAIN/<PUBLIC_PATH>/bundle/stash.yaml?sources=airport-a,airport-b,airport-c`
|
`https://YOUR_DOMAIN/<PUBLIC_PATH>/bundle/stash.yaml?sources=airport-a,airport-b,airport-c`
|
||||||
|
- Conf bundle:
|
||||||
### 管理面板(最小版本)
|
`https://YOUR_DOMAIN/<PUBLIC_PATH>/conf/bundle/mihomo/default.yaml`
|
||||||
|
|
||||||
当前已提供一版服务端渲染的最小管理面板:
|
|
||||||
|
|
||||||
- `/admin/sources`
|
|
||||||
- `/admin/profiles`
|
|
||||||
- `/admin/profiles/{profile_key}/sources`
|
|
||||||
- `/admin/profiles/{profile_key}/groups`
|
|
||||||
- `/admin/profiles/{profile_key}/rules`
|
|
||||||
- `/admin/profiles/{profile_key}/preview`
|
|
||||||
|
|
||||||
当前能力范围:
|
|
||||||
|
|
||||||
- Source 增删改
|
|
||||||
- Profile 基础字段编辑
|
|
||||||
- Profile 默认 source 绑定与顺序编辑
|
|
||||||
- Profile group 新增、删除、基础字段编辑
|
|
||||||
- Profile 规则启停、顺序、目标策略组编辑
|
|
||||||
- 预览最终 bundle YAML
|
|
||||||
|
|
||||||
鉴权方式:
|
|
||||||
|
|
||||||
- 在 `.env` 中设置 `ADMIN_TOKEN`
|
|
||||||
- `docker compose` 会自动把 `.env` 注入容器
|
|
||||||
- 访问 `/admin` 时先进入登录页,提交 token 后写入 session cookie
|
|
||||||
- session 使用 `ADMIN_SESSION_SECRET` 签名,默认有效期由 `ADMIN_SESSION_MAX_AGE` 控制
|
|
||||||
- 如果 `ADMIN_TOKEN` 留空,管理面板默认不启用登录拦截
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -213,6 +161,77 @@ HEAD /<PUBLIC_PATH>/bundle/stash.yaml?sources=airport-a,airport-b,airport-c
|
|||||||
- 上游订阅原始内容也会按 TTL 落盘缓存到 `data/fetch-cache/`,默认 900 秒
|
- 上游订阅原始内容也会按 TTL 落盘缓存到 `data/fetch-cache/`,默认 900 秒
|
||||||
- 如果你想固定使用本地文件订阅,可以把文件放到 `data/sources/`,再把 `AIRPORT_*_URL` 指向 `/app/data/sources/xxx.txt`
|
- 如果你想固定使用本地文件订阅,可以把文件放到 `data/sources/`,再把 `AIRPORT_*_URL` 指向 `/app/data/sources/xxx.txt`
|
||||||
|
|
||||||
|
### 5. Conf 配置入口
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /<PUBLIC_PATH>/conf/clients/{client_type}/{profile_key}.yaml
|
||||||
|
GET /<PUBLIC_PATH>/conf/bundle/{client_type}/{profile_key}.yaml
|
||||||
|
GET /<PUBLIC_PATH>/conf/providers/{profile_key}/{name}.yaml
|
||||||
|
GET /<PUBLIC_PATH>/conf/rules/{profile_key}/{name}.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
特点:
|
||||||
|
|
||||||
|
- 旧 `sources.yaml` 路由保持不变,conf 路由独立存在
|
||||||
|
- 新链路读取 `config/conf/base.conf` 和 `config/conf/profiles/*.conf`
|
||||||
|
- 可以并行对比旧输出和 conf 输出,不影响日常使用
|
||||||
|
- conf 路由按请求懒加载配置;conf 写坏时不会影响旧接口启动
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conf 配置说明
|
||||||
|
|
||||||
|
当前已落地一套 ACL4SSR-like 的 `.conf` 配置链路:
|
||||||
|
|
||||||
|
- `config/conf/base.conf`
|
||||||
|
负责 `source`、`selector`、`group`、`module`、`builtin` 等全局定义
|
||||||
|
- `config/conf/profiles/*.conf`
|
||||||
|
负责 `sources`、`include_modules`、`include_builtins`
|
||||||
|
以及 `override_policy`、`prepend_rule`、`append_rule`
|
||||||
|
|
||||||
|
这条链路目前的目标不是替换旧接口,而是:
|
||||||
|
|
||||||
|
- 保持旧 YAML 配置和旧路由继续可用
|
||||||
|
- 用新 conf 路由做并行验证
|
||||||
|
- 逐步把输出收敛到和旧链路一致
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
`base.conf` 里一条规则模块:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
module = apple,../rules/acl4ssr/Apple.yaml,🍎 苹果服务,80,true
|
||||||
|
```
|
||||||
|
|
||||||
|
`default.conf` 里启用模块:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
include_modules = apple,openai
|
||||||
|
include_builtins = geoip_cn,final
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 对比脚本
|
||||||
|
|
||||||
|
项目内置了一个本地对比脚本:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/compare_conf_outputs.py
|
||||||
|
```
|
||||||
|
|
||||||
|
它会:
|
||||||
|
|
||||||
|
- 启动本地 `TestClient`
|
||||||
|
- 同时请求旧路由和 conf 路由
|
||||||
|
- 输出 `proxy-providers`、`rule-providers`、`proxy-groups`、`rules` 的差异摘要
|
||||||
|
|
||||||
|
如果要指定样例源文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/compare_conf_outputs.py --sample-source C:\path\to\sample.yaml
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 默认策略结构
|
## 默认策略结构
|
||||||
|
|||||||
36
alembic.ini
36
alembic.ini
@@ -1,36 +0,0 @@
|
|||||||
[alembic]
|
|
||||||
script_location = alembic
|
|
||||||
prepend_sys_path = .
|
|
||||||
sqlalchemy.url = sqlite:///./data/app.db
|
|
||||||
|
|
||||||
[loggers]
|
|
||||||
keys = root,sqlalchemy,alembic
|
|
||||||
|
|
||||||
[handlers]
|
|
||||||
keys = console
|
|
||||||
|
|
||||||
[formatters]
|
|
||||||
keys = generic
|
|
||||||
|
|
||||||
[logger_root]
|
|
||||||
level = WARN
|
|
||||||
handlers = console
|
|
||||||
|
|
||||||
[logger_sqlalchemy]
|
|
||||||
level = WARN
|
|
||||||
handlers =
|
|
||||||
qualname = sqlalchemy.engine
|
|
||||||
|
|
||||||
[logger_alembic]
|
|
||||||
level = INFO
|
|
||||||
handlers = console
|
|
||||||
qualname = alembic
|
|
||||||
|
|
||||||
[handler_console]
|
|
||||||
class = StreamHandler
|
|
||||||
args = (sys.stderr,)
|
|
||||||
level = NOTSET
|
|
||||||
formatter = generic
|
|
||||||
|
|
||||||
[formatter_generic]
|
|
||||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from logging.config import fileConfig
|
|
||||||
|
|
||||||
from alembic import context
|
|
||||||
from sqlalchemy import engine_from_config, pool
|
|
||||||
|
|
||||||
from app.config import get_settings
|
|
||||||
from app.db_models import Base
|
|
||||||
|
|
||||||
config = context.config
|
|
||||||
if config.config_file_name is not None:
|
|
||||||
fileConfig(config.config_file_name)
|
|
||||||
|
|
||||||
config.set_main_option("sqlalchemy.url", get_settings().database_url)
|
|
||||||
target_metadata = Base.metadata
|
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_offline() -> None:
|
|
||||||
url = config.get_main_option("sqlalchemy.url")
|
|
||||||
context.configure(url=url, target_metadata=target_metadata, literal_binds=True, compare_type=True)
|
|
||||||
with context.begin_transaction():
|
|
||||||
context.run_migrations()
|
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_online() -> None:
|
|
||||||
connectable = engine_from_config(
|
|
||||||
config.get_section(config.config_ini_section, {}),
|
|
||||||
prefix="sqlalchemy.",
|
|
||||||
poolclass=pool.NullPool,
|
|
||||||
)
|
|
||||||
with connectable.connect() as connection:
|
|
||||||
context.configure(connection=connection, target_metadata=target_metadata, compare_type=True)
|
|
||||||
with context.begin_transaction():
|
|
||||||
context.run_migrations()
|
|
||||||
|
|
||||||
|
|
||||||
if context.is_offline_mode():
|
|
||||||
run_migrations_offline()
|
|
||||||
else:
|
|
||||||
run_migrations_online()
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
"""${message}
|
|
||||||
|
|
||||||
Revision ID: ${up_revision}
|
|
||||||
Revises: ${down_revision | comma,n}
|
|
||||||
Create Date: ${create_date}
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = ${repr(up_revision)}
|
|
||||||
down_revision = ${repr(down_revision)}
|
|
||||||
branch_labels = ${repr(branch_labels)}
|
|
||||||
depends_on = ${repr(depends_on)}
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
${upgrades if upgrades else "pass"}
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
${downgrades if downgrades else "pass"}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
147
app/conf_models.py
Normal file
147
app/conf_models.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ConfSource(BaseModel):
|
||||||
|
key: str
|
||||||
|
source_type: Literal["url", "file", "inline", "base64"] = "url"
|
||||||
|
value: str
|
||||||
|
enabled: bool = True
|
||||||
|
cache_ttl: int | None = None
|
||||||
|
options: dict[str, str] = Field(default_factory=dict)
|
||||||
|
line_no: int
|
||||||
|
|
||||||
|
|
||||||
|
class ConfSelector(BaseModel):
|
||||||
|
key: str
|
||||||
|
regex: str
|
||||||
|
line_no: int
|
||||||
|
|
||||||
|
|
||||||
|
class ConfGroup(BaseModel):
|
||||||
|
name: str
|
||||||
|
group_type: str
|
||||||
|
tokens: list[str] = Field(default_factory=list)
|
||||||
|
raw: str
|
||||||
|
line_no: int
|
||||||
|
|
||||||
|
|
||||||
|
class ConfModule(BaseModel):
|
||||||
|
key: str
|
||||||
|
path: str
|
||||||
|
policy: str
|
||||||
|
order: int
|
||||||
|
enabled: bool = True
|
||||||
|
line_no: int
|
||||||
|
|
||||||
|
def resolved_path(self, config_dir: Path) -> Path:
|
||||||
|
return (config_dir / self.path).resolve()
|
||||||
|
|
||||||
|
|
||||||
|
class ConfBuiltin(BaseModel):
|
||||||
|
key: str
|
||||||
|
builtin_type: Literal["GEOIP", "FINAL"]
|
||||||
|
value: str = ""
|
||||||
|
policy: str
|
||||||
|
order: int
|
||||||
|
enabled: bool = True
|
||||||
|
line_no: int
|
||||||
|
|
||||||
|
|
||||||
|
class ConfBaseConfig(BaseModel):
|
||||||
|
listen: str | None = None
|
||||||
|
output_dir: str | None = None
|
||||||
|
cache_dir: str | None = None
|
||||||
|
mode: str | None = None
|
||||||
|
allow_lan: bool | None = None
|
||||||
|
log_level: str | None = None
|
||||||
|
ipv6: bool | None = None
|
||||||
|
append_userinfo_header: bool | None = None
|
||||||
|
userinfo_source_policy: str | None = None
|
||||||
|
sources: list[ConfSource] = Field(default_factory=list)
|
||||||
|
selectors: list[ConfSelector] = Field(default_factory=list)
|
||||||
|
groups: list[ConfGroup] = Field(default_factory=list)
|
||||||
|
modules: list[ConfModule] = Field(default_factory=list)
|
||||||
|
builtins: list[ConfBuiltin] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfPolicyOverride(BaseModel):
|
||||||
|
module_key: str
|
||||||
|
policy: str
|
||||||
|
line_no: int
|
||||||
|
|
||||||
|
|
||||||
|
class ConfInlineRule(BaseModel):
|
||||||
|
value: str
|
||||||
|
line_no: int
|
||||||
|
|
||||||
|
|
||||||
|
class ConfProfileConfig(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
enabled: bool = True
|
||||||
|
sources: list[str] = Field(default_factory=list)
|
||||||
|
include_modules: list[str] = Field(default_factory=list)
|
||||||
|
exclude_modules: list[str] = Field(default_factory=list)
|
||||||
|
include_builtins: list[str] = Field(default_factory=list)
|
||||||
|
override_policies: list[ConfPolicyOverride] = Field(default_factory=list)
|
||||||
|
prepend_rules: list[ConfInlineRule] = Field(default_factory=list)
|
||||||
|
append_rules: list[ConfInlineRule] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfResolvedSource(BaseModel):
|
||||||
|
key: str
|
||||||
|
display_name: str
|
||||||
|
source_type: str
|
||||||
|
value: str
|
||||||
|
enabled: bool = True
|
||||||
|
cache_ttl: int | None = None
|
||||||
|
options: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfResolvedModule(BaseModel):
|
||||||
|
key: str
|
||||||
|
path: str
|
||||||
|
policy: str
|
||||||
|
order: int
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class ConfResolvedBuiltin(BaseModel):
|
||||||
|
key: str
|
||||||
|
rule_line: str
|
||||||
|
policy: str
|
||||||
|
order: int
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class ConfResolvedGroup(BaseModel):
|
||||||
|
name: str
|
||||||
|
group_type: str
|
||||||
|
members: list[str] = Field(default_factory=list)
|
||||||
|
selector_key: str | None = None
|
||||||
|
url: str | None = None
|
||||||
|
interval: int | None = None
|
||||||
|
tolerance: int | None = None
|
||||||
|
raw: str
|
||||||
|
|
||||||
|
|
||||||
|
class ConfRuntimePlan(BaseModel):
|
||||||
|
profile_key: str
|
||||||
|
profile_name: str | None = None
|
||||||
|
selected_sources: list[ConfResolvedSource] = Field(default_factory=list)
|
||||||
|
selected_modules: list[ConfResolvedModule] = Field(default_factory=list)
|
||||||
|
selected_builtins: list[ConfResolvedBuiltin] = Field(default_factory=list)
|
||||||
|
selectors: dict[str, str] = Field(default_factory=dict)
|
||||||
|
groups: list[ConfResolvedGroup] = Field(default_factory=list)
|
||||||
|
prepend_rules: list[str] = Field(default_factory=list)
|
||||||
|
append_rules: list[str] = Field(default_factory=list)
|
||||||
|
mode: str | None = None
|
||||||
|
allow_lan: bool | None = None
|
||||||
|
log_level: str | None = None
|
||||||
|
ipv6: bool | None = None
|
||||||
|
append_userinfo_header: bool | None = None
|
||||||
|
userinfo_source_policy: str | None = None
|
||||||
@@ -26,19 +26,10 @@ class Settings(BaseSettings):
|
|||||||
bundle_cache_ttl_seconds: int = 600
|
bundle_cache_ttl_seconds: int = 600
|
||||||
max_proxy_name_length: int = 80
|
max_proxy_name_length: int = 80
|
||||||
default_user_agent: str = "sub-provider/0.2"
|
default_user_agent: str = "sub-provider/0.2"
|
||||||
database_url: str = Field(default=f"sqlite:///{(DATA_DIR / 'app.db').resolve().as_posix()}")
|
|
||||||
database_echo: bool = False
|
|
||||||
admin_token: str | None = None
|
|
||||||
admin_session_secret: str = "change-this-admin-session-secret"
|
|
||||||
admin_session_max_age: int = 86400
|
|
||||||
|
|
||||||
config_dir: Path = CONFIG_DIR
|
|
||||||
sources_file: Path = CONFIG_DIR / "sources.yaml"
|
sources_file: Path = CONFIG_DIR / "sources.yaml"
|
||||||
app_config_file: Path = CONFIG_DIR / "app.yaml"
|
conf_base_file: Path = CONFIG_DIR / "conf" / "base.conf"
|
||||||
clients_file: Path = CONFIG_DIR / "clients.yaml"
|
conf_profiles_dir: Path = CONFIG_DIR / "conf" / "profiles"
|
||||||
regions_file: Path = CONFIG_DIR / "regions.yaml"
|
|
||||||
policy_groups_file: Path = CONFIG_DIR / "policy-groups.yaml"
|
|
||||||
rules_config_file: Path = CONFIG_DIR / "rules.yaml"
|
|
||||||
rules_dir: Path = CONFIG_DIR / "rules"
|
rules_dir: Path = CONFIG_DIR / "rules"
|
||||||
bundle_cache_dir: Path = ROOT_DIR / "output" / "bundle-cache"
|
bundle_cache_dir: Path = ROOT_DIR / "output" / "bundle-cache"
|
||||||
fetch_cache_dir: Path = DATA_DIR / "fetch-cache"
|
fetch_cache_dir: Path = DATA_DIR / "fetch-cache"
|
||||||
|
|||||||
40
app/db.py
40
app/db.py
@@ -1,40 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from functools import lru_cache
|
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
|
||||||
|
|
||||||
from app.config import DATA_DIR, get_settings
|
|
||||||
from app.db_models import Base
|
|
||||||
|
|
||||||
|
|
||||||
def _sqlite_connect_args(database_url: str) -> dict[str, bool]:
|
|
||||||
if database_url.startswith("sqlite"):
|
|
||||||
return {"check_same_thread": False}
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
|
||||||
def get_engine():
|
|
||||||
settings = get_settings()
|
|
||||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
return create_engine(
|
|
||||||
settings.database_url,
|
|
||||||
echo=settings.database_echo,
|
|
||||||
future=True,
|
|
||||||
connect_args=_sqlite_connect_args(settings.database_url),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
|
||||||
def get_session_factory():
|
|
||||||
return sessionmaker(bind=get_engine(), autoflush=False, autocommit=False, future=True)
|
|
||||||
|
|
||||||
|
|
||||||
def init_db() -> None:
|
|
||||||
Base.metadata.create_all(bind=get_engine())
|
|
||||||
|
|
||||||
|
|
||||||
def get_session() -> Session:
|
|
||||||
return get_session_factory()()
|
|
||||||
149
app/db_models.py
149
app/db_models.py
@@ -1,149 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
|
||||||
|
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class TimestampMixin:
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
|
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime,
|
|
||||||
default=datetime.utcnow,
|
|
||||||
onupdate=datetime.utcnow,
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AppSettingORM(Base, TimestampMixin):
|
|
||||||
__tablename__ = "app_settings"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
||||||
key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
|
||||||
value: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
|
|
||||||
|
|
||||||
class SourceORM(Base, TimestampMixin):
|
|
||||||
__tablename__ = "sources"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
||||||
key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
|
||||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
||||||
kind: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
||||||
url: Mapped[str] = mapped_column(Text, nullable=False)
|
|
||||||
display_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
||||||
headers_json: Mapped[str] = mapped_column(Text, default="{}", nullable=False)
|
|
||||||
include_regex: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
exclude_regex: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
prefix: Mapped[str] = mapped_column(String(255), default="", nullable=False)
|
|
||||||
suffix: Mapped[str] = mapped_column(String(255), default="", nullable=False)
|
|
||||||
cache_ttl_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
||||||
|
|
||||||
|
|
||||||
class ProfileORM(Base, TimestampMixin):
|
|
||||||
__tablename__ = "profiles"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
||||||
key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
|
||||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
provider_interval: Mapped[int] = mapped_column(Integer, default=21600, nullable=False)
|
|
||||||
rule_interval: Mapped[int] = mapped_column(Integer, default=86400, nullable=False)
|
|
||||||
test_url: Mapped[str] = mapped_column(Text, nullable=False)
|
|
||||||
test_interval: Mapped[int] = mapped_column(Integer, default=300, nullable=False)
|
|
||||||
main_policy: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
source_policy: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
mixed_auto_policy: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
manual_policy: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
direct_policy: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
mode: Mapped[str] = mapped_column(String(50), default="rule", nullable=False)
|
|
||||||
allow_lan: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
||||||
ipv6: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
||||||
mixed_port: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
||||||
socks_port: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
||||||
log_level: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
|
||||||
|
|
||||||
source_links: Mapped[list["ProfileSourceORM"]] = relationship(
|
|
||||||
back_populates="profile",
|
|
||||||
cascade="all, delete-orphan",
|
|
||||||
)
|
|
||||||
rule_links: Mapped[list["ProfileRuleModuleORM"]] = relationship(
|
|
||||||
back_populates="profile",
|
|
||||||
cascade="all, delete-orphan",
|
|
||||||
)
|
|
||||||
groups: Mapped[list["PolicyGroupORM"]] = relationship(
|
|
||||||
back_populates="profile",
|
|
||||||
cascade="all, delete-orphan",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ProfileSourceORM(Base):
|
|
||||||
__tablename__ = "profile_sources"
|
|
||||||
__table_args__ = (UniqueConstraint("profile_id", "source_id", name="uq_profile_source"),)
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
||||||
profile_id: Mapped[int] = mapped_column(ForeignKey("profiles.id"), nullable=False)
|
|
||||||
source_id: Mapped[int] = mapped_column(ForeignKey("sources.id"), nullable=False)
|
|
||||||
order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
|
||||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
||||||
|
|
||||||
profile: Mapped[ProfileORM] = relationship(back_populates="source_links")
|
|
||||||
source: Mapped[SourceORM] = relationship()
|
|
||||||
|
|
||||||
|
|
||||||
class RuleModuleORM(Base, TimestampMixin):
|
|
||||||
__tablename__ = "rule_modules"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
||||||
key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
|
||||||
file_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
behavior: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
||||||
format: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
||||||
policy: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
no_resolve: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
|
||||||
payload_json: Mapped[str] = mapped_column(Text, default="[]", nullable=False)
|
|
||||||
|
|
||||||
profile_links: Mapped[list["ProfileRuleModuleORM"]] = relationship(
|
|
||||||
back_populates="rule_module",
|
|
||||||
cascade="all, delete-orphan",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ProfileRuleModuleORM(Base):
|
|
||||||
__tablename__ = "profile_rule_modules"
|
|
||||||
__table_args__ = (UniqueConstraint("profile_id", "rule_module_id", name="uq_profile_rule_module"),)
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
||||||
profile_id: Mapped[int] = mapped_column(ForeignKey("profiles.id"), nullable=False)
|
|
||||||
rule_module_id: Mapped[int] = mapped_column(ForeignKey("rule_modules.id"), nullable=False)
|
|
||||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
||||||
order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
|
||||||
policy_override: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
||||||
payload_override_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
no_resolve_override: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
|
||||||
|
|
||||||
profile: Mapped[ProfileORM] = relationship(back_populates="rule_links")
|
|
||||||
rule_module: Mapped[RuleModuleORM] = relationship(back_populates="profile_links")
|
|
||||||
|
|
||||||
|
|
||||||
class PolicyGroupORM(Base, TimestampMixin):
|
|
||||||
__tablename__ = "policy_groups"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
||||||
profile_id: Mapped[int] = mapped_column(ForeignKey("profiles.id"), nullable=False)
|
|
||||||
group_kind: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
type: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
||||||
order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
|
||||||
proxies_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
filter_regex: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
tolerance: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
||||||
url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
interval: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
||||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
||||||
|
|
||||||
profile: Mapped[ProfileORM] = relationship(back_populates="groups")
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
|
|
||||||
from app.config import get_settings
|
|
||||||
from app.services.config_store import import_yaml_config_to_db
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
parser = argparse.ArgumentParser(description="Import YAML config into the sub-provider database")
|
|
||||||
parser.add_argument(
|
|
||||||
"--replace",
|
|
||||||
action="store_true",
|
|
||||||
help="Clear existing config rows before import",
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
settings = get_settings()
|
|
||||||
import_yaml_config_to_db(settings.sources_file, replace_existing=args.replace)
|
|
||||||
print(f"Imported config into {settings.database_url}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
583
app/main.py
583
app/main.py
@@ -1,32 +1,17 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hmac
|
|
||||||
import logging
|
import logging
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
from fastapi import FastAPI, Form, HTTPException, Query, Request
|
from fastapi import FastAPI, HTTPException, Query, Request
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
from fastapi.responses import Response
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.models import RuleConfig, SourceConfig, SourceSnapshot
|
from app.models import RuleConfig, SourceConfig, SourceSnapshot
|
||||||
from app.services.bundle_cache import build_bundle_cache_key, load_bundle_cache, save_bundle_cache
|
from app.services.bundle_cache import build_bundle_cache_key, load_bundle_cache, save_bundle_cache
|
||||||
from app.services.config_store import (
|
from app.services.conf_config_store import load_conf_store
|
||||||
delete_source,
|
from app.services.conf_loader import ConfConfigError
|
||||||
get_profile_default_source_keys,
|
from app.services.conf_profiles import build_conf_bundle_profile, build_conf_thin_profile, conf_source_to_source_config
|
||||||
list_profile_groups,
|
from app.services.conf_runtime import build_conf_rule_lines, resolve_conf_runtime_plan
|
||||||
list_profile_rule_bindings,
|
|
||||||
list_profile_source_bindings,
|
|
||||||
list_profiles,
|
|
||||||
list_sources,
|
|
||||||
load_profile_app_config_from_db,
|
|
||||||
replace_profile_groups,
|
|
||||||
save_profile,
|
|
||||||
save_source,
|
|
||||||
update_profile_source_bindings,
|
|
||||||
update_profile_rule_bindings,
|
|
||||||
)
|
|
||||||
from app.services.loader import load_app_config
|
from app.services.loader import load_app_config
|
||||||
from app.services.profiles import build_bundle_profile, build_thin_profile, dump_yaml
|
from app.services.profiles import build_bundle_profile, build_thin_profile, dump_yaml
|
||||||
from app.services.rules import load_rule_text
|
from app.services.rules import load_rule_text
|
||||||
@@ -41,17 +26,8 @@ from app.services.subscriptions import (
|
|||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
app = FastAPI(title=settings.app_name)
|
app = FastAPI(title=settings.app_name)
|
||||||
app.add_middleware(
|
|
||||||
SessionMiddleware,
|
|
||||||
secret_key=settings.admin_session_secret,
|
|
||||||
session_cookie="sub_provider_admin",
|
|
||||||
max_age=settings.admin_session_max_age,
|
|
||||||
same_site="lax",
|
|
||||||
https_only=False,
|
|
||||||
)
|
|
||||||
app_config = load_app_config(settings.sources_file)
|
app_config = load_app_config(settings.sources_file)
|
||||||
PUBLIC_PREFIX = "/" + (app_config.public_path or settings.public_path).strip("/")
|
PUBLIC_PREFIX = "/" + (app_config.public_path or settings.public_path).strip("/")
|
||||||
templates = Jinja2Templates(directory=str(settings.config_dir.parent / "templates"))
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/healthz")
|
@app.get("/healthz")
|
||||||
@@ -65,58 +41,9 @@ def _base_url(request: Request) -> str:
|
|||||||
return str(request.base_url).rstrip("/")
|
return str(request.base_url).rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
def _admin_auth_enabled() -> bool:
|
def _resolve_sources(sources: str | None) -> list[tuple[str, SourceConfig]]:
|
||||||
return bool((settings.admin_token or "").strip())
|
enabled = [(name, src) for name, src in app_config.sources.items() if src.enabled and str(src.url).strip()]
|
||||||
|
|
||||||
|
|
||||||
def _is_admin_authenticated(request: Request) -> bool:
|
|
||||||
return not _admin_auth_enabled() or request.session.get("admin_authenticated") is True
|
|
||||||
|
|
||||||
|
|
||||||
def _set_flash(request: Request, message: str, level: str = "info") -> None:
|
|
||||||
request.session["_flash"] = {"message": message, "level": level}
|
|
||||||
|
|
||||||
|
|
||||||
def _pop_flash(request: Request):
|
|
||||||
return request.session.pop("_flash", None)
|
|
||||||
|
|
||||||
|
|
||||||
def _admin_context(request: Request, *, active: str, **extra):
|
|
||||||
return {
|
|
||||||
"request": request,
|
|
||||||
"active": active,
|
|
||||||
"flash": _pop_flash(request),
|
|
||||||
"auth_enabled": _admin_auth_enabled(),
|
|
||||||
"authenticated": _is_admin_authenticated(request),
|
|
||||||
**extra,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _admin_guard(request: Request) -> RedirectResponse | None:
|
|
||||||
if _is_admin_authenticated(request):
|
|
||||||
return None
|
|
||||||
next_path = quote(str(request.url.path))
|
|
||||||
return _redirect(f"/admin/login?next={next_path}")
|
|
||||||
|
|
||||||
|
|
||||||
def _current_app_config(profile_key: str | None = None):
|
|
||||||
if profile_key:
|
|
||||||
profile_config = load_profile_app_config_from_db(profile_key)
|
|
||||||
if profile_config is not None:
|
|
||||||
return profile_config
|
|
||||||
return load_app_config(settings.sources_file)
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_sources(sources: str | None, profile_key: str | None = None) -> list[tuple[str, SourceConfig]]:
|
|
||||||
current_config = _current_app_config(profile_key=profile_key)
|
|
||||||
enabled = [(name, src) for name, src in current_config.sources.items() if src.enabled and str(src.url).strip()]
|
|
||||||
if not sources:
|
if not sources:
|
||||||
if profile_key:
|
|
||||||
default_keys = get_profile_default_source_keys(profile_key)
|
|
||||||
if default_keys:
|
|
||||||
selected = [(name, current_config.sources[name]) for name in default_keys if name in current_config.sources]
|
|
||||||
logger.info("resolve_sources profile default: profile=%s selected=%s", profile_key, [name for name, _ in selected])
|
|
||||||
return selected
|
|
||||||
logger.info("resolve_sources default: selected=%s", [name for name, _ in enabled])
|
logger.info("resolve_sources default: selected=%s", [name for name, _ in enabled])
|
||||||
return enabled
|
return enabled
|
||||||
|
|
||||||
@@ -126,7 +53,7 @@ def _resolve_sources(sources: str | None, profile_key: str | None = None) -> lis
|
|||||||
for name in names:
|
for name in names:
|
||||||
if name in seen:
|
if name in seen:
|
||||||
continue
|
continue
|
||||||
source = current_config.sources.get(name)
|
source = app_config.sources.get(name)
|
||||||
if source is None or not source.enabled or not str(source.url).strip():
|
if source is None or not source.enabled or not str(source.url).strip():
|
||||||
raise HTTPException(status_code=404, detail=f"source not found or disabled: {name}")
|
raise HTTPException(status_code=404, detail=f"source not found or disabled: {name}")
|
||||||
selected.append((name, source))
|
selected.append((name, source))
|
||||||
@@ -146,6 +73,37 @@ def _rule_path(rule: RuleConfig):
|
|||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _load_conf_profile_runtime(profile_key: str):
|
||||||
|
try:
|
||||||
|
base_config, profiles = load_conf_store()
|
||||||
|
except ConfConfigError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=f"invalid conf config: {exc}") from exc
|
||||||
|
|
||||||
|
profile = profiles.get(profile_key)
|
||||||
|
if profile is None:
|
||||||
|
raise HTTPException(status_code=404, detail="conf profile not found")
|
||||||
|
try:
|
||||||
|
plan = resolve_conf_runtime_plan(
|
||||||
|
profile_key=profile_key,
|
||||||
|
base_config=base_config,
|
||||||
|
profile_config=profile,
|
||||||
|
base_path=settings.conf_base_file,
|
||||||
|
)
|
||||||
|
except ConfConfigError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return base_config, profile, plan
|
||||||
|
|
||||||
|
|
||||||
|
def _conf_source_items(plan) -> list[tuple[str, SourceConfig]]:
|
||||||
|
items: list[tuple[str, SourceConfig]] = []
|
||||||
|
for source in plan.selected_sources:
|
||||||
|
try:
|
||||||
|
items.append((source.key, conf_source_to_source_config(source, base_path=settings.conf_base_file)))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
async def _build_quota_headers(source_items: list[tuple[str, SourceConfig]]) -> dict[str, str]:
|
async def _build_quota_headers(source_items: list[tuple[str, SourceConfig]]) -> dict[str, str]:
|
||||||
headers: dict[str, str] = {}
|
headers: dict[str, str] = {}
|
||||||
quota = await get_first_quota(source_items)
|
quota = await get_first_quota(source_items)
|
||||||
@@ -192,8 +150,7 @@ async def merged_provider(request: Request, sources: str | None = Query(default=
|
|||||||
|
|
||||||
@app.api_route(PUBLIC_PREFIX + "/providers/{name}.yaml", methods=["GET", "HEAD"])
|
@app.api_route(PUBLIC_PREFIX + "/providers/{name}.yaml", methods=["GET", "HEAD"])
|
||||||
async def provider(name: str, request: Request) -> Response:
|
async def provider(name: str, request: Request) -> Response:
|
||||||
current_config = _current_app_config()
|
source = app_config.sources.get(name)
|
||||||
source = current_config.sources.get(name)
|
|
||||||
if source is None or not source.enabled:
|
if source is None or not source.enabled:
|
||||||
raise HTTPException(status_code=404, detail="provider not found")
|
raise HTTPException(status_code=404, detail="provider not found")
|
||||||
|
|
||||||
@@ -210,8 +167,7 @@ async def provider(name: str, request: Request) -> Response:
|
|||||||
|
|
||||||
@app.api_route(PUBLIC_PREFIX + "/rules/{name}.yaml", methods=["GET", "HEAD"])
|
@app.api_route(PUBLIC_PREFIX + "/rules/{name}.yaml", methods=["GET", "HEAD"])
|
||||||
async def rule_file(name: str, request: Request) -> Response:
|
async def rule_file(name: str, request: Request) -> Response:
|
||||||
current_config = _current_app_config()
|
rule = app_config.rules.get(name)
|
||||||
rule = current_config.rules.get(name)
|
|
||||||
if rule is None:
|
if rule is None:
|
||||||
raise HTTPException(status_code=404, detail="rule not found")
|
raise HTTPException(status_code=404, detail="rule not found")
|
||||||
content = load_rule_text(_rule_path(rule))
|
content = load_rule_text(_rule_path(rule))
|
||||||
@@ -220,19 +176,19 @@ async def rule_file(name: str, request: Request) -> Response:
|
|||||||
|
|
||||||
@app.api_route(PUBLIC_PREFIX + "/clients/{client_type}.yaml", methods=["GET", "HEAD"])
|
@app.api_route(PUBLIC_PREFIX + "/clients/{client_type}.yaml", methods=["GET", "HEAD"])
|
||||||
async def client_profile(client_type: str, request: Request, sources: str | None = Query(default=None)) -> Response:
|
async def client_profile(client_type: str, request: Request, sources: str | None = Query(default=None)) -> Response:
|
||||||
current_config = _current_app_config(profile_key=client_type)
|
client = app_config.clients.get(client_type)
|
||||||
client = current_config.clients.get(client_type)
|
|
||||||
if client is None:
|
if client is None:
|
||||||
raise HTTPException(status_code=404, detail="client config not found")
|
raise HTTPException(status_code=404, detail="client config not found")
|
||||||
|
|
||||||
source_items = _resolve_sources(sources, profile_key=client_type)
|
source_items = _resolve_sources(sources)
|
||||||
content = dump_yaml(
|
content = dump_yaml(
|
||||||
build_thin_profile(
|
build_thin_profile(
|
||||||
client_type=client_type,
|
client_type=client_type,
|
||||||
app_config=current_config,
|
app_config=app_config,
|
||||||
|
client=client,
|
||||||
selected_source_names=[name for name, _ in source_items],
|
selected_source_names=[name for name, _ in source_items],
|
||||||
base_url=_base_url(request),
|
base_url=_base_url(request),
|
||||||
public_path=(current_config.public_path or settings.public_path).strip("/"),
|
public_path=(app_config.public_path or settings.public_path).strip("/"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
headers = {"profile-update-interval": str(client.provider_interval)}
|
headers = {"profile-update-interval": str(client.provider_interval)}
|
||||||
@@ -240,6 +196,58 @@ async def client_profile(client_type: str, request: Request, sources: str | None
|
|||||||
return _yaml_response(content, request, headers=headers, filename=f"{client_type}.yaml")
|
return _yaml_response(content, request, headers=headers, filename=f"{client_type}.yaml")
|
||||||
|
|
||||||
|
|
||||||
|
@app.api_route(PUBLIC_PREFIX + "/conf/rules/{profile_key}/{name}.yaml", methods=["GET", "HEAD"])
|
||||||
|
async def conf_rule_file(profile_key: str, name: str, request: Request) -> Response:
|
||||||
|
_, _, plan = _load_conf_profile_runtime(profile_key)
|
||||||
|
module = next((item for item in plan.selected_modules if item.key == name), None)
|
||||||
|
if module is None:
|
||||||
|
raise HTTPException(status_code=404, detail="conf rule not found")
|
||||||
|
path = (settings.conf_base_file.resolve().parent / module.path).resolve()
|
||||||
|
if not path.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="conf rule file missing")
|
||||||
|
content = load_rule_text(path)
|
||||||
|
return _yaml_response(content, request, filename=f"{profile_key}-{name}.yaml")
|
||||||
|
|
||||||
|
|
||||||
|
@app.api_route(PUBLIC_PREFIX + "/conf/providers/{profile_key}/{name}.yaml", methods=["GET", "HEAD"])
|
||||||
|
async def conf_provider(profile_key: str, name: str, request: Request) -> Response:
|
||||||
|
_, _, plan = _load_conf_profile_runtime(profile_key)
|
||||||
|
source_items = _conf_source_items(plan)
|
||||||
|
source = next((item for item in source_items if item[0] == name), None)
|
||||||
|
if source is None:
|
||||||
|
raise HTTPException(status_code=404, detail="conf provider not found")
|
||||||
|
try:
|
||||||
|
document = await build_provider_document(source[0], source[1])
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.exception("conf provider failed: profile=%s source=%s", profile_key, name)
|
||||||
|
raise HTTPException(status_code=502, detail=f"failed to build conf provider: {exc}") from exc
|
||||||
|
content = dump_provider_yaml(document)
|
||||||
|
headers = await _build_quota_headers([source])
|
||||||
|
return _yaml_response(content, request, headers=headers, filename=f"{profile_key}-{name}.yaml")
|
||||||
|
|
||||||
|
|
||||||
|
@app.api_route(PUBLIC_PREFIX + "/conf/clients/{client_type}/{profile_key}.yaml", methods=["GET", "HEAD"])
|
||||||
|
async def conf_client_profile(client_type: str, profile_key: str, request: Request) -> Response:
|
||||||
|
client = app_config.clients.get(client_type)
|
||||||
|
if client is None:
|
||||||
|
raise HTTPException(status_code=404, detail="client config not found")
|
||||||
|
_, _, plan = _load_conf_profile_runtime(profile_key)
|
||||||
|
source_items = _conf_source_items(plan)
|
||||||
|
content = dump_yaml(
|
||||||
|
build_conf_thin_profile(
|
||||||
|
client_type=client_type,
|
||||||
|
client=client,
|
||||||
|
plan=plan,
|
||||||
|
base_path=settings.conf_base_file,
|
||||||
|
base_url=_base_url(request),
|
||||||
|
public_path=(app_config.public_path or settings.public_path).strip("/"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
headers = {"profile-update-interval": str(client.provider_interval)}
|
||||||
|
headers.update(await _build_quota_headers(source_items))
|
||||||
|
return _yaml_response(content, request, headers=headers, filename=f"{client_type}-{profile_key}.yaml")
|
||||||
|
|
||||||
|
|
||||||
@app.api_route(PUBLIC_PREFIX + "/bundle/{client_type}.yaml", methods=["GET", "HEAD"])
|
@app.api_route(PUBLIC_PREFIX + "/bundle/{client_type}.yaml", methods=["GET", "HEAD"])
|
||||||
async def bundle_profile(
|
async def bundle_profile(
|
||||||
client_type: str,
|
client_type: str,
|
||||||
@@ -247,12 +255,11 @@ async def bundle_profile(
|
|||||||
sources: str | None = Query(default=None),
|
sources: str | None = Query(default=None),
|
||||||
force_refresh: bool = Query(default=False),
|
force_refresh: bool = Query(default=False),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
current_config = _current_app_config(profile_key=client_type)
|
client = app_config.clients.get(client_type)
|
||||||
client = current_config.clients.get(client_type)
|
|
||||||
if client is None:
|
if client is None:
|
||||||
raise HTTPException(status_code=404, detail="client config not found")
|
raise HTTPException(status_code=404, detail="client config not found")
|
||||||
|
|
||||||
source_items = _resolve_sources(sources, profile_key=client_type)
|
source_items = _resolve_sources(sources)
|
||||||
cache_key = build_bundle_cache_key(client_type=client_type, source_names=[name for name, _ in source_items])
|
cache_key = build_bundle_cache_key(client_type=client_type, source_names=[name for name, _ in source_items])
|
||||||
if not force_refresh:
|
if not force_refresh:
|
||||||
cached = load_bundle_cache(
|
cached = load_bundle_cache(
|
||||||
@@ -278,7 +285,8 @@ async def bundle_profile(
|
|||||||
content = dump_yaml(
|
content = dump_yaml(
|
||||||
build_bundle_profile(
|
build_bundle_profile(
|
||||||
client_type=client_type,
|
client_type=client_type,
|
||||||
app_config=current_config,
|
app_config=app_config,
|
||||||
|
client=client,
|
||||||
snapshots=snapshots,
|
snapshots=snapshots,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -296,353 +304,56 @@ async def bundle_profile(
|
|||||||
return _yaml_response(content, request, headers=headers, filename=f"bundle-{client_type}.yaml")
|
return _yaml_response(content, request, headers=headers, filename=f"bundle-{client_type}.yaml")
|
||||||
|
|
||||||
|
|
||||||
def _redirect(url: str) -> RedirectResponse:
|
@app.api_route(PUBLIC_PREFIX + "/conf/bundle/{client_type}/{profile_key}.yaml", methods=["GET", "HEAD"])
|
||||||
return RedirectResponse(url=url, status_code=303)
|
async def conf_bundle_profile(client_type: str, profile_key: str, request: Request, force_refresh: bool = Query(default=False)) -> Response:
|
||||||
|
client = app_config.clients.get(client_type)
|
||||||
|
if client is None:
|
||||||
@app.get("/admin/login", response_class=HTMLResponse)
|
raise HTTPException(status_code=404, detail="client config not found")
|
||||||
async def admin_login(request: Request, next: str = Query(default="/admin/sources")) -> HTMLResponse:
|
_, _, plan = _load_conf_profile_runtime(profile_key)
|
||||||
if _is_admin_authenticated(request):
|
source_items = _conf_source_items(plan)
|
||||||
return _redirect(next)
|
cache_key = build_bundle_cache_key(
|
||||||
return templates.TemplateResponse(
|
client_type=f"conf-{client_type}-{profile_key}",
|
||||||
request,
|
source_names=[name for name, _ in source_items],
|
||||||
"login.html",
|
|
||||||
_admin_context(request, active="", next=next),
|
|
||||||
)
|
)
|
||||||
|
if not force_refresh:
|
||||||
|
cached = load_bundle_cache(
|
||||||
@app.post("/admin/login")
|
cache_dir=settings.bundle_cache_dir,
|
||||||
async def admin_login_submit(
|
cache_key=cache_key,
|
||||||
request: Request,
|
ttl_seconds=settings.bundle_cache_ttl_seconds,
|
||||||
token: str = Form(default=""),
|
|
||||||
next: str = Form(default="/admin/sources"),
|
|
||||||
) -> Response:
|
|
||||||
if not _admin_auth_enabled():
|
|
||||||
request.session["admin_authenticated"] = True
|
|
||||||
return _redirect(next)
|
|
||||||
if hmac.compare_digest((settings.admin_token or "").strip(), token.strip()):
|
|
||||||
request.session["admin_authenticated"] = True
|
|
||||||
_set_flash(request, "登录成功", "success")
|
|
||||||
return _redirect(next)
|
|
||||||
_set_flash(request, "Token 不正确", "error")
|
|
||||||
return _redirect(f"/admin/login?next={quote(next)}")
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/admin/logout")
|
|
||||||
async def admin_logout(request: Request) -> Response:
|
|
||||||
request.session.clear()
|
|
||||||
return _redirect("/admin/login")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/admin", response_class=HTMLResponse)
|
|
||||||
async def admin_index(request: Request) -> Response:
|
|
||||||
redirect = _admin_guard(request)
|
|
||||||
if redirect:
|
|
||||||
return redirect
|
|
||||||
return _redirect("/admin/sources")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/admin/sources", response_class=HTMLResponse)
|
|
||||||
async def admin_sources(request: Request) -> HTMLResponse:
|
|
||||||
redirect = _admin_guard(request)
|
|
||||||
if redirect:
|
|
||||||
return redirect
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request,
|
|
||||||
"sources.html",
|
|
||||||
_admin_context(
|
|
||||||
request,
|
|
||||||
active="sources",
|
|
||||||
sources=list_sources(),
|
|
||||||
profiles=list_profiles(),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
if cached is not None:
|
||||||
|
headers = {
|
||||||
@app.post("/admin/sources")
|
"profile-update-interval": str(client.provider_interval),
|
||||||
async def admin_save_source(
|
"X-Sub-Provider-Bundle-Cache": "HIT",
|
||||||
request: Request,
|
|
||||||
key: str = Form(...),
|
|
||||||
url: str = Form(...),
|
|
||||||
kind: str = Form(default="auto"),
|
|
||||||
display_name: str = Form(default=""),
|
|
||||||
prefix: str = Form(default=""),
|
|
||||||
suffix: str = Form(default=""),
|
|
||||||
include_regex: str = Form(default=""),
|
|
||||||
exclude_regex: str = Form(default=""),
|
|
||||||
cache_ttl_seconds: str = Form(default=""),
|
|
||||||
enabled: str | None = Form(default=None),
|
|
||||||
) -> Response:
|
|
||||||
redirect = _admin_guard(request)
|
|
||||||
if redirect:
|
|
||||||
return redirect
|
|
||||||
save_source(
|
|
||||||
key=key.strip(),
|
|
||||||
enabled=enabled is not None,
|
|
||||||
kind=kind,
|
|
||||||
url=url.strip(),
|
|
||||||
display_name=display_name.strip() or None,
|
|
||||||
headers={},
|
|
||||||
include_regex=include_regex.strip() or None,
|
|
||||||
exclude_regex=exclude_regex.strip() or None,
|
|
||||||
prefix=prefix,
|
|
||||||
suffix=suffix,
|
|
||||||
cache_ttl_seconds=int(cache_ttl_seconds) if cache_ttl_seconds.strip() else None,
|
|
||||||
)
|
|
||||||
_set_flash(request, f"Source {key.strip()} 已保存", "success")
|
|
||||||
return _redirect("/admin/sources")
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/admin/sources/{key}/delete")
|
|
||||||
async def admin_delete_source(request: Request, key: str) -> Response:
|
|
||||||
redirect = _admin_guard(request)
|
|
||||||
if redirect:
|
|
||||||
return redirect
|
|
||||||
delete_source(key)
|
|
||||||
_set_flash(request, f"Source {key} 已删除", "success")
|
|
||||||
return _redirect("/admin/sources")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/admin/profiles", response_class=HTMLResponse)
|
|
||||||
async def admin_profiles(request: Request) -> HTMLResponse:
|
|
||||||
redirect = _admin_guard(request)
|
|
||||||
if redirect:
|
|
||||||
return redirect
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request,
|
|
||||||
"profiles.html",
|
|
||||||
_admin_context(
|
|
||||||
request,
|
|
||||||
active="profiles",
|
|
||||||
profiles=list_profiles(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/admin/profiles")
|
|
||||||
async def admin_save_profile(
|
|
||||||
request: Request,
|
|
||||||
key: str = Form(...),
|
|
||||||
title: str = Form(...),
|
|
||||||
provider_interval: int = Form(default=21600),
|
|
||||||
rule_interval: int = Form(default=86400),
|
|
||||||
test_url: str = Form(...),
|
|
||||||
test_interval: int = Form(default=300),
|
|
||||||
main_policy: str = Form(...),
|
|
||||||
source_policy: str = Form(...),
|
|
||||||
mixed_auto_policy: str = Form(...),
|
|
||||||
manual_policy: str = Form(...),
|
|
||||||
direct_policy: str = Form(...),
|
|
||||||
mode: str = Form(default="rule"),
|
|
||||||
allow_lan: str | None = Form(default=None),
|
|
||||||
ipv6: str | None = Form(default=None),
|
|
||||||
mixed_port: str = Form(default=""),
|
|
||||||
socks_port: str = Form(default=""),
|
|
||||||
log_level: str = Form(default="info"),
|
|
||||||
) -> Response:
|
|
||||||
redirect = _admin_guard(request)
|
|
||||||
if redirect:
|
|
||||||
return redirect
|
|
||||||
save_profile(
|
|
||||||
key=key.strip(),
|
|
||||||
title=title.strip(),
|
|
||||||
provider_interval=provider_interval,
|
|
||||||
rule_interval=rule_interval,
|
|
||||||
test_url=test_url.strip(),
|
|
||||||
test_interval=test_interval,
|
|
||||||
main_policy=main_policy.strip(),
|
|
||||||
source_policy=source_policy.strip(),
|
|
||||||
mixed_auto_policy=mixed_auto_policy.strip(),
|
|
||||||
manual_policy=manual_policy.strip(),
|
|
||||||
direct_policy=direct_policy.strip(),
|
|
||||||
mode=mode.strip(),
|
|
||||||
allow_lan=allow_lan is not None,
|
|
||||||
ipv6=ipv6 is not None,
|
|
||||||
mixed_port=int(mixed_port) if mixed_port.strip() else None,
|
|
||||||
socks_port=int(socks_port) if socks_port.strip() else None,
|
|
||||||
log_level=log_level.strip() or None,
|
|
||||||
)
|
|
||||||
_set_flash(request, f"Profile {key.strip()} 已保存", "success")
|
|
||||||
return _redirect("/admin/profiles")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/admin/profiles/{profile_key}/sources", response_class=HTMLResponse)
|
|
||||||
async def admin_profile_sources(request: Request, profile_key: str) -> HTMLResponse:
|
|
||||||
redirect = _admin_guard(request)
|
|
||||||
if redirect:
|
|
||||||
return redirect
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request,
|
|
||||||
"profile_sources.html",
|
|
||||||
_admin_context(
|
|
||||||
request,
|
|
||||||
active="profiles",
|
|
||||||
profile_key=profile_key,
|
|
||||||
bindings=list_profile_source_bindings(profile_key),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/admin/profiles/{profile_key}/sources")
|
|
||||||
async def admin_update_profile_sources(request: Request, profile_key: str) -> Response:
|
|
||||||
redirect = _admin_guard(request)
|
|
||||||
if redirect:
|
|
||||||
return redirect
|
|
||||||
form = await request.form()
|
|
||||||
keys = form.getlist("source_key")
|
|
||||||
rows: list[dict] = []
|
|
||||||
for index, key in enumerate(keys):
|
|
||||||
rows.append(
|
|
||||||
{
|
|
||||||
"key": key,
|
|
||||||
"enabled": form.get(f"enabled_{key}") is not None,
|
|
||||||
"order_index": form.get(f"order_index_{key}", str(index)),
|
|
||||||
}
|
}
|
||||||
)
|
headers.update(cached.headers)
|
||||||
update_profile_source_bindings(profile_key, rows)
|
return _yaml_response(content=cached.content, request=request, headers=headers, filename=f"bundle-{client_type}-{profile_key}.yaml")
|
||||||
_set_flash(request, f"Profile {profile_key} 的默认源已更新", "success")
|
|
||||||
return _redirect(f"/admin/profiles/{profile_key}/sources")
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
snapshots = await build_source_snapshots(source_items)
|
||||||
|
rules = build_conf_rule_lines(plan, base_path=settings.conf_base_file)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.exception("conf_bundle_profile failed: client=%s profile=%s", client_type, profile_key)
|
||||||
|
raise HTTPException(status_code=502, detail=f"failed to build conf bundle: {exc}") from exc
|
||||||
|
|
||||||
@app.get("/admin/profiles/{profile_key}/rules", response_class=HTMLResponse)
|
content = dump_yaml(
|
||||||
async def admin_profile_rules(request: Request, profile_key: str) -> HTMLResponse:
|
build_conf_bundle_profile(
|
||||||
redirect = _admin_guard(request)
|
client_type=client_type,
|
||||||
if redirect:
|
client=client,
|
||||||
return redirect
|
plan=plan,
|
||||||
return templates.TemplateResponse(
|
|
||||||
request,
|
|
||||||
"rules.html",
|
|
||||||
_admin_context(
|
|
||||||
request,
|
|
||||||
active="profiles",
|
|
||||||
profile_key=profile_key,
|
|
||||||
bindings=list_profile_rule_bindings(profile_key),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/admin/profiles/{profile_key}/groups", response_class=HTMLResponse)
|
|
||||||
async def admin_profile_groups(request: Request, profile_key: str) -> HTMLResponse:
|
|
||||||
redirect = _admin_guard(request)
|
|
||||||
if redirect:
|
|
||||||
return redirect
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request,
|
|
||||||
"groups.html",
|
|
||||||
_admin_context(
|
|
||||||
request,
|
|
||||||
active="profiles",
|
|
||||||
profile_key=profile_key,
|
|
||||||
groups=list_profile_groups(profile_key),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/admin/profiles/{profile_key}/groups")
|
|
||||||
async def admin_update_profile_groups(request: Request, profile_key: str) -> Response:
|
|
||||||
redirect = _admin_guard(request)
|
|
||||||
if redirect:
|
|
||||||
return redirect
|
|
||||||
form = await request.form()
|
|
||||||
row_ids = form.getlist("group_id")
|
|
||||||
rows: list[dict] = []
|
|
||||||
for index, group_id in enumerate(row_ids):
|
|
||||||
group_key = str(index)
|
|
||||||
name = str(form.get(f"group_name_{group_key}", "")).strip()
|
|
||||||
if not str(name).strip():
|
|
||||||
continue
|
|
||||||
proxies_raw = str(form.get(f"proxies_{group_key}", "")).strip()
|
|
||||||
if form.get(f"delete_{group_key}") is not None:
|
|
||||||
continue
|
|
||||||
rows.append(
|
|
||||||
{
|
|
||||||
"id": int(group_id) if str(group_id).strip() else None,
|
|
||||||
"name": str(name).strip(),
|
|
||||||
"group_kind": str(form.get(f"group_kind_{group_key}", "policy")).strip(),
|
|
||||||
"type": str(form.get(f"type_{group_key}", "select")).strip(),
|
|
||||||
"order_index": int(str(form.get(f"order_index_{group_key}", index))),
|
|
||||||
"proxies": [item.strip() for item in proxies_raw.splitlines() if item.strip()],
|
|
||||||
"filter_regex": str(form.get(f"filter_regex_{group_key}", "")).strip(),
|
|
||||||
"tolerance": int(str(form.get(f"tolerance_{group_key}", "")).strip()) if str(form.get(f"tolerance_{group_key}", "")).strip() else None,
|
|
||||||
"url": str(form.get(f"url_{group_key}", "")).strip(),
|
|
||||||
"interval": int(str(form.get(f"interval_{group_key}", "")).strip()) if str(form.get(f"interval_{group_key}", "")).strip() else None,
|
|
||||||
"enabled": form.get(f"enabled_{group_key}") is not None,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
new_name = str(form.get("new_group_name", "")).strip()
|
|
||||||
if new_name:
|
|
||||||
new_proxies_raw = str(form.get("new_group_proxies", "")).strip()
|
|
||||||
rows.append(
|
|
||||||
{
|
|
||||||
"id": None,
|
|
||||||
"name": new_name,
|
|
||||||
"group_kind": str(form.get("new_group_kind", "policy")).strip(),
|
|
||||||
"type": str(form.get("new_group_type", "select")).strip(),
|
|
||||||
"order_index": int(str(form.get("new_group_order_index", len(rows))).strip() or len(rows)),
|
|
||||||
"proxies": [item.strip() for item in new_proxies_raw.splitlines() if item.strip()],
|
|
||||||
"filter_regex": str(form.get("new_group_filter_regex", "")).strip(),
|
|
||||||
"tolerance": int(str(form.get("new_group_tolerance", "")).strip()) if str(form.get("new_group_tolerance", "")).strip() else None,
|
|
||||||
"url": str(form.get("new_group_url", "")).strip(),
|
|
||||||
"interval": int(str(form.get("new_group_interval", "")).strip()) if str(form.get("new_group_interval", "")).strip() else None,
|
|
||||||
"enabled": form.get("new_group_enabled") is not None,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
replace_profile_groups(profile_key, rows)
|
|
||||||
_set_flash(request, f"Profile {profile_key} 的策略组已更新", "success")
|
|
||||||
return _redirect(f"/admin/profiles/{profile_key}/groups")
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/admin/profiles/{profile_key}/rules")
|
|
||||||
async def admin_update_profile_rules(request: Request, profile_key: str) -> Response:
|
|
||||||
redirect = _admin_guard(request)
|
|
||||||
if redirect:
|
|
||||||
return redirect
|
|
||||||
form = await request.form()
|
|
||||||
keys = form.getlist("rule_key")
|
|
||||||
rows: list[dict] = []
|
|
||||||
for index, key in enumerate(keys):
|
|
||||||
rows.append(
|
|
||||||
{
|
|
||||||
"key": key,
|
|
||||||
"enabled": form.get(f"enabled_{key}") is not None,
|
|
||||||
"order_index": form.get(f"order_index_{key}", str(index)),
|
|
||||||
"policy": form.get(f"policy_{key}", ""),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
update_profile_rule_bindings(profile_key, rows)
|
|
||||||
_set_flash(request, f"Profile {profile_key} 的规则绑定已更新", "success")
|
|
||||||
return _redirect(f"/admin/profiles/{profile_key}/rules")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/admin/profiles/{profile_key}/preview", response_class=HTMLResponse)
|
|
||||||
async def admin_profile_preview(
|
|
||||||
request: Request,
|
|
||||||
profile_key: str,
|
|
||||||
sources: str | None = Query(default=None),
|
|
||||||
) -> HTMLResponse:
|
|
||||||
redirect = _admin_guard(request)
|
|
||||||
if redirect:
|
|
||||||
return redirect
|
|
||||||
profile_config = _current_app_config(profile_key=profile_key)
|
|
||||||
source_items = _resolve_sources(sources, profile_key=profile_key)
|
|
||||||
selected_names = [name for name, _ in source_items]
|
|
||||||
snapshots = await build_source_snapshots([(name, profile_config.sources[name]) for name in selected_names])
|
|
||||||
yaml_text = dump_yaml(
|
|
||||||
build_bundle_profile(
|
|
||||||
client_type=profile_key,
|
|
||||||
app_config=profile_config,
|
|
||||||
snapshots=snapshots,
|
snapshots=snapshots,
|
||||||
|
rules=rules,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return templates.TemplateResponse(
|
headers = {
|
||||||
request,
|
"profile-update-interval": str(client.provider_interval),
|
||||||
"preview.html",
|
"X-Sub-Provider-Bundle-Cache": "BYPASS" if force_refresh else "MISS",
|
||||||
_admin_context(
|
}
|
||||||
request,
|
headers.update(_quota_headers_from_snapshots(snapshots))
|
||||||
active="profiles",
|
save_bundle_cache(
|
||||||
profile_key=profile_key,
|
cache_dir=settings.bundle_cache_dir,
|
||||||
yaml_text=yaml_text,
|
cache_key=cache_key,
|
||||||
available_sources=list(profile_config.sources.keys()),
|
content=content,
|
||||||
selected_sources=selected_names,
|
headers={key: value for key, value in headers.items() if key != "X-Sub-Provider-Bundle-Cache"},
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
return _yaml_response(content, request, headers=headers, filename=f"bundle-{client_type}-{profile_key}.yaml")
|
||||||
|
|||||||
@@ -82,26 +82,6 @@ class ProviderDocument(BaseModel):
|
|||||||
proxies: list[dict[str, Any]]
|
proxies: list[dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
class ProxyNode(BaseModel):
|
|
||||||
name: str
|
|
||||||
type: str
|
|
||||||
server: str | None = None
|
|
||||||
port: int | None = None
|
|
||||||
udp: bool = True
|
|
||||||
tags: list[str] = Field(default_factory=list)
|
|
||||||
attrs: dict[str, Any] = Field(default_factory=dict)
|
|
||||||
|
|
||||||
def to_proxy_dict(self) -> dict[str, Any]:
|
|
||||||
data = {"name": self.name, "type": self.type}
|
|
||||||
if self.server is not None:
|
|
||||||
data["server"] = self.server
|
|
||||||
if self.port is not None:
|
|
||||||
data["port"] = self.port
|
|
||||||
data["udp"] = self.udp
|
|
||||||
data.update(self.attrs)
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionUserInfo(BaseModel):
|
class SubscriptionUserInfo(BaseModel):
|
||||||
upload: int | None = None
|
upload: int | None = None
|
||||||
download: int | None = None
|
download: int | None = None
|
||||||
@@ -130,13 +110,3 @@ class SourceSnapshot(BaseModel):
|
|||||||
document: ProviderDocument
|
document: ProviderDocument
|
||||||
headers: dict[str, str] = Field(default_factory=dict)
|
headers: dict[str, str] = Field(default_factory=dict)
|
||||||
quota: SubscriptionUserInfo | None = None
|
quota: SubscriptionUserInfo | None = None
|
||||||
|
|
||||||
|
|
||||||
class ResolvedProfile(BaseModel):
|
|
||||||
client_type: str
|
|
||||||
client: ClientConfig
|
|
||||||
selected_sources: dict[str, SourceConfig] = Field(default_factory=dict)
|
|
||||||
rules: dict[str, RuleConfig] = Field(default_factory=dict)
|
|
||||||
regions: dict[str, RegionConfig] = Field(default_factory=dict)
|
|
||||||
selector_groups: list[ProxyGroupConfig] = Field(default_factory=list)
|
|
||||||
policy_groups: list[ProxyGroupConfig] = Field(default_factory=list)
|
|
||||||
|
|||||||
12
app/services/conf_config_store.py
Normal file
12
app/services/conf_config_store.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.conf_models import ConfBaseConfig, ConfProfileConfig
|
||||||
|
from app.services.conf_loader import load_conf_base, load_conf_profiles
|
||||||
|
|
||||||
|
|
||||||
|
def load_conf_store() -> tuple[ConfBaseConfig, dict[str, ConfProfileConfig]]:
|
||||||
|
settings = get_settings()
|
||||||
|
base = load_conf_base(settings.conf_base_file)
|
||||||
|
profiles = load_conf_profiles(settings.conf_profiles_dir, base_config=base)
|
||||||
|
return base, profiles
|
||||||
360
app/services/conf_loader.py
Normal file
360
app/services/conf_loader.py
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.conf_models import (
|
||||||
|
ConfBaseConfig,
|
||||||
|
ConfBuiltin,
|
||||||
|
ConfGroup,
|
||||||
|
ConfInlineRule,
|
||||||
|
ConfModule,
|
||||||
|
ConfPolicyOverride,
|
||||||
|
ConfProfileConfig,
|
||||||
|
ConfSelector,
|
||||||
|
ConfSource,
|
||||||
|
)
|
||||||
|
|
||||||
|
_ENV_PATTERN = re.compile(r"\$\{([A-Z0-9_]+)\}")
|
||||||
|
_SCALAR_KEYS = {
|
||||||
|
"listen",
|
||||||
|
"output_dir",
|
||||||
|
"cache_dir",
|
||||||
|
"mode",
|
||||||
|
"allow_lan",
|
||||||
|
"log_level",
|
||||||
|
"ipv6",
|
||||||
|
"append_userinfo_header",
|
||||||
|
"userinfo_source_policy",
|
||||||
|
}
|
||||||
|
_BOOL_KEYS = {"allow_lan", "ipv6", "append_userinfo_header"}
|
||||||
|
_MULTI_KEYS = {"source", "selector", "group", "module", "builtin"}
|
||||||
|
_SOURCE_TYPES = {"url", "file", "inline", "base64"}
|
||||||
|
_PROFILE_SCALAR_KEYS = {
|
||||||
|
"name",
|
||||||
|
"enabled",
|
||||||
|
"sources",
|
||||||
|
"include_modules",
|
||||||
|
"exclude_modules",
|
||||||
|
"include_builtins",
|
||||||
|
}
|
||||||
|
_PROFILE_MULTI_KEYS = {"override_policy", "prepend_rule", "append_rule"}
|
||||||
|
|
||||||
|
|
||||||
|
class ConfConfigError(ValueError):
|
||||||
|
def __init__(self, path: Path, line_no: int, message: str) -> None:
|
||||||
|
self.path = path
|
||||||
|
self.line_no = line_no
|
||||||
|
self.message = message
|
||||||
|
super().__init__(f"{path}:{line_no}: {message}")
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_env(value: str) -> str:
|
||||||
|
return _ENV_PATTERN.sub(lambda match: os.getenv(match.group(1), ""), value)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_bool(value: str, *, path: Path, line_no: int, key: str) -> bool:
|
||||||
|
normalized = value.strip().lower()
|
||||||
|
mapping = {
|
||||||
|
"true": True,
|
||||||
|
"yes": True,
|
||||||
|
"1": True,
|
||||||
|
"false": False,
|
||||||
|
"no": False,
|
||||||
|
"0": False,
|
||||||
|
}
|
||||||
|
if normalized not in mapping:
|
||||||
|
raise ConfConfigError(path, line_no, f"invalid boolean for {key}: {value}")
|
||||||
|
return mapping[normalized]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_csv_fields(value: str, *, path: Path, line_no: int) -> list[str]:
|
||||||
|
try:
|
||||||
|
row = next(csv.reader([value], skipinitialspace=True))
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
raise ConfConfigError(path, line_no, f"invalid csv payload: {exc}") from exc
|
||||||
|
return [field.strip() for field in row]
|
||||||
|
|
||||||
|
|
||||||
|
def _split_key_value(raw_line: str, *, path: Path, line_no: int) -> tuple[str, str]:
|
||||||
|
if "=" not in raw_line:
|
||||||
|
raise ConfConfigError(path, line_no, "expected key = value")
|
||||||
|
key, value = [part.strip() for part in raw_line.split("=", 1)]
|
||||||
|
if not key:
|
||||||
|
raise ConfConfigError(path, line_no, "key cannot be empty")
|
||||||
|
return key, value
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_conf_lines(path: Path) -> list[tuple[int, str]]:
|
||||||
|
records: list[tuple[int, str]] = []
|
||||||
|
for line_no, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or line.startswith("#") or line.startswith(";"):
|
||||||
|
continue
|
||||||
|
records.append((line_no, raw_line))
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_named_records(records: list[Any], *, key: str, path: Path) -> None:
|
||||||
|
seen: dict[str, int] = {}
|
||||||
|
for record in records:
|
||||||
|
name = getattr(record, key)
|
||||||
|
line_no = getattr(record, "line_no")
|
||||||
|
previous_line = seen.get(name)
|
||||||
|
if previous_line is not None:
|
||||||
|
raise ConfConfigError(path, line_no, f"duplicate {record.__class__.__name__} key '{name}', first defined at line {previous_line}")
|
||||||
|
seen[name] = line_no
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_source(value: str, *, path: Path, line_no: int) -> ConfSource:
|
||||||
|
fields = _parse_csv_fields(_expand_env(value), path=path, line_no=line_no)
|
||||||
|
if len(fields) < 3:
|
||||||
|
raise ConfConfigError(path, line_no, "source requires at least 3 fields: key,type,value")
|
||||||
|
key, source_type, source_value, *extras = fields
|
||||||
|
if not key:
|
||||||
|
raise ConfConfigError(path, line_no, "source key cannot be empty")
|
||||||
|
if source_type not in _SOURCE_TYPES:
|
||||||
|
raise ConfConfigError(path, line_no, f"unsupported source type: {source_type}")
|
||||||
|
options: dict[str, str] = {}
|
||||||
|
enabled = True
|
||||||
|
cache_ttl: int | None = None
|
||||||
|
for item in extras:
|
||||||
|
if "=" not in item:
|
||||||
|
raise ConfConfigError(path, line_no, f"source option must be k=v: {item}")
|
||||||
|
option_key, option_value = [part.strip() for part in item.split("=", 1)]
|
||||||
|
if not option_key:
|
||||||
|
raise ConfConfigError(path, line_no, "source option key cannot be empty")
|
||||||
|
option_value = _expand_env(option_value)
|
||||||
|
if option_key == "enabled":
|
||||||
|
enabled = _parse_bool(option_value, path=path, line_no=line_no, key="source.enabled")
|
||||||
|
elif option_key == "cache_ttl":
|
||||||
|
try:
|
||||||
|
cache_ttl = int(option_value)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ConfConfigError(path, line_no, f"invalid integer for source.cache_ttl: {option_value}") from exc
|
||||||
|
else:
|
||||||
|
options[option_key] = option_value
|
||||||
|
return ConfSource(
|
||||||
|
key=key,
|
||||||
|
source_type=source_type,
|
||||||
|
value=source_value,
|
||||||
|
enabled=enabled,
|
||||||
|
cache_ttl=cache_ttl,
|
||||||
|
options=options,
|
||||||
|
line_no=line_no,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_selector(value: str, *, path: Path, line_no: int) -> ConfSelector:
|
||||||
|
fields = _parse_csv_fields(value, path=path, line_no=line_no)
|
||||||
|
if len(fields) != 2:
|
||||||
|
raise ConfConfigError(path, line_no, "selector requires exactly 2 fields: key,regex")
|
||||||
|
key, regex = fields
|
||||||
|
if not key or not regex:
|
||||||
|
raise ConfConfigError(path, line_no, "selector key and regex cannot be empty")
|
||||||
|
return ConfSelector(key=key, regex=regex, line_no=line_no)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_group(value: str, *, path: Path, line_no: int) -> ConfGroup:
|
||||||
|
segments = [segment.strip() for segment in value.split("`")]
|
||||||
|
if len(segments) < 2:
|
||||||
|
raise ConfConfigError(path, line_no, "group requires at least name`type")
|
||||||
|
name, group_type, *tokens = segments
|
||||||
|
if not name or not group_type:
|
||||||
|
raise ConfConfigError(path, line_no, "group name and type cannot be empty")
|
||||||
|
return ConfGroup(name=name, group_type=group_type, tokens=tokens, raw=value.strip(), line_no=line_no)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_module(value: str, *, path: Path, line_no: int, config_dir: Path) -> ConfModule:
|
||||||
|
fields = _parse_csv_fields(value, path=path, line_no=line_no)
|
||||||
|
if len(fields) != 5:
|
||||||
|
raise ConfConfigError(path, line_no, "module requires exactly 5 fields: key,path,policy,order,enabled")
|
||||||
|
key, module_path, policy, order_text, enabled_text = fields
|
||||||
|
if not key or not module_path or not policy:
|
||||||
|
raise ConfConfigError(path, line_no, "module key, path, and policy cannot be empty")
|
||||||
|
try:
|
||||||
|
order = int(order_text)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ConfConfigError(path, line_no, f"invalid integer for module.order: {order_text}") from exc
|
||||||
|
enabled = _parse_bool(enabled_text, path=path, line_no=line_no, key="module.enabled")
|
||||||
|
resolved_path = (config_dir / module_path).resolve()
|
||||||
|
if not resolved_path.is_file():
|
||||||
|
raise ConfConfigError(path, line_no, f"module path does not exist: {module_path}")
|
||||||
|
return ConfModule(key=key, path=module_path, policy=policy, order=order, enabled=enabled, line_no=line_no)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_builtin(value: str, *, path: Path, line_no: int) -> ConfBuiltin:
|
||||||
|
fields = _parse_csv_fields(value, path=path, line_no=line_no)
|
||||||
|
if len(fields) != 6:
|
||||||
|
raise ConfConfigError(path, line_no, "builtin requires exactly 6 fields: key,type,value,policy,order,enabled")
|
||||||
|
key, builtin_type, builtin_value, policy, order_text, enabled_text = fields
|
||||||
|
if builtin_type not in {"GEOIP", "FINAL"}:
|
||||||
|
raise ConfConfigError(path, line_no, f"unsupported builtin type: {builtin_type}")
|
||||||
|
if not key or not policy:
|
||||||
|
raise ConfConfigError(path, line_no, "builtin key and policy cannot be empty")
|
||||||
|
if builtin_type == "GEOIP" and not builtin_value:
|
||||||
|
raise ConfConfigError(path, line_no, "builtin GEOIP requires a value")
|
||||||
|
try:
|
||||||
|
order = int(order_text)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ConfConfigError(path, line_no, f"invalid integer for builtin.order: {order_text}") from exc
|
||||||
|
enabled = _parse_bool(enabled_text, path=path, line_no=line_no, key="builtin.enabled")
|
||||||
|
return ConfBuiltin(
|
||||||
|
key=key,
|
||||||
|
builtin_type=builtin_type,
|
||||||
|
value=builtin_value,
|
||||||
|
policy=policy,
|
||||||
|
order=order,
|
||||||
|
enabled=enabled,
|
||||||
|
line_no=line_no,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_string_list(value: str, *, path: Path, line_no: int) -> list[str]:
|
||||||
|
fields = _parse_csv_fields(_expand_env(value), path=path, line_no=line_no)
|
||||||
|
return [field for field in fields if field]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_override_policy(value: str, *, path: Path, line_no: int) -> ConfPolicyOverride:
|
||||||
|
fields = _parse_csv_fields(value, path=path, line_no=line_no)
|
||||||
|
if len(fields) != 2:
|
||||||
|
raise ConfConfigError(path, line_no, "override_policy requires exactly 2 fields: module_key,new_policy")
|
||||||
|
module_key, policy = fields
|
||||||
|
if not module_key or not policy:
|
||||||
|
raise ConfConfigError(path, line_no, "override_policy module key and policy cannot be empty")
|
||||||
|
return ConfPolicyOverride(module_key=module_key, policy=policy, line_no=line_no)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_inline_rule(value: str, *, path: Path, line_no: int, key: str) -> ConfInlineRule:
|
||||||
|
expanded = _expand_env(value).strip()
|
||||||
|
if not expanded:
|
||||||
|
raise ConfConfigError(path, line_no, f"{key} cannot be empty")
|
||||||
|
return ConfInlineRule(value=expanded, line_no=line_no)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_profile_references(
|
||||||
|
profile: ConfProfileConfig,
|
||||||
|
base: ConfBaseConfig,
|
||||||
|
*,
|
||||||
|
path: Path,
|
||||||
|
field_lines: dict[str, int],
|
||||||
|
) -> None:
|
||||||
|
source_keys = {item.key for item in base.sources}
|
||||||
|
module_keys = {item.key for item in base.modules}
|
||||||
|
builtin_keys = {item.key for item in base.builtins}
|
||||||
|
|
||||||
|
for source in profile.sources:
|
||||||
|
if source not in source_keys:
|
||||||
|
raise ConfConfigError(path, field_lines.get("sources", 1), f"profile references unknown source: {source}")
|
||||||
|
for module in profile.include_modules:
|
||||||
|
if module not in module_keys:
|
||||||
|
raise ConfConfigError(
|
||||||
|
path,
|
||||||
|
field_lines.get("include_modules", 1),
|
||||||
|
f"profile references unknown module in include_modules: {module}",
|
||||||
|
)
|
||||||
|
for module in profile.exclude_modules:
|
||||||
|
if module not in module_keys:
|
||||||
|
raise ConfConfigError(
|
||||||
|
path,
|
||||||
|
field_lines.get("exclude_modules", 1),
|
||||||
|
f"profile references unknown module in exclude_modules: {module}",
|
||||||
|
)
|
||||||
|
for builtin in profile.include_builtins:
|
||||||
|
if builtin not in builtin_keys:
|
||||||
|
raise ConfConfigError(
|
||||||
|
path,
|
||||||
|
field_lines.get("include_builtins", 1),
|
||||||
|
f"profile references unknown builtin in include_builtins: {builtin}",
|
||||||
|
)
|
||||||
|
for override in profile.override_policies:
|
||||||
|
if override.module_key not in module_keys:
|
||||||
|
raise ConfConfigError(path, override.line_no, f"override_policy references unknown module: {override.module_key}")
|
||||||
|
|
||||||
|
|
||||||
|
def load_conf_base(path: Path) -> ConfBaseConfig:
|
||||||
|
config = ConfBaseConfig()
|
||||||
|
scalar_values: dict[str, Any] = {}
|
||||||
|
config_dir = path.resolve().parent
|
||||||
|
|
||||||
|
for line_no, raw_line in _iter_conf_lines(path):
|
||||||
|
key, value = _split_key_value(raw_line, path=path, line_no=line_no)
|
||||||
|
if key in _SCALAR_KEYS:
|
||||||
|
if key in scalar_values:
|
||||||
|
raise ConfConfigError(path, line_no, f"duplicate scalar key: {key}")
|
||||||
|
expanded_value = _expand_env(value)
|
||||||
|
if key in _BOOL_KEYS:
|
||||||
|
scalar_values[key] = _parse_bool(expanded_value, path=path, line_no=line_no, key=key)
|
||||||
|
else:
|
||||||
|
scalar_values[key] = expanded_value
|
||||||
|
continue
|
||||||
|
if key not in _MULTI_KEYS:
|
||||||
|
raise ConfConfigError(path, line_no, f"unknown directive: {key}")
|
||||||
|
if key == "source":
|
||||||
|
config.sources.append(_parse_source(value, path=path, line_no=line_no))
|
||||||
|
elif key == "selector":
|
||||||
|
config.selectors.append(_parse_selector(value, path=path, line_no=line_no))
|
||||||
|
elif key == "group":
|
||||||
|
config.groups.append(_parse_group(value, path=path, line_no=line_no))
|
||||||
|
elif key == "module":
|
||||||
|
config.modules.append(_parse_module(value, path=path, line_no=line_no, config_dir=config_dir))
|
||||||
|
elif key == "builtin":
|
||||||
|
config.builtins.append(_parse_builtin(value, path=path, line_no=line_no))
|
||||||
|
|
||||||
|
for key, parsed_value in scalar_values.items():
|
||||||
|
setattr(config, key, parsed_value)
|
||||||
|
|
||||||
|
_ensure_named_records(config.sources, key="key", path=path)
|
||||||
|
_ensure_named_records(config.selectors, key="key", path=path)
|
||||||
|
_ensure_named_records(config.groups, key="name", path=path)
|
||||||
|
_ensure_named_records(config.modules, key="key", path=path)
|
||||||
|
_ensure_named_records(config.builtins, key="key", path=path)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def load_conf_profile(path: Path, *, base_config: ConfBaseConfig) -> ConfProfileConfig:
|
||||||
|
profile = ConfProfileConfig()
|
||||||
|
scalar_values: dict[str, Any] = {}
|
||||||
|
field_lines: dict[str, int] = {}
|
||||||
|
|
||||||
|
for line_no, raw_line in _iter_conf_lines(path):
|
||||||
|
key, value = _split_key_value(raw_line, path=path, line_no=line_no)
|
||||||
|
if key in _PROFILE_SCALAR_KEYS:
|
||||||
|
if key in scalar_values:
|
||||||
|
raise ConfConfigError(path, line_no, f"duplicate scalar key: {key}")
|
||||||
|
field_lines[key] = line_no
|
||||||
|
if key == "enabled":
|
||||||
|
scalar_values[key] = _parse_bool(_expand_env(value), path=path, line_no=line_no, key=key)
|
||||||
|
elif key in {"sources", "include_modules", "exclude_modules", "include_builtins"}:
|
||||||
|
scalar_values[key] = _parse_string_list(value, path=path, line_no=line_no)
|
||||||
|
else:
|
||||||
|
scalar_values[key] = _expand_env(value)
|
||||||
|
continue
|
||||||
|
if key not in _PROFILE_MULTI_KEYS:
|
||||||
|
raise ConfConfigError(path, line_no, f"unknown directive: {key}")
|
||||||
|
if key == "override_policy":
|
||||||
|
profile.override_policies.append(_parse_override_policy(value, path=path, line_no=line_no))
|
||||||
|
elif key == "prepend_rule":
|
||||||
|
profile.prepend_rules.append(_parse_inline_rule(value, path=path, line_no=line_no, key=key))
|
||||||
|
elif key == "append_rule":
|
||||||
|
profile.append_rules.append(_parse_inline_rule(value, path=path, line_no=line_no, key=key))
|
||||||
|
|
||||||
|
for key, parsed_value in scalar_values.items():
|
||||||
|
setattr(profile, key, parsed_value)
|
||||||
|
|
||||||
|
_validate_profile_references(profile, base_config, path=path, field_lines=field_lines)
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
def load_conf_profiles(directory: Path, *, base_config: ConfBaseConfig) -> dict[str, ConfProfileConfig]:
|
||||||
|
profiles: dict[str, ConfProfileConfig] = {}
|
||||||
|
for path in sorted(directory.glob("*.conf")):
|
||||||
|
profile = load_conf_profile(path, base_config=base_config)
|
||||||
|
profile_key = path.stem
|
||||||
|
if profile_key in profiles:
|
||||||
|
raise ConfConfigError(path, 1, f"duplicate profile file key: {profile_key}")
|
||||||
|
profiles[profile_key] = profile
|
||||||
|
return profiles
|
||||||
353
app/services/conf_profiles.py
Normal file
353
app/services/conf_profiles.py
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.conf_models import ConfResolvedModule, ConfRuntimePlan
|
||||||
|
from app.models import ClientConfig, SourceConfig, SourceSnapshot
|
||||||
|
from app.services.rules import load_rule_payload
|
||||||
|
|
||||||
|
|
||||||
|
def _source_auto_group_name(source: Any) -> str:
|
||||||
|
display_name = source["display_name"] if isinstance(source, dict) else source.display_name
|
||||||
|
return f"{display_name} 自动"
|
||||||
|
|
||||||
|
|
||||||
|
def conf_source_to_source_config(source: dict[str, Any] | Any, *, base_path: Path) -> SourceConfig:
|
||||||
|
source_type = source["source_type"] if isinstance(source, dict) else source.source_type
|
||||||
|
value = source["value"] if isinstance(source, dict) else source.value
|
||||||
|
options = source["options"] if isinstance(source, dict) else source.options
|
||||||
|
cache_ttl = source["cache_ttl"] if isinstance(source, dict) else source.cache_ttl
|
||||||
|
key = source["key"] if isinstance(source, dict) else source.key
|
||||||
|
|
||||||
|
if source_type not in {"url", "file"}:
|
||||||
|
raise ValueError(f"Unsupported conf source type for runtime route: {source_type}")
|
||||||
|
|
||||||
|
resolved_value = value
|
||||||
|
if source_type == "file":
|
||||||
|
candidate = Path(value)
|
||||||
|
if not candidate.is_absolute():
|
||||||
|
candidate = (base_path.resolve().parent / candidate).resolve()
|
||||||
|
resolved_value = str(candidate)
|
||||||
|
|
||||||
|
kind = str(options.get("kind", "auto")).strip() or "auto"
|
||||||
|
display_name = str(options.get("display_name", key)).strip() or key
|
||||||
|
prefix = str(options.get("prefix", "")).strip()
|
||||||
|
suffix = str(options.get("suffix", "")).strip()
|
||||||
|
include_regex = str(options.get("include_regex", "")).strip() or None
|
||||||
|
exclude_regex = str(options.get("exclude_regex", "")).strip() or None
|
||||||
|
headers: dict[str, str] = {}
|
||||||
|
for option_key, option_value in options.items():
|
||||||
|
if option_key.startswith("header."):
|
||||||
|
header_name = option_key.split(".", 1)[1].strip()
|
||||||
|
if header_name:
|
||||||
|
headers[header_name] = option_value
|
||||||
|
|
||||||
|
return SourceConfig(
|
||||||
|
enabled=True,
|
||||||
|
kind=kind,
|
||||||
|
url=resolved_value,
|
||||||
|
display_name=display_name,
|
||||||
|
headers=headers,
|
||||||
|
include_regex=include_regex,
|
||||||
|
exclude_regex=exclude_regex,
|
||||||
|
prefix=prefix,
|
||||||
|
suffix=suffix,
|
||||||
|
cache_ttl_seconds=cache_ttl,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _module_rule_provider_entry(
|
||||||
|
module: ConfResolvedModule,
|
||||||
|
*,
|
||||||
|
client: ClientConfig,
|
||||||
|
base_url: str,
|
||||||
|
public_path: str,
|
||||||
|
profile_key: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
suffix = Path(module.path).suffix.lower()
|
||||||
|
return {
|
||||||
|
"behavior": "classical",
|
||||||
|
"format": "yaml" if suffix in {".yaml", ".yml"} else "text",
|
||||||
|
"url": f"{base_url}/{public_path}/conf/rules/{profile_key}/{module.key}.yaml",
|
||||||
|
"interval": client.rule_interval,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_inline_module(module: ConfResolvedModule) -> bool:
|
||||||
|
return Path(module.path).suffix.lower() not in {".yaml", ".yml"}
|
||||||
|
|
||||||
|
|
||||||
|
def build_conf_rule_provider_entries(
|
||||||
|
plan: ConfRuntimePlan,
|
||||||
|
*,
|
||||||
|
client: ClientConfig,
|
||||||
|
base_url: str,
|
||||||
|
public_path: str,
|
||||||
|
) -> dict[str, dict[str, Any]]:
|
||||||
|
return {
|
||||||
|
module.key: _module_rule_provider_entry(module, client=client, base_url=base_url, public_path=public_path, profile_key=plan.profile_key)
|
||||||
|
for module in plan.selected_modules
|
||||||
|
if not _is_inline_module(module)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _render_inline_rule(payload_line: str, policy: str) -> str:
|
||||||
|
rendered = payload_line.strip()
|
||||||
|
if not rendered:
|
||||||
|
return ""
|
||||||
|
if rendered.endswith(",no-resolve"):
|
||||||
|
parts = [part.strip() for part in rendered.split(",")]
|
||||||
|
parts.insert(len(parts) - 1, policy)
|
||||||
|
return ",".join(parts)
|
||||||
|
return f"{rendered},{policy}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_conf_rule_set_references(plan: ConfRuntimePlan, *, base_path: Path) -> list[str]:
|
||||||
|
config_dir = base_path.resolve().parent
|
||||||
|
lines: list[str] = []
|
||||||
|
final_lines: list[str] = []
|
||||||
|
for rule in plan.prepend_rules:
|
||||||
|
if rule.startswith("MATCH,"):
|
||||||
|
final_lines.append(rule)
|
||||||
|
else:
|
||||||
|
lines.append(rule)
|
||||||
|
for module in plan.selected_modules:
|
||||||
|
if _is_inline_module(module):
|
||||||
|
module_path = (config_dir / module.path).resolve()
|
||||||
|
for payload_line in load_rule_payload(module_path):
|
||||||
|
rendered = _render_inline_rule(payload_line, module.policy)
|
||||||
|
if rendered:
|
||||||
|
lines.append(rendered)
|
||||||
|
else:
|
||||||
|
lines.append(f"RULE-SET,{module.key},{module.policy}")
|
||||||
|
for builtin in plan.selected_builtins:
|
||||||
|
if builtin.rule_line.startswith("MATCH,"):
|
||||||
|
final_lines.append(builtin.rule_line)
|
||||||
|
else:
|
||||||
|
lines.append(builtin.rule_line)
|
||||||
|
for rule in plan.append_rules:
|
||||||
|
if rule.startswith("MATCH,"):
|
||||||
|
final_lines.append(rule)
|
||||||
|
else:
|
||||||
|
lines.append(rule)
|
||||||
|
return [*lines, *final_lines]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_group_for_thin(
|
||||||
|
group: Any,
|
||||||
|
*,
|
||||||
|
client_type: str,
|
||||||
|
client: ClientConfig,
|
||||||
|
selected_source_names: list[str],
|
||||||
|
source_auto_names: list[str],
|
||||||
|
selectors: dict[str, str],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
built: dict[str, Any] = {
|
||||||
|
"name": group.name,
|
||||||
|
"type": group.group_type,
|
||||||
|
}
|
||||||
|
if group.selector_key:
|
||||||
|
built["filter"] = selectors[group.selector_key]
|
||||||
|
if client_type == "mihomo":
|
||||||
|
built["use"] = selected_source_names
|
||||||
|
else:
|
||||||
|
built["include-all"] = True
|
||||||
|
else:
|
||||||
|
proxies: list[str] = []
|
||||||
|
include_all = False
|
||||||
|
for member in group.members:
|
||||||
|
if member == "@source_auto_groups":
|
||||||
|
proxies.extend(source_auto_names)
|
||||||
|
elif member == "@all_proxies":
|
||||||
|
include_all = True
|
||||||
|
else:
|
||||||
|
proxies.append(member)
|
||||||
|
built["proxies"] = proxies
|
||||||
|
if include_all:
|
||||||
|
if client_type == "mihomo":
|
||||||
|
built["include-all-providers"] = True
|
||||||
|
else:
|
||||||
|
built["include-all"] = True
|
||||||
|
if group.group_type != "select":
|
||||||
|
built["url"] = group.url or str(client.test_url)
|
||||||
|
built["interval"] = group.interval or client.test_interval
|
||||||
|
if group.tolerance is not None:
|
||||||
|
built["tolerance"] = group.tolerance
|
||||||
|
return built
|
||||||
|
|
||||||
|
|
||||||
|
def _build_group_for_bundle(
|
||||||
|
group: Any,
|
||||||
|
*,
|
||||||
|
client: ClientConfig,
|
||||||
|
selectors: dict[str, str],
|
||||||
|
all_proxy_names: list[str],
|
||||||
|
source_auto_names: list[str],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
built: dict[str, Any] = {
|
||||||
|
"name": group.name,
|
||||||
|
"type": group.group_type,
|
||||||
|
}
|
||||||
|
if group.selector_key:
|
||||||
|
pattern = selectors[group.selector_key]
|
||||||
|
built["proxies"] = [name for name in all_proxy_names if re.search(pattern, name)] or [client.direct_policy]
|
||||||
|
else:
|
||||||
|
proxies: list[str] = []
|
||||||
|
for member in group.members:
|
||||||
|
if member == "@source_auto_groups":
|
||||||
|
proxies.extend(source_auto_names)
|
||||||
|
continue
|
||||||
|
if member == "@all_proxies":
|
||||||
|
proxies.extend(all_proxy_names)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
proxies.append(member)
|
||||||
|
built["proxies"] = proxies or [client.direct_policy]
|
||||||
|
if group.group_type != "select":
|
||||||
|
built["url"] = group.url or str(client.test_url)
|
||||||
|
built["interval"] = group.interval or client.test_interval
|
||||||
|
if group.tolerance is not None:
|
||||||
|
built["tolerance"] = group.tolerance
|
||||||
|
return built
|
||||||
|
|
||||||
|
|
||||||
|
def build_conf_thin_profile(
|
||||||
|
*,
|
||||||
|
client_type: str,
|
||||||
|
client: ClientConfig,
|
||||||
|
plan: ConfRuntimePlan,
|
||||||
|
base_path: Path,
|
||||||
|
base_url: str,
|
||||||
|
public_path: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
profile: dict[str, Any] = {
|
||||||
|
"mode": plan.mode or client.mode,
|
||||||
|
"ipv6": client.ipv6 if plan.ipv6 is None else plan.ipv6,
|
||||||
|
"proxy-providers": {},
|
||||||
|
}
|
||||||
|
log_level = plan.log_level or client.log_level
|
||||||
|
if log_level:
|
||||||
|
profile["log-level"] = log_level
|
||||||
|
if client_type == "mihomo":
|
||||||
|
if client.mixed_port is not None:
|
||||||
|
profile["mixed-port"] = client.mixed_port
|
||||||
|
if client.socks_port is not None:
|
||||||
|
profile["socks-port"] = client.socks_port
|
||||||
|
profile["allow-lan"] = client.allow_lan if plan.allow_lan is None else plan.allow_lan
|
||||||
|
|
||||||
|
selected_source_names = [item.key for item in plan.selected_sources]
|
||||||
|
source_auto_names = [_source_auto_group_name(item) for item in plan.selected_sources]
|
||||||
|
for name in selected_source_names:
|
||||||
|
source = next(item for item in plan.selected_sources if item.key == name)
|
||||||
|
group_name = _source_auto_group_name(source)
|
||||||
|
if client_type == "mihomo":
|
||||||
|
profile["proxy-providers"][name] = {
|
||||||
|
"type": "http",
|
||||||
|
"url": f"{base_url}/{public_path}/conf/providers/{plan.profile_key}/{name}.yaml",
|
||||||
|
"path": f"./providers/{plan.profile_key}-{name}.yaml",
|
||||||
|
"interval": client.provider_interval,
|
||||||
|
"health-check": {
|
||||||
|
"enable": True,
|
||||||
|
"url": str(client.test_url),
|
||||||
|
"interval": client.test_interval,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
profile["proxy-providers"][name] = {
|
||||||
|
"url": f"{base_url}/{public_path}/conf/providers/{plan.profile_key}/{name}.yaml",
|
||||||
|
"interval": client.provider_interval,
|
||||||
|
}
|
||||||
|
profile.setdefault("proxy-groups", []).append(
|
||||||
|
{
|
||||||
|
"name": group_name,
|
||||||
|
"type": "url-test",
|
||||||
|
"url": str(client.test_url),
|
||||||
|
"interval": client.test_interval,
|
||||||
|
"use": [name],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
profile["proxy-groups"].extend(
|
||||||
|
[
|
||||||
|
_build_group_for_thin(
|
||||||
|
group,
|
||||||
|
client_type=client_type,
|
||||||
|
client=client,
|
||||||
|
selected_source_names=selected_source_names,
|
||||||
|
source_auto_names=source_auto_names,
|
||||||
|
selectors=plan.selectors,
|
||||||
|
)
|
||||||
|
for group in plan.groups
|
||||||
|
]
|
||||||
|
)
|
||||||
|
profile["rule-providers"] = build_conf_rule_provider_entries(plan, client=client, base_url=base_url, public_path=public_path)
|
||||||
|
profile["rules"] = build_conf_rule_set_references(plan, base_path=base_path)
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
def build_conf_bundle_profile(*, client_type: str, client: ClientConfig, plan: ConfRuntimePlan, snapshots: list[SourceSnapshot], rules: list[str]) -> dict[str, Any]:
|
||||||
|
profile: dict[str, Any] = {
|
||||||
|
"mode": plan.mode or client.mode,
|
||||||
|
"ipv6": client.ipv6 if plan.ipv6 is None else plan.ipv6,
|
||||||
|
}
|
||||||
|
log_level = plan.log_level or client.log_level
|
||||||
|
if log_level:
|
||||||
|
profile["log-level"] = log_level
|
||||||
|
if client_type == "mihomo":
|
||||||
|
if client.mixed_port is not None:
|
||||||
|
profile["mixed-port"] = client.mixed_port
|
||||||
|
if client.socks_port is not None:
|
||||||
|
profile["socks-port"] = client.socks_port
|
||||||
|
profile["allow-lan"] = client.allow_lan if plan.allow_lan is None else plan.allow_lan
|
||||||
|
|
||||||
|
proxies: list[dict[str, Any]] = []
|
||||||
|
all_proxy_names: list[str] = []
|
||||||
|
source_proxy_names: dict[str, list[str]] = {}
|
||||||
|
seen: set[str] = set()
|
||||||
|
for snapshot in snapshots:
|
||||||
|
current_source_names: list[str] = []
|
||||||
|
for proxy in snapshot.document.proxies:
|
||||||
|
candidate = dict(proxy)
|
||||||
|
name = str(candidate.get("name", "")).strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
original = name
|
||||||
|
index = 2
|
||||||
|
while name in seen:
|
||||||
|
name = f"{original} #{index}"
|
||||||
|
index += 1
|
||||||
|
seen.add(name)
|
||||||
|
candidate["name"] = name
|
||||||
|
proxies.append(candidate)
|
||||||
|
all_proxy_names.append(name)
|
||||||
|
current_source_names.append(name)
|
||||||
|
source_proxy_names[snapshot.name] = current_source_names
|
||||||
|
|
||||||
|
profile["proxies"] = proxies
|
||||||
|
profile["proxy-groups"] = []
|
||||||
|
for source in plan.selected_sources:
|
||||||
|
profile["proxy-groups"].append(
|
||||||
|
{
|
||||||
|
"name": _source_auto_group_name(source),
|
||||||
|
"type": "url-test",
|
||||||
|
"url": str(client.test_url),
|
||||||
|
"interval": client.test_interval,
|
||||||
|
"proxies": source_proxy_names.get(source.key) or [client.direct_policy],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
profile["proxy-groups"].extend(
|
||||||
|
[
|
||||||
|
_build_group_for_bundle(
|
||||||
|
group,
|
||||||
|
client=client,
|
||||||
|
selectors=plan.selectors,
|
||||||
|
all_proxy_names=all_proxy_names,
|
||||||
|
source_auto_names=[_source_auto_group_name(item) for item in plan.selected_sources],
|
||||||
|
)
|
||||||
|
for group in plan.groups
|
||||||
|
]
|
||||||
|
)
|
||||||
|
profile["rules"] = rules
|
||||||
|
return profile
|
||||||
252
app/services/conf_runtime.py
Normal file
252
app/services/conf_runtime.py
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.conf_models import (
|
||||||
|
ConfBaseConfig,
|
||||||
|
ConfGroup,
|
||||||
|
ConfProfileConfig,
|
||||||
|
ConfResolvedBuiltin,
|
||||||
|
ConfResolvedGroup,
|
||||||
|
ConfResolvedModule,
|
||||||
|
ConfResolvedSource,
|
||||||
|
ConfRuntimePlan,
|
||||||
|
)
|
||||||
|
from app.services.conf_loader import ConfConfigError
|
||||||
|
from app.services.rules import load_rule_payload
|
||||||
|
|
||||||
|
_GROUP_TYPES = {"select", "url-test", "fallback", "load-balance"}
|
||||||
|
_CIDR_CHARS = set("0123456789abcdefABCDEF:./")
|
||||||
|
_SELECT_SPECIAL_MEMBERS = {"@source_auto_groups", "@all_proxies"}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_group_member(token: str) -> str:
|
||||||
|
if token.startswith("[]"):
|
||||||
|
return token[2:]
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_group(group: ConfGroup, *, selectors: dict[str, str], known_group_names: set[str], path: Path) -> ConfResolvedGroup:
|
||||||
|
if group.group_type not in _GROUP_TYPES:
|
||||||
|
raise ConfConfigError(path, group.line_no, f"unsupported group type: {group.group_type}")
|
||||||
|
if group.group_type == "select":
|
||||||
|
selector_key: str | None = None
|
||||||
|
members = [_parse_group_member(token) for token in group.tokens if token.strip()]
|
||||||
|
if not members:
|
||||||
|
raise ConfConfigError(path, group.line_no, "select group must define at least one member")
|
||||||
|
for token in group.tokens:
|
||||||
|
stripped = token.strip()
|
||||||
|
if stripped.startswith("@"):
|
||||||
|
if stripped in _SELECT_SPECIAL_MEMBERS:
|
||||||
|
continue
|
||||||
|
selector_key = stripped[1:]
|
||||||
|
if selector_key not in selectors:
|
||||||
|
raise ConfConfigError(path, group.line_no, f"group references unknown selector: {selector_key}")
|
||||||
|
continue
|
||||||
|
if stripped.startswith("[]"):
|
||||||
|
target = stripped[2:]
|
||||||
|
if target not in {"DIRECT", "REJECT"} and target not in known_group_names:
|
||||||
|
raise ConfConfigError(path, group.line_no, f"group references unknown group: {target}")
|
||||||
|
return ConfResolvedGroup(
|
||||||
|
name=group.name,
|
||||||
|
group_type=group.group_type,
|
||||||
|
members=[] if selector_key else members,
|
||||||
|
selector_key=selector_key,
|
||||||
|
raw=group.raw,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not group.tokens:
|
||||||
|
raise ConfConfigError(path, group.line_no, f"{group.group_type} group requires selector/member arguments")
|
||||||
|
|
||||||
|
first = group.tokens[0].strip()
|
||||||
|
selector_key: str | None = None
|
||||||
|
members: list[str] = []
|
||||||
|
if first.startswith("@"):
|
||||||
|
selector_key = first[1:]
|
||||||
|
if selector_key not in selectors:
|
||||||
|
raise ConfConfigError(path, group.line_no, f"group references unknown selector: {selector_key}")
|
||||||
|
else:
|
||||||
|
members.append(_parse_group_member(first))
|
||||||
|
if first.startswith("[]"):
|
||||||
|
target = first[2:]
|
||||||
|
if target not in {"DIRECT", "REJECT"} and target not in known_group_names:
|
||||||
|
raise ConfConfigError(path, group.line_no, f"group references unknown group: {target}")
|
||||||
|
|
||||||
|
url = group.tokens[1].strip() if len(group.tokens) > 1 and group.tokens[1].strip() else None
|
||||||
|
interval: int | None = None
|
||||||
|
tolerance: int | None = None
|
||||||
|
if len(group.tokens) > 2 and group.tokens[2].strip():
|
||||||
|
try:
|
||||||
|
interval = int(group.tokens[2].split(",", 1)[0].strip())
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ConfConfigError(path, group.line_no, f"invalid group interval: {group.tokens[2]}") from exc
|
||||||
|
if len(group.tokens) > 3 and group.tokens[3].strip():
|
||||||
|
tolerance_token = group.tokens[3].split(",")[-1].strip()
|
||||||
|
if tolerance_token:
|
||||||
|
try:
|
||||||
|
tolerance = int(tolerance_token)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ConfConfigError(path, group.line_no, f"invalid group tolerance: {group.tokens[3]}") from exc
|
||||||
|
|
||||||
|
return ConfResolvedGroup(
|
||||||
|
name=group.name,
|
||||||
|
group_type=group.group_type,
|
||||||
|
members=members,
|
||||||
|
selector_key=selector_key,
|
||||||
|
url=url,
|
||||||
|
interval=interval,
|
||||||
|
tolerance=tolerance,
|
||||||
|
raw=group.raw,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_builtin_rule_line(builtin_type: str, value: str, policy: str) -> str:
|
||||||
|
if builtin_type == "GEOIP":
|
||||||
|
return f"GEOIP,{value},{policy}"
|
||||||
|
return f"MATCH,{policy}"
|
||||||
|
|
||||||
|
|
||||||
|
def _render_rule_line(payload_line: str, policy: str) -> str:
|
||||||
|
rendered = payload_line.strip()
|
||||||
|
if not rendered:
|
||||||
|
return ""
|
||||||
|
if "," not in rendered:
|
||||||
|
if "/" in rendered and set(rendered) <= _CIDR_CHARS:
|
||||||
|
prefix = "IP-CIDR6" if ":" in rendered else "IP-CIDR"
|
||||||
|
rendered = f"{prefix},{rendered}"
|
||||||
|
else:
|
||||||
|
rendered = f"DOMAIN-SUFFIX,{rendered}"
|
||||||
|
parts = [part.strip() for part in rendered.split(",")]
|
||||||
|
if parts and parts[-1] == "no-resolve":
|
||||||
|
parts.insert(len(parts) - 1, policy)
|
||||||
|
return ",".join(parts)
|
||||||
|
return f"{rendered},{policy}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_conf_rule_lines(plan: ConfRuntimePlan, *, base_path: Path) -> list[str]:
|
||||||
|
config_dir = base_path.resolve().parent
|
||||||
|
lines: list[str] = []
|
||||||
|
final_lines: list[str] = []
|
||||||
|
|
||||||
|
for rule in plan.prepend_rules:
|
||||||
|
if rule.startswith("MATCH,"):
|
||||||
|
final_lines.append(rule)
|
||||||
|
else:
|
||||||
|
lines.append(rule)
|
||||||
|
|
||||||
|
for module in plan.selected_modules:
|
||||||
|
module_path = (config_dir / module.path).resolve()
|
||||||
|
if not module_path.is_file():
|
||||||
|
raise FileNotFoundError(f"Rule file missing: {module.path}")
|
||||||
|
for payload_line in load_rule_payload(module_path):
|
||||||
|
rendered = _render_rule_line(payload_line, module.policy)
|
||||||
|
if rendered:
|
||||||
|
if rendered.startswith("MATCH,"):
|
||||||
|
final_lines.append(rendered)
|
||||||
|
else:
|
||||||
|
lines.append(rendered)
|
||||||
|
|
||||||
|
for builtin in plan.selected_builtins:
|
||||||
|
if builtin.rule_line.startswith("MATCH,"):
|
||||||
|
final_lines.append(builtin.rule_line)
|
||||||
|
else:
|
||||||
|
lines.append(builtin.rule_line)
|
||||||
|
|
||||||
|
for rule in plan.append_rules:
|
||||||
|
if rule.startswith("MATCH,"):
|
||||||
|
final_lines.append(rule)
|
||||||
|
else:
|
||||||
|
lines.append(rule)
|
||||||
|
|
||||||
|
return [*lines, *final_lines]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_conf_runtime_plan(
|
||||||
|
*,
|
||||||
|
profile_key: str,
|
||||||
|
base_config: ConfBaseConfig,
|
||||||
|
profile_config: ConfProfileConfig,
|
||||||
|
base_path: Path,
|
||||||
|
) -> ConfRuntimePlan:
|
||||||
|
if not profile_config.enabled:
|
||||||
|
raise ConfConfigError(base_path, 1, f"profile is disabled: {profile_key}")
|
||||||
|
|
||||||
|
selectors = {item.key: item.regex for item in base_config.selectors}
|
||||||
|
source_map = {item.key: item for item in base_config.sources}
|
||||||
|
module_map = {item.key: item for item in base_config.modules}
|
||||||
|
builtin_map = {item.key: item for item in base_config.builtins}
|
||||||
|
override_map = {item.module_key: item.policy for item in profile_config.override_policies}
|
||||||
|
|
||||||
|
selected_source_keys = profile_config.sources or [item.key for item in base_config.sources if item.enabled]
|
||||||
|
selected_sources = [
|
||||||
|
ConfResolvedSource(
|
||||||
|
key=source_map[key].key,
|
||||||
|
display_name=str(source_map[key].options.get("display_name", source_map[key].key)).strip() or source_map[key].key,
|
||||||
|
source_type=source_map[key].source_type,
|
||||||
|
value=source_map[key].value,
|
||||||
|
enabled=source_map[key].enabled,
|
||||||
|
cache_ttl=source_map[key].cache_ttl,
|
||||||
|
options=dict(source_map[key].options),
|
||||||
|
)
|
||||||
|
for key in selected_source_keys
|
||||||
|
if source_map[key].enabled
|
||||||
|
]
|
||||||
|
|
||||||
|
excluded_modules = set(profile_config.exclude_modules)
|
||||||
|
include_keys = profile_config.include_modules or [item.key for item in base_config.modules if item.enabled]
|
||||||
|
selected_modules: list[ConfResolvedModule] = []
|
||||||
|
for key in include_keys:
|
||||||
|
module = module_map[key]
|
||||||
|
if not module.enabled or key in excluded_modules:
|
||||||
|
continue
|
||||||
|
selected_modules.append(
|
||||||
|
ConfResolvedModule(
|
||||||
|
key=module.key,
|
||||||
|
path=module.path,
|
||||||
|
policy=override_map.get(module.key, module.policy),
|
||||||
|
order=module.order,
|
||||||
|
enabled=module.enabled,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
selected_modules.sort(key=lambda item: (item.order, item.key))
|
||||||
|
|
||||||
|
builtin_keys = profile_config.include_builtins or [item.key for item in base_config.builtins if item.enabled]
|
||||||
|
selected_builtins: list[ConfResolvedBuiltin] = []
|
||||||
|
for key in builtin_keys:
|
||||||
|
builtin = builtin_map[key]
|
||||||
|
if not builtin.enabled:
|
||||||
|
continue
|
||||||
|
selected_builtins.append(
|
||||||
|
ConfResolvedBuiltin(
|
||||||
|
key=builtin.key,
|
||||||
|
rule_line=_resolve_builtin_rule_line(builtin.builtin_type, builtin.value, builtin.policy),
|
||||||
|
policy=builtin.policy,
|
||||||
|
order=builtin.order,
|
||||||
|
enabled=builtin.enabled,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
selected_builtins.sort(key=lambda item: (item.order, item.key))
|
||||||
|
|
||||||
|
known_group_names = {group.name for group in base_config.groups}
|
||||||
|
resolved_groups = [
|
||||||
|
_parse_group(group, selectors=selectors, known_group_names=known_group_names, path=base_path)
|
||||||
|
for group in base_config.groups
|
||||||
|
]
|
||||||
|
|
||||||
|
return ConfRuntimePlan(
|
||||||
|
profile_key=profile_key,
|
||||||
|
profile_name=profile_config.name,
|
||||||
|
selected_sources=selected_sources,
|
||||||
|
selected_modules=selected_modules,
|
||||||
|
selected_builtins=selected_builtins,
|
||||||
|
selectors=selectors,
|
||||||
|
groups=resolved_groups,
|
||||||
|
prepend_rules=[item.value for item in profile_config.prepend_rules],
|
||||||
|
append_rules=[item.value for item in profile_config.append_rules],
|
||||||
|
mode=base_config.mode,
|
||||||
|
allow_lan=base_config.allow_lan,
|
||||||
|
log_level=base_config.log_level,
|
||||||
|
ipv6=base_config.ipv6,
|
||||||
|
append_userinfo_header=base_config.append_userinfo_header,
|
||||||
|
userinfo_source_policy=base_config.userinfo_source_policy,
|
||||||
|
)
|
||||||
@@ -1,640 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
from sqlalchemy import delete, select
|
|
||||||
from sqlalchemy.orm import Session, selectinload
|
|
||||||
|
|
||||||
from app.db import get_session, init_db
|
|
||||||
from app.db_models import (
|
|
||||||
AppSettingORM,
|
|
||||||
PolicyGroupORM,
|
|
||||||
ProfileORM,
|
|
||||||
ProfileRuleModuleORM,
|
|
||||||
ProfileSourceORM,
|
|
||||||
RuleModuleORM,
|
|
||||||
SourceORM,
|
|
||||||
)
|
|
||||||
from app.models import AppConfig, ClientConfig, ProxyGroupConfig, RegionConfig, RuleConfig, SourceConfig
|
|
||||||
from app.services.loader import load_yaml_app_config
|
|
||||||
|
|
||||||
|
|
||||||
def _json_dumps(value) -> str:
|
|
||||||
return json.dumps(value, ensure_ascii=False)
|
|
||||||
|
|
||||||
|
|
||||||
def _json_loads(value: str | None, default):
|
|
||||||
if not value:
|
|
||||||
return default
|
|
||||||
return json.loads(value)
|
|
||||||
|
|
||||||
|
|
||||||
def import_yaml_config_to_db(config_path, *, replace_existing: bool = False) -> None:
|
|
||||||
app_config = load_yaml_app_config(config_path)
|
|
||||||
init_db()
|
|
||||||
with get_session() as session:
|
|
||||||
if replace_existing:
|
|
||||||
_clear_config_tables(session)
|
|
||||||
|
|
||||||
_upsert_setting(session, "public_path", app_config.public_path or "")
|
|
||||||
|
|
||||||
source_rows: list[SourceORM] = []
|
|
||||||
for key, source in app_config.sources.items():
|
|
||||||
row = _get_or_create_source(session, key)
|
|
||||||
row.enabled = source.enabled
|
|
||||||
row.kind = source.kind
|
|
||||||
row.url = source.url
|
|
||||||
row.display_name = source.display_name
|
|
||||||
row.headers_json = _json_dumps(source.headers)
|
|
||||||
row.include_regex = source.include_regex
|
|
||||||
row.exclude_regex = source.exclude_regex
|
|
||||||
row.prefix = source.prefix
|
|
||||||
row.suffix = source.suffix
|
|
||||||
row.cache_ttl_seconds = source.cache_ttl_seconds
|
|
||||||
source_rows.append(row)
|
|
||||||
|
|
||||||
module_rows: list[RuleModuleORM] = []
|
|
||||||
ordered_rule_keys = list(app_config.rules.keys())
|
|
||||||
for key, rule in app_config.rules.items():
|
|
||||||
row = _get_or_create_rule_module(session, key)
|
|
||||||
row.file_path = rule.file
|
|
||||||
row.behavior = rule.behavior
|
|
||||||
row.format = rule.format
|
|
||||||
row.policy = rule.policy
|
|
||||||
row.no_resolve = rule.no_resolve
|
|
||||||
row.payload_json = _json_dumps(rule.payload)
|
|
||||||
module_rows.append(row)
|
|
||||||
|
|
||||||
session.flush()
|
|
||||||
|
|
||||||
for client_key, client in app_config.clients.items():
|
|
||||||
profile = _get_or_create_profile(session, client_key)
|
|
||||||
_populate_profile(profile, client)
|
|
||||||
session.flush()
|
|
||||||
|
|
||||||
_replace_profile_sources(session, profile.id, source_rows)
|
|
||||||
_replace_profile_groups(session, profile.id, app_config)
|
|
||||||
_replace_profile_rule_links(session, profile.id, module_rows, ordered_rule_keys)
|
|
||||||
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def load_app_config_from_db() -> AppConfig | None:
|
|
||||||
init_db()
|
|
||||||
with get_session() as session:
|
|
||||||
return _load_app_config_from_session(session, profile_key=None)
|
|
||||||
|
|
||||||
|
|
||||||
def load_profile_app_config_from_db(profile_key: str) -> AppConfig | None:
|
|
||||||
init_db()
|
|
||||||
with get_session() as session:
|
|
||||||
return _load_app_config_from_session(session, profile_key=profile_key)
|
|
||||||
|
|
||||||
|
|
||||||
def list_sources() -> list[SourceORM]:
|
|
||||||
init_db()
|
|
||||||
with get_session() as session:
|
|
||||||
return list(session.scalars(select(SourceORM).order_by(SourceORM.key)))
|
|
||||||
|
|
||||||
|
|
||||||
def save_source(
|
|
||||||
*,
|
|
||||||
key: str,
|
|
||||||
enabled: bool,
|
|
||||||
kind: str,
|
|
||||||
url: str,
|
|
||||||
display_name: str | None,
|
|
||||||
headers: dict[str, str],
|
|
||||||
include_regex: str | None,
|
|
||||||
exclude_regex: str | None,
|
|
||||||
prefix: str,
|
|
||||||
suffix: str,
|
|
||||||
cache_ttl_seconds: int | None,
|
|
||||||
) -> None:
|
|
||||||
init_db()
|
|
||||||
with get_session() as session:
|
|
||||||
source = _get_or_create_source(session, key)
|
|
||||||
source.enabled = enabled
|
|
||||||
source.kind = kind
|
|
||||||
source.url = url
|
|
||||||
source.display_name = display_name
|
|
||||||
source.headers_json = _json_dumps(headers)
|
|
||||||
source.include_regex = include_regex
|
|
||||||
source.exclude_regex = exclude_regex
|
|
||||||
source.prefix = prefix
|
|
||||||
source.suffix = suffix
|
|
||||||
source.cache_ttl_seconds = cache_ttl_seconds
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def delete_source(key: str) -> None:
|
|
||||||
init_db()
|
|
||||||
with get_session() as session:
|
|
||||||
source = session.scalar(select(SourceORM).where(SourceORM.key == key))
|
|
||||||
if source is None:
|
|
||||||
return
|
|
||||||
session.execute(delete(ProfileSourceORM).where(ProfileSourceORM.source_id == source.id))
|
|
||||||
session.delete(source)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def list_profiles() -> list[ProfileORM]:
|
|
||||||
init_db()
|
|
||||||
with get_session() as session:
|
|
||||||
return list(session.scalars(select(ProfileORM).order_by(ProfileORM.key)))
|
|
||||||
|
|
||||||
|
|
||||||
def save_profile(
|
|
||||||
*,
|
|
||||||
key: str,
|
|
||||||
title: str,
|
|
||||||
provider_interval: int,
|
|
||||||
rule_interval: int,
|
|
||||||
test_url: str,
|
|
||||||
test_interval: int,
|
|
||||||
main_policy: str,
|
|
||||||
source_policy: str,
|
|
||||||
mixed_auto_policy: str,
|
|
||||||
manual_policy: str,
|
|
||||||
direct_policy: str,
|
|
||||||
mode: str,
|
|
||||||
allow_lan: bool,
|
|
||||||
ipv6: bool,
|
|
||||||
mixed_port: int | None,
|
|
||||||
socks_port: int | None,
|
|
||||||
log_level: str | None,
|
|
||||||
) -> None:
|
|
||||||
init_db()
|
|
||||||
with get_session() as session:
|
|
||||||
profile = _get_or_create_profile(session, key)
|
|
||||||
profile.title = title
|
|
||||||
profile.provider_interval = provider_interval
|
|
||||||
profile.rule_interval = rule_interval
|
|
||||||
profile.test_url = test_url
|
|
||||||
profile.test_interval = test_interval
|
|
||||||
profile.main_policy = main_policy
|
|
||||||
profile.source_policy = source_policy
|
|
||||||
profile.mixed_auto_policy = mixed_auto_policy
|
|
||||||
profile.manual_policy = manual_policy
|
|
||||||
profile.direct_policy = direct_policy
|
|
||||||
profile.mode = mode
|
|
||||||
profile.allow_lan = allow_lan
|
|
||||||
profile.ipv6 = ipv6
|
|
||||||
profile.mixed_port = mixed_port
|
|
||||||
profile.socks_port = socks_port
|
|
||||||
profile.log_level = log_level
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def list_profile_rule_bindings(profile_key: str) -> list[dict]:
|
|
||||||
init_db()
|
|
||||||
with get_session() as session:
|
|
||||||
profile = _load_profile_with_relationships(session, profile_key)
|
|
||||||
if profile is None:
|
|
||||||
return []
|
|
||||||
bindings: list[dict] = []
|
|
||||||
for link in sorted(profile.rule_links, key=lambda item: (item.order_index, item.id)):
|
|
||||||
module = link.rule_module
|
|
||||||
bindings.append(
|
|
||||||
{
|
|
||||||
"key": module.key,
|
|
||||||
"enabled": link.enabled,
|
|
||||||
"order_index": link.order_index,
|
|
||||||
"policy": link.policy_override or module.policy,
|
|
||||||
"file_path": module.file_path,
|
|
||||||
"behavior": module.behavior,
|
|
||||||
"format": module.format,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return bindings
|
|
||||||
|
|
||||||
|
|
||||||
def list_profile_source_bindings(profile_key: str) -> list[dict]:
|
|
||||||
init_db()
|
|
||||||
with get_session() as session:
|
|
||||||
profile = _load_profile_with_relationships(session, profile_key)
|
|
||||||
if profile is None:
|
|
||||||
return []
|
|
||||||
bindings: list[dict] = []
|
|
||||||
for link in sorted(profile.source_links, key=lambda item: (item.order_index, item.id)):
|
|
||||||
bindings.append(
|
|
||||||
{
|
|
||||||
"key": link.source.key,
|
|
||||||
"enabled": link.enabled,
|
|
||||||
"order_index": link.order_index,
|
|
||||||
"display_name": link.source.display_name or link.source.key,
|
|
||||||
"kind": link.source.kind,
|
|
||||||
"url": link.source.url,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return bindings
|
|
||||||
|
|
||||||
|
|
||||||
def update_profile_source_bindings(profile_key: str, rows: list[dict]) -> None:
|
|
||||||
init_db()
|
|
||||||
with get_session() as session:
|
|
||||||
profile = _load_profile_with_relationships(session, profile_key)
|
|
||||||
if profile is None:
|
|
||||||
raise KeyError(f"profile not found: {profile_key}")
|
|
||||||
source_keys = [row["key"] for row in rows]
|
|
||||||
sources = list(session.scalars(select(SourceORM).where(SourceORM.key.in_(source_keys))))
|
|
||||||
source_by_key = {source.key: source for source in sources}
|
|
||||||
session.execute(delete(ProfileSourceORM).where(ProfileSourceORM.profile_id == profile.id))
|
|
||||||
for row in rows:
|
|
||||||
source = source_by_key.get(row["key"])
|
|
||||||
if source is None:
|
|
||||||
continue
|
|
||||||
session.add(
|
|
||||||
ProfileSourceORM(
|
|
||||||
profile_id=profile.id,
|
|
||||||
source_id=source.id,
|
|
||||||
order_index=int(row["order_index"]),
|
|
||||||
enabled=bool(row["enabled"]),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def get_profile_default_source_keys(profile_key: str) -> list[str]:
|
|
||||||
init_db()
|
|
||||||
with get_session() as session:
|
|
||||||
profile = _load_profile_with_relationships(session, profile_key)
|
|
||||||
if profile is None:
|
|
||||||
return []
|
|
||||||
keys = [
|
|
||||||
link.source.key
|
|
||||||
for link in sorted(profile.source_links, key=lambda item: (item.order_index, item.id))
|
|
||||||
if link.enabled and link.source.enabled and str(link.source.url).strip()
|
|
||||||
]
|
|
||||||
return keys
|
|
||||||
|
|
||||||
|
|
||||||
def list_profile_groups(profile_key: str) -> list[dict]:
|
|
||||||
init_db()
|
|
||||||
with get_session() as session:
|
|
||||||
profile = _load_profile_with_relationships(session, profile_key)
|
|
||||||
if profile is None:
|
|
||||||
return []
|
|
||||||
rows: list[dict] = []
|
|
||||||
for group in sorted(profile.groups, key=lambda item: (item.order_index, item.id)):
|
|
||||||
rows.append(
|
|
||||||
{
|
|
||||||
"id": group.id,
|
|
||||||
"group_kind": group.group_kind,
|
|
||||||
"name": group.name,
|
|
||||||
"type": group.type,
|
|
||||||
"order_index": group.order_index,
|
|
||||||
"proxies": _json_loads(group.proxies_json, []),
|
|
||||||
"filter_regex": group.filter_regex or "",
|
|
||||||
"tolerance": group.tolerance,
|
|
||||||
"url": group.url or "",
|
|
||||||
"interval": group.interval,
|
|
||||||
"enabled": group.enabled,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def replace_profile_groups(profile_key: str, rows: list[dict]) -> None:
|
|
||||||
init_db()
|
|
||||||
with get_session() as session:
|
|
||||||
profile = _load_profile_with_relationships(session, profile_key)
|
|
||||||
if profile is None:
|
|
||||||
raise KeyError(f"profile not found: {profile_key}")
|
|
||||||
session.execute(delete(PolicyGroupORM).where(PolicyGroupORM.profile_id == profile.id))
|
|
||||||
for row in rows:
|
|
||||||
session.add(
|
|
||||||
PolicyGroupORM(
|
|
||||||
profile_id=profile.id,
|
|
||||||
group_kind=row["group_kind"],
|
|
||||||
name=row["name"],
|
|
||||||
type=row["type"],
|
|
||||||
order_index=int(row["order_index"]),
|
|
||||||
proxies_json=_json_dumps(row["proxies"]) if row["proxies"] else None,
|
|
||||||
filter_regex=row["filter_regex"] or None,
|
|
||||||
tolerance=row["tolerance"],
|
|
||||||
url=row["url"] or None,
|
|
||||||
interval=row["interval"],
|
|
||||||
enabled=bool(row["enabled"]),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def update_profile_rule_bindings(profile_key: str, rows: list[dict]) -> None:
|
|
||||||
init_db()
|
|
||||||
with get_session() as session:
|
|
||||||
profile = _load_profile_with_relationships(session, profile_key)
|
|
||||||
if profile is None:
|
|
||||||
raise KeyError(f"profile not found: {profile_key}")
|
|
||||||
module_by_key = {link.rule_module.key: link for link in profile.rule_links}
|
|
||||||
for row in rows:
|
|
||||||
link = module_by_key.get(row["key"])
|
|
||||||
if link is None:
|
|
||||||
continue
|
|
||||||
link.enabled = bool(row["enabled"])
|
|
||||||
link.order_index = int(row["order_index"])
|
|
||||||
policy = str(row["policy"]).strip()
|
|
||||||
link.policy_override = policy or None
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def _load_app_config_from_session(session: Session, profile_key: str | None) -> AppConfig | None:
|
|
||||||
profiles = list(
|
|
||||||
session.scalars(
|
|
||||||
select(ProfileORM)
|
|
||||||
.options(
|
|
||||||
selectinload(ProfileORM.groups),
|
|
||||||
selectinload(ProfileORM.rule_links).selectinload(ProfileRuleModuleORM.rule_module),
|
|
||||||
)
|
|
||||||
.order_by(ProfileORM.key)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
sources = list(session.scalars(select(SourceORM).order_by(SourceORM.key)))
|
|
||||||
if not profiles or not sources:
|
|
||||||
return None
|
|
||||||
|
|
||||||
target_profile = next((profile for profile in profiles if profile.key == profile_key), profiles[0])
|
|
||||||
public_path = _setting_value(session, "public_path")
|
|
||||||
regions, selector_groups, policy_groups = _build_group_configs(target_profile)
|
|
||||||
rules = _build_rule_configs(target_profile)
|
|
||||||
|
|
||||||
return AppConfig(
|
|
||||||
public_path=public_path or None,
|
|
||||||
sources={source.key: _source_to_config(source) for source in sources},
|
|
||||||
rules=rules,
|
|
||||||
clients={profile.key: _profile_to_client_config(profile) for profile in profiles},
|
|
||||||
regions=regions,
|
|
||||||
selector_groups=selector_groups,
|
|
||||||
policy_groups=policy_groups,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _source_to_config(source: SourceORM) -> SourceConfig:
|
|
||||||
return SourceConfig(
|
|
||||||
enabled=source.enabled,
|
|
||||||
kind=source.kind,
|
|
||||||
url=source.url,
|
|
||||||
display_name=source.display_name,
|
|
||||||
headers=_json_loads(source.headers_json, {}),
|
|
||||||
include_regex=source.include_regex,
|
|
||||||
exclude_regex=source.exclude_regex,
|
|
||||||
prefix=source.prefix,
|
|
||||||
suffix=source.suffix,
|
|
||||||
cache_ttl_seconds=source.cache_ttl_seconds,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_group_configs(profile: ProfileORM) -> tuple[dict[str, RegionConfig], list[ProxyGroupConfig], list[ProxyGroupConfig]]:
|
|
||||||
regions: dict[str, RegionConfig] = {}
|
|
||||||
selector_groups: list[ProxyGroupConfig] = []
|
|
||||||
policy_groups: list[ProxyGroupConfig] = []
|
|
||||||
|
|
||||||
for group in sorted(profile.groups, key=lambda item: (item.order_index, item.id)):
|
|
||||||
if not group.enabled:
|
|
||||||
continue
|
|
||||||
if group.group_kind == "region":
|
|
||||||
regions[_slugify(group.name)] = RegionConfig(
|
|
||||||
name=group.name,
|
|
||||||
filter=group.filter_regex or "",
|
|
||||||
tolerance=group.tolerance or 50,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
target = selector_groups if group.group_kind == "selector" else policy_groups
|
|
||||||
target.append(
|
|
||||||
ProxyGroupConfig(
|
|
||||||
name=group.name,
|
|
||||||
type=group.type,
|
|
||||||
proxies=_json_loads(group.proxies_json, []),
|
|
||||||
filter=group.filter_regex,
|
|
||||||
tolerance=group.tolerance,
|
|
||||||
url=group.url,
|
|
||||||
interval=group.interval,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return regions, selector_groups, policy_groups
|
|
||||||
|
|
||||||
|
|
||||||
def _build_rule_configs(profile: ProfileORM) -> dict[str, RuleConfig]:
|
|
||||||
rules: dict[str, RuleConfig] = {}
|
|
||||||
for link in sorted(profile.rule_links, key=lambda item: (item.order_index, item.id)):
|
|
||||||
if not link.enabled:
|
|
||||||
continue
|
|
||||||
module = link.rule_module
|
|
||||||
rules[module.key] = RuleConfig(
|
|
||||||
file=module.file_path,
|
|
||||||
behavior=module.behavior,
|
|
||||||
format=module.format,
|
|
||||||
policy=link.policy_override or module.policy,
|
|
||||||
no_resolve=module.no_resolve if link.no_resolve_override is None else link.no_resolve_override,
|
|
||||||
payload=_json_loads(link.payload_override_json, _json_loads(module.payload_json, [])),
|
|
||||||
)
|
|
||||||
return rules
|
|
||||||
|
|
||||||
|
|
||||||
def _load_profile_with_relationships(session: Session, profile_key: str) -> ProfileORM | None:
|
|
||||||
return session.scalar(
|
|
||||||
select(ProfileORM)
|
|
||||||
.where(ProfileORM.key == profile_key)
|
|
||||||
.options(
|
|
||||||
selectinload(ProfileORM.groups),
|
|
||||||
selectinload(ProfileORM.rule_links).selectinload(ProfileRuleModuleORM.rule_module),
|
|
||||||
selectinload(ProfileORM.source_links).selectinload(ProfileSourceORM.source),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _clear_config_tables(session: Session) -> None:
|
|
||||||
session.execute(delete(ProfileRuleModuleORM))
|
|
||||||
session.execute(delete(PolicyGroupORM))
|
|
||||||
session.execute(delete(ProfileSourceORM))
|
|
||||||
session.execute(delete(ProfileORM))
|
|
||||||
session.execute(delete(RuleModuleORM))
|
|
||||||
session.execute(delete(SourceORM))
|
|
||||||
session.execute(delete(AppSettingORM))
|
|
||||||
session.flush()
|
|
||||||
|
|
||||||
|
|
||||||
def _upsert_setting(session: Session, key: str, value: str) -> None:
|
|
||||||
row = session.scalar(select(AppSettingORM).where(AppSettingORM.key == key))
|
|
||||||
if row is None:
|
|
||||||
row = AppSettingORM(key=key, value=value)
|
|
||||||
session.add(row)
|
|
||||||
else:
|
|
||||||
row.value = value
|
|
||||||
|
|
||||||
|
|
||||||
def _setting_value(session: Session, key: str) -> str | None:
|
|
||||||
row = session.scalar(select(AppSettingORM).where(AppSettingORM.key == key))
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
return row.value
|
|
||||||
|
|
||||||
|
|
||||||
def _get_or_create_source(session: Session, key: str) -> SourceORM:
|
|
||||||
row = session.scalar(select(SourceORM).where(SourceORM.key == key))
|
|
||||||
if row is None:
|
|
||||||
row = SourceORM(key=key, kind="auto", url="")
|
|
||||||
session.add(row)
|
|
||||||
return row
|
|
||||||
|
|
||||||
|
|
||||||
def _get_or_create_rule_module(session: Session, key: str) -> RuleModuleORM:
|
|
||||||
row = session.scalar(select(RuleModuleORM).where(RuleModuleORM.key == key))
|
|
||||||
if row is None:
|
|
||||||
row = RuleModuleORM(key=key, behavior="classical", format="text", policy="DIRECT")
|
|
||||||
session.add(row)
|
|
||||||
return row
|
|
||||||
|
|
||||||
|
|
||||||
def _get_or_create_profile(session: Session, key: str) -> ProfileORM:
|
|
||||||
row = session.scalar(select(ProfileORM).where(ProfileORM.key == key))
|
|
||||||
if row is None:
|
|
||||||
row = ProfileORM(
|
|
||||||
key=key,
|
|
||||||
title=key,
|
|
||||||
test_url="https://www.gstatic.com/generate_204",
|
|
||||||
main_policy="🚀 节点选择",
|
|
||||||
source_policy="☁️ 机场选择",
|
|
||||||
mixed_auto_policy="♻️ 自动选择",
|
|
||||||
manual_policy="🚀 手动切换",
|
|
||||||
direct_policy="DIRECT",
|
|
||||||
)
|
|
||||||
session.add(row)
|
|
||||||
return row
|
|
||||||
|
|
||||||
|
|
||||||
def _populate_profile(profile: ProfileORM, client: ClientConfig) -> None:
|
|
||||||
profile.title = client.title
|
|
||||||
profile.provider_interval = client.provider_interval
|
|
||||||
profile.rule_interval = client.rule_interval
|
|
||||||
profile.test_url = str(client.test_url)
|
|
||||||
profile.test_interval = client.test_interval
|
|
||||||
profile.main_policy = client.main_policy
|
|
||||||
profile.source_policy = client.source_policy
|
|
||||||
profile.mixed_auto_policy = client.mixed_auto_policy
|
|
||||||
profile.manual_policy = client.manual_policy
|
|
||||||
profile.direct_policy = client.direct_policy
|
|
||||||
profile.mode = client.mode
|
|
||||||
profile.allow_lan = client.allow_lan
|
|
||||||
profile.ipv6 = client.ipv6
|
|
||||||
profile.mixed_port = client.mixed_port
|
|
||||||
profile.socks_port = client.socks_port
|
|
||||||
profile.log_level = client.log_level
|
|
||||||
|
|
||||||
|
|
||||||
def _replace_profile_sources(session: Session, profile_id: int, source_rows: list[SourceORM]) -> None:
|
|
||||||
session.execute(delete(ProfileSourceORM).where(ProfileSourceORM.profile_id == profile_id))
|
|
||||||
for order_index, source in enumerate(source_rows):
|
|
||||||
session.add(
|
|
||||||
ProfileSourceORM(
|
|
||||||
profile_id=profile_id,
|
|
||||||
source_id=source.id,
|
|
||||||
order_index=order_index,
|
|
||||||
enabled=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _replace_profile_groups(session: Session, profile_id: int, app_config: AppConfig) -> None:
|
|
||||||
session.execute(delete(PolicyGroupORM).where(PolicyGroupORM.profile_id == profile_id))
|
|
||||||
|
|
||||||
order_index = 0
|
|
||||||
for region in app_config.regions.values():
|
|
||||||
session.add(
|
|
||||||
PolicyGroupORM(
|
|
||||||
profile_id=profile_id,
|
|
||||||
group_kind="region",
|
|
||||||
name=region.name,
|
|
||||||
type="url-test",
|
|
||||||
order_index=order_index,
|
|
||||||
filter_regex=region.filter,
|
|
||||||
tolerance=region.tolerance,
|
|
||||||
enabled=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
order_index += 1
|
|
||||||
|
|
||||||
for group in app_config.selector_groups:
|
|
||||||
session.add(
|
|
||||||
PolicyGroupORM(
|
|
||||||
profile_id=profile_id,
|
|
||||||
group_kind="selector",
|
|
||||||
name=group.name,
|
|
||||||
type=group.type,
|
|
||||||
order_index=order_index,
|
|
||||||
proxies_json=_json_dumps(group.proxies),
|
|
||||||
filter_regex=group.filter,
|
|
||||||
tolerance=group.tolerance,
|
|
||||||
url=str(group.url) if group.url else None,
|
|
||||||
interval=group.interval,
|
|
||||||
enabled=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
order_index += 1
|
|
||||||
|
|
||||||
for group in app_config.policy_groups:
|
|
||||||
session.add(
|
|
||||||
PolicyGroupORM(
|
|
||||||
profile_id=profile_id,
|
|
||||||
group_kind="policy",
|
|
||||||
name=group.name,
|
|
||||||
type=group.type,
|
|
||||||
order_index=order_index,
|
|
||||||
proxies_json=_json_dumps(group.proxies),
|
|
||||||
filter_regex=group.filter,
|
|
||||||
tolerance=group.tolerance,
|
|
||||||
url=str(group.url) if group.url else None,
|
|
||||||
interval=group.interval,
|
|
||||||
enabled=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
order_index += 1
|
|
||||||
|
|
||||||
|
|
||||||
def _replace_profile_rule_links(
|
|
||||||
session: Session,
|
|
||||||
profile_id: int,
|
|
||||||
module_rows: list[RuleModuleORM],
|
|
||||||
ordered_rule_keys: list[str],
|
|
||||||
) -> None:
|
|
||||||
session.execute(delete(ProfileRuleModuleORM).where(ProfileRuleModuleORM.profile_id == profile_id))
|
|
||||||
module_by_key = {module.key: module for module in module_rows}
|
|
||||||
for order_index, key in enumerate(ordered_rule_keys):
|
|
||||||
module = module_by_key[key]
|
|
||||||
session.add(
|
|
||||||
ProfileRuleModuleORM(
|
|
||||||
profile_id=profile_id,
|
|
||||||
rule_module_id=module.id,
|
|
||||||
enabled=True,
|
|
||||||
order_index=order_index,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _profile_to_client_config(profile: ProfileORM) -> ClientConfig:
|
|
||||||
return ClientConfig(
|
|
||||||
title=profile.title,
|
|
||||||
provider_interval=profile.provider_interval,
|
|
||||||
rule_interval=profile.rule_interval,
|
|
||||||
test_url=profile.test_url,
|
|
||||||
test_interval=profile.test_interval,
|
|
||||||
main_policy=profile.main_policy,
|
|
||||||
source_policy=profile.source_policy,
|
|
||||||
mixed_auto_policy=profile.mixed_auto_policy,
|
|
||||||
manual_policy=profile.manual_policy,
|
|
||||||
direct_policy=profile.direct_policy,
|
|
||||||
mode=profile.mode,
|
|
||||||
allow_lan=profile.allow_lan,
|
|
||||||
ipv6=profile.ipv6,
|
|
||||||
mixed_port=profile.mixed_port,
|
|
||||||
socks_port=profile.socks_port,
|
|
||||||
log_level=profile.log_level,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _slugify(name: str) -> str:
|
|
||||||
text = "".join(char.lower() if char.isalnum() else "-" for char in name).strip("-")
|
|
||||||
while "--" in text:
|
|
||||||
text = text.replace("--", "-")
|
|
||||||
return text or "group"
|
|
||||||
@@ -3,11 +3,9 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from app.db import init_db
|
|
||||||
from app.models import AppConfig
|
from app.models import AppConfig
|
||||||
|
|
||||||
_ENV_PATTERN = re.compile(r"\$\{([A-Z0-9_]+)\}")
|
_ENV_PATTERN = re.compile(r"\$\{([A-Z0-9_]+)\}")
|
||||||
@@ -23,54 +21,7 @@ def _expand_env(value):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _load_yaml(path: Path) -> dict[str, Any]:
|
|
||||||
if not path.is_file():
|
|
||||||
return {}
|
|
||||||
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
||||||
if not isinstance(raw, dict):
|
|
||||||
raise ValueError(f"Config file must contain a top-level mapping: {path}")
|
|
||||||
return raw
|
|
||||||
|
|
||||||
|
|
||||||
def _section_value(raw: dict[str, Any], section: str) -> Any:
|
|
||||||
if section in raw:
|
|
||||||
return raw[section]
|
|
||||||
return raw
|
|
||||||
|
|
||||||
|
|
||||||
def _load_split_config(path: Path) -> dict[str, Any]:
|
|
||||||
config_dir = path if path.is_dir() else path.parent
|
|
||||||
legacy_raw = _load_yaml(path) if path.is_file() else {}
|
|
||||||
|
|
||||||
app_raw = _load_yaml(config_dir / "app.yaml")
|
|
||||||
sources_raw = _load_yaml(config_dir / "sources.yaml")
|
|
||||||
regions_raw = _load_yaml(config_dir / "regions.yaml")
|
|
||||||
groups_raw = _load_yaml(config_dir / "policy-groups.yaml")
|
|
||||||
rules_raw = _load_yaml(config_dir / "rules.yaml")
|
|
||||||
clients_raw = _load_yaml(config_dir / "clients.yaml")
|
|
||||||
|
|
||||||
merged: dict[str, Any] = {}
|
|
||||||
merged.update({key: value for key, value in legacy_raw.items() if key not in AppConfig.model_fields})
|
|
||||||
merged["public_path"] = app_raw.get("public_path", legacy_raw.get("public_path"))
|
|
||||||
merged["sources"] = _section_value(sources_raw, "sources") if sources_raw else legacy_raw.get("sources", {})
|
|
||||||
merged["regions"] = _section_value(regions_raw, "regions") if regions_raw else legacy_raw.get("regions", {})
|
|
||||||
merged["selector_groups"] = groups_raw.get("selector_groups", legacy_raw.get("selector_groups", []))
|
|
||||||
merged["policy_groups"] = groups_raw.get("policy_groups", legacy_raw.get("policy_groups", []))
|
|
||||||
merged["rules"] = _section_value(rules_raw, "rules") if rules_raw else legacy_raw.get("rules", {})
|
|
||||||
merged["clients"] = _section_value(clients_raw, "clients") if clients_raw else legacy_raw.get("clients", {})
|
|
||||||
return merged
|
|
||||||
|
|
||||||
|
|
||||||
def load_app_config(path: Path) -> AppConfig:
|
def load_app_config(path: Path) -> AppConfig:
|
||||||
init_db()
|
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||||
from app.services.config_store import load_app_config_from_db
|
expanded = _expand_env(raw)
|
||||||
|
|
||||||
db_config = load_app_config_from_db()
|
|
||||||
if db_config is not None:
|
|
||||||
return db_config
|
|
||||||
return load_yaml_app_config(path)
|
|
||||||
|
|
||||||
|
|
||||||
def load_yaml_app_config(path: Path) -> AppConfig:
|
|
||||||
expanded = _expand_env(_load_split_config(path))
|
|
||||||
return AppConfig.model_validate(expanded)
|
return AppConfig.model_validate(expanded)
|
||||||
|
|||||||
@@ -1,309 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from app.models import ProxyGroupConfig, ResolvedProfile, SourceSnapshot
|
|
||||||
from app.services.proxy_pipeline import source_auto_group_name
|
|
||||||
|
|
||||||
|
|
||||||
def _expand_proxy_tokens(
|
|
||||||
proxies: list[str],
|
|
||||||
*,
|
|
||||||
resolved_profile: ResolvedProfile,
|
|
||||||
source_auto_names: list[str],
|
|
||||||
selector_names: list[str],
|
|
||||||
) -> list[str]:
|
|
||||||
client = resolved_profile.client
|
|
||||||
tokens = {
|
|
||||||
"{{ main_policy }}": [client.main_policy],
|
|
||||||
"{{main_policy}}": [client.main_policy],
|
|
||||||
"{{ source_policy }}": [client.source_policy],
|
|
||||||
"{{source_policy}}": [client.source_policy],
|
|
||||||
"{{ mixed_auto_policy }}": [client.mixed_auto_policy],
|
|
||||||
"{{mixed_auto_policy}}": [client.mixed_auto_policy],
|
|
||||||
"{{ manual_policy }}": [client.manual_policy],
|
|
||||||
"{{manual_policy}}": [client.manual_policy],
|
|
||||||
"{{ direct_policy }}": [client.direct_policy],
|
|
||||||
"{{direct_policy}}": [client.direct_policy],
|
|
||||||
"{{ source_auto_groups }}": source_auto_names,
|
|
||||||
"{{source_auto_groups}}": source_auto_names,
|
|
||||||
"{{ selector_groups }}": selector_names,
|
|
||||||
"{{selector_groups}}": selector_names,
|
|
||||||
}
|
|
||||||
expanded: list[str] = []
|
|
||||||
for item in proxies:
|
|
||||||
expanded.extend(tokens.get(item, [item]))
|
|
||||||
return expanded
|
|
||||||
|
|
||||||
|
|
||||||
def _build_filter_group_for_thin(
|
|
||||||
*,
|
|
||||||
resolved_profile: ResolvedProfile,
|
|
||||||
group: ProxyGroupConfig,
|
|
||||||
selected_source_names: list[str],
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
client = resolved_profile.client
|
|
||||||
built: dict[str, Any] = {"name": group.name, "type": group.type, "filter": group.filter}
|
|
||||||
if group.type == "url-test":
|
|
||||||
built["url"] = str(group.url or client.test_url)
|
|
||||||
built["interval"] = group.interval or client.test_interval
|
|
||||||
if group.tolerance is not None:
|
|
||||||
built["tolerance"] = group.tolerance
|
|
||||||
if resolved_profile.client_type == "mihomo":
|
|
||||||
built["use"] = selected_source_names
|
|
||||||
else:
|
|
||||||
built["include-all"] = True
|
|
||||||
return built
|
|
||||||
|
|
||||||
|
|
||||||
def _build_filter_group_for_bundle(
|
|
||||||
*,
|
|
||||||
resolved_profile: ResolvedProfile,
|
|
||||||
group: ProxyGroupConfig,
|
|
||||||
all_proxy_names: list[str],
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
client = resolved_profile.client
|
|
||||||
matched = [name for name in all_proxy_names if group.filter and re.search(group.filter, name)]
|
|
||||||
built: dict[str, Any] = {"name": group.name, "type": group.type, "proxies": matched or [client.direct_policy]}
|
|
||||||
if group.type == "url-test":
|
|
||||||
built["url"] = str(group.url or client.test_url)
|
|
||||||
built["interval"] = group.interval or client.test_interval
|
|
||||||
if group.tolerance is not None:
|
|
||||||
built["tolerance"] = group.tolerance
|
|
||||||
return built
|
|
||||||
|
|
||||||
|
|
||||||
def _build_custom_policy_groups(
|
|
||||||
*,
|
|
||||||
resolved_profile: ResolvedProfile,
|
|
||||||
source_auto_names: list[str],
|
|
||||||
selector_names: list[str],
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
groups: list[dict[str, Any]] = []
|
|
||||||
client = resolved_profile.client
|
|
||||||
for group in resolved_profile.policy_groups:
|
|
||||||
built: dict[str, Any] = {
|
|
||||||
"name": group.name,
|
|
||||||
"type": group.type,
|
|
||||||
"proxies": _expand_proxy_tokens(
|
|
||||||
group.proxies,
|
|
||||||
resolved_profile=resolved_profile,
|
|
||||||
source_auto_names=source_auto_names,
|
|
||||||
selector_names=selector_names,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
if group.type == "url-test":
|
|
||||||
built["url"] = str(group.url or client.test_url)
|
|
||||||
built["interval"] = group.interval or client.test_interval
|
|
||||||
if group.tolerance is not None:
|
|
||||||
built["tolerance"] = group.tolerance
|
|
||||||
groups.append(built)
|
|
||||||
return groups
|
|
||||||
|
|
||||||
|
|
||||||
def build_thin_groups(
|
|
||||||
*,
|
|
||||||
resolved_profile: ResolvedProfile,
|
|
||||||
selected_source_names: list[str],
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
client = resolved_profile.client
|
|
||||||
groups: list[dict[str, Any]] = []
|
|
||||||
source_auto_names: list[str] = []
|
|
||||||
|
|
||||||
for source_name in selected_source_names:
|
|
||||||
source = resolved_profile.selected_sources[source_name]
|
|
||||||
group_name = source_auto_group_name(source.display_name or source_name)
|
|
||||||
source_auto_names.append(group_name)
|
|
||||||
groups.append(
|
|
||||||
{
|
|
||||||
"name": group_name,
|
|
||||||
"type": "url-test",
|
|
||||||
"url": str(client.test_url),
|
|
||||||
"interval": client.test_interval,
|
|
||||||
"use": [source_name],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if resolved_profile.client_type == "mihomo":
|
|
||||||
mixed_auto = {
|
|
||||||
"name": client.mixed_auto_policy,
|
|
||||||
"type": "url-test",
|
|
||||||
"url": str(client.test_url),
|
|
||||||
"interval": client.test_interval,
|
|
||||||
"include-all-providers": True,
|
|
||||||
}
|
|
||||||
manual = {
|
|
||||||
"name": client.manual_policy,
|
|
||||||
"type": "select",
|
|
||||||
"proxies": [client.direct_policy],
|
|
||||||
"include-all-providers": True,
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
mixed_auto = {
|
|
||||||
"name": client.mixed_auto_policy,
|
|
||||||
"type": "url-test",
|
|
||||||
"url": str(client.test_url),
|
|
||||||
"interval": client.test_interval,
|
|
||||||
"include-all": True,
|
|
||||||
}
|
|
||||||
manual = {
|
|
||||||
"name": client.manual_policy,
|
|
||||||
"type": "select",
|
|
||||||
"proxies": [client.direct_policy],
|
|
||||||
"include-all": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
groups.append(mixed_auto)
|
|
||||||
region_names = [region.name for region in resolved_profile.regions.values()]
|
|
||||||
selector_names = [*region_names, *[selector.name for selector in resolved_profile.selector_groups]]
|
|
||||||
|
|
||||||
groups.append({"name": client.source_policy, "type": "select", "proxies": [client.mixed_auto_policy, *source_auto_names, client.direct_policy]})
|
|
||||||
groups.append(manual)
|
|
||||||
groups.append(
|
|
||||||
{
|
|
||||||
"name": client.main_policy,
|
|
||||||
"type": "select",
|
|
||||||
"proxies": [client.source_policy, client.mixed_auto_policy, *selector_names, client.manual_policy, client.direct_policy],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
groups.extend(
|
|
||||||
_build_custom_policy_groups(
|
|
||||||
resolved_profile=resolved_profile,
|
|
||||||
source_auto_names=source_auto_names,
|
|
||||||
selector_names=selector_names,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
for region in resolved_profile.regions.values():
|
|
||||||
group: dict[str, Any] = {
|
|
||||||
"name": region.name,
|
|
||||||
"type": "url-test",
|
|
||||||
"url": str(client.test_url),
|
|
||||||
"interval": client.test_interval,
|
|
||||||
"filter": region.filter,
|
|
||||||
"tolerance": region.tolerance,
|
|
||||||
}
|
|
||||||
if resolved_profile.client_type == "mihomo":
|
|
||||||
group["include-all-providers"] = True
|
|
||||||
else:
|
|
||||||
group["include-all"] = True
|
|
||||||
groups.append(group)
|
|
||||||
|
|
||||||
for selector in resolved_profile.selector_groups:
|
|
||||||
if selector.filter:
|
|
||||||
groups.append(
|
|
||||||
_build_filter_group_for_thin(
|
|
||||||
resolved_profile=resolved_profile,
|
|
||||||
group=selector,
|
|
||||||
selected_source_names=selected_source_names,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
groups.append(
|
|
||||||
{
|
|
||||||
"name": selector.name,
|
|
||||||
"type": selector.type,
|
|
||||||
"proxies": _expand_proxy_tokens(
|
|
||||||
selector.proxies,
|
|
||||||
resolved_profile=resolved_profile,
|
|
||||||
source_auto_names=source_auto_names,
|
|
||||||
selector_names=selector_names,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return groups
|
|
||||||
|
|
||||||
|
|
||||||
def build_bundle_groups(
|
|
||||||
*,
|
|
||||||
resolved_profile: ResolvedProfile,
|
|
||||||
snapshots: list[SourceSnapshot],
|
|
||||||
source_proxy_names: dict[str, list[str]],
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
client = resolved_profile.client
|
|
||||||
groups: list[dict[str, Any]] = []
|
|
||||||
source_auto_names: list[str] = []
|
|
||||||
all_proxy_names = [name for names in source_proxy_names.values() for name in names]
|
|
||||||
|
|
||||||
for snapshot in snapshots:
|
|
||||||
group_name = source_auto_group_name(snapshot.display_name)
|
|
||||||
source_auto_names.append(group_name)
|
|
||||||
groups.append(
|
|
||||||
{
|
|
||||||
"name": group_name,
|
|
||||||
"type": "url-test",
|
|
||||||
"url": str(client.test_url),
|
|
||||||
"interval": client.test_interval,
|
|
||||||
"proxies": source_proxy_names.get(snapshot.name) or [client.direct_policy],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
groups.append(
|
|
||||||
{
|
|
||||||
"name": client.mixed_auto_policy,
|
|
||||||
"type": "url-test",
|
|
||||||
"url": str(client.test_url),
|
|
||||||
"interval": client.test_interval,
|
|
||||||
"proxies": all_proxy_names or [client.direct_policy],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
region_names = [region.name for region in resolved_profile.regions.values()]
|
|
||||||
selector_names = [*region_names, *[selector.name for selector in resolved_profile.selector_groups]]
|
|
||||||
|
|
||||||
groups.append({"name": client.source_policy, "type": "select", "proxies": [client.mixed_auto_policy, *source_auto_names, client.direct_policy]})
|
|
||||||
groups.append({"name": client.manual_policy, "type": "select", "proxies": [*all_proxy_names, client.direct_policy] if all_proxy_names else [client.direct_policy]})
|
|
||||||
groups.append(
|
|
||||||
{
|
|
||||||
"name": client.main_policy,
|
|
||||||
"type": "select",
|
|
||||||
"proxies": [client.source_policy, client.mixed_auto_policy, *selector_names, client.manual_policy, client.direct_policy],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
groups.extend(
|
|
||||||
_build_custom_policy_groups(
|
|
||||||
resolved_profile=resolved_profile,
|
|
||||||
source_auto_names=source_auto_names,
|
|
||||||
selector_names=selector_names,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
for region in resolved_profile.regions.values():
|
|
||||||
matched = [name for name in all_proxy_names if re.search(region.filter, name)]
|
|
||||||
groups.append(
|
|
||||||
{
|
|
||||||
"name": region.name,
|
|
||||||
"type": "url-test",
|
|
||||||
"url": str(client.test_url),
|
|
||||||
"interval": client.test_interval,
|
|
||||||
"tolerance": region.tolerance,
|
|
||||||
"proxies": matched or [client.direct_policy],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
for selector in resolved_profile.selector_groups:
|
|
||||||
if selector.filter:
|
|
||||||
groups.append(
|
|
||||||
_build_filter_group_for_bundle(
|
|
||||||
resolved_profile=resolved_profile,
|
|
||||||
group=selector,
|
|
||||||
all_proxy_names=all_proxy_names,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
groups.append(
|
|
||||||
{
|
|
||||||
"name": selector.name,
|
|
||||||
"type": selector.type,
|
|
||||||
"proxies": _expand_proxy_tokens(
|
|
||||||
selector.proxies,
|
|
||||||
resolved_profile=resolved_profile,
|
|
||||||
source_auto_names=source_auto_names,
|
|
||||||
selector_names=selector_names,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return groups
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from app.models import AppConfig, ResolvedProfile
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_profile(
|
|
||||||
*,
|
|
||||||
app_config: AppConfig,
|
|
||||||
client_type: str,
|
|
||||||
selected_source_names: list[str],
|
|
||||||
) -> ResolvedProfile:
|
|
||||||
client = app_config.clients[client_type]
|
|
||||||
return ResolvedProfile(
|
|
||||||
client_type=client_type,
|
|
||||||
client=client,
|
|
||||||
selected_sources={name: app_config.sources[name] for name in selected_source_names if name in app_config.sources},
|
|
||||||
rules=dict(app_config.rules),
|
|
||||||
regions=dict(app_config.regions),
|
|
||||||
selector_groups=list(app_config.selector_groups),
|
|
||||||
policy_groups=list(app_config.policy_groups),
|
|
||||||
)
|
|
||||||
@@ -1,44 +1,143 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from app.models import AppConfig, SourceSnapshot
|
from app.models import AppConfig, ClientConfig, ProxyGroupConfig, SourceSnapshot
|
||||||
from app.services.policy_group_builder import build_bundle_groups, build_thin_groups
|
from app.services.rules import build_inline_rules, build_rule_provider_entries, build_rule_set_references
|
||||||
from app.services.profile_resolver import resolve_profile
|
|
||||||
from app.services.proxy_pipeline import build_bundle_proxy_inventory
|
|
||||||
from app.services.rule_resolver import build_rule_provider_entries, iter_resolved_rule_lines
|
|
||||||
|
|
||||||
|
|
||||||
def dump_yaml(data: dict[str, Any]) -> str:
|
def dump_yaml(data: dict[str, Any]) -> str:
|
||||||
return yaml.safe_dump(data, allow_unicode=True, sort_keys=False, default_flow_style=False)
|
return yaml.safe_dump(data, allow_unicode=True, sort_keys=False, default_flow_style=False)
|
||||||
|
|
||||||
|
|
||||||
def _build_profile_header(*, resolved_profile) -> dict[str, Any]:
|
def _expand_proxy_tokens(
|
||||||
client = resolved_profile.client
|
proxies: list[str],
|
||||||
profile: dict[str, Any] = {"mode": client.mode, "ipv6": client.ipv6}
|
*,
|
||||||
|
client: ClientConfig,
|
||||||
|
source_auto_names: list[str],
|
||||||
|
selector_names: list[str],
|
||||||
|
) -> list[str]:
|
||||||
|
expanded: list[str] = []
|
||||||
|
tokens = {
|
||||||
|
"{{ main_policy }}": [client.main_policy],
|
||||||
|
"{{main_policy}}": [client.main_policy],
|
||||||
|
"{{ source_policy }}": [client.source_policy],
|
||||||
|
"{{source_policy}}": [client.source_policy],
|
||||||
|
"{{ mixed_auto_policy }}": [client.mixed_auto_policy],
|
||||||
|
"{{mixed_auto_policy}}": [client.mixed_auto_policy],
|
||||||
|
"{{ manual_policy }}": [client.manual_policy],
|
||||||
|
"{{manual_policy}}": [client.manual_policy],
|
||||||
|
"{{ direct_policy }}": [client.direct_policy],
|
||||||
|
"{{direct_policy}}": [client.direct_policy],
|
||||||
|
"{{ source_auto_groups }}": source_auto_names,
|
||||||
|
"{{source_auto_groups}}": source_auto_names,
|
||||||
|
"{{ selector_groups }}": selector_names,
|
||||||
|
"{{selector_groups}}": selector_names,
|
||||||
|
}
|
||||||
|
for item in proxies:
|
||||||
|
expanded.extend(tokens.get(item, [item]))
|
||||||
|
return expanded
|
||||||
|
|
||||||
|
|
||||||
|
def _build_thin_filter_group(
|
||||||
|
*,
|
||||||
|
client_type: str,
|
||||||
|
client: ClientConfig,
|
||||||
|
group: ProxyGroupConfig,
|
||||||
|
selected_source_names: list[str],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
built: dict[str, Any] = {
|
||||||
|
"name": group.name,
|
||||||
|
"type": group.type,
|
||||||
|
"filter": group.filter,
|
||||||
|
}
|
||||||
|
if group.type == "url-test":
|
||||||
|
built["url"] = str(group.url or client.test_url)
|
||||||
|
built["interval"] = group.interval or client.test_interval
|
||||||
|
if group.tolerance is not None:
|
||||||
|
built["tolerance"] = group.tolerance
|
||||||
|
if client_type == "mihomo":
|
||||||
|
built["use"] = selected_source_names
|
||||||
|
else:
|
||||||
|
built["include-all"] = True
|
||||||
|
return built
|
||||||
|
|
||||||
|
|
||||||
|
def _build_bundle_filter_group(
|
||||||
|
*,
|
||||||
|
client: ClientConfig,
|
||||||
|
group: ProxyGroupConfig,
|
||||||
|
all_proxy_names: list[str],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
matched = [name for name in all_proxy_names if group.filter and re.search(group.filter, name)]
|
||||||
|
built: dict[str, Any] = {
|
||||||
|
"name": group.name,
|
||||||
|
"type": group.type,
|
||||||
|
"proxies": matched or [client.direct_policy],
|
||||||
|
}
|
||||||
|
if group.type == "url-test":
|
||||||
|
built["url"] = str(group.url or client.test_url)
|
||||||
|
built["interval"] = group.interval or client.test_interval
|
||||||
|
if group.tolerance is not None:
|
||||||
|
built["tolerance"] = group.tolerance
|
||||||
|
return built
|
||||||
|
|
||||||
|
|
||||||
|
def _build_custom_policy_groups(
|
||||||
|
*,
|
||||||
|
app_config: AppConfig,
|
||||||
|
client: ClientConfig,
|
||||||
|
source_auto_names: list[str],
|
||||||
|
selector_names: list[str],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
groups: list[dict[str, Any]] = []
|
||||||
|
for group in app_config.policy_groups:
|
||||||
|
built: dict[str, Any] = {
|
||||||
|
"name": group.name,
|
||||||
|
"type": group.type,
|
||||||
|
"proxies": _expand_proxy_tokens(
|
||||||
|
group.proxies,
|
||||||
|
client=client,
|
||||||
|
source_auto_names=source_auto_names,
|
||||||
|
selector_names=selector_names,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if group.type == "url-test":
|
||||||
|
built["url"] = str(group.url or client.test_url)
|
||||||
|
built["interval"] = group.interval or client.test_interval
|
||||||
|
if group.tolerance is not None:
|
||||||
|
built["tolerance"] = group.tolerance
|
||||||
|
groups.append(built)
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
def build_thin_profile(
|
||||||
|
*,
|
||||||
|
client_type: str,
|
||||||
|
app_config: AppConfig,
|
||||||
|
client: ClientConfig,
|
||||||
|
selected_source_names: list[str],
|
||||||
|
base_url: str,
|
||||||
|
public_path: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
profile: dict[str, Any] = {
|
||||||
|
"mode": client.mode,
|
||||||
|
"ipv6": client.ipv6,
|
||||||
|
}
|
||||||
if client.log_level:
|
if client.log_level:
|
||||||
profile["log-level"] = client.log_level
|
profile["log-level"] = client.log_level
|
||||||
if resolved_profile.client_type == "mihomo":
|
if client_type == "mihomo":
|
||||||
if client.mixed_port is not None:
|
if client.mixed_port is not None:
|
||||||
profile["mixed-port"] = client.mixed_port
|
profile["mixed-port"] = client.mixed_port
|
||||||
if client.socks_port is not None:
|
if client.socks_port is not None:
|
||||||
profile["socks-port"] = client.socks_port
|
profile["socks-port"] = client.socks_port
|
||||||
profile["allow-lan"] = client.allow_lan
|
profile["allow-lan"] = client.allow_lan
|
||||||
return profile
|
|
||||||
|
|
||||||
|
|
||||||
def _build_proxy_providers(
|
|
||||||
*,
|
|
||||||
resolved_profile,
|
|
||||||
base_url: str,
|
|
||||||
public_path: str,
|
|
||||||
) -> dict[str, dict[str, Any]]:
|
|
||||||
proxy_providers: dict[str, dict[str, Any]] = {}
|
proxy_providers: dict[str, dict[str, Any]] = {}
|
||||||
client = resolved_profile.client
|
for name in selected_source_names:
|
||||||
for name in resolved_profile.selected_sources:
|
if client_type == "mihomo":
|
||||||
if resolved_profile.client_type == "mihomo":
|
|
||||||
proxy_providers[name] = {
|
proxy_providers[name] = {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"url": f"{base_url}/{public_path}/providers/{name}.yaml",
|
"url": f"{base_url}/{public_path}/providers/{name}.yaml",
|
||||||
@@ -55,42 +154,10 @@ def _build_proxy_providers(
|
|||||||
"url": f"{base_url}/{public_path}/providers/{name}.yaml",
|
"url": f"{base_url}/{public_path}/providers/{name}.yaml",
|
||||||
"interval": client.provider_interval,
|
"interval": client.provider_interval,
|
||||||
}
|
}
|
||||||
return proxy_providers
|
profile["proxy-providers"] = proxy_providers
|
||||||
|
profile["proxy-groups"] = _build_thin_groups(client_type, app_config, client, selected_source_names)
|
||||||
|
profile["rule-providers"] = build_rule_provider_entries(app_config, client, base_url, public_path)
|
||||||
def build_thin_profile(
|
profile["rules"] = build_rule_set_references(app_config, client)
|
||||||
*,
|
|
||||||
client_type: str,
|
|
||||||
app_config: AppConfig,
|
|
||||||
selected_source_names: list[str],
|
|
||||||
base_url: str,
|
|
||||||
public_path: str,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
resolved_profile = resolve_profile(
|
|
||||||
app_config=app_config,
|
|
||||||
client_type=client_type,
|
|
||||||
selected_source_names=selected_source_names,
|
|
||||||
)
|
|
||||||
profile = _build_profile_header(resolved_profile=resolved_profile)
|
|
||||||
profile["proxy-providers"] = _build_proxy_providers(
|
|
||||||
resolved_profile=resolved_profile,
|
|
||||||
base_url=base_url,
|
|
||||||
public_path=public_path,
|
|
||||||
)
|
|
||||||
profile["proxy-groups"] = build_thin_groups(
|
|
||||||
resolved_profile=resolved_profile,
|
|
||||||
selected_source_names=selected_source_names,
|
|
||||||
)
|
|
||||||
profile["rule-providers"] = build_rule_provider_entries(
|
|
||||||
resolved_profile=resolved_profile,
|
|
||||||
base_url=base_url,
|
|
||||||
public_path=public_path,
|
|
||||||
)
|
|
||||||
profile["rules"] = iter_resolved_rule_lines(
|
|
||||||
resolved_profile=resolved_profile,
|
|
||||||
include_rule_set_references=True,
|
|
||||||
inline_file_payloads=False,
|
|
||||||
)
|
|
||||||
return profile
|
return profile
|
||||||
|
|
||||||
|
|
||||||
@@ -98,24 +165,275 @@ def build_bundle_profile(
|
|||||||
*,
|
*,
|
||||||
client_type: str,
|
client_type: str,
|
||||||
app_config: AppConfig,
|
app_config: AppConfig,
|
||||||
|
client: ClientConfig,
|
||||||
snapshots: list[SourceSnapshot],
|
snapshots: list[SourceSnapshot],
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
resolved_profile = resolve_profile(
|
profile: dict[str, Any] = {
|
||||||
app_config=app_config,
|
"mode": client.mode,
|
||||||
client_type=client_type,
|
"ipv6": client.ipv6,
|
||||||
selected_source_names=[snapshot.name for snapshot in snapshots],
|
}
|
||||||
)
|
if client.log_level:
|
||||||
profile = _build_profile_header(resolved_profile=resolved_profile)
|
profile["log-level"] = client.log_level
|
||||||
proxy_nodes, source_proxy_names = build_bundle_proxy_inventory(snapshots)
|
if client_type == "mihomo":
|
||||||
profile["proxies"] = [node.to_proxy_dict() for node in proxy_nodes]
|
if client.mixed_port is not None:
|
||||||
profile["proxy-groups"] = build_bundle_groups(
|
profile["mixed-port"] = client.mixed_port
|
||||||
resolved_profile=resolved_profile,
|
if client.socks_port is not None:
|
||||||
snapshots=snapshots,
|
profile["socks-port"] = client.socks_port
|
||||||
source_proxy_names=source_proxy_names,
|
profile["allow-lan"] = client.allow_lan
|
||||||
)
|
|
||||||
profile["rules"] = iter_resolved_rule_lines(
|
proxies: list[dict[str, Any]] = []
|
||||||
resolved_profile=resolved_profile,
|
source_proxy_names: dict[str, list[str]] = {}
|
||||||
include_rule_set_references=False,
|
seen: set[str] = set()
|
||||||
inline_file_payloads=True,
|
for snapshot in snapshots:
|
||||||
)
|
names: list[str] = []
|
||||||
|
for proxy in snapshot.document.proxies:
|
||||||
|
candidate = dict(proxy)
|
||||||
|
name = str(candidate.get("name", "")).strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
original = name
|
||||||
|
index = 2
|
||||||
|
while name in seen:
|
||||||
|
name = f"{original} #{index}"
|
||||||
|
index += 1
|
||||||
|
seen.add(name)
|
||||||
|
candidate["name"] = name
|
||||||
|
proxies.append(candidate)
|
||||||
|
names.append(name)
|
||||||
|
source_proxy_names[snapshot.name] = names
|
||||||
|
|
||||||
|
profile["proxies"] = proxies
|
||||||
|
profile["proxy-groups"] = _build_bundle_groups(app_config, client, snapshots, source_proxy_names)
|
||||||
|
profile["rules"] = build_inline_rules(app_config, client)
|
||||||
return profile
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
def _build_thin_groups(client_type: str, app_config: AppConfig, client: ClientConfig, selected_source_names: list[str]) -> list[dict[str, Any]]:
|
||||||
|
groups: list[dict[str, Any]] = []
|
||||||
|
source_auto_names: list[str] = []
|
||||||
|
|
||||||
|
for source_name in selected_source_names:
|
||||||
|
display_name = app_config.sources[source_name].display_name or source_name
|
||||||
|
group_name = f"{display_name} 自动"
|
||||||
|
source_auto_names.append(group_name)
|
||||||
|
groups.append(
|
||||||
|
{
|
||||||
|
"name": group_name,
|
||||||
|
"type": "url-test",
|
||||||
|
"url": str(client.test_url),
|
||||||
|
"interval": client.test_interval,
|
||||||
|
"use": [source_name],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if client_type == "mihomo":
|
||||||
|
mixed_auto = {
|
||||||
|
"name": client.mixed_auto_policy,
|
||||||
|
"type": "url-test",
|
||||||
|
"url": str(client.test_url),
|
||||||
|
"interval": client.test_interval,
|
||||||
|
"include-all-providers": True,
|
||||||
|
}
|
||||||
|
manual = {
|
||||||
|
"name": client.manual_policy,
|
||||||
|
"type": "select",
|
||||||
|
"proxies": [client.direct_policy],
|
||||||
|
"include-all-providers": True,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
mixed_auto = {
|
||||||
|
"name": client.mixed_auto_policy,
|
||||||
|
"type": "url-test",
|
||||||
|
"url": str(client.test_url),
|
||||||
|
"interval": client.test_interval,
|
||||||
|
"include-all": True,
|
||||||
|
}
|
||||||
|
manual = {
|
||||||
|
"name": client.manual_policy,
|
||||||
|
"type": "select",
|
||||||
|
"proxies": [client.direct_policy],
|
||||||
|
"include-all": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
groups.append(mixed_auto)
|
||||||
|
|
||||||
|
region_names = [region.name for region in app_config.regions.values()]
|
||||||
|
selector_names = [*region_names, *[selector.name for selector in app_config.selector_groups]]
|
||||||
|
|
||||||
|
groups.append(
|
||||||
|
{
|
||||||
|
"name": client.source_policy,
|
||||||
|
"type": "select",
|
||||||
|
"proxies": [client.mixed_auto_policy, *source_auto_names, client.direct_policy],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
groups.append(manual)
|
||||||
|
groups.append(
|
||||||
|
{
|
||||||
|
"name": client.main_policy,
|
||||||
|
"type": "select",
|
||||||
|
"proxies": [
|
||||||
|
client.source_policy,
|
||||||
|
client.mixed_auto_policy,
|
||||||
|
*selector_names,
|
||||||
|
client.manual_policy,
|
||||||
|
client.direct_policy,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
groups.extend(
|
||||||
|
_build_custom_policy_groups(
|
||||||
|
app_config=app_config,
|
||||||
|
client=client,
|
||||||
|
source_auto_names=source_auto_names,
|
||||||
|
selector_names=selector_names,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for region in app_config.regions.values():
|
||||||
|
group = {
|
||||||
|
"name": region.name,
|
||||||
|
"type": "url-test",
|
||||||
|
"url": str(client.test_url),
|
||||||
|
"interval": client.test_interval,
|
||||||
|
"filter": region.filter,
|
||||||
|
"tolerance": region.tolerance,
|
||||||
|
}
|
||||||
|
if client_type == "mihomo":
|
||||||
|
group["include-all-providers"] = True
|
||||||
|
else:
|
||||||
|
group["include-all"] = True
|
||||||
|
groups.append(group)
|
||||||
|
|
||||||
|
for selector in app_config.selector_groups:
|
||||||
|
if selector.filter:
|
||||||
|
groups.append(
|
||||||
|
_build_thin_filter_group(
|
||||||
|
client_type=client_type,
|
||||||
|
client=client,
|
||||||
|
group=selector,
|
||||||
|
selected_source_names=selected_source_names,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
groups.append(
|
||||||
|
{
|
||||||
|
"name": selector.name,
|
||||||
|
"type": selector.type,
|
||||||
|
"proxies": _expand_proxy_tokens(
|
||||||
|
selector.proxies,
|
||||||
|
client=client,
|
||||||
|
source_auto_names=source_auto_names,
|
||||||
|
selector_names=selector_names,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
def _build_bundle_groups(
|
||||||
|
app_config: AppConfig,
|
||||||
|
client: ClientConfig,
|
||||||
|
snapshots: list[SourceSnapshot],
|
||||||
|
source_proxy_names: dict[str, list[str]],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
groups: list[dict[str, Any]] = []
|
||||||
|
source_auto_names: list[str] = []
|
||||||
|
all_proxy_names = [name for names in source_proxy_names.values() for name in names]
|
||||||
|
|
||||||
|
for snapshot in snapshots:
|
||||||
|
group_name = f"{snapshot.display_name} 自动"
|
||||||
|
source_auto_names.append(group_name)
|
||||||
|
groups.append(
|
||||||
|
{
|
||||||
|
"name": group_name,
|
||||||
|
"type": "url-test",
|
||||||
|
"url": str(client.test_url),
|
||||||
|
"interval": client.test_interval,
|
||||||
|
"proxies": source_proxy_names.get(snapshot.name) or [client.direct_policy],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
groups.append(
|
||||||
|
{
|
||||||
|
"name": client.mixed_auto_policy,
|
||||||
|
"type": "url-test",
|
||||||
|
"url": str(client.test_url),
|
||||||
|
"interval": client.test_interval,
|
||||||
|
"proxies": all_proxy_names or [client.direct_policy],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
region_names = [region.name for region in app_config.regions.values()]
|
||||||
|
selector_names = [*region_names, *[selector.name for selector in app_config.selector_groups]]
|
||||||
|
|
||||||
|
groups.append(
|
||||||
|
{
|
||||||
|
"name": client.source_policy,
|
||||||
|
"type": "select",
|
||||||
|
"proxies": [client.mixed_auto_policy, *source_auto_names, client.direct_policy],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
groups.append(
|
||||||
|
{
|
||||||
|
"name": client.manual_policy,
|
||||||
|
"type": "select",
|
||||||
|
"proxies": [*all_proxy_names, client.direct_policy] if all_proxy_names else [client.direct_policy],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
groups.append(
|
||||||
|
{
|
||||||
|
"name": client.main_policy,
|
||||||
|
"type": "select",
|
||||||
|
"proxies": [
|
||||||
|
client.source_policy,
|
||||||
|
client.mixed_auto_policy,
|
||||||
|
*selector_names,
|
||||||
|
client.manual_policy,
|
||||||
|
client.direct_policy,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
groups.extend(
|
||||||
|
_build_custom_policy_groups(
|
||||||
|
app_config=app_config,
|
||||||
|
client=client,
|
||||||
|
source_auto_names=source_auto_names,
|
||||||
|
selector_names=selector_names,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for region in app_config.regions.values():
|
||||||
|
matched = [name for name in all_proxy_names if re.search(region.filter, name)]
|
||||||
|
groups.append(
|
||||||
|
{
|
||||||
|
"name": region.name,
|
||||||
|
"type": "url-test",
|
||||||
|
"url": str(client.test_url),
|
||||||
|
"interval": client.test_interval,
|
||||||
|
"tolerance": region.tolerance,
|
||||||
|
"proxies": matched or [client.direct_policy],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for selector in app_config.selector_groups:
|
||||||
|
if selector.filter:
|
||||||
|
groups.append(
|
||||||
|
_build_bundle_filter_group(
|
||||||
|
client=client,
|
||||||
|
group=selector,
|
||||||
|
all_proxy_names=all_proxy_names,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
groups.append(
|
||||||
|
{
|
||||||
|
"name": selector.name,
|
||||||
|
"type": selector.type,
|
||||||
|
"proxies": _expand_proxy_tokens(
|
||||||
|
selector.proxies,
|
||||||
|
client=client,
|
||||||
|
source_auto_names=source_auto_names,
|
||||||
|
selector_names=selector_names,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return groups
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from app.models import ProxyNode, SourceSnapshot
|
|
||||||
|
|
||||||
|
|
||||||
def source_auto_group_name(display_name: str) -> str:
|
|
||||||
return f"{display_name} 自动"
|
|
||||||
|
|
||||||
|
|
||||||
def proxy_dict_to_node(proxy: dict[str, Any]) -> ProxyNode | None:
|
|
||||||
name = str(proxy.get("name", "")).strip()
|
|
||||||
proxy_type = str(proxy.get("type", "")).strip()
|
|
||||||
if not name or not proxy_type:
|
|
||||||
return None
|
|
||||||
|
|
||||||
attrs = dict(proxy)
|
|
||||||
attrs.pop("name", None)
|
|
||||||
attrs.pop("type", None)
|
|
||||||
server = attrs.pop("server", None)
|
|
||||||
port = attrs.pop("port", None)
|
|
||||||
udp = bool(attrs.pop("udp", True))
|
|
||||||
return ProxyNode(
|
|
||||||
name=name,
|
|
||||||
type=proxy_type,
|
|
||||||
server=server,
|
|
||||||
port=port,
|
|
||||||
udp=udp,
|
|
||||||
attrs=attrs,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def dedupe_proxy_nodes(nodes: list[ProxyNode]) -> list[ProxyNode]:
|
|
||||||
seen: set[str] = set()
|
|
||||||
deduped: list[ProxyNode] = []
|
|
||||||
for node in nodes:
|
|
||||||
original = node.name
|
|
||||||
name = original
|
|
||||||
index = 2
|
|
||||||
while name in seen:
|
|
||||||
name = f"{original} #{index}"
|
|
||||||
index += 1
|
|
||||||
seen.add(name)
|
|
||||||
deduped.append(node.model_copy(update={"name": name}))
|
|
||||||
return deduped
|
|
||||||
|
|
||||||
|
|
||||||
def build_bundle_proxy_inventory(
|
|
||||||
snapshots: list[SourceSnapshot],
|
|
||||||
) -> tuple[list[ProxyNode], dict[str, list[str]]]:
|
|
||||||
all_nodes: list[ProxyNode] = []
|
|
||||||
source_nodes: dict[str, list[ProxyNode]] = {}
|
|
||||||
|
|
||||||
for snapshot in snapshots:
|
|
||||||
nodes: list[ProxyNode] = []
|
|
||||||
for proxy in snapshot.document.proxies:
|
|
||||||
node = proxy_dict_to_node(proxy)
|
|
||||||
if node is not None:
|
|
||||||
nodes.append(node)
|
|
||||||
source_nodes[snapshot.name] = nodes
|
|
||||||
all_nodes.extend(nodes)
|
|
||||||
|
|
||||||
deduped_all = dedupe_proxy_nodes(all_nodes)
|
|
||||||
source_proxy_names: dict[str, list[str]] = {}
|
|
||||||
cursor = 0
|
|
||||||
for snapshot in snapshots:
|
|
||||||
original_nodes = source_nodes[snapshot.name]
|
|
||||||
count = len(original_nodes)
|
|
||||||
source_proxy_names[snapshot.name] = [node.name for node in deduped_all[cursor : cursor + count]]
|
|
||||||
cursor += count
|
|
||||||
|
|
||||||
return deduped_all, source_proxy_names
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
from app.config import get_settings
|
|
||||||
from app.models import ClientConfig, ResolvedProfile, RuleConfig
|
|
||||||
|
|
||||||
_CIDR_PATTERN = re.compile(r"^[0-9a-fA-F:.]+/\d+$")
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_policy(policy: str, client: ClientConfig) -> str:
|
|
||||||
return (
|
|
||||||
policy.replace("{{ main_policy }}", client.main_policy)
|
|
||||||
.replace("{{main_policy}}", client.main_policy)
|
|
||||||
.replace("{{ direct_policy }}", client.direct_policy)
|
|
||||||
.replace("{{direct_policy}}", client.direct_policy)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def load_rule_text(path: Path) -> str:
|
|
||||||
return path.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def load_rule_payload(path: Path) -> list[str]:
|
|
||||||
if path.suffix.lower() in {".yaml", ".yml"}:
|
|
||||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
||||||
payload = data.get("payload", [])
|
|
||||||
if not isinstance(payload, list):
|
|
||||||
raise ValueError(f"Rule file {path.name} must contain a list field named 'payload'")
|
|
||||||
return [str(item).strip() for item in payload if str(item).strip()]
|
|
||||||
|
|
||||||
lines: list[str] = []
|
|
||||||
for line in path.read_text(encoding="utf-8").splitlines():
|
|
||||||
stripped = line.strip()
|
|
||||||
if not stripped or stripped.startswith("#"):
|
|
||||||
continue
|
|
||||||
lines.append(stripped)
|
|
||||||
return lines
|
|
||||||
|
|
||||||
|
|
||||||
def _render_payload_line(payload_line: str, behavior: str) -> str:
|
|
||||||
if "," in payload_line:
|
|
||||||
return payload_line
|
|
||||||
if behavior == "classical":
|
|
||||||
if _CIDR_PATTERN.fullmatch(payload_line):
|
|
||||||
prefix = "IP-CIDR6" if ":" in payload_line else "IP-CIDR"
|
|
||||||
return f"{prefix},{payload_line}"
|
|
||||||
return f"DOMAIN-SUFFIX,{payload_line}"
|
|
||||||
if behavior == "ipcidr":
|
|
||||||
return f"IP-CIDR,{payload_line}"
|
|
||||||
if behavior == "domain":
|
|
||||||
return f"DOMAIN-SUFFIX,{payload_line}"
|
|
||||||
return payload_line
|
|
||||||
|
|
||||||
|
|
||||||
def _attach_policy(rendered_line: str, target: str, append_no_resolve: bool) -> str:
|
|
||||||
parts = [part.strip() for part in rendered_line.split(",")]
|
|
||||||
if parts and parts[-1] == "no-resolve":
|
|
||||||
parts.insert(len(parts) - 1, target)
|
|
||||||
return ",".join(parts)
|
|
||||||
line = f"{rendered_line},{target}"
|
|
||||||
if append_no_resolve:
|
|
||||||
line += ",no-resolve"
|
|
||||||
return line
|
|
||||||
|
|
||||||
|
|
||||||
def iter_resolved_rule_lines(
|
|
||||||
*,
|
|
||||||
resolved_profile: ResolvedProfile,
|
|
||||||
include_rule_set_references: bool,
|
|
||||||
inline_file_payloads: bool,
|
|
||||||
) -> list[str]:
|
|
||||||
settings = get_settings()
|
|
||||||
lines: list[str] = []
|
|
||||||
|
|
||||||
for rule_name, rule in resolved_profile.rules.items():
|
|
||||||
target = resolve_policy(rule.policy, resolved_profile.client)
|
|
||||||
for payload_line in rule.payload:
|
|
||||||
lines.append(_attach_policy(_render_payload_line(payload_line, rule.behavior), target, rule.no_resolve))
|
|
||||||
|
|
||||||
if not rule.file:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if include_rule_set_references:
|
|
||||||
ref_line = f"RULE-SET,{rule_name},{target}"
|
|
||||||
if rule.no_resolve:
|
|
||||||
ref_line += ",no-resolve"
|
|
||||||
lines.append(ref_line)
|
|
||||||
|
|
||||||
if not inline_file_payloads:
|
|
||||||
continue
|
|
||||||
|
|
||||||
path = (settings.rules_dir / rule.file).resolve()
|
|
||||||
if not path.is_file() or settings.rules_dir.resolve() not in path.parents:
|
|
||||||
raise FileNotFoundError(f"Rule file missing: {rule.file}")
|
|
||||||
for payload_line in load_rule_payload(path):
|
|
||||||
lines.append(_attach_policy(_render_payload_line(payload_line, rule.behavior), target, rule.no_resolve))
|
|
||||||
|
|
||||||
lines.append(f"MATCH,{resolved_profile.client.main_policy}")
|
|
||||||
return lines
|
|
||||||
|
|
||||||
|
|
||||||
def build_rule_provider_entries(
|
|
||||||
*,
|
|
||||||
resolved_profile: ResolvedProfile,
|
|
||||||
base_url: str,
|
|
||||||
public_path: str,
|
|
||||||
) -> dict[str, dict]:
|
|
||||||
providers: dict[str, dict] = {}
|
|
||||||
for name, rule in resolved_profile.rules.items():
|
|
||||||
if not rule.file:
|
|
||||||
continue
|
|
||||||
providers[name] = {
|
|
||||||
"behavior": rule.behavior,
|
|
||||||
"format": rule.format,
|
|
||||||
"url": f"{base_url}/{public_path}/rules/{name}.yaml",
|
|
||||||
"interval": rule.interval,
|
|
||||||
}
|
|
||||||
return providers
|
|
||||||
@@ -1,69 +1,130 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
from app.models import AppConfig, ClientConfig
|
from app.models import AppConfig, ClientConfig
|
||||||
from app.services.profile_resolver import resolve_profile
|
|
||||||
from app.services.rule_resolver import (
|
|
||||||
build_rule_provider_entries as build_rule_provider_entries_for_profile,
|
def resolve_policy(policy: str, client: ClientConfig) -> str:
|
||||||
iter_resolved_rule_lines,
|
return (
|
||||||
load_rule_payload,
|
policy.replace("{{ main_policy }}", client.main_policy)
|
||||||
load_rule_text,
|
.replace("{{main_policy}}", client.main_policy)
|
||||||
resolve_policy,
|
.replace("{{ direct_policy }}", client.direct_policy)
|
||||||
)
|
.replace("{{direct_policy}}", client.direct_policy)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_CIDR_PATTERN = re.compile(r"^[0-9a-fA-F:.]+/\d+$")
|
||||||
|
|
||||||
|
|
||||||
|
def load_rule_text(path: Path) -> str:
|
||||||
|
return path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def load_rule_payload(path: Path) -> list[str]:
|
||||||
|
if path.suffix.lower() in {".yaml", ".yml"}:
|
||||||
|
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||||
|
payload = data.get("payload", [])
|
||||||
|
if not isinstance(payload, list):
|
||||||
|
raise ValueError(f"Rule file {path.name} must contain a list field named 'payload'")
|
||||||
|
return [str(item).strip() for item in payload if str(item).strip()]
|
||||||
|
|
||||||
|
lines: list[str] = []
|
||||||
|
for line in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped or stripped.startswith("#"):
|
||||||
|
continue
|
||||||
|
lines.append(stripped)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _render_payload_line(payload_line: str, behavior: str) -> str:
|
||||||
|
if "," in payload_line:
|
||||||
|
return payload_line
|
||||||
|
if behavior == "classical":
|
||||||
|
if _CIDR_PATTERN.fullmatch(payload_line):
|
||||||
|
prefix = "IP-CIDR6" if ":" in payload_line else "IP-CIDR"
|
||||||
|
return f"{prefix},{payload_line}"
|
||||||
|
return f"DOMAIN-SUFFIX,{payload_line}"
|
||||||
|
if behavior == "ipcidr":
|
||||||
|
return f"IP-CIDR,{payload_line}"
|
||||||
|
if behavior == "domain":
|
||||||
|
return f"DOMAIN-SUFFIX,{payload_line}"
|
||||||
|
return payload_line
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_policy(rendered_line: str, target: str, append_no_resolve: bool) -> str:
|
||||||
|
parts = [part.strip() for part in rendered_line.split(",")]
|
||||||
|
if parts and parts[-1] == "no-resolve":
|
||||||
|
parts.insert(len(parts) - 1, target)
|
||||||
|
line = ",".join(parts)
|
||||||
|
else:
|
||||||
|
line = f"{rendered_line},{target}"
|
||||||
|
if append_no_resolve:
|
||||||
|
line += ",no-resolve"
|
||||||
|
return line
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_rule_lines(rule_name: str, app_config: AppConfig, client: ClientConfig) -> list[str]:
|
||||||
|
rule = app_config.rules[rule_name]
|
||||||
|
target = resolve_policy(rule.policy, client)
|
||||||
|
lines: list[str] = []
|
||||||
|
|
||||||
|
for payload_line in rule.payload:
|
||||||
|
rendered = _render_payload_line(payload_line, rule.behavior)
|
||||||
|
lines.append(_attach_policy(rendered, target, rule.no_resolve))
|
||||||
|
|
||||||
|
if rule.file:
|
||||||
|
line = f"RULE-SET,{rule_name},{target}"
|
||||||
|
if rule.no_resolve:
|
||||||
|
line += ",no-resolve"
|
||||||
|
lines.append(line)
|
||||||
|
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
def build_rule_provider_entries(app_config: AppConfig, client: ClientConfig, base_url: str, public_path: str):
|
def build_rule_provider_entries(app_config: AppConfig, client: ClientConfig, base_url: str, public_path: str):
|
||||||
resolved_profile = resolve_profile(
|
providers: dict[str, dict] = {}
|
||||||
app_config=app_config,
|
for name, rule in app_config.rules.items():
|
||||||
client_type=_find_client_type(app_config, client),
|
if not rule.file:
|
||||||
selected_source_names=list(app_config.sources.keys()),
|
continue
|
||||||
)
|
entry = {
|
||||||
return build_rule_provider_entries_for_profile(
|
"behavior": rule.behavior,
|
||||||
resolved_profile=resolved_profile,
|
"format": rule.format,
|
||||||
base_url=base_url,
|
"url": f"{base_url}/{public_path}/rules/{name}.yaml",
|
||||||
public_path=public_path,
|
"interval": rule.interval,
|
||||||
)
|
}
|
||||||
|
providers[name] = entry
|
||||||
|
return providers
|
||||||
|
|
||||||
|
|
||||||
def build_rule_set_references(app_config: AppConfig, client: ClientConfig) -> list[str]:
|
def build_rule_set_references(app_config: AppConfig, client: ClientConfig) -> list[str]:
|
||||||
resolved_profile = resolve_profile(
|
refs: list[str] = []
|
||||||
app_config=app_config,
|
for name in app_config.rules:
|
||||||
client_type=_find_client_type(app_config, client),
|
refs.extend(_resolve_rule_lines(name, app_config, client))
|
||||||
selected_source_names=list(app_config.sources.keys()),
|
refs.append(f"MATCH,{client.main_policy}")
|
||||||
)
|
return refs
|
||||||
return iter_resolved_rule_lines(
|
|
||||||
resolved_profile=resolved_profile,
|
|
||||||
include_rule_set_references=True,
|
|
||||||
inline_file_payloads=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_inline_rules(app_config: AppConfig, client: ClientConfig) -> list[str]:
|
def build_inline_rules(app_config: AppConfig, client: ClientConfig) -> list[str]:
|
||||||
resolved_profile = resolve_profile(
|
settings = get_settings()
|
||||||
app_config=app_config,
|
lines: list[str] = []
|
||||||
client_type=_find_client_type(app_config, client),
|
for name, rule in app_config.rules.items():
|
||||||
selected_source_names=list(app_config.sources.keys()),
|
target = resolve_policy(rule.policy, client)
|
||||||
)
|
for payload_line in rule.payload:
|
||||||
return iter_resolved_rule_lines(
|
rendered = _render_payload_line(payload_line, rule.behavior)
|
||||||
resolved_profile=resolved_profile,
|
lines.append(_attach_policy(rendered, target, rule.no_resolve))
|
||||||
include_rule_set_references=False,
|
if not rule.file:
|
||||||
inline_file_payloads=True,
|
continue
|
||||||
)
|
path = (settings.rules_dir / rule.file).resolve()
|
||||||
|
if not path.is_file() or settings.rules_dir.resolve() not in path.parents:
|
||||||
|
raise FileNotFoundError(f"Rule file missing: {rule.file}")
|
||||||
def _find_client_type(app_config: AppConfig, client: ClientConfig) -> str:
|
for payload_line in load_rule_payload(path):
|
||||||
for client_type, candidate in app_config.clients.items():
|
rendered = _render_payload_line(payload_line, rule.behavior)
|
||||||
if candidate == client:
|
lines.append(_attach_policy(rendered, target, rule.no_resolve))
|
||||||
return client_type
|
lines.append(f"MATCH,{client.main_policy}")
|
||||||
raise KeyError("client config not found in app config")
|
return lines
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"build_inline_rules",
|
|
||||||
"build_rule_provider_entries",
|
|
||||||
"build_rule_set_references",
|
|
||||||
"load_rule_payload",
|
|
||||||
"load_rule_text",
|
|
||||||
"resolve_policy",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
public_path: ${PUBLIC_PATH}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
clients:
|
|
||||||
mihomo:
|
|
||||||
title: HomeLab Mihomo
|
|
||||||
provider_interval: 21600
|
|
||||||
rule_interval: 86400
|
|
||||||
test_url: https://www.gstatic.com/generate_204
|
|
||||||
test_interval: 300
|
|
||||||
main_policy: 🚀 节点选择
|
|
||||||
source_policy: ☁️ 机场选择
|
|
||||||
mixed_auto_policy: ♻️ 自动选择
|
|
||||||
manual_policy: 🚀 手动切换
|
|
||||||
direct_policy: DIRECT
|
|
||||||
mode: rule
|
|
||||||
allow_lan: true
|
|
||||||
ipv6: true
|
|
||||||
mixed_port: 7890
|
|
||||||
socks_port: 7891
|
|
||||||
log_level: info
|
|
||||||
|
|
||||||
stash:
|
|
||||||
title: HomeLab Stash
|
|
||||||
provider_interval: 21600
|
|
||||||
rule_interval: 86400
|
|
||||||
test_url: https://www.gstatic.com/generate_204
|
|
||||||
test_interval: 300
|
|
||||||
main_policy: 🚀 节点选择
|
|
||||||
source_policy: ☁️ 机场选择
|
|
||||||
mixed_auto_policy: ♻️ 自动选择
|
|
||||||
manual_policy: 🚀 手动切换
|
|
||||||
direct_policy: DIRECT
|
|
||||||
mode: rule
|
|
||||||
ipv6: true
|
|
||||||
log_level: info
|
|
||||||
82
config/conf/base.conf
Normal file
82
config/conf/base.conf
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
listen = 0.0.0.0:3000
|
||||||
|
output_dir = ./output
|
||||||
|
cache_dir = ./data/cache
|
||||||
|
mode = rule
|
||||||
|
allow_lan = true
|
||||||
|
log_level = info
|
||||||
|
ipv6 = true
|
||||||
|
append_userinfo_header = true
|
||||||
|
userinfo_source_policy = first_enabled_source
|
||||||
|
|
||||||
|
source = airport-a,url,${AIRPORT_A_URL},enabled=true,cache_ttl=1800,display_name=A,prefix=[A]
|
||||||
|
source = airport-b,url,${AIRPORT_B_URL},enabled=true,cache_ttl=1800,display_name=B,prefix=[B]
|
||||||
|
source = airport-c,url,${AIRPORT_C_URL},enabled=true,cache_ttl=1800,display_name=C,prefix=[C]
|
||||||
|
|
||||||
|
selector = all,.*
|
||||||
|
selector = hk,(?i)(港|hk|hong kong|hongkong)
|
||||||
|
selector = tw,(?i)(台|新北|彰化|tw|taiwan)
|
||||||
|
selector = sg,(?i)(新加坡|坡|狮城|sg|singapore)
|
||||||
|
selector = jp,(?i)(日本|东京|大阪|埼玉|jp|japan)
|
||||||
|
selector = us,(?i)(美|us|united states)
|
||||||
|
selector = kr,(?i)(kr|korea|kor|首尔|韩|韓)
|
||||||
|
selector = netflix,(?i)(nf|奈飞|解锁|netflix|media)
|
||||||
|
|
||||||
|
group = ♻️ 自动选择`url-test`@all`https://www.gstatic.com/generate_204`300,,50
|
||||||
|
group = ☁️ 机场选择`select`[]♻️ 自动选择`@source_auto_groups`[]DIRECT
|
||||||
|
group = 🚀 手动切换`select`@all_proxies`[]DIRECT
|
||||||
|
group = 🚀 节点选择`select`[]☁️ 机场选择`[]♻️ 自动选择`[]🇭🇰 香港节点`[]🇨🇳 台湾节点`[]🇸🇬 狮城节点`[]🇯🇵 日本节点`[]🇺🇲 美国节点`[]🇰🇷 韩国节点`[]🎥 奈飞节点`[]🚀 手动切换`[]DIRECT
|
||||||
|
group = 💬 Ai平台`select`[]🚀 节点选择`[]♻️ 自动选择`[]🇺🇲 美国节点`[]🇸🇬 狮城节点`[]🇯🇵 日本节点`[]🇭🇰 香港节点`[]🇨🇳 台湾节点`[]🇰🇷 韩国节点`[]🚀 手动切换`[]DIRECT
|
||||||
|
group = 📢 谷歌`select`[]🚀 节点选择`[]DIRECT`[]🇺🇲 美国节点`[]🇭🇰 香港节点`[]🇨🇳 台湾节点`[]🇸🇬 狮城节点`[]🇯🇵 日本节点`[]🇰🇷 韩国节点`[]🚀 手动切换
|
||||||
|
group = 🎮 PT平台`select`[]🚀 节点选择`[]DIRECT`[]🇭🇰 香港节点`[]🇨🇳 台湾节点`[]🇸🇬 狮城节点`[]🇯🇵 日本节点`[]🇺🇲 美国节点`[]🇰🇷 韩国节点`[]🚀 手动切换
|
||||||
|
group = 📲 电报消息`select`[]🚀 节点选择`[]♻️ 自动选择`[]🇸🇬 狮城节点`[]🇭🇰 香港节点`[]🇨🇳 台湾节点`[]🇯🇵 日本节点`[]🇺🇲 美国节点`[]🇰🇷 韩国节点`[]🚀 手动切换`[]DIRECT
|
||||||
|
group = 📹 油管视频`select`[]🚀 节点选择`[]♻️ 自动选择`[]🇸🇬 狮城节点`[]🇭🇰 香港节点`[]🇨🇳 台湾节点`[]🇯🇵 日本节点`[]🇺🇲 美国节点`[]🇰🇷 韩国节点`[]🚀 手动切换`[]DIRECT
|
||||||
|
group = 🎥 奈飞视频`select`[]🎥 奈飞节点`[]🚀 节点选择`[]♻️ 自动选择`[]🇸🇬 狮城节点`[]🇭🇰 香港节点`[]🇨🇳 台湾节点`[]🇯🇵 日本节点`[]🇺🇲 美国节点`[]🇰🇷 韩国节点`[]🚀 手动切换`[]DIRECT
|
||||||
|
group = 🌍 国外媒体`select`[]🚀 节点选择`[]♻️ 自动选择`[]🇭🇰 香港节点`[]🇨🇳 台湾节点`[]🇸🇬 狮城节点`[]🇯🇵 日本节点`[]🇺🇲 美国节点`[]🇰🇷 韩国节点`[]🚀 手动切换`[]DIRECT
|
||||||
|
group = Ⓜ️ 微软Bing`select`[]DIRECT`[]🚀 节点选择`[]🇺🇲 美国节点`[]🇭🇰 香港节点`[]🇨🇳 台湾节点`[]🇸🇬 狮城节点`[]🇯🇵 日本节点`[]🇰🇷 韩国节点`[]🚀 手动切换
|
||||||
|
group = Ⓜ️ 微软云盘`select`[]DIRECT`[]🚀 节点选择`[]🇺🇲 美国节点`[]🇭🇰 香港节点`[]🇨🇳 台湾节点`[]🇸🇬 狮城节点`[]🇯🇵 日本节点`[]🇰🇷 韩国节点`[]🚀 手动切换
|
||||||
|
group = Ⓜ️ 微软服务`select`[]DIRECT`[]🚀 节点选择`[]🇺🇲 美国节点`[]🇭🇰 香港节点`[]🇨🇳 台湾节点`[]🇸🇬 狮城节点`[]🇯🇵 日本节点`[]🇰🇷 韩国节点`[]🚀 手动切换
|
||||||
|
group = 🍎 苹果服务`select`[]DIRECT`[]🚀 节点选择`[]🇺🇲 美国节点`[]🇭🇰 香港节点`[]🇨🇳 台湾节点`[]🇸🇬 狮城节点`[]🇯🇵 日本节点`[]🇰🇷 韩国节点`[]🚀 手动切换
|
||||||
|
group = 🎮 游戏平台`select`[]🚀 节点选择`[]DIRECT`[]🇺🇲 美国节点`[]🇭🇰 香港节点`[]🇨🇳 台湾节点`[]🇸🇬 狮城节点`[]🇯🇵 日本节点`[]🇰🇷 韩国节点`[]🚀 手动切换
|
||||||
|
group = 🎯 全球直连`select`[]DIRECT`[]🚀 节点选择`[]♻️ 自动选择
|
||||||
|
group = 🛑 广告拦截`select`[]REJECT`[]DIRECT
|
||||||
|
group = 🍃 应用净化`select`[]REJECT`[]DIRECT
|
||||||
|
group = 🐟 漏网之鱼`select`[]DIRECT`[]🚀 节点选择`[]♻️ 自动选择`[]🇭🇰 香港节点`[]🇨🇳 台湾节点`[]🇸🇬 狮城节点`[]🇯🇵 日本节点`[]🇺🇲 美国节点`[]🇰🇷 韩国节点`[]🎥 奈飞节点`[]🚀 手动切换
|
||||||
|
group = 🇭🇰 香港节点`url-test`@hk`https://www.gstatic.com/generate_204`300,,50
|
||||||
|
group = 🇨🇳 台湾节点`url-test`@tw`https://www.gstatic.com/generate_204`300,,50
|
||||||
|
group = 🇸🇬 狮城节点`url-test`@sg`https://www.gstatic.com/generate_204`300,,50
|
||||||
|
group = 🇯🇵 日本节点`url-test`@jp`https://www.gstatic.com/generate_204`300,,50
|
||||||
|
group = 🇺🇲 美国节点`url-test`@us`https://www.gstatic.com/generate_204`300,,150
|
||||||
|
group = 🇰🇷 韩国节点`url-test`@kr`https://www.gstatic.com/generate_204`300,,50
|
||||||
|
group = 🎥 奈飞节点`select`@netflix
|
||||||
|
|
||||||
|
module = custom-proxy,../rules/modules/custom-proxy.list,🚀 节点选择,1,true
|
||||||
|
module = local-network,../rules/acl4ssr/LocalAreaNetwork.yaml,🎯 全球直连,10,true
|
||||||
|
module = unban,../rules/acl4ssr/UnBan.yaml,🎯 全球直连,20,true
|
||||||
|
module = reject,../rules/acl4ssr/BanAD.yaml,🛑 广告拦截,40,true
|
||||||
|
module = app-purify,../rules/acl4ssr/BanProgramAD.yaml,🍃 应用净化,50,true
|
||||||
|
module = google,../rules/acl4ssr/Google.yaml,📢 谷歌,60,true
|
||||||
|
module = google-cn,../rules/acl4ssr/GoogleCN.yaml,🎯 全球直连,61,true
|
||||||
|
module = steam-cn,../rules/acl4ssr/SteamCN.yaml,🎯 全球直连,62,true
|
||||||
|
module = microsoft-bing,../rules/acl4ssr/Bing.yaml,Ⓜ️ 微软Bing,70,true
|
||||||
|
module = microsoft-onedrive,../rules/acl4ssr/OneDrive.yaml,Ⓜ️ 微软云盘,71,true
|
||||||
|
module = microsoft,../rules/acl4ssr/Microsoft.yaml,Ⓜ️ 微软服务,72,true
|
||||||
|
module = apple,../rules/acl4ssr/Apple.yaml,🍎 苹果服务,80,true
|
||||||
|
module = telegram,../rules/acl4ssr/Telegram.yaml,📲 电报消息,90,true
|
||||||
|
module = ai,../rules/acl4ssr/AI.yaml,💬 Ai平台,99,true
|
||||||
|
module = openai,../rules/acl4ssr/OpenAi.yaml,💬 Ai平台,100,true
|
||||||
|
module = youtube,../rules/acl4ssr/YouTube.yaml,📹 油管视频,110,true
|
||||||
|
module = netflix,../rules/acl4ssr/Netflix.yaml,🎥 奈飞视频,111,true
|
||||||
|
module = proxy-media,../rules/acl4ssr/ProxyMedia.yaml,🌍 国外媒体,112,true
|
||||||
|
module = games-epic,../rules/acl4ssr/Epic.yaml,🎮 游戏平台,120,true
|
||||||
|
module = games-origin,../rules/acl4ssr/Origin.yaml,🎮 游戏平台,121,true
|
||||||
|
module = games-sony,../rules/acl4ssr/Sony.yaml,🎮 游戏平台,122,true
|
||||||
|
module = games-steam,../rules/acl4ssr/Steam.yaml,🎮 游戏平台,123,true
|
||||||
|
module = games-nintendo,../rules/acl4ssr/Nintendo.yaml,🎮 游戏平台,124,true
|
||||||
|
module = pt,../rules/acl4ssr/PrivateTracker.yaml,🎮 PT平台,125,true
|
||||||
|
module = cn-domain,../rules/acl4ssr/ChinaDomain.yaml,🎯 全球直连,130,true
|
||||||
|
module = cn-company-ip,../rules/acl4ssr/ChinaCompanyIp.yaml,🎯 全球直连,131,true
|
||||||
|
module = download,../rules/acl4ssr/Download.yaml,🎯 全球直连,132,true
|
||||||
|
module = proxy-gfw,../rules/acl4ssr/ProxyGFWlistLite.yaml,🚀 节点选择,140,true
|
||||||
|
|
||||||
|
builtin = geoip_cn,GEOIP,CN,🎯 全球直连,9000,true
|
||||||
|
builtin = final,FINAL,,🚀 节点选择,9999,true
|
||||||
6
config/conf/profiles/a.conf
Normal file
6
config/conf/profiles/a.conf
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name = 仅 A
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
sources = airport-a
|
||||||
|
exclude_modules =
|
||||||
|
include_builtins = geoip_cn,final
|
||||||
6
config/conf/profiles/abc.conf
Normal file
6
config/conf/profiles/abc.conf
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name = 三机场全开
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
sources = airport-a,airport-b,airport-c
|
||||||
|
exclude_modules =
|
||||||
|
include_builtins = geoip_cn,final
|
||||||
6
config/conf/profiles/b.conf
Normal file
6
config/conf/profiles/b.conf
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name = 仅 B
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
sources = airport-b
|
||||||
|
exclude_modules =
|
||||||
|
include_builtins = geoip_cn,final
|
||||||
6
config/conf/profiles/c.conf
Normal file
6
config/conf/profiles/c.conf
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name = 仅 C
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
sources = airport-c
|
||||||
|
exclude_modules =
|
||||||
|
include_builtins = geoip_cn,final
|
||||||
6
config/conf/profiles/default.conf
Normal file
6
config/conf/profiles/default.conf
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name = 默认完整配置
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
sources = airport-a,airport-b
|
||||||
|
exclude_modules =
|
||||||
|
include_builtins = geoip_cn,final
|
||||||
9
config/conf/profiles/lite.conf
Normal file
9
config/conf/profiles/lite.conf
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
name = 精简配置
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
sources = airport-a
|
||||||
|
include_modules = openai
|
||||||
|
exclude_modules =
|
||||||
|
include_builtins = final
|
||||||
|
|
||||||
|
prepend_rule = DOMAIN-SUFFIX,lite.example.com,DIRECT
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
selector_groups:
|
|
||||||
- name: "🎥 奈飞节点"
|
|
||||||
type: select
|
|
||||||
filter: "(?i)(nf|奈飞|解锁|netflix|media)"
|
|
||||||
|
|
||||||
policy_groups:
|
|
||||||
- name: "📲 电报消息"
|
|
||||||
type: select
|
|
||||||
proxies:
|
|
||||||
- "{{ main_policy }}"
|
|
||||||
- "{{ mixed_auto_policy }}"
|
|
||||||
- "🇸🇬 狮城节点"
|
|
||||||
- "🇭🇰 香港节点"
|
|
||||||
- "🇨🇳 台湾节点"
|
|
||||||
- "🇯🇵 日本节点"
|
|
||||||
- "🇺🇲 美国节点"
|
|
||||||
- "🇰🇷 韩国节点"
|
|
||||||
- "{{ manual_policy }}"
|
|
||||||
- "{{ direct_policy }}"
|
|
||||||
- name: "💬 Ai平台"
|
|
||||||
type: select
|
|
||||||
proxies:
|
|
||||||
- "{{ main_policy }}"
|
|
||||||
- "{{ mixed_auto_policy }}"
|
|
||||||
- "🇺🇲 美国节点"
|
|
||||||
- "🇸🇬 狮城节点"
|
|
||||||
- "🇯🇵 日本节点"
|
|
||||||
- "🇭🇰 香港节点"
|
|
||||||
- "🇨🇳 台湾节点"
|
|
||||||
- "🇰🇷 韩国节点"
|
|
||||||
- "{{ manual_policy }}"
|
|
||||||
- "{{ direct_policy }}"
|
|
||||||
- name: "📹 油管视频"
|
|
||||||
type: select
|
|
||||||
proxies:
|
|
||||||
- "{{ main_policy }}"
|
|
||||||
- "{{ mixed_auto_policy }}"
|
|
||||||
- "🇸🇬 狮城节点"
|
|
||||||
- "🇭🇰 香港节点"
|
|
||||||
- "🇨🇳 台湾节点"
|
|
||||||
- "🇯🇵 日本节点"
|
|
||||||
- "🇺🇲 美国节点"
|
|
||||||
- "🇰🇷 韩国节点"
|
|
||||||
- "{{ manual_policy }}"
|
|
||||||
- "{{ direct_policy }}"
|
|
||||||
- name: "🎥 奈飞视频"
|
|
||||||
type: select
|
|
||||||
proxies:
|
|
||||||
- "🎥 奈飞节点"
|
|
||||||
- "{{ main_policy }}"
|
|
||||||
- "{{ mixed_auto_policy }}"
|
|
||||||
- "🇸🇬 狮城节点"
|
|
||||||
- "🇭🇰 香港节点"
|
|
||||||
- "🇨🇳 台湾节点"
|
|
||||||
- "🇯🇵 日本节点"
|
|
||||||
- "🇺🇲 美国节点"
|
|
||||||
- "🇰🇷 韩国节点"
|
|
||||||
- "{{ manual_policy }}"
|
|
||||||
- "{{ direct_policy }}"
|
|
||||||
- name: "🌍 国外媒体"
|
|
||||||
type: select
|
|
||||||
proxies:
|
|
||||||
- "{{ main_policy }}"
|
|
||||||
- "{{ mixed_auto_policy }}"
|
|
||||||
- "🇭🇰 香港节点"
|
|
||||||
- "🇨🇳 台湾节点"
|
|
||||||
- "🇸🇬 狮城节点"
|
|
||||||
- "🇯🇵 日本节点"
|
|
||||||
- "🇺🇲 美国节点"
|
|
||||||
- "🇰🇷 韩国节点"
|
|
||||||
- "{{ manual_policy }}"
|
|
||||||
- "{{ direct_policy }}"
|
|
||||||
- name: "📢 谷歌"
|
|
||||||
type: select
|
|
||||||
proxies:
|
|
||||||
- "{{ main_policy }}"
|
|
||||||
- "{{ direct_policy }}"
|
|
||||||
- "🇺🇲 美国节点"
|
|
||||||
- "🇭🇰 香港节点"
|
|
||||||
- "🇨🇳 台湾节点"
|
|
||||||
- "🇸🇬 狮城节点"
|
|
||||||
- "🇯🇵 日本节点"
|
|
||||||
- "🇰🇷 韩国节点"
|
|
||||||
- "{{ manual_policy }}"
|
|
||||||
- name: "Ⓜ️ 微软Bing"
|
|
||||||
type: select
|
|
||||||
proxies:
|
|
||||||
- "{{ direct_policy }}"
|
|
||||||
- "{{ main_policy }}"
|
|
||||||
- "🇺🇲 美国节点"
|
|
||||||
- "🇭🇰 香港节点"
|
|
||||||
- "🇨🇳 台湾节点"
|
|
||||||
- "🇸🇬 狮城节点"
|
|
||||||
- "🇯🇵 日本节点"
|
|
||||||
- "🇰🇷 韩国节点"
|
|
||||||
- "{{ manual_policy }}"
|
|
||||||
- name: "Ⓜ️ 微软云盘"
|
|
||||||
type: select
|
|
||||||
proxies:
|
|
||||||
- "{{ direct_policy }}"
|
|
||||||
- "{{ main_policy }}"
|
|
||||||
- "🇺🇲 美国节点"
|
|
||||||
- "🇭🇰 香港节点"
|
|
||||||
- "🇨🇳 台湾节点"
|
|
||||||
- "🇸🇬 狮城节点"
|
|
||||||
- "🇯🇵 日本节点"
|
|
||||||
- "🇰🇷 韩国节点"
|
|
||||||
- "{{ manual_policy }}"
|
|
||||||
- name: "Ⓜ️ 微软服务"
|
|
||||||
type: select
|
|
||||||
proxies:
|
|
||||||
- "{{ direct_policy }}"
|
|
||||||
- "{{ main_policy }}"
|
|
||||||
- "🇺🇲 美国节点"
|
|
||||||
- "🇭🇰 香港节点"
|
|
||||||
- "🇨🇳 台湾节点"
|
|
||||||
- "🇸🇬 狮城节点"
|
|
||||||
- "🇯🇵 日本节点"
|
|
||||||
- "🇰🇷 韩国节点"
|
|
||||||
- "{{ manual_policy }}"
|
|
||||||
- name: "🍎 苹果服务"
|
|
||||||
type: select
|
|
||||||
proxies:
|
|
||||||
- "{{ direct_policy }}"
|
|
||||||
- "{{ main_policy }}"
|
|
||||||
- "🇺🇲 美国节点"
|
|
||||||
- "🇭🇰 香港节点"
|
|
||||||
- "🇨🇳 台湾节点"
|
|
||||||
- "🇸🇬 狮城节点"
|
|
||||||
- "🇯🇵 日本节点"
|
|
||||||
- "🇰🇷 韩国节点"
|
|
||||||
- "{{ manual_policy }}"
|
|
||||||
- name: "🎮 游戏平台"
|
|
||||||
type: select
|
|
||||||
proxies:
|
|
||||||
- "{{ main_policy }}"
|
|
||||||
- "{{ direct_policy }}"
|
|
||||||
- "🇺🇲 美国节点"
|
|
||||||
- "🇭🇰 香港节点"
|
|
||||||
- "🇨🇳 台湾节点"
|
|
||||||
- "🇸🇬 狮城节点"
|
|
||||||
- "🇯🇵 日本节点"
|
|
||||||
- "🇰🇷 韩国节点"
|
|
||||||
- "{{ manual_policy }}"
|
|
||||||
- name: "🎮 PT平台"
|
|
||||||
type: select
|
|
||||||
proxies:
|
|
||||||
- "{{ main_policy }}"
|
|
||||||
- "{{ direct_policy }}"
|
|
||||||
- "🇭🇰 香港节点"
|
|
||||||
- "🇨🇳 台湾节点"
|
|
||||||
- "🇸🇬 狮城节点"
|
|
||||||
- "🇯🇵 日本节点"
|
|
||||||
- "🇺🇲 美国节点"
|
|
||||||
- "🇰🇷 韩国节点"
|
|
||||||
- "{{ manual_policy }}"
|
|
||||||
- name: "🎯 全球直连"
|
|
||||||
type: select
|
|
||||||
proxies:
|
|
||||||
- "{{ direct_policy }}"
|
|
||||||
- "{{ main_policy }}"
|
|
||||||
- "{{ mixed_auto_policy }}"
|
|
||||||
- name: "🛑 广告拦截"
|
|
||||||
type: select
|
|
||||||
proxies:
|
|
||||||
- REJECT
|
|
||||||
- "{{ direct_policy }}"
|
|
||||||
- name: "🍃 应用净化"
|
|
||||||
type: select
|
|
||||||
proxies:
|
|
||||||
- REJECT
|
|
||||||
- "{{ direct_policy }}"
|
|
||||||
- name: "🐟 漏网之鱼"
|
|
||||||
type: select
|
|
||||||
proxies:
|
|
||||||
- "{{ direct_policy }}"
|
|
||||||
- "{{ main_policy }}"
|
|
||||||
- "{{ mixed_auto_policy }}"
|
|
||||||
- "{{ selector_groups }}"
|
|
||||||
- "{{ manual_policy }}"
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
regions:
|
|
||||||
hk:
|
|
||||||
name: "🇭🇰 香港节点"
|
|
||||||
filter: "(?i)(港|hk|hong kong|hongkong)"
|
|
||||||
tolerance: 50
|
|
||||||
tw:
|
|
||||||
name: "🇨🇳 台湾节点"
|
|
||||||
filter: "(?i)(台|新北|彰化|tw|taiwan)"
|
|
||||||
tolerance: 50
|
|
||||||
sg:
|
|
||||||
name: "🇸🇬 狮城节点"
|
|
||||||
filter: "(?i)(新加坡|坡|狮城|sg|singapore)"
|
|
||||||
tolerance: 50
|
|
||||||
jp:
|
|
||||||
name: "🇯🇵 日本节点"
|
|
||||||
filter: "(?i)(日本|东京|大阪|埼玉|jp|japan)"
|
|
||||||
tolerance: 50
|
|
||||||
us:
|
|
||||||
name: "🇺🇲 美国节点"
|
|
||||||
filter: "(?i)(美|波特兰|达拉斯|俄勒冈|凤凰城|费利蒙|硅谷|拉斯维加斯|洛杉矶|圣何塞|圣克拉拉|西雅图|芝加哥|us|united states)"
|
|
||||||
tolerance: 150
|
|
||||||
kr:
|
|
||||||
name: "🇰🇷 韩国节点"
|
|
||||||
filter: "(?i)(kr|korea|kor|首尔|韩|韓)"
|
|
||||||
tolerance: 50
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
rules:
|
|
||||||
custom-proxy:
|
|
||||||
behavior: classical
|
|
||||||
format: text
|
|
||||||
policy: "{{ main_policy }}"
|
|
||||||
payload:
|
|
||||||
- DOMAIN-KEYWORD,cloudflare
|
|
||||||
- DOMAIN-KEYWORD,hetzner
|
|
||||||
- DOMAIN-KEYWORD,hdkylin
|
|
||||||
- DOMAIN-KEYWORD,steamusercontent
|
|
||||||
- DOMAIN-KEYWORD,steamcontent
|
|
||||||
- DOMAIN-KEYWORD,nintendo
|
|
||||||
- DOMAIN-KEYWORD,vscode
|
|
||||||
- DOMAIN-KEYWORD,btschool
|
|
||||||
|
|
||||||
local-network:
|
|
||||||
file: acl4ssr/LocalAreaNetwork.yaml
|
|
||||||
behavior: classical
|
|
||||||
format: yaml
|
|
||||||
policy: "🎯 全球直连"
|
|
||||||
interval: 86400
|
|
||||||
|
|
||||||
unban:
|
|
||||||
file: acl4ssr/UnBan.yaml
|
|
||||||
behavior: classical
|
|
||||||
format: yaml
|
|
||||||
policy: "🎯 全球直连"
|
|
||||||
interval: 86400
|
|
||||||
|
|
||||||
reject:
|
|
||||||
file: acl4ssr/BanAD.yaml
|
|
||||||
behavior: classical
|
|
||||||
format: yaml
|
|
||||||
interval: 86400
|
|
||||||
policy: "🛑 广告拦截"
|
|
||||||
|
|
||||||
app-purify:
|
|
||||||
file: acl4ssr/BanProgramAD.yaml
|
|
||||||
behavior: classical
|
|
||||||
format: yaml
|
|
||||||
interval: 86400
|
|
||||||
policy: "🍃 应用净化"
|
|
||||||
|
|
||||||
google:
|
|
||||||
file: acl4ssr/Google.yaml
|
|
||||||
behavior: classical
|
|
||||||
format: yaml
|
|
||||||
policy: "📢 谷歌"
|
|
||||||
interval: 86400
|
|
||||||
|
|
||||||
google-cn:
|
|
||||||
file: acl4ssr/GoogleCN.yaml
|
|
||||||
behavior: classical
|
|
||||||
format: yaml
|
|
||||||
policy: "🎯 全球直连"
|
|
||||||
interval: 86400
|
|
||||||
|
|
||||||
steam-cn:
|
|
||||||
file: acl4ssr/SteamCN.yaml
|
|
||||||
behavior: classical
|
|
||||||
format: yaml
|
|
||||||
policy: "🎯 全球直连"
|
|
||||||
interval: 86400
|
|
||||||
|
|
||||||
microsoft-bing:
|
|
||||||
file: acl4ssr/Bing.yaml
|
|
||||||
behavior: classical
|
|
||||||
format: yaml
|
|
||||||
policy: "Ⓜ️ 微软Bing"
|
|
||||||
interval: 86400
|
|
||||||
|
|
||||||
microsoft-onedrive:
|
|
||||||
file: acl4ssr/OneDrive.yaml
|
|
||||||
behavior: classical
|
|
||||||
format: yaml
|
|
||||||
policy: "Ⓜ️ 微软云盘"
|
|
||||||
interval: 86400
|
|
||||||
|
|
||||||
microsoft:
|
|
||||||
file: acl4ssr/Microsoft.yaml
|
|
||||||
behavior: classical
|
|
||||||
format: yaml
|
|
||||||
policy: "Ⓜ️ 微软服务"
|
|
||||||
interval: 86400
|
|
||||||
|
|
||||||
apple:
|
|
||||||
file: acl4ssr/Apple.yaml
|
|
||||||
behavior: classical
|
|
||||||
format: yaml
|
|
||||||
policy: "🍎 苹果服务"
|
|
||||||
interval: 86400
|
|
||||||
|
|
||||||
telegram:
|
|
||||||
file: acl4ssr/Telegram.yaml
|
|
||||||
behavior: classical
|
|
||||||
format: yaml
|
|
||||||
policy: "📲 电报消息"
|
|
||||||
interval: 86400
|
|
||||||
|
|
||||||
ai:
|
|
||||||
file: acl4ssr/AI.yaml
|
|
||||||
behavior: classical
|
|
||||||
format: yaml
|
|
||||||
policy: "💬 Ai平台"
|
|
||||||
interval: 86400
|
|
||||||
|
|
||||||
openai:
|
|
||||||
file: acl4ssr/OpenAi.yaml
|
|
||||||
behavior: classical
|
|
||||||
format: yaml
|
|
||||||
policy: "💬 Ai平台"
|
|
||||||
interval: 86400
|
|
||||||
|
|
||||||
youtube:
|
|
||||||
file: acl4ssr/YouTube.yaml
|
|
||||||
behavior: classical
|
|
||||||
format: yaml
|
|
||||||
policy: "📹 油管视频"
|
|
||||||
interval: 86400
|
|
||||||
|
|
||||||
netflix:
|
|
||||||
file: acl4ssr/Netflix.yaml
|
|
||||||
behavior: classical
|
|
||||||
format: yaml
|
|
||||||
policy: "🎥 奈飞视频"
|
|
||||||
interval: 86400
|
|
||||||
|
|
||||||
proxy-media:
|
|
||||||
file: acl4ssr/ProxyMedia.yaml
|
|
||||||
behavior: classical
|
|
||||||
format: yaml
|
|
||||||
policy: "🌍 国外媒体"
|
|
||||||
interval: 86400
|
|
||||||
|
|
||||||
games-epic:
|
|
||||||
file: acl4ssr/Epic.yaml
|
|
||||||
behavior: classical
|
|
||||||
format: yaml
|
|
||||||
policy: "🎮 游戏平台"
|
|
||||||
interval: 86400
|
|
||||||
|
|
||||||
games-origin:
|
|
||||||
file: acl4ssr/Origin.yaml
|
|
||||||
behavior: classical
|
|
||||||
format: yaml
|
|
||||||
policy: "🎮 游戏平台"
|
|
||||||
interval: 86400
|
|
||||||
|
|
||||||
games-sony:
|
|
||||||
file: acl4ssr/Sony.yaml
|
|
||||||
behavior: classical
|
|
||||||
format: yaml
|
|
||||||
policy: "🎮 游戏平台"
|
|
||||||
interval: 86400
|
|
||||||
|
|
||||||
games-steam:
|
|
||||||
file: acl4ssr/Steam.yaml
|
|
||||||
behavior: classical
|
|
||||||
format: yaml
|
|
||||||
policy: "🎮 游戏平台"
|
|
||||||
interval: 86400
|
|
||||||
|
|
||||||
games-nintendo:
|
|
||||||
file: acl4ssr/Nintendo.yaml
|
|
||||||
behavior: classical
|
|
||||||
format: yaml
|
|
||||||
policy: "🎮 游戏平台"
|
|
||||||
interval: 86400
|
|
||||||
|
|
||||||
pt:
|
|
||||||
file: acl4ssr/PrivateTracker.yaml
|
|
||||||
behavior: classical
|
|
||||||
format: yaml
|
|
||||||
policy: "🎮 PT平台"
|
|
||||||
interval: 86400
|
|
||||||
|
|
||||||
cn-domain:
|
|
||||||
file: acl4ssr/ChinaDomain.yaml
|
|
||||||
behavior: classical
|
|
||||||
format: yaml
|
|
||||||
policy: "🎯 全球直连"
|
|
||||||
interval: 86400
|
|
||||||
|
|
||||||
cn-company-ip:
|
|
||||||
file: acl4ssr/ChinaCompanyIp.yaml
|
|
||||||
behavior: classical
|
|
||||||
format: yaml
|
|
||||||
policy: "🎯 全球直连"
|
|
||||||
interval: 86400
|
|
||||||
|
|
||||||
download:
|
|
||||||
file: acl4ssr/Download.yaml
|
|
||||||
behavior: classical
|
|
||||||
format: yaml
|
|
||||||
policy: "🎯 全球直连"
|
|
||||||
interval: 86400
|
|
||||||
|
|
||||||
proxy-gfw:
|
|
||||||
file: acl4ssr/ProxyGFWlist.yaml
|
|
||||||
behavior: classical
|
|
||||||
format: yaml
|
|
||||||
policy: "{{ main_policy }}"
|
|
||||||
interval: 86400
|
|
||||||
|
|
||||||
geoip-cn:
|
|
||||||
behavior: classical
|
|
||||||
format: text
|
|
||||||
policy: "🎯 全球直连"
|
|
||||||
payload:
|
|
||||||
- GEOIP,CN
|
|
||||||
104
config/rules/acl4ssr/ProxyGFWlistLite.yaml
Normal file
104
config/rules/acl4ssr/ProxyGFWlistLite.yaml
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
payload:
|
||||||
|
- DOMAIN-KEYWORD,facebook
|
||||||
|
- DOMAIN-KEYWORD,github
|
||||||
|
- DOMAIN-KEYWORD,instagram
|
||||||
|
- DOMAIN-KEYWORD,twitter
|
||||||
|
- DOMAIN-KEYWORD,whatsapp
|
||||||
|
- DOMAIN-SUFFIX,bbc.co
|
||||||
|
- DOMAIN-SUFFIX,bbc.co.uk
|
||||||
|
- DOMAIN-SUFFIX,bbc.com
|
||||||
|
- DOMAIN-SUFFIX,bbc.in
|
||||||
|
- DOMAIN-SUFFIX,bbcchinese.com
|
||||||
|
- DOMAIN-SUFFIX,bbci.co.uk
|
||||||
|
- DOMAIN-SUFFIX,bloomberg.cn
|
||||||
|
- DOMAIN-SUFFIX,bloomberg.com
|
||||||
|
- DOMAIN-SUFFIX,bloomberg.de
|
||||||
|
- DOMAIN-SUFFIX,bloombergview.com
|
||||||
|
- DOMAIN-SUFFIX,cdninstagram.com
|
||||||
|
- DOMAIN-SUFFIX,cloudflare-dns.com
|
||||||
|
- DOMAIN-SUFFIX,cloudflare-ipfs.com
|
||||||
|
- DOMAIN-SUFFIX,cloudflarestatus.com
|
||||||
|
- DOMAIN-SUFFIX,discord.co
|
||||||
|
- DOMAIN-SUFFIX,discord.com
|
||||||
|
- DOMAIN-SUFFIX,discord.gg
|
||||||
|
- DOMAIN-SUFFIX,discord.media
|
||||||
|
- DOMAIN-SUFFIX,discordapp.com
|
||||||
|
- DOMAIN-SUFFIX,discordapp.net
|
||||||
|
- DOMAIN-SUFFIX,economist.com
|
||||||
|
- DOMAIN-SUFFIX,facebook.br
|
||||||
|
- DOMAIN-SUFFIX,facebook.com
|
||||||
|
- DOMAIN-SUFFIX,facebook.de
|
||||||
|
- DOMAIN-SUFFIX,facebook.design
|
||||||
|
- DOMAIN-SUFFIX,facebook.hu
|
||||||
|
- DOMAIN-SUFFIX,facebook.in
|
||||||
|
- DOMAIN-SUFFIX,facebook.net
|
||||||
|
- DOMAIN-SUFFIX,facebook.nl
|
||||||
|
- DOMAIN-SUFFIX,facebook.se
|
||||||
|
- DOMAIN-SUFFIX,facebookmail.com
|
||||||
|
- DOMAIN-SUFFIX,fbcdn.com
|
||||||
|
- DOMAIN-SUFFIX,fbcdn.net
|
||||||
|
- DOMAIN-SUFFIX,github.blog
|
||||||
|
- DOMAIN-SUFFIX,github.com
|
||||||
|
- DOMAIN-SUFFIX,github.io
|
||||||
|
- DOMAIN-SUFFIX,githubapp.com
|
||||||
|
- DOMAIN-SUFFIX,githubassets.com
|
||||||
|
- DOMAIN-SUFFIX,githubcopilot.com
|
||||||
|
- DOMAIN-SUFFIX,githubmemory.com
|
||||||
|
- DOMAIN-SUFFIX,githubusercontent.com
|
||||||
|
- DOMAIN-SUFFIX,gitlab.com
|
||||||
|
- DOMAIN-SUFFIX,gitlab.io
|
||||||
|
- DOMAIN-SUFFIX,gitlab.net
|
||||||
|
- DOMAIN-SUFFIX,instagram.com
|
||||||
|
- DOMAIN-SUFFIX,jsdelivr.net
|
||||||
|
- DOMAIN-SUFFIX,medium.com
|
||||||
|
- DOMAIN-SUFFIX,netlify.app
|
||||||
|
- DOMAIN-SUFFIX,nytimes.com
|
||||||
|
- DOMAIN-SUFFIX,nytimes.map.fastly.net
|
||||||
|
- DOMAIN-SUFFIX,oraclecloud.com
|
||||||
|
- DOMAIN-SUFFIX,pages.dev
|
||||||
|
- DOMAIN-SUFFIX,pincong.rocks
|
||||||
|
- DOMAIN-SUFFIX,rawgithub.com
|
||||||
|
- DOMAIN-SUFFIX,redd.it
|
||||||
|
- DOMAIN-SUFFIX,reddit.com
|
||||||
|
- DOMAIN-SUFFIX,reddithelp.com
|
||||||
|
- DOMAIN-SUFFIX,redditlist.com
|
||||||
|
- DOMAIN-SUFFIX,redditmedia.com
|
||||||
|
- DOMAIN-SUFFIX,redditspace.com
|
||||||
|
- DOMAIN-SUFFIX,redditstatic.com
|
||||||
|
- DOMAIN-SUFFIX,reuters.com
|
||||||
|
- DOMAIN-SUFFIX,reutersmedia.net
|
||||||
|
- DOMAIN-SUFFIX,signal.org
|
||||||
|
- DOMAIN-SUFFIX,sourceforge.net
|
||||||
|
- DOMAIN-SUFFIX,static-economist.com
|
||||||
|
- DOMAIN-SUFFIX,substack.com
|
||||||
|
- DOMAIN-SUFFIX,thefacebook.com
|
||||||
|
- DOMAIN-SUFFIX,threads.com
|
||||||
|
- DOMAIN-SUFFIX,threads.net
|
||||||
|
- DOMAIN-SUFFIX,twitter.com
|
||||||
|
- DOMAIN-SUFFIX,twitter.jp
|
||||||
|
- DOMAIN-SUFFIX,twitter4j.org
|
||||||
|
- DOMAIN-SUFFIX,twittercounter.com
|
||||||
|
- DOMAIN-SUFFIX,twitterfeed.com
|
||||||
|
- DOMAIN-SUFFIX,twittergadget.com
|
||||||
|
- DOMAIN-SUFFIX,twitterkr.com
|
||||||
|
- DOMAIN-SUFFIX,twittermail.com
|
||||||
|
- DOMAIN-SUFFIX,twitterrific.com
|
||||||
|
- DOMAIN-SUFFIX,twittertim.es
|
||||||
|
- DOMAIN-SUFFIX,v2ex.com
|
||||||
|
- DOMAIN-SUFFIX,vercel.app
|
||||||
|
- DOMAIN-SUFFIX,whatsapp.com
|
||||||
|
- DOMAIN-SUFFIX,whatsapp.net
|
||||||
|
- DOMAIN-SUFFIX,wikibooks.org
|
||||||
|
- DOMAIN-SUFFIX,wikimedia.org
|
||||||
|
- DOMAIN-SUFFIX,wikinews.org
|
||||||
|
- DOMAIN-SUFFIX,wikipedia.com
|
||||||
|
- DOMAIN-SUFFIX,wikipedia.org
|
||||||
|
- DOMAIN-SUFFIX,wikiquote.org
|
||||||
|
- DOMAIN-SUFFIX,wikisource.org
|
||||||
|
- DOMAIN-SUFFIX,wikiversity.org
|
||||||
|
- DOMAIN-SUFFIX,wikivoyage.org
|
||||||
|
- DOMAIN-SUFFIX,wiktionary.org
|
||||||
|
- DOMAIN-SUFFIX,workers.dev
|
||||||
|
- DOMAIN-SUFFIX,wsj.com
|
||||||
|
- DOMAIN-SUFFIX,wsj.net
|
||||||
|
- DOMAIN-SUFFIX,wsjhk.com
|
||||||
8
config/rules/modules/custom-proxy.list
Normal file
8
config/rules/modules/custom-proxy.list
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
DOMAIN-KEYWORD,cloudflare
|
||||||
|
DOMAIN-KEYWORD,hetzner
|
||||||
|
DOMAIN-KEYWORD,hdkylin
|
||||||
|
DOMAIN-KEYWORD,steamusercontent
|
||||||
|
DOMAIN-KEYWORD,steamcontent
|
||||||
|
DOMAIN-KEYWORD,nintendo
|
||||||
|
DOMAIN-KEYWORD,vscode
|
||||||
|
DOMAIN-KEYWORD,btschool
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
airport-a:
|
public_path: ${PUBLIC_PATH}
|
||||||
|
|
||||||
|
sources:
|
||||||
|
airport-a:
|
||||||
enabled: true
|
enabled: true
|
||||||
display_name: A
|
display_name: A
|
||||||
kind: auto
|
kind: auto
|
||||||
@@ -7,7 +10,7 @@ airport-a:
|
|||||||
include_regex: ""
|
include_regex: ""
|
||||||
exclude_regex: "流量|重置|到期|续费|官网|离线|套餐"
|
exclude_regex: "流量|重置|到期|续费|官网|离线|套餐"
|
||||||
|
|
||||||
airport-b:
|
airport-b:
|
||||||
enabled: true
|
enabled: true
|
||||||
display_name: B
|
display_name: B
|
||||||
kind: auto
|
kind: auto
|
||||||
@@ -18,7 +21,7 @@ airport-b:
|
|||||||
include_regex: ""
|
include_regex: ""
|
||||||
exclude_regex: "流量|重置|到期|续费|官网|离线|套餐"
|
exclude_regex: "流量|重置|到期|续费|官网|离线|套餐"
|
||||||
|
|
||||||
airport-c:
|
airport-c:
|
||||||
enabled: true
|
enabled: true
|
||||||
display_name: C
|
display_name: C
|
||||||
kind: auto
|
kind: auto
|
||||||
@@ -26,3 +29,455 @@ airport-c:
|
|||||||
prefix: "[C] "
|
prefix: "[C] "
|
||||||
include_regex: ""
|
include_regex: ""
|
||||||
exclude_regex: "流量|重置|到期|续费|官网|离线|套餐"
|
exclude_regex: "流量|重置|到期|续费|官网|离线|套餐"
|
||||||
|
|
||||||
|
regions:
|
||||||
|
hk:
|
||||||
|
name: "🇭🇰 香港节点"
|
||||||
|
filter: "(?i)(港|hk|hong kong|hongkong)"
|
||||||
|
tolerance: 50
|
||||||
|
tw:
|
||||||
|
name: "🇨🇳 台湾节点"
|
||||||
|
filter: "(?i)(台|新北|彰化|tw|taiwan)"
|
||||||
|
tolerance: 50
|
||||||
|
sg:
|
||||||
|
name: "🇸🇬 狮城节点"
|
||||||
|
filter: "(?i)(新加坡|坡|狮城|sg|singapore)"
|
||||||
|
tolerance: 50
|
||||||
|
jp:
|
||||||
|
name: "🇯🇵 日本节点"
|
||||||
|
filter: "(?i)(日本|东京|大阪|埼玉|jp|japan)"
|
||||||
|
tolerance: 50
|
||||||
|
us:
|
||||||
|
name: "🇺🇲 美国节点"
|
||||||
|
filter: "(?i)(美|波特兰|达拉斯|俄勒冈|凤凰城|费利蒙|硅谷|拉斯维加斯|洛杉矶|圣何塞|圣克拉拉|西雅图|芝加哥|us|united states)"
|
||||||
|
tolerance: 150
|
||||||
|
kr:
|
||||||
|
name: "🇰🇷 韩国节点"
|
||||||
|
filter: "(?i)(kr|korea|kor|首尔|韩|韓)"
|
||||||
|
tolerance: 50
|
||||||
|
|
||||||
|
selector_groups:
|
||||||
|
- name: "🎥 奈飞节点"
|
||||||
|
type: select
|
||||||
|
filter: "(?i)(nf|奈飞|解锁|netflix|media)"
|
||||||
|
|
||||||
|
policy_groups:
|
||||||
|
- name: "📲 电报消息"
|
||||||
|
type: select
|
||||||
|
proxies:
|
||||||
|
- "{{ main_policy }}"
|
||||||
|
- "{{ mixed_auto_policy }}"
|
||||||
|
- "🇸🇬 狮城节点"
|
||||||
|
- "🇭🇰 香港节点"
|
||||||
|
- "🇨🇳 台湾节点"
|
||||||
|
- "🇯🇵 日本节点"
|
||||||
|
- "🇺🇲 美国节点"
|
||||||
|
- "🇰🇷 韩国节点"
|
||||||
|
- "{{ manual_policy }}"
|
||||||
|
- "{{ direct_policy }}"
|
||||||
|
- name: "💬 Ai平台"
|
||||||
|
type: select
|
||||||
|
proxies:
|
||||||
|
- "{{ main_policy }}"
|
||||||
|
- "{{ mixed_auto_policy }}"
|
||||||
|
- "🇺🇲 美国节点"
|
||||||
|
- "🇸🇬 狮城节点"
|
||||||
|
- "🇯🇵 日本节点"
|
||||||
|
- "🇭🇰 香港节点"
|
||||||
|
- "🇨🇳 台湾节点"
|
||||||
|
- "🇰🇷 韩国节点"
|
||||||
|
- "{{ manual_policy }}"
|
||||||
|
- "{{ direct_policy }}"
|
||||||
|
- name: "📹 油管视频"
|
||||||
|
type: select
|
||||||
|
proxies:
|
||||||
|
- "{{ main_policy }}"
|
||||||
|
- "{{ mixed_auto_policy }}"
|
||||||
|
- "🇸🇬 狮城节点"
|
||||||
|
- "🇭🇰 香港节点"
|
||||||
|
- "🇨🇳 台湾节点"
|
||||||
|
- "🇯🇵 日本节点"
|
||||||
|
- "🇺🇲 美国节点"
|
||||||
|
- "🇰🇷 韩国节点"
|
||||||
|
- "{{ manual_policy }}"
|
||||||
|
- "{{ direct_policy }}"
|
||||||
|
- name: "🎥 奈飞视频"
|
||||||
|
type: select
|
||||||
|
proxies:
|
||||||
|
- "🎥 奈飞节点"
|
||||||
|
- "{{ main_policy }}"
|
||||||
|
- "{{ mixed_auto_policy }}"
|
||||||
|
- "🇸🇬 狮城节点"
|
||||||
|
- "🇭🇰 香港节点"
|
||||||
|
- "🇨🇳 台湾节点"
|
||||||
|
- "🇯🇵 日本节点"
|
||||||
|
- "🇺🇲 美国节点"
|
||||||
|
- "🇰🇷 韩国节点"
|
||||||
|
- "{{ manual_policy }}"
|
||||||
|
- "{{ direct_policy }}"
|
||||||
|
- name: "🌍 国外媒体"
|
||||||
|
type: select
|
||||||
|
proxies:
|
||||||
|
- "{{ main_policy }}"
|
||||||
|
- "{{ mixed_auto_policy }}"
|
||||||
|
- "🇭🇰 香港节点"
|
||||||
|
- "🇨🇳 台湾节点"
|
||||||
|
- "🇸🇬 狮城节点"
|
||||||
|
- "🇯🇵 日本节点"
|
||||||
|
- "🇺🇲 美国节点"
|
||||||
|
- "🇰🇷 韩国节点"
|
||||||
|
- "{{ manual_policy }}"
|
||||||
|
- "{{ direct_policy }}"
|
||||||
|
- name: "📢 谷歌"
|
||||||
|
type: select
|
||||||
|
proxies:
|
||||||
|
- "{{ main_policy }}"
|
||||||
|
- "{{ direct_policy }}"
|
||||||
|
- "🇺🇲 美国节点"
|
||||||
|
- "🇭🇰 香港节点"
|
||||||
|
- "🇨🇳 台湾节点"
|
||||||
|
- "🇸🇬 狮城节点"
|
||||||
|
- "🇯🇵 日本节点"
|
||||||
|
- "🇰🇷 韩国节点"
|
||||||
|
- "{{ manual_policy }}"
|
||||||
|
- name: "Ⓜ️ 微软Bing"
|
||||||
|
type: select
|
||||||
|
proxies:
|
||||||
|
- "{{ direct_policy }}"
|
||||||
|
- "{{ main_policy }}"
|
||||||
|
- "🇺🇲 美国节点"
|
||||||
|
- "🇭🇰 香港节点"
|
||||||
|
- "🇨🇳 台湾节点"
|
||||||
|
- "🇸🇬 狮城节点"
|
||||||
|
- "🇯🇵 日本节点"
|
||||||
|
- "🇰🇷 韩国节点"
|
||||||
|
- "{{ manual_policy }}"
|
||||||
|
- name: "Ⓜ️ 微软云盘"
|
||||||
|
type: select
|
||||||
|
proxies:
|
||||||
|
- "{{ direct_policy }}"
|
||||||
|
- "{{ main_policy }}"
|
||||||
|
- "🇺🇲 美国节点"
|
||||||
|
- "🇭🇰 香港节点"
|
||||||
|
- "🇨🇳 台湾节点"
|
||||||
|
- "🇸🇬 狮城节点"
|
||||||
|
- "🇯🇵 日本节点"
|
||||||
|
- "🇰🇷 韩国节点"
|
||||||
|
- "{{ manual_policy }}"
|
||||||
|
- name: "Ⓜ️ 微软服务"
|
||||||
|
type: select
|
||||||
|
proxies:
|
||||||
|
- "{{ direct_policy }}"
|
||||||
|
- "{{ main_policy }}"
|
||||||
|
- "🇺🇲 美国节点"
|
||||||
|
- "🇭🇰 香港节点"
|
||||||
|
- "🇨🇳 台湾节点"
|
||||||
|
- "🇸🇬 狮城节点"
|
||||||
|
- "🇯🇵 日本节点"
|
||||||
|
- "🇰🇷 韩国节点"
|
||||||
|
- "{{ manual_policy }}"
|
||||||
|
- name: "🍎 苹果服务"
|
||||||
|
type: select
|
||||||
|
proxies:
|
||||||
|
- "{{ direct_policy }}"
|
||||||
|
- "{{ main_policy }}"
|
||||||
|
- "🇺🇲 美国节点"
|
||||||
|
- "🇭🇰 香港节点"
|
||||||
|
- "🇨🇳 台湾节点"
|
||||||
|
- "🇸🇬 狮城节点"
|
||||||
|
- "🇯🇵 日本节点"
|
||||||
|
- "🇰🇷 韩国节点"
|
||||||
|
- "{{ manual_policy }}"
|
||||||
|
- name: "🎮 游戏平台"
|
||||||
|
type: select
|
||||||
|
proxies:
|
||||||
|
- "{{ main_policy }}"
|
||||||
|
- "{{ direct_policy }}"
|
||||||
|
- "🇺🇲 美国节点"
|
||||||
|
- "🇭🇰 香港节点"
|
||||||
|
- "🇨🇳 台湾节点"
|
||||||
|
- "🇸🇬 狮城节点"
|
||||||
|
- "🇯🇵 日本节点"
|
||||||
|
- "🇰🇷 韩国节点"
|
||||||
|
- "{{ manual_policy }}"
|
||||||
|
- name: "🎮 PT平台"
|
||||||
|
type: select
|
||||||
|
proxies:
|
||||||
|
- "{{ main_policy }}"
|
||||||
|
- "{{ direct_policy }}"
|
||||||
|
- "🇭🇰 香港节点"
|
||||||
|
- "🇨🇳 台湾节点"
|
||||||
|
- "🇸🇬 狮城节点"
|
||||||
|
- "🇯🇵 日本节点"
|
||||||
|
- "🇺🇲 美国节点"
|
||||||
|
- "🇰🇷 韩国节点"
|
||||||
|
- "{{ manual_policy }}"
|
||||||
|
- name: "🎯 全球直连"
|
||||||
|
type: select
|
||||||
|
proxies:
|
||||||
|
- "{{ direct_policy }}"
|
||||||
|
- "{{ main_policy }}"
|
||||||
|
- "{{ mixed_auto_policy }}"
|
||||||
|
- name: "🛑 广告拦截"
|
||||||
|
type: select
|
||||||
|
proxies:
|
||||||
|
- REJECT
|
||||||
|
- "{{ direct_policy }}"
|
||||||
|
- name: "🍃 应用净化"
|
||||||
|
type: select
|
||||||
|
proxies:
|
||||||
|
- REJECT
|
||||||
|
- "{{ direct_policy }}"
|
||||||
|
- name: "🐟 漏网之鱼"
|
||||||
|
type: select
|
||||||
|
proxies:
|
||||||
|
- "{{ direct_policy }}"
|
||||||
|
- "{{ main_policy }}"
|
||||||
|
- "{{ mixed_auto_policy }}"
|
||||||
|
- "{{ selector_groups }}"
|
||||||
|
- "{{ manual_policy }}"
|
||||||
|
|
||||||
|
rules:
|
||||||
|
custom-proxy:
|
||||||
|
behavior: classical
|
||||||
|
format: text
|
||||||
|
policy: "{{ main_policy }}"
|
||||||
|
payload:
|
||||||
|
- DOMAIN-KEYWORD,cloudflare
|
||||||
|
- DOMAIN-KEYWORD,hetzner
|
||||||
|
- DOMAIN-KEYWORD,hdkylin
|
||||||
|
- DOMAIN-KEYWORD,steamusercontent
|
||||||
|
- DOMAIN-KEYWORD,steamcontent
|
||||||
|
- DOMAIN-KEYWORD,nintendo
|
||||||
|
- DOMAIN-KEYWORD,vscode
|
||||||
|
- DOMAIN-KEYWORD,btschool
|
||||||
|
|
||||||
|
local-network:
|
||||||
|
file: acl4ssr/LocalAreaNetwork.yaml
|
||||||
|
behavior: classical
|
||||||
|
format: yaml
|
||||||
|
policy: "🎯 全球直连"
|
||||||
|
interval: 86400
|
||||||
|
|
||||||
|
unban:
|
||||||
|
file: acl4ssr/UnBan.yaml
|
||||||
|
behavior: classical
|
||||||
|
format: yaml
|
||||||
|
policy: "🎯 全球直连"
|
||||||
|
interval: 86400
|
||||||
|
|
||||||
|
reject:
|
||||||
|
file: acl4ssr/BanAD.yaml
|
||||||
|
behavior: classical
|
||||||
|
format: yaml
|
||||||
|
interval: 86400
|
||||||
|
policy: "🛑 广告拦截"
|
||||||
|
|
||||||
|
app-purify:
|
||||||
|
file: acl4ssr/BanProgramAD.yaml
|
||||||
|
behavior: classical
|
||||||
|
format: yaml
|
||||||
|
interval: 86400
|
||||||
|
policy: "🍃 应用净化"
|
||||||
|
|
||||||
|
google:
|
||||||
|
file: acl4ssr/Google.yaml
|
||||||
|
behavior: classical
|
||||||
|
format: yaml
|
||||||
|
policy: "📢 谷歌"
|
||||||
|
interval: 86400
|
||||||
|
|
||||||
|
google-cn:
|
||||||
|
file: acl4ssr/GoogleCN.yaml
|
||||||
|
behavior: classical
|
||||||
|
format: yaml
|
||||||
|
policy: "🎯 全球直连"
|
||||||
|
interval: 86400
|
||||||
|
|
||||||
|
steam-cn:
|
||||||
|
file: acl4ssr/SteamCN.yaml
|
||||||
|
behavior: classical
|
||||||
|
format: yaml
|
||||||
|
policy: "🎯 全球直连"
|
||||||
|
interval: 86400
|
||||||
|
|
||||||
|
microsoft-bing:
|
||||||
|
file: acl4ssr/Bing.yaml
|
||||||
|
behavior: classical
|
||||||
|
format: yaml
|
||||||
|
policy: "Ⓜ️ 微软Bing"
|
||||||
|
interval: 86400
|
||||||
|
|
||||||
|
microsoft-onedrive:
|
||||||
|
file: acl4ssr/OneDrive.yaml
|
||||||
|
behavior: classical
|
||||||
|
format: yaml
|
||||||
|
policy: "Ⓜ️ 微软云盘"
|
||||||
|
interval: 86400
|
||||||
|
|
||||||
|
microsoft:
|
||||||
|
file: acl4ssr/Microsoft.yaml
|
||||||
|
behavior: classical
|
||||||
|
format: yaml
|
||||||
|
policy: "Ⓜ️ 微软服务"
|
||||||
|
interval: 86400
|
||||||
|
|
||||||
|
apple:
|
||||||
|
file: acl4ssr/Apple.yaml
|
||||||
|
behavior: classical
|
||||||
|
format: yaml
|
||||||
|
policy: "🍎 苹果服务"
|
||||||
|
interval: 86400
|
||||||
|
|
||||||
|
telegram:
|
||||||
|
file: acl4ssr/Telegram.yaml
|
||||||
|
behavior: classical
|
||||||
|
format: yaml
|
||||||
|
policy: "📲 电报消息"
|
||||||
|
interval: 86400
|
||||||
|
|
||||||
|
ai:
|
||||||
|
file: acl4ssr/AI.yaml
|
||||||
|
behavior: classical
|
||||||
|
format: yaml
|
||||||
|
policy: "💬 Ai平台"
|
||||||
|
interval: 86400
|
||||||
|
|
||||||
|
openai:
|
||||||
|
file: acl4ssr/OpenAi.yaml
|
||||||
|
behavior: classical
|
||||||
|
format: yaml
|
||||||
|
policy: "💬 Ai平台"
|
||||||
|
interval: 86400
|
||||||
|
|
||||||
|
youtube:
|
||||||
|
file: acl4ssr/YouTube.yaml
|
||||||
|
behavior: classical
|
||||||
|
format: yaml
|
||||||
|
policy: "📹 油管视频"
|
||||||
|
interval: 86400
|
||||||
|
|
||||||
|
netflix:
|
||||||
|
file: acl4ssr/Netflix.yaml
|
||||||
|
behavior: classical
|
||||||
|
format: yaml
|
||||||
|
policy: "🎥 奈飞视频"
|
||||||
|
interval: 86400
|
||||||
|
|
||||||
|
proxy-media:
|
||||||
|
file: acl4ssr/ProxyMedia.yaml
|
||||||
|
behavior: classical
|
||||||
|
format: yaml
|
||||||
|
policy: "🌍 国外媒体"
|
||||||
|
interval: 86400
|
||||||
|
|
||||||
|
games-epic:
|
||||||
|
file: acl4ssr/Epic.yaml
|
||||||
|
behavior: classical
|
||||||
|
format: yaml
|
||||||
|
policy: "🎮 游戏平台"
|
||||||
|
interval: 86400
|
||||||
|
|
||||||
|
games-origin:
|
||||||
|
file: acl4ssr/Origin.yaml
|
||||||
|
behavior: classical
|
||||||
|
format: yaml
|
||||||
|
policy: "🎮 游戏平台"
|
||||||
|
interval: 86400
|
||||||
|
|
||||||
|
games-sony:
|
||||||
|
file: acl4ssr/Sony.yaml
|
||||||
|
behavior: classical
|
||||||
|
format: yaml
|
||||||
|
policy: "🎮 游戏平台"
|
||||||
|
interval: 86400
|
||||||
|
|
||||||
|
games-steam:
|
||||||
|
file: acl4ssr/Steam.yaml
|
||||||
|
behavior: classical
|
||||||
|
format: yaml
|
||||||
|
policy: "🎮 游戏平台"
|
||||||
|
interval: 86400
|
||||||
|
|
||||||
|
games-nintendo:
|
||||||
|
file: acl4ssr/Nintendo.yaml
|
||||||
|
behavior: classical
|
||||||
|
format: yaml
|
||||||
|
policy: "🎮 游戏平台"
|
||||||
|
interval: 86400
|
||||||
|
|
||||||
|
pt:
|
||||||
|
file: acl4ssr/PrivateTracker.yaml
|
||||||
|
behavior: classical
|
||||||
|
format: yaml
|
||||||
|
policy: "🎮 PT平台"
|
||||||
|
interval: 86400
|
||||||
|
|
||||||
|
cn-domain:
|
||||||
|
file: acl4ssr/ChinaDomain.yaml
|
||||||
|
behavior: classical
|
||||||
|
format: yaml
|
||||||
|
policy: "🎯 全球直连"
|
||||||
|
interval: 86400
|
||||||
|
|
||||||
|
cn-company-ip:
|
||||||
|
file: acl4ssr/ChinaCompanyIp.yaml
|
||||||
|
behavior: classical
|
||||||
|
format: yaml
|
||||||
|
policy: "🎯 全球直连"
|
||||||
|
interval: 86400
|
||||||
|
|
||||||
|
download:
|
||||||
|
file: acl4ssr/Download.yaml
|
||||||
|
behavior: classical
|
||||||
|
format: yaml
|
||||||
|
policy: "🎯 全球直连"
|
||||||
|
interval: 86400
|
||||||
|
|
||||||
|
proxy-gfw:
|
||||||
|
file: acl4ssr/ProxyGFWlistLite.yaml
|
||||||
|
behavior: classical
|
||||||
|
format: yaml
|
||||||
|
policy: "{{ main_policy }}"
|
||||||
|
interval: 86400
|
||||||
|
|
||||||
|
geoip-cn:
|
||||||
|
behavior: classical
|
||||||
|
format: text
|
||||||
|
policy: "🎯 全球直连"
|
||||||
|
payload:
|
||||||
|
- GEOIP,CN
|
||||||
|
|
||||||
|
clients:
|
||||||
|
mihomo:
|
||||||
|
title: HomeLab Mihomo
|
||||||
|
provider_interval: 21600
|
||||||
|
rule_interval: 86400
|
||||||
|
test_url: https://www.gstatic.com/generate_204
|
||||||
|
test_interval: 300
|
||||||
|
main_policy: 🚀 节点选择
|
||||||
|
source_policy: ☁️ 机场选择
|
||||||
|
mixed_auto_policy: ♻️ 自动选择
|
||||||
|
manual_policy: 🚀 手动切换
|
||||||
|
direct_policy: DIRECT
|
||||||
|
mode: rule
|
||||||
|
allow_lan: true
|
||||||
|
ipv6: true
|
||||||
|
mixed_port: 7890
|
||||||
|
socks_port: 7891
|
||||||
|
log_level: info
|
||||||
|
|
||||||
|
stash:
|
||||||
|
title: HomeLab Stash
|
||||||
|
provider_interval: 21600
|
||||||
|
rule_interval: 86400
|
||||||
|
test_url: https://www.gstatic.com/generate_204
|
||||||
|
test_interval: 300
|
||||||
|
main_policy: 🚀 节点选择
|
||||||
|
source_policy: ☁️ 机场选择
|
||||||
|
mixed_auto_policy: ♻️ 自动选择
|
||||||
|
manual_policy: 🚀 手动切换
|
||||||
|
direct_policy: DIRECT
|
||||||
|
mode: rule
|
||||||
|
ipv6: true
|
||||||
|
log_level: info
|
||||||
|
|||||||
809
docs/ini改造方案.md
Normal file
809
docs/ini改造方案.md
Normal file
@@ -0,0 +1,809 @@
|
|||||||
|
可以。下面这份就是一版 **“类 ACL4SSR 风格 INI/CONF 配置整体说明”**,你可以直接丢给 Codex。
|
||||||
|
它刻意 **不引入 YAML**,只保留高密度、行导向、声明式的写法。你现在贴的配置本身已经在用 `[custom]`、`ruleset=`、`custom_proxy_group=`、`enable_rule_generator=true`、`overwrite_original_rules=true` 这套思路;subconverter 现有 ACL4SSR 相关配置和外部配置 gist 里也能看到同类结构,甚至有把公共规则和公共分组拆出去再导入的写法。 ([GitHub][1])
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# sub-provider 类 ACL4SSR 风格 INI 配置说明(草案)
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
将 sub-provider 的配置设计为一种 **ACL4SSR-like 的高密度 INI/CONF DSL**,用于:
|
||||||
|
|
||||||
|
* 管理订阅源
|
||||||
|
* 定义节点选择器
|
||||||
|
* 定义代理组
|
||||||
|
* 注册规则模块
|
||||||
|
* 选择输出 profile
|
||||||
|
* 生成完整 Clash/Mihomo YAML
|
||||||
|
|
||||||
|
这里的重点不是兼容标准 INI 解析器,而是:
|
||||||
|
|
||||||
|
> **保留 INI 的可读感 + ACL4SSR 的高密度声明风格 + 方便 Python 自定义解析**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 设计原则
|
||||||
|
|
||||||
|
### 2.1 保留 ACL4SSR 的使用习惯
|
||||||
|
|
||||||
|
沿用这些核心心智模型:
|
||||||
|
|
||||||
|
* `ruleset = 规则模块 -> 目标策略组`
|
||||||
|
* `group = 策略组定义`
|
||||||
|
* `selector = 节点筛选器`
|
||||||
|
* `source = 订阅源`
|
||||||
|
* `profile = 输出方案`
|
||||||
|
|
||||||
|
### 2.2 高密度、行导向
|
||||||
|
|
||||||
|
每一条配置尽量一行表达一个对象,方便:
|
||||||
|
|
||||||
|
* 快速编辑
|
||||||
|
* 批量复制
|
||||||
|
* diff
|
||||||
|
* 注释开关
|
||||||
|
* 手工维护
|
||||||
|
|
||||||
|
### 2.3 规则正文与配置分离
|
||||||
|
|
||||||
|
* `*.list` 文件只保存规则主体
|
||||||
|
* 目标策略组由配置指定
|
||||||
|
* 生成器负责最终拼接
|
||||||
|
|
||||||
|
### 2.4 不追求严格标准 INI
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
* 标准 INI 不擅长重复 key
|
||||||
|
* 不擅长表达列表对象
|
||||||
|
* 不擅长表达高密度分组语法
|
||||||
|
|
||||||
|
因此这里定义的是:
|
||||||
|
|
||||||
|
> **INI-like / CONF-like 自定义配置格式**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 文件组织
|
||||||
|
|
||||||
|
推荐结构:
|
||||||
|
|
||||||
|
```text
|
||||||
|
config/
|
||||||
|
base.conf
|
||||||
|
profiles/
|
||||||
|
default.conf
|
||||||
|
lite.conf
|
||||||
|
media.conf
|
||||||
|
|
||||||
|
rules/
|
||||||
|
modules/
|
||||||
|
Riglen.list
|
||||||
|
LocalAreaNetwork.list
|
||||||
|
UnBan.list
|
||||||
|
BanAD.list
|
||||||
|
BanProgramAD.list
|
||||||
|
Google.list
|
||||||
|
GoogleCN.list
|
||||||
|
SteamCN.list
|
||||||
|
Bing.list
|
||||||
|
OneDrive.list
|
||||||
|
Microsoft.list
|
||||||
|
Apple.list
|
||||||
|
Telegram.list
|
||||||
|
AI.list
|
||||||
|
OpenAi.list
|
||||||
|
YouTube.list
|
||||||
|
Netflix.list
|
||||||
|
ProxyMedia.list
|
||||||
|
ProxyGFWlist.list
|
||||||
|
Pt.list
|
||||||
|
ChinaDomain.list
|
||||||
|
ChinaCompanyIp.list
|
||||||
|
Download.list
|
||||||
|
```
|
||||||
|
|
||||||
|
### 说明
|
||||||
|
|
||||||
|
* `base.conf`:全局配置、源、选择器、组定义、模块注册、内置规则
|
||||||
|
* `profiles/*.conf`:不同输出方案
|
||||||
|
* `rules/modules/*.list`:规则正文
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 基础语法
|
||||||
|
|
||||||
|
## 4.1 注释
|
||||||
|
|
||||||
|
支持两种注释:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
; 这是注释
|
||||||
|
# 这也是注释
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4.2 空行
|
||||||
|
|
||||||
|
空行忽略。
|
||||||
|
|
||||||
|
## 4.3 键值形式
|
||||||
|
|
||||||
|
基本格式:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
key = value
|
||||||
|
```
|
||||||
|
|
||||||
|
左右空格允许存在,解析时应 trim。
|
||||||
|
|
||||||
|
## 4.4 重复 key
|
||||||
|
|
||||||
|
允许重复 key。
|
||||||
|
例如:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
source = ...
|
||||||
|
source = ...
|
||||||
|
selector = ...
|
||||||
|
selector = ...
|
||||||
|
group = ...
|
||||||
|
group = ...
|
||||||
|
module = ...
|
||||||
|
module = ...
|
||||||
|
```
|
||||||
|
|
||||||
|
解析器应将这类 key 视为“多条记录”,而不是覆盖。
|
||||||
|
|
||||||
|
## 4.5 大小写
|
||||||
|
|
||||||
|
建议:
|
||||||
|
|
||||||
|
* 指令名小写
|
||||||
|
* 值区分大小写
|
||||||
|
* 策略组名、规则内容、正则表达式保持原样
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 支持的指令
|
||||||
|
|
||||||
|
本方案定义以下核心指令:
|
||||||
|
|
||||||
|
* `listen`
|
||||||
|
* `output_dir`
|
||||||
|
* `cache_dir`
|
||||||
|
* `mode`
|
||||||
|
* `allow_lan`
|
||||||
|
* `log_level`
|
||||||
|
* `ipv6`
|
||||||
|
* `append_userinfo_header`
|
||||||
|
* `userinfo_source_policy`
|
||||||
|
|
||||||
|
以及对象型指令:
|
||||||
|
|
||||||
|
* `source =`
|
||||||
|
* `selector =`
|
||||||
|
* `group =`
|
||||||
|
* `module =`
|
||||||
|
* `builtin =`
|
||||||
|
|
||||||
|
profile 内支持:
|
||||||
|
|
||||||
|
* `name =`
|
||||||
|
* `enabled =`
|
||||||
|
* `sources =`
|
||||||
|
* `include_modules =`
|
||||||
|
* `exclude_modules =`
|
||||||
|
* `include_builtins =`
|
||||||
|
* `override_policy =`
|
||||||
|
* `prepend_rule =`
|
||||||
|
* `append_rule =`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. base.conf 说明
|
||||||
|
|
||||||
|
## 6.1 全局标量配置
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```ini
|
||||||
|
listen = 0.0.0.0:3000
|
||||||
|
output_dir = ./output
|
||||||
|
cache_dir = ./data/cache
|
||||||
|
mode = rule
|
||||||
|
allow_lan = true
|
||||||
|
log_level = info
|
||||||
|
ipv6 = false
|
||||||
|
append_userinfo_header = true
|
||||||
|
userinfo_source_policy = first_enabled_source
|
||||||
|
```
|
||||||
|
|
||||||
|
### 含义
|
||||||
|
|
||||||
|
* `listen`:监听地址
|
||||||
|
* `output_dir`:生成结果输出目录
|
||||||
|
* `cache_dir`:缓存目录
|
||||||
|
* `mode`:Clash 模式,通常为 `rule`
|
||||||
|
* `allow_lan`:是否允许局域网访问
|
||||||
|
* `log_level`:日志级别
|
||||||
|
* `ipv6`:是否开启 IPv6
|
||||||
|
* `append_userinfo_header`:是否透传订阅流量头
|
||||||
|
* `userinfo_source_policy`:多源时如何选取订阅信息头
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.2 source 指令
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
定义订阅输入源。
|
||||||
|
|
||||||
|
### 格式
|
||||||
|
|
||||||
|
```ini
|
||||||
|
source = key,type,value,enabled=true,cache_ttl=1800
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```ini
|
||||||
|
source = airport_main,url,https://example.com/sub?token=xxxx,enabled=true,cache_ttl=1800
|
||||||
|
source = local_links,file,./data/local_links.txt,enabled=false
|
||||||
|
```
|
||||||
|
|
||||||
|
### 字段说明
|
||||||
|
|
||||||
|
* 第 1 列:`key`
|
||||||
|
* 第 2 列:`type`
|
||||||
|
* 第 3 列:`value`
|
||||||
|
* 后续:可选命名参数
|
||||||
|
|
||||||
|
### 支持类型
|
||||||
|
|
||||||
|
* `url`:远程订阅链接
|
||||||
|
* `file`:本地文件
|
||||||
|
* `inline`:内联文本
|
||||||
|
* `base64`:base64 内容
|
||||||
|
|
||||||
|
### 建议解析规则
|
||||||
|
|
||||||
|
前三列固定,后续按 `k=v` 解析。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.3 selector 指令
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
定义节点筛选器,供代理组复用。
|
||||||
|
|
||||||
|
### 格式
|
||||||
|
|
||||||
|
```ini
|
||||||
|
selector = key,regex
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```ini
|
||||||
|
selector = all,.*
|
||||||
|
selector = hk,(港|HK|hk|Hong Kong|HongKong|hongkong)
|
||||||
|
selector = jp,(日本|东京|大阪|JP|Japan)
|
||||||
|
selector = us,(美|洛杉矶|西雅图|US|United States)
|
||||||
|
selector = netflix,(NF|奈飞|解锁|Netflix|NETFLIX|Media)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 含义
|
||||||
|
|
||||||
|
* `key`:选择器名
|
||||||
|
* `regex`:匹配节点名的正则表达式
|
||||||
|
|
||||||
|
### 约定
|
||||||
|
|
||||||
|
后续在 `group` 指令中通过 `@selector_key` 引用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.4 group 指令
|
||||||
|
|
||||||
|
这是整个 DSL 的核心。
|
||||||
|
它保留 ACL4SSR 风格的高密度分组写法。你的示例里已经大量使用 `custom_proxy_group=` 这种反引号分隔格式。 现有 subconverter 相关 ACL4SSR 配置里也是同类写法。([GitHub][1])
|
||||||
|
|
||||||
|
### 格式
|
||||||
|
|
||||||
|
```ini
|
||||||
|
group = 组名`类型`参数1`参数2`参数3...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 成员约定
|
||||||
|
|
||||||
|
* `[]组名`:引用已有组
|
||||||
|
* `[]DIRECT`:内置 DIRECT
|
||||||
|
* `[]REJECT`:内置 REJECT
|
||||||
|
* `@selector_key`:引用 selector 动态筛节点
|
||||||
|
* `.*`:兼容保留写法,可映射为全部节点
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```ini
|
||||||
|
group = 🚀 节点选择`select`[]♻️ 自动选择`[]🇭🇰 香港节点`[]🇯🇵 日本节点`[]🚀 手动切换`[]DIRECT
|
||||||
|
group = 🚀 手动切换`select`@all
|
||||||
|
group = ♻️ 自动选择`url-test`@all`http://www.gstatic.com/generate_204`300,,50
|
||||||
|
group = 🇭🇰 香港节点`url-test`@hk`http://www.gstatic.com/generate_204`300,,50
|
||||||
|
group = 🇯🇵 日本节点`url-test`@jp`http://www.gstatic.com/generate_204`300,,50
|
||||||
|
group = 🎥 奈飞节点`select`@netflix
|
||||||
|
group = 🐟 漏网之鱼`select`[]DIRECT`[]🚀 节点选择`[]♻️ 自动选择
|
||||||
|
```
|
||||||
|
|
||||||
|
### 解析规则建议
|
||||||
|
|
||||||
|
#### `select`
|
||||||
|
|
||||||
|
```ini
|
||||||
|
group = 组名`select`成员1`成员2`成员3...
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `url-test`
|
||||||
|
|
||||||
|
```ini
|
||||||
|
group = 组名`url-test`@selector`测试URL`间隔,,容差
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `fallback`
|
||||||
|
|
||||||
|
```ini
|
||||||
|
group = 组名`fallback`@selector`测试URL`间隔,,容差
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `load-balance`
|
||||||
|
|
||||||
|
```ini
|
||||||
|
group = 组名`load-balance`@selector`测试URL`间隔,,容差
|
||||||
|
```
|
||||||
|
|
||||||
|
### 设计建议
|
||||||
|
|
||||||
|
不要再继续沿用 `custom_proxy_group=` 这个旧名字。
|
||||||
|
内部 DSL 里直接统一为:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
group = ...
|
||||||
|
```
|
||||||
|
|
||||||
|
这样更短,也更像你自己的项目语法。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.5 module 指令
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
注册规则模块。
|
||||||
|
|
||||||
|
### 格式
|
||||||
|
|
||||||
|
```ini
|
||||||
|
module = key,path,policy,order,enabled
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```ini
|
||||||
|
module = riglen,./rules/modules/Riglen.list,🚀 节点选择,10,true
|
||||||
|
module = lan,./rules/modules/LocalAreaNetwork.list,🎯 全球直连,20,true
|
||||||
|
module = banad,./rules/modules/BanAD.list,🛑 广告拦截,40,true
|
||||||
|
module = apple,./rules/modules/Apple.list,🍎 苹果服务,80,true
|
||||||
|
module = ai,./rules/modules/AI.list,💬 Ai平台,100,true
|
||||||
|
module = openai,./rules/modules/OpenAi.list,💬 Ai平台,101,true
|
||||||
|
```
|
||||||
|
|
||||||
|
### 字段说明
|
||||||
|
|
||||||
|
* `key`:模块唯一标识
|
||||||
|
* `path`:规则文件路径
|
||||||
|
* `policy`:默认策略组
|
||||||
|
* `order`:排序
|
||||||
|
* `enabled`:默认是否启用
|
||||||
|
|
||||||
|
### 规则文件约定
|
||||||
|
|
||||||
|
规则文件只保存规则主体,不含目标策略组。
|
||||||
|
|
||||||
|
例如 `OpenAi.list`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
DOMAIN-SUFFIX,openai.com
|
||||||
|
DOMAIN-SUFFIX,chatgpt.com
|
||||||
|
DOMAIN-SUFFIX,oaistatic.com
|
||||||
|
```
|
||||||
|
|
||||||
|
生成时补成:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
DOMAIN-SUFFIX,openai.com,💬 Ai平台
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6.6 builtin 指令
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
定义不来自 `.list` 文件的内置规则。
|
||||||
|
|
||||||
|
### 格式
|
||||||
|
|
||||||
|
```ini
|
||||||
|
builtin = key,type,value,policy,order,enabled
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```ini
|
||||||
|
builtin = geoip_cn,GEOIP,CN,🎯 全球直连,9000,true
|
||||||
|
builtin = final,FINAL,,🐟 漏网之鱼,9999,true
|
||||||
|
```
|
||||||
|
|
||||||
|
### 映射规则
|
||||||
|
|
||||||
|
* `GEOIP + CN + 🎯 全球直连`
|
||||||
|
-> `GEOIP,CN,🎯 全球直连`
|
||||||
|
* `FINAL + 🐟 漏网之鱼`
|
||||||
|
-> `MATCH,🐟 漏网之鱼`
|
||||||
|
|
||||||
|
### 说明
|
||||||
|
|
||||||
|
这里保留了 ACL4SSR 配置里 `[]GEOIP,CN`、`[]FINAL` 这种内置规则思想,只是换成更适合你项目的数据化声明。你的现有配置里已经有这两类用法。 ([GitHub][1])
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. profile 配置说明
|
||||||
|
|
||||||
|
每个 profile 一个文件,放在 `config/profiles/` 下。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7.1 基本字段
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```ini
|
||||||
|
name = 默认完整配置
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
sources = airport_main
|
||||||
|
include_modules = riglen,lan,unban,banad,apple,telegram,ai,openai
|
||||||
|
exclude_modules =
|
||||||
|
include_builtins = geoip_cn,final
|
||||||
|
```
|
||||||
|
|
||||||
|
### 含义
|
||||||
|
|
||||||
|
* `name`:profile 名称
|
||||||
|
* `enabled`:是否启用
|
||||||
|
* `sources`:本 profile 使用哪些订阅源
|
||||||
|
* `include_modules`:启用哪些模块
|
||||||
|
* `exclude_modules`:从启用列表中排除哪些模块
|
||||||
|
* `include_builtins`:启用哪些内置规则
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7.2 override_policy 指令
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
覆盖模块默认策略组。
|
||||||
|
|
||||||
|
### 格式
|
||||||
|
|
||||||
|
```ini
|
||||||
|
override_policy = module_key,new_policy
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```ini
|
||||||
|
override_policy = apple,🚀 节点选择
|
||||||
|
override_policy = ai,💬 Ai平台
|
||||||
|
```
|
||||||
|
|
||||||
|
### 含义
|
||||||
|
|
||||||
|
例如某模块默认是 `🍎 苹果服务`,某 profile 想改成 `🚀 节点选择`,就在 profile 里覆盖。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7.3 prepend_rule / append_rule 指令
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
给特定 profile 注入自定义规则。
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```ini
|
||||||
|
prepend_rule = DOMAIN-SUFFIX,internal.example.com,DIRECT
|
||||||
|
append_rule = DOMAIN-SUFFIX,test.example.com,🚀 节点选择
|
||||||
|
```
|
||||||
|
|
||||||
|
### 含义
|
||||||
|
|
||||||
|
* `prepend_rule`:插在 rules 最前
|
||||||
|
* `append_rule`:插在 builtin 之前或之后,由实现约定
|
||||||
|
|
||||||
|
### 建议
|
||||||
|
|
||||||
|
`MATCH` / `FINAL` 仍应最后输出。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 解析器行为规范
|
||||||
|
|
||||||
|
## 8.1 base.conf
|
||||||
|
|
||||||
|
解析器应支持:
|
||||||
|
|
||||||
|
* 普通标量键值
|
||||||
|
* 多条 `source`
|
||||||
|
* 多条 `selector`
|
||||||
|
* 多条 `group`
|
||||||
|
* 多条 `module`
|
||||||
|
* 多条 `builtin`
|
||||||
|
|
||||||
|
## 8.2 profile.conf
|
||||||
|
|
||||||
|
解析器应支持:
|
||||||
|
|
||||||
|
* 普通标量键值
|
||||||
|
* 多条 `override_policy`
|
||||||
|
* 多条 `prepend_rule`
|
||||||
|
* 多条 `append_rule`
|
||||||
|
|
||||||
|
## 8.3 字段 trim
|
||||||
|
|
||||||
|
建议默认:
|
||||||
|
|
||||||
|
* 去掉左右空白
|
||||||
|
* 不改动组名内部空格
|
||||||
|
* 不改动正则原文
|
||||||
|
|
||||||
|
## 8.4 布尔值
|
||||||
|
|
||||||
|
支持:
|
||||||
|
|
||||||
|
* `true/false`
|
||||||
|
* `yes/no`
|
||||||
|
* `1/0`
|
||||||
|
|
||||||
|
内部统一成布尔值。
|
||||||
|
|
||||||
|
## 8.5 列表字段
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
sources = airport_main,local_links
|
||||||
|
include_modules = apple,ai,openai
|
||||||
|
```
|
||||||
|
|
||||||
|
按逗号切分,trim 后转数组。
|
||||||
|
|
||||||
|
## 8.6 module/source 的 CSV 解析
|
||||||
|
|
||||||
|
建议用 Python `csv` 模块解析逗号分隔,而不是简单 `split(",")`,避免后续扩展时出问题。
|
||||||
|
|
||||||
|
## 8.7 group 的反引号解析
|
||||||
|
|
||||||
|
建议:
|
||||||
|
|
||||||
|
1. 去掉前缀 `group =`
|
||||||
|
2. 按反引号 `` ` `` 分段
|
||||||
|
3. 第一段为组名
|
||||||
|
4. 第二段为类型
|
||||||
|
5. 后续段按组类型解释
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 生成流程规范
|
||||||
|
|
||||||
|
生成器处理顺序建议如下:
|
||||||
|
|
||||||
|
### 第 1 步:加载 base.conf
|
||||||
|
|
||||||
|
构建:
|
||||||
|
|
||||||
|
* 源表
|
||||||
|
* selector 表
|
||||||
|
* group 定义表
|
||||||
|
* module 注册表
|
||||||
|
* builtin 注册表
|
||||||
|
|
||||||
|
### 第 2 步:加载指定 profile.conf
|
||||||
|
|
||||||
|
得到:
|
||||||
|
|
||||||
|
* 使用哪些源
|
||||||
|
* 启用哪些模块
|
||||||
|
* 启用哪些 builtin
|
||||||
|
* policy override
|
||||||
|
* prepend/append 规则
|
||||||
|
|
||||||
|
### 第 3 步:拉取并解析节点
|
||||||
|
|
||||||
|
* 拉 URL 订阅
|
||||||
|
* 读本地文件
|
||||||
|
* 统一解析为节点对象
|
||||||
|
* 去重 / 重命名 / 过滤
|
||||||
|
|
||||||
|
### 第 4 步:按 selector 构建节点集合
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
* `hk` -> 匹配香港节点
|
||||||
|
* `us` -> 匹配美国节点
|
||||||
|
* `all` -> 全部节点
|
||||||
|
|
||||||
|
### 第 5 步:构建 proxy-groups
|
||||||
|
|
||||||
|
解析 `group = ...` 指令,生成最终组定义。
|
||||||
|
|
||||||
|
### 第 6 步:构建 rules
|
||||||
|
|
||||||
|
1. 按模块顺序加载 `.list`
|
||||||
|
2. 对每行规则补上目标 policy
|
||||||
|
3. 应用 `override_policy`
|
||||||
|
4. 加入 `prepend_rule`
|
||||||
|
5. 加入 builtin
|
||||||
|
6. 加入 `append_rule`
|
||||||
|
7. 确保 `FINAL/MATCH` 最后
|
||||||
|
|
||||||
|
### 第 7 步:渲染完整 YAML
|
||||||
|
|
||||||
|
这里是生成器内部输出,不需要在配置层暴露 YAML 结构给用户。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 错误处理建议
|
||||||
|
|
||||||
|
以下情况应报错并指出行号:
|
||||||
|
|
||||||
|
* 未知指令
|
||||||
|
* `group` 语法不合法
|
||||||
|
* `module` 路径不存在
|
||||||
|
* profile 引用了不存在的 `source`
|
||||||
|
* profile 引用了不存在的 `module`
|
||||||
|
* `override_policy` 指向未知模块
|
||||||
|
* `@selector_key` 未定义
|
||||||
|
* builtin 类型不支持
|
||||||
|
|
||||||
|
以下情况可仅 warning:
|
||||||
|
|
||||||
|
* selector 匹配不到节点
|
||||||
|
* group 最终成员为空
|
||||||
|
* 模块文件为空
|
||||||
|
* 规则重复
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 兼容性策略建议
|
||||||
|
|
||||||
|
## 11.1 兼容旧 ACL4SSR 心智模型
|
||||||
|
|
||||||
|
虽然这是你自己的 DSL,但建议:
|
||||||
|
|
||||||
|
* 注释继续支持 `;`
|
||||||
|
* 保留 `ruleset -> policy` 的思想
|
||||||
|
* 保留高密度 `group` 行语法
|
||||||
|
* 保留 `GEOIP` / `FINAL` 的 builtin 概念
|
||||||
|
|
||||||
|
## 11.2 不必兼容旧关键字原样
|
||||||
|
|
||||||
|
内部不建议继续沿用:
|
||||||
|
|
||||||
|
* `ruleset=`
|
||||||
|
* `custom_proxy_group=`
|
||||||
|
|
||||||
|
建议统一改成更短的:
|
||||||
|
|
||||||
|
* `module =`
|
||||||
|
* `group =`
|
||||||
|
|
||||||
|
这样语义更清晰:
|
||||||
|
|
||||||
|
* `module` 是规则模块注册
|
||||||
|
* `group` 是代理组定义
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 最小示例
|
||||||
|
|
||||||
|
## 12.1 base.conf
|
||||||
|
|
||||||
|
```ini
|
||||||
|
listen = 0.0.0.0:3000
|
||||||
|
mode = rule
|
||||||
|
allow_lan = true
|
||||||
|
log_level = info
|
||||||
|
|
||||||
|
source = airport_main,url,https://example.com/sub?token=xxxx,enabled=true,cache_ttl=1800
|
||||||
|
|
||||||
|
selector = all,.*
|
||||||
|
selector = hk,(港|HK|hk|Hong Kong|HongKong|hongkong)
|
||||||
|
selector = us,(美|US|United States)
|
||||||
|
|
||||||
|
group = 🚀 节点选择`select`[]♻️ 自动选择`[]🇭🇰 香港节点`[]🇺🇲 美国节点`[]DIRECT
|
||||||
|
group = ♻️ 自动选择`url-test`@all`http://www.gstatic.com/generate_204`300,,50
|
||||||
|
group = 🇭🇰 香港节点`url-test`@hk`http://www.gstatic.com/generate_204`300,,50
|
||||||
|
group = 🇺🇲 美国节点`url-test`@us`http://www.gstatic.com/generate_204`300,,150
|
||||||
|
group = 🐟 漏网之鱼`select`[]DIRECT`[]🚀 节点选择
|
||||||
|
|
||||||
|
module = apple,./rules/modules/Apple.list,🍎 苹果服务,80,true
|
||||||
|
module = openai,./rules/modules/OpenAi.list,💬 Ai平台,100,true
|
||||||
|
|
||||||
|
builtin = geoip_cn,GEOIP,CN,🎯 全球直连,9000,true
|
||||||
|
builtin = final,FINAL,,🐟 漏网之鱼,9999,true
|
||||||
|
```
|
||||||
|
|
||||||
|
## 12.2 profiles/default.conf
|
||||||
|
|
||||||
|
```ini
|
||||||
|
name = 默认完整配置
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
sources = airport_main
|
||||||
|
include_modules = apple,openai
|
||||||
|
exclude_modules =
|
||||||
|
include_builtins = geoip_cn,final
|
||||||
|
|
||||||
|
override_policy = apple,🚀 节点选择
|
||||||
|
prepend_rule = DOMAIN-SUFFIX,internal.example.com,DIRECT
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 给 Codex 的实现要求
|
||||||
|
|
||||||
|
你可以直接把下面这段交给 Codex:
|
||||||
|
|
||||||
|
```md
|
||||||
|
请为 sub-provider 设计并实现一套类 ACL4SSR 风格的 INI/CONF 配置系统,不要引入 YAML 配置层。
|
||||||
|
|
||||||
|
目标:
|
||||||
|
- 使用高密度、行导向、声明式配置
|
||||||
|
- 保留 ACL4SSR 的使用习惯,但不要求 100% 兼容其原始关键字
|
||||||
|
- 规则正文继续使用 rules/modules/*.list
|
||||||
|
- 配置文件负责声明:
|
||||||
|
- source
|
||||||
|
- selector
|
||||||
|
- group
|
||||||
|
- module
|
||||||
|
- builtin
|
||||||
|
- profile
|
||||||
|
|
||||||
|
要求:
|
||||||
|
1. base.conf 负责全局配置、source、selector、group、module、builtin。
|
||||||
|
2. profiles/*.conf 负责具体输出 profile。
|
||||||
|
3. group 使用反引号分隔的 ACL4SSR-like 语法。
|
||||||
|
4. module 指向规则文件路径和默认策略组。
|
||||||
|
5. builtin 用于表达 GEOIP、FINAL 这类内置规则。
|
||||||
|
6. 生成器读取这些 conf 文件后,生成完整 Clash/Mihomo YAML。
|
||||||
|
7. 不要接数据库,不要做面板。
|
||||||
|
8. 解析器要能给出清晰的行号错误提示。
|
||||||
|
9. profile 支持 override_policy、prepend_rule、append_rule。
|
||||||
|
10. 先实现最小可用版本,再逐步扩展。
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 最终结论
|
||||||
|
|
||||||
|
你现在最适合的路线,就是:
|
||||||
|
|
||||||
|
> **规则正文继续放 `.list`**
|
||||||
|
> **配置层改成类 ACL4SSR 风格的 `.conf`**
|
||||||
|
> **Python 写一个轻量 parser + generator**
|
||||||
|
> **最终输出完整 YAML**
|
||||||
|
|
||||||
|
这条路最顺,也最符合你现在的使用习惯。
|
||||||
|
|
||||||
|
[1]: https://github.com/tindy2013/subconverter/blob/master/base/config/ACL4SSR_NoMicrosoft.ini "subconverter/base/config/ACL4SSR_NoMicrosoft.ini at master · tindy2013/subconverter · GitHub"
|
||||||
686
docs/分阶段重构方案.md
686
docs/分阶段重构方案.md
@@ -1,686 +0,0 @@
|
|||||||
# sub-provider 分阶段重构方案
|
|
||||||
|
|
||||||
## 1. 文档目标
|
|
||||||
|
|
||||||
这份文档不是重新定义“最终想做成什么”,而是基于当前 `sub-provider` 已有实现,给出一版可逐步落地、每阶段都能交付可运行结果的重构路径。
|
|
||||||
|
|
||||||
当前项目已经具备以下基础能力:
|
|
||||||
|
|
||||||
* 读取 `config/sources.yaml`
|
|
||||||
* 拉取和缓存上游订阅
|
|
||||||
* 解析 Clash YAML / base64 URI / 明文 URI 订阅
|
|
||||||
* 合并节点并生成 bundle YAML
|
|
||||||
* 生成 thin client 配置
|
|
||||||
* 加载本地规则文件并拼接 `rules`
|
|
||||||
* 透传第一个源的 `Subscription-Userinfo`
|
|
||||||
|
|
||||||
因此重构的目标不是“从零搭系统”,而是把现有静态配置驱动的生成器,逐步改造成:
|
|
||||||
|
|
||||||
* 内部模块化更清晰
|
|
||||||
* 配置存储可演进到数据库
|
|
||||||
* 对外仍以完整 YAML 为主
|
|
||||||
* 保留现有已验证能力
|
|
||||||
* 后续可平滑加管理页和快照系统
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 总体原则
|
|
||||||
|
|
||||||
### 原则 1:对外接口尽量稳定
|
|
||||||
|
|
||||||
在重构前几阶段,尽量不打断现有输出能力:
|
|
||||||
|
|
||||||
* `/bundle/{client}.yaml`
|
|
||||||
* `/clients/{client}.yaml`
|
|
||||||
* `/providers/{name}.yaml`
|
|
||||||
* `/providers/merged.yaml`
|
|
||||||
|
|
||||||
即使最终主路线偏向 bundle,也不建议在早期重构中直接删除 thin/provider 能力。
|
|
||||||
|
|
||||||
### 原则 2:先拆“配置来源”,再拆“表现层”
|
|
||||||
|
|
||||||
当前项目的核心问题不是生成逻辑完全不可用,而是配置全部堆在 `sources.yaml` 里。
|
|
||||||
优先级应是:
|
|
||||||
|
|
||||||
1. 把配置从单一 YAML 拆到更清晰的数据结构
|
|
||||||
2. 再引入数据库承载配置
|
|
||||||
3. 最后再补管理页面
|
|
||||||
|
|
||||||
不要一上来把大量时间花在页面上。
|
|
||||||
|
|
||||||
### 原则 3:规则正文继续文件化
|
|
||||||
|
|
||||||
规则文件仍然保存在项目目录中,继续使用 Git 管理。
|
|
||||||
数据库只存:
|
|
||||||
|
|
||||||
* 规则模块元数据
|
|
||||||
* 规则启用关系
|
|
||||||
* 规则顺序
|
|
||||||
* 规则绑定到哪个策略组
|
|
||||||
|
|
||||||
不把 ACL4SSR 规则逐条拆进数据库。
|
|
||||||
|
|
||||||
### 原则 4:保留动态选源能力
|
|
||||||
|
|
||||||
当前项目支持通过 `?sources=` 在请求级动态组合源,这个能力实际很有价值。
|
|
||||||
重构后不建议退化成只能在 profile 中静态绑定源。
|
|
||||||
|
|
||||||
建议模型上同时支持:
|
|
||||||
|
|
||||||
* profile 默认源集合
|
|
||||||
* 请求级 `sources` override
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 对现有项目的基线判断
|
|
||||||
|
|
||||||
### 3.1 现状优点
|
|
||||||
|
|
||||||
当前代码结构虽然还偏轻量,但核心职责已经初步分开:
|
|
||||||
|
|
||||||
* `subscriptions.py` 负责源抓取、解析、节点转换
|
|
||||||
* `profiles.py` 负责 thin / bundle 配置组装
|
|
||||||
* `rules.py` 负责规则文件加载和规则输出
|
|
||||||
* `bundle_cache.py` / `fetch_cache.py` 负责磁盘缓存
|
|
||||||
|
|
||||||
这意味着重构时应该优先“抽象和替换内部数据来源”,而不是推倒服务层。
|
|
||||||
|
|
||||||
### 3.2 现状主要问题
|
|
||||||
|
|
||||||
当前主要限制有:
|
|
||||||
|
|
||||||
* `sources.yaml` 同时承载源配置、地区分组、业务策略组、规则绑定、客户端配置
|
|
||||||
* 配置模型偏静态,不利于面板管理
|
|
||||||
* bundle 缓存仍是 TTL 模式,不是内容指纹模式
|
|
||||||
* 没有配置快照、生成快照、审计能力
|
|
||||||
* 当前没有数据库层,也没有迁移体系
|
|
||||||
|
|
||||||
### 3.3 结论
|
|
||||||
|
|
||||||
最稳的路线不是“大重写”,而是:
|
|
||||||
|
|
||||||
1. 保持现有生成骨架
|
|
||||||
2. 把配置与渲染逻辑进一步解耦
|
|
||||||
3. 再把配置落进数据库
|
|
||||||
4. 最后加面板和快照
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 目标架构
|
|
||||||
|
|
||||||
推荐演进成如下结构:
|
|
||||||
|
|
||||||
```text
|
|
||||||
请求
|
|
||||||
-> API 路由
|
|
||||||
-> Profile 解析
|
|
||||||
-> Source 选择
|
|
||||||
-> Source 拉取 / 缓存
|
|
||||||
-> 节点解析 / 标准化 / 处理
|
|
||||||
-> 策略组装配
|
|
||||||
-> 规则模块解析
|
|
||||||
-> 完整 YAML 渲染
|
|
||||||
-> 结果缓存 / 快照
|
|
||||||
-> 返回响应头与 YAML
|
|
||||||
```
|
|
||||||
|
|
||||||
配置来源则逐步从:
|
|
||||||
|
|
||||||
```text
|
|
||||||
sources.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
演进为:
|
|
||||||
|
|
||||||
```text
|
|
||||||
数据库配置 + 本地规则资产文件 + 少量默认 YAML 模板
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 分阶段路线
|
|
||||||
|
|
||||||
建议拆成五个阶段,每个阶段结束都能保留一个稳定可运行版本。
|
|
||||||
|
|
||||||
### 阶段 0:重构准备与基线冻结
|
|
||||||
|
|
||||||
目标:
|
|
||||||
|
|
||||||
* 不改行为,先明确现有能力边界
|
|
||||||
* 为后续重构建立可回归的基线
|
|
||||||
|
|
||||||
本阶段任务:
|
|
||||||
|
|
||||||
1. 盘点当前公开接口、查询参数、响应头语义
|
|
||||||
2. 记录当前 `sources.yaml` 中各配置块的职责
|
|
||||||
3. 为核心生成路径补最小测试
|
|
||||||
4. 输出一份“当前行为基线”文档
|
|
||||||
|
|
||||||
验收标准:
|
|
||||||
|
|
||||||
* 至少覆盖以下场景:
|
|
||||||
* 单源 provider
|
|
||||||
* 多源 merged provider
|
|
||||||
* bundle 输出
|
|
||||||
* thin 输出
|
|
||||||
* 第一个源配额头透传
|
|
||||||
* 有一组固定输入可验证当前输出结构不被意外破坏
|
|
||||||
|
|
||||||
建议产物:
|
|
||||||
|
|
||||||
* `tests/` 下最小回归测试
|
|
||||||
* `docs/当前行为基线.md`
|
|
||||||
|
|
||||||
说明:
|
|
||||||
|
|
||||||
这一阶段很重要。没有基线,后面数据库化后很难判断是“重构带来的结构变化”,还是“偷偷改了行为”。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 阶段 1:配置解耦,但仍以 YAML 为配置源
|
|
||||||
|
|
||||||
目标:
|
|
||||||
|
|
||||||
* 先把“一个巨大的 `sources.yaml`”拆成多个清晰模块
|
|
||||||
* 暂时不引入数据库
|
|
||||||
* 不改变线上运行方式
|
|
||||||
|
|
||||||
本阶段任务:
|
|
||||||
|
|
||||||
1. 拆分当前 `sources.yaml`:
|
|
||||||
* `config/sources.yaml`
|
|
||||||
* `config/clients.yaml`
|
|
||||||
* `config/rule-bindings.yaml`
|
|
||||||
* `config/policy-groups.yaml`
|
|
||||||
* `config/regions.yaml`
|
|
||||||
2. 新增统一配置加载层,把多个 YAML 聚合成当前运行所需的内部配置对象
|
|
||||||
3. 把当前 `AppConfig` 进一步拆开,减少“大一统模型”
|
|
||||||
4. 让 `main.py` 不再直接依赖单个全局 `app_config`
|
|
||||||
5. 把“配置加载”和“配置解释”职责从“服务逻辑”中剥离
|
|
||||||
|
|
||||||
建议目录演进:
|
|
||||||
|
|
||||||
```text
|
|
||||||
app/
|
|
||||||
config.py
|
|
||||||
main.py
|
|
||||||
models/
|
|
||||||
source.py
|
|
||||||
client.py
|
|
||||||
profile.py
|
|
||||||
rule.py
|
|
||||||
group.py
|
|
||||||
services/
|
|
||||||
config_loader.py
|
|
||||||
subscriptions.py
|
|
||||||
profiles.py
|
|
||||||
rules.py
|
|
||||||
```
|
|
||||||
|
|
||||||
验收标准:
|
|
||||||
|
|
||||||
* 对外接口路径和行为保持不变
|
|
||||||
* `bundle` 与 `thin` 输出内容结构基本一致
|
|
||||||
* 已不再依赖单个巨型 YAML 文件
|
|
||||||
|
|
||||||
这一阶段收益:
|
|
||||||
|
|
||||||
* 先把系统结构理顺
|
|
||||||
* 后续接数据库时,只需要替换配置仓储层,不需要大改生成器本身
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 阶段 2:引入数据库配置层,保留 YAML 兼容导入
|
|
||||||
|
|
||||||
目标:
|
|
||||||
|
|
||||||
* 让配置从静态文件迁移到数据库
|
|
||||||
* 但仍支持从 YAML 导入初始数据
|
|
||||||
|
|
||||||
本阶段任务:
|
|
||||||
|
|
||||||
1. 引入 SQLAlchemy 与 Alembic
|
|
||||||
2. 增加 `DATABASE_URL`
|
|
||||||
3. 建立第一批核心表
|
|
||||||
4. 实现配置仓储层
|
|
||||||
5. 提供 YAML 初始化导入脚本
|
|
||||||
|
|
||||||
推荐第一批表:
|
|
||||||
|
|
||||||
* `sources`
|
|
||||||
* `profiles`
|
|
||||||
* `profile_sources`
|
|
||||||
* `rule_modules`
|
|
||||||
* `profile_rule_modules`
|
|
||||||
* `policy_groups`
|
|
||||||
* `profile_overrides`
|
|
||||||
|
|
||||||
推荐暂不引入的表:
|
|
||||||
|
|
||||||
* `generated_artifacts`
|
|
||||||
* `sync_logs`
|
|
||||||
* `rule_module_versions`
|
|
||||||
|
|
||||||
原因:
|
|
||||||
|
|
||||||
先把“当前运行配置”迁过去,比一开始就把快照、审计、版本全部做全更稳。
|
|
||||||
|
|
||||||
#### 推荐字段设计
|
|
||||||
|
|
||||||
`sources`
|
|
||||||
|
|
||||||
* `id`
|
|
||||||
* `key`
|
|
||||||
* `name`
|
|
||||||
* `enabled`
|
|
||||||
* `kind`
|
|
||||||
* `url`
|
|
||||||
* `display_name`
|
|
||||||
* `headers_json`
|
|
||||||
* `include_regex`
|
|
||||||
* `exclude_regex`
|
|
||||||
* `prefix`
|
|
||||||
* `suffix`
|
|
||||||
* `cache_ttl_seconds`
|
|
||||||
* `created_at`
|
|
||||||
* `updated_at`
|
|
||||||
|
|
||||||
`profiles`
|
|
||||||
|
|
||||||
* `id`
|
|
||||||
* `key`
|
|
||||||
* `name`
|
|
||||||
* `client_type`
|
|
||||||
* `description`
|
|
||||||
* `enabled`
|
|
||||||
* `allow_lan`
|
|
||||||
* `ipv6`
|
|
||||||
* `mixed_port`
|
|
||||||
* `socks_port`
|
|
||||||
* `mode`
|
|
||||||
* `log_level`
|
|
||||||
* `main_policy`
|
|
||||||
* `source_policy`
|
|
||||||
* `mixed_auto_policy`
|
|
||||||
* `manual_policy`
|
|
||||||
* `direct_policy`
|
|
||||||
* `test_url`
|
|
||||||
* `test_interval`
|
|
||||||
* `provider_interval`
|
|
||||||
* `rule_interval`
|
|
||||||
* `created_at`
|
|
||||||
* `updated_at`
|
|
||||||
|
|
||||||
`profile_sources`
|
|
||||||
|
|
||||||
* `id`
|
|
||||||
* `profile_id`
|
|
||||||
* `source_id`
|
|
||||||
* `order_index`
|
|
||||||
* `enabled`
|
|
||||||
|
|
||||||
`rule_modules`
|
|
||||||
|
|
||||||
* `id`
|
|
||||||
* `key`
|
|
||||||
* `name`
|
|
||||||
* `description`
|
|
||||||
* `file_path`
|
|
||||||
* `behavior`
|
|
||||||
* `format`
|
|
||||||
* `default_policy`
|
|
||||||
* `default_no_resolve`
|
|
||||||
* `default_payload_json`
|
|
||||||
* `category`
|
|
||||||
* `created_at`
|
|
||||||
* `updated_at`
|
|
||||||
|
|
||||||
`profile_rule_modules`
|
|
||||||
|
|
||||||
* `id`
|
|
||||||
* `profile_id`
|
|
||||||
* `rule_module_id`
|
|
||||||
* `enabled`
|
|
||||||
* `order_index`
|
|
||||||
* `policy_override`
|
|
||||||
* `payload_override_json`
|
|
||||||
* `no_resolve_override`
|
|
||||||
|
|
||||||
`policy_groups`
|
|
||||||
|
|
||||||
* `id`
|
|
||||||
* `profile_id`
|
|
||||||
* `name`
|
|
||||||
* `group_kind`
|
|
||||||
* `type`
|
|
||||||
* `order_index`
|
|
||||||
* `filter_regex`
|
|
||||||
* `proxies_json`
|
|
||||||
* `url`
|
|
||||||
* `interval`
|
|
||||||
* `tolerance`
|
|
||||||
* `enabled`
|
|
||||||
|
|
||||||
这里的 `group_kind` 建议至少支持:
|
|
||||||
|
|
||||||
* `static`
|
|
||||||
* `filter`
|
|
||||||
* `generated`
|
|
||||||
|
|
||||||
这比单纯用一个 `config_json` 更适合当前项目,因为现有组装里同时存在静态组和按 regex 选节点的组。
|
|
||||||
|
|
||||||
#### 仓储层建议
|
|
||||||
|
|
||||||
新增仓储抽象,例如:
|
|
||||||
|
|
||||||
* `SourceRepository`
|
|
||||||
* `ProfileRepository`
|
|
||||||
* `RuleModuleRepository`
|
|
||||||
* `PolicyGroupRepository`
|
|
||||||
|
|
||||||
让生成服务只依赖仓储接口,而不是依赖数据库实现细节。
|
|
||||||
|
|
||||||
验收标准:
|
|
||||||
|
|
||||||
* 使用数据库也能生成与阶段 1 基本一致的 bundle/thin 配置
|
|
||||||
* 可以从原 YAML 导入初始配置
|
|
||||||
* 切换配置来源时,渲染层无需大改
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 阶段 3:渲染流水线模块化与输出主路线收敛
|
|
||||||
|
|
||||||
目标:
|
|
||||||
|
|
||||||
* 把“源处理、规则处理、组装渲染”彻底做成稳定流水线
|
|
||||||
* 明确 bundle 是主输出路线
|
|
||||||
* thin/provider 进入兼容保留状态
|
|
||||||
|
|
||||||
本阶段任务:
|
|
||||||
|
|
||||||
1. 拆分服务层职责:
|
|
||||||
* `source_fetcher.py`
|
|
||||||
* `source_parser.py`
|
|
||||||
* `proxy_processor.py`
|
|
||||||
* `policy_group_builder.py`
|
|
||||||
* `rule_resolver.py`
|
|
||||||
* `profile_renderer.py`
|
|
||||||
2. 引入统一内部节点模型
|
|
||||||
3. 引入统一内部“已解析 profile”模型
|
|
||||||
4. 将当前 `build_bundle_profile()` 的逻辑拆成多步处理
|
|
||||||
5. 清理当前 token 展开逻辑,明确保留哪些模板 token
|
|
||||||
|
|
||||||
#### 内部模型建议
|
|
||||||
|
|
||||||
不要把节点模型收得太死。建议:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class ProxyNode(BaseModel):
|
|
||||||
name: str
|
|
||||||
type: str
|
|
||||||
server: str | None = None
|
|
||||||
port: int | None = None
|
|
||||||
udp: bool = True
|
|
||||||
tags: list[str] = Field(default_factory=list)
|
|
||||||
attrs: dict[str, Any] = Field(default_factory=dict)
|
|
||||||
```
|
|
||||||
|
|
||||||
其中:
|
|
||||||
|
|
||||||
* 通用字段单独保留
|
|
||||||
* 协议特有字段放 `attrs`
|
|
||||||
* 最终输出时再按协议合并回 YAML 结构
|
|
||||||
|
|
||||||
这样比单纯的 `raw` 更可控。
|
|
||||||
|
|
||||||
#### 规则系统建议
|
|
||||||
|
|
||||||
这一阶段不要急着把所有规则文件改成 `{{ target_group }}` 模板格式。
|
|
||||||
先保留当前模式:
|
|
||||||
|
|
||||||
* 文件内容仍是 payload
|
|
||||||
* policy 在 profile 绑定关系上决定
|
|
||||||
|
|
||||||
原因:
|
|
||||||
|
|
||||||
* 当前 thin 模式的 `rule-providers` 仍依赖 payload 文件输出
|
|
||||||
* 先保留兼容性,后面再决定是否升级规则文件模板化
|
|
||||||
|
|
||||||
验收标准:
|
|
||||||
|
|
||||||
* bundle 渲染主路径清晰可测
|
|
||||||
* 服务层之间依赖方向清楚
|
|
||||||
* 配置仓储和渲染器分离
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 阶段 4:管理接口与最小面板
|
|
||||||
|
|
||||||
目标:
|
|
||||||
|
|
||||||
* 提供最小配置管理能力
|
|
||||||
* 不做复杂前后端分离
|
|
||||||
|
|
||||||
本阶段任务:
|
|
||||||
|
|
||||||
1. 增加管理 API:
|
|
||||||
* source CRUD
|
|
||||||
* profile CRUD
|
|
||||||
* rule module 查看与 profile 绑定管理
|
|
||||||
* policy group 查看与编辑
|
|
||||||
2. 增加预览接口
|
|
||||||
3. 增加最小页面:
|
|
||||||
* sources
|
|
||||||
* profiles
|
|
||||||
* rule bindings
|
|
||||||
* preview
|
|
||||||
4. 提供 profile 级 bundle 下载入口
|
|
||||||
|
|
||||||
建议接口:
|
|
||||||
|
|
||||||
* `GET /api/sources`
|
|
||||||
* `POST /api/sources`
|
|
||||||
* `PUT /api/sources/{id}`
|
|
||||||
* `GET /api/profiles`
|
|
||||||
* `POST /api/profiles`
|
|
||||||
* `PUT /api/profiles/{id}`
|
|
||||||
* `GET /api/profiles/{id}/rules`
|
|
||||||
* `PUT /api/profiles/{id}/rules`
|
|
||||||
* `GET /api/profiles/{id}/groups`
|
|
||||||
* `PUT /api/profiles/{id}/groups`
|
|
||||||
* `POST /api/profiles/{id}/preview`
|
|
||||||
* `GET /profiles/{key}/bundle.yaml`
|
|
||||||
|
|
||||||
面板技术建议:
|
|
||||||
|
|
||||||
* Jinja2
|
|
||||||
* HTMX 或最少量原生 JS
|
|
||||||
|
|
||||||
不建议此时做:
|
|
||||||
|
|
||||||
* React/Vue 前后端分离
|
|
||||||
* 复杂权限系统
|
|
||||||
* 在线全文规则编辑器
|
|
||||||
|
|
||||||
验收标准:
|
|
||||||
|
|
||||||
* 不改代码即可新增一个 source
|
|
||||||
* 不改 YAML 文件即可调整 profile 规则顺序
|
|
||||||
* 页面可直接预览 bundle 输出
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 阶段 5:快照、缓存升级与可追踪性
|
|
||||||
|
|
||||||
目标:
|
|
||||||
|
|
||||||
* 从“能生成”升级到“可追踪、可复现、可排查”
|
|
||||||
|
|
||||||
本阶段任务:
|
|
||||||
|
|
||||||
1. 增加 `generated_artifacts`
|
|
||||||
2. 增加内容指纹缓存
|
|
||||||
3. 增加源同步状态记录
|
|
||||||
4. 增加简单 diff 能力
|
|
||||||
5. 增加错误展示
|
|
||||||
|
|
||||||
推荐新增表:
|
|
||||||
|
|
||||||
`generated_artifacts`
|
|
||||||
|
|
||||||
* `id`
|
|
||||||
* `profile_id`
|
|
||||||
* `request_sources_json`
|
|
||||||
* `content`
|
|
||||||
* `content_hash`
|
|
||||||
* `source_hash`
|
|
||||||
* `rules_hash`
|
|
||||||
* `profile_hash`
|
|
||||||
* `headers_json`
|
|
||||||
* `generated_at`
|
|
||||||
|
|
||||||
`source_sync_logs`
|
|
||||||
|
|
||||||
* `id`
|
|
||||||
* `source_id`
|
|
||||||
* `status`
|
|
||||||
* `error_message`
|
|
||||||
* `response_headers_json`
|
|
||||||
* `content_hash`
|
|
||||||
* `created_at`
|
|
||||||
|
|
||||||
#### 缓存策略建议
|
|
||||||
|
|
||||||
当前项目 bundle 缓存是 TTL 模式。后续应逐步改为:
|
|
||||||
|
|
||||||
* fetch cache: TTL
|
|
||||||
* parsed snapshot cache: 源内容 hash
|
|
||||||
* bundle cache: `source_hash + rules_hash + profile_hash + request_sources`
|
|
||||||
|
|
||||||
这样才能真正做到:
|
|
||||||
|
|
||||||
* 配置没变就稳定复用
|
|
||||||
* 配置变了就精准失效
|
|
||||||
|
|
||||||
验收标准:
|
|
||||||
|
|
||||||
* 能查看某个 profile 最近几次生成记录
|
|
||||||
* 能知道本次 bundle 为什么重新生成
|
|
||||||
* 能追踪某个源最近一次同步是否失败
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 推荐实施顺序
|
|
||||||
|
|
||||||
如果按投入产出比排序,建议实际开发顺序如下:
|
|
||||||
|
|
||||||
1. 阶段 0
|
|
||||||
2. 阶段 1
|
|
||||||
3. 阶段 2
|
|
||||||
4. 阶段 3
|
|
||||||
5. 阶段 4
|
|
||||||
6. 阶段 5
|
|
||||||
|
|
||||||
其中真正的“第一版可交付里程碑”建议定在阶段 2 结束时。
|
|
||||||
|
|
||||||
因为到了阶段 2,已经具备:
|
|
||||||
|
|
||||||
* 数据库存储配置
|
|
||||||
* 可继续生成完整 YAML
|
|
||||||
* 配置不再绑死在单文件 YAML 中
|
|
||||||
|
|
||||||
这时即使还没有面板,系统内核已经完成了最关键升级。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 每阶段风险点
|
|
||||||
|
|
||||||
### 阶段 1 风险
|
|
||||||
|
|
||||||
风险:
|
|
||||||
|
|
||||||
* 拆 YAML 时容易出现字段兼容问题
|
|
||||||
|
|
||||||
控制方式:
|
|
||||||
|
|
||||||
* 先保留兼容加载层
|
|
||||||
* 老配置格式在一段时间内继续可读
|
|
||||||
|
|
||||||
### 阶段 2 风险
|
|
||||||
|
|
||||||
风险:
|
|
||||||
|
|
||||||
* 数据建模过度,导致表过多、实现过重
|
|
||||||
|
|
||||||
控制方式:
|
|
||||||
|
|
||||||
* 第一批只建运行必需表
|
|
||||||
* 快照与日志延后
|
|
||||||
|
|
||||||
### 阶段 3 风险
|
|
||||||
|
|
||||||
风险:
|
|
||||||
|
|
||||||
* 过早重写规则系统,导致 thin 模式兼容性下降
|
|
||||||
|
|
||||||
控制方式:
|
|
||||||
|
|
||||||
* 先保留 payload 模式
|
|
||||||
* 规则模板化放后续增量处理
|
|
||||||
|
|
||||||
### 阶段 4 风险
|
|
||||||
|
|
||||||
风险:
|
|
||||||
|
|
||||||
* 过早投入页面开发,拖慢主流程
|
|
||||||
|
|
||||||
控制方式:
|
|
||||||
|
|
||||||
* 先 API 后页面
|
|
||||||
* 页面只做最小可用
|
|
||||||
|
|
||||||
### 阶段 5 风险
|
|
||||||
|
|
||||||
风险:
|
|
||||||
|
|
||||||
* 快照和缓存逻辑做得太复杂,维护成本上升
|
|
||||||
|
|
||||||
控制方式:
|
|
||||||
|
|
||||||
* 先做内容哈希与生成记录
|
|
||||||
* diff 与审计能力逐步补
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 明确不建议的做法
|
|
||||||
|
|
||||||
1. 不建议一开始就删除当前 thin/provider 能力
|
|
||||||
2. 不建议一开始就把所有规则文件模板化
|
|
||||||
3. 不建议把 profile 的源集合只做成一个 `source_ids_json`
|
|
||||||
4. 不建议把策略组全塞进一个不透明 `config_json`
|
|
||||||
5. 不建议先做复杂前端再补业务内核
|
|
||||||
6. 不建议先做快照系统再做数据库配置层
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 建议的第一批实际改动
|
|
||||||
|
|
||||||
如果下一步开始真正动代码,建议先做这几件事:
|
|
||||||
|
|
||||||
1. 新建阶段 0 的测试和基线文档
|
|
||||||
2. 拆分 `sources.yaml`,完成阶段 1
|
|
||||||
3. 引入 SQLAlchemy/Alembic 和最小表结构
|
|
||||||
4. 增加 YAML -> DB 导入脚本
|
|
||||||
5. 让 bundle 渲染从数据库读取 profile / source / rules 配置
|
|
||||||
|
|
||||||
这五步完成后,再去做页面,整体节奏会稳很多。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. 一句话结论
|
|
||||||
|
|
||||||
`sub-provider` 的重构最稳路线是:
|
|
||||||
|
|
||||||
> 先做配置解耦,再做数据库配置层,再做渲染流水线固化,最后补管理页面和快照系统。
|
|
||||||
|
|
||||||
这样每一阶段都能保留一个可运行版本,也最符合当前项目已经有代码基础的现实情况。
|
|
||||||
976
docs/改造方案.md
976
docs/改造方案.md
@@ -1,976 +0,0 @@
|
|||||||
下面这份你可以直接丢给 Codex。
|
|
||||||
我把我们刚刚聊出来的**最终拍板结论**和**完整实现方案**都整理进去了。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# sub-provider 重构方案(最终版,完整 YAML 路线)
|
|
||||||
|
|
||||||
## 1. 最终结论
|
|
||||||
|
|
||||||
本项目后续重构,采用以下最终选择:
|
|
||||||
|
|
||||||
### 对外输出
|
|
||||||
|
|
||||||
* **统一输出单个完整 YAML**
|
|
||||||
* 不强依赖客户端支持 `rule-providers` / `proxy-providers`
|
|
||||||
* 目标是优先保证:
|
|
||||||
|
|
||||||
* 兼容性
|
|
||||||
* 可调试性
|
|
||||||
* 易分发
|
|
||||||
* 易维护
|
|
||||||
|
|
||||||
### 对内实现
|
|
||||||
|
|
||||||
* 内部仍然采用**模块化**设计
|
|
||||||
* 在 sub-provider 内部完成:
|
|
||||||
|
|
||||||
* 源拉取与缓存
|
|
||||||
* 节点解析与标准化
|
|
||||||
* 节点筛选、重命名、去重
|
|
||||||
* 规则模块组合
|
|
||||||
* 策略组装配
|
|
||||||
* DNS / TUN / 通用配置拼装
|
|
||||||
* 最终完整 YAML 渲染
|
|
||||||
|
|
||||||
### 规则存储方式
|
|
||||||
|
|
||||||
* **规则正文继续放项目文件**
|
|
||||||
* 采用类似 ACL4SSR 的维护方式:
|
|
||||||
|
|
||||||
* 一个类别的规则放一个文件
|
|
||||||
* 规则文件便于 Git 管理、diff、回滚、手工维护
|
|
||||||
* 不把 ACL4SSR 大量规则逐条入库
|
|
||||||
|
|
||||||
### 规则组合与面板配置
|
|
||||||
|
|
||||||
* **规则的组合关系、启用状态、绑定的策略组、顺序、参数等放 SQLite**
|
|
||||||
* 面板配置的是:
|
|
||||||
|
|
||||||
* 选哪些规则模块
|
|
||||||
* 每个模块绑定哪个策略组
|
|
||||||
* 模块顺序
|
|
||||||
* 模块参数
|
|
||||||
* profile 配置
|
|
||||||
* 源配置
|
|
||||||
* 少量 prepend / append 自定义规则
|
|
||||||
|
|
||||||
### 数据库选择
|
|
||||||
|
|
||||||
* **默认数据库:SQLite**
|
|
||||||
* 原因:
|
|
||||||
|
|
||||||
* 当前项目是单服务、轻量配置中心、生成器型应用
|
|
||||||
* SQLite 部署和迁移成本最低
|
|
||||||
* 最适合当前阶段快速落地
|
|
||||||
* 代码层要保留未来切 PostgreSQL 的能力:
|
|
||||||
|
|
||||||
* 使用 SQLAlchemy
|
|
||||||
* 使用 Alembic
|
|
||||||
* 用 `DATABASE_URL` 配置数据库连接
|
|
||||||
* 当前不推荐 Mongo 作为主库
|
|
||||||
* 当前不优先推荐 MySQL
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 目标
|
|
||||||
|
|
||||||
将现有 Python sub-provider 项目重构为一个:
|
|
||||||
|
|
||||||
* 可面板管理
|
|
||||||
* 配置解耦
|
|
||||||
* 规则模块化
|
|
||||||
* 输出完整 YAML
|
|
||||||
* 可缓存
|
|
||||||
* 可预览
|
|
||||||
* 可追踪生成结果
|
|
||||||
|
|
||||||
的配置生成系统。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 项目边界
|
|
||||||
|
|
||||||
### 本阶段要做
|
|
||||||
|
|
||||||
1. 面板化管理订阅源
|
|
||||||
2. 规则模块化
|
|
||||||
3. 规则组合关系数据库化
|
|
||||||
4. 支持多个输出 Profile
|
|
||||||
5. 服务端拼装生成完整 YAML
|
|
||||||
6. 做缓存和快照
|
|
||||||
7. 支持预览和下载
|
|
||||||
|
|
||||||
### 本阶段不做
|
|
||||||
|
|
||||||
1. 不强制对外输出 provider 模式
|
|
||||||
2. 不做“在线全文编辑 ACL4SSR 大规则文件”为主交互方式
|
|
||||||
3. 不做复杂多用户权限系统
|
|
||||||
4. 不做重型前后端分离
|
|
||||||
5. 不把所有规则全文放 SQLite
|
|
||||||
6. 不引入 Mongo 做主存储
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 架构原则
|
|
||||||
|
|
||||||
### 原则 1:外部简单,内部灵活
|
|
||||||
|
|
||||||
客户端只拿一个完整 YAML。
|
|
||||||
服务端内部怎么拆、怎么缓存、怎么组合,都由 sub-provider 负责。
|
|
||||||
|
|
||||||
### 原则 2:规则内容文件化,组合关系数据库化
|
|
||||||
|
|
||||||
规则正文属于“资产”,继续放文件。
|
|
||||||
面板管理的是“配置和组合关系”,放 SQLite。
|
|
||||||
|
|
||||||
### 原则 3:90% 的配置修改通过“选模块 + 改参数”完成
|
|
||||||
|
|
||||||
不要把面板做成“大文本框配置编辑器”。
|
|
||||||
|
|
||||||
### 原则 4:优先可维护,而不是一开始就追求炫技
|
|
||||||
|
|
||||||
先把结构跑顺,再考虑高级模式。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 总体架构
|
|
||||||
|
|
||||||
```text
|
|
||||||
输入源
|
|
||||||
-> 拉取缓存
|
|
||||||
-> 原始内容缓存
|
|
||||||
-> 解析节点
|
|
||||||
-> 节点标准化
|
|
||||||
-> 节点处理(过滤/去重/重命名/分类)
|
|
||||||
-> 策略组生成
|
|
||||||
-> 规则模块加载
|
|
||||||
-> 规则模块参数渲染
|
|
||||||
-> 规则按顺序拼接
|
|
||||||
-> 插入 prepend/append 自定义规则
|
|
||||||
-> 渲染完整 YAML
|
|
||||||
-> 结果缓存 / 生成快照
|
|
||||||
-> 提供下载 / 预览 / HEAD 信息
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 推荐技术栈
|
|
||||||
|
|
||||||
### 后端
|
|
||||||
|
|
||||||
* FastAPI
|
|
||||||
|
|
||||||
### 数据库
|
|
||||||
|
|
||||||
* SQLite(默认)
|
|
||||||
* SQLAlchemy ORM
|
|
||||||
* Alembic migration
|
|
||||||
|
|
||||||
### 模板
|
|
||||||
|
|
||||||
* Jinja2
|
|
||||||
|
|
||||||
### 前端
|
|
||||||
|
|
||||||
* 优先服务端渲染
|
|
||||||
* 可用 Jinja2 + HTMX 或简单模板页
|
|
||||||
* 先不要求 React/Vue 前后端分离
|
|
||||||
|
|
||||||
### 缓存
|
|
||||||
|
|
||||||
* 轻量场景下可先用:
|
|
||||||
|
|
||||||
* SQLite 表
|
|
||||||
* 本地文件缓存
|
|
||||||
* 不必一上来引入 Redis
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 目录结构建议
|
|
||||||
|
|
||||||
```text
|
|
||||||
app/
|
|
||||||
api/
|
|
||||||
routes_sources.py
|
|
||||||
routes_profiles.py
|
|
||||||
routes_rules.py
|
|
||||||
routes_preview.py
|
|
||||||
core/
|
|
||||||
config.py
|
|
||||||
database.py
|
|
||||||
cache.py
|
|
||||||
db/
|
|
||||||
base.py
|
|
||||||
session.py
|
|
||||||
models/
|
|
||||||
source.py
|
|
||||||
profile.py
|
|
||||||
rule_module.py
|
|
||||||
policy_group.py
|
|
||||||
artifact.py
|
|
||||||
schemas/
|
|
||||||
source.py
|
|
||||||
profile.py
|
|
||||||
rule_module.py
|
|
||||||
services/
|
|
||||||
source_fetcher.py
|
|
||||||
source_parser.py
|
|
||||||
proxy_normalizer.py
|
|
||||||
proxy_processor.py
|
|
||||||
policy_group_builder.py
|
|
||||||
rule_loader.py
|
|
||||||
rule_renderer.py
|
|
||||||
profile_renderer.py
|
|
||||||
artifact_service.py
|
|
||||||
templates/
|
|
||||||
base.html
|
|
||||||
sources.html
|
|
||||||
profiles.html
|
|
||||||
rules.html
|
|
||||||
preview.html
|
|
||||||
main.py
|
|
||||||
|
|
||||||
rules/
|
|
||||||
modules/
|
|
||||||
lan.list
|
|
||||||
private.list
|
|
||||||
cn_domain.list
|
|
||||||
cn_ip.list
|
|
||||||
apple.list
|
|
||||||
microsoft.list
|
|
||||||
github.list
|
|
||||||
telegram.list
|
|
||||||
openai.list
|
|
||||||
streaming.list
|
|
||||||
ads.list
|
|
||||||
final_proxy.list
|
|
||||||
final_direct.list
|
|
||||||
|
|
||||||
presets/
|
|
||||||
minimal.yaml
|
|
||||||
daily.yaml
|
|
||||||
router.yaml
|
|
||||||
|
|
||||||
group_templates/
|
|
||||||
basic.yaml
|
|
||||||
ai.yaml
|
|
||||||
streaming.yaml
|
|
||||||
|
|
||||||
data/
|
|
||||||
app.db
|
|
||||||
cache/
|
|
||||||
raw_sources/
|
|
||||||
parsed_sources/
|
|
||||||
generated/
|
|
||||||
artifacts/
|
|
||||||
|
|
||||||
migrations/
|
|
||||||
tests/
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 规则系统设计
|
|
||||||
|
|
||||||
## 8.1 规则正文:继续放文件
|
|
||||||
|
|
||||||
规则正文文件继续按 ACL4SSR 风格维护。
|
|
||||||
|
|
||||||
例如:
|
|
||||||
|
|
||||||
* `rules/modules/apple.list`
|
|
||||||
* `rules/modules/openai.list`
|
|
||||||
* `rules/modules/telegram.list`
|
|
||||||
* `rules/modules/cn_domain.list`
|
|
||||||
* `rules/modules/final_proxy.list`
|
|
||||||
|
|
||||||
### 推荐格式
|
|
||||||
|
|
||||||
规则文件使用模板变量,不直接写死策略组:
|
|
||||||
|
|
||||||
```text
|
|
||||||
DOMAIN-SUFFIX,openai.com,{{ target_group }}
|
|
||||||
DOMAIN-SUFFIX,chatgpt.com,{{ target_group }}
|
|
||||||
DOMAIN-SUFFIX,oaistatic.com,{{ target_group }}
|
|
||||||
DOMAIN-SUFFIX,auth0.openai.com,{{ target_group }}
|
|
||||||
```
|
|
||||||
|
|
||||||
这样同一个规则模块可复用到不同策略组。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8.2 规则模块:放 SQLite
|
|
||||||
|
|
||||||
SQLite 里存规则模块元数据,而不是规则全文。
|
|
||||||
|
|
||||||
### 表:`rule_modules`
|
|
||||||
|
|
||||||
字段建议:
|
|
||||||
|
|
||||||
* `id`
|
|
||||||
* `key`
|
|
||||||
* `name`
|
|
||||||
* `description`
|
|
||||||
* `file_path`
|
|
||||||
* `category`
|
|
||||||
* `default_enabled`
|
|
||||||
* `default_order`
|
|
||||||
* `default_target_policy`
|
|
||||||
* `params_schema_json`
|
|
||||||
* `created_at`
|
|
||||||
* `updated_at`
|
|
||||||
|
|
||||||
### 示例
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"key": "openai",
|
|
||||||
"name": "OpenAI",
|
|
||||||
"file_path": "rules/modules/openai.list",
|
|
||||||
"category": "service",
|
|
||||||
"default_enabled": true,
|
|
||||||
"default_order": 80,
|
|
||||||
"default_target_policy": "🤖 AI",
|
|
||||||
"params_schema_json": {
|
|
||||||
"target_group": {
|
|
||||||
"type": "string",
|
|
||||||
"default": "🤖 AI"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8.3 Profile 中的规则组合:放 SQLite
|
|
||||||
|
|
||||||
### 表:`profile_rule_modules`
|
|
||||||
|
|
||||||
字段建议:
|
|
||||||
|
|
||||||
* `id`
|
|
||||||
* `profile_id`
|
|
||||||
* `module_id`
|
|
||||||
* `enabled`
|
|
||||||
* `order_index`
|
|
||||||
* `target_policy`
|
|
||||||
* `params_json`
|
|
||||||
|
|
||||||
含义:
|
|
||||||
|
|
||||||
* 这个 profile 是否启用这个模块
|
|
||||||
* 顺序是什么
|
|
||||||
* 最终绑定到哪个策略组
|
|
||||||
* 参数是什么
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8.4 规则预设
|
|
||||||
|
|
||||||
可选支持“预设”。
|
|
||||||
|
|
||||||
例如:
|
|
||||||
|
|
||||||
* `minimal`
|
|
||||||
* `daily`
|
|
||||||
* `router`
|
|
||||||
|
|
||||||
但预设只作为**初始化模板**,最终仍落到具体 profile 配置里。
|
|
||||||
|
|
||||||
不要求上线初期就把预设做得很复杂。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 策略组系统设计
|
|
||||||
|
|
||||||
策略组不要硬编码死在一个大 YAML 模板里。
|
|
||||||
也要做成可配置对象。
|
|
||||||
|
|
||||||
### 表:`policy_groups`
|
|
||||||
|
|
||||||
字段建议:
|
|
||||||
|
|
||||||
* `id`
|
|
||||||
* `profile_id`
|
|
||||||
* `name`
|
|
||||||
* `type`
|
|
||||||
* `order_index`
|
|
||||||
* `config_json`
|
|
||||||
|
|
||||||
### `config_json` 示例
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"proxies": ["DIRECT", "REJECT", "香港节点", "日本节点"],
|
|
||||||
"include_all_nodes": false,
|
|
||||||
"filter": "HK|Hong Kong"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 支持的策略组类型
|
|
||||||
|
|
||||||
* `select`
|
|
||||||
* `url-test`
|
|
||||||
* `fallback`
|
|
||||||
* `load-balance`
|
|
||||||
|
|
||||||
### 建议的默认组
|
|
||||||
|
|
||||||
* `🚀 节点选择`
|
|
||||||
* `🤖 AI`
|
|
||||||
* `📺 流媒体`
|
|
||||||
* `🍎 苹果服务`
|
|
||||||
* `📲 Telegram`
|
|
||||||
* `🌍 国外网站`
|
|
||||||
* `🇨🇳 国内网站`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. 源管理设计
|
|
||||||
|
|
||||||
### 表:`sources`
|
|
||||||
|
|
||||||
字段建议:
|
|
||||||
|
|
||||||
* `id`
|
|
||||||
* `name`
|
|
||||||
* `type`
|
|
||||||
值可为:
|
|
||||||
|
|
||||||
* `base64`
|
|
||||||
* `url`
|
|
||||||
* `link`
|
|
||||||
* `static`
|
|
||||||
* `content`
|
|
||||||
* `headers_json`
|
|
||||||
* `enabled`
|
|
||||||
* `priority`
|
|
||||||
* `update_interval_sec`
|
|
||||||
* `rename_rules_json`
|
|
||||||
* `filter_rules_json`
|
|
||||||
* `dedupe_policy`
|
|
||||||
* `last_sync_at`
|
|
||||||
* `last_sync_status`
|
|
||||||
* `last_error`
|
|
||||||
* `created_at`
|
|
||||||
* `updated_at`
|
|
||||||
|
|
||||||
### 说明
|
|
||||||
|
|
||||||
这里的源是原始输入源,不是给客户端的 provider。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Profile 设计
|
|
||||||
|
|
||||||
Profile 是最终对外输出的配置单元。
|
|
||||||
|
|
||||||
例如:
|
|
||||||
|
|
||||||
* iPhone 配置
|
|
||||||
* Windows 配置
|
|
||||||
* OpenWrt 配置
|
|
||||||
* Apple TV 配置
|
|
||||||
* 精简版配置
|
|
||||||
* 完整版配置
|
|
||||||
|
|
||||||
### 表:`profiles`
|
|
||||||
|
|
||||||
字段建议:
|
|
||||||
|
|
||||||
* `id`
|
|
||||||
* `name`
|
|
||||||
* `description`
|
|
||||||
* `source_ids_json`
|
|
||||||
* `dns_template`
|
|
||||||
* `tun_enabled`
|
|
||||||
* `udp_enabled`
|
|
||||||
* `append_subscription_info`
|
|
||||||
* `expose_head_info`
|
|
||||||
* `enabled`
|
|
||||||
* `created_at`
|
|
||||||
* `updated_at`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. 自定义覆盖设计
|
|
||||||
|
|
||||||
为了兼容高级用户需求,支持少量覆盖,但不做全文编辑器。
|
|
||||||
|
|
||||||
### 表:`profile_overrides`
|
|
||||||
|
|
||||||
字段建议:
|
|
||||||
|
|
||||||
* `id`
|
|
||||||
* `profile_id`
|
|
||||||
* `custom_rules_prepend`
|
|
||||||
* `custom_rules_append`
|
|
||||||
* `custom_proxy_groups`
|
|
||||||
* `custom_dns_patch`
|
|
||||||
* `custom_yaml_patch`
|
|
||||||
|
|
||||||
### 原则
|
|
||||||
|
|
||||||
只提供:
|
|
||||||
|
|
||||||
* prepend rules
|
|
||||||
* append rules
|
|
||||||
* 少量 patch
|
|
||||||
|
|
||||||
不鼓励直接在面板里手写全部 YAML。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. 缓存设计
|
|
||||||
|
|
||||||
## 13.1 原始源缓存
|
|
||||||
|
|
||||||
缓存拉回来的原始内容。
|
|
||||||
|
|
||||||
目的:
|
|
||||||
|
|
||||||
* 避免频繁请求订阅源
|
|
||||||
* 降低上游压力
|
|
||||||
* 出问题可复现
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13.2 解析结果缓存
|
|
||||||
|
|
||||||
把原始内容解析为统一节点模型后缓存。
|
|
||||||
|
|
||||||
目的:
|
|
||||||
|
|
||||||
* 多 profile 复用解析结果
|
|
||||||
* 避免重复解析
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13.3 最终 YAML 缓存
|
|
||||||
|
|
||||||
同一个 profile 在以下内容不变时,直接返回缓存:
|
|
||||||
|
|
||||||
* 订阅源内容未变
|
|
||||||
* profile 配置未变
|
|
||||||
* 规则模块未变
|
|
||||||
* 策略组未变
|
|
||||||
|
|
||||||
缓存键建议基于 hash:
|
|
||||||
|
|
||||||
* 源快照 hash
|
|
||||||
* 规则快照 hash
|
|
||||||
* profile 配置 hash
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. 生成快照设计
|
|
||||||
|
|
||||||
### 表:`generated_artifacts`
|
|
||||||
|
|
||||||
字段建议:
|
|
||||||
|
|
||||||
* `id`
|
|
||||||
* `profile_id`
|
|
||||||
* `version`
|
|
||||||
* `content`
|
|
||||||
* `content_hash`
|
|
||||||
* `source_snapshot_json`
|
|
||||||
* `rule_snapshot_json`
|
|
||||||
* `generated_at`
|
|
||||||
|
|
||||||
### 用途
|
|
||||||
|
|
||||||
* 预览
|
|
||||||
* diff
|
|
||||||
* 回滚
|
|
||||||
* 调试
|
|
||||||
* 追踪“为什么这次生成结果变了”
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 15. 内部统一节点模型
|
|
||||||
|
|
||||||
建议在解析层统一成一个内部对象。
|
|
||||||
|
|
||||||
### Python 草案
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
class ProxyNode(BaseModel):
|
|
||||||
name: str
|
|
||||||
type: str
|
|
||||||
server: str
|
|
||||||
port: int
|
|
||||||
udp: bool = True
|
|
||||||
tags: list[str] = []
|
|
||||||
raw: dict[str, Any] = {}
|
|
||||||
```
|
|
||||||
|
|
||||||
对不同类型协议(ss、vmess、vless、trojan、hysteria 等)额外字段可放在 `raw` 里。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 16. 生成流水线
|
|
||||||
|
|
||||||
## 第 1 步:拉取源
|
|
||||||
|
|
||||||
* 读取启用的 sources
|
|
||||||
* 获取原始订阅内容
|
|
||||||
* 更新缓存
|
|
||||||
* 记录同步状态
|
|
||||||
|
|
||||||
## 第 2 步:解析源
|
|
||||||
|
|
||||||
* 解析 base64
|
|
||||||
* 解析单链接
|
|
||||||
* 解析远程订阅
|
|
||||||
* 标准化成统一节点模型
|
|
||||||
|
|
||||||
## 第 3 步:节点处理
|
|
||||||
|
|
||||||
* 去重
|
|
||||||
* 重命名
|
|
||||||
* 按国家/协议/标签分类
|
|
||||||
* 过滤无效节点
|
|
||||||
* 应用 profile 的筛选逻辑
|
|
||||||
|
|
||||||
## 第 4 步:生成策略组
|
|
||||||
|
|
||||||
* 读取 profile 绑定的 policy groups
|
|
||||||
* 根据节点分类与过滤规则生成 `proxy-groups`
|
|
||||||
|
|
||||||
## 第 5 步:加载规则模块
|
|
||||||
|
|
||||||
* 读取 profile 启用的规则模块
|
|
||||||
* 按顺序加载规则文件
|
|
||||||
* 渲染模板变量(如 `target_group`)
|
|
||||||
|
|
||||||
## 第 6 步:组装规则
|
|
||||||
|
|
||||||
* 拼接所有规则模块
|
|
||||||
* 插入 prepend rules
|
|
||||||
* 插入 append rules
|
|
||||||
* 保证 `MATCH` 规则最后输出
|
|
||||||
|
|
||||||
## 第 7 步:生成完整 YAML
|
|
||||||
|
|
||||||
输出包含:
|
|
||||||
|
|
||||||
* mixed-port / socks-port / redir-port / tproxy-port(按需)
|
|
||||||
* mode
|
|
||||||
* log-level
|
|
||||||
* dns
|
|
||||||
* tun
|
|
||||||
* proxies
|
|
||||||
* proxy-groups
|
|
||||||
* rules
|
|
||||||
|
|
||||||
## 第 8 步:缓存与快照
|
|
||||||
|
|
||||||
* 计算 hash
|
|
||||||
* 命中缓存则复用
|
|
||||||
* 否则生成新 artifact
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 17. 输出设计
|
|
||||||
|
|
||||||
### 对外输出接口
|
|
||||||
|
|
||||||
* `/profiles/{id}/clash.yaml`
|
|
||||||
* `/profiles/{id}/download`
|
|
||||||
* `/profiles/{id}/preview`
|
|
||||||
* `/profiles/{id}/head`
|
|
||||||
|
|
||||||
### 输出原则
|
|
||||||
|
|
||||||
* 默认返回完整 YAML
|
|
||||||
* 对客户端透明
|
|
||||||
* 客户端无需理解内部模块化结构
|
|
||||||
|
|
||||||
### 可选响应头
|
|
||||||
|
|
||||||
支持将源站订阅信息透传到最终响应头,例如:
|
|
||||||
|
|
||||||
* `subscription-userinfo`
|
|
||||||
* 其他流量额度相关头
|
|
||||||
|
|
||||||
如果多个源混合:
|
|
||||||
|
|
||||||
* 默认只透传第一个主源的额度信息
|
|
||||||
* 或后续设计聚合策略,但不是当前必做
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 18. API 草案
|
|
||||||
|
|
||||||
## 源管理
|
|
||||||
|
|
||||||
* `GET /api/sources`
|
|
||||||
* `POST /api/sources`
|
|
||||||
* `PUT /api/sources/{id}`
|
|
||||||
* `DELETE /api/sources/{id}`
|
|
||||||
* `POST /api/sources/{id}/test`
|
|
||||||
* `POST /api/sources/{id}/sync`
|
|
||||||
|
|
||||||
## 规则模块
|
|
||||||
|
|
||||||
* `GET /api/rule-modules`
|
|
||||||
* `GET /api/rule-modules/{id}`
|
|
||||||
* `PUT /api/rule-modules/{id}`
|
|
||||||
|
|
||||||
## Profile
|
|
||||||
|
|
||||||
* `GET /api/profiles`
|
|
||||||
* `POST /api/profiles`
|
|
||||||
* `PUT /api/profiles/{id}`
|
|
||||||
* `DELETE /api/profiles/{id}`
|
|
||||||
|
|
||||||
## Profile 规则配置
|
|
||||||
|
|
||||||
* `GET /api/profiles/{id}/rules`
|
|
||||||
* `PUT /api/profiles/{id}/rules`
|
|
||||||
|
|
||||||
## Profile 策略组配置
|
|
||||||
|
|
||||||
* `GET /api/profiles/{id}/policy-groups`
|
|
||||||
* `PUT /api/profiles/{id}/policy-groups`
|
|
||||||
|
|
||||||
## 预览与生成
|
|
||||||
|
|
||||||
* `POST /api/profiles/{id}/preview`
|
|
||||||
* `POST /api/profiles/{id}/generate`
|
|
||||||
* `GET /api/profiles/{id}/artifacts`
|
|
||||||
* `GET /api/artifacts/{id}`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 19. 面板设计原则
|
|
||||||
|
|
||||||
### 普通模式
|
|
||||||
|
|
||||||
允许:
|
|
||||||
|
|
||||||
* 配源
|
|
||||||
* 选 profile
|
|
||||||
* 勾选规则模块
|
|
||||||
* 选择规则模块绑定的策略组
|
|
||||||
* 调模块顺序
|
|
||||||
* 加 prepend / append 规则
|
|
||||||
* 预览生成结果
|
|
||||||
|
|
||||||
### 高级模式
|
|
||||||
|
|
||||||
允许:
|
|
||||||
|
|
||||||
* 编辑少量覆盖配置
|
|
||||||
* 自定义组模板
|
|
||||||
* 导入自定义规则模块
|
|
||||||
|
|
||||||
### 不建议
|
|
||||||
|
|
||||||
* 直接在线编辑整份 ACL4SSR 规则大文件作为主要操作方式
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 20. 示例规则模块
|
|
||||||
|
|
||||||
## `rules/modules/openai.list`
|
|
||||||
|
|
||||||
```text
|
|
||||||
DOMAIN-SUFFIX,openai.com,{{ target_group }}
|
|
||||||
DOMAIN-SUFFIX,chatgpt.com,{{ target_group }}
|
|
||||||
DOMAIN-SUFFIX,oaistatic.com,{{ target_group }}
|
|
||||||
DOMAIN-SUFFIX,auth0.openai.com,{{ target_group }}
|
|
||||||
```
|
|
||||||
|
|
||||||
## `rules/modules/apple.list`
|
|
||||||
|
|
||||||
```text
|
|
||||||
DOMAIN-SUFFIX,apple.com,{{ target_group }}
|
|
||||||
DOMAIN-SUFFIX,icloud.com,{{ target_group }}
|
|
||||||
DOMAIN-SUFFIX,itunes.apple.com,{{ target_group }}
|
|
||||||
DOMAIN-SUFFIX,apps.apple.com,{{ target_group }}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 21. 示例最终输出 YAML 骨架
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
mixed-port: 7890
|
|
||||||
allow-lan: true
|
|
||||||
mode: rule
|
|
||||||
log-level: info
|
|
||||||
|
|
||||||
dns:
|
|
||||||
enable: true
|
|
||||||
ipv6: false
|
|
||||||
nameserver:
|
|
||||||
- 223.5.5.5
|
|
||||||
- 119.29.29.29
|
|
||||||
|
|
||||||
proxies:
|
|
||||||
- name: 香港节点 01
|
|
||||||
type: ss
|
|
||||||
server: 1.2.3.4
|
|
||||||
port: 443
|
|
||||||
cipher: aes-128-gcm
|
|
||||||
password: xxxx
|
|
||||||
|
|
||||||
proxy-groups:
|
|
||||||
- name: 🚀 节点选择
|
|
||||||
type: select
|
|
||||||
proxies:
|
|
||||||
- 香港节点 01
|
|
||||||
- 日本节点 01
|
|
||||||
- DIRECT
|
|
||||||
|
|
||||||
- name: 🤖 AI
|
|
||||||
type: select
|
|
||||||
proxies:
|
|
||||||
- 🚀 节点选择
|
|
||||||
- 香港节点 01
|
|
||||||
- 日本节点 01
|
|
||||||
|
|
||||||
rules:
|
|
||||||
- DOMAIN-SUFFIX,openai.com,🤖 AI
|
|
||||||
- DOMAIN-SUFFIX,chatgpt.com,🤖 AI
|
|
||||||
- DOMAIN-SUFFIX,apple.com,🍎 苹果服务
|
|
||||||
- GEOIP,CN,DIRECT
|
|
||||||
- MATCH,🚀 节点选择
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 22. 数据库配置要求
|
|
||||||
|
|
||||||
### 默认
|
|
||||||
|
|
||||||
```env
|
|
||||||
DATABASE_URL=sqlite:///./data/app.db
|
|
||||||
```
|
|
||||||
|
|
||||||
### 未来切 PostgreSQL
|
|
||||||
|
|
||||||
```env
|
|
||||||
DATABASE_URL=postgresql+psycopg://user:pass@postgres:5432/subprovider
|
|
||||||
```
|
|
||||||
|
|
||||||
### 要求
|
|
||||||
|
|
||||||
* 所有数据库访问都走 SQLAlchemy
|
|
||||||
* migration 走 Alembic
|
|
||||||
* 不写死 SQLite 特性到业务逻辑中
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 23. 开发阶段建议
|
|
||||||
|
|
||||||
## 第一阶段:解耦与最小可用
|
|
||||||
|
|
||||||
目标:
|
|
||||||
|
|
||||||
* 先跑通结构
|
|
||||||
|
|
||||||
任务:
|
|
||||||
|
|
||||||
1. 引入 SQLAlchemy + Alembic
|
|
||||||
2. 建 sources / profiles / rule_modules / profile_rule_modules / policy_groups / artifacts 表
|
|
||||||
3. 将现有规则整理为 `rules/modules/*.list`
|
|
||||||
4. 将现有单体生成逻辑拆成 service
|
|
||||||
5. 支持生成一个完整 YAML
|
|
||||||
6. 提供最简预览页面
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 第二阶段:面板化
|
|
||||||
|
|
||||||
目标:
|
|
||||||
|
|
||||||
* 基础可用
|
|
||||||
|
|
||||||
任务:
|
|
||||||
|
|
||||||
1. 源管理页面
|
|
||||||
2. Profile 管理页面
|
|
||||||
3. 规则模块勾选和排序页面
|
|
||||||
4. 策略组配置页面
|
|
||||||
5. 预览和下载页面
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 第三阶段:增强能力
|
|
||||||
|
|
||||||
目标:
|
|
||||||
|
|
||||||
* 更稳定、更可追踪
|
|
||||||
|
|
||||||
任务:
|
|
||||||
|
|
||||||
1. 快照与 diff
|
|
||||||
2. 生成缓存优化
|
|
||||||
3. 同步日志和错误展示
|
|
||||||
4. 自定义规则导入
|
|
||||||
5. 自定义组模板
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 24. 明确不推荐的方向
|
|
||||||
|
|
||||||
1. 不推荐把 ACL4SSR 规则逐条入 SQLite
|
|
||||||
2. 不推荐面板主交互是“编辑一万条规则文本”
|
|
||||||
3. 不推荐一开始就做 rule-provider / proxy-provider 对外发布
|
|
||||||
4. 不推荐 Mongo 作为主配置库
|
|
||||||
5. 不推荐先做复杂权限系统
|
|
||||||
6. 不推荐先做重前端再补业务逻辑
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 25. 给 Codex 的明确执行要求
|
|
||||||
|
|
||||||
请按以下要求重构项目:
|
|
||||||
|
|
||||||
1. 保留对外输出为单个完整 YAML
|
|
||||||
2. 将内部生成逻辑改为模块化流水线
|
|
||||||
3. 规则正文继续使用文件存储
|
|
||||||
4. 使用 SQLite 存储:
|
|
||||||
|
|
||||||
* 源配置
|
|
||||||
* profile 配置
|
|
||||||
* 规则模块元数据
|
|
||||||
* profile 与规则模块的绑定关系
|
|
||||||
* 策略组配置
|
|
||||||
* 生成快照
|
|
||||||
5. 使用 SQLAlchemy + Alembic,数据库 URL 可配置
|
|
||||||
6. 代码结构按 service 分层,避免单文件堆逻辑
|
|
||||||
7. 首先实现最小可运行版本:
|
|
||||||
|
|
||||||
* 能配置 source
|
|
||||||
* 能配置 profile
|
|
||||||
* 能选择规则模块
|
|
||||||
* 能选择目标策略组
|
|
||||||
* 能生成并预览完整 YAML
|
|
||||||
8. 不要实现对外 provider 模式作为默认路线
|
|
||||||
9. 不要将规则正文全文入库
|
|
||||||
10. 不要将面板设计成规则全文编辑器
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 26. 最终一句话总结
|
|
||||||
|
|
||||||
本项目的最终路线是:
|
|
||||||
|
|
||||||
> **内部模块化、缓存化、可配置化**
|
|
||||||
> **对外输出完整单文件 YAML**
|
|
||||||
> **规则正文继续文件维护**
|
|
||||||
> **规则组合关系和面板配置放 SQLite**
|
|
||||||
|
|
||||||
这条路线最符合当前项目阶段,也最稳。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
如果你要,我下一条可以继续给你一份**更像任务单的 Codex 执行拆分版**,我会按“先改哪些文件、再改哪些文件、每步验收什么”来写。
|
|
||||||
@@ -3,8 +3,3 @@ uvicorn[standard]>=0.30,<1.0
|
|||||||
httpx>=0.27,<1.0
|
httpx>=0.27,<1.0
|
||||||
PyYAML>=6.0,<7.0
|
PyYAML>=6.0,<7.0
|
||||||
pydantic-settings>=2.3,<3.0
|
pydantic-settings>=2.3,<3.0
|
||||||
SQLAlchemy>=2.0,<3.0
|
|
||||||
alembic>=1.13,<2.0
|
|
||||||
Jinja2>=3.1,<4.0
|
|
||||||
python-multipart>=0.0.9,<1.0
|
|
||||||
itsdangerous>=2.2,<3.0
|
|
||||||
|
|||||||
195
scripts/compare_conf_outputs.py
Normal file
195
scripts/compare_conf_outputs.py
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
if str(ROOT_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT_DIR))
|
||||||
|
|
||||||
|
|
||||||
|
def _write_sample_source(path: Path) -> None:
|
||||||
|
path.write_text(
|
||||||
|
"\n".join(
|
||||||
|
[
|
||||||
|
"proxies:",
|
||||||
|
" - name: hk-demo",
|
||||||
|
" type: ss",
|
||||||
|
" server: 1.1.1.1",
|
||||||
|
" port: 443",
|
||||||
|
" cipher: aes-128-gcm",
|
||||||
|
" password: demo-pass",
|
||||||
|
" - name: us-demo",
|
||||||
|
" type: ss",
|
||||||
|
" server: 2.2.2.2",
|
||||||
|
" port: 443",
|
||||||
|
" cipher: aes-128-gcm",
|
||||||
|
" password: demo-pass",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_yaml(text: str) -> dict[str, Any]:
|
||||||
|
data = yaml.safe_load(text)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError("response is not a yaml mapping")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _list_names(items: list[dict[str, Any]] | None) -> list[str]:
|
||||||
|
names: list[str] = []
|
||||||
|
for item in items or []:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
name = item.get("name")
|
||||||
|
if isinstance(name, str) and name.strip():
|
||||||
|
names.append(name)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping_keys(mapping: dict[str, Any] | None) -> list[str]:
|
||||||
|
if not isinstance(mapping, dict):
|
||||||
|
return []
|
||||||
|
return sorted(mapping.keys())
|
||||||
|
|
||||||
|
|
||||||
|
def _group_map(groups: list[dict[str, Any]] | None) -> dict[str, dict[str, Any]]:
|
||||||
|
result: dict[str, dict[str, Any]] = {}
|
||||||
|
for group in groups or []:
|
||||||
|
if not isinstance(group, dict):
|
||||||
|
continue
|
||||||
|
name = group.get("name")
|
||||||
|
if isinstance(name, str) and name.strip():
|
||||||
|
result[name] = group
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_summary(data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"proxy_provider_keys": _mapping_keys(data.get("proxy-providers")),
|
||||||
|
"rule_provider_keys": _mapping_keys(data.get("rule-providers")),
|
||||||
|
"proxy_group_names": _list_names(data.get("proxy-groups")),
|
||||||
|
"proxy_names": _list_names(data.get("proxies")),
|
||||||
|
"rules_count": len(data.get("rules") or []),
|
||||||
|
"rules_head": list(data.get("rules") or [])[:5],
|
||||||
|
"rules_tail": list(data.get("rules") or [])[-5:],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _compare_group_members(legacy_groups: list[dict[str, Any]], conf_groups: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
mismatches: list[dict[str, Any]] = []
|
||||||
|
legacy_map = _group_map(legacy_groups)
|
||||||
|
conf_map = _group_map(conf_groups)
|
||||||
|
for name in sorted(set(legacy_map) & set(conf_map)):
|
||||||
|
legacy = legacy_map[name]
|
||||||
|
conf = conf_map[name]
|
||||||
|
legacy_members = legacy.get("proxies") or legacy.get("use") or []
|
||||||
|
conf_members = conf.get("proxies") or conf.get("use") or []
|
||||||
|
if legacy_members != conf_members:
|
||||||
|
mismatches.append(
|
||||||
|
{
|
||||||
|
"group": name,
|
||||||
|
"legacy_members": legacy_members,
|
||||||
|
"conf_members": conf_members,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return mismatches
|
||||||
|
|
||||||
|
|
||||||
|
def compare_outputs(client_type: str, profile_key: str, sources: str, *, sample_source: Path | None) -> dict[str, Any]:
|
||||||
|
if sample_source is not None:
|
||||||
|
os.environ["AIRPORT_A_URL"] = str(sample_source)
|
||||||
|
os.environ["AIRPORT_B_URL"] = str(sample_source)
|
||||||
|
os.environ["AIRPORT_C_URL"] = str(sample_source)
|
||||||
|
|
||||||
|
from app.main import PUBLIC_PREFIX, app
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
legacy_thin = client.get(f"{PUBLIC_PREFIX}/clients/{client_type}.yaml?sources={sources}")
|
||||||
|
legacy_bundle = client.get(f"{PUBLIC_PREFIX}/bundle/{client_type}.yaml?sources={sources}&force_refresh=true")
|
||||||
|
conf_thin = client.get(f"{PUBLIC_PREFIX}/conf/clients/{client_type}/{profile_key}.yaml")
|
||||||
|
conf_bundle = client.get(f"{PUBLIC_PREFIX}/conf/bundle/{client_type}/{profile_key}.yaml?force_refresh=true")
|
||||||
|
|
||||||
|
responses = {
|
||||||
|
"legacy_thin": legacy_thin,
|
||||||
|
"legacy_bundle": legacy_bundle,
|
||||||
|
"conf_thin": conf_thin,
|
||||||
|
"conf_bundle": conf_bundle,
|
||||||
|
}
|
||||||
|
for name, response in responses.items():
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise RuntimeError(f"{name} returned {response.status_code}: {response.text}")
|
||||||
|
|
||||||
|
legacy_thin_yaml = _load_yaml(legacy_thin.text)
|
||||||
|
legacy_bundle_yaml = _load_yaml(legacy_bundle.text)
|
||||||
|
conf_thin_yaml = _load_yaml(conf_thin.text)
|
||||||
|
conf_bundle_yaml = _load_yaml(conf_bundle.text)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"inputs": {
|
||||||
|
"client_type": client_type,
|
||||||
|
"profile_key": profile_key,
|
||||||
|
"sources": sources,
|
||||||
|
},
|
||||||
|
"thin": {
|
||||||
|
"legacy": _extract_summary(legacy_thin_yaml),
|
||||||
|
"conf": _extract_summary(conf_thin_yaml),
|
||||||
|
"group_member_mismatches": _compare_group_members(
|
||||||
|
legacy_thin_yaml.get("proxy-groups") or [],
|
||||||
|
conf_thin_yaml.get("proxy-groups") or [],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"legacy": _extract_summary(legacy_bundle_yaml),
|
||||||
|
"conf": _extract_summary(conf_bundle_yaml),
|
||||||
|
"group_member_mismatches": _compare_group_members(
|
||||||
|
legacy_bundle_yaml.get("proxy-groups") or [],
|
||||||
|
conf_bundle_yaml.get("proxy-groups") or [],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result["thin"]["proxy_provider_match"] = result["thin"]["legacy"]["proxy_provider_keys"] == result["thin"]["conf"]["proxy_provider_keys"]
|
||||||
|
result["thin"]["rule_provider_match"] = result["thin"]["legacy"]["rule_provider_keys"] == result["thin"]["conf"]["rule_provider_keys"]
|
||||||
|
result["thin"]["group_name_match"] = result["thin"]["legacy"]["proxy_group_names"] == result["thin"]["conf"]["proxy_group_names"]
|
||||||
|
result["bundle"]["group_name_match"] = result["bundle"]["legacy"]["proxy_group_names"] == result["bundle"]["conf"]["proxy_group_names"]
|
||||||
|
result["bundle"]["proxy_count_match"] = len(result["bundle"]["legacy"]["proxy_names"]) == len(result["bundle"]["conf"]["proxy_names"])
|
||||||
|
result["bundle"]["rules_count_match"] = result["bundle"]["legacy"]["rules_count"] == result["bundle"]["conf"]["rules_count"]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Compare legacy YAML routes with conf routes")
|
||||||
|
parser.add_argument("--client-type", default="mihomo")
|
||||||
|
parser.add_argument("--profile", default="default")
|
||||||
|
parser.add_argument("--sources", default="airport-a,airport-b")
|
||||||
|
parser.add_argument("--sample-source", default="")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.sample_source:
|
||||||
|
sample_source = Path(args.sample_source).resolve()
|
||||||
|
else:
|
||||||
|
sample_source = None
|
||||||
|
|
||||||
|
if sample_source is None:
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
generated = Path(tmp) / "sample-proxies.yaml"
|
||||||
|
_write_sample_source(generated)
|
||||||
|
result = compare_outputs(args.client_type, args.profile, args.sources, sample_source=generated)
|
||||||
|
else:
|
||||||
|
result = compare_outputs(args.client_type, args.profile, args.sources, sample_source=sample_source)
|
||||||
|
|
||||||
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>sub-provider admin</title>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg: #f3efe6;
|
|
||||||
--panel: #fffdf8;
|
|
||||||
--ink: #1f2a2e;
|
|
||||||
--muted: #66757f;
|
|
||||||
--line: #d7ccbb;
|
|
||||||
--accent: #b8542a;
|
|
||||||
--accent-soft: #f0d7c5;
|
|
||||||
}
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: "Segoe UI", "PingFang SC", sans-serif;
|
|
||||||
color: var(--ink);
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at top left, #f7d9c5, transparent 28%),
|
|
||||||
radial-gradient(circle at bottom right, #dce8da, transparent 22%),
|
|
||||||
var(--bg);
|
|
||||||
}
|
|
||||||
a { color: inherit; }
|
|
||||||
.shell {
|
|
||||||
max-width: 1320px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 24px;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 240px minmax(0, 1fr);
|
|
||||||
gap: 24px;
|
|
||||||
}
|
|
||||||
.nav, .panel {
|
|
||||||
background: rgba(255, 253, 248, 0.92);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 24px;
|
|
||||||
box-shadow: 0 20px 40px rgba(31, 42, 46, 0.08);
|
|
||||||
}
|
|
||||||
.nav { padding: 18px; align-self: start; position: sticky; top: 18px; }
|
|
||||||
.brand { font-size: 24px; font-weight: 700; margin-bottom: 20px; }
|
|
||||||
.nav a {
|
|
||||||
display: block;
|
|
||||||
text-decoration: none;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
.nav a.active {
|
|
||||||
background: var(--accent);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.nav form { margin-top: 18px; }
|
|
||||||
.panel { padding: 24px; }
|
|
||||||
h1, h2 { margin-top: 0; }
|
|
||||||
.muted { color: var(--muted); }
|
|
||||||
.stack { display: grid; gap: 20px; }
|
|
||||||
.grid-2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
|
||||||
.card {
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 18px;
|
|
||||||
padding: 18px;
|
|
||||||
background: var(--panel);
|
|
||||||
}
|
|
||||||
label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 6px; }
|
|
||||||
input, select, textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
background: #fff;
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
textarea { min-height: 140px; resize: vertical; }
|
|
||||||
table { width: 100%; border-collapse: collapse; }
|
|
||||||
th, td { padding: 12px 10px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
|
|
||||||
th { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; }
|
|
||||||
.actions { display: flex; gap: 10px; flex-wrap: wrap; }
|
|
||||||
button, .button {
|
|
||||||
border: 0;
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 10px 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
background: var(--accent);
|
|
||||||
color: #fff;
|
|
||||||
text-decoration: none;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
.button.secondary, button.secondary {
|
|
||||||
background: var(--accent-soft);
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
.flash {
|
|
||||||
padding: 12px 14px;
|
|
||||||
border-radius: 14px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
margin-bottom: 18px;
|
|
||||||
}
|
|
||||||
.flash.success { background: #e8f3e6; border-color: #b7d3b1; }
|
|
||||||
.flash.error { background: #f7dfdb; border-color: #ddb0a7; }
|
|
||||||
.inline { display: inline-flex; align-items: center; gap: 8px; }
|
|
||||||
.wide { width: 100%; min-height: 65vh; font-family: Consolas, monospace; }
|
|
||||||
@media (max-width: 920px) {
|
|
||||||
.shell { grid-template-columns: 1fr; }
|
|
||||||
.nav { position: static; }
|
|
||||||
.grid-2 { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="shell">
|
|
||||||
<nav class="nav">
|
|
||||||
<div class="brand">sub-provider</div>
|
|
||||||
{% if authenticated %}
|
|
||||||
<a href="/admin/sources" class="{% if active == 'sources' %}active{% endif %}">Sources</a>
|
|
||||||
<a href="/admin/profiles" class="{% if active == 'profiles' %}active{% endif %}">Profiles</a>
|
|
||||||
{% if auth_enabled %}
|
|
||||||
<form method="post" action="/admin/logout">
|
|
||||||
<button type="submit" class="secondary" style="width:100%;">Logout</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
</nav>
|
|
||||||
<main class="panel">
|
|
||||||
{% if flash %}
|
|
||||||
<div class="flash {{ flash.level }}">{{ flash.message }}</div>
|
|
||||||
{% endif %}
|
|
||||||
{% block content %}{% endblock %}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="stack">
|
|
||||||
<div class="actions" style="justify-content:space-between; align-items:center;">
|
|
||||||
<div>
|
|
||||||
<h1>Groups: {{ profile_key }}</h1>
|
|
||||||
<p class="muted">管理 region / selector / policy groups。当前版本以整表提交为主。</p>
|
|
||||||
</div>
|
|
||||||
<div class="actions">
|
|
||||||
<a class="button secondary" href="/admin/profiles">Back</a>
|
|
||||||
<a class="button" href="/admin/profiles/{{ profile_key }}/preview">Preview</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form method="post" action="/admin/profiles/{{ profile_key }}/groups" class="stack">
|
|
||||||
{% for group in groups %}
|
|
||||||
<div class="card">
|
|
||||||
<input type="hidden" name="group_id" value="{{ group.id }}">
|
|
||||||
<div class="grid-2">
|
|
||||||
<div>
|
|
||||||
<label>Name</label>
|
|
||||||
<input name="group_name_{{ loop.index0 }}" value="{{ group.name }}">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Kind</label>
|
|
||||||
<select name="group_kind_{{ loop.index0 }}">
|
|
||||||
<option value="region" {% if group.group_kind == 'region' %}selected{% endif %}>region</option>
|
|
||||||
<option value="selector" {% if group.group_kind == 'selector' %}selected{% endif %}>selector</option>
|
|
||||||
<option value="policy" {% if group.group_kind == 'policy' %}selected{% endif %}>policy</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Type</label>
|
|
||||||
<select name="type_{{ loop.index0 }}">
|
|
||||||
<option value="select" {% if group.type == 'select' %}selected{% endif %}>select</option>
|
|
||||||
<option value="url-test" {% if group.type == 'url-test' %}selected{% endif %}>url-test</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Order</label>
|
|
||||||
<input name="order_index_{{ loop.index0 }}" value="{{ group.order_index }}">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Filter Regex</label>
|
|
||||||
<input name="filter_regex_{{ loop.index0 }}" value="{{ group.filter_regex }}">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Tolerance</label>
|
|
||||||
<input name="tolerance_{{ loop.index0 }}" value="{{ group.tolerance or '' }}">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>URL</label>
|
|
||||||
<input name="url_{{ loop.index0 }}" value="{{ group.url }}">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Interval</label>
|
|
||||||
<input name="interval_{{ loop.index0 }}" value="{{ group.interval or '' }}">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="margin-top:12px;">
|
|
||||||
<label>Proxies / Tokens</label>
|
|
||||||
<textarea name="proxies_{{ loop.index0 }}">{% for item in group.proxies %}{{ item }}{% if not loop.last %}
|
|
||||||
{% endif %}{% endfor %}</textarea>
|
|
||||||
</div>
|
|
||||||
<div class="actions" style="margin-top:12px;">
|
|
||||||
<label class="inline"><input type="checkbox" name="enabled_{{ loop.index0 }}" {% if group.enabled %}checked{% endif %} style="width:auto"> Enabled</label>
|
|
||||||
<label class="inline"><input type="checkbox" name="delete_{{ loop.index0 }}" style="width:auto"> Delete</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>New Group</h2>
|
|
||||||
<div class="grid-2">
|
|
||||||
<div>
|
|
||||||
<label>Name</label>
|
|
||||||
<input name="new_group_name" placeholder="🤖 AI">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Kind</label>
|
|
||||||
<select name="new_group_kind">
|
|
||||||
<option value="policy">policy</option>
|
|
||||||
<option value="selector">selector</option>
|
|
||||||
<option value="region">region</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Type</label>
|
|
||||||
<select name="new_group_type">
|
|
||||||
<option value="select">select</option>
|
|
||||||
<option value="url-test">url-test</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Order</label>
|
|
||||||
<input name="new_group_order_index" placeholder="{{ groups|length }}">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Filter Regex</label>
|
|
||||||
<input name="new_group_filter_regex">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Tolerance</label>
|
|
||||||
<input name="new_group_tolerance">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>URL</label>
|
|
||||||
<input name="new_group_url">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Interval</label>
|
|
||||||
<input name="new_group_interval">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="margin-top:12px;">
|
|
||||||
<label>Proxies / Tokens</label>
|
|
||||||
<textarea name="new_group_proxies"></textarea>
|
|
||||||
</div>
|
|
||||||
<label class="inline" style="margin-top:12px;"><input type="checkbox" name="new_group_enabled" checked style="width:auto"> Enabled</label>
|
|
||||||
</div>
|
|
||||||
<div class="actions">
|
|
||||||
<button type="submit">Save Groups</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>sub-provider login</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
min-height: 100vh;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
font-family: "Segoe UI", "PingFang SC", sans-serif;
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at top left, #f7d9c5, transparent 28%),
|
|
||||||
radial-gradient(circle at bottom right, #dce8da, transparent 22%),
|
|
||||||
#f3efe6;
|
|
||||||
color: #1f2a2e;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
width: min(420px, calc(100vw - 32px));
|
|
||||||
background: rgba(255,253,248,0.95);
|
|
||||||
border: 1px solid #d7ccbb;
|
|
||||||
border-radius: 24px;
|
|
||||||
padding: 28px;
|
|
||||||
box-shadow: 0 20px 40px rgba(31,42,46,0.08);
|
|
||||||
}
|
|
||||||
h1 { margin-top: 0; }
|
|
||||||
label { display:block; margin-bottom: 6px; color:#66757f; font-size: 13px; }
|
|
||||||
input {
|
|
||||||
width:100%;
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid #d7ccbb;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
margin-top: 16px;
|
|
||||||
width: 100%;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: #b8542a;
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.muted { color:#66757f; }
|
|
||||||
.flash {
|
|
||||||
padding: 12px 14px;
|
|
||||||
border-radius: 14px;
|
|
||||||
border: 1px solid #ddb0a7;
|
|
||||||
background: #f7dfdb;
|
|
||||||
margin-bottom: 18px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="card">
|
|
||||||
<h1>Admin Login</h1>
|
|
||||||
<p class="muted">输入 `.env` 中配置的 `ADMIN_TOKEN`。</p>
|
|
||||||
{% if flash %}
|
|
||||||
<div class="flash">{{ flash.message }}</div>
|
|
||||||
{% endif %}
|
|
||||||
<form method="post" action="/admin/login">
|
|
||||||
<input type="hidden" name="next" value="{{ next }}">
|
|
||||||
<label>Token</label>
|
|
||||||
<input type="password" name="token" autofocus>
|
|
||||||
<button type="submit">Login</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="stack">
|
|
||||||
<div class="actions" style="justify-content:space-between; align-items:center;">
|
|
||||||
<div>
|
|
||||||
<h1>Preview: {{ profile_key }}</h1>
|
|
||||||
<p class="muted">预览 profile 当前生成的完整 bundle YAML。</p>
|
|
||||||
</div>
|
|
||||||
<div class="actions">
|
|
||||||
<a class="button secondary" href="/admin/profiles/{{ profile_key }}/rules">Rules</a>
|
|
||||||
<a class="button" href="/admin/profiles">Profiles</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<form method="get" action="/admin/profiles/{{ profile_key }}/preview" class="stack">
|
|
||||||
<div>
|
|
||||||
<label>Sources (comma separated)</label>
|
|
||||||
<input name="sources" value="{{ selected_sources|join(',') }}" placeholder="{{ available_sources|join(',') }}">
|
|
||||||
</div>
|
|
||||||
<div class="actions">
|
|
||||||
<button type="submit">Refresh Preview</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<textarea class="wide" readonly>{{ yaml_text }}</textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="stack">
|
|
||||||
<div class="actions" style="justify-content:space-between; align-items:center;">
|
|
||||||
<div>
|
|
||||||
<h1>Profile Sources: {{ profile_key }}</h1>
|
|
||||||
<p class="muted">配置 profile 默认会使用哪些源,以及它们的顺序。</p>
|
|
||||||
</div>
|
|
||||||
<div class="actions">
|
|
||||||
<a class="button secondary" href="/admin/profiles">Back</a>
|
|
||||||
<a class="button" href="/admin/profiles/{{ profile_key }}/preview">Preview</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form method="post" action="/admin/profiles/{{ profile_key }}/sources" class="card">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr><th>Enabled</th><th>Key</th><th>Order</th><th>Type</th><th>URL</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for binding in bindings %}
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<input type="hidden" name="source_key" value="{{ binding.key }}">
|
|
||||||
<input type="checkbox" name="enabled_{{ binding.key }}" {% if binding.enabled %}checked{% endif %} style="width:auto">
|
|
||||||
</td>
|
|
||||||
<td><strong>{{ binding.key }}</strong><div class="muted">{{ binding.display_name }}</div></td>
|
|
||||||
<td><input name="order_index_{{ binding.key }}" value="{{ binding.order_index }}"></td>
|
|
||||||
<td>{{ binding.kind }}</td>
|
|
||||||
<td class="muted">{{ binding.url }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<div class="actions" style="margin-top:18px;">
|
|
||||||
<button type="submit">Save Source Bindings</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="stack">
|
|
||||||
<div>
|
|
||||||
<h1>Profiles</h1>
|
|
||||||
<p class="muted">管理客户端模板,并跳转到规则和预览页。</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>New / Edit Profile</h2>
|
|
||||||
<form method="post" action="/admin/profiles" class="stack">
|
|
||||||
<div class="grid-2">
|
|
||||||
<div><label>Key</label><input name="key" required placeholder="mihomo"></div>
|
|
||||||
<div><label>Title</label><input name="title" required></div>
|
|
||||||
<div><label>Provider Interval</label><input name="provider_interval" value="21600"></div>
|
|
||||||
<div><label>Rule Interval</label><input name="rule_interval" value="86400"></div>
|
|
||||||
<div><label>Test URL</label><input name="test_url" value="https://www.gstatic.com/generate_204"></div>
|
|
||||||
<div><label>Test Interval</label><input name="test_interval" value="300"></div>
|
|
||||||
<div><label>Main Policy</label><input name="main_policy" value="🚀 节点选择"></div>
|
|
||||||
<div><label>Source Policy</label><input name="source_policy" value="☁️ 机场选择"></div>
|
|
||||||
<div><label>Mixed Auto Policy</label><input name="mixed_auto_policy" value="♻️ 自动选择"></div>
|
|
||||||
<div><label>Manual Policy</label><input name="manual_policy" value="🚀 手动切换"></div>
|
|
||||||
<div><label>Direct Policy</label><input name="direct_policy" value="DIRECT"></div>
|
|
||||||
<div><label>Mode</label><input name="mode" value="rule"></div>
|
|
||||||
<div><label>Mixed Port</label><input name="mixed_port" value="7890"></div>
|
|
||||||
<div><label>Socks Port</label><input name="socks_port" value="7891"></div>
|
|
||||||
<div><label>Log Level</label><input name="log_level" value="info"></div>
|
|
||||||
</div>
|
|
||||||
<label class="inline"><input type="checkbox" name="allow_lan" checked style="width:auto"> Allow LAN</label>
|
|
||||||
<label class="inline"><input type="checkbox" name="ipv6" checked style="width:auto"> IPv6</label>
|
|
||||||
<div class="actions"><button type="submit">Save Profile</button></div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stack">
|
|
||||||
{% for profile in profiles %}
|
|
||||||
<div class="card">
|
|
||||||
<div class="actions" style="justify-content:space-between; align-items:center;">
|
|
||||||
<div>
|
|
||||||
<h2 style="margin-bottom:4px;">{{ profile.key }}</h2>
|
|
||||||
<div class="muted">{{ profile.title }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="actions">
|
|
||||||
<a class="button secondary" href="/admin/profiles/{{ profile.key }}/sources">Sources</a>
|
|
||||||
<a class="button secondary" href="/admin/profiles/{{ profile.key }}/groups">Groups</a>
|
|
||||||
<a class="button secondary" href="/admin/profiles/{{ profile.key }}/rules">Rules</a>
|
|
||||||
<a class="button" href="/admin/profiles/{{ profile.key }}/preview">Preview</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="grid-2" style="margin-top:14px;">
|
|
||||||
<div><strong>Main:</strong> {{ profile.main_policy }}</div>
|
|
||||||
<div><strong>Source:</strong> {{ profile.source_policy }}</div>
|
|
||||||
<div><strong>Test URL:</strong> <span class="muted">{{ profile.test_url }}</span></div>
|
|
||||||
<div><strong>Mode:</strong> {{ profile.mode }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="stack">
|
|
||||||
<div class="actions" style="justify-content:space-between; align-items:center;">
|
|
||||||
<div>
|
|
||||||
<h1>Rules: {{ profile_key }}</h1>
|
|
||||||
<p class="muted">控制规则模块启用状态、顺序和目标策略组。</p>
|
|
||||||
</div>
|
|
||||||
<div class="actions">
|
|
||||||
<a class="button secondary" href="/admin/profiles">Back</a>
|
|
||||||
<a class="button" href="/admin/profiles/{{ profile_key }}/preview">Preview</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form method="post" action="/admin/profiles/{{ profile_key }}/rules" class="card">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr><th>Enabled</th><th>Key</th><th>Order</th><th>Policy</th><th>Rule File</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for binding in bindings %}
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<input type="hidden" name="rule_key" value="{{ binding.key }}">
|
|
||||||
<input type="checkbox" name="enabled_{{ binding.key }}" {% if binding.enabled %}checked{% endif %} style="width:auto">
|
|
||||||
</td>
|
|
||||||
<td><strong>{{ binding.key }}</strong><div class="muted">{{ binding.behavior }} / {{ binding.format }}</div></td>
|
|
||||||
<td><input name="order_index_{{ binding.key }}" value="{{ binding.order_index }}"></td>
|
|
||||||
<td><input name="policy_{{ binding.key }}" value="{{ binding.policy }}"></td>
|
|
||||||
<td class="muted">{{ binding.file_path or "inline payload" }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<div class="actions" style="margin-top:18px;">
|
|
||||||
<button type="submit">Save Rules</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block content %}
|
|
||||||
<div class="stack">
|
|
||||||
<div>
|
|
||||||
<h1>Sources</h1>
|
|
||||||
<p class="muted">管理订阅源。先做最小可用版本:增改删和启停。</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>New / Edit Source</h2>
|
|
||||||
<form method="post" action="/admin/sources" class="stack">
|
|
||||||
<div class="grid-2">
|
|
||||||
<div><label>Key</label><input name="key" required placeholder="airport-d"></div>
|
|
||||||
<div><label>Display Name</label><input name="display_name" placeholder="D"></div>
|
|
||||||
<div><label>Kind</label><select name="kind"><option value="auto">auto</option><option value="clash_yaml">clash_yaml</option><option value="base64_uri">base64_uri</option><option value="uri">uri</option></select></div>
|
|
||||||
<div><label>Cache TTL Seconds</label><input name="cache_ttl_seconds" placeholder="900"></div>
|
|
||||||
</div>
|
|
||||||
<div><label>URL</label><input name="url" required></div>
|
|
||||||
<div class="grid-2">
|
|
||||||
<div><label>Prefix</label><input name="prefix"></div>
|
|
||||||
<div><label>Suffix</label><input name="suffix"></div>
|
|
||||||
<div><label>Include Regex</label><input name="include_regex"></div>
|
|
||||||
<div><label>Exclude Regex</label><input name="exclude_regex"></div>
|
|
||||||
</div>
|
|
||||||
<label class="inline"><input type="checkbox" name="enabled" checked style="width:auto"> Enabled</label>
|
|
||||||
<div class="actions"><button type="submit">Save Source</button></div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>Existing Sources</h2>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr><th>Key</th><th>Type</th><th>URL</th><th>Prefix</th><th>Status</th><th></th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for source in sources %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ source.key }}</td>
|
|
||||||
<td>{{ source.kind }}</td>
|
|
||||||
<td class="muted">{{ source.url }}</td>
|
|
||||||
<td>{{ source.prefix }}</td>
|
|
||||||
<td>{{ "enabled" if source.enabled else "disabled" }}</td>
|
|
||||||
<td>
|
|
||||||
<form method="post" action="/admin/sources/{{ source.key }}/delete" onsubmit="return confirm('Delete source {{ source.key }}?')">
|
|
||||||
<button type="submit" class="secondary">Delete</button>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
61
tests/test_conf_parity.py
Normal file
61
tests/test_conf_parity.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
if str(ROOT_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT_DIR))
|
||||||
|
|
||||||
|
|
||||||
|
def _write_sample_source(path: Path) -> None:
|
||||||
|
path.write_text(
|
||||||
|
"\n".join(
|
||||||
|
[
|
||||||
|
"proxies:",
|
||||||
|
" - name: hk-demo",
|
||||||
|
" type: ss",
|
||||||
|
" server: 1.1.1.1",
|
||||||
|
" port: 443",
|
||||||
|
" cipher: aes-128-gcm",
|
||||||
|
" password: demo-pass",
|
||||||
|
" - name: us-demo",
|
||||||
|
" type: ss",
|
||||||
|
" server: 2.2.2.2",
|
||||||
|
" port: 443",
|
||||||
|
" cipher: aes-128-gcm",
|
||||||
|
" password: demo-pass",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfParityTest(unittest.TestCase):
|
||||||
|
def test_compare_script_core_matches(self) -> None:
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
sample = Path(tmp) / "sample.yaml"
|
||||||
|
_write_sample_source(sample)
|
||||||
|
os.environ["AIRPORT_A_URL"] = str(sample)
|
||||||
|
os.environ["AIRPORT_B_URL"] = str(sample)
|
||||||
|
os.environ["AIRPORT_C_URL"] = str(sample)
|
||||||
|
|
||||||
|
from scripts.compare_conf_outputs import compare_outputs
|
||||||
|
|
||||||
|
result = compare_outputs("mihomo", "default", "airport-a,airport-b", sample_source=sample)
|
||||||
|
|
||||||
|
self.assertTrue(result["thin"]["proxy_provider_match"])
|
||||||
|
self.assertTrue(result["thin"]["rule_provider_match"])
|
||||||
|
self.assertTrue(result["thin"]["group_name_match"])
|
||||||
|
self.assertTrue(result["bundle"]["group_name_match"])
|
||||||
|
self.assertTrue(result["bundle"]["proxy_count_match"])
|
||||||
|
self.assertTrue(result["bundle"]["rules_count_match"])
|
||||||
|
self.assertEqual(result["thin"]["legacy"]["rules_head"], result["thin"]["conf"]["rules_head"])
|
||||||
|
self.assertEqual(result["bundle"]["legacy"]["rules_tail"], result["bundle"]["conf"]["rules_tail"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user