14 Commits

Author SHA1 Message Date
riglen
b29e245ff5 gitignore 2026-07-29 13:54:04 +08:00
riglen
3feab7d5d8 gitignore 2026-07-29 13:53:01 +08:00
riglen
9bb7858d42 配置 2026-05-28 17:11:57 +08:00
riglen
28c49ce299 配置 2026-04-22 10:25:01 +08:00
riglen
86cabce537 配置 2026-04-21 20:10:04 +08:00
riglen
d5cfae22ea 重构 2026-04-21 16:56:06 +08:00
riglen
05e0355e14 step1 2026-04-21 15:47:09 +08:00
riglen
333b66c6bd Revert "rule重构"
This reverts commit 1cbf080e1e.
2026-04-20 14:42:37 +08:00
riglen
70b3568ccd Revert "db"
This reverts commit 0a46ebf0a8.
2026-04-20 14:42:33 +08:00
riglen
6f71846cf0 Revert "模块化"
This reverts commit ee9cc6f429.
2026-04-20 14:42:24 +08:00
riglen
d30018ed71 Revert "面板"
This reverts commit 564042a8cc.
2026-04-20 14:42:19 +08:00
riglen
7c7c233a2a Revert "面板"
This reverts commit b389b5e351.
2026-04-20 14:42:15 +08:00
riglen
b389b5e351 面板 2026-04-20 14:27:29 +08:00
riglen
564042a8cc 面板 2026-04-20 14:16:09 +08:00
48 changed files with 3677 additions and 3600 deletions

View File

@@ -3,10 +3,10 @@ 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 运行时数据目录,建议放在仓库外 # 上游订阅抓取时使用的 User-Agent。
APP_DATA_DIR=../sub-provider-data # 如果机场对默认 UA 返回 406可改成你客户端实际使用的 UA例如 Clash Party 抓包得到的值。
UPSTREAM_USER_AGENT=sub-provider/0.2
# 对外访问前缀,尽量改成足够长的随机字符串 # 对外访问前缀,尽量改成足够长的随机字符串
PUBLIC_PATH=change-me-random-hash-path PUBLIC_PATH=change-me-random-hash-path

4
.gitignore vendored
View File

@@ -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/
@@ -209,3 +207,5 @@ cython_debug/
marimo/_static/ marimo/_static/
marimo/_lsp/ marimo/_lsp/
__marimo__/ __marimo__/
output/
data/

127
README.md
View File

