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-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 nav slim>
<v-list-subheader>系统</v-list-subheader> <v-list-subheader>系统</v-list-subheader>
@@ -319,8 +351,13 @@ async function toggleTheme() {
} }
const drawer = ref(true) const drawer = ref(true)
const btPanelMenuOpen = ref(route.path.startsWith('/btpanel'))
const { mobile } = useDisplay() const { mobile } = useDisplay()
watch(() => route.path, (p) => {
if (p.startsWith('/btpanel')) btPanelMenuOpen.value = true
})
/** 终端页监听菜单开合,在动画结束后 refit xterm */ /** 终端页监听菜单开合,在动画结束后 refit xterm */
provide('nexusDrawer', drawer) provide('nexusDrawer', drawer)
@@ -356,6 +393,19 @@ const sysItems = [
{ to: '/audit', title: '审计日志', icon: 'mdi-clipboard-text-outline' }, { 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' }) const snackbar = reactive({ show: false, text: '', color: 'success' })
function navigateTo(to: string) { 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 = [ export const CACHED_PAGE_NAMES = [
'DashboardPage', 'DashboardPage',
'ServersPage', 'ServersPage',
'TerminalPage',
'ScriptsPage', 'ScriptsPage',
'ScriptRunsPage', 'ScriptRunsPage',
'PushPage', 'PushPage',
@@ -12,4 +13,14 @@ export const CACHED_PAGE_NAMES = [
'AlertsPage', 'AlertsPage',
'AuditPage', 'AuditPage',
'SettingsPage', 'SettingsPage',
'BtPanelLoginPage',
'BtPanelMonitorPage',
'BtPanelSitesPage',
'BtPanelSiteCreatePage',
'BtPanelDomainsPage',
'BtPanelSslPage',
'BtPanelDatabasesPage',
'BtPanelCrontabPage',
'BtPanelServicesPage',
'BtPanelSettingsPage',
] as const ] 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: '/audit', name: 'Audit', component: () => import('@/pages/AuditPage.vue') },
{ path: '/settings', name: 'Settings', component: () => import('@/pages/SettingsPage.vue') }, { path: '/settings', name: 'Settings', component: () => import('@/pages/SettingsPage.vue') },
{ path: '/login', name: 'Login', component: () => import('@/pages/LoginPage.vue'), meta: { public: true } }, { 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 }) 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 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: def _server_has_agent_monitoring(server: Server) -> bool:
"""True when Agent has actually reported in (version/heartbeat), not key-only.""" """True when Agent has actually reported in (version/heartbeat), not key-only."""
from server.utils.agent_version import agent_is_installed 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"), platform_id: Optional[int] = Query(None, description="Filter by platform ID"),
node_id: Optional[int] = Query(None, description="Filter by node ID"), node_id: Optional[int] = Query(None, description="Filter by node ID"),
search: Optional[str] = Query(None, description="Search by name or domain (substring)"), 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"), sort_order: Optional[str] = Query("asc", description="Sort direction: asc or desc"),
is_online: Optional[bool] = Query(None, description="Filter by online status"), is_online: Optional[bool] = Query(None, description="Filter by online status"),
target_path_unset: Optional[bool] = Query( 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) onboarding_job_id = await _schedule_server_onboarding(result.created_ids, admin.username)
if onboarding_job_id is not None: if onboarding_job_id is not None:
result.onboarding_job_id = onboarding_job_id result.onboarding_job_id = onboarding_job_id
await _schedule_bt_panel_bootstrap(result.created_ids)
# Audit log # Audit log
ip_address = request.client.host if request.client else "" ip_address = request.client.host if request.client else ""
@@ -948,6 +958,7 @@ async def _create_server_from_poll(
), ),
ip_address=ip_address, ip_address=ip_address,
)) ))
await _schedule_bt_panel_bootstrap([created.id])
return created return created
@@ -1507,6 +1518,7 @@ async def create_server(
if onboarding_job_id is not None: if onboarding_job_id is not None:
resp["onboarding_job_id"] = onboarding_job_id resp["onboarding_job_id"] = onboarding_job_id
resp["onboarding_label"] = "新服务器初始化" resp["onboarding_label"] = "新服务器初始化"
await _schedule_bt_panel_bootstrap([created.id])
return resp 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", "install-agent": "批量安装 Agent",
"upgrade-agent": "批量升级 Agent", "upgrade-agent": "批量升级 Agent",
"uninstall-agent": "批量卸载 Agent", "uninstall-agent": "批量卸载 Agent",
"bt-panel-bootstrap": "宝塔 SSH 获取 API",
} }
AUDIT_ACTIONS = { AUDIT_ACTIONS = {
@@ -51,6 +52,7 @@ AUDIT_ACTIONS = {
"install-agent": "batch_install_agent", "install-agent": "batch_install_agent",
"upgrade-agent": "batch_upgrade_agent", "upgrade-agent": "batch_upgrade_agent",
"uninstall-agent": "batch_uninstall_agent", "uninstall-agent": "batch_uninstall_agent",
"bt-panel-bootstrap": "batch_bt_panel_bootstrap",
} }
HEARTBEAT_PREFIX = "heartbeat:" HEARTBEAT_PREFIX = "heartbeat:"
@@ -336,6 +338,8 @@ async def _run_background(job_id: int) -> None:
await _run_upgrade_agent(live) await _run_upgrade_agent(live)
elif op == "uninstall-agent": elif op == "uninstall-agent":
await _run_uninstall_agent(live) await _run_uninstall_agent(live)
elif op == "bt-panel-bootstrap":
await _run_bt_panel_bootstrap(live)
else: else:
live["status"] = "failed" live["status"] = "failed"
await sbs.save_live(live) 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]) return result_item_dict(server_id=sid, server_name=label, success=False, error=str(e)[:300])
await _run_with_semaphore(live, handler) 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_RESTART: str = "true" # Nexus 后端重启通知
NOTIFY_SCRIPT_COMPLETE: str = "true" # 脚本执行完成汇总(含看板链接) 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 # extra=ignore: .env 中与 MCP 共存的 MYSQL_* / NEXUS_API_BASE_URL 等不进入 Settings
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_prefix="NEXUS_", env_prefix="NEXUS_",
@@ -167,6 +173,10 @@ class Settings(BaseSettings):
"notify_system_mysql": "NOTIFY_SYSTEM_MYSQL", "notify_system_mysql": "NOTIFY_SYSTEM_MYSQL",
"notify_restart": "NOTIFY_RESTART", "notify_restart": "NOTIFY_RESTART",
"notify_script_complete": "NOTIFY_SCRIPT_COMPLETE", "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 # 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", "DB_POOL_SIZE", "DB_MAX_OVERFLOW", "HEALTH_CHECK_INTERVAL",
"JWT_ACCESS_TOKEN_EXPIRE_MINUTES", "JWT_REFRESH_TOKEN_EXPIRE_DAYS", "JWT_ACCESS_TOKEN_EXPIRE_MINUTES", "JWT_REFRESH_TOKEN_EXPIRE_DAYS",
"CPU_ALERT_THRESHOLD", "MEM_ALERT_THRESHOLD", "DISK_ALERT_THRESHOLD", "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: 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 json
import logging import logging
import time import time
from http.cookies import SimpleCookie
from typing import Any from typing import Any
from urllib.parse import urlencode 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.domain.models import Server
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command 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 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.search import router as search_router
from server.api.terminal import router as terminal_router from server.api.terminal import router as terminal_router
from server.api.watch import router as watch_router from server.api.watch import router as watch_router
from server.api.btpanel import router as btpanel_router
# Background tasks # Background tasks
from server.background.heartbeat_flush import heartbeat_flush_loop from server.background.heartbeat_flush import heartbeat_flush_loop
from server.background.script_execution_flush import script_execution_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.unset_path_detect_loop import unset_path_detect_loop
from server.background.agent_install_loop import agent_install_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.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") logger = logging.getLogger("nexus")
@@ -331,6 +333,9 @@ async def lifespan(app: FastAPI):
task_offline_daily_report = asyncio.create_task( task_offline_daily_report = asyncio.create_task(
offline_daily_report_loop(), name="offline_daily_report" 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([ _background_tasks.extend([
task_flush, task_flush,
task_script_flush, task_script_flush,
@@ -346,6 +351,7 @@ async def lifespan(app: FastAPI):
task_unset_path_detect, task_unset_path_detect,
task_agent_install, task_agent_install,
task_offline_daily_report, task_offline_daily_report,
task_bt_panel_bootstrap,
]) ])
logger.info("Primary worker — background tasks launched") logger.info("Primary worker — background tasks launched")
else: else:
@@ -711,6 +717,7 @@ app.include_router(search_router)
# Terminal Quick Commands # Terminal Quick Commands
app.include_router(terminal_router) app.include_router(terminal_router)
app.include_router(watch_router) app.include_router(watch_router)
app.include_router(btpanel_router)
# ── Custom 404: return blank HTML to hide backend identity ── # ── Custom 404: return blank HTML to hide backend identity ──
@app.exception_handler(StarletteHTTPException) @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