4 Commits

Author SHA1 Message Date
riglen
564042a8cc 面板 2026-04-20 14:16:09 +08:00
riglen
ee9cc6f429 模块化 2026-04-20 11:47:10 +08:00
riglen
0a46ebf0a8 db 2026-04-20 11:09:36 +08:00
riglen
1cbf080e1e rule重构 2026-04-20 10:35:03 +08:00
39 changed files with 4928 additions and 1011 deletions

View File

@@ -3,6 +3,15 @@ APP_ENV=prod
HOST=0.0.0.0
PORT=18080
LOG_LEVEL=info
DATABASE_URL=sqlite:////app/data/app.db
# Docker 运行时数据目录,建议放在仓库外
APP_DATA_DIR=../sub-provider-data
# 管理面板鉴权;留空则不启用登录校验
ADMIN_TOKEN=
ADMIN_SESSION_SECRET=change-this-admin-session-secret
ADMIN_SESSION_MAX_AGE=86400
# 对外访问前缀,尽量改成足够长的随机字符串
PUBLIC_PATH=change-me-random-hash-path

2
.gitignore vendored
View File

@@ -62,6 +62,8 @@ cover/
local_settings.py
db.sqlite3
db.sqlite3-journal
data/
output/bundle-cache/
# Flask stuff:
instance/

View File

@@ -40,6 +40,11 @@ sub-provider/
rules.py
subscriptions.py
config/
app.yaml
clients.yaml
policy-groups.yaml
regions.yaml
rules.yaml
sources.yaml
rules/
reject.yaml
@@ -67,21 +72,49 @@ cp .env.example .env
2. 编辑 `.env`
- `DATABASE_URL` 默认可用 `sqlite:////app/data/app.db`
- `APP_DATA_DIR` 建议指向仓库外目录,例如 `../sub-provider-data`
- `PUBLIC_PATH` 改成足够长的随机字符串
- `PUBLIC_BASE_URL` 建议填写你反代后的最终访问地址,例如 `https://sub.example.com`
- `ADMIN_TOKEN` 可选;配置后管理面板会启用登录鉴权,不配置则不校验登录
- `ADMIN_SESSION_SECRET` 用于签发管理面板 session部署时应改成随机长字符串
- `AIRPORT_A_URL` / `AIRPORT_B_URL` / `AIRPORT_C_URL` 都可以直接填订阅地址,项目会自动判断是 YAML 还是 URI 订阅
- 也可以直接填本地文件路径,例如 `/app/data/sources/airport-b.txt``file:///app/data/sources/airport-b.txt`
- 允许把其中一个留空;留空时这个机场会自动跳过
3. 启动
3. 按需调整 `config/` 下拆分后的配置文件
- `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
docker compose up -d --build
```
4. 访问检查:
6. 访问检查:
- 健康检查:`http://YOUR_HOST:18080/healthz`
- 管理面板:`http://YOUR_HOST:18080/admin`
- 单 provider
`https://YOUR_DOMAIN/<PUBLIC_PATH>/providers/airport-a.yaml`
- merged provider
@@ -95,6 +128,34 @@ docker compose up -d --build
- Stash bundle
`https://YOUR_DOMAIN/<PUBLIC_PATH>/bundle/stash.yaml?sources=airport-a,airport-b,airport-c`
### 管理面板(最小版本)
当前已提供一版服务端渲染的最小管理面板:
- `/admin/sources`
- `/admin/profiles`
- `/admin/profiles/{profile_key}/sources`
- `/admin/profiles/{profile_key}/groups`
- `/admin/profiles/{profile_key}/rules`
- `/admin/profiles/{profile_key}/preview`
当前能力范围:
- Source 增删改
- Profile 基础字段编辑
- Profile 默认 source 绑定与顺序编辑
- Profile group 新增、删除、基础字段编辑
- Profile 规则启停、顺序、目标策略组编辑
- 预览最终 bundle YAML
鉴权方式:
-`.env` 中设置 `ADMIN_TOKEN`
- `docker compose` 会自动把 `.env` 注入容器
- 访问 `/admin` 时先进入登录页,提交 token 后写入 session cookie
- session 使用 `ADMIN_SESSION_SECRET` 签名,默认有效期由 `ADMIN_SESSION_MAX_AGE` 控制
- 如果 `ADMIN_TOKEN` 留空,管理面板默认不启用登录拦截
---
## 接口说明

36
alembic.ini Normal file
View File

