feat(btpanel): 独立宝塔模块与 SSH 自动获取 API
新增侧栏「宝塔面板」菜单、代理 API 与连接配置页;通过 SSH 读写子机 api.json 自动写入凭据,5 分钟后台重试,含审计修复、检测缓存与并发锁。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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` 成功台数。
|
||||
@@ -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。
|
||||
@@ -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` 可保留无害。
|
||||
@@ -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` 通过
|
||||
@@ -81,6 +81,38 @@
|
||||
|
||||
<v-divider class="mx-3 my-1" />
|
||||
|
||||
<v-list nav slim>
|
||||
<v-list-subheader>宝塔面板</v-list-subheader>
|
||||
<v-list-group v-model="btPanelMenuOpen">
|
||||
<template #activator="{ props: groupProps }">
|
||||
<v-list-item
|
||||
v-bind="groupProps"
|
||||
prepend-icon="mdi-view-dashboard-variant-outline"
|
||||
title="宝塔管理"
|
||||
:active="route.path.startsWith('/btpanel')"
|
||||
/>
|
||||
</template>
|
||||
<v-list-item
|
||||
v-for="item in btPanelItems"
|
||||
:key="item.to"
|
||||
:prepend-icon="item.icon"
|
||||
:title="item.title"
|
||||
:to="item.to"
|
||||
:active="route.path === item.to"
|
||||
active-class="text-primary"
|
||||
link
|
||||
nav
|
||||
slim
|
||||
class="pl-4"
|
||||
@click="navigateTo(item.to)"
|
||||
@mouseenter="scheduleRoutePrefetch(item.to)"
|
||||
@mouseleave="cancelRoutePrefetch(item.to)"
|
||||
/>
|
||||
</v-list-group>
|
||||
</v-list>
|
||||
|
||||
<v-divider class="mx-3 my-1" />
|
||||
|
||||
<v-list nav slim>
|
||||
<v-list-subheader>系统</v-list-subheader>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<BtPanelServerRow[]>('/btpanel/servers')
|
||||
}
|
||||
|
||||
export function getBtConfig(serverId: number, options?: { refreshBtInstalled?: boolean }) {
|
||||
const qs = options?.refreshBtInstalled ? '?refresh_bt_installed=true' : ''
|
||||
return api<BtPanelConfig>(`/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<BtPanelConfig>(`/btpanel/servers/${serverId}/config`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export function getBtGlobalSettings() {
|
||||
return api<BtPanelGlobalSettings>('/btpanel/settings')
|
||||
}
|
||||
|
||||
export function updateBtGlobalSettings(body: {
|
||||
bt_panel_source_ip?: string
|
||||
bt_panel_auto_bootstrap_enabled?: boolean
|
||||
}) {
|
||||
return api<BtPanelGlobalSettings>('/btpanel/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export function bootstrapBtServer(serverId: number) {
|
||||
return api<BtPanelConfig>(`/btpanel/servers/${serverId}/bootstrap`, { method: 'POST', body: '{}' })
|
||||
}
|
||||
|
||||
export function batchBootstrapBtServers(serverIds: number[]) {
|
||||
return api<BatchJobResponse>('/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<Record<string, unknown>>(`/btpanel/servers/${serverId}/system/total`)
|
||||
}
|
||||
|
||||
export function btSystemDisk(serverId: number) {
|
||||
return api<unknown[]>(`/btpanel/servers/${serverId}/system/disk`)
|
||||
}
|
||||
|
||||
export function btSystemNetwork(serverId: number) {
|
||||
return api<Record<string, unknown>>(`/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<unknown[]>(`/btpanel/servers/${serverId}/sites/php-versions`)
|
||||
}
|
||||
|
||||
export function btCreateSite(serverId: number, body: Record<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
return api(`/btpanel/servers/${serverId}/domains`, { method: 'POST', body: JSON.stringify(body) })
|
||||
}
|
||||
|
||||
export function btDeleteDomain(serverId: number, body: Record<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
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<unknown[] | { data?: unknown[] }>(`/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 }),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<v-container fluid class="pa-6">
|
||||
<div class="d-flex flex-wrap align-center ga-3 mb-4">
|
||||
<div>
|
||||
<h1 class="text-h6 font-weight-bold">{{ title }}</h1>
|
||||
<p v-if="subtitle" class="text-caption text-medium-emphasis mb-0">{{ subtitle }}</p>
|
||||
</div>
|
||||
<v-spacer />
|
||||
<div style="min-width: 280px; max-width: 420px; flex: 1">
|
||||
<BtPanelServerPicker
|
||||
:model-value="serverId"
|
||||
:servers="servers"
|
||||
:loading="loading"
|
||||
@update:model-value="setServerId"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-alert v-if="!serverId" type="info" variant="tonal" class="mb-4" text="请选择服务器" />
|
||||
|
||||
<slot v-else :server-id="serverId" :server="selected()" />
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, provide } from 'vue'
|
||||
import BtPanelServerPicker from '@/components/btpanel/BtPanelServerPicker.vue'
|
||||
import { btPanelContextKey } from '@/composables/btpanel/btPanelContext'
|
||||
import { useBtPanelServer } from '@/composables/btpanel/useBtPanelServer'
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
subtitle?: string
|
||||
}>()
|
||||
|
||||
const ctx = useBtPanelServer()
|
||||
const { servers, serverId, loading, loadServers, setServerId, selected } = ctx
|
||||
|
||||
provide(btPanelContextKey, ctx)
|
||||
|
||||
onMounted(() => { void loadServers() })
|
||||
</script>
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<v-select
|
||||
:model-value="modelValue"
|
||||
:items="items"
|
||||
item-title="label"
|
||||
item-value="id"
|
||||
label="目标服务器"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
class="bt-panel-server-picker"
|
||||
:loading="loading"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
>
|
||||
<template #item="{ props, item }">
|
||||
<v-list-item v-bind="props" :subtitle="item.raw.hint" />
|
||||
</template>
|
||||
</v-select>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { BtPanelServerRow } from '@/api/btpanel'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: number | null
|
||||
servers: BtPanelServerRow[]
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{ 'update:modelValue': [number | null] }>()
|
||||
|
||||
const items = computed(() =>
|
||||
props.servers.map(s => ({
|
||||
id: s.id,
|
||||
label: `${s.name} (${s.domain})`,
|
||||
hint: s.configured ? '已配置宝塔 API' : '未配置 API(一键登录可走 SSH)',
|
||||
})),
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
import type { BtPanelServerRow } from '@/api/btpanel'
|
||||
|
||||
export interface BtPanelContext {
|
||||
servers: Ref<BtPanelServerRow[]>
|
||||
serverId: Ref<number | null>
|
||||
loading: Ref<boolean>
|
||||
loadServers: () => Promise<void>
|
||||
setServerId: (id: number | null) => void
|
||||
selected: () => BtPanelServerRow | null
|
||||
}
|
||||
|
||||
export const btPanelContextKey: InjectionKey<BtPanelContext> = Symbol('btPanelContext')
|
||||
@@ -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<void>) {
|
||||
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<number | null>, selected: ctx.selected, run }
|
||||
}
|
||||
@@ -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<BtPanelServerRow[]>([])
|
||||
const serverId = ref<number | null>(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 }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<BtPanelPageShell title="宝塔 · 计划任务" subtitle="只读查看子机宝塔 Crontab(F1)">
|
||||
<template #default>
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="rows"
|
||||
:loading="loading"
|
||||
density="compact"
|
||||
/>
|
||||
<p class="text-caption text-medium-emphasis mt-3">
|
||||
此处仅展示宝塔面板内计划任务,与 Nexus「调度」页无关。
|
||||
</p>
|
||||
</template>
|
||||
</BtPanelPageShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import BtPanelPageShell from '@/components/btpanel/BtPanelPageShell.vue'
|
||||
import { btCrontab } from '@/api/btpanel'
|
||||
import { useBtPanelPageLoad } from '@/composables/btpanel/useBtPanelPageLoad'
|
||||
|
||||
defineOptions({ name: 'BtPanelCrontabPage' })
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<Record<string, unknown>[]>([])
|
||||
const headers = [
|
||||
{ title: 'ID', key: 'id', width: 70 },
|
||||
{ title: '名称', key: 'name' },
|
||||
{ title: '周期', key: 'type' },
|
||||
{ title: '状态', key: 'status', width: 90 },
|
||||
{ title: '备注', key: 'save' },
|
||||
]
|
||||
|
||||
useBtPanelPageLoad(async (id) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await btCrontab(id)
|
||||
if (Array.isArray(res)) rows.value = res as Record<string, unknown>[]
|
||||
else rows.value = (res as { data?: Record<string, unknown>[] }).data ?? []
|
||||
} catch (e: unknown) {
|
||||
window.$snackbar?.(e instanceof Error ? e.message : '加载失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<BtPanelPageShell title="宝塔 · 数据库" subtitle="列表 / 创建 / 改密 / 备份(D1–D4)">
|
||||
<template #default>
|
||||
<v-data-table :headers="headers" :items="rows" :loading="loading" density="compact">
|
||||
<template #item.actions="{ item }">
|
||||
<v-btn size="x-small" variant="text" @click="backup(item)">备份</v-btn>
|
||||
</template>
|
||||
</v-data-table>
|
||||
|
||||
<v-expansion-panels class="mt-4" variant="accordion">
|
||||
<v-expansion-panel title="创建数据库">
|
||||
<v-expansion-panel-text>
|
||||
<v-text-field v-model="createForm.name" label="库名" density="compact" class="mb-2" />
|
||||
<v-text-field v-model="createForm.password" label="密码" type="password" density="compact" class="mb-2" />
|
||||
<v-btn color="primary" :loading="loading" @click="createDb">创建</v-btn>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
<v-expansion-panel title="修改密码">
|
||||
<v-expansion-panel-text>
|
||||
<v-text-field v-model.number="pwdForm.id" label="ID" type="number" density="compact" class="mb-2" />
|
||||
<v-text-field v-model="pwdForm.name" label="库名" density="compact" class="mb-2" />
|
||||
<v-text-field v-model="pwdForm.password" label="新密码" type="password" density="compact" class="mb-2" />
|
||||
<v-btn variant="tonal" :loading="loading" @click="resetPwd">修改</v-btn>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</template>
|
||||
</BtPanelPageShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import BtPanelPageShell from '@/components/btpanel/BtPanelPageShell.vue'
|
||||
import { btCreateDatabase, btDbBackup, btDbPassword, btListDatabases } from '@/api/btpanel'
|
||||
import { useBtPanelPageLoad } from '@/composables/btpanel/useBtPanelPageLoad'
|
||||
|
||||
defineOptions({ name: 'BtPanelDatabasesPage' })
|
||||
|
||||
interface DbRow { id: number; name: string; username?: string; ps?: string }
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<DbRow[]>([])
|
||||
const createForm = reactive({ name: '', password: '' })
|
||||
const pwdForm = reactive({ id: 0, name: '', password: '' })
|
||||
const headers = [
|
||||
{ title: 'ID', key: 'id', width: 70 },
|
||||
{ title: '库名', key: 'name' },
|
||||
{ title: '用户', key: 'username' },
|
||||
{ title: '备注', key: 'ps' },
|
||||
{ title: '操作', key: 'actions', sortable: false, width: 80 },
|
||||
]
|
||||
|
||||
const { serverId, run } = useBtPanelPageLoad(async (id) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await btListDatabases(id)
|
||||
rows.value = (res.data ?? []) as DbRow[]
|
||||
} catch (e: unknown) {
|
||||
window.$snackbar?.(e instanceof Error ? e.message : '加载失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function createDb() {
|
||||
const sid = serverId.value
|
||||
if (!sid || !createForm.name) return
|
||||
loading.value = true
|
||||
try {
|
||||
await btCreateDatabase(sid, {
|
||||
name: createForm.name,
|
||||
password: createForm.password,
|
||||
db_user: createForm.name,
|
||||
codeing: 'utf8mb4',
|
||||
})
|
||||
window.$snackbar?.('已创建', 'success')
|
||||
await run()
|
||||
} catch (e: unknown) {
|
||||
window.$snackbar?.(e instanceof Error ? e.message : '创建失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPwd() {
|
||||
const sid = serverId.value
|
||||
if (!sid || !pwdForm.id) return
|
||||
loading.value = true
|
||||
try {
|
||||
await btDbPassword(sid, { ...pwdForm })
|
||||
window.$snackbar?.('密码已更新', 'success')
|
||||
} catch (e: unknown) {
|
||||
window.$snackbar?.(e instanceof Error ? e.message : '失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function backup(item: DbRow) {
|
||||
const sid = serverId.value
|
||||
if (!sid) return
|
||||
loading.value = true
|
||||
try {
|
||||
await btDbBackup(sid, item.id)
|
||||
window.$snackbar?.('备份任务已提交', 'success')
|
||||
} catch (e: unknown) {
|
||||
window.$snackbar?.(e instanceof Error ? e.message : '备份失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<BtPanelPageShell title="宝塔 · 域名管理" subtitle="绑定域名增删(C10)">
|
||||
<template #default>
|
||||
<v-row class="mb-4" dense>
|
||||
<v-col cols="12" md="3">
|
||||
<v-text-field v-model.number="siteId" label="网站 ID" type="number" density="compact" />
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-text-field v-model="webname" label="网站名" density="compact" />
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-btn variant="tonal" :loading="loading" @click="loadDomains">加载域名</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-data-table :headers="headers" :items="rows" :loading="loading" density="compact">
|
||||
<template #item.actions="{ item }">
|
||||
<v-btn size="x-small" variant="text" color="error" @click="remove(item)">删除</v-btn>
|
||||
</template>
|
||||
</v-data-table>
|
||||
|
||||
<v-divider class="my-4" />
|
||||
<div class="d-flex flex-wrap ga-2 align-center">
|
||||
<v-text-field v-model="newDomain" label="新域名" density="compact" hide-details style="max-width: 280px" />
|
||||
<v-text-field v-model="newPort" label="端口" density="compact" hide-details style="max-width: 100px" />
|
||||
<v-btn color="primary" :loading="loading" @click="add">添加域名</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</BtPanelPageShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import BtPanelPageShell from '@/components/btpanel/BtPanelPageShell.vue'
|
||||
import { btAddDomain, btDeleteDomain, btListDomains } from '@/api/btpanel'
|
||||
import { useBtPanelPageLoad } from '@/composables/btpanel/useBtPanelPageLoad'
|
||||
|
||||
defineOptions({ name: 'BtPanelDomainsPage' })
|
||||
|
||||
interface DomainRow { id: number; name: string; port: number; pid: number }
|
||||
|
||||
const loading = ref(false)
|
||||
const siteId = ref<number | null>(null)
|
||||
const webname = ref('')
|
||||
const newDomain = ref('')
|
||||
const newPort = ref('80')
|
||||
const rows = ref<DomainRow[]>([])
|
||||
const headers = [
|
||||
{ title: 'ID', key: 'id', width: 70 },
|
||||
{ title: '域名', key: 'name' },
|
||||
{ title: '端口', key: 'port', width: 80 },
|
||||
{ title: '操作', key: 'actions', sortable: false, width: 80 },
|
||||
]
|
||||
|
||||
const { serverId } = useBtPanelPageLoad(() => {})
|
||||
|
||||
async function loadDomains() {
|
||||
const sid = serverId.value
|
||||
if (!sid || !siteId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await btListDomains(sid, siteId.value)
|
||||
rows.value = (res.data ?? res ?? []) as DomainRow[]
|
||||
} catch (e: unknown) {
|
||||
window.$snackbar?.(e instanceof Error ? e.message : '加载失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function add() {
|
||||
const sid = serverId.value
|
||||
if (!sid || !siteId.value || !webname.value || !newDomain.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
await btAddDomain(sid, {
|
||||
id: siteId.value,
|
||||
webname: webname.value,
|
||||
domain: newDomain.value,
|
||||
port: newPort.value,
|
||||
})
|
||||
window.$snackbar?.('已添加', 'success')
|
||||
newDomain.value = ''
|
||||
await loadDomains()
|
||||
} catch (e: unknown) {
|
||||
window.$snackbar?.(e instanceof Error ? e.message : '添加失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(item: DomainRow) {
|
||||
const sid = serverId.value
|
||||
if (!sid || !siteId.value || !webname.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
await btDeleteDomain(sid, {
|
||||
id: siteId.value,
|
||||
webname: webname.value,
|
||||
domain: item.name,
|
||||
port: String(item.port),
|
||||
})
|
||||
await loadDomains()
|
||||
} catch (e: unknown) {
|
||||
window.$snackbar?.(e instanceof Error ? e.message : '删除失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<BtPanelPageShell title="宝塔 · 面板登录" subtitle="生成临时链接,免输密码打开宝塔(A1/A5)">
|
||||
<template #default="{ serverId, server }">
|
||||
<v-card variant="outlined" rounded="lg">
|
||||
<v-card-text>
|
||||
<v-chip v-if="server" size="small" class="mb-3" :color="server.configured ? 'success' : 'warning'" variant="tonal">
|
||||
{{ server.configured ? 'API 已配置' : '未配置 API,将尝试 SSH 生成链接' }}
|
||||
</v-chip>
|
||||
<div class="d-flex flex-wrap ga-2">
|
||||
<v-btn color="primary" :loading="loading" prepend-icon="mdi-open-in-new" @click="openLogin(serverId!)">
|
||||
一键登录宝塔
|
||||
</v-btn>
|
||||
<v-btn variant="tonal" :to="`/btpanel/settings?server_id=${serverId}`">连接配置</v-btn>
|
||||
</div>
|
||||
<p class="text-caption text-medium-emphasis mt-4 mb-0">
|
||||
链接约 3 小时内有效、一次性使用。请确保本机浏览器能访问该服务器面板端口。
|
||||
</p>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
</BtPanelPageShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BtPanelPageShell from '@/components/btpanel/BtPanelPageShell.vue'
|
||||
import { createBtLoginUrl } from '@/api/btpanel'
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineOptions({ name: 'BtPanelLoginPage' })
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
async function openLogin(serverId: number) {
|
||||
loading.value = true
|
||||
try {
|
||||
const { url } = await createBtLoginUrl(serverId)
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
window.$snackbar?.('已打开宝塔登录链接', 'success')
|
||||
} catch (e: unknown) {
|
||||
window.$snackbar?.(e instanceof Error ? e.message : '生成链接失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<BtPanelPageShell title="宝塔 · 系统监控" subtitle="GetSystemTotal / GetDiskInfo / GetNetWork(B1–B3)">
|
||||
<template #default>
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<v-card variant="outlined" rounded="lg" :loading="loading">
|
||||
<v-card-title class="text-subtitle-1">系统概览</v-card-title>
|
||||
<v-card-text>
|
||||
<pre v-if="total" class="bt-json">{{ JSON.stringify(total, null, 2) }}</pre>
|
||||
<span v-else class="text-medium-emphasis">暂无数据</span>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-card variant="outlined" rounded="lg" :loading="loading">
|
||||
<v-card-title class="text-subtitle-1">实时负载 / 网络</v-card-title>
|
||||
<v-card-text>
|
||||
<pre v-if="network" class="bt-json">{{ JSON.stringify(network, null, 2) }}</pre>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12">
|
||||
<v-card variant="outlined" rounded="lg" :loading="loading">
|
||||
<v-card-title class="text-subtitle-1">磁盘分区</v-card-title>
|
||||
<v-card-text>
|
||||
<pre v-if="disk" class="bt-json">{{ JSON.stringify(disk, null, 2) }}</pre>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-btn size="small" variant="tonal" class="mt-3" :loading="loading" @click="run">刷新</v-btn>
|
||||
</template>
|
||||
</BtPanelPageShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import BtPanelPageShell from '@/components/btpanel/BtPanelPageShell.vue'
|
||||
import { btSystemDisk, btSystemNetwork, btSystemTotal } from '@/api/btpanel'
|
||||
import { useBtPanelPageLoad } from '@/composables/btpanel/useBtPanelPageLoad'
|
||||
|
||||
defineOptions({ name: 'BtPanelMonitorPage' })
|
||||
|
||||
const loading = ref(false)
|
||||
const total = ref<Record<string, unknown> | null>(null)
|
||||
const disk = ref<unknown[] | null>(null)
|
||||
const network = ref<Record<string, unknown> | null>(null)
|
||||
|
||||
const { run } = useBtPanelPageLoad(async (serverId) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const [t, d, n] = await Promise.all([
|
||||
btSystemTotal(serverId),
|
||||
btSystemDisk(serverId),
|
||||
btSystemNetwork(serverId),
|
||||
])
|
||||
total.value = t
|
||||
disk.value = d
|
||||
network.value = n
|
||||
} catch (e: unknown) {
|
||||
window.$snackbar?.(e instanceof Error ? e.message : '加载失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bt-json {
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<BtPanelPageShell title="宝塔 · 服务管理" subtitle="Nginx / MySQL / Redis / PHP 启停重启(H1)">
|
||||
<template #default>
|
||||
<v-row>
|
||||
<v-col v-for="svc in services" :key="svc" cols="12" sm="6" md="4">
|
||||
<v-card variant="outlined" rounded="lg">
|
||||
<v-card-title class="text-subtitle-2">{{ svc }}</v-card-title>
|
||||
<v-card-actions>
|
||||
<v-btn size="small" variant="tonal" color="success" :loading="loading" @click="act(svc, 'start')">启动</v-btn>
|
||||
<v-btn size="small" variant="tonal" color="warning" :loading="loading" @click="act(svc, 'stop')">停止</v-btn>
|
||||
<v-btn size="small" variant="tonal" color="primary" :loading="loading" @click="act(svc, 'restart')">重启</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
</BtPanelPageShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import BtPanelPageShell from '@/components/btpanel/BtPanelPageShell.vue'
|
||||
import { btServiceAdmin } from '@/api/btpanel'
|
||||
import { useBtPanelPageLoad } from '@/composables/btpanel/useBtPanelPageLoad'
|
||||
|
||||
defineOptions({ name: 'BtPanelServicesPage' })
|
||||
|
||||
const services = ['nginx', 'mysql', 'redis', 'php-fpm']
|
||||
const loading = ref(false)
|
||||
|
||||
const { serverId } = useBtPanelPageLoad(() => {})
|
||||
|
||||
async function act(name: string, type: string) {
|
||||
const sid = serverId.value
|
||||
if (!sid) return
|
||||
if (!window.confirm(`确认对 ${name} 执行 ${type}?`)) return
|
||||
loading.value = true
|
||||
try {
|
||||
await btServiceAdmin(sid, name, type)
|
||||
window.$snackbar?.(`${name} ${type} 已提交`, 'success')
|
||||
} catch (e: unknown) {
|
||||
window.$snackbar?.(e instanceof Error ? e.message : '操作失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,303 @@
|
||||
<template>
|
||||
<BtPanelPageShell title="宝塔 · 连接配置" subtitle="SSH 自动获取 API 或手动填写面板地址与密钥">
|
||||
<template #default="{ serverId }">
|
||||
<v-card max-width="720" variant="outlined" rounded="lg" class="mb-4">
|
||||
<v-card-title class="text-subtitle-1">全局</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="globalForm.bt_panel_source_ip"
|
||||
label="中心调用 IP(API 白名单)"
|
||||
hint="留空则解析系统 api_base_url DNS"
|
||||
persistent-hint
|
||||
class="mb-2"
|
||||
/>
|
||||
<v-switch
|
||||
v-model="globalForm.bt_panel_auto_bootstrap_enabled"
|
||||
label="新服务器自动 SSH 获取(每 5 分钟重试)"
|
||||
color="primary"
|
||||
hide-details
|
||||
class="mb-2"
|
||||
/>
|
||||
<div v-if="globalSettings?.resolved_center_ip" class="text-caption text-medium-emphasis mb-3">
|
||||
当前生效白名单 IP:{{ globalSettings.resolved_center_ip }}
|
||||
</div>
|
||||
<v-btn color="primary" variant="tonal" :loading="globalSaving" @click="saveGlobal">
|
||||
保存全局设置
|
||||
</v-btn>
|
||||
<v-btn
|
||||
class="ml-2"
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
:disabled="!unconfiguredIds.length"
|
||||
@click="batchConfirmOpen = true"
|
||||
>
|
||||
批量初始化未配置({{ unconfiguredIds.length }})
|
||||
</v-btn>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-card max-width="720" variant="outlined" rounded="lg" :loading="loading">
|
||||
<v-card-text>
|
||||
<div class="d-flex flex-wrap align-center ga-2 mb-3">
|
||||
<v-chip :color="btInstalledChipColor" size="small" variant="tonal">
|
||||
SSH 检测:{{ btInstalledLabel }}
|
||||
</v-chip>
|
||||
<v-btn
|
||||
v-if="serverId"
|
||||
icon="mdi-refresh"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
:loading="refreshingBt"
|
||||
title="重新 SSH 检测宝塔"
|
||||
@click="refreshBtInstalled(serverId)"
|
||||
/>
|
||||
<v-chip :color="bootstrapChipColor" size="small" variant="tonal">
|
||||
引导:{{ bootstrapStateLabel }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<div v-if="config?.bootstrap_last_error" class="text-caption text-error mb-2">
|
||||
上次错误:{{ config.bootstrap_last_error }}
|
||||
</div>
|
||||
<div v-if="config?.bootstrap_next_attempt_at" class="text-caption text-medium-emphasis mb-3">
|
||||
下次重试:{{ config.bootstrap_next_attempt_at }}
|
||||
</div>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
:loading="bootstrapping"
|
||||
@click="runBootstrap(serverId!)"
|
||||
>
|
||||
立即 SSH 获取
|
||||
</v-btn>
|
||||
<v-form @submit.prevent="save(serverId!)">
|
||||
<v-switch
|
||||
v-model="form.auto_bootstrap"
|
||||
label="自动获取(每 5 分钟重试,直至成功)"
|
||||
color="primary"
|
||||
hide-details
|
||||
class="mb-3"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="form.base_url"
|
||||
label="面板地址"
|
||||
hint="含协议、端口与安全入口,如 https://1.2.3.4:8888/abc123"
|
||||
persistent-hint
|
||||
class="mb-2"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="form.api_key"
|
||||
label="API 密钥"
|
||||
type="password"
|
||||
:placeholder="config?.api_key_set ? '已设置,留空不修改' : '在面板设置-API接口获取'"
|
||||
class="mb-2"
|
||||
/>
|
||||
<v-switch v-model="form.verify_ssl" label="校验 SSL 证书" color="primary" hide-details class="mb-4" />
|
||||
<v-btn type="submit" color="primary" :loading="saving">保存</v-btn>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-dialog v-model="batchConfirmOpen" max-width="480">
|
||||
<v-card title="批量 SSH 获取宝塔 API">
|
||||
<v-card-text>
|
||||
将对 <strong>{{ unconfiguredIds.length }}</strong> 台未配置服务器发起 SSH 引导(写入子机
|
||||
<code>api.json</code> 并加密保存到 Nexus)。此操作会占用 SSH 连接,建议在低峰执行。
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="batchConfirmOpen = false">取消</v-btn>
|
||||
<v-btn color="primary" :loading="batchLoading" @click="confirmBatchBootstrap">确认开始</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
</BtPanelPageShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import BtPanelPageShell from '@/components/btpanel/BtPanelPageShell.vue'
|
||||
import {
|
||||
batchBootstrapBtServers,
|
||||
bootstrapBtServer,
|
||||
getBtConfig,
|
||||
getBtGlobalSettings,
|
||||
listBtServers,
|
||||
updateBtConfig,
|
||||
updateBtGlobalSettings,
|
||||
type BtPanelConfig,
|
||||
type BtPanelGlobalSettings,
|
||||
} from '@/api/btpanel'
|
||||
import { useBtPanelPageLoad } from '@/composables/btpanel/useBtPanelPageLoad'
|
||||
|
||||
defineOptions({ name: 'BtPanelSettingsPage' })
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const bootstrapping = ref(false)
|
||||
const refreshingBt = ref(false)
|
||||
const globalSaving = ref(false)
|
||||
const batchLoading = ref(false)
|
||||
const batchConfirmOpen = ref(false)
|
||||
const config = ref<BtPanelConfig | null>(null)
|
||||
const globalSettings = ref<BtPanelGlobalSettings | null>(null)
|
||||
const serverList = ref<Awaited<ReturnType<typeof listBtServers>>>([])
|
||||
const form = reactive({ base_url: '', api_key: '', verify_ssl: false, auto_bootstrap: true })
|
||||
const globalForm = reactive({ bt_panel_source_ip: '', bt_panel_auto_bootstrap_enabled: true })
|
||||
|
||||
const bootstrapStateLabels: Record<string, string> = {
|
||||
pending: '等待获取',
|
||||
installing: '宝塔安装中',
|
||||
ready: '已完成',
|
||||
failed: 'SSH 失败(已停止)',
|
||||
disabled: '已关闭自动获取',
|
||||
}
|
||||
|
||||
const bootstrapStateLabel = computed(() => {
|
||||
const state = config.value?.bootstrap_state
|
||||
if (!state) return '未启用'
|
||||
return bootstrapStateLabels[state] ?? state
|
||||
})
|
||||
|
||||
const btInstalledLabel = computed(() => {
|
||||
const v = config.value?.bt_installed
|
||||
if (v === true) return '已安装宝塔'
|
||||
if (v === false) return '未检测到宝塔目录'
|
||||
return '未检测(引导中或手动配置)'
|
||||
})
|
||||
|
||||
const btInstalledChipColor = computed(() => {
|
||||
const v = config.value?.bt_installed
|
||||
if (v === true) return 'success'
|
||||
if (v === false) return 'warning'
|
||||
return 'default'
|
||||
})
|
||||
|
||||
const bootstrapChipColor = computed(() => {
|
||||
const s = config.value?.bootstrap_state
|
||||
if (s === 'ready' || config.value?.configured) return 'success'
|
||||
if (s === 'failed') return 'error'
|
||||
if (s === 'installing') return 'warning'
|
||||
return 'default'
|
||||
})
|
||||
|
||||
const unconfiguredIds = computed(() =>
|
||||
serverList.value.filter(s => !s.configured).map(s => s.id),
|
||||
)
|
||||
|
||||
async function loadConfig(id: number, refreshBtInstalled = false) {
|
||||
loading.value = true
|
||||
try {
|
||||
config.value = await getBtConfig(id, { refreshBtInstalled })
|
||||
form.base_url = config.value.base_url ?? ''
|
||||
form.verify_ssl = config.value.verify_ssl
|
||||
form.auto_bootstrap = config.value.auto_bootstrap === true
|
||||
form.api_key = ''
|
||||
} catch (e: unknown) {
|
||||
window.$snackbar?.(e instanceof Error ? e.message : '加载失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const { serverId } = useBtPanelPageLoad(async (id) => {
|
||||
await loadConfig(id)
|
||||
})
|
||||
|
||||
async function refreshBtInstalled(id: number) {
|
||||
refreshingBt.value = true
|
||||
try {
|
||||
config.value = await getBtConfig(id, { refreshBtInstalled: true })
|
||||
} catch (e: unknown) {
|
||||
window.$snackbar?.(e instanceof Error ? e.message : '检测失败', 'error')
|
||||
} finally {
|
||||
refreshingBt.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGlobal() {
|
||||
try {
|
||||
globalSettings.value = await getBtGlobalSettings()
|
||||
globalForm.bt_panel_source_ip = globalSettings.value.bt_panel_source_ip ?? ''
|
||||
globalForm.bt_panel_auto_bootstrap_enabled = globalSettings.value.bt_panel_auto_bootstrap_enabled
|
||||
serverList.value = await listBtServers()
|
||||
} catch (e: unknown) {
|
||||
window.$snackbar?.(e instanceof Error ? e.message : '加载全局设置失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { void loadGlobal() })
|
||||
|
||||
async function save(serverId: number) {
|
||||
saving.value = true
|
||||
try {
|
||||
const body: {
|
||||
base_url?: string
|
||||
api_key?: string
|
||||
verify_ssl?: boolean
|
||||
auto_bootstrap?: boolean
|
||||
} = {
|
||||
base_url: form.base_url,
|
||||
verify_ssl: form.verify_ssl,
|
||||
auto_bootstrap: form.auto_bootstrap,
|
||||
}
|
||||
if (form.api_key.trim()) body.api_key = form.api_key.trim()
|
||||
config.value = await updateBtConfig(serverId, body)
|
||||
form.api_key = ''
|
||||
window.$snackbar?.('已保存', 'success')
|
||||
} catch (e: unknown) {
|
||||
window.$snackbar?.(e instanceof Error ? e.message : '保存失败', 'error')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveGlobal() {
|
||||
globalSaving.value = true
|
||||
try {
|
||||
globalSettings.value = await updateBtGlobalSettings({
|
||||
bt_panel_source_ip: globalForm.bt_panel_source_ip,
|
||||
bt_panel_auto_bootstrap_enabled: globalForm.bt_panel_auto_bootstrap_enabled,
|
||||
})
|
||||
window.$snackbar?.('全局设置已保存', 'success')
|
||||
} catch (e: unknown) {
|
||||
window.$snackbar?.(e instanceof Error ? e.message : '保存失败', 'error')
|
||||
} finally {
|
||||
globalSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runBootstrap(id: number) {
|
||||
bootstrapping.value = true
|
||||
try {
|
||||
config.value = await bootstrapBtServer(id)
|
||||
if (config.value.ok) {
|
||||
form.base_url = config.value.base_url ?? ''
|
||||
window.$snackbar?.('宝塔 API 已自动配置', 'success')
|
||||
} else {
|
||||
window.$snackbar?.(config.value.error ?? config.value.bootstrap_last_error ?? '获取失败', 'warning')
|
||||
}
|
||||
await loadConfig(id)
|
||||
} catch (e: unknown) {
|
||||
window.$snackbar?.(e instanceof Error ? e.message : '获取失败', 'error')
|
||||
} finally {
|
||||
bootstrapping.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmBatchBootstrap() {
|
||||
const ids = unconfiguredIds.value
|
||||
if (!ids.length) return
|
||||
batchLoading.value = true
|
||||
try {
|
||||
const job = await batchBootstrapBtServers(ids)
|
||||
batchConfirmOpen.value = false
|
||||
window.$snackbar?.(`已启动批量任务 #${job.job_id}`, 'success')
|
||||
} catch (e: unknown) {
|
||||
window.$snackbar?.(e instanceof Error ? e.message : '批量任务启动失败', 'error')
|
||||
} finally {
|
||||
batchLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<BtPanelPageShell title="宝塔 · 创建网站" subtitle="AddSite(C4)">
|
||||
<template #default>
|
||||
<v-card max-width="640" variant="outlined" rounded="lg">
|
||||
<v-card-text>
|
||||
<v-form @submit.prevent="submit">
|
||||
<v-text-field v-model="form.webname" label="主域名" required />
|
||||
<v-text-field v-model="form.path" label="根目录" hint="如 /www/wwwroot/example.com" persistent-hint />
|
||||
<v-select v-model="form.version" :items="phpItems" label="PHP 版本" />
|
||||
<v-text-field v-model="form.port" label="端口" />
|
||||
<v-text-field v-model="form.ps" label="备注" />
|
||||
<v-btn type="submit" color="primary" :loading="loading">创建</v-btn>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
</BtPanelPageShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import BtPanelPageShell from '@/components/btpanel/BtPanelPageShell.vue'
|
||||
import { btCreateSite, btPhpVersions } from '@/api/btpanel'
|
||||
import { useBtPanelPageLoad } from '@/composables/btpanel/useBtPanelPageLoad'
|
||||
|
||||
defineOptions({ name: 'BtPanelSiteCreatePage' })
|
||||
|
||||
const loading = ref(false)
|
||||
const phpItems = ref<string[]>(['74', '80', '82'])
|
||||
const form = reactive({
|
||||
webname: '',
|
||||
path: '',
|
||||
version: '74',
|
||||
port: '80',
|
||||
ps: '',
|
||||
type_id: 0,
|
||||
type: 'PHP',
|
||||
ftp: false,
|
||||
sql: false,
|
||||
})
|
||||
|
||||
const { serverId } = useBtPanelPageLoad(async (id) => {
|
||||
try {
|
||||
const versions = await btPhpVersions(id)
|
||||
if (Array.isArray(versions)) {
|
||||
phpItems.value = versions
|
||||
.map((v: { version?: string }) => v?.version)
|
||||
.filter((v): v is string => !!v && v !== '00')
|
||||
}
|
||||
} catch { /* optional */ }
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
const sid = serverId.value
|
||||
if (!sid || !form.webname || !form.path) return
|
||||
loading.value = true
|
||||
try {
|
||||
await btCreateSite(sid, { ...form })
|
||||
window.$snackbar?.('创建请求已提交', 'success')
|
||||
} catch (e: unknown) {
|
||||
window.$snackbar?.(e instanceof Error ? e.message : '创建失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<BtPanelPageShell title="宝塔 · 网站列表" subtitle="站点列表与启停(C1 / C6)">
|
||||
<template #default>
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="rows"
|
||||
:loading="loading"
|
||||
density="compact"
|
||||
item-value="id"
|
||||
>
|
||||
<template #item.status="{ item }">
|
||||
<v-chip size="x-small" :color="item.status === '1' ? 'success' : 'warning'" variant="tonal">
|
||||
{{ item.status === '1' ? '运行' : '停用' }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<template #item.actions="{ item }">
|
||||
<v-btn
|
||||
v-if="item.status !== '1'"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
color="success"
|
||||
@click="toggle(item, true)"
|
||||
>启用</v-btn>
|
||||
<v-btn
|
||||
v-else
|
||||
size="x-small"
|
||||
variant="text"
|
||||
color="warning"
|
||||
@click="toggle(item, false)"
|
||||
>停用</v-btn>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</template>
|
||||
</BtPanelPageShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import BtPanelPageShell from '@/components/btpanel/BtPanelPageShell.vue'
|
||||
import { btListSites, btSiteStart, btSiteStop } from '@/api/btpanel'
|
||||
import { useBtPanelPageLoad } from '@/composables/btpanel/useBtPanelPageLoad'
|
||||
|
||||
defineOptions({ name: 'BtPanelSitesPage' })
|
||||
|
||||
interface SiteRow {
|
||||
id: number
|
||||
name: string
|
||||
path: string
|
||||
status: string
|
||||
ps?: string
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<SiteRow[]>([])
|
||||
const headers = [
|
||||
{ title: 'ID', key: 'id', width: 70 },
|
||||
{ title: '域名', key: 'name' },
|
||||
{ title: '路径', key: 'path' },
|
||||
{ title: '备注', key: 'ps' },
|
||||
{ title: '状态', key: 'status', width: 90 },
|
||||
{ title: '操作', key: 'actions', sortable: false, width: 100 },
|
||||
]
|
||||
|
||||
const { run, serverId } = useBtPanelPageLoad(async (id) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await btListSites(id)
|
||||
rows.value = (res.data ?? []) as SiteRow[]
|
||||
} catch (e: unknown) {
|
||||
window.$snackbar?.(e instanceof Error ? e.message : '加载失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function toggle(item: SiteRow, start: boolean) {
|
||||
const sid = serverId.value
|
||||
if (!sid) return
|
||||
loading.value = true
|
||||
try {
|
||||
if (start) await btSiteStart(sid, item.id, item.name)
|
||||
else await btSiteStop(sid, item.id, item.name)
|
||||
window.$snackbar?.(start ? '已启用' : '已停用', 'success')
|
||||
await run()
|
||||
} catch (e: unknown) {
|
||||
window.$snackbar?.(e instanceof Error ? e.message : '操作失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<BtPanelPageShell title="宝塔 · SSL 证书" subtitle="站点证书查看 / 上传 / Let's Encrypt(C15)">
|
||||
<template #default>
|
||||
<v-data-table :headers="headers" :items="rows" :loading="loading" density="compact">
|
||||
<template #item.ssl="{ item }">
|
||||
<span>{{ formatSsl(item) }}</span>
|
||||
</template>
|
||||
<template #item.actions="{ item }">
|
||||
<v-btn size="x-small" variant="text" @click="applyLe(item)">申请 LE</v-btn>
|
||||
</template>
|
||||
</v-data-table>
|
||||
|
||||
<v-expansion-panels class="mt-4" variant="accordion">
|
||||
<v-expansion-panel title="上传证书(SetSSL)">
|
||||
<v-expansion-panel-text>
|
||||
<v-text-field v-model="upload.siteName" label="网站名" density="compact" class="mb-2" />
|
||||
<v-textarea v-model="upload.csr" label="证书 PEM" rows="4" class="mb-2" />
|
||||
<v-textarea v-model="upload.key" label="私钥 PEM" rows="4" class="mb-2" />
|
||||
<v-btn color="primary" :loading="loading" @click="uploadSsl">上传</v-btn>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</template>
|
||||
</BtPanelPageShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import BtPanelPageShell from '@/components/btpanel/BtPanelPageShell.vue'
|
||||
import { btApplySsl, btSetSsl, btSslSites } from '@/api/btpanel'
|
||||
import { useBtPanelPageLoad } from '@/composables/btpanel/useBtPanelPageLoad'
|
||||
|
||||
defineOptions({ name: 'BtPanelSslPage' })
|
||||
|
||||
interface SiteRow {
|
||||
id: number
|
||||
name: string
|
||||
ssl?: { subject?: string; notAfter?: string; endtime?: string } | number
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<SiteRow[]>([])
|
||||
const upload = reactive({ siteName: '', key: '', csr: '' })
|
||||
const headers = [
|
||||
{ title: 'ID', key: 'id', width: 70 },
|
||||
{ title: '网站', key: 'name' },
|
||||
{ title: 'SSL', key: 'ssl' },
|
||||
{ title: '操作', key: 'actions', sortable: false, width: 100 },
|
||||
]
|
||||
|
||||
const { serverId, run } = useBtPanelPageLoad(async (id) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await btSslSites(id)
|
||||
rows.value = (res.data ?? []) as SiteRow[]
|
||||
} catch (e: unknown) {
|
||||
window.$snackbar?.(e instanceof Error ? e.message : '加载失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function formatSsl(item: SiteRow) {
|
||||
if (!item.ssl || item.ssl === -1) return '无'
|
||||
if (typeof item.ssl === 'object') return item.ssl.subject || item.ssl.notAfter || '已配置'
|
||||
return String(item.ssl)
|
||||
}
|
||||
|
||||
async function applyLe(item: SiteRow) {
|
||||
const sid = serverId.value
|
||||
if (!sid) return
|
||||
loading.value = true
|
||||
try {
|
||||
await btApplySsl(sid, { domains: item.name, id: item.id, auth_type: 'http' })
|
||||
window.$snackbar?.('已提交申请', 'success')
|
||||
await run()
|
||||
} catch (e: unknown) {
|
||||
window.$snackbar?.(e instanceof Error ? e.message : '申请失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadSsl() {
|
||||
const sid = serverId.value
|
||||
if (!sid || !upload.siteName) return
|
||||
loading.value = true
|
||||
try {
|
||||
await btSetSsl(sid, { siteName: upload.siteName, key: upload.key, csr: upload.csr })
|
||||
window.$snackbar?.('证书已上传', 'success')
|
||||
await run()
|
||||
} catch (e: unknown) {
|
||||
window.$snackbar?.(e instanceof Error ? e.message : '上传失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
@@ -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)$")
|
||||
+13
-1
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Baota (BT Panel) HTTP API client and SSH helpers."""
|
||||
@@ -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
|
||||
@@ -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 {}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 "")
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user