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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
r
2026-06-21 09:13:14 +08:00
parent ebab836554
commit 82426b1941
46 changed files with 3942 additions and 4 deletions
@@ -0,0 +1,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` |
| B1B3 | 系统监控 | 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 |
| B1B3 | `/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` |
| D1D4 | `/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 Bootstrap2026-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` 通过
+50
View File
@@ -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) {
+189
View File
@@ -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 }
}
+12 -1
View File
@@ -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="只读查看子机宝塔 CrontabF1">
<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="列表 / 创建 / 改密 / 备份(D1D4">
<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 / GetNetWorkB1B3">
<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="中心调用 IPAPI 白名单)"
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="AddSiteC4">
<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 EncryptC15">
<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>
+11
View File
@@ -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 })
+428
View File
@@ -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
+90
View File
@@ -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
View File
@@ -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)
# ── B1B3 ──
async def system_total(self, server_id: int) -> Any:
return await self.proxy(server_id, "/system?action=GetSystemTotal")
async def system_disk(self, server_id: int) -> Any:
return await self.proxy(server_id, "/system?action=GetDiskInfo")
async def system_network(self, server_id: int) -> Any:
return await self.proxy(server_id, "/system?action=GetNetWork")
# ── C1,C6 ──
async def list_sites(self, server_id: int, *, limit: int = 200) -> Any:
return await self.proxy(server_id, "/data?action=getData", {
"table": "sites",
"limit": limit,
"p": 1,
"order": "id desc",
})
async def site_start(self, server_id: int, site_id: int, name: str) -> Any:
return await self.proxy(server_id, "/site?action=SiteStart", {"id": site_id, "name": name})
async def site_stop(self, server_id: int, site_id: int, name: str) -> Any:
return await self.proxy(server_id, "/site?action=SiteStop", {"id": site_id, "name": name})
# ── C4 ──
async def php_versions(self, server_id: int) -> Any:
return await self.proxy(server_id, "/site?action=GetPHPVersion")
async def create_site(self, server_id: int, payload: dict[str, Any], *, admin_username: str, ip_address: str | None) -> Any:
result = await self.proxy(server_id, "/site?action=AddSite", payload)
await self.audit.create(AuditLog(
admin_username=admin_username,
action="bt_panel_site_create",
target_type="server",
target_id=server_id,
detail=json.dumps({"webname": payload.get("webname")}, ensure_ascii=False)[:500],
ip_address=ip_address,
))
return result
# ── C10 ──
async def list_domains(self, server_id: int, site_id: int) -> Any:
return await self.proxy(server_id, "/data?action=getData", {
"table": "domain",
"search": str(site_id),
"list": "true",
})
async def add_domain(self, server_id: int, payload: dict[str, Any]) -> Any:
return await self.proxy(server_id, "/site?action=AddDomain", payload)
async def delete_domain(self, server_id: int, payload: dict[str, Any]) -> Any:
return await self.proxy(server_id, "/site?action=DelDomain", payload)
# ── C15 ──
async def list_ssl_sites(self, server_id: int) -> Any:
return await self.list_sites(server_id, limit=500)
async def set_ssl(self, server_id: int, payload: dict[str, Any], *, admin_username: str, ip_address: str | None) -> Any:
result = await self.proxy(server_id, "/site?action=SetSSL", payload)
await self.audit.create(AuditLog(
admin_username=admin_username,
action="bt_panel_ssl_set",
target_type="server",
target_id=server_id,
detail=json.dumps({"siteName": payload.get("siteName")}, ensure_ascii=False)[:300],
ip_address=ip_address,
))
return result
async def apply_letsencrypt(self, server_id: int, payload: dict[str, Any]) -> Any:
return await self.proxy(server_id, "/acme?action=apply_cert", payload)
# ── D1D4 ──
async def list_databases(self, server_id: int, *, limit: int = 200) -> Any:
return await self.proxy(server_id, "/data?action=getData", {
"table": "databases",
"limit": limit,
"p": 1,
})
async def create_database(self, server_id: int, payload: dict[str, Any], *, admin_username: str, ip_address: str | None) -> Any:
result = await self.proxy(server_id, "/database?action=AddDatabase", payload)
await self.audit.create(AuditLog(
admin_username=admin_username,
action="bt_panel_db_create",
target_type="server",
target_id=server_id,
detail=json.dumps({"name": payload.get("name")}, ensure_ascii=False)[:200],
ip_address=ip_address,
))
return result
async def reset_database_password(self, server_id: int, payload: dict[str, Any]) -> Any:
return await self.proxy(server_id, "/database?action=ResDatabasePassword", payload)
async def backup_database(self, server_id: int, db_id: int) -> Any:
return await self.proxy(server_id, "/database?action=ToBackup", {"id": db_id})
# ── F1 ──
async def list_crontab(self, server_id: int) -> Any:
return await self.proxy(server_id, "/crontab?action=GetCrontab")
# ── H1 ──
async def service_admin(self, server_id: int, name: str, op: str, *, admin_username: str, ip_address: str | None) -> Any:
result = await self.proxy(server_id, "/system?action=ServiceAdmin", {"name": name, "type": op})
await self.audit.create(AuditLog(
admin_username=admin_username,
action="bt_panel_service_admin",
target_type="server",
target_id=server_id,
detail=json.dumps({"name": name, "type": op}, ensure_ascii=False),
ip_address=ip_address,
))
return result
@@ -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)
+11
View File
@@ -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 {}
-1
View File
@@ -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
+7
View File
@@ -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)
+89
View File
@@ -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
+55
View File
@@ -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
+77
View File
@@ -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")
+144
View File
@@ -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
+30
View File
@@ -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