@@ -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,44 +72,20 @@ 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`
- 如果上游订阅地址对默认 UA 返回 `406`,可在 `.env` 里设置 `UPSTREAM_USER_AGENT`,填成你客户端实际使用的 UA例如 Clash Party 抓包出来的值
- `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`
- 单 provider - 单 provider
@@ -120,10 +96,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`
--- ---
@@ -182,6 +162,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
```
--- ---
## 默认策略结构 ## 默认策略结构
@@ -221,3 +272,15 @@ HEAD /<PUBLIC_PATH>/bundle/stash.yaml?sources=airport-a,airport-b,airport-c
- `sources` 填多个机场源:**只取第一个**机场源的 `Subscription-Userinfo` - `sources` 填多个机场源:**只取第一个**机场源的 `Subscription-Userinfo`
这样 Stash、Clash Party 这类客户端读取配置订阅头时,行为是稳定可预期的。 这样 Stash、Clash Party 这类客户端读取配置订阅头时,行为是稳定可预期的。
---
## 上游 `406` 处理
上游订阅抓取默认使用 `sub-provider/0.2` 作为 `User-Agent`。如果机场对这个 UA 返回 `406 Not Acceptable`,可以直接在 `.env` 里覆盖:
```env
UPSTREAM_USER_AGENT=Clash Party/<你的版本号或抓包值>
```
代码里也兼容旧变量名 `DEFAULT_USER_AGENT`,但建议后续统一用 `UPSTREAM_USER_AGENT`

View File

@@ -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

View File

@@ -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()

View File

@@ -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"}

View File

@@ -1 +0,0 @@

147
app/conf_models.py Normal file
View 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

View File

@@ -1,9 +1,10 @@
from __future__ import annotations from __future__ import annotations
import os
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
from pydantic import Field from pydantic import AliasChoices, Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -25,17 +26,14 @@ class Settings(BaseSettings):
cache_ttl_seconds: int = 900 cache_ttl_seconds: int = 900
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 = Field(
database_url: str = Field(default=f"sqlite:///{(DATA_DIR / 'app.db').resolve().as_posix()}") default="sub-provider/0.2",
database_echo: bool = False validation_alias=AliasChoices("UPSTREAM_USER_AGENT", "DEFAULT_USER_AGENT"),
)
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"
@@ -46,6 +44,16 @@ class Settings(BaseSettings):
extra="ignore", extra="ignore",
) )
@field_validator("default_user_agent", mode="before")
@classmethod
def _coalesce_default_user_agent(cls, value: str) -> str:
if isinstance(value, str) and value.strip():
return value
legacy = os.getenv("DEFAULT_USER_AGENT", "").strip()
if legacy:
return legacy
return "sub-provider/0.2"
@lru_cache(maxsize=1) @lru_cache(maxsize=1)
def get_settings() -> Settings: def get_settings() -> Settings:

View File

@@ -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()()

View File

@@ -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")

View File

@@ -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()

View File

@@ -8,6 +8,10 @@ from fastapi.responses import Response
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.conf_config_store import load_conf_store
from app.services.conf_loader import ConfConfigError
from app.services.conf_profiles import build_conf_bundle_profile, build_conf_thin_profile, conf_source_to_source_config
from app.services.conf_runtime import build_conf_rule_lines, resolve_conf_runtime_plan
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
@@ -69,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)
@@ -150,6 +185,7 @@ async def client_profile(client_type: str, request: Request, sources: str | None
build_thin_profile( build_thin_profile(
client_type=client_type, client_type=client_type,
app_config=app_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=(app_config.public_path or settings.public_path).strip("/"), public_path=(app_config.public_path or settings.public_path).strip("/"),
@@ -160,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,
@@ -198,6 +286,7 @@ async def bundle_profile(
build_bundle_profile( build_bundle_profile(
client_type=client_type, client_type=client_type,
app_config=app_config, app_config=app_config,
client=client,
snapshots=snapshots, snapshots=snapshots,
) )
) )
@@ -213,3 +302,58 @@ async def bundle_profile(
headers={key: value for key, value in headers.items() if key != "X-Sub-Provider-Bundle-Cache"}, 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}.yaml") return _yaml_response(content, request, headers=headers, filename=f"bundle-{client_type}.yaml")
@app.api_route(PUBLIC_PREFIX + "/conf/bundle/{client_type}/{profile_key}.yaml", methods=["GET", "HEAD"])
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:
raise HTTPException(status_code=404, detail="client config not found")
_, _, plan = _load_conf_profile_runtime(profile_key)
source_items = _conf_source_items(plan)
cache_key = build_bundle_cache_key(
client_type=f"conf-{client_type}-{profile_key}",
source_names=[name for name, _ in source_items],
)
if not force_refresh:
cached = load_bundle_cache(
cache_dir=settings.bundle_cache_dir,
cache_key=cache_key,
ttl_seconds=settings.bundle_cache_ttl_seconds,
)
if cached is not None:
headers = {
"profile-update-interval": str(client.provider_interval),
"X-Sub-Provider-Bundle-Cache": "HIT",
}
headers.update(cached.headers)
return _yaml_response(content=cached.content, request=request, headers=headers, filename=f"bundle-{client_type}-{profile_key}.yaml")
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
content = dump_yaml(
build_conf_bundle_profile(
client_type=client_type,
client=client,
plan=plan,
snapshots=snapshots,
rules=rules,
)
)
headers = {
"profile-update-interval": str(client.provider_interval),
"X-Sub-Provider-Bundle-Cache": "BYPASS" if force_refresh else "MISS",
}
headers.update(_quota_headers_from_snapshots(snapshots))
save_bundle_cache(
cache_dir=settings.bundle_cache_dir,
cache_key=cache_key,
content=content,
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")

View File

@@ -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)

View 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
View 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

View 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

View 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,
)

View File

@@ -1,361 +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:
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
public_path = _setting_value(session, "public_path")
primary_profile = profiles[0]
regions: dict[str, RegionConfig] = {}
selector_groups: list[ProxyGroupConfig] = []
policy_groups: list[ProxyGroupConfig] = []
for group in sorted(primary_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,
)
)
rules: dict[str, RuleConfig] = {}
for link in sorted(primary_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 AppConfig(
public_path=public_path or None,
sources={
source.key: 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,
)
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 _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"

View File

@@ -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)

View File

@@ -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

View File

@@ -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),
)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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",
]

View File

@@ -1 +0,0 @@
public_path: ${PUBLIC_PATH}

View File

@@ -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
View 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

View File

@@ -0,0 +1,6 @@
name = 仅 A
enabled = true
sources = airport-a
exclude_modules =
include_builtins = geoip_cn,final

View File

@@ -0,0 +1,6 @@
name = 三机场全开
enabled = true
sources = airport-a,airport-b,airport-c
exclude_modules =
include_builtins = geoip_cn,final

View File

@@ -0,0 +1,6 @@
name = 仅 B
enabled = true
sources = airport-b
exclude_modules =
include_builtins = geoip_cn,final

View File

@@ -0,0 +1,6 @@
name = 仅 C
enabled = true
sources = airport-c
exclude_modules =
include_builtins = geoip_cn,final

View File

@@ -0,0 +1,6 @@
name = 默认完整配置
enabled = true
sources = airport-a,airport-b
exclude_modules =
include_builtins = geoip_cn,final

View 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

View File

@@ -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 }}"

View File

@@ -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

View File

@@ -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

View 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

View 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

View File

@@ -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

View File

@@ -10,5 +10,5 @@ services:
- .env - .env
volumes: volumes:
- ./config:/app/config:ro - ./config:/app/config:ro
- ${APP_DATA_DIR:-../sub-provider-data}:/app/data - ./data:/app/data
- ./output:/app/output - ./output:/app/output

809
docs/ini改造方案.md Normal file
View 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"

View File

@@ -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` 的重构最稳路线是:
> 先做配置解耦,再做数据库配置层,再做渲染流水线固化,最后补管理页面和快照系统。
这样每一阶段都能保留一个可运行版本,也最符合当前项目已经有代码基础的现实情况。

View File

@@ -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。
### 原则 390% 的配置修改通过“选模块 + 改参数”完成
不要把面板做成“大文本框配置编辑器”。
### 原则 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 执行拆分版**,我会按“先改哪些文件、再改哪些文件、每步验收什么”来写。

View File

@@ -3,5 +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

View 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()

61
tests/test_conf_parity.py Normal file
View 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()