Files
Nexus/server/application/services/btpanel_service.py
T
r a16ba169fd
Nexus CI/CD / test (push) Waiting to run
Nexus CI/CD / e2e (push) Blocked by required conditions
Nexus CI/CD / deploy (push) Blocked by required conditions
Nexus Pre-commit Checks / quick-check (push) Waiting to run
fix(btpanel): 未装宝塔时一键登录返回友好提示
SSH/API 登录前检测面板;将 public 模块 Traceback 映射为可读中文;
bootstrap bt_not_installed 不再回退 SSH。AGENTS 约定改完推 Gitea。
2026-07-09 01:25:54 +08:00

767 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Baota panel orchestration — proxy to remote panel API or SSH."""
from __future__ import annotations
import json
import logging
import time
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.login_url_lock import login_url_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.constants import (
BT_LOGIN_URL_CACHE_TTL_SECONDS,
BT_TEMP_LOGIN_TTL_SECONDS,
)
from server.infrastructure.btpanel.ssh_login import (
BtPanelNotInstalledError,
detect_bt_panel_installed,
normalize_temp_login_url,
resolve_temp_login_url,
ssh_temp_login_url,
_panel_port_base,
)
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from server.infrastructure.database.server_repo import ServerRepositoryImpl
logger = logging.getLogger("nexus.btpanel.service")
# 一键登录:bootstrap 失败时仍允许 SSH 临时登录回退(2026-06-21 前行为)
BOOTSTRAP_LOGIN_HARD_FAIL_ERRORS = frozenset({
"ssh_auth_failed",
"ssh_no_credentials",
"ssh_sudo_required",
"missing_center_ip",
})
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]]:
from server.infrastructure.redis.client import get_redis
from server.utils.agent_version import agent_is_installed
servers = await self.servers.get_all()
redis = get_redis()
pipe = redis.pipeline()
for s in servers:
pipe.hgetall(f"heartbeat:{s.id}")
heartbeats = await pipe.execute()
out: list[dict[str, Any]] = []
for s, heartbeat in zip(servers, heartbeats):
hb = heartbeat or {}
if hb:
is_online = hb.get("is_online") == "True"
agent_version = hb.get("agent_version") or s.agent_version
else:
is_online = bool(s.is_online)
agent_version = s.agent_version
has_agent = agent_is_installed(agent_version_db=s.agent_version, heartbeat=hb if hb else None)
if has_agent or hb or is_online:
status = "online" if is_online else "offline"
else:
status = "unknown"
out.append({
"id": s.id,
"name": s.name,
"domain": s.domain,
"category": s.category,
"is_online": is_online,
"status": status,
**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 in ("background", "batch"):
return {**public_bt_panel_status(server), "ok": True, "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],
*,
all_unconfigured: bool = False,
admin_username: str,
ip_address: str | None,
) -> dict[str, Any]:
from server.application.services.server_batch_service import start_batch_job
if all_unconfigured:
statuses = await self.list_server_statuses()
ids = [int(s["id"]) for s in statuses if not s.get("configured")]
else:
ids = []
for x in server_ids:
try:
n = int(x)
except (TypeError, ValueError):
continue
if n > 0:
ids.append(n)
if not ids:
raise ValueError("没有可批量初始化的服务器(均已配置或未录入)")
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,
))
@staticmethod
def _login_url_cache_key(server_id: int) -> str:
return f"btpanel:login_url:{server_id}"
async def _get_cached_login_url(self, server_id: int) -> dict[str, str | bool] | None:
"""短窗口内复用上次生成的登录链接(防连点/多标签/多设备自我顶替)。"""
from server.infrastructure.redis.client import get_redis
try:
cached = await get_redis().get(self._login_url_cache_key(server_id))
except Exception: # noqa: BLE001 — 缓存不可用时退化为直接生成
return None
if not cached:
return None
try:
data = json.loads(cached)
except (TypeError, ValueError):
return None
if isinstance(data, dict) and data.get("url"):
data["reused"] = True
return data
return None
async def _store_cached_login_url(self, server_id: int, result: dict[str, str | bool]) -> None:
from server.infrastructure.redis.client import get_redis
try:
await get_redis().set(
self._login_url_cache_key(server_id),
json.dumps(result, ensure_ascii=False),
ex=BT_LOGIN_URL_CACHE_TTL_SECONDS,
)
except Exception: # noqa: BLE001 — 缓存写失败不影响本次登录
pass
async def create_login_url(
self,
server_id: int,
*,
admin_username: str,
ip_address: str | None,
) -> dict[str, str | bool]:
# 短窗口去重:宝塔 tmp_token 一次性,且新建会作废上一枚;跨请求/多设备连点会互相顶掉刚登录的会话
cached = await self._get_cached_login_url(server_id)
if cached is not None:
return cached
# 串行化:宝塔新建 tmp_token 会作废上一枚;并发 POST 会导致先返回的链 404
async with login_url_lock(server_id):
# 双检:并发方在等锁期间,持锁者可能已生成并写入缓存
cached = await self._get_cached_login_url(server_id)
if cached is not None:
return cached
result = await self._create_login_url_fresh(
server_id,
admin_username=admin_username,
ip_address=ip_address,
)
await self._store_cached_login_url(server_id, result)
return result
async def _create_login_url_fresh(
self,
server_id: int,
*,
admin_username: str,
ip_address: str | None,
) -> dict[str, str | bool]:
server = await self.get_server(server_id)
url: str | None = None
method = "api"
bootstrapped = False
if not bt_panel_configured(server):
bootstrapped = True
result = await self.bootstrap_server(
server_id,
source="immediate",
admin_username=admin_username,
ip_address=ip_address,
force=True,
)
if not result.get("ok") and not result.get("skipped"):
code = str(result.get("error") or "bootstrap_failed")
if code == "bt_not_installed":
raise BtPanelNotInstalledError()
if code in BOOTSTRAP_LOGIN_HARD_FAIL_ERRORS:
raise ValueError(f"获取宝塔 API 失败: {code}")
logger.warning(
"bt panel login bootstrap failed server=%s code=%s; falling back to SSH temp login",
server_id,
code,
)
server = await self.get_server(server_id)
creds = read_bt_panel_credentials(server)
if creds:
url, method = await self._try_login_url_via_api(server, creds)
if not url:
method = "ssh"
fresh = read_bt_panel_credentials(server)
url = await ssh_temp_login_url(
server,
base_url=fresh.base_url if fresh else None,
)
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, "bootstrapped": bootstrapped}, ensure_ascii=False),
ip_address=ip_address,
))
return {"url": url, "method": method, "bootstrapped": bootstrapped}
async def _try_login_url_via_api(
self,
server: Server,
creds: BtPanelCredentials,
) -> tuple[str | None, str]:
"""Try API temp login; on failure refresh base_url from SSH (port.pl) and retry once."""
try:
return await self._login_url_via_api(creds, server), "api"
except BtPanelApiError as exc:
logger.warning("bt panel api temp login failed server=%s: %s", server.id, exc)
except Exception as exc:
logger.warning("bt panel api temp login error server=%s: %s", server.id, exc)
if await self._repair_ip_whitelist(server):
server = await self.get_server(server.id)
creds = read_bt_panel_credentials(server)
if creds:
try:
return await self._login_url_via_api(creds, server), "api"
except BtPanelApiError as exc:
logger.warning(
"bt panel api temp login retry failed server=%s: %s",
server.id,
exc,
)
except Exception as exc:
logger.warning(
"bt panel api temp login retry error server=%s: %s",
server.id,
exc,
)
return None, "ssh_fallback"
async def _login_url_via_api(
self,
creds: BtPanelCredentials,
server: Server,
) -> str:
expire_time = int(time.time()) + BT_TEMP_LOGIN_TTL_SECONDS
client = BtPanelClient(creds, server.id)
data = await client.post(
"/config?action=set_temp_login",
{"expire_time": expire_time},
)
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 await resolve_temp_login_url(server, val)
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 await resolve_temp_login_url(server, val)
token = data.get("token")
if data.get("status") and isinstance(token, str) and "tmp_token=" not in token:
rel = f"/login?tmp_token={token}"
return normalize_temp_login_url(
rel,
base_url=_panel_port_base(creds.base_url),
prefer_host=server.domain or "",
)
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)
try:
return await client.post(path, data)
except BtPanelApiError as exc:
if "IP校验失败" not in str(exc):
raise
if not await self._repair_ip_whitelist(server):
raise
server = await self.get_server(server_id)
return await self._client(server).post(path, data)
async def _repair_ip_whitelist(self, server: Server) -> bool:
"""SSH 追加中心 IP 到宝塔 ``config/api.json`` 白名单(路径/token 漂移时自愈)。"""
center_ip = get_bt_panel_source_ip()
if not center_ip:
logger.warning("bt panel ip whitelist repair skipped server=%s: no center ip", server.id)
return False
try:
result = await ssh_bootstrap_panel(server, center_ip=center_ip)
except BootstrapRemoteError as exc:
logger.warning("bt panel ip whitelist repair failed server=%s: %s", server.id, exc)
return False
base_url = str(result.get("base_url") or "").strip()
api_key = str(result.get("api_key") or "").strip()
if base_url and api_key:
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(result.get("verify_ssl")),
actions=list(result.get("actions") or []),
)
await self.servers.update(server)
logger.info(
"bt panel ip whitelist repaired server=%s actions=%s",
server.id,
result.get("actions"),
)
return True
# ── 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