feat(btpanel): 独立宝塔模块与 SSH 自动获取 API

新增侧栏「宝塔面板」菜单、代理 API 与连接配置页;通过 SSH 读写子机
api.json 自动写入凭据,5 分钟后台重试,含审计修复、检测缓存与并发锁。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
r
2026-06-21 09:13:14 +08:00
parent ebab836554
commit 82426b1941
46 changed files with 3942 additions and 4 deletions
@@ -0,0 +1,541 @@
"""Baota panel orchestration — proxy to remote panel API or SSH."""
from __future__ import annotations
import json
import logging
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from server.domain.models import AuditLog, Server
from server.infrastructure.btpanel.client import BtPanelApiError, BtPanelClient
from server.infrastructure.btpanel.credentials import (
BtPanelCredentials,
merge_bt_panel_extra,
public_bt_panel_status,
read_bt_panel_credentials,
bt_panel_configured,
)
from server.infrastructure.btpanel.bootstrap_state import (
STATE_DISABLED,
STATE_INSTALLING,
STATE_PENDING,
apply_bootstrap_failure,
apply_bootstrap_success,
merge_bootstrap_fields,
_iso,
_utcnow,
)
from server.infrastructure.btpanel.bootstrap_lock import bootstrap_lock
from server.infrastructure.btpanel.source_ip import get_bt_panel_source_ip
from server.infrastructure.btpanel.ssh_bootstrap import BootstrapRemoteError, ssh_bootstrap_panel
from server.infrastructure.btpanel.ssh_login import detect_bt_panel_installed, ssh_temp_login_url
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from server.infrastructure.database.server_repo import ServerRepositoryImpl
logger = logging.getLogger("nexus.btpanel.service")
class BtPanelService:
def __init__(self, db: AsyncSession):
self.db = db
self.servers = ServerRepositoryImpl(db)
self.audit = AuditLogRepositoryImpl(db)
async def get_server(self, server_id: int) -> Server:
server = await self.servers.get_by_id(server_id)
if not server:
raise ValueError("服务器不存在")
return server
def _client(self, server: Server) -> BtPanelClient:
creds = read_bt_panel_credentials(server)
if not creds:
raise ValueError("未配置宝塔面板地址或 API 密钥")
return BtPanelClient(creds, server.id)
async def list_server_statuses(self) -> list[dict[str, Any]]:
servers = await self.servers.get_all()
out: list[dict[str, Any]] = []
for s in servers:
out.append({
"id": s.id,
"name": s.name,
"domain": s.domain,
"category": s.category,
**public_bt_panel_status(s),
})
return out
async def get_config(self, server_id: int, *, refresh_bt_installed: bool = False) -> dict[str, Any]:
server = await self.get_server(server_id)
bt_installed = await self._resolve_bt_installed(server, refresh=refresh_bt_installed)
return {**public_bt_panel_status(server), "bt_installed": bt_installed}
async def _resolve_bt_installed(self, server: Server, *, refresh: bool = False) -> bool | None:
from server.infrastructure.btpanel.credentials import get_bt_panel_block
block = get_bt_panel_block(server)
cached = block.get("bt_installed")
state = block.get("bootstrap_state")
if not refresh:
if bt_panel_configured(server):
return True if cached is None else bool(cached)
if cached is not None and state not in (STATE_PENDING, STATE_INSTALLING):
return bool(cached)
if state not in (STATE_PENDING, STATE_INSTALLING):
return bool(cached) if cached is not None else None
detected = await detect_bt_panel_installed(server)
server.extra_attrs = merge_bootstrap_fields(
server.extra_attrs if isinstance(server.extra_attrs, dict) else {},
bt_installed=detected,
bt_installed_checked_at=_iso(_utcnow()),
)
await self.servers.update(server)
return detected
async def get_global_settings(self) -> dict[str, Any]:
from server.config import settings
return {
"bt_panel_source_ip": (getattr(settings, "BT_PANEL_SOURCE_IP", None) or "").strip(),
"bt_panel_auto_bootstrap_enabled": self._auto_bootstrap_enabled(),
"resolved_center_ip": get_bt_panel_source_ip(),
}
async def update_global_settings(
self,
*,
bt_panel_source_ip: str | None,
bt_panel_auto_bootstrap_enabled: bool | None,
admin_username: str,
ip_address: str | None,
) -> dict[str, Any]:
from server.infrastructure.btpanel.source_ip import _is_ip as _is_valid_source_ip
if bt_panel_source_ip is not None:
ip = bt_panel_source_ip.strip()
if ip and not _is_valid_source_ip(ip):
raise ValueError("bt_panel_source_ip 必须是合法 IPv4/IPv6 地址")
await self._persist_setting("bt_panel_source_ip", ip)
if bt_panel_auto_bootstrap_enabled is not None:
await self._persist_setting(
"bt_panel_auto_bootstrap_enabled",
"true" if bt_panel_auto_bootstrap_enabled else "false",
)
await self.audit.create(AuditLog(
admin_username=admin_username,
action="bt_panel_global_settings",
target_type="setting",
target_id=0,
detail=json.dumps({
"bt_panel_source_ip": bt_panel_source_ip,
"bt_panel_auto_bootstrap_enabled": bt_panel_auto_bootstrap_enabled,
}, ensure_ascii=False),
ip_address=ip_address,
))
return await self.get_global_settings()
async def _persist_setting(self, key: str, value: str) -> None:
from server.config import settings
from server.infrastructure.database.setting_repo import SettingRepositoryImpl
from server.infrastructure.settings_broadcast import publish_setting_change
repo = SettingRepositoryImpl(self.db)
await repo.set(key, value)
if settings.apply_db_override(key, value):
await publish_setting_change(key, value)
@staticmethod
def _auto_bootstrap_enabled() -> bool:
from server.application.services.btpanel_bootstrap_schedule import is_auto_bootstrap_enabled
return is_auto_bootstrap_enabled()
async def update_config(
self,
server_id: int,
*,
base_url: str | None,
api_key: str | None,
verify_ssl: bool | None,
auto_bootstrap: bool | None,
admin_username: str,
ip_address: str | None,
) -> dict[str, Any]:
server = await self.get_server(server_id)
extra = merge_bt_panel_extra(
server.extra_attrs if isinstance(server.extra_attrs, dict) else {},
base_url=base_url,
api_key=api_key,
verify_ssl=verify_ssl,
)
if auto_bootstrap is not None:
if auto_bootstrap:
from datetime import datetime, timezone
from server.infrastructure.btpanel.bootstrap_state import _iso
now = _iso(datetime.now(timezone.utc))
extra = merge_bootstrap_fields(
extra,
auto_bootstrap=True,
bootstrap_state=STATE_PENDING,
bootstrap_next_attempt_at=now,
bootstrap_last_error=None,
)
else:
extra = merge_bootstrap_fields(
extra,
auto_bootstrap=False,
bootstrap_state=STATE_DISABLED,
bootstrap_next_attempt_at=None,
)
server.extra_attrs = extra
await self.servers.update(server)
await self.audit.create(AuditLog(
admin_username=admin_username,
action="bt_panel_config_update",
target_type="server",
target_id=server_id,
detail=json.dumps({"base_url": base_url, "auto_bootstrap": auto_bootstrap}, ensure_ascii=False),
ip_address=ip_address,
))
return await self.get_config(server_id)
async def bootstrap_server(
self,
server_id: int,
*,
source: str,
admin_username: str,
ip_address: str | None,
force: bool = False,
) -> dict[str, Any]:
async with bootstrap_lock(server_id):
return await self._bootstrap_server_locked(
server_id,
source=source,
admin_username=admin_username,
ip_address=ip_address,
force=force,
)
async def _bootstrap_server_locked(
self,
server_id: int,
*,
source: str,
admin_username: str,
ip_address: str | None,
force: bool,
) -> dict[str, Any]:
from datetime import datetime, timezone
from server.infrastructure.btpanel.bootstrap_state import is_eligible_for_background_bootstrap
server = await self.get_server(server_id)
if bt_panel_configured(server) and source == "background":
return {**public_bt_panel_status(server), "skipped": True, "reason": "already_configured"}
if source == "background" and not is_eligible_for_background_bootstrap(server):
return {**public_bt_panel_status(server), "skipped": True, "reason": "not_due"}
if force or source in ("manual", "batch", "immediate"):
now = _iso(datetime.now(timezone.utc))
from server.infrastructure.btpanel.credentials import get_bt_panel_block
block = get_bt_panel_block(server)
fields: dict[str, Any] = {
"bootstrap_state": STATE_PENDING,
"bootstrap_next_attempt_at": now,
"bootstrap_last_error": None,
}
# 手动「立即获取」不覆盖用户已关闭的自动获取
if source != "manual" or block.get("auto_bootstrap") is not False:
fields["auto_bootstrap"] = True
server.extra_attrs = merge_bootstrap_fields(
server.extra_attrs if isinstance(server.extra_attrs, dict) else {},
**fields,
)
await self.servers.update(server)
center_ip = get_bt_panel_source_ip()
if not center_ip:
server.extra_attrs = apply_bootstrap_failure(
server.extra_attrs if isinstance(server.extra_attrs, dict) else {},
error_code="missing_center_ip",
permanent=False,
)
await self.servers.update(server)
await self._audit_bootstrap(server_id, admin_username, ip_address, source, ok=False, error="missing_center_ip")
return {**public_bt_panel_status(server), "ok": False, "error": "missing_center_ip"}
try:
data = await ssh_bootstrap_panel(server, center_ip=center_ip)
except BootstrapRemoteError as exc:
permanent = exc.code == "ssh_auth_failed"
server.extra_attrs = apply_bootstrap_failure(
server.extra_attrs if isinstance(server.extra_attrs, dict) else {},
error_code=exc.code,
permanent=permanent,
)
await self.servers.update(server)
await self._audit_bootstrap(
server_id, admin_username, ip_address, source, ok=False, error=exc.code,
)
return {**public_bt_panel_status(server), "ok": False, "error": exc.code}
base_url = str(data.get("base_url") or "").strip()
api_key = str(data.get("api_key") or "").strip()
if not base_url or not api_key:
server.extra_attrs = apply_bootstrap_failure(
server.extra_attrs if isinstance(server.extra_attrs, dict) else {},
error_code="bootstrap_empty_credentials",
permanent=False,
)
await self.servers.update(server)
await self._audit_bootstrap(
server_id, admin_username, ip_address, source, ok=False, error="bootstrap_empty_credentials",
)
return {**public_bt_panel_status(server), "ok": False, "error": "bootstrap_empty_credentials"}
server.extra_attrs = apply_bootstrap_success(
server.extra_attrs if isinstance(server.extra_attrs, dict) else {},
base_url=base_url,
api_key=api_key,
verify_ssl=bool(data.get("verify_ssl")),
actions=list(data.get("actions") or []),
)
await self.servers.update(server)
await self._audit_bootstrap(
server_id,
admin_username,
ip_address,
source,
ok=True,
actions=list(data.get("actions") or []),
)
return {**public_bt_panel_status(server), "ok": True}
async def batch_bootstrap(
self,
server_ids: list[int],
*,
admin_username: str,
ip_address: str | None,
) -> dict[str, Any]:
from server.application.services.server_batch_service import start_batch_job
ids = [int(x) for x in server_ids if x]
if not ids:
raise ValueError("server_ids 不能为空")
return await start_batch_job(
op="bt-panel-bootstrap",
server_ids=ids,
operator=admin_username,
)
async def _audit_bootstrap(
self,
server_id: int,
admin_username: str,
ip_address: str | None,
source: str,
*,
ok: bool,
error: str | None = None,
actions: list[str] | None = None,
) -> None:
await self.audit.create(AuditLog(
admin_username=admin_username,
action="bt_panel_ssh_bootstrap",
target_type="server",
target_id=server_id,
detail=json.dumps({
"source": source,
"ok": ok,
"error": error,
"actions": actions or [],
}, ensure_ascii=False)[:500],
ip_address=ip_address,
))
async def create_login_url(
self,
server_id: int,
*,
admin_username: str,
ip_address: str | None,
) -> dict[str, str]:
server = await self.get_server(server_id)
url: str | None = None
method = "api"
creds = read_bt_panel_credentials(server)
if creds:
try:
url = await self._login_url_via_api(creds, server.id)
except BtPanelApiError as exc:
logger.warning("bt panel api temp login failed server=%s: %s", server_id, exc)
method = "ssh_fallback"
if not url:
method = "ssh"
url = await ssh_temp_login_url(server)
await self.audit.create(AuditLog(
admin_username=admin_username,
action="bt_panel_login_url",
target_type="server",
target_id=server_id,
detail=json.dumps({"method": method}, ensure_ascii=False),
ip_address=ip_address,
))
return {"url": url, "method": method}
async def _login_url_via_api(self, creds: BtPanelCredentials, server_id: int) -> str:
client = BtPanelClient(creds, server_id)
data = await client.post("/config?action=set_temp_login", {})
if isinstance(data, dict):
for key in ("url", "login_url", "msg"):
val = data.get(key)
if isinstance(val, str) and "tmp_token=" in val:
return val.strip()
if data.get("status") and isinstance(data.get("data"), dict):
inner = data["data"]
for key in ("url", "login_url"):
val = inner.get(key)
if isinstance(val, str) and "tmp_token=" in val:
return val.strip()
raise BtPanelApiError("set_temp_login 未返回登录链接")
async def proxy(
self,
server_id: int,
path: str,
data: dict[str, Any] | None = None,
) -> Any:
server = await self.get_server(server_id)
client = self._client(server)
return await client.post(path, data)
# ── B1B3 ──
async def system_total(self, server_id: int) -> Any:
return await self.proxy(server_id, "/system?action=GetSystemTotal")
async def system_disk(self, server_id: int) -> Any:
return await self.proxy(server_id, "/system?action=GetDiskInfo")
async def system_network(self, server_id: int) -> Any:
return await self.proxy(server_id, "/system?action=GetNetWork")
# ── C1,C6 ──
async def list_sites(self, server_id: int, *, limit: int = 200) -> Any:
return await self.proxy(server_id, "/data?action=getData", {
"table": "sites",
"limit": limit,
"p": 1,
"order": "id desc",
})
async def site_start(self, server_id: int, site_id: int, name: str) -> Any:
return await self.proxy(server_id, "/site?action=SiteStart", {"id": site_id, "name": name})
async def site_stop(self, server_id: int, site_id: int, name: str) -> Any:
return await self.proxy(server_id, "/site?action=SiteStop", {"id": site_id, "name": name})
# ── C4 ──
async def php_versions(self, server_id: int) -> Any:
return await self.proxy(server_id, "/site?action=GetPHPVersion")
async def create_site(self, server_id: int, payload: dict[str, Any], *, admin_username: str, ip_address: str | None) -> Any:
result = await self.proxy(server_id, "/site?action=AddSite", payload)
await self.audit.create(AuditLog(
admin_username=admin_username,
action="bt_panel_site_create",
target_type="server",
target_id=server_id,
detail=json.dumps({"webname": payload.get("webname")}, ensure_ascii=False)[:500],
ip_address=ip_address,
))
return result
# ── C10 ──
async def list_domains(self, server_id: int, site_id: int) -> Any:
return await self.proxy(server_id, "/data?action=getData", {
"table": "domain",
"search": str(site_id),
"list": "true",
})
async def add_domain(self, server_id: int, payload: dict[str, Any]) -> Any:
return await self.proxy(server_id, "/site?action=AddDomain", payload)
async def delete_domain(self, server_id: int, payload: dict[str, Any]) -> Any:
return await self.proxy(server_id, "/site?action=DelDomain", payload)
# ── C15 ──
async def list_ssl_sites(self, server_id: int) -> Any:
return await self.list_sites(server_id, limit=500)
async def set_ssl(self, server_id: int, payload: dict[str, Any], *, admin_username: str, ip_address: str | None) -> Any:
result = await self.proxy(server_id, "/site?action=SetSSL", payload)
await self.audit.create(AuditLog(
admin_username=admin_username,
action="bt_panel_ssl_set",
target_type="server",
target_id=server_id,
detail=json.dumps({"siteName": payload.get("siteName")}, ensure_ascii=False)[:300],
ip_address=ip_address,
))
return result
async def apply_letsencrypt(self, server_id: int, payload: dict[str, Any]) -> Any:
return await self.proxy(server_id, "/acme?action=apply_cert", payload)
# ── D1D4 ──
async def list_databases(self, server_id: int, *, limit: int = 200) -> Any:
return await self.proxy(server_id, "/data?action=getData", {
"table": "databases",
"limit": limit,
"p": 1,
})
async def create_database(self, server_id: int, payload: dict[str, Any], *, admin_username: str, ip_address: str | None) -> Any:
result = await self.proxy(server_id, "/database?action=AddDatabase", payload)
await self.audit.create(AuditLog(
admin_username=admin_username,
action="bt_panel_db_create",
target_type="server",
target_id=server_id,
detail=json.dumps({"name": payload.get("name")}, ensure_ascii=False)[:200],
ip_address=ip_address,
))
return result
async def reset_database_password(self, server_id: int, payload: dict[str, Any]) -> Any:
return await self.proxy(server_id, "/database?action=ResDatabasePassword", payload)
async def backup_database(self, server_id: int, db_id: int) -> Any:
return await self.proxy(server_id, "/database?action=ToBackup", {"id": db_id})
# ── F1 ──
async def list_crontab(self, server_id: int) -> Any:
return await self.proxy(server_id, "/crontab?action=GetCrontab")
# ── H1 ──
async def service_admin(self, server_id: int, name: str, op: str, *, admin_username: str, ip_address: str | None) -> Any:
result = await self.proxy(server_id, "/system?action=ServiceAdmin", {"name": name, "type": op})
await self.audit.create(AuditLog(
admin_username=admin_username,
action="bt_panel_service_admin",
target_type="server",
target_id=server_id,
detail=json.dumps({"name": name, "type": op}, ensure_ascii=False),
ip_address=ip_address,
))
return result