@@ -0,0 +1,36 @@
[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

41
alembic/env.py Normal file
View File

@@ -0,0 +1,41 @@
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()

25
alembic/script.py.mako Normal file
View File

@@ -0,0 +1,25 @@
"""${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

@@ -0,0 +1 @@

View File

@@ -26,8 +26,19 @@ class Settings(BaseSettings):
bundle_cache_ttl_seconds: int = 600
max_proxy_name_length: int = 80
default_user_agent: str = "sub-provider/0.2"
database_url: str = Field(default=f"sqlite:///{(DATA_DIR / 'app.db').resolve().as_posix()}")
database_echo: bool = False
admin_token: str | None = None
admin_session_secret: str = "change-this-admin-session-secret"
admin_session_max_age: int = 86400
config_dir: Path = CONFIG_DIR
sources_file: Path = CONFIG_DIR / "sources.yaml"
app_config_file: Path = CONFIG_DIR / "app.yaml"
clients_file: Path = CONFIG_DIR / "clients.yaml"
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"
bundle_cache_dir: Path = ROOT_DIR / "output" / "bundle-cache"
fetch_cache_dir: Path = DATA_DIR / "fetch-cache"

40
app/db.py Normal file
View File

@@ -0,0 +1,40 @@
from __future__ import annotations
from functools import lru_cache
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.config import DATA_DIR, get_settings
from app.db_models import Base
def _sqlite_connect_args(database_url: str) -> dict[str, bool]:
if database_url.startswith("sqlite"):
return {"check_same_thread": False}
return {}
@lru_cache(maxsize=1)
def get_engine():
settings = get_settings()
DATA_DIR.mkdir(parents=True, exist_ok=True)
return create_engine(
settings.database_url,
echo=settings.database_echo,
future=True,
connect_args=_sqlite_connect_args(settings.database_url),
)
@lru_cache(maxsize=1)
def get_session_factory():
return sessionmaker(bind=get_engine(), autoflush=False, autocommit=False, future=True)
def init_db() -> None:
Base.metadata.create_all(bind=get_engine())
def get_session() -> Session:
return get_session_factory()()

149
app/db_models.py Normal file
View File

@@ -0,0 +1,149 @@
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")

24
app/import_config.py Normal file
View File

@@ -0,0 +1,24 @@
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

@@ -1,13 +1,32 @@
from __future__ import annotations
import hmac
import logging
from urllib.parse import quote
from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.responses import Response
from fastapi import FastAPI, Form, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
from app.config import get_settings
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.config_store import (
delete_source,
get_profile_default_source_keys,
list_profile_groups,
list_profile_rule_bindings,
list_profile_source_bindings,
list_profiles,
list_sources,
load_profile_app_config_from_db,
replace_profile_groups,
save_profile,
save_source,
update_profile_source_bindings,
update_profile_rule_bindings,
)
from app.services.loader import load_app_config
from app.services.profiles import build_bundle_profile, build_thin_profile, dump_yaml
from app.services.rules import load_rule_text
@@ -22,8 +41,17 @@ from app.services.subscriptions import (
settings = get_settings()
logger = logging.getLogger(__name__)
app = FastAPI(title=settings.app_name)
app.add_middleware(
SessionMiddleware,
secret_key=settings.admin_session_secret,
session_cookie="sub_provider_admin",
max_age=settings.admin_session_max_age,
same_site="lax",
https_only=False,
)
app_config = load_app_config(settings.sources_file)
PUBLIC_PREFIX = "/" + (app_config.public_path or settings.public_path).strip("/")
templates = Jinja2Templates(directory=str(settings.config_dir.parent / "templates"))
@app.get("/healthz")
@@ -37,9 +65,58 @@ def _base_url(request: Request) -> str:
return str(request.base_url).rstrip("/")
def _resolve_sources(sources: str | None) -> list[tuple[str, SourceConfig]]:
enabled = [(name, src) for name, src in app_config.sources.items() if src.enabled and str(src.url).strip()]
def _admin_auth_enabled() -> bool:
return bool((settings.admin_token or "").strip())
def _is_admin_authenticated(request: Request) -> bool:
return not _admin_auth_enabled() or request.session.get("admin_authenticated") is True
def _set_flash(request: Request, message: str, level: str = "info") -> None:
request.session["_flash"] = {"message": message, "level": level}
def _pop_flash(request: Request):
return request.session.pop("_flash", None)
def _admin_context(request: Request, *, active: str, **extra):
return {
"request": request,
"active": active,
"flash": _pop_flash(request),
"auth_enabled": _admin_auth_enabled(),
"authenticated": _is_admin_authenticated(request),
**extra,
}
def _admin_guard(request: Request) -> RedirectResponse | None:
if _is_admin_authenticated(request):
return None
next_path = quote(str(request.url.path))
return _redirect(f"/admin/login?next={next_path}")
def _current_app_config(profile_key: str | None = None):
if profile_key:
profile_config = load_profile_app_config_from_db(profile_key)
if profile_config is not None:
return profile_config
return load_app_config(settings.sources_file)
def _resolve_sources(sources: str | None, profile_key: str | None = None) -> list[tuple[str, SourceConfig]]:
current_config = _current_app_config(profile_key=profile_key)
enabled = [(name, src) for name, src in current_config.sources.items() if src.enabled and str(src.url).strip()]
if not sources:
if profile_key:
default_keys = get_profile_default_source_keys(profile_key)
if default_keys:
selected = [(name, current_config.sources[name]) for name in default_keys if name in current_config.sources]
logger.info("resolve_sources profile default: profile=%s selected=%s", profile_key, [name for name, _ in selected])
return selected
logger.info("resolve_sources default: selected=%s", [name for name, _ in enabled])
return enabled
@@ -49,7 +126,7 @@ def _resolve_sources(sources: str | None) -> list[tuple[str, SourceConfig]]:
for name in names:
if name in seen:
continue
source = app_config.sources.get(name)
source = current_config.sources.get(name)
if source is None or not source.enabled or not str(source.url).strip():
raise HTTPException(status_code=404, detail=f"source not found or disabled: {name}")
selected.append((name, source))
@@ -115,7 +192,8 @@ async def merged_provider(request: Request, sources: str | None = Query(default=
@app.api_route(PUBLIC_PREFIX + "/providers/{name}.yaml", methods=["GET", "HEAD"])
async def provider(name: str, request: Request) -> Response:
source = app_config.sources.get(name)
current_config = _current_app_config()
source = current_config.sources.get(name)
if source is None or not source.enabled:
raise HTTPException(status_code=404, detail="provider not found")
@@ -132,7 +210,8 @@ async def provider(name: str, request: Request) -> Response:
@app.api_route(PUBLIC_PREFIX + "/rules/{name}.yaml", methods=["GET", "HEAD"])
async def rule_file(name: str, request: Request) -> Response:
rule = app_config.rules.get(name)
current_config = _current_app_config()
rule = current_config.rules.get(name)
if rule is None:
raise HTTPException(status_code=404, detail="rule not found")
content = load_rule_text(_rule_path(rule))
@@ -141,19 +220,19 @@ async def rule_file(name: str, request: Request) -> Response:
@app.api_route(PUBLIC_PREFIX + "/clients/{client_type}.yaml", methods=["GET", "HEAD"])
async def client_profile(client_type: str, request: Request, sources: str | None = Query(default=None)) -> Response:
client = app_config.clients.get(client_type)
current_config = _current_app_config(profile_key=client_type)
client = current_config.clients.get(client_type)
if client is None:
raise HTTPException(status_code=404, detail="client config not found")
source_items = _resolve_sources(sources)
source_items = _resolve_sources(sources, profile_key=client_type)
content = dump_yaml(
build_thin_profile(
client_type=client_type,
app_config=app_config,
client=client,
app_config=current_config,
selected_source_names=[name for name, _ in source_items],
base_url=_base_url(request),
public_path=(app_config.public_path or settings.public_path).strip("/"),
public_path=(current_config.public_path or settings.public_path).strip("/"),
)
)
headers = {"profile-update-interval": str(client.provider_interval)}
@@ -168,11 +247,12 @@ async def bundle_profile(
sources: str | None = Query(default=None),
force_refresh: bool = Query(default=False),
) -> Response:
client = app_config.clients.get(client_type)
current_config = _current_app_config(profile_key=client_type)
client = current_config.clients.get(client_type)
if client is None:
raise HTTPException(status_code=404, detail="client config not found")
source_items = _resolve_sources(sources)
source_items = _resolve_sources(sources, profile_key=client_type)
cache_key = build_bundle_cache_key(client_type=client_type, source_names=[name for name, _ in source_items])
if not force_refresh:
cached = load_bundle_cache(
@@ -198,8 +278,7 @@ async def bundle_profile(
content = dump_yaml(
build_bundle_profile(
client_type=client_type,
app_config=app_config,
client=client,
app_config=current_config,
snapshots=snapshots,
)
)
@@ -215,3 +294,355 @@ async def bundle_profile(
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")
def _redirect(url: str) -> RedirectResponse:
return RedirectResponse(url=url, status_code=303)
@app.get("/admin/login", response_class=HTMLResponse)
async def admin_login(request: Request, next: str = Query(default="/admin/sources")) -> HTMLResponse:
if _is_admin_authenticated(request):
return _redirect(next)
return templates.TemplateResponse(
request,
"login.html",
_admin_context(request, active="", next=next),
)
@app.post("/admin/login")
async def admin_login_submit(
request: Request,
token: str = Form(default=""),
next: str = Form(default="/admin/sources"),
) -> Response:
if not _admin_auth_enabled():
request.session["admin_authenticated"] = True
return _redirect(next)
if hmac.compare_digest((settings.admin_token or "").strip(), token.strip()):
request.session["admin_authenticated"] = True
_set_flash(request, "登录成功", "success")
return _redirect(next)
_set_flash(request, "Token 不正确", "error")
return _redirect(f"/admin/login?next={quote(next)}")
@app.post("/admin/logout")
async def admin_logout(request: Request) -> Response:
request.session.clear()
return _redirect("/admin/login")
@app.get("/admin", response_class=HTMLResponse)
async def admin_index(request: Request) -> Response:
redirect = _admin_guard(request)
if redirect:
return redirect
return _redirect("/admin/sources")
@app.get("/admin/sources", response_class=HTMLResponse)
async def admin_sources(request: Request) -> HTMLResponse:
redirect = _admin_guard(request)
if redirect:
return redirect
return templates.TemplateResponse(
request,
"sources.html",
_admin_context(
request,
active="sources",
sources=list_sources(),
profiles=list_profiles(),
),
)
@app.post("/admin/sources")
async def admin_save_source(
request: Request,
key: str = Form(...),
url: str = Form(...),
kind: str = Form(default="auto"),
display_name: str = Form(default=""),
prefix: str = Form(default=""),
suffix: str = Form(default=""),
include_regex: str = Form(default=""),
exclude_regex: str = Form(default=""),
cache_ttl_seconds: str = Form(default=""),
enabled: str | None = Form(default=None),
) -> Response:
redirect = _admin_guard(request)
if redirect:
return redirect
save_source(
key=key.strip(),
enabled=enabled is not None,
kind=kind,
url=url.strip(),
display_name=display_name.strip() or None,
headers={},
include_regex=include_regex.strip() or None,
exclude_regex=exclude_regex.strip() or None,
prefix=prefix,
suffix=suffix,
cache_ttl_seconds=int(cache_ttl_seconds) if cache_ttl_seconds.strip() else None,
)
_set_flash(request, f"Source {key.strip()} 已保存", "success")
return _redirect("/admin/sources")
@app.post("/admin/sources/{key}/delete")
async def admin_delete_source(request: Request, key: str) -> Response:
redirect = _admin_guard(request)
if redirect:
return redirect
delete_source(key)
_set_flash(request, f"Source {key} 已删除", "success")
return _redirect("/admin/sources")
@app.get("/admin/profiles", response_class=HTMLResponse)
async def admin_profiles(request: Request) -> HTMLResponse:
redirect = _admin_guard(request)
if redirect:
return redirect
return templates.TemplateResponse(
request,
"profiles.html",
_admin_context(
request,
active="profiles",
profiles=list_profiles(),
),
)
@app.post("/admin/profiles")
async def admin_save_profile(
request: Request,
key: str = Form(...),
title: str = Form(...),
provider_interval: int = Form(default=21600),
rule_interval: int = Form(default=86400),
test_url: str = Form(...),
test_interval: int = Form(default=300),
main_policy: str = Form(...),
source_policy: str = Form(...),
mixed_auto_policy: str = Form(...),
manual_policy: str = Form(...),
direct_policy: str = Form(...),
mode: str = Form(default="rule"),
allow_lan: str | None = Form(default=None),
ipv6: str | None = Form(default=None),
mixed_port: str = Form(default=""),
socks_port: str = Form(default=""),
log_level: str = Form(default="info"),
) -> Response:
redirect = _admin_guard(request)
if redirect:
return redirect
save_profile(
key=key.strip(),
title=title.strip(),
provider_interval=provider_interval,
rule_interval=rule_interval,
test_url=test_url.strip(),
test_interval=test_interval,
main_policy=main_policy.strip(),
source_policy=source_policy.strip(),
mixed_auto_policy=mixed_auto_policy.strip(),
manual_policy=manual_policy.strip(),
direct_policy=direct_policy.strip(),
mode=mode.strip(),
allow_lan=allow_lan is not None,
ipv6=ipv6 is not None,
mixed_port=int(mixed_port) if mixed_port.strip() else None,
socks_port=int(socks_port) if socks_port.strip() else None,
log_level=log_level.strip() or None,
)
_set_flash(request, f"Profile {key.strip()} 已保存", "success")
return _redirect("/admin/profiles")
@app.get("/admin/profiles/{profile_key}/sources", response_class=HTMLResponse)
async def admin_profile_sources(request: Request, profile_key: str) -> HTMLResponse:
redirect = _admin_guard(request)
if redirect:
return redirect
return templates.TemplateResponse(
request,
"profile_sources.html",
_admin_context(
request,
active="profiles",
profile_key=profile_key,
bindings=list_profile_source_bindings(profile_key),
),
)
@app.post("/admin/profiles/{profile_key}/sources")
async def admin_update_profile_sources(request: Request, profile_key: str) -> Response:
redirect = _admin_guard(request)
if redirect:
return redirect
form = await request.form()
keys = form.getlist("source_key")
rows: list[dict] = []
for index, key in enumerate(keys):
rows.append(
{
"key": key,
"enabled": form.get(f"enabled_{key}") is not None,
"order_index": form.get(f"order_index_{key}", str(index)),
}
)
update_profile_source_bindings(profile_key, rows)
_set_flash(request, f"Profile {profile_key} 的默认源已更新", "success")
return _redirect(f"/admin/profiles/{profile_key}/sources")
@app.get("/admin/profiles/{profile_key}/rules", response_class=HTMLResponse)
async def admin_profile_rules(request: Request, profile_key: str) -> HTMLResponse:
redirect = _admin_guard(request)
if redirect:
return redirect
return templates.TemplateResponse(
request,
"rules.html",
_admin_context(
request,
active="profiles",
profile_key=profile_key,
bindings=list_profile_rule_bindings(profile_key),
),
)
@app.get("/admin/profiles/{profile_key}/groups", response_class=HTMLResponse)
async def admin_profile_groups(request: Request, profile_key: str) -> HTMLResponse:
redirect = _admin_guard(request)
if redirect:
return redirect
return templates.TemplateResponse(
request,
"groups.html",
_admin_context(
request,
active="profiles",
profile_key=profile_key,
groups=list_profile_groups(profile_key),
),
)
@app.post("/admin/profiles/{profile_key}/groups")
async def admin_update_profile_groups(request: Request, profile_key: str) -> Response:
redirect = _admin_guard(request)
if redirect:
return redirect
form = await request.form()
row_ids = form.getlist("group_id")
rows: list[dict] = []
for index, group_id in enumerate(row_ids):
group_key = str(index)
name = str(form.get(f"group_name_{group_key}", "")).strip()
if not str(name).strip():
continue
proxies_raw = str(form.get(f"proxies_{group_key}", "")).strip()
if form.get(f"delete_{group_key}") is not None:
continue
rows.append(
{
"id": int(group_id) if str(group_id).strip() else None,
"name": str(name).strip(),
"group_kind": str(form.get(f"group_kind_{group_key}", "policy")).strip(),
"type": str(form.get(f"type_{group_key}", "select")).strip(),
"order_index": int(str(form.get(f"order_index_{group_key}", index))),
"proxies": [item.strip() for item in proxies_raw.splitlines() if item.strip()],
"filter_regex": str(form.get(f"filter_regex_{group_key}", "")).strip(),
"tolerance": int(str(form.get(f"tolerance_{group_key}", "")).strip()) if str(form.get(f"tolerance_{group_key}", "")).strip() else None,
"url": str(form.get(f"url_{group_key}", "")).strip(),
"interval": int(str(form.get(f"interval_{group_key}", "")).strip()) if str(form.get(f"interval_{group_key}", "")).strip() else None,
"enabled": form.get(f"enabled_{group_key}") is not None,
}
)
new_name = str(form.get("new_group_name", "")).strip()
if new_name:
new_proxies_raw = str(form.get("new_group_proxies", "")).strip()
rows.append(
{
"id": None,
"name": new_name,
"group_kind": str(form.get("new_group_kind", "policy")).strip(),
"type": str(form.get("new_group_type", "select")).strip(),
"order_index": int(str(form.get("new_group_order_index", len(rows))).strip() or len(rows)),
"proxies": [item.strip() for item in new_proxies_raw.splitlines() if item.strip()],
"filter_regex": str(form.get("new_group_filter_regex", "")).strip(),
"tolerance": int(str(form.get("new_group_tolerance", "")).strip()) if str(form.get("new_group_tolerance", "")).strip() else None,
"url": str(form.get("new_group_url", "")).strip(),
"interval": int(str(form.get("new_group_interval", "")).strip()) if str(form.get("new_group_interval", "")).strip() else None,
"enabled": form.get("new_group_enabled") is not None,
}
)
replace_profile_groups(profile_key, rows)
_set_flash(request, f"Profile {profile_key} 的策略组已更新", "success")
return _redirect(f"/admin/profiles/{profile_key}/groups")
@app.post("/admin/profiles/{profile_key}/rules")
async def admin_update_profile_rules(request: Request, profile_key: str) -> Response:
redirect = _admin_guard(request)
if redirect:
return redirect
form = await request.form()
keys = form.getlist("rule_key")
rows: list[dict] = []
for index, key in enumerate(keys):
rows.append(
{
"key": key,
"enabled": form.get(f"enabled_{key}") is not None,
"order_index": form.get(f"order_index_{key}", str(index)),
"policy": form.get(f"policy_{key}", ""),
}
)
update_profile_rule_bindings(profile_key, rows)
_set_flash(request, f"Profile {profile_key} 的规则绑定已更新", "success")
return _redirect(f"/admin/profiles/{profile_key}/rules")
@app.get("/admin/profiles/{profile_key}/preview", response_class=HTMLResponse)
async def admin_profile_preview(
request: Request,
profile_key: str,
sources: str | None = Query(default=None),
) -> HTMLResponse:
redirect = _admin_guard(request)
if redirect:
return redirect
profile_config = _current_app_config(profile_key=profile_key)
source_items = _resolve_sources(sources, profile_key=profile_key)
selected_names = [name for name, _ in source_items]
snapshots = await build_source_snapshots([(name, profile_config.sources[name]) for name in selected_names])
yaml_text = dump_yaml(
build_bundle_profile(
client_type=profile_key,
app_config=profile_config,
snapshots=snapshots,
)
)
return templates.TemplateResponse(
request,
"preview.html",
_admin_context(
request,
active="profiles",
profile_key=profile_key,
yaml_text=yaml_text,
available_sources=list(profile_config.sources.keys()),
selected_sources=selected_names,
),
)

View File

@@ -82,6 +82,26 @@ class ProviderDocument(BaseModel):
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):
upload: int | None = None
download: int | None = None
@@ -110,3 +130,13 @@ class SourceSnapshot(BaseModel):
document: ProviderDocument
headers: dict[str, str] = Field(default_factory=dict)
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,640 @@
from __future__ import annotations
import json
from sqlalchemy import delete, select
from sqlalchemy.orm import Session, selectinload
from app.db import get_session, init_db
from app.db_models import (
AppSettingORM,
PolicyGroupORM,
ProfileORM,
ProfileRuleModuleORM,
ProfileSourceORM,
RuleModuleORM,
SourceORM,
)
from app.models import AppConfig, ClientConfig, ProxyGroupConfig, RegionConfig, RuleConfig, SourceConfig
from app.services.loader import load_yaml_app_config
def _json_dumps(value) -> str:
return json.dumps(value, ensure_ascii=False)
def _json_loads(value: str | None, default):
if not value:
return default
return json.loads(value)
def import_yaml_config_to_db(config_path, *, replace_existing: bool = False) -> None:
app_config = load_yaml_app_config(config_path)
init_db()
with get_session() as session:
if replace_existing:
_clear_config_tables(session)
_upsert_setting(session, "public_path", app_config.public_path or "")
source_rows: list[SourceORM] = []
for key, source in app_config.sources.items():
row = _get_or_create_source(session, key)
row.enabled = source.enabled
row.kind = source.kind
row.url = source.url
row.display_name = source.display_name
row.headers_json = _json_dumps(source.headers)
row.include_regex = source.include_regex
row.exclude_regex = source.exclude_regex
row.prefix = source.prefix
row.suffix = source.suffix
row.cache_ttl_seconds = source.cache_ttl_seconds
source_rows.append(row)
module_rows: list[RuleModuleORM] = []
ordered_rule_keys = list(app_config.rules.keys())
for key, rule in app_config.rules.items():
row = _get_or_create_rule_module(session, key)
row.file_path = rule.file
row.behavior = rule.behavior
row.format = rule.format
row.policy = rule.policy
row.no_resolve = rule.no_resolve
row.payload_json = _json_dumps(rule.payload)
module_rows.append(row)
session.flush()
for client_key, client in app_config.clients.items():
profile = _get_or_create_profile(session, client_key)
_populate_profile(profile, client)
session.flush()
_replace_profile_sources(session, profile.id, source_rows)
_replace_profile_groups(session, profile.id, app_config)
_replace_profile_rule_links(session, profile.id, module_rows, ordered_rule_keys)
session.commit()
def load_app_config_from_db() -> AppConfig | None:
init_db()
with get_session() as session:
return _load_app_config_from_session(session, profile_key=None)
def load_profile_app_config_from_db(profile_key: str) -> AppConfig | None:
init_db()
with get_session() as session:
return _load_app_config_from_session(session, profile_key=profile_key)
def list_sources() -> list[SourceORM]:
init_db()
with get_session() as session:
return list(session.scalars(select(SourceORM).order_by(SourceORM.key)))
def save_source(
*,
key: str,
enabled: bool,
kind: str,
url: str,
display_name: str | None,
headers: dict[str, str],
include_regex: str | None,
exclude_regex: str | None,
prefix: str,
suffix: str,
cache_ttl_seconds: int | None,
) -> None:
init_db()
with get_session() as session:
source = _get_or_create_source(session, key)
source.enabled = enabled
source.kind = kind
source.url = url
source.display_name = display_name
source.headers_json = _json_dumps(headers)
source.include_regex = include_regex
source.exclude_regex = exclude_regex
source.prefix = prefix
source.suffix = suffix
source.cache_ttl_seconds = cache_ttl_seconds
session.commit()
def delete_source(key: str) -> None:
init_db()
with get_session() as session:
source = session.scalar(select(SourceORM).where(SourceORM.key == key))
if source is None:
return
session.execute(delete(ProfileSourceORM).where(ProfileSourceORM.source_id == source.id))
session.delete(source)
session.commit()
def list_profiles() -> list[ProfileORM]:
init_db()
with get_session() as session:
return list(session.scalars(select(ProfileORM).order_by(ProfileORM.key)))
def save_profile(
*,
key: str,
title: str,
provider_interval: int,
rule_interval: int,
test_url: str,
test_interval: int,
main_policy: str,
source_policy: str,
mixed_auto_policy: str,
manual_policy: str,
direct_policy: str,
mode: str,
allow_lan: bool,
ipv6: bool,
mixed_port: int | None,
socks_port: int | None,
log_level: str | None,
) -> None:
init_db()
with get_session() as session:
profile = _get_or_create_profile(session, key)
profile.title = title
profile.provider_interval = provider_interval
profile.rule_interval = rule_interval
profile.test_url = test_url
profile.test_interval = test_interval
profile.main_policy = main_policy
profile.source_policy = source_policy
profile.mixed_auto_policy = mixed_auto_policy
profile.manual_policy = manual_policy
profile.direct_policy = direct_policy
profile.mode = mode
profile.allow_lan = allow_lan
profile.ipv6 = ipv6
profile.mixed_port = mixed_port
profile.socks_port = socks_port
profile.log_level = log_level
session.commit()
def list_profile_rule_bindings(profile_key: str) -> list[dict]:
init_db()
with get_session() as session:
profile = _load_profile_with_relationships(session, profile_key)
if profile is None:
return []
bindings: list[dict] = []
for link in sorted(profile.rule_links, key=lambda item: (item.order_index, item.id)):
module = link.rule_module
bindings.append(
{
"key": module.key,
"enabled": link.enabled,
"order_index": link.order_index,
"policy": link.policy_override or module.policy,
"file_path": module.file_path,
"behavior": module.behavior,
"format": module.format,
}
)
return bindings
def list_profile_source_bindings(profile_key: str) -> list[dict]:
init_db()
with get_session() as session:
profile = _load_profile_with_relationships(session, profile_key)
if profile is None:
return []
bindings: list[dict] = []
for link in sorted(profile.source_links, key=lambda item: (item.order_index, item.id)):
bindings.append(
{
"key": link.source.key,
"enabled": link.enabled,
"order_index": link.order_index,
"display_name": link.source.display_name or link.source.key,
"kind": link.source.kind,
"url": link.source.url,
}
)
return bindings
def update_profile_source_bindings(profile_key: str, rows: list[dict]) -> None:
init_db()
with get_session() as session:
profile = _load_profile_with_relationships(session, profile_key)
if profile is None:
raise KeyError(f"profile not found: {profile_key}")
source_keys = [row["key"] for row in rows]
sources = list(session.scalars(select(SourceORM).where(SourceORM.key.in_(source_keys))))
source_by_key = {source.key: source for source in sources}
session.execute(delete(ProfileSourceORM).where(ProfileSourceORM.profile_id == profile.id))
for row in rows:
source = source_by_key.get(row["key"])
if source is None:
continue
session.add(
ProfileSourceORM(
profile_id=profile.id,
source_id=source.id,
order_index=int(row["order_index"]),
enabled=bool(row["enabled"]),
)
)
session.commit()
def get_profile_default_source_keys(profile_key: str) -> list[str]:
init_db()
with get_session() as session:
profile = _load_profile_with_relationships(session, profile_key)
if profile is None:
return []
keys = [
link.source.key
for link in sorted(profile.source_links, key=lambda item: (item.order_index, item.id))
if link.enabled and link.source.enabled and str(link.source.url).strip()
]
return keys
def list_profile_groups(profile_key: str) -> list[dict]:
init_db()
with get_session() as session:
profile = _load_profile_with_relationships(session, profile_key)
if profile is None:
return []
rows: list[dict] = []
for group in sorted(profile.groups, key=lambda item: (item.order_index, item.id)):
rows.append(
{
"id": group.id,
"group_kind": group.group_kind,
"name": group.name,
"type": group.type,
"order_index": group.order_index,
"proxies": _json_loads(group.proxies_json, []),
"filter_regex": group.filter_regex or "",
"tolerance": group.tolerance,
"url": group.url or "",
"interval": group.interval,
"enabled": group.enabled,
}
)
return rows
def replace_profile_groups(profile_key: str, rows: list[dict]) -> None:
init_db()
with get_session() as session:
profile = _load_profile_with_relationships(session, profile_key)
if profile is None:
raise KeyError(f"profile not found: {profile_key}")
session.execute(delete(PolicyGroupORM).where(PolicyGroupORM.profile_id == profile.id))
for row in rows:
session.add(
PolicyGroupORM(
profile_id=profile.id,
group_kind=row["group_kind"],
name=row["name"],
type=row["type"],
order_index=int(row["order_index"]),
proxies_json=_json_dumps(row["proxies"]) if row["proxies"] else None,
filter_regex=row["filter_regex"] or None,
tolerance=row["tolerance"],
url=row["url"] or None,
interval=row["interval"],
enabled=bool(row["enabled"]),
)
)
session.commit()
def update_profile_rule_bindings(profile_key: str, rows: list[dict]) -> None:
init_db()
with get_session() as session:
profile = _load_profile_with_relationships(session, profile_key)
if profile is None:
raise KeyError(f"profile not found: {profile_key}")
module_by_key = {link.rule_module.key: link for link in profile.rule_links}
for row in rows:
link = module_by_key.get(row["key"])
if link is None:
continue
link.enabled = bool(row["enabled"])
link.order_index = int(row["order_index"])
policy = str(row["policy"]).strip()
link.policy_override = policy or None
session.commit()
def _load_app_config_from_session(session: Session, profile_key: str | None) -> AppConfig | None:
profiles = list(
session.scalars(
select(ProfileORM)
.options(
selectinload(ProfileORM.groups),
selectinload(ProfileORM.rule_links).selectinload(ProfileRuleModuleORM.rule_module),
)
.order_by(ProfileORM.key)
)
)
sources = list(session.scalars(select(SourceORM).order_by(SourceORM.key)))
if not profiles or not sources:
return None
target_profile = next((profile for profile in profiles if profile.key == profile_key), profiles[0])
public_path = _setting_value(session, "public_path")
regions, selector_groups, policy_groups = _build_group_configs(target_profile)
rules = _build_rule_configs(target_profile)
return AppConfig(
public_path=public_path or None,
sources={source.key: _source_to_config(source) for source in sources},
rules=rules,
clients={profile.key: _profile_to_client_config(profile) for profile in profiles},
regions=regions,
selector_groups=selector_groups,
policy_groups=policy_groups,
)
def _source_to_config(source: SourceORM) -> SourceConfig:
return SourceConfig(
enabled=source.enabled,
kind=source.kind,
url=source.url,
display_name=source.display_name,
headers=_json_loads(source.headers_json, {}),
include_regex=source.include_regex,
exclude_regex=source.exclude_regex,
prefix=source.prefix,
suffix=source.suffix,
cache_ttl_seconds=source.cache_ttl_seconds,
)
def _build_group_configs(profile: ProfileORM) -> tuple[dict[str, RegionConfig], list[ProxyGroupConfig], list[ProxyGroupConfig]]:
regions: dict[str, RegionConfig] = {}
selector_groups: list[ProxyGroupConfig] = []
policy_groups: list[ProxyGroupConfig] = []
for group in sorted(profile.groups, key=lambda item: (item.order_index, item.id)):
if not group.enabled:
continue
if group.group_kind == "region":
regions[_slugify(group.name)] = RegionConfig(
name=group.name,
filter=group.filter_regex or "",
tolerance=group.tolerance or 50,
)
continue
target = selector_groups if group.group_kind == "selector" else policy_groups
target.append(
ProxyGroupConfig(
name=group.name,
type=group.type,
proxies=_json_loads(group.proxies_json, []),
filter=group.filter_regex,
tolerance=group.tolerance,
url=group.url,
interval=group.interval,
)
)
return regions, selector_groups, policy_groups
def _build_rule_configs(profile: ProfileORM) -> dict[str, RuleConfig]:
rules: dict[str, RuleConfig] = {}
for link in sorted(profile.rule_links, key=lambda item: (item.order_index, item.id)):
if not link.enabled:
continue
module = link.rule_module
rules[module.key] = RuleConfig(
file=module.file_path,
behavior=module.behavior,
format=module.format,
policy=link.policy_override or module.policy,
no_resolve=module.no_resolve if link.no_resolve_override is None else link.no_resolve_override,
payload=_json_loads(link.payload_override_json, _json_loads(module.payload_json, [])),
)
return rules
def _load_profile_with_relationships(session: Session, profile_key: str) -> ProfileORM | None:
return session.scalar(
select(ProfileORM)
.where(ProfileORM.key == profile_key)
.options(
selectinload(ProfileORM.groups),
selectinload(ProfileORM.rule_links).selectinload(ProfileRuleModuleORM.rule_module),
selectinload(ProfileORM.source_links).selectinload(ProfileSourceORM.source),
)
)
def _clear_config_tables(session: Session) -> None:
session.execute(delete(ProfileRuleModuleORM))
session.execute(delete(PolicyGroupORM))
session.execute(delete(ProfileSourceORM))
session.execute(delete(ProfileORM))
session.execute(delete(RuleModuleORM))
session.execute(delete(SourceORM))
session.execute(delete(AppSettingORM))
session.flush()
def _upsert_setting(session: Session, key: str, value: str) -> None:
row = session.scalar(select(AppSettingORM).where(AppSettingORM.key == key))
if row is None:
row = AppSettingORM(key=key, value=value)
session.add(row)
else:
row.value = value
def _setting_value(session: Session, key: str) -> str | None:
row = session.scalar(select(AppSettingORM).where(AppSettingORM.key == key))
if row is None:
return None
return row.value
def _get_or_create_source(session: Session, key: str) -> SourceORM:
row = session.scalar(select(SourceORM).where(SourceORM.key == key))
if row is None:
row = SourceORM(key=key, kind="auto", url="")
session.add(row)
return row
def _get_or_create_rule_module(session: Session, key: str) -> RuleModuleORM:
row = session.scalar(select(RuleModuleORM).where(RuleModuleORM.key == key))
if row is None:
row = RuleModuleORM(key=key, behavior="classical", format="text", policy="DIRECT")
session.add(row)
return row
def _get_or_create_profile(session: Session, key: str) -> ProfileORM:
row = session.scalar(select(ProfileORM).where(ProfileORM.key == key))
if row is None:
row = ProfileORM(
key=key,
title=key,
test_url="https://www.gstatic.com/generate_204",
main_policy="🚀 节点选择",
source_policy="☁️ 机场选择",
mixed_auto_policy="♻️ 自动选择",
manual_policy="🚀 手动切换",
direct_policy="DIRECT",
)
session.add(row)
return row
def _populate_profile(profile: ProfileORM, client: ClientConfig) -> None:
profile.title = client.title
profile.provider_interval = client.provider_interval
profile.rule_interval = client.rule_interval
profile.test_url = str(client.test_url)
profile.test_interval = client.test_interval
profile.main_policy = client.main_policy
profile.source_policy = client.source_policy
profile.mixed_auto_policy = client.mixed_auto_policy
profile.manual_policy = client.manual_policy
profile.direct_policy = client.direct_policy
profile.mode = client.mode
profile.allow_lan = client.allow_lan
profile.ipv6 = client.ipv6
profile.mixed_port = client.mixed_port
profile.socks_port = client.socks_port
profile.log_level = client.log_level
def _replace_profile_sources(session: Session, profile_id: int, source_rows: list[SourceORM]) -> None:
session.execute(delete(ProfileSourceORM).where(ProfileSourceORM.profile_id == profile_id))
for order_index, source in enumerate(source_rows):
session.add(
ProfileSourceORM(
profile_id=profile_id,
source_id=source.id,
order_index=order_index,
enabled=True,
)
)
def _replace_profile_groups(session: Session, profile_id: int, app_config: AppConfig) -> None:
session.execute(delete(PolicyGroupORM).where(PolicyGroupORM.profile_id == profile_id))
order_index = 0
for region in app_config.regions.values():
session.add(
PolicyGroupORM(
profile_id=profile_id,
group_kind="region",
name=region.name,
type="url-test",
order_index=order_index,
filter_regex=region.filter,
tolerance=region.tolerance,
enabled=True,
)
)
order_index += 1
for group in app_config.selector_groups:
session.add(
PolicyGroupORM(
profile_id=profile_id,
group_kind="selector",
name=group.name,
type=group.type,
order_index=order_index,
proxies_json=_json_dumps(group.proxies),
filter_regex=group.filter,
tolerance=group.tolerance,
url=str(group.url) if group.url else None,
interval=group.interval,
enabled=True,
)
)
order_index += 1
for group in app_config.policy_groups:
session.add(
PolicyGroupORM(
profile_id=profile_id,
group_kind="policy",
name=group.name,
type=group.type,
order_index=order_index,
proxies_json=_json_dumps(group.proxies),
filter_regex=group.filter,
tolerance=group.tolerance,
url=str(group.url) if group.url else None,
interval=group.interval,
enabled=True,
)
)
order_index += 1
def _replace_profile_rule_links(
session: Session,
profile_id: int,
module_rows: list[RuleModuleORM],
ordered_rule_keys: list[str],
) -> None:
session.execute(delete(ProfileRuleModuleORM).where(ProfileRuleModuleORM.profile_id == profile_id))
module_by_key = {module.key: module for module in module_rows}
for order_index, key in enumerate(ordered_rule_keys):
module = module_by_key[key]
session.add(
ProfileRuleModuleORM(
profile_id=profile_id,
rule_module_id=module.id,
enabled=True,
order_index=order_index,
)
)
def _profile_to_client_config(profile: ProfileORM) -> ClientConfig:
return ClientConfig(
title=profile.title,
provider_interval=profile.provider_interval,
rule_interval=profile.rule_interval,
test_url=profile.test_url,
test_interval=profile.test_interval,
main_policy=profile.main_policy,
source_policy=profile.source_policy,
mixed_auto_policy=profile.mixed_auto_policy,
manual_policy=profile.manual_policy,
direct_policy=profile.direct_policy,
mode=profile.mode,
allow_lan=profile.allow_lan,
ipv6=profile.ipv6,
mixed_port=profile.mixed_port,
socks_port=profile.socks_port,
log_level=profile.log_level,
)
def _slugify(name: str) -> str:
text = "".join(char.lower() if char.isalnum() else "-" for char in name).strip("-")
while "--" in text:
text = text.replace("--", "-")
return text or "group"

View File

@@ -3,9 +3,11 @@ from __future__ import annotations
import os
import re
from pathlib import Path
from typing import Any
import yaml
from app.db import init_db
from app.models import AppConfig
_ENV_PATTERN = re.compile(r"\$\{([A-Z0-9_]+)\}")
@@ -21,7 +23,54 @@ def _expand_env(value):
return value
def load_app_config(path: Path) -> AppConfig:
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 {}
expanded = _expand_env(raw)
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:
init_db()
from app.services.config_store import load_app_config_from_db
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)

View File

@@ -0,0 +1,309 @@
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

@@ -0,0 +1,21 @@
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,143 +1,44 @@
from __future__ import annotations
import re
from typing import Any
import yaml
from app.models import AppConfig, ClientConfig, ProxyGroupConfig, SourceSnapshot
from app.services.rules import build_inline_rules, build_rule_provider_entries, build_rule_set_references
from app.models import AppConfig, SourceSnapshot
from app.services.policy_group_builder import build_bundle_groups, build_thin_groups
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:
return yaml.safe_dump(data, allow_unicode=True, sort_keys=False, default_flow_style=False)
def _expand_proxy_tokens(
proxies: list[str],
*,
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,
}
def _build_profile_header(*, resolved_profile) -> dict[str, Any]:
client = resolved_profile.client
profile: dict[str, Any] = {"mode": client.mode, "ipv6": client.ipv6}
if client.log_level:
profile["log-level"] = client.log_level
if client_type == "mihomo":
if resolved_profile.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
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]] = {}
for name in selected_source_names:
if client_type == "mihomo":
client = resolved_profile.client
for name in resolved_profile.selected_sources:
if resolved_profile.client_type == "mihomo":
proxy_providers[name] = {
"type": "http",
"url": f"{base_url}/{public_path}/providers/{name}.yaml",
@@ -154,10 +55,42 @@ def build_thin_profile(
"url": f"{base_url}/{public_path}/providers/{name}.yaml",
"interval": client.provider_interval,
}
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)
profile["rules"] = build_rule_set_references(app_config, client)
return proxy_providers
def build_thin_profile(
*,
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
@@ -165,275 +98,24 @@ def build_bundle_profile(
*,
client_type: str,
app_config: AppConfig,
client: ClientConfig,
snapshots: list[SourceSnapshot],
) -> dict[str, Any]:
profile: dict[str, Any] = {
"mode": client.mode,
"ipv6": client.ipv6,
}
if client.log_level:
profile["log-level"] = client.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
proxies: list[dict[str, Any]] = []
source_proxy_names: dict[str, list[str]] = {}
seen: set[str] = set()
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
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(
resolved_profile = resolve_profile(
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,
selected_source_names=[snapshot.name for snapshot in snapshots],
)
profile = _build_profile_header(resolved_profile=resolved_profile)
proxy_nodes, source_proxy_names = build_bundle_proxy_inventory(snapshots)
profile["proxies"] = [node.to_proxy_dict() for node in proxy_nodes]
profile["proxy-groups"] = build_bundle_groups(
resolved_profile=resolved_profile,
snapshots=snapshots,
source_proxy_names=source_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,
),
}
profile["rules"] = iter_resolved_rule_lines(
resolved_profile=resolved_profile,
include_rule_set_references=False,
inline_file_payloads=True,
)
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
return profile

View File

@@ -0,0 +1,73 @@
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

@@ -0,0 +1,122 @@
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,130 +1,69 @@
from __future__ import annotations
from pathlib import Path
import re
import yaml
from app.config import get_settings
from app.models import AppConfig, ClientConfig
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)
)
_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
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,
iter_resolved_rule_lines,
load_rule_payload,
load_rule_text,
resolve_policy,
)
def build_rule_provider_entries(app_config: AppConfig, client: ClientConfig, base_url: str, public_path: str):
providers: dict[str, dict] = {}
for name, rule in app_config.rules.items():
if not rule.file:
continue
entry = {
"behavior": rule.behavior,
"format": rule.format,
"url": f"{base_url}/{public_path}/rules/{name}.yaml",
"interval": rule.interval,
}
providers[name] = entry
return providers
resolved_profile = resolve_profile(
app_config=app_config,
client_type=_find_client_type(app_config, client),
selected_source_names=list(app_config.sources.keys()),
)
return build_rule_provider_entries_for_profile(
resolved_profile=resolved_profile,
base_url=base_url,
public_path=public_path,
)
def build_rule_set_references(app_config: AppConfig, client: ClientConfig) -> list[str]:
refs: list[str] = []
for name in app_config.rules:
refs.extend(_resolve_rule_lines(name, app_config, client))
refs.append(f"MATCH,{client.main_policy}")
return refs
resolved_profile = resolve_profile(
app_config=app_config,
client_type=_find_client_type(app_config, client),
selected_source_names=list(app_config.sources.keys()),
)
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]:
settings = get_settings()
lines: list[str] = []
for name, rule in app_config.rules.items():
target = resolve_policy(rule.policy, client)
for payload_line in rule.payload:
rendered = _render_payload_line(payload_line, rule.behavior)
lines.append(_attach_policy(rendered, target, rule.no_resolve))
if not rule.file:
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):
rendered = _render_payload_line(payload_line, rule.behavior)
lines.append(_attach_policy(rendered, target, rule.no_resolve))
lines.append(f"MATCH,{client.main_policy}")
return lines
resolved_profile = resolve_profile(
app_config=app_config,
client_type=_find_client_type(app_config, client),
selected_source_names=list(app_config.sources.keys()),
)
return iter_resolved_rule_lines(
resolved_profile=resolved_profile,
include_rule_set_references=False,
inline_file_payloads=True,
)
def _find_client_type(app_config: AppConfig, client: ClientConfig) -> str:
for client_type, candidate in app_config.clients.items():
if candidate == client:
return client_type
raise KeyError("client config not found in app config")
__all__ = [
"build_inline_rules",
"build_rule_provider_entries",
"build_rule_set_references",
"load_rule_payload",
"load_rule_text",
"resolve_policy",
]

1
config/app.yaml Normal file
View File

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

33
config/clients.yaml Normal file
View File

@@ -0,0 +1,33 @@
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

180
config/policy-groups.yaml Normal file
View File

@@ -0,0 +1,180 @@
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 }}"

25
config/regions.yaml Normal file
View File

@@ -0,0 +1,25 @@
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

210
config/rules.yaml Normal file
View File

@@ -0,0 +1,210 @@
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

@@ -1,7 +1,4 @@
public_path: ${PUBLIC_PATH}
sources:
airport-a:
airport-a:
enabled: true
display_name: A
kind: auto
@@ -10,7 +7,7 @@ sources:
include_regex: ""
exclude_regex: "流量|重置|到期|续费|官网|离线|套餐"
airport-b:
airport-b:
enabled: true
display_name: B
kind: auto
@@ -21,7 +18,7 @@ sources:
include_regex: ""
exclude_regex: "流量|重置|到期|续费|官网|离线|套餐"
airport-c:
airport-c:
enabled: true
display_name: C
kind: auto
@@ -29,455 +26,3 @@ sources:
prefix: "[C] "
include_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/ProxyGFWlist.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
volumes:
- ./config:/app/config:ro
- ./data:/app/data
- ${APP_DATA_DIR:-../sub-provider-data}:/app/data
- ./output:/app/output

View File

@@ -0,0 +1,686 @@
# sub-provider 分阶段重构方案
## 1. 文档目标
这份文档不是重新定义“最终想做成什么”,而是基于当前 `sub-provider` 已有实现,给出一版可逐步落地、每阶段都能交付可运行结果的重构路径。
当前项目已经具备以下基础能力:
* 读取 `config/sources.yaml`
* 拉取和缓存上游订阅
* 解析 Clash YAML / base64 URI / 明文 URI 订阅
* 合并节点并生成 bundle YAML
* 生成 thin client 配置
* 加载本地规则文件并拼接 `rules`
* 透传第一个源的 `Subscription-Userinfo`
因此重构的目标不是“从零搭系统”,而是把现有静态配置驱动的生成器,逐步改造成:
* 内部模块化更清晰
* 配置存储可演进到数据库
* 对外仍以完整 YAML 为主
* 保留现有已验证能力
* 后续可平滑加管理页和快照系统
---
## 2. 总体原则
### 原则 1对外接口尽量稳定
在重构前几阶段,尽量不打断现有输出能力:
* `/bundle/{client}.yaml`
* `/clients/{client}.yaml`
* `/providers/{name}.yaml`
* `/providers/merged.yaml`
即使最终主路线偏向 bundle也不建议在早期重构中直接删除 thin/provider 能力。
### 原则 2先拆“配置来源”再拆“表现层”
当前项目的核心问题不是生成逻辑完全不可用,而是配置全部堆在 `sources.yaml` 里。
优先级应是:
1. 把配置从单一 YAML 拆到更清晰的数据结构
2. 再引入数据库承载配置
3. 最后再补管理页面
不要一上来把大量时间花在页面上。
### 原则 3规则正文继续文件化
规则文件仍然保存在项目目录中,继续使用 Git 管理。
数据库只存:
* 规则模块元数据
* 规则启用关系
* 规则顺序
* 规则绑定到哪个策略组
不把 ACL4SSR 规则逐条拆进数据库。
### 原则 4保留动态选源能力
当前项目支持通过 `?sources=` 在请求级动态组合源,这个能力实际很有价值。
重构后不建议退化成只能在 profile 中静态绑定源。
建议模型上同时支持:
* profile 默认源集合
* 请求级 `sources` override
---
## 3. 对现有项目的基线判断
### 3.1 现状优点
当前代码结构虽然还偏轻量,但核心职责已经初步分开:
* `subscriptions.py` 负责源抓取、解析、节点转换
* `profiles.py` 负责 thin / bundle 配置组装
* `rules.py` 负责规则文件加载和规则输出
* `bundle_cache.py` / `fetch_cache.py` 负责磁盘缓存
这意味着重构时应该优先“抽象和替换内部数据来源”,而不是推倒服务层。
### 3.2 现状主要问题
当前主要限制有:
* `sources.yaml` 同时承载源配置、地区分组、业务策略组、规则绑定、客户端配置
* 配置模型偏静态,不利于面板管理
* bundle 缓存仍是 TTL 模式,不是内容指纹模式
* 没有配置快照、生成快照、审计能力
* 当前没有数据库层,也没有迁移体系
### 3.3 结论
最稳的路线不是“大重写”,而是:
1. 保持现有生成骨架
2. 把配置与渲染逻辑进一步解耦
3. 再把配置落进数据库
4. 最后加面板和快照
---
## 4. 目标架构
推荐演进成如下结构:
```text
请求
-> API 路由
-> Profile 解析
-> Source 选择
-> Source 拉取 / 缓存
-> 节点解析 / 标准化 / 处理
-> 策略组装配
-> 规则模块解析
-> 完整 YAML 渲染
-> 结果缓存 / 快照
-> 返回响应头与 YAML
```
配置来源则逐步从:
```text
sources.yaml
```
演进为:
```text
数据库配置 + 本地规则资产文件 + 少量默认 YAML 模板
```
---
## 5. 分阶段路线
建议拆成五个阶段,每个阶段结束都能保留一个稳定可运行版本。
### 阶段 0重构准备与基线冻结
目标:
* 不改行为,先明确现有能力边界
* 为后续重构建立可回归的基线
本阶段任务:
1. 盘点当前公开接口、查询参数、响应头语义
2. 记录当前 `sources.yaml` 中各配置块的职责
3. 为核心生成路径补最小测试
4. 输出一份“当前行为基线”文档
验收标准:
* 至少覆盖以下场景:
* 单源 provider
* 多源 merged provider
* bundle 输出
* thin 输出
* 第一个源配额头透传
* 有一组固定输入可验证当前输出结构不被意外破坏
建议产物:
* `tests/` 下最小回归测试
* `docs/当前行为基线.md`
说明:
这一阶段很重要。没有基线,后面数据库化后很难判断是“重构带来的结构变化”,还是“偷偷改了行为”。
---
### 阶段 1配置解耦但仍以 YAML 为配置源
目标:
* 先把“一个巨大的 `sources.yaml`”拆成多个清晰模块
* 暂时不引入数据库
* 不改变线上运行方式
本阶段任务:
1. 拆分当前 `sources.yaml`
* `config/sources.yaml`
* `config/clients.yaml`
* `config/rule-bindings.yaml`
* `config/policy-groups.yaml`
* `config/regions.yaml`
2. 新增统一配置加载层,把多个 YAML 聚合成当前运行所需的内部配置对象
3. 把当前 `AppConfig` 进一步拆开,减少“大一统模型”
4.`main.py` 不再直接依赖单个全局 `app_config`
5. 把“配置加载”和“配置解释”职责从“服务逻辑”中剥离
建议目录演进:
```text
app/
config.py
main.py
models/
source.py
client.py
profile.py
rule.py
group.py
services/
config_loader.py
subscriptions.py
profiles.py
rules.py
```
验收标准:
* 对外接口路径和行为保持不变
* `bundle``thin` 输出内容结构基本一致
* 已不再依赖单个巨型 YAML 文件
这一阶段收益:
* 先把系统结构理顺
* 后续接数据库时,只需要替换配置仓储层,不需要大改生成器本身
---
### 阶段 2引入数据库配置层保留 YAML 兼容导入
目标:
* 让配置从静态文件迁移到数据库
* 但仍支持从 YAML 导入初始数据
本阶段任务:
1. 引入 SQLAlchemy 与 Alembic
2. 增加 `DATABASE_URL`
3. 建立第一批核心表
4. 实现配置仓储层
5. 提供 YAML 初始化导入脚本
推荐第一批表:
* `sources`
* `profiles`
* `profile_sources`
* `rule_modules`
* `profile_rule_modules`
* `policy_groups`
* `profile_overrides`
推荐暂不引入的表:
* `generated_artifacts`
* `sync_logs`
* `rule_module_versions`
原因:
先把“当前运行配置”迁过去,比一开始就把快照、审计、版本全部做全更稳。
#### 推荐字段设计
`sources`
* `id`
* `key`
* `name`
* `enabled`
* `kind`
* `url`
* `display_name`
* `headers_json`
* `include_regex`
* `exclude_regex`
* `prefix`
* `suffix`
* `cache_ttl_seconds`
* `created_at`
* `updated_at`
`profiles`
* `id`
* `key`
* `name`
* `client_type`
* `description`
* `enabled`
* `allow_lan`
* `ipv6`
* `mixed_port`
* `socks_port`
* `mode`
* `log_level`
* `main_policy`
* `source_policy`
* `mixed_auto_policy`
* `manual_policy`
* `direct_policy`
* `test_url`
* `test_interval`
* `provider_interval`
* `rule_interval`
* `created_at`
* `updated_at`
`profile_sources`
* `id`
* `profile_id`
* `source_id`
* `order_index`
* `enabled`
`rule_modules`
* `id`
* `key`
* `name`
* `description`
* `file_path`
* `behavior`
* `format`
* `default_policy`
* `default_no_resolve`
* `default_payload_json`
* `category`
* `created_at`
* `updated_at`
`profile_rule_modules`
* `id`
* `profile_id`
* `rule_module_id`
* `enabled`
* `order_index`
* `policy_override`
* `payload_override_json`
* `no_resolve_override`
`policy_groups`
* `id`
* `profile_id`
* `name`
* `group_kind`
* `type`
* `order_index`
* `filter_regex`
* `proxies_json`
* `url`
* `interval`
* `tolerance`
* `enabled`
这里的 `group_kind` 建议至少支持:
* `static`
* `filter`
* `generated`
这比单纯用一个 `config_json` 更适合当前项目,因为现有组装里同时存在静态组和按 regex 选节点的组。
#### 仓储层建议
新增仓储抽象,例如:
* `SourceRepository`
* `ProfileRepository`
* `RuleModuleRepository`
* `PolicyGroupRepository`
让生成服务只依赖仓储接口,而不是依赖数据库实现细节。
验收标准:
* 使用数据库也能生成与阶段 1 基本一致的 bundle/thin 配置
* 可以从原 YAML 导入初始配置
* 切换配置来源时,渲染层无需大改
---
### 阶段 3渲染流水线模块化与输出主路线收敛
目标:
* 把“源处理、规则处理、组装渲染”彻底做成稳定流水线
* 明确 bundle 是主输出路线
* thin/provider 进入兼容保留状态
本阶段任务:
1. 拆分服务层职责:
* `source_fetcher.py`
* `source_parser.py`
* `proxy_processor.py`
* `policy_group_builder.py`
* `rule_resolver.py`
* `profile_renderer.py`
2. 引入统一内部节点模型
3. 引入统一内部“已解析 profile”模型
4. 将当前 `build_bundle_profile()` 的逻辑拆成多步处理
5. 清理当前 token 展开逻辑,明确保留哪些模板 token
#### 内部模型建议
不要把节点模型收得太死。建议:
```python
class ProxyNode(BaseModel):
name: str
type: str
server: str | None = None
port: int | None = None
udp: bool = True
tags: list[str] = Field(default_factory=list)
attrs: dict[str, Any] = Field(default_factory=dict)
```
其中:
* 通用字段单独保留
* 协议特有字段放 `attrs`
* 最终输出时再按协议合并回 YAML 结构
这样比单纯的 `raw` 更可控。
#### 规则系统建议
这一阶段不要急着把所有规则文件改成 `{{ target_group }}` 模板格式。
先保留当前模式:
* 文件内容仍是 payload
* policy 在 profile 绑定关系上决定
原因:
* 当前 thin 模式的 `rule-providers` 仍依赖 payload 文件输出
* 先保留兼容性,后面再决定是否升级规则文件模板化
验收标准:
* bundle 渲染主路径清晰可测
* 服务层之间依赖方向清楚
* 配置仓储和渲染器分离
---
### 阶段 4管理接口与最小面板
目标:
* 提供最小配置管理能力
* 不做复杂前后端分离
本阶段任务:
1. 增加管理 API
* source CRUD
* profile CRUD
* rule module 查看与 profile 绑定管理
* policy group 查看与编辑
2. 增加预览接口
3. 增加最小页面:
* sources
* profiles
* rule bindings
* preview
4. 提供 profile 级 bundle 下载入口
建议接口:
* `GET /api/sources`
* `POST /api/sources`
* `PUT /api/sources/{id}`
* `GET /api/profiles`
* `POST /api/profiles`
* `PUT /api/profiles/{id}`
* `GET /api/profiles/{id}/rules`
* `PUT /api/profiles/{id}/rules`
* `GET /api/profiles/{id}/groups`
* `PUT /api/profiles/{id}/groups`
* `POST /api/profiles/{id}/preview`
* `GET /profiles/{key}/bundle.yaml`
面板技术建议:
* Jinja2
* HTMX 或最少量原生 JS
不建议此时做:
* React/Vue 前后端分离
* 复杂权限系统
* 在线全文规则编辑器
验收标准:
* 不改代码即可新增一个 source
* 不改 YAML 文件即可调整 profile 规则顺序
* 页面可直接预览 bundle 输出
---
### 阶段 5快照、缓存升级与可追踪性
目标:
* 从“能生成”升级到“可追踪、可复现、可排查”
本阶段任务:
1. 增加 `generated_artifacts`
2. 增加内容指纹缓存
3. 增加源同步状态记录
4. 增加简单 diff 能力
5. 增加错误展示
推荐新增表:
`generated_artifacts`
* `id`
* `profile_id`
* `request_sources_json`
* `content`
* `content_hash`
* `source_hash`
* `rules_hash`
* `profile_hash`
* `headers_json`
* `generated_at`
`source_sync_logs`
* `id`
* `source_id`
* `status`
* `error_message`
* `response_headers_json`
* `content_hash`
* `created_at`
#### 缓存策略建议
当前项目 bundle 缓存是 TTL 模式。后续应逐步改为:
* fetch cache: TTL
* parsed snapshot cache: 源内容 hash
* bundle cache: `source_hash + rules_hash + profile_hash + request_sources`
这样才能真正做到:
* 配置没变就稳定复用
* 配置变了就精准失效
验收标准:
* 能查看某个 profile 最近几次生成记录
* 能知道本次 bundle 为什么重新生成
* 能追踪某个源最近一次同步是否失败
---
## 6. 推荐实施顺序
如果按投入产出比排序,建议实际开发顺序如下:
1. 阶段 0
2. 阶段 1
3. 阶段 2
4. 阶段 3
5. 阶段 4
6. 阶段 5
其中真正的“第一版可交付里程碑”建议定在阶段 2 结束时。
因为到了阶段 2已经具备
* 数据库存储配置
* 可继续生成完整 YAML
* 配置不再绑死在单文件 YAML 中
这时即使还没有面板,系统内核已经完成了最关键升级。
---
## 7. 每阶段风险点
### 阶段 1 风险
风险:
* 拆 YAML 时容易出现字段兼容问题
控制方式:
* 先保留兼容加载层
* 老配置格式在一段时间内继续可读
### 阶段 2 风险
风险:
* 数据建模过度,导致表过多、实现过重
控制方式:
* 第一批只建运行必需表
* 快照与日志延后
### 阶段 3 风险
风险:
* 过早重写规则系统,导致 thin 模式兼容性下降
控制方式:
* 先保留 payload 模式
* 规则模板化放后续增量处理
### 阶段 4 风险
风险:
* 过早投入页面开发,拖慢主流程
控制方式:
* 先 API 后页面
* 页面只做最小可用
### 阶段 5 风险
风险:
* 快照和缓存逻辑做得太复杂,维护成本上升
控制方式:
* 先做内容哈希与生成记录
* diff 与审计能力逐步补
---
## 8. 明确不建议的做法
1. 不建议一开始就删除当前 thin/provider 能力
2. 不建议一开始就把所有规则文件模板化
3. 不建议把 profile 的源集合只做成一个 `source_ids_json`
4. 不建议把策略组全塞进一个不透明 `config_json`
5. 不建议先做复杂前端再补业务内核
6. 不建议先做快照系统再做数据库配置层
---
## 9. 建议的第一批实际改动
如果下一步开始真正动代码,建议先做这几件事:
1. 新建阶段 0 的测试和基线文档
2. 拆分 `sources.yaml`,完成阶段 1
3. 引入 SQLAlchemy/Alembic 和最小表结构
4. 增加 YAML -> DB 导入脚本
5. 让 bundle 渲染从数据库读取 profile / source / rules 配置
这五步完成后,再去做页面,整体节奏会稳很多。
---
## 10. 一句话结论
`sub-provider` 的重构最稳路线是:
> 先做配置解耦,再做数据库配置层,再做渲染流水线固化,最后补管理页面和快照系统。
这样每一阶段都能保留一个可运行版本,也最符合当前项目已经有代码基础的现实情况。

976
docs/改造方案.md Normal file
View File

@@ -0,0 +1,976 @@
下面这份你可以直接丢给 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,3 +3,8 @@ uvicorn[standard]>=0.30,<1.0
httpx>=0.27,<1.0
PyYAML>=6.0,<7.0
pydantic-settings>=2.3,<3.0
SQLAlchemy>=2.0,<3.0
alembic>=1.13,<2.0
Jinja2>=3.1,<4.0
python-multipart>=0.0.9,<1.0
itsdangerous>=2.2,<3.0

136
templates/base.html Normal file
View File

@@ -0,0 +1,136 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>sub-provider admin</title>
<style>
:root {
--bg: #f3efe6;
--panel: #fffdf8;
--ink: #1f2a2e;
--muted: #66757f;
--line: #d7ccbb;
--accent: #b8542a;
--accent-soft: #f0d7c5;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Segoe UI", "PingFang SC", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, #f7d9c5, transparent 28%),
radial-gradient(circle at bottom right, #dce8da, transparent 22%),
var(--bg);
}
a { color: inherit; }
.shell {
max-width: 1320px;
margin: 0 auto;
padding: 24px;
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
gap: 24px;
}
.nav, .panel {
background: rgba(255, 253, 248, 0.92);
backdrop-filter: blur(10px);
border: 1px solid var(--line);
border-radius: 24px;
box-shadow: 0 20px 40px rgba(31, 42, 46, 0.08);
}
.nav { padding: 18px; align-self: start; position: sticky; top: 18px; }
.brand { font-size: 24px; font-weight: 700; margin-bottom: 20px; }
.nav a {
display: block;
text-decoration: none;
padding: 10px 12px;
border-radius: 12px;
margin-bottom: 8px;
}
.nav a.active {
background: var(--accent);
color: #fff;
}
.nav form { margin-top: 18px; }
.panel { padding: 24px; }
h1, h2 { margin-top: 0; }
.muted { color: var(--muted); }
.stack { display: grid; gap: 20px; }
.grid-2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.card {
border: 1px solid var(--line);
border-radius: 18px;
padding: 18px;
background: var(--panel);
}
label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 6px; }
input, select, textarea {
width: 100%;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--line);
background: #fff;
color: var(--ink);
}
textarea { min-height: 140px; resize: vertical; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 12px 10px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
th { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; }
.actions { display: flex; gap: 10px; flex-wrap: wrap; }
button, .button {
border: 0;
border-radius: 999px;
padding: 10px 16px;
font-weight: 600;
cursor: pointer;
background: var(--accent);
color: #fff;
text-decoration: none;
display: inline-block;
}
.button.secondary, button.secondary {
background: var(--accent-soft);
color: var(--ink);
}
.flash {
padding: 12px 14px;
border-radius: 14px;
border: 1px solid var(--line);
margin-bottom: 18px;
}
.flash.success { background: #e8f3e6; border-color: #b7d3b1; }
.flash.error { background: #f7dfdb; border-color: #ddb0a7; }
.inline { display: inline-flex; align-items: center; gap: 8px; }
.wide { width: 100%; min-height: 65vh; font-family: Consolas, monospace; }
@media (max-width: 920px) {
.shell { grid-template-columns: 1fr; }
.nav { position: static; }
.grid-2 { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="shell">
<nav class="nav">
<div class="brand">sub-provider</div>
{% if authenticated %}
<a href="/admin/sources" class="{% if active == 'sources' %}active{% endif %}">Sources</a>
<a href="/admin/profiles" class="{% if active == 'profiles' %}active{% endif %}">Profiles</a>
{% if auth_enabled %}
<form method="post" action="/admin/logout">
<button type="submit" class="secondary" style="width:100%;">Logout</button>
</form>
{% endif %}
{% endif %}
</nav>
<main class="panel">
{% if flash %}
<div class="flash {{ flash.level }}">{{ flash.message }}</div>
{% endif %}
{% block content %}{% endblock %}
</main>
</div>
</body>
</html>

126
templates/groups.html Normal file
View File

@@ -0,0 +1,126 @@
{% extends "base.html" %}
{% block content %}
<div class="stack">
<div class="actions" style="justify-content:space-between; align-items:center;">
<div>
<h1>Groups: {{ profile_key }}</h1>
<p class="muted">管理 region / selector / policy groups。当前版本以整表提交为主。</p>
</div>
<div class="actions">
<a class="button secondary" href="/admin/profiles">Back</a>
<a class="button" href="/admin/profiles/{{ profile_key }}/preview">Preview</a>
</div>
</div>
<form method="post" action="/admin/profiles/{{ profile_key }}/groups" class="stack">
{% for group in groups %}
<div class="card">
<input type="hidden" name="group_id" value="{{ group.id }}">
<div class="grid-2">
<div>
<label>Name</label>
<input name="group_name_{{ loop.index0 }}" value="{{ group.name }}">
</div>
<div>
<label>Kind</label>
<select name="group_kind_{{ loop.index0 }}">
<option value="region" {% if group.group_kind == 'region' %}selected{% endif %}>region</option>
<option value="selector" {% if group.group_kind == 'selector' %}selected{% endif %}>selector</option>
<option value="policy" {% if group.group_kind == 'policy' %}selected{% endif %}>policy</option>
</select>
</div>
<div>
<label>Type</label>
<select name="type_{{ loop.index0 }}">
<option value="select" {% if group.type == 'select' %}selected{% endif %}>select</option>
<option value="url-test" {% if group.type == 'url-test' %}selected{% endif %}>url-test</option>
</select>
</div>
<div>
<label>Order</label>
<input name="order_index_{{ loop.index0 }}" value="{{ group.order_index }}">
</div>
<div>
<label>Filter Regex</label>
<input name="filter_regex_{{ loop.index0 }}" value="{{ group.filter_regex }}">
</div>
<div>
<label>Tolerance</label>
<input name="tolerance_{{ loop.index0 }}" value="{{ group.tolerance or '' }}">
</div>
<div>
<label>URL</label>
<input name="url_{{ loop.index0 }}" value="{{ group.url }}">
</div>
<div>
<label>Interval</label>
<input name="interval_{{ loop.index0 }}" value="{{ group.interval or '' }}">
</div>
</div>
<div style="margin-top:12px;">
<label>Proxies / Tokens</label>
<textarea name="proxies_{{ loop.index0 }}">{% for item in group.proxies %}{{ item }}{% if not loop.last %}
{% endif %}{% endfor %}</textarea>
</div>
<div class="actions" style="margin-top:12px;">
<label class="inline"><input type="checkbox" name="enabled_{{ loop.index0 }}" {% if group.enabled %}checked{% endif %} style="width:auto"> Enabled</label>
<label class="inline"><input type="checkbox" name="delete_{{ loop.index0 }}" style="width:auto"> Delete</label>
</div>
</div>
{% endfor %}
<div class="card">
<h2>New Group</h2>
<div class="grid-2">
<div>
<label>Name</label>
<input name="new_group_name" placeholder="🤖 AI">
</div>
<div>
<label>Kind</label>
<select name="new_group_kind">
<option value="policy">policy</option>
<option value="selector">selector</option>
<option value="region">region</option>
</select>
</div>
<div>
<label>Type</label>
<select name="new_group_type">
<option value="select">select</option>
<option value="url-test">url-test</option>
</select>
</div>
<div>
<label>Order</label>
<input name="new_group_order_index" placeholder="{{ groups|length }}">
</div>
<div>
<label>Filter Regex</label>
<input name="new_group_filter_regex">
</div>
<div>
<label>Tolerance</label>
<input name="new_group_tolerance">
</div>
<div>
<label>URL</label>
<input name="new_group_url">
</div>
<div>
<label>Interval</label>
<input name="new_group_interval">
</div>
</div>
<div style="margin-top:12px;">
<label>Proxies / Tokens</label>
<textarea name="new_group_proxies"></textarea>
</div>
<label class="inline" style="margin-top:12px;"><input type="checkbox" name="new_group_enabled" checked style="width:auto"> Enabled</label>
</div>
<div class="actions">
<button type="submit">Save Groups</button>
</div>
</form>
</div>
{% endblock %}

73
templates/login.html Normal file
View File

@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>sub-provider login</title>
<style>
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
font-family: "Segoe UI", "PingFang SC", sans-serif;
background:
radial-gradient(circle at top left, #f7d9c5, transparent 28%),
radial-gradient(circle at bottom right, #dce8da, transparent 22%),
#f3efe6;
color: #1f2a2e;
}
.card {
width: min(420px, calc(100vw - 32px));
background: rgba(255,253,248,0.95);
border: 1px solid #d7ccbb;
border-radius: 24px;
padding: 28px;
box-shadow: 0 20px 40px rgba(31,42,46,0.08);
}
h1 { margin-top: 0; }
label { display:block; margin-bottom: 6px; color:#66757f; font-size: 13px; }
input {
width:100%;
padding: 12px;
border-radius: 12px;
border: 1px solid #d7ccbb;
box-sizing: border-box;
}
button {
margin-top: 16px;
width: 100%;
border: 0;
border-radius: 999px;
padding: 12px 16px;
background: #b8542a;
color: #fff;
font-weight: 600;
cursor: pointer;
}
.muted { color:#66757f; }
.flash {
padding: 12px 14px;
border-radius: 14px;
border: 1px solid #ddb0a7;
background: #f7dfdb;
margin-bottom: 18px;
}
</style>
</head>
<body>
<div class="card">
<h1>Admin Login</h1>
<p class="muted">输入 `.env` 中配置的 `ADMIN_TOKEN`。</p>
{% if flash %}
<div class="flash">{{ flash.message }}</div>
{% endif %}
<form method="post" action="/admin/login">
<input type="hidden" name="next" value="{{ next }}">
<label>Token</label>
<input type="password" name="token" autofocus>
<button type="submit">Login</button>
</form>
</div>
</body>
</html>

31
templates/preview.html Normal file
View File

@@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block content %}
<div class="stack">
<div class="actions" style="justify-content:space-between; align-items:center;">
<div>
<h1>Preview: {{ profile_key }}</h1>
<p class="muted">预览 profile 当前生成的完整 bundle YAML。</p>
</div>
<div class="actions">
<a class="button secondary" href="/admin/profiles/{{ profile_key }}/rules">Rules</a>
<a class="button" href="/admin/profiles">Profiles</a>
</div>
</div>
<div class="card">
<form method="get" action="/admin/profiles/{{ profile_key }}/preview" class="stack">
<div>
<label>Sources (comma separated)</label>
<input name="sources" value="{{ selected_sources|join(',') }}" placeholder="{{ available_sources|join(',') }}">
</div>
<div class="actions">
<button type="submit">Refresh Preview</button>
</div>
</form>
</div>
<div class="card">
<textarea class="wide" readonly>{{ yaml_text }}</textarea>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block content %}
<div class="stack">
<div class="actions" style="justify-content:space-between; align-items:center;">
<div>
<h1>Profile Sources: {{ profile_key }}</h1>
<p class="muted">配置 profile 默认会使用哪些源,以及它们的顺序。</p>
</div>
<div class="actions">
<a class="button secondary" href="/admin/profiles">Back</a>
<a class="button" href="/admin/profiles/{{ profile_key }}/preview">Preview</a>
</div>
</div>
<form method="post" action="/admin/profiles/{{ profile_key }}/sources" class="card">
<table>
<thead>
<tr><th>Enabled</th><th>Key</th><th>Order</th><th>Type</th><th>URL</th></tr>
</thead>
<tbody>
{% for binding in bindings %}
<tr>
<td>
<input type="hidden" name="source_key" value="{{ binding.key }}">
<input type="checkbox" name="enabled_{{ binding.key }}" {% if binding.enabled %}checked{% endif %} style="width:auto">
</td>
<td><strong>{{ binding.key }}</strong><div class="muted">{{ binding.display_name }}</div></td>
<td><input name="order_index_{{ binding.key }}" value="{{ binding.order_index }}"></td>
<td>{{ binding.kind }}</td>
<td class="muted">{{ binding.url }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="actions" style="margin-top:18px;">
<button type="submit">Save Source Bindings</button>
</div>
</form>
</div>
{% endblock %}

60
templates/profiles.html Normal file
View File

@@ -0,0 +1,60 @@
{% extends "base.html" %}
{% block content %}
<div class="stack">
<div>
<h1>Profiles</h1>
<p class="muted">管理客户端模板,并跳转到规则和预览页。</p>
</div>
<div class="card">
<h2>New / Edit Profile</h2>
<form method="post" action="/admin/profiles" class="stack">
<div class="grid-2">
<div><label>Key</label><input name="key" required placeholder="mihomo"></div>
<div><label>Title</label><input name="title" required></div>
<div><label>Provider Interval</label><input name="provider_interval" value="21600"></div>
<div><label>Rule Interval</label><input name="rule_interval" value="86400"></div>
<div><label>Test URL</label><input name="test_url" value="https://www.gstatic.com/generate_204"></div>
<div><label>Test Interval</label><input name="test_interval" value="300"></div>
<div><label>Main Policy</label><input name="main_policy" value="🚀 节点选择"></div>
<div><label>Source Policy</label><input name="source_policy" value="☁️ 机场选择"></div>
<div><label>Mixed Auto Policy</label><input name="mixed_auto_policy" value="♻️ 自动选择"></div>
<div><label>Manual Policy</label><input name="manual_policy" value="🚀 手动切换"></div>
<div><label>Direct Policy</label><input name="direct_policy" value="DIRECT"></div>
<div><label>Mode</label><input name="mode" value="rule"></div>
<div><label>Mixed Port</label><input name="mixed_port" value="7890"></div>
<div><label>Socks Port</label><input name="socks_port" value="7891"></div>
<div><label>Log Level</label><input name="log_level" value="info"></div>
</div>
<label class="inline"><input type="checkbox" name="allow_lan" checked style="width:auto"> Allow LAN</label>
<label class="inline"><input type="checkbox" name="ipv6" checked style="width:auto"> IPv6</label>
<div class="actions"><button type="submit">Save Profile</button></div>
</form>
</div>
<div class="stack">
{% for profile in profiles %}
<div class="card">
<div class="actions" style="justify-content:space-between; align-items:center;">
<div>
<h2 style="margin-bottom:4px;">{{ profile.key }}</h2>
<div class="muted">{{ profile.title }}</div>
</div>
<div class="actions">
<a class="button secondary" href="/admin/profiles/{{ profile.key }}/sources">Sources</a>
<a class="button secondary" href="/admin/profiles/{{ profile.key }}/groups">Groups</a>
<a class="button secondary" href="/admin/profiles/{{ profile.key }}/rules">Rules</a>
<a class="button" href="/admin/profiles/{{ profile.key }}/preview">Preview</a>
</div>
</div>
<div class="grid-2" style="margin-top:14px;">
<div><strong>Main:</strong> {{ profile.main_policy }}</div>
<div><strong>Source:</strong> {{ profile.source_policy }}</div>
<div><strong>Test URL:</strong> <span class="muted">{{ profile.test_url }}</span></div>
<div><strong>Mode:</strong> {{ profile.mode }}</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endblock %}

40
templates/rules.html Normal file
View File

@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block content %}
<div class="stack">
<div class="actions" style="justify-content:space-between; align-items:center;">
<div>
<h1>Rules: {{ profile_key }}</h1>
<p class="muted">控制规则模块启用状态、顺序和目标策略组。</p>
</div>
<div class="actions">
<a class="button secondary" href="/admin/profiles">Back</a>
<a class="button" href="/admin/profiles/{{ profile_key }}/preview">Preview</a>
</div>
</div>
<form method="post" action="/admin/profiles/{{ profile_key }}/rules" class="card">
<table>
<thead>
<tr><th>Enabled</th><th>Key</th><th>Order</th><th>Policy</th><th>Rule File</th></tr>
</thead>
<tbody>
{% for binding in bindings %}
<tr>
<td>
<input type="hidden" name="rule_key" value="{{ binding.key }}">
<input type="checkbox" name="enabled_{{ binding.key }}" {% if binding.enabled %}checked{% endif %} style="width:auto">
</td>
<td><strong>{{ binding.key }}</strong><div class="muted">{{ binding.behavior }} / {{ binding.format }}</div></td>
<td><input name="order_index_{{ binding.key }}" value="{{ binding.order_index }}"></td>
<td><input name="policy_{{ binding.key }}" value="{{ binding.policy }}"></td>
<td class="muted">{{ binding.file_path or "inline payload" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="actions" style="margin-top:18px;">
<button type="submit">Save Rules</button>
</div>
</form>
</div>
{% endblock %}

55
templates/sources.html Normal file
View File

@@ -0,0 +1,55 @@
{% extends "base.html" %}
{% block content %}
<div class="stack">
<div>
<h1>Sources</h1>
<p class="muted">管理订阅源。先做最小可用版本:增改删和启停。</p>
</div>
<div class="card">
<h2>New / Edit Source</h2>
<form method="post" action="/admin/sources" class="stack">
<div class="grid-2">
<div><label>Key</label><input name="key" required placeholder="airport-d"></div>
<div><label>Display Name</label><input name="display_name" placeholder="D"></div>
<div><label>Kind</label><select name="kind"><option value="auto">auto</option><option value="clash_yaml">clash_yaml</option><option value="base64_uri">base64_uri</option><option value="uri">uri</option></select></div>
<div><label>Cache TTL Seconds</label><input name="cache_ttl_seconds" placeholder="900"></div>
</div>
<div><label>URL</label><input name="url" required></div>
<div class="grid-2">
<div><label>Prefix</label><input name="prefix"></div>
<div><label>Suffix</label><input name="suffix"></div>
<div><label>Include Regex</label><input name="include_regex"></div>
<div><label>Exclude Regex</label><input name="exclude_regex"></div>
</div>
<label class="inline"><input type="checkbox" name="enabled" checked style="width:auto"> Enabled</label>
<div class="actions"><button type="submit">Save Source</button></div>
</form>
</div>
<div class="card">
<h2>Existing Sources</h2>
<table>
<thead>
<tr><th>Key</th><th>Type</th><th>URL</th><th>Prefix</th><th>Status</th><th></th></tr>
</thead>
<tbody>
{% for source in sources %}
<tr>
<td>{{ source.key }}</td>
<td>{{ source.kind }}</td>
<td class="muted">{{ source.url }}</td>
<td>{{ source.prefix }}</td>
<td>{{ "enabled" if source.enabled else "disabled" }}</td>
<td>
<form method="post" action="/admin/sources/{{ source.key }}/delete" onsubmit="return confirm('Delete source {{ source.key }}?')">
<button type="submit" class="secondary">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}