From 82426b19414ac70f8909dda43a74ef4abcfd609a Mon Sep 17 00:00:00 2001 From: r Date: Sun, 21 Jun 2026 09:13:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(btpanel):=20=E7=8B=AC=E7=AB=8B=E5=AE=9D?= =?UTF-8?q?=E5=A1=94=E6=A8=A1=E5=9D=97=E4=B8=8E=20SSH=20=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E8=8E=B7=E5=8F=96=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增侧栏「宝塔面板」菜单、代理 API 与连接配置页;通过 SSH 读写子机 api.json 自动写入凭据,5 分钟后台重试,含审计修复、检测缓存与并发锁。 Co-authored-by: Cursor --- .../2026-06-20-btpanel-ssh-api-bootstrap.md | 80 +++ .../2026-06-20-btpanel-standalone-module.md | 50 ++ .../design/plans/2026-06-20-btpanel-module.md | 34 ++ .../specs/2026-06-20-btpanel-module-design.md | 78 +++ frontend/src/App.vue | 50 ++ frontend/src/api/btpanel.ts | 189 ++++++ .../components/btpanel/BtPanelPageShell.vue | 42 ++ .../btpanel/BtPanelServerPicker.vue | 40 ++ .../src/composables/btpanel/btPanelContext.ts | 13 + .../composables/btpanel/useBtPanelPageLoad.ts | 18 + .../composables/btpanel/useBtPanelServer.ts | 61 ++ frontend/src/constants/cachedPages.ts | 13 +- .../src/pages/btpanel/BtPanelCrontabPage.vue | 47 ++ .../pages/btpanel/BtPanelDatabasesPage.vue | 112 ++++ .../src/pages/btpanel/BtPanelDomainsPage.vue | 110 ++++ .../src/pages/btpanel/BtPanelLoginPage.vue | 45 ++ .../src/pages/btpanel/BtPanelMonitorPage.vue | 76 +++ .../src/pages/btpanel/BtPanelServicesPage.vue | 47 ++ .../src/pages/btpanel/BtPanelSettingsPage.vue | 303 ++++++++++ .../pages/btpanel/BtPanelSiteCreatePage.vue | 66 +++ .../src/pages/btpanel/BtPanelSitesPage.vue | 91 +++ frontend/src/pages/btpanel/BtPanelSslPage.vue | 98 ++++ frontend/src/router/index.ts | 11 + server/api/btpanel.py | 428 ++++++++++++++ server/api/btpanel_schemas.py | 90 +++ server/api/servers.py | 14 +- .../services/btpanel_bootstrap_schedule.py | 128 +++++ .../application/services/btpanel_service.py | 541 ++++++++++++++++++ .../services/server_batch_service.py | 36 ++ server/background/bt_panel_bootstrap_loop.py | 35 ++ server/config.py | 11 + server/infrastructure/btpanel/__init__.py | 1 + .../infrastructure/btpanel/bootstrap_lock.py | 20 + .../infrastructure/btpanel/bootstrap_state.py | 202 +++++++ server/infrastructure/btpanel/client.py | 1 - server/infrastructure/btpanel/credentials.py | 86 +++ server/infrastructure/btpanel/source_ip.py | 58 ++ .../infrastructure/btpanel/ssh_bootstrap.py | 170 ++++++ server/infrastructure/btpanel/ssh_login.py | 48 ++ server/infrastructure/ssh/remote_shell.py | 1 - server/main.py | 7 + tests/test_btpanel_bootstrap_loop.py | 89 +++ tests/test_btpanel_client.py | 55 ++ tests/test_btpanel_get_config.py | 77 +++ tests/test_btpanel_ssh_bootstrap.py | 144 +++++ tests/test_btpanel_ssh_login.py | 30 + 46 files changed, 3942 insertions(+), 4 deletions(-) create mode 100644 docs/changelog/2026-06-20-btpanel-ssh-api-bootstrap.md create mode 100644 docs/changelog/2026-06-20-btpanel-standalone-module.md create mode 100644 docs/design/plans/2026-06-20-btpanel-module.md create mode 100644 docs/design/specs/2026-06-20-btpanel-module-design.md create mode 100644 frontend/src/api/btpanel.ts create mode 100644 frontend/src/components/btpanel/BtPanelPageShell.vue create mode 100644 frontend/src/components/btpanel/BtPanelServerPicker.vue create mode 100644 frontend/src/composables/btpanel/btPanelContext.ts create mode 100644 frontend/src/composables/btpanel/useBtPanelPageLoad.ts create mode 100644 frontend/src/composables/btpanel/useBtPanelServer.ts create mode 100644 frontend/src/pages/btpanel/BtPanelCrontabPage.vue create mode 100644 frontend/src/pages/btpanel/BtPanelDatabasesPage.vue create mode 100644 frontend/src/pages/btpanel/BtPanelDomainsPage.vue create mode 100644 frontend/src/pages/btpanel/BtPanelLoginPage.vue create mode 100644 frontend/src/pages/btpanel/BtPanelMonitorPage.vue create mode 100644 frontend/src/pages/btpanel/BtPanelServicesPage.vue create mode 100644 frontend/src/pages/btpanel/BtPanelSettingsPage.vue create mode 100644 frontend/src/pages/btpanel/BtPanelSiteCreatePage.vue create mode 100644 frontend/src/pages/btpanel/BtPanelSitesPage.vue create mode 100644 frontend/src/pages/btpanel/BtPanelSslPage.vue create mode 100644 server/api/btpanel.py create mode 100644 server/api/btpanel_schemas.py create mode 100644 server/application/services/btpanel_bootstrap_schedule.py create mode 100644 server/application/services/btpanel_service.py create mode 100644 server/background/bt_panel_bootstrap_loop.py create mode 100644 server/infrastructure/btpanel/__init__.py create mode 100644 server/infrastructure/btpanel/bootstrap_lock.py create mode 100644 server/infrastructure/btpanel/bootstrap_state.py create mode 100644 server/infrastructure/btpanel/credentials.py create mode 100644 server/infrastructure/btpanel/source_ip.py create mode 100644 server/infrastructure/btpanel/ssh_bootstrap.py create mode 100644 server/infrastructure/btpanel/ssh_login.py create mode 100644 tests/test_btpanel_bootstrap_loop.py create mode 100644 tests/test_btpanel_client.py create mode 100644 tests/test_btpanel_get_config.py create mode 100644 tests/test_btpanel_ssh_bootstrap.py create mode 100644 tests/test_btpanel_ssh_login.py diff --git a/docs/changelog/2026-06-20-btpanel-ssh-api-bootstrap.md b/docs/changelog/2026-06-20-btpanel-ssh-api-bootstrap.md new file mode 100644 index 00000000..93362b77 --- /dev/null +++ b/docs/changelog/2026-06-20-btpanel-ssh-api-bootstrap.md @@ -0,0 +1,80 @@ +# Changelog — 宝塔 SSH 自动获取 API + +**日期**:2026-06-20 + +## 摘要 + +通过 SSH 在子机自动读取/开启宝塔 API(`api.json`),加密写入 Nexus `extra_attrs.bt_panel`;未成功时每 5 分钟后台重试,适配「先 SSH、后装宝塔」场景。 + +## 动机 + +- 2000+ 子机手工填 `base_url` + API Key 不可运维 +- 装机窗口内宝塔可能尚未安装,需后台轮询而非绑 WebSSH +- 中心 IP 白名单需自动追加到子机 `limit_addr` + +## 涉及文件 + +| 层 | 路径 | +|----|------| +| 远程脚本 | `server/infrastructure/btpanel/ssh_bootstrap.py` | +| 状态机 | `server/infrastructure/btpanel/bootstrap_state.py` | +| 并发锁 | `server/infrastructure/btpanel/bootstrap_lock.py` | +| 中心 IP | `server/infrastructure/btpanel/source_ip.py` | +| SSH 检测 | `server/infrastructure/btpanel/ssh_login.py` | +| 调度 | `server/application/services/btpanel_bootstrap_schedule.py` | +| 后台 | `server/background/bt_panel_bootstrap_loop.py` | +| 服务/API | `server/application/services/btpanel_service.py`, `server/api/btpanel.py` | +| 触发 | `server/api/servers.py`(创建/凭据轮询成功) | +| 批量 | `server/application/services/server_batch_service.py`(`bt-panel-bootstrap`) | +| 配置 | `server/config.py`(`BT_PANEL_*`) | +| 前端 | `frontend/src/pages/btpanel/BtPanelSettingsPage.vue`, `frontend/src/api/btpanel.ts` | +| 测试 | `tests/test_btpanel_ssh_bootstrap.py`, `tests/test_btpanel_bootstrap_loop.py`, `tests/test_btpanel_ssh_login.py`, `tests/test_btpanel_get_config.py` | + +## 配置项(settings 表 / `.env` `NEXUS_` 前缀) + +- `bt_panel_source_ip` — 中心调用 IP(优先于 DNS) +- `bt_panel_auto_bootstrap_enabled` — 全局自动获取(默认 `true`) +- `bt_panel_bootstrap_batch` — 每轮最多处理台数(默认 20) +- `bt_panel_bootstrap_interval` — 轮询间隔秒(默认 300) + +## 迁移 / 重启 + +- 无 DB 迁移(状态存 `servers.extra_attrs`,含 `bt_installed` 缓存) +- 需重启 API 以注册 `bt_panel_bootstrap_loop`(primary worker) + +## 验证 + +```bash +.venv/bin/pytest tests/test_btpanel_*.py -q +cd frontend && npx vite build +``` + +- 新服务器创建 → `bootstrap_state=pending`,立即试 1 次 +- 无宝塔目录 → `installing`,5min 后再试 +- 成功 → `ready`,loop 跳过 +- SSH 认证失败 → `failed`,停止直至手动「立即 SSH 获取」 + +--- + +## 审计修复(同日增补) + +### 严重 + +1. **后台 loop 误扫全库**:未入库引导的服务器因默认 `pending` 被 eligible → 仅已有 `bt_panel` 块且显式 `pending`/`installing` + `next_attempt_at` 到期才进 loop。 +2. **`Permission denied` 误判永久失败**:区分 SSH 认证 vs 远程写 `api.json` 权限错误。 +3. **`ssh_login` 使用 `result.success`**:`exec_ssh_command` 返回 `status` 字段,导致检测/一键登录异常 → 已改 `status == "success"`。 + +### 中等 + +4. **手动获取覆盖 `disabled`**:手动「立即获取」不再强制 `auto_bootstrap=true`。 +5. **空凭据标 ready**:远程 JSON 空 `base_url`/`api_key` → `bootstrap_empty_credentials` 可重试。 +6. **loop 时间解析**:`run_bootstrap_tick` 改用 `_parse_iso`,避免畸形时间打挂整轮。 +7. **中心 IP 校验**:全局设置非空须合法 IPv4/IPv6。 +8. **UI 状态**:`bootstrap_state=null` 显示「未启用」;`auto_bootstrap` 仅 `true` 为开。 + +### 体验 / 性能(本轮) + +9. **`get_config` SSH 节流**:仅 `pending`/`installing` 或 `?refresh_bt_installed=true` 时 SSH 检测;结果缓存 `extra_attrs.bt_panel.bt_installed`。 +10. **批量初始化确认**:连接配置页批量操作前 `v-dialog` 二次确认。 +11. **per-server 锁**:`bootstrap_lock` 防止即时触发与 5min loop 并发双 SSH。 +12. **loop 日志**:`run_bootstrap_tick` 仅统计 `ok=true` 成功台数。 diff --git a/docs/changelog/2026-06-20-btpanel-standalone-module.md b/docs/changelog/2026-06-20-btpanel-standalone-module.md new file mode 100644 index 00000000..746ba0f7 --- /dev/null +++ b/docs/changelog/2026-06-20-btpanel-standalone-module.md @@ -0,0 +1,50 @@ +# 2026-06-20 — 宝塔面板独立模块 + +## 摘要 + +新增与「运维」菜单分离的 **宝塔面板** 分组及 10 个子页,后端 `/api/btpanel/*` 代理宝塔 API(签名 + Redis Cookie)并支持 SSH 生成 `tmp_token` 一键登录。 + +## 动机 + +用户需在 Nexus 内统一管理子机宝塔:一键登录、监控、网站/域名/SSL/数据库/计划任务/服务启停,且**不并入**现有服务器、文件、调度页面。 + +## 功能范围 + +| 编号 | 子菜单 | 说明 | +|------|--------|------| +| A1/A5 | 面板登录 | API `set_temp_login` 或 SSH `tools.py` | +| B1–B3 | 系统监控 | GetSystemTotal / Disk / NetWork | +| C1,C6 | 网站列表 | 列表 + 启停 | +| C4 | 创建网站 | AddSite | +| C10 | 域名管理 | AddDomain / DelDomain | +| C15 | SSL 证书 | 列表 + SetSSL + apply_cert | +| D1–D4 | 数据库 | 列表 / 创建 / 改密 / 备份 | +| F1 | 计划任务 | GetCrontab 只读 | +| H1 | 服务管理 | ServiceAdmin | +| — | 连接配置 | `extra_attrs.bt_panel` 加密存 API Key | + +## 涉及文件 + +- `server/infrastructure/btpanel/*` +- `server/application/services/btpanel_service.py` +- `server/api/btpanel.py`, `btpanel_schemas.py` +- `server/main.py` +- `frontend/src/pages/btpanel/*`, `components/btpanel/*`, `api/btpanel.ts` +- `frontend/src/App.vue`, `router/index.ts` +- `docs/design/specs/2026-06-20-btpanel-module-design.md` +- `tests/test_btpanel_client.py` + +## 迁移 / 重启 + +- 无 DB 迁移(凭据在 `servers.extra_attrs`) +- 需重启 API 加载新路由 +- 前端需 `vite build` + +## 验证 + +```bash +pytest tests/test_btpanel_client.py -q +cd frontend && npx vite build +``` + +侧栏「宝塔面板」→ 各子页;先在「连接配置」填写面板地址与 API Key,并在子机白名单加入中心 IP。 diff --git a/docs/design/plans/2026-06-20-btpanel-module.md b/docs/design/plans/2026-06-20-btpanel-module.md new file mode 100644 index 00000000..e390cd15 --- /dev/null +++ b/docs/design/plans/2026-06-20-btpanel-module.md @@ -0,0 +1,34 @@ +# 宝塔面板独立模块 — 实施计划 + +## 涉及文件 + +### 后端(新增) + +- `server/infrastructure/btpanel/credentials.py` — extra_attrs 读写与解密 +- `server/infrastructure/btpanel/client.py` — 签名、Cookie、POST 代理 +- `server/infrastructure/btpanel/ssh_login.py` — SSH 临时登录链接 +- `server/application/services/btpanel_service.py` — 业务编排 +- `server/api/btpanel.py` — HTTP 路由 +- `server/api/btpanel_schemas.py` — Pydantic 模型 +- `tests/test_btpanel_client.py` — 签名与凭据单测 + +### 后端(修改) + +- `server/main.py` — 挂载 router + +### 前端(新增) + +- `frontend/src/pages/btpanel/*.vue` — 9 个子页 +- `frontend/src/components/btpanel/BtPanelServerPicker.vue` +- `frontend/src/composables/btpanel/useBtPanelServer.ts` +- `frontend/src/api/btpanel.ts` + +### 前端(修改) + +- `frontend/src/router/index.ts` +- `frontend/src/App.vue` — 独立导航分组 +- `frontend/src/constants/cachedPages.ts` + +## 回滚 + +删除 `server/api/btpanel.py` 与 `server/infrastructure/btpanel/`,移除路由与菜单即可;`extra_attrs.bt_panel` 可保留无害。 diff --git a/docs/design/specs/2026-06-20-btpanel-module-design.md b/docs/design/specs/2026-06-20-btpanel-module-design.md new file mode 100644 index 00000000..3769bdd0 --- /dev/null +++ b/docs/design/specs/2026-06-20-btpanel-module-design.md @@ -0,0 +1,78 @@ +# 宝塔面板独立模块 — 设计文档 + +> 日期:2026-06-20 +> 状态:已批准实施 + +## 背景与目标 + +用户在 Nexus 管理 2000+ 台装宝塔的子机,需要**独立菜单**操作宝塔能力(一键登录、监控、网站/库/任务/服务),**不与**现有服务器页、文件、调度等功能合并 UI。 + +## 方案选定 + +| 层 | 方案 | +|----|------| +| 凭据 | 每台 `servers.extra_attrs.bt_panel`:`base_url`、`api_key_enc`(Fernet)、`verify_ssl` | +| 登录 A1/A5 | 优先 `POST /config?action=set_temp_login`;失败则 SSH `tools.py get_temp_login_ipv4` | +| 其它能力 | 中心 HTTP 代理宝塔 API(签名 + Redis Cookie 复用) | +| 前端 | 新导航分组「宝塔面板」+ 9 个子路由,共享服务器选择器 | + +## 功能映射 + +| 编号 | Nexus 路由 | 宝塔 API / 方式 | +|------|-----------|----------------| +| A1/A5 | `/btpanel/login` | `set_temp_login` / SSH tmp_token | +| B1–B3 | `/btpanel/monitor` | `GetSystemTotal` / `GetDiskInfo` / `GetNetWork` | +| C1,C6 | `/btpanel/sites` | `getData sites` / `SiteStart` `SiteStop` | +| C4 | `/btpanel/sites/create` | `AddSite` + `GetPHPVersion` | +| C10 | `/btpanel/domains` | `getData domain` / `AddDomain` / `DelDomain` | +| C15 | `/btpanel/ssl` | 站点 SSL 字段 + `SetSSL` / `apply_cert` | +| D1–D4 | `/btpanel/databases` | `getData databases` / `AddDatabase` / `ResDatabasePassword` / `ToBackup` | +| F1 | `/btpanel/crontab` | `GetCrontab`(只读) | +| H1 | `/btpanel/services` | `ServiceAdmin` | +| 配置 | `/btpanel/settings` | 读写 `extra_attrs.bt_panel` | + +## 安全 + +- 所有路由 `Depends(get_current_admin)` +- API Key 仅写入加密字段;响应 `api_key_set: bool` +- 生成登录链接、建站、删库等写操作记 `audit_logs` +- 中心机 IP 须在各子机宝塔 API 白名单;SSH Bootstrap 可自动追加 `limit_addr`(见下节) + +## SSH Bootstrap(2026-06-20 增补) + +子机 Linux 宝塔 API 配置位于 `/www/server/panel/data/api.json`。中心通过 root SSH 执行 `ssh_bootstrap.py`: + +1. 无面板目录 → `bt_not_installed`(状态 `installing`,5min 重试) +2. 读/写 `api.json`:保留 token、开启 API、追加中心 IP 到 `limit_addr` +3. 原子写 + `bt reload`;stdout JSON → 加密写入 `api_key_enc` + `base_url` + +### 状态机(`extra_attrs.bt_panel`) + +| `bootstrap_state` | 含义 | 5min 重试 | +|-------------------|------|-----------| +| `pending` | 等待首次/下次 | 是 | +| `installing` | 宝塔尚未装好 | 是 | +| `ready` | 已写入凭据 | 否 | +| `failed` | SSH 认证失败等 | 否 | +| `disabled` | 用户关闭自动 | 否 | + +### Background Loop + +- `server/background/bt_panel_bootstrap_loop.py`,primary worker,默认 300s +- 每轮最多 `BT_PANEL_BOOTSTRAP_BATCH`(20)台 +- 触发:创建服务器、凭据轮询成功、UI「立即 SSH 获取」、批量 `bt-panel-bootstrap` + +### API + +| 方法 | 路径 | +|------|------| +| POST | `/api/btpanel/servers/{id}/bootstrap` | +| POST | `/api/btpanel/servers/batch-bootstrap` | +| GET/PUT | `/api/btpanel/settings`(中心 IP、全局开关) | + +## 验收 + +1. 侧栏见独立「宝塔面板」分组及子项,与「运维」分组分离 +2. 配置 API 后可一键打开 tmp_token 登录页 +3. 各子页选定服务器后能拉取对应宝塔数据或执行操作 +4. `pytest tests/test_btpanel*.py` + `vite build` 通过 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 4829811c..90968184 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -81,6 +81,38 @@ + + 宝塔面板 + + + + + + + + 系统 @@ -319,8 +351,13 @@ async function toggleTheme() { } const drawer = ref(true) +const btPanelMenuOpen = ref(route.path.startsWith('/btpanel')) const { mobile } = useDisplay() +watch(() => route.path, (p) => { + if (p.startsWith('/btpanel')) btPanelMenuOpen.value = true +}) + /** 终端页监听菜单开合,在动画结束后 refit xterm */ provide('nexusDrawer', drawer) @@ -356,6 +393,19 @@ const sysItems = [ { to: '/audit', title: '审计日志', icon: 'mdi-clipboard-text-outline' }, ] +const btPanelItems = [ + { to: '/btpanel/login', title: '面板登录', icon: 'mdi-login' }, + { to: '/btpanel/monitor', title: '系统监控', icon: 'mdi-speedometer' }, + { to: '/btpanel/sites', title: '网站列表', icon: 'mdi-web' }, + { to: '/btpanel/sites/create', title: '创建网站', icon: 'mdi-web-plus' }, + { to: '/btpanel/domains', title: '域名管理', icon: 'mdi-domain' }, + { to: '/btpanel/ssl', title: 'SSL 证书', icon: 'mdi-certificate-outline' }, + { to: '/btpanel/databases', title: '数据库', icon: 'mdi-database-outline' }, + { to: '/btpanel/crontab', title: '计划任务', icon: 'mdi-calendar-clock' }, + { to: '/btpanel/services', title: '服务管理', icon: 'mdi-cog-transfer-outline' }, + { to: '/btpanel/settings', title: '连接配置', icon: 'mdi-link-variant' }, +] + const snackbar = reactive({ show: false, text: '', color: 'success' }) function navigateTo(to: string) { diff --git a/frontend/src/api/btpanel.ts b/frontend/src/api/btpanel.ts new file mode 100644 index 00000000..b021fa9f --- /dev/null +++ b/frontend/src/api/btpanel.ts @@ -0,0 +1,189 @@ +import { api } from '@/api' + +export interface BtPanelServerRow { + id: number + name: string + domain: string + category: string | null + base_url: string | null + api_key_set: boolean + verify_ssl: boolean + configured: boolean + auto_bootstrap?: boolean + bootstrap_state?: string + bootstrap_last_attempt_at?: string | null + bootstrap_next_attempt_at?: string | null + bootstrap_attempts?: number + bootstrap_last_error?: string | null +} + +export interface BtPanelConfig { + base_url: string | null + api_key_set: boolean + verify_ssl: boolean + configured: boolean + bt_installed: boolean | null + auto_bootstrap?: boolean + bootstrap_state?: string + bootstrap_last_attempt_at?: string | null + bootstrap_next_attempt_at?: string | null + bootstrap_attempts?: number + bootstrap_last_error?: string | null + bootstrap_last_actions?: string[] | null + ok?: boolean + error?: string +} + +export interface BtPanelGlobalSettings { + bt_panel_source_ip: string + bt_panel_auto_bootstrap_enabled: boolean + resolved_center_ip: string | null +} + +export interface BatchJobResponse { + job_id: number + status: string + label: string +} + +export function listBtServers() { + return api('/btpanel/servers') +} + +export function getBtConfig(serverId: number, options?: { refreshBtInstalled?: boolean }) { + const qs = options?.refreshBtInstalled ? '?refresh_bt_installed=true' : '' + return api(`/btpanel/servers/${serverId}/config${qs}`) +} + +export function updateBtConfig(serverId: number, body: { + base_url?: string + api_key?: string + verify_ssl?: boolean + auto_bootstrap?: boolean +}) { + return api(`/btpanel/servers/${serverId}/config`, { + method: 'PUT', + body: JSON.stringify(body), + }) +} + +export function getBtGlobalSettings() { + return api('/btpanel/settings') +} + +export function updateBtGlobalSettings(body: { + bt_panel_source_ip?: string + bt_panel_auto_bootstrap_enabled?: boolean +}) { + return api('/btpanel/settings', { + method: 'PUT', + body: JSON.stringify(body), + }) +} + +export function bootstrapBtServer(serverId: number) { + return api(`/btpanel/servers/${serverId}/bootstrap`, { method: 'POST', body: '{}' }) +} + +export function batchBootstrapBtServers(serverIds: number[]) { + return api('/btpanel/servers/batch-bootstrap', { + method: 'POST', + body: JSON.stringify({ server_ids: serverIds }), + }) +} + +export function createBtLoginUrl(serverId: number) { + return api<{ url: string; method: string }>(`/btpanel/servers/${serverId}/login-url`, { method: 'POST', body: '{}' }) +} + +export function btSystemTotal(serverId: number) { + return api>(`/btpanel/servers/${serverId}/system/total`) +} + +export function btSystemDisk(serverId: number) { + return api(`/btpanel/servers/${serverId}/system/disk`) +} + +export function btSystemNetwork(serverId: number) { + return api>(`/btpanel/servers/${serverId}/system/network`) +} + +export function btListSites(serverId: number) { + return api<{ data?: unknown[] }>(`/btpanel/servers/${serverId}/sites`) +} + +export function btSiteStart(serverId: number, site_id: number, name: string) { + return api(`/btpanel/servers/${serverId}/sites/start`, { + method: 'POST', + body: JSON.stringify({ site_id, name }), + }) +} + +export function btSiteStop(serverId: number, site_id: number, name: string) { + return api(`/btpanel/servers/${serverId}/sites/stop`, { + method: 'POST', + body: JSON.stringify({ site_id, name }), + }) +} + +export function btPhpVersions(serverId: number) { + return api(`/btpanel/servers/${serverId}/sites/php-versions`) +} + +export function btCreateSite(serverId: number, body: Record) { + return api(`/btpanel/servers/${serverId}/sites`, { + method: 'POST', + body: JSON.stringify(body), + }) +} + +export function btListDomains(serverId: number, siteId: number) { + return api<{ data?: unknown[] }>(`/btpanel/servers/${serverId}/sites/${siteId}/domains`) +} + +export function btAddDomain(serverId: number, body: Record) { + return api(`/btpanel/servers/${serverId}/domains`, { method: 'POST', body: JSON.stringify(body) }) +} + +export function btDeleteDomain(serverId: number, body: Record) { + return api(`/btpanel/servers/${serverId}/domains/delete`, { method: 'POST', body: JSON.stringify(body) }) +} + +export function btSslSites(serverId: number) { + return api<{ data?: unknown[] }>(`/btpanel/servers/${serverId}/ssl/sites`) +} + +export function btSetSsl(serverId: number, body: { siteName: string; key: string; csr: string }) { + return api(`/btpanel/servers/${serverId}/ssl/set`, { method: 'POST', body: JSON.stringify(body) }) +} + +export function btApplySsl(serverId: number, body: { domains: string; id: number; auth_type?: string }) { + return api(`/btpanel/servers/${serverId}/ssl/apply`, { method: 'POST', body: JSON.stringify(body) }) +} + +export function btListDatabases(serverId: number) { + return api<{ data?: unknown[] }>(`/btpanel/servers/${serverId}/databases`) +} + +export function btCreateDatabase(serverId: number, body: Record) { + return api(`/btpanel/servers/${serverId}/databases`, { method: 'POST', body: JSON.stringify(body) }) +} + +export function btDbPassword(serverId: number, body: { id: number; name: string; password: string }) { + return api(`/btpanel/servers/${serverId}/databases/password`, { method: 'POST', body: JSON.stringify(body) }) +} + +export function btDbBackup(serverId: number, id: number) { + return api(`/btpanel/servers/${serverId}/databases/backup`, { method: 'POST', body: JSON.stringify({ id }) }) +} + +export function btCrontab(serverId: number) { + return api(`/btpanel/servers/${serverId}/crontab`) +} + +export function btServiceAdmin(serverId: number, name: string, type: string) { + return api(`/btpanel/servers/${serverId}/services`, { + method: 'POST', + body: JSON.stringify({ name, type }), + }) +} diff --git a/frontend/src/components/btpanel/BtPanelPageShell.vue b/frontend/src/components/btpanel/BtPanelPageShell.vue new file mode 100644 index 00000000..2bc93fa5 --- /dev/null +++ b/frontend/src/components/btpanel/BtPanelPageShell.vue @@ -0,0 +1,42 @@ + + + diff --git a/frontend/src/components/btpanel/BtPanelServerPicker.vue b/frontend/src/components/btpanel/BtPanelServerPicker.vue new file mode 100644 index 00000000..31a8a501 --- /dev/null +++ b/frontend/src/components/btpanel/BtPanelServerPicker.vue @@ -0,0 +1,40 @@ + + + diff --git a/frontend/src/composables/btpanel/btPanelContext.ts b/frontend/src/composables/btpanel/btPanelContext.ts new file mode 100644 index 00000000..29740247 --- /dev/null +++ b/frontend/src/composables/btpanel/btPanelContext.ts @@ -0,0 +1,13 @@ +import type { InjectionKey, Ref } from 'vue' +import type { BtPanelServerRow } from '@/api/btpanel' + +export interface BtPanelContext { + servers: Ref + serverId: Ref + loading: Ref + loadServers: () => Promise + setServerId: (id: number | null) => void + selected: () => BtPanelServerRow | null +} + +export const btPanelContextKey: InjectionKey = Symbol('btPanelContext') diff --git a/frontend/src/composables/btpanel/useBtPanelPageLoad.ts b/frontend/src/composables/btpanel/useBtPanelPageLoad.ts new file mode 100644 index 00000000..0470a9fa --- /dev/null +++ b/frontend/src/composables/btpanel/useBtPanelPageLoad.ts @@ -0,0 +1,18 @@ +import { inject, onMounted, watch, type Ref } from 'vue' +import { btPanelContextKey } from '@/composables/btpanel/btPanelContext' + +export function useBtPanelPageLoad(loadFn: (serverId: number) => void | Promise) { + const ctx = inject(btPanelContextKey) + if (!ctx) throw new Error('useBtPanelPageLoad must be used inside BtPanelPageShell') + + async function run() { + const id = ctx.serverId.value + if (!id) return + await loadFn(id) + } + + onMounted(() => { void run() }) + watch(ctx.serverId, () => { void run() }) + + return { serverId: ctx.serverId as Ref, selected: ctx.selected, run } +} diff --git a/frontend/src/composables/btpanel/useBtPanelServer.ts b/frontend/src/composables/btpanel/useBtPanelServer.ts new file mode 100644 index 00000000..58e74149 --- /dev/null +++ b/frontend/src/composables/btpanel/useBtPanelServer.ts @@ -0,0 +1,61 @@ +import { ref, watch } from 'vue' +import { useRoute, useRouter } from 'vue-router' +import { listBtServers, type BtPanelServerRow } from '@/api/btpanel' + +const STORAGE_KEY = 'nexus_bt_panel_server_id' + +export function useBtPanelServer() { + const route = useRoute() + const router = useRouter() + const servers = ref([]) + const serverId = ref(null) + const loading = ref(false) + + async function loadServers() { + loading.value = true + try { + servers.value = await listBtServers() + } finally { + loading.value = false + } + } + + function resolveInitialId(): number | null { + const q = route.query.server_id + if (q) { + const n = Number(q) + if (Number.isFinite(n) && n > 0) return n + } + const stored = sessionStorage.getItem(STORAGE_KEY) + if (stored) { + const n = Number(stored) + if (Number.isFinite(n) && n > 0) return n + } + return null + } + + function setServerId(id: number | null) { + serverId.value = id + if (id) { + sessionStorage.setItem(STORAGE_KEY, String(id)) + if (route.query.server_id !== String(id)) { + router.replace({ query: { ...route.query, server_id: String(id) } }).catch(() => {}) + } + } + } + + watch(servers, (list) => { + if (!list.length) return + const initial = resolveInitialId() + if (initial && list.some(s => s.id === initial)) { + serverId.value = initial + return + } + const configured = list.find(s => s.configured) + setServerId(configured?.id ?? list[0].id) + }) + + const selected = () => servers.value.find(s => s.id === serverId.value) ?? null + + return { servers, serverId, loading, loadServers, setServerId, selected } +} diff --git a/frontend/src/constants/cachedPages.ts b/frontend/src/constants/cachedPages.ts index b1afb901..497e906f 100644 --- a/frontend/src/constants/cachedPages.ts +++ b/frontend/src/constants/cachedPages.ts @@ -1,7 +1,8 @@ -/** Component names included in App.vue keep-alive (exclude Terminal + Login). */ +/** Component names included in App.vue keep-alive (exclude Login). */ export const CACHED_PAGE_NAMES = [ 'DashboardPage', 'ServersPage', + 'TerminalPage', 'ScriptsPage', 'ScriptRunsPage', 'PushPage', @@ -12,4 +13,14 @@ export const CACHED_PAGE_NAMES = [ 'AlertsPage', 'AuditPage', 'SettingsPage', + 'BtPanelLoginPage', + 'BtPanelMonitorPage', + 'BtPanelSitesPage', + 'BtPanelSiteCreatePage', + 'BtPanelDomainsPage', + 'BtPanelSslPage', + 'BtPanelDatabasesPage', + 'BtPanelCrontabPage', + 'BtPanelServicesPage', + 'BtPanelSettingsPage', ] as const diff --git a/frontend/src/pages/btpanel/BtPanelCrontabPage.vue b/frontend/src/pages/btpanel/BtPanelCrontabPage.vue new file mode 100644 index 00000000..279c0f1f --- /dev/null +++ b/frontend/src/pages/btpanel/BtPanelCrontabPage.vue @@ -0,0 +1,47 @@ + + + diff --git a/frontend/src/pages/btpanel/BtPanelDatabasesPage.vue b/frontend/src/pages/btpanel/BtPanelDatabasesPage.vue new file mode 100644 index 00000000..e7fe277c --- /dev/null +++ b/frontend/src/pages/btpanel/BtPanelDatabasesPage.vue @@ -0,0 +1,112 @@ + + + diff --git a/frontend/src/pages/btpanel/BtPanelDomainsPage.vue b/frontend/src/pages/btpanel/BtPanelDomainsPage.vue new file mode 100644 index 00000000..09552d18 --- /dev/null +++ b/frontend/src/pages/btpanel/BtPanelDomainsPage.vue @@ -0,0 +1,110 @@ + + + diff --git a/frontend/src/pages/btpanel/BtPanelLoginPage.vue b/frontend/src/pages/btpanel/BtPanelLoginPage.vue new file mode 100644 index 00000000..ff08605a --- /dev/null +++ b/frontend/src/pages/btpanel/BtPanelLoginPage.vue @@ -0,0 +1,45 @@ + + + diff --git a/frontend/src/pages/btpanel/BtPanelMonitorPage.vue b/frontend/src/pages/btpanel/BtPanelMonitorPage.vue new file mode 100644 index 00000000..cb14a4db --- /dev/null +++ b/frontend/src/pages/btpanel/BtPanelMonitorPage.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/frontend/src/pages/btpanel/BtPanelServicesPage.vue b/frontend/src/pages/btpanel/BtPanelServicesPage.vue new file mode 100644 index 00000000..9aaa831c --- /dev/null +++ b/frontend/src/pages/btpanel/BtPanelServicesPage.vue @@ -0,0 +1,47 @@ + + + diff --git a/frontend/src/pages/btpanel/BtPanelSettingsPage.vue b/frontend/src/pages/btpanel/BtPanelSettingsPage.vue new file mode 100644 index 00000000..4bfae114 --- /dev/null +++ b/frontend/src/pages/btpanel/BtPanelSettingsPage.vue @@ -0,0 +1,303 @@ + + + diff --git a/frontend/src/pages/btpanel/BtPanelSiteCreatePage.vue b/frontend/src/pages/btpanel/BtPanelSiteCreatePage.vue new file mode 100644 index 00000000..0c3d3772 --- /dev/null +++ b/frontend/src/pages/btpanel/BtPanelSiteCreatePage.vue @@ -0,0 +1,66 @@ + + + diff --git a/frontend/src/pages/btpanel/BtPanelSitesPage.vue b/frontend/src/pages/btpanel/BtPanelSitesPage.vue new file mode 100644 index 00000000..754a27cd --- /dev/null +++ b/frontend/src/pages/btpanel/BtPanelSitesPage.vue @@ -0,0 +1,91 @@ + + + diff --git a/frontend/src/pages/btpanel/BtPanelSslPage.vue b/frontend/src/pages/btpanel/BtPanelSslPage.vue new file mode 100644 index 00000000..1cc2a29a --- /dev/null +++ b/frontend/src/pages/btpanel/BtPanelSslPage.vue @@ -0,0 +1,98 @@ + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 4e77595b..2110322c 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -21,6 +21,17 @@ const routes = [ { path: '/audit', name: 'Audit', component: () => import('@/pages/AuditPage.vue') }, { path: '/settings', name: 'Settings', component: () => import('@/pages/SettingsPage.vue') }, { path: '/login', name: 'Login', component: () => import('@/pages/LoginPage.vue'), meta: { public: true } }, + // ── 宝塔面板(独立模块,不与运维页合并)── + { path: '/btpanel/login', name: 'BtPanelLogin', component: () => import('@/pages/btpanel/BtPanelLoginPage.vue') }, + { path: '/btpanel/monitor', name: 'BtPanelMonitor', component: () => import('@/pages/btpanel/BtPanelMonitorPage.vue') }, + { path: '/btpanel/sites', name: 'BtPanelSites', component: () => import('@/pages/btpanel/BtPanelSitesPage.vue') }, + { path: '/btpanel/sites/create', name: 'BtPanelSiteCreate', component: () => import('@/pages/btpanel/BtPanelSiteCreatePage.vue') }, + { path: '/btpanel/domains', name: 'BtPanelDomains', component: () => import('@/pages/btpanel/BtPanelDomainsPage.vue') }, + { path: '/btpanel/ssl', name: 'BtPanelSsl', component: () => import('@/pages/btpanel/BtPanelSslPage.vue') }, + { path: '/btpanel/databases', name: 'BtPanelDatabases', component: () => import('@/pages/btpanel/BtPanelDatabasesPage.vue') }, + { path: '/btpanel/crontab', name: 'BtPanelCrontab', component: () => import('@/pages/btpanel/BtPanelCrontabPage.vue') }, + { path: '/btpanel/services', name: 'BtPanelServices', component: () => import('@/pages/btpanel/BtPanelServicesPage.vue') }, + { path: '/btpanel/settings', name: 'BtPanelSettings', component: () => import('@/pages/btpanel/BtPanelSettingsPage.vue') }, ] const router = createRouter({ history: createWebHashHistory(), routes }) diff --git a/server/api/btpanel.py b/server/api/btpanel.py new file mode 100644 index 00000000..85c2c8b9 --- /dev/null +++ b/server/api/btpanel.py @@ -0,0 +1,428 @@ +"""Baota (BT Panel) management API — standalone module, not merged with servers UI.""" + +from __future__ import annotations + +import logging +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from server.api.auth_jwt import get_current_admin +from server.api.btpanel_schemas import ( + BtPanelApplyCert, + BtPanelBatchBootstrap, + BtPanelConfigUpdate, + BtPanelCreateDatabase, + BtPanelCreateSite, + BtPanelDbBackup, + BtPanelDbPassword, + BtPanelDomainAdd, + BtPanelDomainDelete, + BtPanelGlobalSettingsUpdate, + BtPanelServiceAction, + BtPanelSetSsl, + BtPanelSiteAction, +) +from server.api.dependencies import get_db +from server.application.services.btpanel_service import BtPanelService +from server.domain.models import Admin +from server.infrastructure.btpanel.client import BtPanelApiError + +logger = logging.getLogger("nexus.api.btpanel") + +router = APIRouter(prefix="/api/btpanel", tags=["btpanel"]) + + +def _client_ip(request: Request) -> str | None: + return request.client.host if request.client else None + + +def _svc(db: AsyncSession) -> BtPanelService: + return BtPanelService(db) + + +def _http_error(exc: Exception) -> HTTPException: + if isinstance(exc, ValueError): + return HTTPException(status_code=400, detail=str(exc)) + if isinstance(exc, BtPanelApiError): + return HTTPException(status_code=502, detail=str(exc)) + if isinstance(exc, RuntimeError): + return HTTPException(status_code=502, detail=str(exc)) + logger.exception("btpanel error") + return HTTPException(status_code=500, detail="宝塔操作失败") + + +@router.get("/servers") +async def list_bt_servers( + db: AsyncSession = Depends(get_db), + _admin: Admin = Depends(get_current_admin), +) -> list[dict[str, Any]]: + return await _svc(db).list_server_statuses() + + +@router.get("/servers/{server_id}/config") +async def get_bt_config( + server_id: int, + refresh_bt_installed: bool = Query(False, description="强制 SSH 重新检测宝塔是否安装"), + db: AsyncSession = Depends(get_db), + _admin: Admin = Depends(get_current_admin), +) -> dict[str, Any]: + try: + return await _svc(db).get_config(server_id, refresh_bt_installed=refresh_bt_installed) + except Exception as exc: + raise _http_error(exc) from exc + + +@router.put("/servers/{server_id}/config") +async def update_bt_config( + server_id: int, + body: BtPanelConfigUpdate, + request: Request, + db: AsyncSession = Depends(get_db), + admin: Admin = Depends(get_current_admin), +) -> dict[str, Any]: + try: + return await _svc(db).update_config( + server_id, + base_url=body.base_url, + api_key=body.api_key, + verify_ssl=body.verify_ssl, + auto_bootstrap=body.auto_bootstrap, + admin_username=admin.username, + ip_address=_client_ip(request), + ) + except Exception as exc: + raise _http_error(exc) from exc + + +@router.get("/settings") +async def get_bt_global_settings( + db: AsyncSession = Depends(get_db), + _admin: Admin = Depends(get_current_admin), +) -> dict[str, Any]: + return await _svc(db).get_global_settings() + + +@router.put("/settings") +async def update_bt_global_settings( + body: BtPanelGlobalSettingsUpdate, + request: Request, + db: AsyncSession = Depends(get_db), + admin: Admin = Depends(get_current_admin), +) -> dict[str, Any]: + try: + return await _svc(db).update_global_settings( + bt_panel_source_ip=body.bt_panel_source_ip, + bt_panel_auto_bootstrap_enabled=body.bt_panel_auto_bootstrap_enabled, + admin_username=admin.username, + ip_address=_client_ip(request), + ) + except Exception as exc: + raise _http_error(exc) from exc + + +@router.post("/servers/{server_id}/bootstrap") +async def bootstrap_bt_server( + server_id: int, + request: Request, + db: AsyncSession = Depends(get_db), + admin: Admin = Depends(get_current_admin), +) -> dict[str, Any]: + try: + return await _svc(db).bootstrap_server( + server_id, + source="manual", + admin_username=admin.username, + ip_address=_client_ip(request), + force=True, + ) + except Exception as exc: + raise _http_error(exc) from exc + + +@router.post("/servers/batch-bootstrap") +async def batch_bootstrap_bt_servers( + body: BtPanelBatchBootstrap, + request: Request, + db: AsyncSession = Depends(get_db), + admin: Admin = Depends(get_current_admin), +) -> dict[str, Any]: + try: + return await _svc(db).batch_bootstrap( + body.server_ids, + admin_username=admin.username, + ip_address=_client_ip(request), + ) + except Exception as exc: + raise _http_error(exc) from exc + + +@router.post("/servers/{server_id}/login-url") +async def create_bt_login_url( + server_id: int, + request: Request, + db: AsyncSession = Depends(get_db), + admin: Admin = Depends(get_current_admin), +) -> dict[str, str]: + try: + return await _svc(db).create_login_url( + server_id, + admin_username=admin.username, + ip_address=_client_ip(request), + ) + except Exception as exc: + raise _http_error(exc) from exc + + +@router.get("/servers/{server_id}/system/total") +async def bt_system_total(server_id: int, db: AsyncSession = Depends(get_db), _admin: Admin = Depends(get_current_admin)): + try: + return await _svc(db).system_total(server_id) + except Exception as exc: + raise _http_error(exc) from exc + + +@router.get("/servers/{server_id}/system/disk") +async def bt_system_disk(server_id: int, db: AsyncSession = Depends(get_db), _admin: Admin = Depends(get_current_admin)): + try: + return await _svc(db).system_disk(server_id) + except Exception as exc: + raise _http_error(exc) from exc + + +@router.get("/servers/{server_id}/system/network") +async def bt_system_network(server_id: int, db: AsyncSession = Depends(get_db), _admin: Admin = Depends(get_current_admin)): + try: + return await _svc(db).system_network(server_id) + except Exception as exc: + raise _http_error(exc) from exc + + +@router.get("/servers/{server_id}/sites") +async def bt_list_sites(server_id: int, db: AsyncSession = Depends(get_db), _admin: Admin = Depends(get_current_admin)): + try: + return await _svc(db).list_sites(server_id) + except Exception as exc: + raise _http_error(exc) from exc + + +@router.post("/servers/{server_id}/sites/start") +async def bt_site_start( + server_id: int, + body: BtPanelSiteAction, + db: AsyncSession = Depends(get_db), + _admin: Admin = Depends(get_current_admin), +): + try: + return await _svc(db).site_start(server_id, body.site_id, body.name) + except Exception as exc: + raise _http_error(exc) from exc + + +@router.post("/servers/{server_id}/sites/stop") +async def bt_site_stop( + server_id: int, + body: BtPanelSiteAction, + db: AsyncSession = Depends(get_db), + _admin: Admin = Depends(get_current_admin), +): + try: + return await _svc(db).site_stop(server_id, body.site_id, body.name) + except Exception as exc: + raise _http_error(exc) from exc + + +@router.get("/servers/{server_id}/sites/php-versions") +async def bt_php_versions(server_id: int, db: AsyncSession = Depends(get_db), _admin: Admin = Depends(get_current_admin)): + try: + return await _svc(db).php_versions(server_id) + except Exception as exc: + raise _http_error(exc) from exc + + +@router.post("/servers/{server_id}/sites") +async def bt_create_site( + server_id: int, + body: BtPanelCreateSite, + request: Request, + db: AsyncSession = Depends(get_db), + admin: Admin = Depends(get_current_admin), +): + import json + webname_obj = {"domain": body.webname, "domainlist": [], "count": 0} + payload = { + "webname": json.dumps(webname_obj, ensure_ascii=False), + "path": body.path, + "type_id": body.type_id, + "type": body.type, + "version": body.version, + "port": body.port, + "ps": body.ps, + "ftp": "true" if body.ftp else "false", + "sql": "true" if body.sql else "false", + } + try: + return await _svc(db).create_site( + server_id, payload, + admin_username=admin.username, + ip_address=_client_ip(request), + ) + except Exception as exc: + raise _http_error(exc) from exc + + +@router.get("/servers/{server_id}/sites/{site_id}/domains") +async def bt_list_domains( + server_id: int, + site_id: int, + db: AsyncSession = Depends(get_db), + _admin: Admin = Depends(get_current_admin), +): + try: + return await _svc(db).list_domains(server_id, site_id) + except Exception as exc: + raise _http_error(exc) from exc + + +@router.post("/servers/{server_id}/domains") +async def bt_add_domain( + server_id: int, + body: BtPanelDomainAdd, + db: AsyncSession = Depends(get_db), + _admin: Admin = Depends(get_current_admin), +): + try: + return await _svc(db).add_domain(server_id, body.model_dump()) + except Exception as exc: + raise _http_error(exc) from exc + + +@router.post("/servers/{server_id}/domains/delete") +async def bt_delete_domain( + server_id: int, + body: BtPanelDomainDelete, + db: AsyncSession = Depends(get_db), + _admin: Admin = Depends(get_current_admin), +): + try: + return await _svc(db).delete_domain(server_id, body.model_dump()) + except Exception as exc: + raise _http_error(exc) from exc + + +@router.get("/servers/{server_id}/ssl/sites") +async def bt_ssl_sites(server_id: int, db: AsyncSession = Depends(get_db), _admin: Admin = Depends(get_current_admin)): + try: + return await _svc(db).list_ssl_sites(server_id) + except Exception as exc: + raise _http_error(exc) from exc + + +@router.post("/servers/{server_id}/ssl/set") +async def bt_set_ssl( + server_id: int, + body: BtPanelSetSsl, + request: Request, + db: AsyncSession = Depends(get_db), + admin: Admin = Depends(get_current_admin), +): + try: + return await _svc(db).set_ssl( + server_id, body.model_dump(), + admin_username=admin.username, + ip_address=_client_ip(request), + ) + except Exception as exc: + raise _http_error(exc) from exc + + +@router.post("/servers/{server_id}/ssl/apply") +async def bt_apply_ssl( + server_id: int, + body: BtPanelApplyCert, + db: AsyncSession = Depends(get_db), + _admin: Admin = Depends(get_current_admin), +): + try: + return await _svc(db).apply_letsencrypt(server_id, body.model_dump()) + except Exception as exc: + raise _http_error(exc) from exc + + +@router.get("/servers/{server_id}/databases") +async def bt_list_databases(server_id: int, db: AsyncSession = Depends(get_db), _admin: Admin = Depends(get_current_admin)): + try: + return await _svc(db).list_databases(server_id) + except Exception as exc: + raise _http_error(exc) from exc + + +@router.post("/servers/{server_id}/databases") +async def bt_create_database( + server_id: int, + body: BtPanelCreateDatabase, + request: Request, + db: AsyncSession = Depends(get_db), + admin: Admin = Depends(get_current_admin), +): + try: + return await _svc(db).create_database( + server_id, body.model_dump(), + admin_username=admin.username, + ip_address=_client_ip(request), + ) + except Exception as exc: + raise _http_error(exc) from exc + + +@router.post("/servers/{server_id}/databases/password") +async def bt_db_password( + server_id: int, + body: BtPanelDbPassword, + db: AsyncSession = Depends(get_db), + _admin: Admin = Depends(get_current_admin), +): + try: + return await _svc(db).reset_database_password(server_id, body.model_dump()) + except Exception as exc: + raise _http_error(exc) from exc + + +@router.post("/servers/{server_id}/databases/backup") +async def bt_db_backup( + server_id: int, + body: BtPanelDbBackup, + db: AsyncSession = Depends(get_db), + _admin: Admin = Depends(get_current_admin), +): + try: + return await _svc(db).backup_database(server_id, body.id) + except Exception as exc: + raise _http_error(exc) from exc + + +@router.get("/servers/{server_id}/crontab") +async def bt_crontab(server_id: int, db: AsyncSession = Depends(get_db), _admin: Admin = Depends(get_current_admin)): + try: + return await _svc(db).list_crontab(server_id) + except Exception as exc: + raise _http_error(exc) from exc + + +@router.post("/servers/{server_id}/services") +async def bt_service_admin( + server_id: int, + body: BtPanelServiceAction, + request: Request, + db: AsyncSession = Depends(get_db), + admin: Admin = Depends(get_current_admin), +): + try: + return await _svc(db).service_admin( + server_id, body.name, body.type, + admin_username=admin.username, + ip_address=_client_ip(request), + ) + except Exception as exc: + raise _http_error(exc) from exc diff --git a/server/api/btpanel_schemas.py b/server/api/btpanel_schemas.py new file mode 100644 index 00000000..bb540449 --- /dev/null +++ b/server/api/btpanel_schemas.py @@ -0,0 +1,90 @@ +"""Pydantic models for Baota panel API.""" + +from typing import Optional + +from pydantic import BaseModel, Field + + +class BtPanelConfigUpdate(BaseModel): + base_url: Optional[str] = Field(None, max_length=500) + api_key: Optional[str] = Field(None, max_length=256) + verify_ssl: Optional[bool] = None + auto_bootstrap: Optional[bool] = None + + +class BtPanelGlobalSettingsUpdate(BaseModel): + bt_panel_source_ip: Optional[str] = Field(None, max_length=64) + bt_panel_auto_bootstrap_enabled: Optional[bool] = None + + +class BtPanelBatchBootstrap(BaseModel): + server_ids: list[int] = Field(..., min_length=1, max_length=500) + + +class BtPanelSiteAction(BaseModel): + site_id: int + name: str = Field(..., min_length=1, max_length=255) + + +class BtPanelCreateSite(BaseModel): + webname: str = Field(..., min_length=1) + path: str = Field(..., min_length=1) + type_id: int = 0 + type: str = "PHP" + version: str = "74" + port: str = "80" + ps: str = "" + ftp: bool = False + sql: bool = False + + +class BtPanelDomainAdd(BaseModel): + id: int + webname: str + domain: str + port: str = "80" + + +class BtPanelDomainDelete(BaseModel): + id: int + webname: str + domain: str + port: str = "80" + + +class BtPanelSetSsl(BaseModel): + siteName: str + key: str + csr: str + + +class BtPanelApplyCert(BaseModel): + domains: str + auth_type: str = "http" + id: int + + +class BtPanelCreateDatabase(BaseModel): + name: str = Field(..., min_length=1, max_length=64) + codeing: str = "utf8mb4" + db_user: str = "" + password: str = "" + ps: str = "" + dtype: str = "MySQL" + dataAccess: str = "127.0.0.1" + address: str = "127.0.0.1" + + +class BtPanelDbPassword(BaseModel): + id: int + name: str + password: str = Field(..., min_length=6) + + +class BtPanelDbBackup(BaseModel): + id: int + + +class BtPanelServiceAction(BaseModel): + name: str = Field(..., min_length=1, max_length=64) + type: str = Field(..., pattern=r"^(start|stop|restart|reload)$") diff --git a/server/api/servers.py b/server/api/servers.py index c7d9e7f5..80eeead2 100644 --- a/server/api/servers.py +++ b/server/api/servers.py @@ -62,6 +62,15 @@ async def _schedule_server_onboarding(server_ids: list[int], operator: str) -> i return int(job_id) if job_id is not None else None +async def _schedule_bt_panel_bootstrap(server_ids: list[int]) -> None: + """Mark pending and try SSH bootstrap once (background loop retries every 5 min).""" + if not server_ids: + return + from server.application.services.btpanel_bootstrap_schedule import schedule_immediate_bootstrap + + await schedule_immediate_bootstrap(server_ids, source="immediate") + + def _server_has_agent_monitoring(server: Server) -> bool: """True when Agent has actually reported in (version/heartbeat), not key-only.""" from server.utils.agent_version import agent_is_installed @@ -87,7 +96,7 @@ async def list_servers( platform_id: Optional[int] = Query(None, description="Filter by platform ID"), node_id: Optional[int] = Query(None, description="Filter by node ID"), search: Optional[str] = Query(None, description="Search by name or domain (substring)"), - sort_by: Optional[str] = Query(None, description="Sort field: name/domain/status/heartbeat/agent_version/category/id"), + sort_by: Optional[str] = Query(None, description="Sort field: name/domain/status/heartbeat/agent_version/category/id/created_at"), sort_order: Optional[str] = Query("asc", description="Sort direction: asc or desc"), is_online: Optional[bool] = Query(None, description="Filter by online status"), target_path_unset: Optional[bool] = Query( @@ -729,6 +738,7 @@ async def import_servers( onboarding_job_id = await _schedule_server_onboarding(result.created_ids, admin.username) if onboarding_job_id is not None: result.onboarding_job_id = onboarding_job_id + await _schedule_bt_panel_bootstrap(result.created_ids) # Audit log ip_address = request.client.host if request.client else "" @@ -948,6 +958,7 @@ async def _create_server_from_poll( ), ip_address=ip_address, )) + await _schedule_bt_panel_bootstrap([created.id]) return created @@ -1507,6 +1518,7 @@ async def create_server( if onboarding_job_id is not None: resp["onboarding_job_id"] = onboarding_job_id resp["onboarding_label"] = "新服务器初始化" + await _schedule_bt_panel_bootstrap([created.id]) return resp diff --git a/server/application/services/btpanel_bootstrap_schedule.py b/server/application/services/btpanel_bootstrap_schedule.py new file mode 100644 index 00000000..9460b14d --- /dev/null +++ b/server/application/services/btpanel_bootstrap_schedule.py @@ -0,0 +1,128 @@ +"""Fire-and-forget and background tick for Baota SSH API bootstrap.""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime, timezone + +from server.config import settings +from server.infrastructure.btpanel.bootstrap_state import ( + is_eligible_for_background_bootstrap, + mark_bootstrap_pending, + _parse_iso, +) +from server.infrastructure.btpanel.credentials import bt_panel_configured +from server.infrastructure.database.server_repo import ServerRepositoryImpl +from server.infrastructure.database.session import AsyncSessionLocal + +logger = logging.getLogger("nexus.btpanel.bootstrap_schedule") + +_BG_TASKS: set[asyncio.Task] = set() + + +def is_auto_bootstrap_enabled() -> bool: + val = (getattr(settings, "BT_PANEL_AUTO_BOOTSTRAP_ENABLED", None) or "true").strip().lower() + return val not in ("false", "0", "no", "off") + + +def bootstrap_batch_size() -> int: + raw = getattr(settings, "BT_PANEL_BOOTSTRAP_BATCH", 20) + try: + n = int(raw) + except (TypeError, ValueError): + n = 20 + return max(1, min(n, 100)) + + +async def mark_servers_bootstrap_pending(server_ids: list[int]) -> None: + ids = [int(x) for x in server_ids if x] + if not ids: + return + async with AsyncSessionLocal() as session: + repo = ServerRepositoryImpl(session) + for sid in ids: + server = await repo.get_by_id(sid) + if not server or bt_panel_configured(server): + continue + server.extra_attrs = mark_bootstrap_pending( + server.extra_attrs if isinstance(server.extra_attrs, dict) else {}, + ) + await repo.update(server) + + +async def schedule_immediate_bootstrap(server_ids: list[int], *, source: str = "immediate") -> None: + """Mark pending and try once without waiting for the 5-minute loop.""" + if not is_auto_bootstrap_enabled(): + return + ids = [int(x) for x in server_ids if x] + if not ids: + return + await mark_servers_bootstrap_pending(ids) + for sid in ids: + task = asyncio.create_task(_bootstrap_one_background(sid, source=source)) + _BG_TASKS.add(task) + task.add_done_callback(_BG_TASKS.discard) + + +async def _bootstrap_one_background(server_id: int, *, source: str) -> None: + from server.application.services.btpanel_service import BtPanelService + + try: + async with AsyncSessionLocal() as session: + svc = BtPanelService(session) + await svc.bootstrap_server( + server_id, + source=source, + admin_username="system", + ip_address=None, + ) + except Exception as exc: + logger.warning("bt panel bootstrap task failed server=%s source=%s: %s", server_id, source, exc) + + +async def run_bootstrap_tick() -> int: + """Pick due servers and bootstrap up to batch limit; returns attempt count.""" + if not is_auto_bootstrap_enabled(): + return 0 + + from server.application.services.btpanel_service import BtPanelService + + now = datetime.now(timezone.utc) + limit = bootstrap_batch_size() + due_ids: list[int] = [] + + async with AsyncSessionLocal() as session: + repo = ServerRepositoryImpl(session) + servers = await repo.get_all() + candidates: list[tuple[datetime, int]] = [] + for server in servers: + if is_eligible_for_background_bootstrap(server, now=now): + from server.infrastructure.btpanel.credentials import get_bt_panel_block + + block = get_bt_panel_block(server) + next_raw = block.get("bootstrap_next_attempt_at") if block else None + next_at = _parse_iso(next_raw) or now + candidates.append((next_at, server.id)) + candidates.sort(key=lambda x: x[0]) + due_ids = [sid for _, sid in candidates[:limit]] + + if not due_ids: + return 0 + + succeeded = 0 + for sid in due_ids: + try: + async with AsyncSessionLocal() as session: + svc = BtPanelService(session) + result = await svc.bootstrap_server( + sid, + source="background", + admin_username="system", + ip_address=None, + ) + if result.get("ok"): + succeeded += 1 + except Exception as exc: + logger.warning("bt panel background bootstrap failed server=%s: %s", sid, exc) + return succeeded diff --git a/server/application/services/btpanel_service.py b/server/application/services/btpanel_service.py new file mode 100644 index 00000000..4e898fdc --- /dev/null +++ b/server/application/services/btpanel_service.py @@ -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) + + # ── B1–B3 ── + 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) + + # ── D1–D4 ── + 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 diff --git a/server/application/services/server_batch_service.py b/server/application/services/server_batch_service.py index b73a8eb3..bbe3a1b1 100644 --- a/server/application/services/server_batch_service.py +++ b/server/application/services/server_batch_service.py @@ -41,6 +41,7 @@ OP_LABELS = { "install-agent": "批量安装 Agent", "upgrade-agent": "批量升级 Agent", "uninstall-agent": "批量卸载 Agent", + "bt-panel-bootstrap": "宝塔 SSH 获取 API", } AUDIT_ACTIONS = { @@ -51,6 +52,7 @@ AUDIT_ACTIONS = { "install-agent": "batch_install_agent", "upgrade-agent": "batch_upgrade_agent", "uninstall-agent": "batch_uninstall_agent", + "bt-panel-bootstrap": "batch_bt_panel_bootstrap", } HEARTBEAT_PREFIX = "heartbeat:" @@ -336,6 +338,8 @@ async def _run_background(job_id: int) -> None: await _run_upgrade_agent(live) elif op == "uninstall-agent": await _run_uninstall_agent(live) + elif op == "bt-panel-bootstrap": + await _run_bt_panel_bootstrap(live) else: live["status"] = "failed" await sbs.save_live(live) @@ -721,3 +725,35 @@ async def _run_uninstall_agent(live: dict[str, Any]) -> None: return result_item_dict(server_id=sid, server_name=label, success=False, error=str(e)[:300]) await _run_with_semaphore(live, handler) + + +async def _run_bt_panel_bootstrap(live: dict[str, Any]) -> None: + from server.application.services.btpanel_service import BtPanelService + + operator = str(live.get("operator") or "admin") + + async def handler(sid: int, server_map: dict[int, Server], labels: dict[int, str], _live) -> dict: + server = server_map.get(sid) + label = labels[sid] + if not server: + return result_item_dict(server_id=sid, server_name=label, success=False, error="服务器不存在") + try: + async with AsyncSessionLocal() as session: + svc = BtPanelService(session) + result = await svc.bootstrap_server( + sid, + source="batch", + admin_username=operator, + ip_address=None, + force=True, + ) + if result.get("ok"): + actions = result.get("bootstrap_last_actions") or [] + detail = ", ".join(actions) if actions else "已写入 API 配置" + return result_item_dict(server_id=sid, server_name=label, success=True, stdout=detail) + err = result.get("error") or result.get("bootstrap_last_error") or "bootstrap 失败" + return result_item_dict(server_id=sid, server_name=label, success=False, error=str(err)[:300]) + except Exception as e: + return result_item_dict(server_id=sid, server_name=label, success=False, error=str(e)[:300]) + + await _run_with_semaphore(live, handler) diff --git a/server/background/bt_panel_bootstrap_loop.py b/server/background/bt_panel_bootstrap_loop.py new file mode 100644 index 00000000..900617a7 --- /dev/null +++ b/server/background/bt_panel_bootstrap_loop.py @@ -0,0 +1,35 @@ +"""Primary-worker loop: retry Baota SSH API bootstrap every 5 minutes.""" + +from __future__ import annotations + +import asyncio +import logging + +from server.application.services.btpanel_bootstrap_schedule import run_bootstrap_tick +from server.config import settings + +logger = logging.getLogger("nexus.bt_panel_bootstrap_loop") + + +def _interval_seconds() -> int: + raw = getattr(settings, "BT_PANEL_BOOTSTRAP_INTERVAL", 300) + try: + n = int(raw) + except (TypeError, ValueError): + n = 300 + return max(60, n) + + +async def bt_panel_bootstrap_loop() -> None: + interval = _interval_seconds() + logger.info("bt_panel_bootstrap_loop started interval=%ss", interval) + while True: + try: + count = await run_bootstrap_tick() + if count: + logger.info("bt_panel_bootstrap_loop succeeded=%s", count) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("bt_panel_bootstrap_loop tick failed") + await asyncio.sleep(interval) diff --git a/server/config.py b/server/config.py index 2310cd8f..fd325c92 100644 --- a/server/config.py +++ b/server/config.py @@ -123,6 +123,12 @@ class Settings(BaseSettings): NOTIFY_RESTART: str = "true" # Nexus 后端重启通知 NOTIFY_SCRIPT_COMPLETE: str = "true" # 脚本执行完成汇总(含看板链接) + # Baota SSH auto-bootstrap (mutable via settings table) + BT_PANEL_AUTO_BOOTSTRAP_ENABLED: str = "true" + BT_PANEL_SOURCE_IP: str = "" + BT_PANEL_BOOTSTRAP_BATCH: int = 20 + BT_PANEL_BOOTSTRAP_INTERVAL: int = 300 + # extra=ignore: .env 中与 MCP 共存的 MYSQL_* / NEXUS_API_BASE_URL 等不进入 Settings model_config = SettingsConfigDict( env_prefix="NEXUS_", @@ -167,6 +173,10 @@ class Settings(BaseSettings): "notify_system_mysql": "NOTIFY_SYSTEM_MYSQL", "notify_restart": "NOTIFY_RESTART", "notify_script_complete": "NOTIFY_SCRIPT_COMPLETE", + "bt_panel_auto_bootstrap_enabled": "BT_PANEL_AUTO_BOOTSTRAP_ENABLED", + "bt_panel_source_ip": "BT_PANEL_SOURCE_IP", + "bt_panel_bootstrap_batch": "BT_PANEL_BOOTSTRAP_BATCH", + "bt_panel_bootstrap_interval": "BT_PANEL_BOOTSTRAP_INTERVAL", } # Settings that require int conversion from DB string values @@ -174,6 +184,7 @@ class Settings(BaseSettings): "DB_POOL_SIZE", "DB_MAX_OVERFLOW", "HEALTH_CHECK_INTERVAL", "JWT_ACCESS_TOKEN_EXPIRE_MINUTES", "JWT_REFRESH_TOKEN_EXPIRE_DAYS", "CPU_ALERT_THRESHOLD", "MEM_ALERT_THRESHOLD", "DISK_ALERT_THRESHOLD", + "BT_PANEL_BOOTSTRAP_BATCH", "BT_PANEL_BOOTSTRAP_INTERVAL", } async def load_settings_from_db(self, session: AsyncSession) -> int: diff --git a/server/infrastructure/btpanel/__init__.py b/server/infrastructure/btpanel/__init__.py new file mode 100644 index 00000000..47e281d8 --- /dev/null +++ b/server/infrastructure/btpanel/__init__.py @@ -0,0 +1 @@ +"""Baota (BT Panel) HTTP API client and SSH helpers.""" diff --git a/server/infrastructure/btpanel/bootstrap_lock.py b/server/infrastructure/btpanel/bootstrap_lock.py new file mode 100644 index 00000000..8fae0b91 --- /dev/null +++ b/server/infrastructure/btpanel/bootstrap_lock.py @@ -0,0 +1,20 @@ +"""Per-server asyncio lock — avoid concurrent SSH bootstrap on the same host.""" + +from __future__ import annotations + +import asyncio +from contextlib import asynccontextmanager + +_registry_guard = asyncio.Lock() +_locks: dict[int, asyncio.Lock] = {} + + +@asynccontextmanager +async def bootstrap_lock(server_id: int): + async with _registry_guard: + lock = _locks.get(server_id) + if lock is None: + lock = asyncio.Lock() + _locks[server_id] = lock + async with lock: + yield diff --git a/server/infrastructure/btpanel/bootstrap_state.py b/server/infrastructure/btpanel/bootstrap_state.py new file mode 100644 index 00000000..a39752cc --- /dev/null +++ b/server/infrastructure/btpanel/bootstrap_state.py @@ -0,0 +1,202 @@ +"""Baota SSH bootstrap state in ``servers.extra_attrs.bt_panel``.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any + +from server.infrastructure.btpanel.credentials import BT_PANEL_ATTR, bt_panel_configured + +BOOTSTRAP_RETRY_SECONDS = 300 +STATE_PENDING = "pending" +STATE_INSTALLING = "installing" +STATE_READY = "ready" +STATE_FAILED = "failed" +STATE_DISABLED = "disabled" +RETRYABLE_STATES = frozenset({STATE_PENDING, STATE_INSTALLING}) +PERMANENT_STOP_STATES = frozenset({STATE_READY, STATE_FAILED, STATE_DISABLED}) + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _iso(dt: datetime) -> str: + return dt.replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _parse_iso(value: Any) -> datetime | None: + if not value or not isinstance(value, str): + return None + text = value.strip() + if text.endswith("Z"): + text = text[:-1] + "+00:00" + try: + parsed = datetime.fromisoformat(text) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed + except ValueError: + return None + + +def get_bootstrap_block(server) -> dict[str, Any]: + from server.infrastructure.btpanel.credentials import get_bt_panel_block + + block = get_bt_panel_block(server) + if not block: + return { + "auto_bootstrap": None, + "bootstrap_state": None, + "bootstrap_last_attempt_at": None, + "bootstrap_next_attempt_at": None, + "bootstrap_attempts": 0, + "bootstrap_last_error": None, + "bootstrap_last_actions": None, + } + return { + "auto_bootstrap": block.get("auto_bootstrap", True) is not False, + "bootstrap_state": block.get("bootstrap_state"), + "bootstrap_last_attempt_at": block.get("bootstrap_last_attempt_at"), + "bootstrap_next_attempt_at": block.get("bootstrap_next_attempt_at"), + "bootstrap_attempts": int(block.get("bootstrap_attempts") or 0), + "bootstrap_last_error": block.get("bootstrap_last_error"), + "bootstrap_last_actions": block.get("bootstrap_last_actions"), + } + + +def public_bootstrap_fields(server) -> dict[str, Any]: + b = get_bootstrap_block(server) + return { + "auto_bootstrap": b["auto_bootstrap"], + "bootstrap_state": b["bootstrap_state"], + "bootstrap_last_attempt_at": b["bootstrap_last_attempt_at"], + "bootstrap_next_attempt_at": b["bootstrap_next_attempt_at"], + "bootstrap_attempts": b["bootstrap_attempts"], + "bootstrap_last_error": b["bootstrap_last_error"], + "bootstrap_last_actions": b["bootstrap_last_actions"], + } + + +def merge_bootstrap_fields( + extra_attrs: dict[str, Any] | None, + **fields: Any, +) -> dict[str, Any]: + out = dict(extra_attrs or {}) + block = dict(out.get(BT_PANEL_ATTR) or {}) if isinstance(out.get(BT_PANEL_ATTR), dict) else {} + for key, value in fields.items(): + if value is None: + block.pop(key, None) + else: + block[key] = value + if block: + out[BT_PANEL_ATTR] = block + return out + + +def mark_bootstrap_pending(extra_attrs: dict[str, Any] | None) -> dict[str, Any]: + if bt_panel_configured_from_attrs(extra_attrs): + return extra_attrs or {} + now = _iso(_utcnow()) + return merge_bootstrap_fields( + extra_attrs, + auto_bootstrap=True, + bootstrap_state=STATE_PENDING, + bootstrap_last_attempt_at=None, + bootstrap_next_attempt_at=now, + bootstrap_last_error=None, + ) + + +def bt_panel_configured_from_attrs(extra_attrs: dict[str, Any] | None) -> bool: + if not isinstance(extra_attrs, dict): + return False + block = extra_attrs.get(BT_PANEL_ATTR) + if not isinstance(block, dict): + return False + return bool(str(block.get("base_url") or "").strip() and block.get("api_key_enc")) + + +def is_eligible_for_background_bootstrap(server, *, now: datetime | None = None) -> bool: + if bt_panel_configured(server): + return False + from server.infrastructure.btpanel.credentials import get_bt_panel_block + + block = get_bt_panel_block(server) + if not block: + return False + if block.get("auto_bootstrap") is False: + return False + state = block.get("bootstrap_state") + if state not in RETRYABLE_STATES: + return False + next_at = _parse_iso(block.get("bootstrap_next_attempt_at")) + ref = now or _utcnow() + if next_at is None: + return False + return next_at <= ref + + +def apply_bootstrap_success( + extra_attrs: dict[str, Any] | None, + *, + base_url: str, + api_key: str, + verify_ssl: bool, + actions: list[str], +) -> dict[str, Any]: + from server.infrastructure.btpanel.credentials import merge_bt_panel_extra + + now = _iso(_utcnow()) + out = merge_bt_panel_extra( + extra_attrs, + base_url=base_url, + api_key=api_key, + verify_ssl=verify_ssl, + ) + attempts = int((get_bootstrap_block_from_attrs(out)).get("bootstrap_attempts") or 0) + 1 + return merge_bootstrap_fields( + out, + bootstrap_state=STATE_READY, + bootstrap_last_attempt_at=now, + bootstrap_next_attempt_at=None, + bootstrap_attempts=attempts, + bootstrap_last_error=None, + bootstrap_last_actions=actions, + bt_installed=True, + bt_installed_checked_at=now, + ) + + +def apply_bootstrap_failure( + extra_attrs: dict[str, Any] | None, + *, + error_code: str, + permanent: bool, +) -> dict[str, Any]: + now = _utcnow() + attempts = int((get_bootstrap_block_from_attrs(extra_attrs)).get("bootstrap_attempts") or 0) + 1 + state = STATE_FAILED if permanent else ( + STATE_INSTALLING if error_code == "bt_not_installed" else STATE_PENDING + ) + next_attempt = None if permanent or state == STATE_READY else _iso(now + timedelta(seconds=BOOTSTRAP_RETRY_SECONDS)) + cache_fields: dict[str, Any] = {} + if error_code == "bt_not_installed": + cache_fields["bt_installed"] = False + cache_fields["bt_installed_checked_at"] = _iso(now) + return merge_bootstrap_fields( + extra_attrs, + bootstrap_state=state, + bootstrap_last_attempt_at=_iso(now), + bootstrap_next_attempt_at=next_attempt, + bootstrap_attempts=attempts, + bootstrap_last_error=error_code, + **cache_fields, + ) + + +def get_bootstrap_block_from_attrs(extra_attrs: dict[str, Any] | None) -> dict[str, Any]: + if not isinstance(extra_attrs, dict): + return {} + block = extra_attrs.get(BT_PANEL_ATTR) + return block if isinstance(block, dict) else {} diff --git a/server/infrastructure/btpanel/client.py b/server/infrastructure/btpanel/client.py index fb3a58fa..b0788d93 100644 --- a/server/infrastructure/btpanel/client.py +++ b/server/infrastructure/btpanel/client.py @@ -6,7 +6,6 @@ import hashlib import json import logging import time -from http.cookies import SimpleCookie from typing import Any from urllib.parse import urlencode diff --git a/server/infrastructure/btpanel/credentials.py b/server/infrastructure/btpanel/credentials.py new file mode 100644 index 00000000..070f2e2c --- /dev/null +++ b/server/infrastructure/btpanel/credentials.py @@ -0,0 +1,86 @@ +"""Per-server Baota panel URL and encrypted API key in ``servers.extra_attrs``.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from server.domain.models import Server +from server.infrastructure.database.crypto import decrypt_value, encrypt_value + +BT_PANEL_ATTR = "bt_panel" + + +@dataclass(frozen=True) +class BtPanelCredentials: + base_url: str + api_key: str + verify_ssl: bool = False + + +def _attrs(server: Server) -> dict[str, Any]: + raw = server.extra_attrs + return raw if isinstance(raw, dict) else {} + + +def get_bt_panel_block(server: Server) -> dict[str, Any]: + block = _attrs(server).get(BT_PANEL_ATTR) + return block if isinstance(block, dict) else {} + + +def bt_panel_configured(server: Server) -> bool: + block = get_bt_panel_block(server) + return bool(str(block.get("base_url") or "").strip() and block.get("api_key_enc")) + + +def read_bt_panel_credentials(server: Server) -> BtPanelCredentials | None: + block = get_bt_panel_block(server) + base_url = str(block.get("base_url") or "").strip().rstrip("/") + enc = block.get("api_key_enc") + if not base_url or not enc: + return None + api_key = decrypt_value(str(enc)) + if not api_key: + return None + verify_ssl = bool(block.get("verify_ssl")) + return BtPanelCredentials(base_url=base_url, api_key=api_key, verify_ssl=verify_ssl) + + +def merge_bt_panel_extra( + extra_attrs: dict[str, Any] | None, + *, + base_url: str | None = None, + api_key: str | None = None, + verify_ssl: bool | None = None, + clear_api_key: bool = False, +) -> dict[str, Any]: + out = dict(extra_attrs or {}) + block = dict(out.get(BT_PANEL_ATTR) or {}) if isinstance(out.get(BT_PANEL_ATTR), dict) else {} + + if base_url is not None: + block["base_url"] = base_url.strip().rstrip("/") + if api_key is not None and api_key.strip(): + block["api_key_enc"] = encrypt_value(api_key.strip()) + elif clear_api_key: + block.pop("api_key_enc", None) + if verify_ssl is not None: + block["verify_ssl"] = verify_ssl + + if block: + out[BT_PANEL_ATTR] = block + else: + out.pop(BT_PANEL_ATTR, None) + return out + + +def public_bt_panel_status(server: Server) -> dict[str, Any]: + from server.infrastructure.btpanel.bootstrap_state import public_bootstrap_fields + + block = get_bt_panel_block(server) + return { + "base_url": str(block.get("base_url") or "").strip() or None, + "api_key_set": bool(block.get("api_key_enc")), + "verify_ssl": bool(block.get("verify_ssl")), + "configured": bt_panel_configured(server), + **public_bootstrap_fields(server), + } diff --git a/server/infrastructure/btpanel/source_ip.py b/server/infrastructure/btpanel/source_ip.py new file mode 100644 index 00000000..d08617c1 --- /dev/null +++ b/server/infrastructure/btpanel/source_ip.py @@ -0,0 +1,58 @@ +"""Resolve Nexus center IP for Baota API whitelist.""" + +from __future__ import annotations + +import ipaddress +import logging +import socket +from urllib.parse import urlparse + +from server.config import settings + +logger = logging.getLogger("nexus.btpanel.source_ip") + + +def _is_ip(value: str) -> bool: + try: + ipaddress.ip_address(value.strip()) + return True + except ValueError: + return False + + +def _host_from_url(url: str) -> str | None: + parsed = urlparse(url.strip()) + host = (parsed.hostname or "").strip() + return host or None + + +def resolve_center_ip_from_dns(host: str) -> str | None: + if not host: + return None + if _is_ip(host): + return host + try: + infos = socket.getaddrinfo(host, None, type=socket.SOCK_STREAM) + for info in infos: + addr = info[4][0] + if _is_ip(addr): + return addr + except OSError as exc: + logger.warning("bt panel source ip dns failed host=%s: %s", host, exc) + return None + + +def get_bt_panel_source_ip() -> str | None: + """Center outbound IP for Baota ``limit_addr`` (settings → API_BASE_URL DNS).""" + explicit = (getattr(settings, "BT_PANEL_SOURCE_IP", None) or "").strip() + if explicit and _is_ip(explicit): + return explicit + + base = (settings.API_BASE_URL or "").strip() + host = _host_from_url(base) if base else None + if host: + resolved = resolve_center_ip_from_dns(host) + if resolved: + return resolved + + return None diff --git a/server/infrastructure/btpanel/ssh_bootstrap.py b/server/infrastructure/btpanel/ssh_bootstrap.py new file mode 100644 index 00000000..4eb2e039 --- /dev/null +++ b/server/infrastructure/btpanel/ssh_bootstrap.py @@ -0,0 +1,170 @@ +"""SSH remote bootstrap: enable Baota API, token, whitelist, base_url.""" + +from __future__ import annotations + +import base64 +import json +import logging +import shlex +from typing import Any + +from server.domain.models import Server +from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command + +logger = logging.getLogger("nexus.btpanel.ssh_bootstrap") + +# Runs on sub-server as root; stdout = one JSON line +_REMOTE_SCRIPT = r""" +import json, os, secrets, sys, subprocess + +PANEL_ROOT = "/www/server/panel" +DATA_DIR = os.path.join(PANEL_ROOT, "data") +API_PATH = os.path.join(DATA_DIR, "api.json") + +def read_text(path, default=""): + try: + with open(path, "r", encoding="utf-8") as f: + return f.read().strip() + except FileNotFoundError: + return default + +def emit(obj): + print(json.dumps(obj, ensure_ascii=False)) + sys.exit(0) + +def main(): + if len(sys.argv) < 2: + emit({"ok": False, "error": "bad_args", "message": "missing payload"}) + try: + payload = json.loads(sys.argv[1]) + except json.JSONDecodeError: + emit({"ok": False, "error": "bad_args", "message": "invalid payload"}) + center_ip = (payload.get("center_ip") or "").strip() + panel_host = (payload.get("panel_host") or "").strip() + if not center_ip: + emit({"ok": False, "error": "missing_center_ip", "message": "center_ip required"}) + if not os.path.isdir(PANEL_ROOT): + emit({"ok": False, "error": "bt_not_installed", "message": "panel not installed"}) + port = read_text(os.path.join(DATA_DIR, "port.pl"), "8888") or "8888" + admin_path = read_text(os.path.join(DATA_DIR, "admin_path.pl"), "") + ssl = os.path.exists(os.path.join(DATA_DIR, "ssl.pl")) + scheme = "https" if ssl else "http" + host = panel_host or "127.0.0.1" + base = f"{scheme}://{host}:{port}" + if admin_path and admin_path != "/": + base += "/" + admin_path.strip("/") + api = {} + if os.path.isfile(API_PATH): + try: + with open(API_PATH, "r", encoding="utf-8") as f: + api = json.load(f) + except Exception: + api = {} + actions = [] + token = (api.get("token") or "").strip() + if not token: + token = secrets.token_hex(16) + actions.append("created_token") + if not api.get("open"): + actions.append("enabled_api") + api["open"] = True + api["token"] = token + limit = api.get("limit_addr") + if not isinstance(limit, list): + limit = [] + if center_ip not in limit: + limit.append(center_ip) + actions.append("appended_whitelist") + api["limit_addr"] = limit + tmp = API_PATH + ".nexus.tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(api, f, ensure_ascii=False) + os.chmod(tmp, 0o600) + os.replace(tmp, API_PATH) + try: + subprocess.run(["/etc/init.d/bt", "reload"], capture_output=True, timeout=30) + except Exception: + pass + emit({ + "ok": True, + "base_url": base, + "api_key": token, + "actions": actions, + "verify_ssl": False, + }) + +if __name__ == "__main__": + main() +""" + + +def build_bootstrap_command(*, center_ip: str, panel_host: str) -> str: + payload = json.dumps({"center_ip": center_ip, "panel_host": panel_host}, ensure_ascii=False) + script_b64 = base64.b64encode(_REMOTE_SCRIPT.encode("utf-8")).decode("ascii") + return ( + f"echo {shlex.quote(script_b64)} | base64 -d > /tmp/nexus_bt_bootstrap.py && " + f"python3 /tmp/nexus_bt_bootstrap.py {shlex.quote(payload)}; " + f"rc=$?; rm -f /tmp/nexus_bt_bootstrap.py; exit $rc" + ) + + +def classify_ssh_result(result: dict[str, Any]) -> tuple[str, bool]: + """Return (error_code, permanent).""" + status = result.get("status") or "" + stderr = (result.get("stderr") or "").lower() + stdout = (result.get("stdout") or "").lower() + if status == "connection_error": + return "ssh_connection_failed", False + if status == "timeout": + return "ssh_timeout", False + # SSH 认证失败(勿把远程脚本写 api.json 的 PermissionError 误判为永久失败) + auth_markers = ( + "permission denied (publickey)", + "permission denied (password)", + "permission denied, please try again", + "authentication failed", + "auth fail", + "no more authentication methods", + ) + if any(m in stderr for m in auth_markers): + return "ssh_auth_failed", True + if status != "success" or int(result.get("exit_code") or 1) != 0: + if "permission denied" in stdout and "api.json" in stdout: + return "ssh_permission_denied", False + return "ssh_command_failed", False + return "", False + + +def parse_bootstrap_stdout(stdout: str) -> dict[str, Any]: + text = (stdout or "").strip() + if not text: + raise ValueError("empty bootstrap output") + line = text.splitlines()[-1].strip() + data = json.loads(line) + if not isinstance(data, dict): + raise ValueError("bootstrap output not object") + if not data.get("ok"): + code = str(data.get("error") or "bootstrap_failed") + raise BootstrapRemoteError(code, str(data.get("message") or code)) + return data + + +class BootstrapRemoteError(Exception): + def __init__(self, code: str, message: str): + super().__init__(message) + self.code = code + + +async def ssh_bootstrap_panel(server: Server, *, center_ip: str) -> dict[str, Any]: + cmd = build_bootstrap_command(center_ip=center_ip, panel_host=(server.domain or "").strip()) + result = await exec_ssh_command(server, cmd, timeout=60, login_shell=False) + err_code, permanent = classify_ssh_result(result) + if err_code: + raise BootstrapRemoteError(err_code, result.get("stderr") or err_code) + try: + return parse_bootstrap_stdout(result.get("stdout") or "") + except BootstrapRemoteError: + raise + except (json.JSONDecodeError, ValueError) as exc: + logger.warning("bootstrap parse failed server=%s: %s stdout=%s", server.id, exc, (result.get("stdout") or "")[:200]) + raise BootstrapRemoteError("bootstrap_parse_failed", str(exc)) from exc diff --git a/server/infrastructure/btpanel/ssh_login.py b/server/infrastructure/btpanel/ssh_login.py new file mode 100644 index 00000000..eb46bbc1 --- /dev/null +++ b/server/infrastructure/btpanel/ssh_login.py @@ -0,0 +1,48 @@ +"""SSH-based Baota temporary login URL (tools.py).""" + +from __future__ import annotations + +import logging +import re +from urllib.parse import urlparse + +from server.domain.models import Server +from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command + +logger = logging.getLogger("nexus.btpanel.ssh") + +_BT_PANEL_TOOLS = "/www/server/panel/tools.py" +_URL_RE = re.compile(r"^https?://\S+/login\?tmp_token=\S+$", re.IGNORECASE) + +SSH_TEMP_LOGIN_CMD = ( + f"test -f {_BT_PANEL_TOOLS} && " + f"cd /www/server/panel && python3 tools.py get_temp_login_ipv4 2>/dev/null | tail -1" +) + +DETECT_BT_CMD = "test -f /www/server/panel/class/common.py && echo BT_OK || echo BT_NO" + + +async def detect_bt_panel_installed(server: Server) -> bool: + result = await exec_ssh_command(server, DETECT_BT_CMD, timeout=20) + if result.get("status") != "success": + return False + return "BT_OK" in (result.get("stdout") or "") + + +def _rewrite_login_host(url: str, prefer_host: str) -> str: + prefer_host = prefer_host.strip() + if not prefer_host or not _URL_RE.match(url): + return url + parsed = urlparse(url) + port = parsed.port or (443 if parsed.scheme == "https" else 80) + return f"{parsed.scheme}://{prefer_host}:{port}{parsed.path}?{parsed.query}" + + +async def ssh_temp_login_url(server: Server) -> str: + result = await exec_ssh_command(server, SSH_TEMP_LOGIN_CMD, timeout=45) + if result.get("status") != "success": + raise RuntimeError(result.get("stderr") or result.get("stdout") or "SSH 执行失败") + line = (result.get("stdout") or "").strip().splitlines()[-1].strip() + if not _URL_RE.match(line): + raise RuntimeError(f"未获得有效临时登录链接: {line[:120]}") + return _rewrite_login_host(line, server.domain or "") diff --git a/server/infrastructure/ssh/remote_shell.py b/server/infrastructure/ssh/remote_shell.py index 90848264..f6ec2c43 100644 --- a/server/infrastructure/ssh/remote_shell.py +++ b/server/infrastructure/ssh/remote_shell.py @@ -6,7 +6,6 @@ import shlex from server.domain.models import Server from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command -from server.infrastructure.ssh.shell_wrap import wrap_login_shell from server.utils.files_elevation import FilesElevation, get_files_elevation diff --git a/server/main.py b/server/main.py index a76eac0c..f2ba6006 100644 --- a/server/main.py +++ b/server/main.py @@ -72,6 +72,7 @@ from server.api.files import router as files_router from server.api.search import router as search_router from server.api.terminal import router as terminal_router from server.api.watch import router as watch_router +from server.api.btpanel import router as btpanel_router # Background tasks from server.background.heartbeat_flush import heartbeat_flush_loop from server.background.script_execution_flush import script_execution_flush_loop @@ -86,6 +87,7 @@ from server.background.watch_probe_runner import watch_probe_loop from server.background.unset_path_detect_loop import unset_path_detect_loop from server.background.agent_install_loop import agent_install_loop from server.background.offline_daily_report_loop import offline_daily_report_loop +from server.background.bt_panel_bootstrap_loop import bt_panel_bootstrap_loop logger = logging.getLogger("nexus") @@ -331,6 +333,9 @@ async def lifespan(app: FastAPI): task_offline_daily_report = asyncio.create_task( offline_daily_report_loop(), name="offline_daily_report" ) + task_bt_panel_bootstrap = asyncio.create_task( + bt_panel_bootstrap_loop(), name="bt_panel_bootstrap" + ) _background_tasks.extend([ task_flush, task_script_flush, @@ -346,6 +351,7 @@ async def lifespan(app: FastAPI): task_unset_path_detect, task_agent_install, task_offline_daily_report, + task_bt_panel_bootstrap, ]) logger.info("Primary worker — background tasks launched") else: @@ -711,6 +717,7 @@ app.include_router(search_router) # Terminal Quick Commands app.include_router(terminal_router) app.include_router(watch_router) +app.include_router(btpanel_router) # ── Custom 404: return blank HTML to hide backend identity ── @app.exception_handler(StarletteHTTPException) diff --git a/tests/test_btpanel_bootstrap_loop.py b/tests/test_btpanel_bootstrap_loop.py new file mode 100644 index 00000000..5a711b4f --- /dev/null +++ b/tests/test_btpanel_bootstrap_loop.py @@ -0,0 +1,89 @@ +"""Tests for Baota bootstrap background scheduling.""" + +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, patch + +import pytest + +from server.application.services.btpanel_bootstrap_schedule import ( + bootstrap_batch_size, + is_auto_bootstrap_enabled, + run_bootstrap_tick, +) +from server.domain.models import Server + + +@pytest.mark.parametrize("value,expected", [ + ("true", True), + ("false", False), + ("0", False), +]) +def test_is_auto_bootstrap_enabled(value, expected, monkeypatch): + monkeypatch.setattr( + "server.application.services.btpanel_bootstrap_schedule.settings.BT_PANEL_AUTO_BOOTSTRAP_ENABLED", + value, + ) + assert is_auto_bootstrap_enabled() is expected + + +def test_bootstrap_batch_size_clamped(monkeypatch): + monkeypatch.setattr( + "server.application.services.btpanel_bootstrap_schedule.settings.BT_PANEL_BOOTSTRAP_BATCH", + 999, + ) + assert bootstrap_batch_size() == 100 + + +@pytest.mark.asyncio +async def test_run_bootstrap_tick_disabled(monkeypatch): + monkeypatch.setattr( + "server.application.services.btpanel_bootstrap_schedule.is_auto_bootstrap_enabled", + lambda: False, + ) + assert await run_bootstrap_tick() == 0 + + +@pytest.mark.asyncio +async def test_run_bootstrap_tick_processes_due_servers(monkeypatch): + past = (datetime.now(timezone.utc) - timedelta(minutes=1)).isoformat().replace("+00:00", "Z") + servers = [ + Server( + id=1, name="a", domain="1.1.1.1", + extra_attrs={"bt_panel": { + "bootstrap_state": "pending", + "bootstrap_next_attempt_at": past, + "auto_bootstrap": True, + }}, + ), + ] + + class _Repo: + async def get_all(self): + return servers + + class _Session: + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return False + + monkeypatch.setattr( + "server.application.services.btpanel_bootstrap_schedule.is_auto_bootstrap_enabled", + lambda: True, + ) + monkeypatch.setattr( + "server.application.services.btpanel_bootstrap_schedule.AsyncSessionLocal", + lambda: _Session(), + ) + monkeypatch.setattr( + "server.application.services.btpanel_bootstrap_schedule.ServerRepositoryImpl", + lambda _db: _Repo(), + ) + + bootstrap_mock = AsyncMock(return_value={"ok": True}) + with patch("server.application.services.btpanel_service.BtPanelService") as mock_cls: + mock_cls.return_value.bootstrap_server = bootstrap_mock + count = await run_bootstrap_tick() + + assert count == 1 diff --git a/tests/test_btpanel_client.py b/tests/test_btpanel_client.py new file mode 100644 index 00000000..38864e97 --- /dev/null +++ b/tests/test_btpanel_client.py @@ -0,0 +1,55 @@ +"""Tests for Baota panel client signing and credentials.""" + +import hashlib +import time + +import pytest + +from server.infrastructure.btpanel.client import BtPanelClient +from server.infrastructure.btpanel.credentials import ( + BT_PANEL_ATTR, + BtPanelCredentials, + merge_bt_panel_extra, + public_bt_panel_status, +) +from server.domain.models import Server + + +def test_sign_algorithm_matches_official_demo(): + api_key = "test-api-key" + now = 1700000000 + key_md5 = hashlib.md5(api_key.encode()).hexdigest() + expected = hashlib.md5(f"{now}{key_md5}".encode()).hexdigest() + + creds = BtPanelCredentials(base_url="http://127.0.0.1:8888", api_key=api_key) + client = BtPanelClient(creds, server_id=1) + # patch time + orig = time.time + try: + time.time = lambda: now # type: ignore[method-assign] + signed = client._sign() + finally: + time.time = orig # type: ignore[method-assign] + + assert signed["request_time"] == now + assert signed["request_token"] == expected + + +def test_merge_bt_panel_extra_encrypts_key(monkeypatch): + monkeypatch.setattr( + "server.infrastructure.btpanel.credentials.encrypt_value", + lambda s: f"enc:{s}", + ) + extra = merge_bt_panel_extra({}, base_url="https://1.2.3.4:8888/abc", api_key="secret") + block = extra[BT_PANEL_ATTR] + assert block["base_url"] == "https://1.2.3.4:8888/abc" + assert block["api_key_enc"] == "enc:secret" + + +def test_public_bt_panel_status(): + server = Server(id=1, name="s", domain="1.2.3.4", extra_attrs={ + BT_PANEL_ATTR: {"base_url": "https://x:8888", "api_key_enc": "x"}, + }) + st = public_bt_panel_status(server) + assert st["configured"] is True + assert st["api_key_set"] is True diff --git a/tests/test_btpanel_get_config.py b/tests/test_btpanel_get_config.py new file mode 100644 index 00000000..a4b1fadb --- /dev/null +++ b/tests/test_btpanel_get_config.py @@ -0,0 +1,77 @@ +"""Tests for Baota get_config SSH detect caching and bootstrap lock.""" + +import asyncio +from unittest.mock import AsyncMock, patch + +import pytest + +from server.application.services.btpanel_service import BtPanelService +from server.domain.models import Server +from server.infrastructure.btpanel.bootstrap_lock import bootstrap_lock + + +@pytest.mark.asyncio +async def test_resolve_bt_installed_uses_cache_when_disabled(): + server = Server( + id=1, name="s", domain="1.2.3.4", + extra_attrs={"bt_panel": { + "bootstrap_state": "disabled", + "bt_installed": True, + }}, + ) + + class _Repo: + async def get_by_id(self, _sid): + return server + + svc = BtPanelService(db=None) # type: ignore[arg-type] + svc.servers = _Repo() # type: ignore[assignment] + + with patch( + "server.application.services.btpanel_service.detect_bt_panel_installed", + new_callable=AsyncMock, + ) as detect: + result = await svc._resolve_bt_installed(server, refresh=False) + assert result is True + detect.assert_not_called() + + +@pytest.mark.asyncio +async def test_resolve_bt_installed_ssh_when_pending(): + server = Server( + id=2, name="s", domain="1.2.3.4", + extra_attrs={"bt_panel": {"bootstrap_state": "pending", "auto_bootstrap": True}}, + ) + + class _Repo: + async def update(self, s): + return s + + svc = BtPanelService(db=None) # type: ignore[arg-type] + svc.servers = _Repo() # type: ignore[assignment] + + with patch( + "server.application.services.btpanel_service.detect_bt_panel_installed", + new_callable=AsyncMock, + return_value=False, + ) as detect: + result = await svc._resolve_bt_installed(server, refresh=False) + assert result is False + detect.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_bootstrap_lock_serializes_same_server(): + order: list[str] = [] + + async def worker(tag: str) -> None: + async with bootstrap_lock(99): + order.append(f"{tag}-in") + await asyncio.sleep(0.05) + order.append(f"{tag}-out") + + await asyncio.gather(worker("a"), worker("b")) + # One worker fully completes before the other enters + assert order.index("a-in") < order.index("a-out") + assert order.index("b-in") < order.index("b-out") + assert (order[1] == "a-out" and order[2] == "b-in") or (order[1] == "b-out" and order[2] == "a-in") diff --git a/tests/test_btpanel_ssh_bootstrap.py b/tests/test_btpanel_ssh_bootstrap.py new file mode 100644 index 00000000..81c30db6 --- /dev/null +++ b/tests/test_btpanel_ssh_bootstrap.py @@ -0,0 +1,144 @@ +"""Tests for Baota SSH bootstrap state machine and remote script parsing.""" + +import json + +import pytest + +from server.infrastructure.btpanel.bootstrap_state import ( + BOOTSTRAP_RETRY_SECONDS, + STATE_FAILED, + STATE_INSTALLING, + STATE_PENDING, + STATE_READY, + apply_bootstrap_failure, + apply_bootstrap_success, + is_eligible_for_background_bootstrap, + mark_bootstrap_pending, + merge_bootstrap_fields, +) +from server.infrastructure.btpanel.ssh_bootstrap import ( + BootstrapRemoteError, + build_bootstrap_command, + classify_ssh_result, + parse_bootstrap_stdout, +) +from server.domain.models import Server + + +class _Srv: + def __init__(self, extra_attrs=None): + self.extra_attrs = extra_attrs or {} + + +def test_mark_bootstrap_pending_sets_now(): + extra = mark_bootstrap_pending({}) + block = extra["bt_panel"] + assert block["bootstrap_state"] == STATE_PENDING + assert block["bootstrap_next_attempt_at"] is not None + assert block["auto_bootstrap"] is True + + +def test_apply_failure_bt_not_installed_goes_installing(): + extra = apply_bootstrap_failure({}, error_code="bt_not_installed", permanent=False) + block = extra["bt_panel"] + assert block["bootstrap_state"] == STATE_INSTALLING + assert block["bootstrap_next_attempt_at"] is not None + assert block["bootstrap_last_error"] == "bt_not_installed" + + +def test_apply_failure_ssh_auth_is_permanent(): + extra = apply_bootstrap_failure({}, error_code="ssh_auth_failed", permanent=True) + block = extra["bt_panel"] + assert block["bootstrap_state"] == STATE_FAILED + assert block.get("bootstrap_next_attempt_at") is None + + +def test_apply_success_sets_ready(monkeypatch): + monkeypatch.setattr( + "server.infrastructure.btpanel.credentials.encrypt_value", + lambda s: f"enc:{s}", + ) + extra = apply_bootstrap_success( + {}, + base_url="https://1.2.3.4:8888/abc", + api_key="token123", + verify_ssl=False, + actions=["enabled_api"], + ) + block = extra["bt_panel"] + assert block["bootstrap_state"] == STATE_READY + assert block["base_url"] == "https://1.2.3.4:8888/abc" + assert block["api_key_enc"] == "enc:token123" + assert block.get("bootstrap_next_attempt_at") is None + + +def test_is_eligible_respects_next_attempt(): + from datetime import datetime, timedelta, timezone + + past = (datetime.now(timezone.utc) - timedelta(seconds=10)).isoformat().replace("+00:00", "Z") + future = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat().replace("+00:00", "Z") + server_due = Server( + id=1, name="a", domain="1.2.3.4", + extra_attrs={"bt_panel": { + "bootstrap_state": STATE_PENDING, + "bootstrap_next_attempt_at": past, + "auto_bootstrap": True, + }}, + ) + server_later = Server( + id=2, name="b", domain="1.2.3.5", + extra_attrs={"bt_panel": { + "bootstrap_state": STATE_INSTALLING, + "bootstrap_next_attempt_at": future, + "auto_bootstrap": True, + }}, + ) + server_never_enrolled = Server(id=3, name="c", domain="1.2.3.6", extra_attrs={}) + assert is_eligible_for_background_bootstrap(server_due) is True + assert is_eligible_for_background_bootstrap(server_later) is False + assert is_eligible_for_background_bootstrap(server_never_enrolled) is False + + +def test_classify_ssh_auth_permanent(): + code, permanent = classify_ssh_result({ + "status": "success", + "exit_code": 1, + "stderr": "Permission denied (publickey).", + }) + assert code == "ssh_auth_failed" + assert permanent is True + + +def test_classify_api_json_permission_not_permanent(): + code, permanent = classify_ssh_result({ + "status": "success", + "exit_code": 1, + "stdout": "permission denied: '/www/server/panel/data/api.json'", + "stderr": "", + }) + assert code == "ssh_permission_denied" + assert permanent is False + + +def test_parse_bootstrap_stdout_ok(): + payload = {"ok": True, "base_url": "https://x:8888", "api_key": "t", "actions": []} + data = parse_bootstrap_stdout(json.dumps(payload)) + assert data["base_url"] == "https://x:8888" + + +def test_parse_bootstrap_stdout_remote_error(): + payload = {"ok": False, "error": "bt_not_installed", "message": "panel not installed"} + with pytest.raises(BootstrapRemoteError) as exc: + parse_bootstrap_stdout(json.dumps(payload)) + assert exc.value.code == "bt_not_installed" + + +def test_build_bootstrap_command_includes_payload(): + cmd = build_bootstrap_command(center_ip="10.0.0.1", panel_host="203.0.113.1") + assert "10.0.0.1" in cmd + assert "203.0.113.1" in cmd + assert "nexus_bt_bootstrap.py" in cmd + + +def test_retry_interval_constant(): + assert BOOTSTRAP_RETRY_SECONDS == 300 diff --git a/tests/test_btpanel_ssh_login.py b/tests/test_btpanel_ssh_login.py new file mode 100644 index 00000000..394775e0 --- /dev/null +++ b/tests/test_btpanel_ssh_login.py @@ -0,0 +1,30 @@ +"""Tests for Baota SSH login helpers.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from server.domain.models import Server +from server.infrastructure.btpanel.ssh_login import detect_bt_panel_installed + + +@pytest.mark.asyncio +async def test_detect_bt_panel_installed_uses_status_field(): + server = Server(id=1, name="s", domain="1.2.3.4") + with patch( + "server.infrastructure.btpanel.ssh_login.exec_ssh_command", + new_callable=AsyncMock, + return_value={"status": "success", "stdout": "BT_OK\n", "stderr": "", "exit_code": 0}, + ): + assert await detect_bt_panel_installed(server) is True + + +@pytest.mark.asyncio +async def test_detect_bt_panel_installed_failed_ssh(): + server = Server(id=1, name="s", domain="1.2.3.4") + with patch( + "server.infrastructure.btpanel.ssh_login.exec_ssh_command", + new_callable=AsyncMock, + return_value={"status": "connection_error", "stdout": "", "stderr": "refused", "exit_code": -1}, + ): + assert await detect_bt_panel_installed(server) is False