@@ -3,10 +3,6 @@ APP_ENV=prod
|
|||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
PORT=18080
|
PORT=18080
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
DATABASE_URL=sqlite:////app/data/app.db
|
|
||||||
|
|
||||||
# Docker 运行时数据目录,建议放在仓库外
|
|
||||||
APP_DATA_DIR=../sub-provider-data
|
|
||||||
|
|
||||||
# 对外访问前缀,尽量改成足够长的随机字符串
|
# 对外访问前缀,尽量改成足够长的随机字符串
|
||||||
PUBLIC_PATH=change-me-random-hash-path
|
PUBLIC_PATH=change-me-random-hash-path
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -62,8 +62,6 @@ cover/
|
|||||||
local_settings.py
|
local_settings.py
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
db.sqlite3-journal
|
db.sqlite3-journal
|
||||||
data/
|
|
||||||
output/bundle-cache/
|
|
||||||
|
|
||||||
# Flask stuff:
|
# Flask stuff:
|
||||||
instance/
|
instance/
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -72,8 +72,6 @@ cp .env.example .env
|
|||||||
|
|
||||||
2. 编辑 `.env`:
|
2. 编辑 `.env`:
|
||||||
|
|
||||||
- `DATABASE_URL` 默认可用 `sqlite:////app/data/app.db`
|
|
||||||
- `APP_DATA_DIR` 建议指向仓库外目录,例如 `../sub-provider-data`
|
|
||||||
- `PUBLIC_PATH` 改成足够长的随机字符串
|
- `PUBLIC_PATH` 改成足够长的随机字符串
|
||||||
- `PUBLIC_BASE_URL` 建议填写你反代后的最终访问地址,例如 `https://sub.example.com`
|
- `PUBLIC_BASE_URL` 建议填写你反代后的最终访问地址,例如 `https://sub.example.com`
|
||||||
- `AIRPORT_A_URL` / `AIRPORT_B_URL` / `AIRPORT_C_URL` 都可以直接填订阅地址,项目会自动判断是 YAML 还是 URI 订阅
|
- `AIRPORT_A_URL` / `AIRPORT_B_URL` / `AIRPORT_C_URL` 都可以直接填订阅地址,项目会自动判断是 YAML 还是 URI 订阅
|
||||||
@@ -89,27 +87,13 @@ cp .env.example .env
|
|||||||
- `rules.yaml`:规则绑定
|
- `rules.yaml`:规则绑定
|
||||||
- `clients.yaml`:客户端模板
|
- `clients.yaml`:客户端模板
|
||||||
|
|
||||||
4. 如需切换到数据库配置源,先执行一次导入:
|
4. 启动:
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m app.import_config --replace
|
|
||||||
```
|
|
||||||
|
|
||||||
导入完成后,运行时会优先读取数据库;当数据库内没有配置时,仍会回退到 `config/` 下的 YAML 文件。
|
|
||||||
|
|
||||||
说明:
|
|
||||||
|
|
||||||
- `app.db`、抓取缓存等运行态数据默认写入 `/app/data`
|
|
||||||
- `docker-compose` 已将 `/app/data` 挂载到 `APP_DATA_DIR`
|
|
||||||
- 建议把这个目录放在仓库外,避免 `git pull`、切分支时碰到本地状态文件
|
|
||||||
|
|
||||||
5. 启动:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
6. 访问检查:
|
5. 访问检查:
|
||||||
|
|
||||||
- 健康检查:`http://YOUR_HOST:18080/healthz`
|
- 健康检查:`http://YOUR_HOST:18080/healthz`
|
||||||
- 单 provider:
|
- 单 provider:
|
||||||
|
|||||||
36
alembic.ini
36
alembic.ini
@@ -1,36 +0,0 @@
|
|||||||
[alembic]
|
|
||||||
script_location = alembic
|
|
||||||
prepend_sys_path = .
|
|
||||||
sqlalchemy.url = sqlite:///./data/app.db
|
|
||||||
|
|
||||||
[loggers]
|
|
||||||
keys = root,sqlalchemy,alembic
|
|
||||||
|
|
||||||
[handlers]
|
|
||||||
keys = console
|
|
||||||
|
|
||||||
[formatters]
|
|
||||||
keys = generic
|
|
||||||
|
|
||||||
[logger_root]
|
|
||||||
level = WARN
|
|
||||||
handlers = console
|
|
||||||
|
|
||||||
[logger_sqlalchemy]
|
|
||||||
level = WARN
|
|
||||||
handlers =
|
|
||||||
qualname = sqlalchemy.engine
|
|
||||||
|
|
||||||
[logger_alembic]
|
|
||||||
level = INFO
|
|
||||||
handlers = console
|
|
||||||
qualname = alembic
|
|
||||||
|
|
||||||
[handler_console]
|
|
||||||
class = StreamHandler
|
|
||||||
args = (sys.stderr,)
|
|
||||||
level = NOTSET
|
|
||||||
formatter = generic
|
|
||||||
|
|
||||||
[formatter_generic]
|
|
||||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from logging.config import fileConfig
|
|
||||||
|
|
||||||
from alembic import context
|
|
||||||
from sqlalchemy import engine_from_config, pool
|
|
||||||
|
|
||||||
from app.config import get_settings
|
|
||||||
from app.db_models import Base
|
|
||||||
|
|
||||||
config = context.config
|
|
||||||
if config.config_file_name is not None:
|
|
||||||
fileConfig(config.config_file_name)
|
|
||||||
|
|
||||||
config.set_main_option("sqlalchemy.url", get_settings().database_url)
|
|
||||||
target_metadata = Base.metadata
|
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_offline() -> None:
|
|
||||||
url = config.get_main_option("sqlalchemy.url")
|
|
||||||
context.configure(url=url, target_metadata=target_metadata, literal_binds=True, compare_type=True)
|
|
||||||
with context.begin_transaction():
|
|
||||||
context.run_migrations()
|
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_online() -> None:
|
|
||||||
connectable = engine_from_config(
|
|
||||||
config.get_section(config.config_ini_section, {}),
|
|
||||||
prefix="sqlalchemy.",
|
|
||||||
poolclass=pool.NullPool,
|
|
||||||
)
|
|
||||||
with connectable.connect() as connection:
|
|
||||||
context.configure(connection=connection, target_metadata=target_metadata, compare_type=True)
|
|
||||||
with context.begin_transaction():
|
|
||||||
context.run_migrations()
|
|
||||||
|
|
||||||
|
|
||||||
if context.is_offline_mode():
|
|
||||||
run_migrations_offline()
|
|
||||||
else:
|
|
||||||
run_migrations_online()
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
"""${message}
|
|
||||||
|
|
||||||
Revision ID: ${up_revision}
|
|
||||||
Revises: ${down_revision | comma,n}
|
|
||||||
Create Date: ${create_date}
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = ${repr(up_revision)}
|
|
||||||
down_revision = ${repr(down_revision)}
|
|
||||||
branch_labels = ${repr(branch_labels)}
|
|
||||||
depends_on = ${repr(depends_on)}
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
${upgrades if upgrades else "pass"}
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
${downgrades if downgrades else "pass"}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -26,8 +26,6 @@ class Settings(BaseSettings):
|
|||||||
bundle_cache_ttl_seconds: int = 600
|
bundle_cache_ttl_seconds: int = 600
|
||||||
max_proxy_name_length: int = 80
|
max_proxy_name_length: int = 80
|
||||||
default_user_agent: str = "sub-provider/0.2"
|
default_user_agent: str = "sub-provider/0.2"
|
||||||
database_url: str = Field(default=f"sqlite:///{(DATA_DIR / 'app.db').resolve().as_posix()}")
|
|
||||||
database_echo: bool = False
|
|
||||||
|
|
||||||
config_dir: Path = CONFIG_DIR
|
config_dir: Path = CONFIG_DIR
|
||||||
sources_file: Path = CONFIG_DIR / "sources.yaml"
|
sources_file: Path = CONFIG_DIR / "sources.yaml"
|
||||||
|
|||||||
40
app/db.py
40
app/db.py
@@ -1,40 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from functools import lru_cache
|
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
|
||||||
|
|
||||||
from app.config import DATA_DIR, get_settings
|
|
||||||
from app.db_models import Base
|
|
||||||
|
|
||||||
|
|
||||||
def _sqlite_connect_args(database_url: str) -> dict[str, bool]:
|
|
||||||
if database_url.startswith("sqlite"):
|
|
||||||
return {"check_same_thread": False}
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
|
||||||
def get_engine():
|
|
||||||
settings = get_settings()
|
|
||||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
return create_engine(
|
|
||||||
settings.database_url,
|
|
||||||
echo=settings.database_echo,
|
|
||||||
future=True,
|
|
||||||
connect_args=_sqlite_connect_args(settings.database_url),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
|
||||||
def get_session_factory():
|
|
||||||
return sessionmaker(bind=get_engine(), autoflush=False, autocommit=False, future=True)
|
|
||||||
|
|
||||||
|
|
||||||
def init_db() -> None:
|
|
||||||
Base.metadata.create_all(bind=get_engine())
|
|
||||||
|
|
||||||
|
|
||||||
def get_session() -> Session:
|
|
||||||
return get_session_factory()()
|
|
||||||
149
app/db_models.py
149
app/db_models.py
@@ -1,149 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
|
||||||
|
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class TimestampMixin:
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
|
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime,
|
|
||||||
default=datetime.utcnow,
|
|
||||||
onupdate=datetime.utcnow,
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AppSettingORM(Base, TimestampMixin):
|
|
||||||
__tablename__ = "app_settings"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
||||||
key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
|
||||||
value: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
|
|
||||||
|
|
||||||
class SourceORM(Base, TimestampMixin):
|
|
||||||
__tablename__ = "sources"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
||||||
key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
|
||||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
||||||
kind: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
||||||
url: Mapped[str] = mapped_column(Text, nullable=False)
|
|
||||||
display_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
||||||
headers_json: Mapped[str] = mapped_column(Text, default="{}", nullable=False)
|
|
||||||
include_regex: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
exclude_regex: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
prefix: Mapped[str] = mapped_column(String(255), default="", nullable=False)
|
|
||||||
suffix: Mapped[str] = mapped_column(String(255), default="", nullable=False)
|
|
||||||
cache_ttl_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
||||||
|
|
||||||
|
|
||||||
class ProfileORM(Base, TimestampMixin):
|
|
||||||
__tablename__ = "profiles"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
||||||
key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
|
||||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
provider_interval: Mapped[int] = mapped_column(Integer, default=21600, nullable=False)
|
|
||||||
rule_interval: Mapped[int] = mapped_column(Integer, default=86400, nullable=False)
|
|
||||||
test_url: Mapped[str] = mapped_column(Text, nullable=False)
|
|
||||||
test_interval: Mapped[int] = mapped_column(Integer, default=300, nullable=False)
|
|
||||||
main_policy: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
source_policy: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
mixed_auto_policy: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
manual_policy: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
direct_policy: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
mode: Mapped[str] = mapped_column(String(50), default="rule", nullable=False)
|
|
||||||
allow_lan: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
||||||
ipv6: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
||||||
mixed_port: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
||||||
socks_port: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
||||||
log_level: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
|
||||||
|
|
||||||
source_links: Mapped[list["ProfileSourceORM"]] = relationship(
|
|
||||||
back_populates="profile",
|
|
||||||
cascade="all, delete-orphan",
|
|
||||||
)
|
|
||||||
rule_links: Mapped[list["ProfileRuleModuleORM"]] = relationship(
|
|
||||||
back_populates="profile",
|
|
||||||
cascade="all, delete-orphan",
|
|
||||||
)
|
|
||||||
groups: Mapped[list["PolicyGroupORM"]] = relationship(
|
|
||||||
back_populates="profile",
|
|
||||||
cascade="all, delete-orphan",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ProfileSourceORM(Base):
|
|
||||||
__tablename__ = "profile_sources"
|
|
||||||
__table_args__ = (UniqueConstraint("profile_id", "source_id", name="uq_profile_source"),)
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
||||||
profile_id: Mapped[int] = mapped_column(ForeignKey("profiles.id"), nullable=False)
|
|
||||||
source_id: Mapped[int] = mapped_column(ForeignKey("sources.id"), nullable=False)
|
|
||||||
order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
|
||||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
||||||
|
|
||||||
profile: Mapped[ProfileORM] = relationship(back_populates="source_links")
|
|
||||||
source: Mapped[SourceORM] = relationship()
|
|
||||||
|
|
||||||
|
|
||||||
class RuleModuleORM(Base, TimestampMixin):
|
|
||||||
__tablename__ = "rule_modules"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
||||||
key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
|
||||||
file_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
behavior: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
||||||
format: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
||||||
policy: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
no_resolve: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
|
||||||
payload_json: Mapped[str] = mapped_column(Text, default="[]", nullable=False)
|
|
||||||
|
|
||||||
profile_links: Mapped[list["ProfileRuleModuleORM"]] = relationship(
|
|
||||||
back_populates="rule_module",
|
|
||||||
cascade="all, delete-orphan",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ProfileRuleModuleORM(Base):
|
|
||||||
__tablename__ = "profile_rule_modules"
|
|
||||||
__table_args__ = (UniqueConstraint("profile_id", "rule_module_id", name="uq_profile_rule_module"),)
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
||||||
profile_id: Mapped[int] = mapped_column(ForeignKey("profiles.id"), nullable=False)
|
|
||||||
rule_module_id: Mapped[int] = mapped_column(ForeignKey("rule_modules.id"), nullable=False)
|
|
||||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
||||||
order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
|
||||||
policy_override: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
||||||
payload_override_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
no_resolve_override: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
|
||||||
|
|
||||||
profile: Mapped[ProfileORM] = relationship(back_populates="rule_links")
|
|
||||||
rule_module: Mapped[RuleModuleORM] = relationship(back_populates="profile_links")
|
|
||||||
|
|
||||||
|
|
||||||
class PolicyGroupORM(Base, TimestampMixin):
|
|
||||||
__tablename__ = "policy_groups"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
||||||
profile_id: Mapped[int] = mapped_column(ForeignKey("profiles.id"), nullable=False)
|
|
||||||
group_kind: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
type: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
||||||
order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
|
||||||
proxies_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
filter_regex: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
tolerance: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
||||||
url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
interval: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
||||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
||||||
|
|
||||||
profile: Mapped[ProfileORM] = relationship(back_populates="groups")
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
|
|
||||||
from app.config import get_settings
|
|
||||||
from app.services.config_store import import_yaml_config_to_db
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
parser = argparse.ArgumentParser(description="Import YAML config into the sub-provider database")
|
|
||||||
parser.add_argument(
|
|
||||||
"--replace",
|
|
||||||
action="store_true",
|
|
||||||
help="Clear existing config rows before import",
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
settings = get_settings()
|
|
||||||
import_yaml_config_to_db(settings.sources_file, replace_existing=args.replace)
|
|
||||||
print(f"Imported config into {settings.database_url}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,361 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
from sqlalchemy import delete, select
|
|
||||||
from sqlalchemy.orm import Session, selectinload
|
|
||||||
|
|
||||||
from app.db import get_session, init_db
|
|
||||||
from app.db_models import (
|
|
||||||
AppSettingORM,
|
|
||||||
PolicyGroupORM,
|
|
||||||
ProfileORM,
|
|
||||||
ProfileRuleModuleORM,
|
|
||||||
ProfileSourceORM,
|
|
||||||
RuleModuleORM,
|
|
||||||
SourceORM,
|
|
||||||
)
|
|
||||||
from app.models import AppConfig, ClientConfig, ProxyGroupConfig, RegionConfig, RuleConfig, SourceConfig
|
|
||||||
from app.services.loader import load_yaml_app_config
|
|
||||||
|
|
||||||
|
|
||||||
def _json_dumps(value) -> str:
|
|
||||||
return json.dumps(value, ensure_ascii=False)
|
|
||||||
|
|
||||||
|
|
||||||
def _json_loads(value: str | None, default):
|
|
||||||
if not value:
|
|
||||||
return default
|
|
||||||
return json.loads(value)
|
|
||||||
|
|
||||||
|
|
||||||
def import_yaml_config_to_db(config_path, *, replace_existing: bool = False) -> None:
|
|
||||||
app_config = load_yaml_app_config(config_path)
|
|
||||||
init_db()
|
|
||||||
with get_session() as session:
|
|
||||||
if replace_existing:
|
|
||||||
_clear_config_tables(session)
|
|
||||||
|
|
||||||
_upsert_setting(session, "public_path", app_config.public_path or "")
|
|
||||||
|
|
||||||
source_rows: list[SourceORM] = []
|
|
||||||
for key, source in app_config.sources.items():
|
|
||||||
row = _get_or_create_source(session, key)
|
|
||||||
row.enabled = source.enabled
|
|
||||||
row.kind = source.kind
|
|
||||||
row.url = source.url
|
|
||||||
row.display_name = source.display_name
|
|
||||||
row.headers_json = _json_dumps(source.headers)
|
|
||||||
row.include_regex = source.include_regex
|
|
||||||
row.exclude_regex = source.exclude_regex
|
|
||||||
row.prefix = source.prefix
|
|
||||||
row.suffix = source.suffix
|
|
||||||
row.cache_ttl_seconds = source.cache_ttl_seconds
|
|
||||||
source_rows.append(row)
|
|
||||||
|
|
||||||
module_rows: list[RuleModuleORM] = []
|
|
||||||
ordered_rule_keys = list(app_config.rules.keys())
|
|
||||||
for key, rule in app_config.rules.items():
|
|
||||||
row = _get_or_create_rule_module(session, key)
|
|
||||||
row.file_path = rule.file
|
|
||||||
row.behavior = rule.behavior
|
|
||||||
row.format = rule.format
|
|
||||||
row.policy = rule.policy
|
|
||||||
row.no_resolve = rule.no_resolve
|
|
||||||
row.payload_json = _json_dumps(rule.payload)
|
|
||||||
module_rows.append(row)
|
|
||||||
|
|
||||||
session.flush()
|
|
||||||
|
|
||||||
for client_key, client in app_config.clients.items():
|
|
||||||
profile = _get_or_create_profile(session, client_key)
|
|
||||||
_populate_profile(profile, client)
|
|
||||||
session.flush()
|
|
||||||
|
|
||||||
_replace_profile_sources(session, profile.id, source_rows)
|
|
||||||
_replace_profile_groups(session, profile.id, app_config)
|
|
||||||
_replace_profile_rule_links(session, profile.id, module_rows, ordered_rule_keys)
|
|
||||||
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def load_app_config_from_db() -> AppConfig | None:
|
|
||||||
init_db()
|
|
||||||
with get_session() as session:
|
|
||||||
profiles = list(
|
|
||||||
session.scalars(
|
|
||||||
select(ProfileORM)
|
|
||||||
.options(
|
|
||||||
selectinload(ProfileORM.groups),
|
|
||||||
selectinload(ProfileORM.rule_links).selectinload(ProfileRuleModuleORM.rule_module),
|
|
||||||
)
|
|
||||||
.order_by(ProfileORM.key)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
sources = list(session.scalars(select(SourceORM).order_by(SourceORM.key)))
|
|
||||||
if not profiles or not sources:
|
|
||||||
return None
|
|
||||||
|
|
||||||
public_path = _setting_value(session, "public_path")
|
|
||||||
primary_profile = profiles[0]
|
|
||||||
|
|
||||||
regions: dict[str, RegionConfig] = {}
|
|
||||||
selector_groups: list[ProxyGroupConfig] = []
|
|
||||||
policy_groups: list[ProxyGroupConfig] = []
|
|
||||||
|
|
||||||
for group in sorted(primary_profile.groups, key=lambda item: (item.order_index, item.id)):
|
|
||||||
if not group.enabled:
|
|
||||||
continue
|
|
||||||
if group.group_kind == "region":
|
|
||||||
regions[_slugify(group.name)] = RegionConfig(
|
|
||||||
name=group.name,
|
|
||||||
filter=group.filter_regex or "",
|
|
||||||
tolerance=group.tolerance or 50,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
target = selector_groups if group.group_kind == "selector" else policy_groups
|
|
||||||
target.append(
|
|
||||||
ProxyGroupConfig(
|
|
||||||
name=group.name,
|
|
||||||
type=group.type,
|
|
||||||
proxies=_json_loads(group.proxies_json, []),
|
|
||||||
filter=group.filter_regex,
|
|
||||||
tolerance=group.tolerance,
|
|
||||||
url=group.url,
|
|
||||||
interval=group.interval,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
rules: dict[str, RuleConfig] = {}
|
|
||||||
for link in sorted(primary_profile.rule_links, key=lambda item: (item.order_index, item.id)):
|
|
||||||
if not link.enabled:
|
|
||||||
continue
|
|
||||||
module = link.rule_module
|
|
||||||
rules[module.key] = RuleConfig(
|
|
||||||
file=module.file_path,
|
|
||||||
behavior=module.behavior,
|
|
||||||
format=module.format,
|
|
||||||
policy=link.policy_override or module.policy,
|
|
||||||
no_resolve=module.no_resolve if link.no_resolve_override is None else link.no_resolve_override,
|
|
||||||
payload=_json_loads(link.payload_override_json, _json_loads(module.payload_json, [])),
|
|
||||||
)
|
|
||||||
|
|
||||||
return AppConfig(
|
|
||||||
public_path=public_path or None,
|
|
||||||
sources={
|
|
||||||
source.key: SourceConfig(
|
|
||||||
enabled=source.enabled,
|
|
||||||
kind=source.kind,
|
|
||||||
url=source.url,
|
|
||||||
display_name=source.display_name,
|
|
||||||
headers=_json_loads(source.headers_json, {}),
|
|
||||||
include_regex=source.include_regex,
|
|
||||||
exclude_regex=source.exclude_regex,
|
|
||||||
prefix=source.prefix,
|
|
||||||
suffix=source.suffix,
|
|
||||||
cache_ttl_seconds=source.cache_ttl_seconds,
|
|
||||||
)
|
|
||||||
for source in sources
|
|
||||||
},
|
|
||||||
rules=rules,
|
|
||||||
clients={profile.key: _profile_to_client_config(profile) for profile in profiles},
|
|
||||||
regions=regions,
|
|
||||||
selector_groups=selector_groups,
|
|
||||||
policy_groups=policy_groups,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _clear_config_tables(session: Session) -> None:
|
|
||||||
session.execute(delete(ProfileRuleModuleORM))
|
|
||||||
session.execute(delete(PolicyGroupORM))
|
|
||||||
session.execute(delete(ProfileSourceORM))
|
|
||||||
session.execute(delete(ProfileORM))
|
|
||||||
session.execute(delete(RuleModuleORM))
|
|
||||||
session.execute(delete(SourceORM))
|
|
||||||
session.execute(delete(AppSettingORM))
|
|
||||||
session.flush()
|
|
||||||
|
|
||||||
|
|
||||||
def _upsert_setting(session: Session, key: str, value: str) -> None:
|
|
||||||
row = session.scalar(select(AppSettingORM).where(AppSettingORM.key == key))
|
|
||||||
if row is None:
|
|
||||||
row = AppSettingORM(key=key, value=value)
|
|
||||||
session.add(row)
|
|
||||||
else:
|
|
||||||
row.value = value
|
|
||||||
|
|
||||||
|
|
||||||
def _setting_value(session: Session, key: str) -> str | None:
|
|
||||||
row = session.scalar(select(AppSettingORM).where(AppSettingORM.key == key))
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
return row.value
|
|
||||||
|
|
||||||
|
|
||||||
def _get_or_create_source(session: Session, key: str) -> SourceORM:
|
|
||||||
row = session.scalar(select(SourceORM).where(SourceORM.key == key))
|
|
||||||
if row is None:
|
|
||||||
row = SourceORM(key=key, kind="auto", url="")
|
|
||||||
session.add(row)
|
|
||||||
return row
|
|
||||||
|
|
||||||
|
|
||||||
def _get_or_create_rule_module(session: Session, key: str) -> RuleModuleORM:
|
|
||||||
row = session.scalar(select(RuleModuleORM).where(RuleModuleORM.key == key))
|
|
||||||
if row is None:
|
|
||||||
row = RuleModuleORM(key=key, behavior="classical", format="text", policy="DIRECT")
|
|
||||||
session.add(row)
|
|
||||||
return row
|
|
||||||
|
|
||||||
|
|
||||||
def _get_or_create_profile(session: Session, key: str) -> ProfileORM:
|
|
||||||
row = session.scalar(select(ProfileORM).where(ProfileORM.key == key))
|
|
||||||
if row is None:
|
|
||||||
row = ProfileORM(
|
|
||||||
key=key,
|
|
||||||
title=key,
|
|
||||||
test_url="https://www.gstatic.com/generate_204",
|
|
||||||
main_policy="🚀 节点选择",
|
|
||||||
source_policy="☁️ 机场选择",
|
|
||||||
mixed_auto_policy="♻️ 自动选择",
|
|
||||||
manual_policy="🚀 手动切换",
|
|
||||||
direct_policy="DIRECT",
|
|
||||||
)
|
|
||||||
session.add(row)
|
|
||||||
return row
|
|
||||||
|
|
||||||
|
|
||||||
def _populate_profile(profile: ProfileORM, client: ClientConfig) -> None:
|
|
||||||
profile.title = client.title
|
|
||||||
profile.provider_interval = client.provider_interval
|
|
||||||
profile.rule_interval = client.rule_interval
|
|
||||||
profile.test_url = str(client.test_url)
|
|
||||||
profile.test_interval = client.test_interval
|
|
||||||
profile.main_policy = client.main_policy
|
|
||||||
profile.source_policy = client.source_policy
|
|
||||||
profile.mixed_auto_policy = client.mixed_auto_policy
|
|
||||||
profile.manual_policy = client.manual_policy
|
|
||||||
profile.direct_policy = client.direct_policy
|
|
||||||
profile.mode = client.mode
|
|
||||||
profile.allow_lan = client.allow_lan
|
|
||||||
profile.ipv6 = client.ipv6
|
|
||||||
profile.mixed_port = client.mixed_port
|
|
||||||
profile.socks_port = client.socks_port
|
|
||||||
profile.log_level = client.log_level
|
|
||||||
|
|
||||||
|
|
||||||
def _replace_profile_sources(session: Session, profile_id: int, source_rows: list[SourceORM]) -> None:
|
|
||||||
session.execute(delete(ProfileSourceORM).where(ProfileSourceORM.profile_id == profile_id))
|
|
||||||
for order_index, source in enumerate(source_rows):
|
|
||||||
session.add(
|
|
||||||
ProfileSourceORM(
|
|
||||||
profile_id=profile_id,
|
|
||||||
source_id=source.id,
|
|
||||||
order_index=order_index,
|
|
||||||
enabled=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _replace_profile_groups(session: Session, profile_id: int, app_config: AppConfig) -> None:
|
|
||||||
session.execute(delete(PolicyGroupORM).where(PolicyGroupORM.profile_id == profile_id))
|
|
||||||
|
|
||||||
order_index = 0
|
|
||||||
for region in app_config.regions.values():
|
|
||||||
session.add(
|
|
||||||
PolicyGroupORM(
|
|
||||||
profile_id=profile_id,
|
|
||||||
group_kind="region",
|
|
||||||
name=region.name,
|
|
||||||
type="url-test",
|
|
||||||
order_index=order_index,
|
|
||||||
filter_regex=region.filter,
|
|
||||||
tolerance=region.tolerance,
|
|
||||||
enabled=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
order_index += 1
|
|
||||||
|
|
||||||
for group in app_config.selector_groups:
|
|
||||||
session.add(
|
|
||||||
PolicyGroupORM(
|
|
||||||
profile_id=profile_id,
|
|
||||||
group_kind="selector",
|
|
||||||
name=group.name,
|
|
||||||
type=group.type,
|
|
||||||
order_index=order_index,
|
|
||||||
proxies_json=_json_dumps(group.proxies),
|
|
||||||
filter_regex=group.filter,
|
|
||||||
tolerance=group.tolerance,
|
|
||||||
url=str(group.url) if group.url else None,
|
|
||||||
interval=group.interval,
|
|
||||||
enabled=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
order_index += 1
|
|
||||||
|
|
||||||
for group in app_config.policy_groups:
|
|
||||||
session.add(
|
|
||||||
PolicyGroupORM(
|
|
||||||
profile_id=profile_id,
|
|
||||||
group_kind="policy",
|
|
||||||
name=group.name,
|
|
||||||
type=group.type,
|
|
||||||
order_index=order_index,
|
|
||||||
proxies_json=_json_dumps(group.proxies),
|
|
||||||
filter_regex=group.filter,
|
|
||||||
tolerance=group.tolerance,
|
|
||||||
url=str(group.url) if group.url else None,
|
|
||||||
interval=group.interval,
|
|
||||||
enabled=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
order_index += 1
|
|
||||||
|
|
||||||
|
|
||||||
def _replace_profile_rule_links(
|
|
||||||
session: Session,
|
|
||||||
profile_id: int,
|
|
||||||
module_rows: list[RuleModuleORM],
|
|
||||||
ordered_rule_keys: list[str],
|
|
||||||
) -> None:
|
|
||||||
session.execute(delete(ProfileRuleModuleORM).where(ProfileRuleModuleORM.profile_id == profile_id))
|
|
||||||
module_by_key = {module.key: module for module in module_rows}
|
|
||||||
for order_index, key in enumerate(ordered_rule_keys):
|
|
||||||
module = module_by_key[key]
|
|
||||||
session.add(
|
|
||||||
ProfileRuleModuleORM(
|
|
||||||
profile_id=profile_id,
|
|
||||||
rule_module_id=module.id,
|
|
||||||
enabled=True,
|
|
||||||
order_index=order_index,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _profile_to_client_config(profile: ProfileORM) -> ClientConfig:
|
|
||||||
return ClientConfig(
|
|
||||||
title=profile.title,
|
|
||||||
provider_interval=profile.provider_interval,
|
|
||||||
rule_interval=profile.rule_interval,
|
|
||||||
test_url=profile.test_url,
|
|
||||||
test_interval=profile.test_interval,
|
|
||||||
main_policy=profile.main_policy,
|
|
||||||
source_policy=profile.source_policy,
|
|
||||||
mixed_auto_policy=profile.mixed_auto_policy,
|
|
||||||
manual_policy=profile.manual_policy,
|
|
||||||
direct_policy=profile.direct_policy,
|
|
||||||
mode=profile.mode,
|
|
||||||
allow_lan=profile.allow_lan,
|
|
||||||
ipv6=profile.ipv6,
|
|
||||||
mixed_port=profile.mixed_port,
|
|
||||||
socks_port=profile.socks_port,
|
|
||||||
log_level=profile.log_level,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _slugify(name: str) -> str:
|
|
||||||
text = "".join(char.lower() if char.isalnum() else "-" for char in name).strip("-")
|
|
||||||
while "--" in text:
|
|
||||||
text = text.replace("--", "-")
|
|
||||||
return text or "group"
|
|
||||||
@@ -7,7 +7,6 @@ from typing import Any
|
|||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from app.db import init_db
|
|
||||||
from app.models import AppConfig
|
from app.models import AppConfig
|
||||||
|
|
||||||
_ENV_PATTERN = re.compile(r"\$\{([A-Z0-9_]+)\}")
|
_ENV_PATTERN = re.compile(r"\$\{([A-Z0-9_]+)\}")
|
||||||
@@ -62,15 +61,5 @@ def _load_split_config(path: Path) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def load_app_config(path: Path) -> AppConfig:
|
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))
|
expanded = _expand_env(_load_split_config(path))
|
||||||
return AppConfig.model_validate(expanded)
|
return AppConfig.model_validate(expanded)
|
||||||
|
|||||||
@@ -10,5 +10,5 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
volumes:
|
volumes:
|
||||||
- ./config:/app/config:ro
|
- ./config:/app/config:ro
|
||||||
- ${APP_DATA_DIR:-../sub-provider-data}:/app/data
|
- ./data:/app/data
|
||||||
- ./output:/app/output
|
- ./output:/app/output
|
||||||
|
|||||||
@@ -3,5 +3,3 @@ uvicorn[standard]>=0.30,<1.0
|
|||||||
httpx>=0.27,<1.0
|
httpx>=0.27,<1.0
|
||||||
PyYAML>=6.0,<7.0
|
PyYAML>=6.0,<7.0
|
||||||
pydantic-settings>=2.3,<3.0
|
pydantic-settings>=2.3,<3.0
|
||||||
SQLAlchemy>=2.0,<3.0
|
|
||||||
alembic>=1.13,<2.0
|
|
||||||
|
|||||||
Reference in New Issue
Block a user