diff --git a/.env.example b/.env.example index 33782ea..9ea48c2 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,11 @@ 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 diff --git a/README.md b/README.md index 6c45f3f..b5424ff 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,8 @@ cp .env.example .env - `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` - 允许把其中一个留空;留空时这个机场会自动跳过 @@ -112,6 +114,7 @@ docker compose up -d --build 6. 访问检查: - 健康检查:`http://YOUR_HOST:18080/healthz` +- 管理面板:`http://YOUR_HOST:18080/admin` - 单 provider: `https://YOUR_DOMAIN//providers/airport-a.yaml` - merged provider: @@ -125,6 +128,34 @@ docker compose up -d --build - Stash bundle: `https://YOUR_DOMAIN//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` 留空,管理面板默认不启用登录拦截 + --- ## 接口说明 diff --git a/app/config.py b/app/config.py index b568d57..b1633c5 100644 --- a/app/config.py +++ b/app/config.py @@ -28,6 +28,9 @@ class Settings(BaseSettings): 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" diff --git a/app/main.py b/app/main.py index 14b177f..8d43ce6 100644 --- a/app/main.py +++ b/app/main.py @@ -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,18 +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, + 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)} @@ -167,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( @@ -197,7 +278,7 @@ async def bundle_profile( content = dump_yaml( build_bundle_profile( client_type=client_type, - app_config=app_config, + app_config=current_config, snapshots=snapshots, ) ) @@ -213,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, + ), + ) diff --git a/app/services/config_store.py b/app/services/config_store.py index ab06d3a..a69244c 100644 --- a/app/services/config_store.py +++ b/app/services/config_store.py @@ -82,87 +82,366 @@ def import_yaml_config_to_db(config_path, *, replace_existing: bool = False) -> 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 + return _load_app_config_from_session(session, profile_key=None) - public_path = _setting_value(session, "public_path") - primary_profile = profiles[0] - regions: dict[str, RegionConfig] = {} - selector_groups: list[ProxyGroupConfig] = [] - policy_groups: list[ProxyGroupConfig] = [] +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) - 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 +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 - 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, [])), + 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 - 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, + +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"]), ) - 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, + ) + 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: diff --git a/requirements.txt b/requirements.txt index 214c77b..41f5456 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,6 @@ 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 diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..4d927a7 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,136 @@ + + + + + + sub-provider admin + + + +
+ +
+ {% if flash %} +
{{ flash.message }}
+ {% endif %} + {% block content %}{% endblock %} +
+
+ + diff --git a/templates/groups.html b/templates/groups.html new file mode 100644 index 0000000..3923250 --- /dev/null +++ b/templates/groups.html @@ -0,0 +1,126 @@ +{% extends "base.html" %} +{% block content %} +
+
+
+

Groups: {{ profile_key }}

+

管理 region / selector / policy groups。当前版本以整表提交为主。

+
+
+ Back + Preview +
+
+ +
+ {% for group in groups %} +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ {% endfor %} + +
+

New Group

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ +
+
+ +
+
+
+{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..76937d5 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,73 @@ + + + + + + sub-provider login + + + +
+

Admin Login

+

输入 `.env` 中配置的 `ADMIN_TOKEN`。

+ {% if flash %} +
{{ flash.message }}
+ {% endif %} +
+ + + + +
+
+ + diff --git a/templates/preview.html b/templates/preview.html new file mode 100644 index 0000000..62c6083 --- /dev/null +++ b/templates/preview.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block content %} +
+
+
+

Preview: {{ profile_key }}

+

预览 profile 当前生成的完整 bundle YAML。

+
+
+ Rules + Profiles +
+
+ +
+
+
+ + +
+
+ +
+
+
+ +
+ +
+
+{% endblock %} diff --git a/templates/profile_sources.html b/templates/profile_sources.html new file mode 100644 index 0000000..4f1d129 --- /dev/null +++ b/templates/profile_sources.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block content %} +
+
+
+

Profile Sources: {{ profile_key }}

+

配置 profile 默认会使用哪些源,以及它们的顺序。

+
+
+ Back + Preview +
+
+ +
+ + + + + + {% for binding in bindings %} + + + + + + + + {% endfor %} + +
EnabledKeyOrderTypeURL
+ + + {{ binding.key }}
{{ binding.display_name }}
{{ binding.kind }}{{ binding.url }}
+
+ +
+
+
+{% endblock %} diff --git a/templates/profiles.html b/templates/profiles.html new file mode 100644 index 0000000..47f1360 --- /dev/null +++ b/templates/profiles.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} +{% block content %} +
+
+

Profiles

+

管理客户端模板,并跳转到规则和预览页。

+
+ +
+

New / Edit Profile

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+ +
+ {% for profile in profiles %} +
+
+
+

{{ profile.key }}

+
{{ profile.title }}
+
+ +
+
+
Main: {{ profile.main_policy }}
+
Source: {{ profile.source_policy }}
+
Test URL: {{ profile.test_url }}
+
Mode: {{ profile.mode }}
+
+
+ {% endfor %} +
+
+{% endblock %} diff --git a/templates/rules.html b/templates/rules.html new file mode 100644 index 0000000..8a1846e --- /dev/null +++ b/templates/rules.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block content %} +
+
+
+

Rules: {{ profile_key }}

+

控制规则模块启用状态、顺序和目标策略组。

+
+
+ Back + Preview +
+
+ +
+ + + + + + {% for binding in bindings %} + + + + + + + + {% endfor %} + +
EnabledKeyOrderPolicyRule File
+ + + {{ binding.key }}
{{ binding.behavior }} / {{ binding.format }}
{{ binding.file_path or "inline payload" }}
+
+ +
+
+
+{% endblock %} diff --git a/templates/sources.html b/templates/sources.html new file mode 100644 index 0000000..fc002f1 --- /dev/null +++ b/templates/sources.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} +{% block content %} +
+
+

Sources

+

管理订阅源。先做最小可用版本:增改删和启停。

+
+ +
+

New / Edit Source

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+ +
+

Existing Sources

+ + + + + + {% for source in sources %} + + + + + + + + + {% endfor %} + +
KeyTypeURLPrefixStatus
{{ source.key }}{{ source.kind }}{{ source.url }}{{ source.prefix }}{{ "enabled" if source.enabled else "disabled" }} +
+ +
+
+
+
+{% endblock %}