feat(ops-patrol): Layer 3 巡检与 Telegram 配置打通

保存 Telegram 时同步至 .env,health_monitor 支持 MySQL 回退,
设置页展示巡检状态,部署时自动安装 cron。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nexus Agent
2026-06-07 20:17:24 +08:00
parent 66a4cba39b
commit f9934bd4f1
13 changed files with 642 additions and 2 deletions
+10 -2
View File
@@ -126,8 +126,16 @@ echo " /health → ${HEALTH}"
echo " /app/ → ${SPA}" echo " /app/ → ${SPA}"
if [[ "${HEALTH}" == "ok" && "${SPA}" == "200" ]]; then if [[ "${HEALTH}" == "ok" && "${SPA}" == "200" ]]; then
echo "✓ Deploy successful" echo "✓ Deploy successful"
ssh_cmd "command -v crontab >/dev/null && crontab -l 2>/dev/null | grep -q nexus-health-monitor" 2>/dev/null || \ if ssh_cmd "command -v crontab >/dev/null && crontab -l 2>/dev/null | grep -q nexus-health-monitor" 2>/dev/null; then
echo " 提示: 远程未安装巡检 cron,可在服务器执行 sudo nx cron" echo "✓ Ops cron (health_monitor) already installed"
else
echo "▶ Installing ops cron (health_monitor + db_backup)…"
if ssh_cmd "sudo env NEXUS_ROOT=${DEPLOY_PATH} bash ${DEPLOY_PATH}/deploy/install_ops_cron.sh" 2>/dev/null; then
echo "✓ Ops cron installed"
else
echo " 提示: 远程 cron 安装失败,可在服务器执行: sudo nx cron"
fi
fi
else else
echo "✗ Verification failed — check logs on server" >&2 echo "✗ Verification failed — check logs on server" >&2
exit 1 exit 1
+30
View File
@@ -396,3 +396,33 @@
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"} {"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"} {"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"summary","result":"PASS","detail":"7/7"} {"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-07T19:45:16+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-settings-telegram-fix.md 37lines"}
{"ts":"2026-06-07T19:45:16+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-settings-telegram-fix.md"}
{"ts":"2026-06-07T19:45:16+08:00","date":"2026-06-07","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-07T19:45:16+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-07T19:45:16+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-07T19:45:16+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-07T19:45:16+08:00","date":"2026-06-07","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-07T19:45:16+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-07T19:45:16+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-07T19:45:16+08:00","date":"2026-06-07","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-07T20:16:43+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-ops-patrol-layer3-telegram.md 40lines"}
{"ts":"2026-06-07T20:16:43+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-settings-telegram-fix.md"}
{"ts":"2026-06-07T20:16:43+08:00","date":"2026-06-07","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-07T20:16:43+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-07T20:16:43+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-07T20:16:43+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-07T20:16:43+08:00","date":"2026-06-07","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-07T20:16:43+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-07T20:16:43+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-07T20:16:43+08:00","date":"2026-06-07","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-07T20:17:03+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-ops-patrol-layer3-telegram.md 40lines"}
{"ts":"2026-06-07T20:17:03+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-ops-patrol-layer3-telegram.md"}
{"ts":"2026-06-07T20:17:03+08:00","date":"2026-06-07","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-07T20:17:03+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-07T20:17:03+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-07T20:17:03+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-07T20:17:03+08:00","date":"2026-06-07","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-07T20:17:03+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-07T20:17:03+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-07T20:17:03+08:00","date":"2026-06-07","gate":"summary","result":"PASS","detail":"7/7"}
+31
View File
@@ -33,6 +33,35 @@ read_env_var() {
grep -E "^${key}=" "$file" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '\r"' || true grep -E "^${key}=" "$file" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '\r"' || true
} }
load_telegram_from_db() {
local cname="$1"
[[ -n "$cname" ]] || return 1
[[ -n "${BOT_TOKEN:-}" && -n "${CHAT_ID:-}" ]] && return 0
local out token chat
out="$(docker exec "$cname" python3 -c "
import asyncio
from server.config import settings as s
from server.infrastructure.database.session import AsyncSessionLocal
async def main():
async with AsyncSessionLocal() as db:
await s.load_settings_from_db(db)
t = (s.TELEGRAM_BOT_TOKEN or '').strip()
c = (s.TELEGRAM_CHAT_ID or '').strip()
if t and c:
print(t)
print(c)
asyncio.run(main())
" 2>/dev/null)" || true
[[ -n "$out" ]] || return 1
token="$(printf '%s\n' "$out" | sed -n '1p')"
chat="$(printf '%s\n' "$out" | sed -n '2p')"
[[ -n "$token" && -n "$chat" ]] || return 1
BOT_TOKEN="$token"
CHAT_ID="$chat"
}
load_telegram_config() { load_telegram_config() {
local cname="$1" dotenv local cname="$1" dotenv
BOT_TOKEN="" BOT_TOKEN=""
@@ -54,6 +83,8 @@ load_telegram_config() {
NEXUS_TELEGRAM_CHAT_ID=*) CHAT_ID="${line#NEXUS_TELEGRAM_CHAT_ID=}" ;; NEXUS_TELEGRAM_CHAT_ID=*) CHAT_ID="${line#NEXUS_TELEGRAM_CHAT_ID=}" ;;
esac esac
done < <(docker exec "$cname" grep -E '^NEXUS_TELEGRAM_' /app/.env 2>/dev/null || true) done < <(docker exec "$cname" grep -E '^NEXUS_TELEGRAM_' /app/.env 2>/dev/null || true)
[[ -n "$BOT_TOKEN" && -n "$CHAT_ID" ]] && return 0
load_telegram_from_db "$cname" || true
fi fi
} }
+2
View File
@@ -363,6 +363,8 @@ keys = (
"NEXUS_ENCRYPTION_KEY", "NEXUS_ENCRYPTION_KEY",
"NEXUS_REDIS_URL", "NEXUS_REDIS_URL",
"NEXUS_DATABASE_URL", "NEXUS_DATABASE_URL",
"NEXUS_TELEGRAM_BOT_TOKEN",
"NEXUS_TELEGRAM_CHAT_ID",
) )
prod = {} prod = {}
for line in env_file.read_text(encoding="utf-8").splitlines(): for line in env_file.read_text(encoding="utf-8").splitlines():
@@ -0,0 +1,44 @@
# 审计 — Layer 3 宿主机巡检 Telegram 打通
## 范围
| 文件 | 变更 |
|------|------|
| `server/infrastructure/env_file.py` | `.env` upsert/readUTF-8 LF |
| `server/application/services/ops_patrol_sync.py` | Telegram → `/app/.env`、persist 卷、host `.env.prod` |
| `server/api/settings.py` | `set_setting` 钩子;`GET/POST ops-patrol/*` |
| `deploy/health_monitor.sh` | `load_telegram_from_db` MySQL 回退 |
| `deploy/deploy-production.sh` | 部署成功自动 `install_ops_cron.sh` |
| `deploy/nexus-1panel.sh` | `sync_env_prod_to_volume` 含 Telegram 键 |
| `frontend/src/pages/SettingsPage.vue` | Layer 3 巡检状态与同步 UI |
| `frontend/src/types/api.ts` | `OpsPatrol*` 类型 |
| `tests/test_ops_patrol_sync.py` | 7 用例 |
| `docs/changelog/2026-06-07-ops-patrol-layer3-telegram.md` | changelog |
| `docs/design/plans/2026-06-07-ops-patrol-layer3-telegram.md` | 技术说明 |
| `deploy/gate_log.jsonl` | 门控记录(自动生成) |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | Layer-3 与设置页 Telegram 脱节 | FIXED — 保存时 sync + DB 回退 |
| H2 | `.env` 写入破坏不可变键 | SAFE — 仅 upsert `NEXUS_TELEGRAM_*` |
| H3 | 宿主机 cron 未安装 | FIXED — deploy 自动 install_ops_cron |
| H4 | env 同步失败静默 | SAFE — 记录 warningDB 回退保底 |
| H5 | 敏感 Token 泄露至日志 | SAFE — 无 log token;审计仅写布尔标志 |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1H5 | FIXED / SAFE | 代码 + pytest 7/7 |
## DoD
- [x] pytest `test_ops_patrol_sync.py` + `test_settings_telegram.py` 13 passed
- [x] changelog / audit / plan
- [x] 无 SECRET_KEY/API_KEY/ENCRYPTION_KEY/DATABASE_URL 变更
## 验证
`.venv/bin/pytest tests/test_ops_patrol_sync.py tests/test_settings_telegram.py -q`
@@ -0,0 +1,40 @@
# Changelog — Layer 3 宿主机巡检完美实现
**日期**: 2026-06-07
## 摘要
打通设置页 Telegram 配置与 Layer-3 宿主机巡检(`health_monitor.sh` + cron):保存时自动同步 `.env`、脚本支持从 MySQL 回退读取、设置页展示巡检状态与手动同步、部署时自动安装 cron。
## 动机
用户在设置页配置 Telegram 后,应用内告警与测试发送可用,但宿主机 cron 巡检仅读 `docker/.env.prod` / 容器 `/app/.env`,导致 Layer-3 重启告警 Telegram 可能静默失败。
## 涉及文件
| 文件 | 变更 |
|------|------|
| `server/infrastructure/env_file.py` | 新增 `.env` upsert/read 工具 |
| `server/application/services/ops_patrol_sync.py` | Telegram → `/app/.env`、persist 卷、host `.env.prod` |
| `server/api/settings.py` | `set_setting` 钩子;`GET/POST ops-patrol/*` |
| `deploy/health_monitor.sh` | `load_telegram_from_db` MySQL 回退 |
| `deploy/deploy-production.sh` | 部署成功时自动 `install_ops_cron.sh` |
| `deploy/nexus-1panel.sh` | `sync_env_prod_to_volume` 含 Telegram 键 |
| `frontend/src/pages/SettingsPage.vue` | Layer 3 巡检状态与同步 UI |
| `frontend/src/types/api.ts` | OpsPatrol 类型 |
| `tests/test_ops_patrol_sync.py` | 单元测试 |
## 迁移 / 重启
- 无需 DB 迁移
- 后端需重启/重新部署容器;前端需 vite build
- 生产需确保 cron 已安装(部署脚本现自动尝试)
## 验证方式
```bash
pytest tests/test_ops_patrol_sync.py -q
bash scripts/local_verify.sh
# 设置页保存 Telegram → 刷新 Layer 3 状态 → 「同步巡检 Telegram」
# 生产: crontab -l | grep nexus-health-monitor; tail /var/log/nexus_health.log
```
@@ -0,0 +1,36 @@
# 技术说明 — Layer 3 宿主机巡检 Telegram 打通
**日期**: 2026-06-07
## 目标
设置页保存 `telegram_bot_token` / `telegram_chat_id` 后,Layer-3 `health_monitor.sh` 能可靠发送 Telegram(/health 连续失败 → 重启通知)。
## 三层配置读取顺序(health_monitor.sh
1. 宿主机 `${NEXUS_ROOT}/.env``docker/.env.prod`
2. 容器 `/app/.env``docker exec grep`
3. **新增** 容器内 Python 从 MySQL `settings` 表加载(与运行时 `load_settings_from_db` 一致)
## 应用内同步(保存 Telegram 时)
`ops_patrol_sync.sync_telegram_to_env` 写入:
- `/app/.env` + `NEXUS_PERSIST_DIR/.env`nexus-state 卷)
-`NEXUS_HOST_ROOT` 可写:`{NEXUS_HOST_ROOT}/docker/.env.prod`
## API
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/settings/ops-patrol/status` | 巡检 Telegram / env 同步状态 |
| POST | `/api/settings/ops-patrol/sync-telegram` | 手动同步 DB → .env |
## 部署
`deploy-production.sh` 健康检查通过后,若远程无 `nexus-health-monitor` cron,自动执行 `install_ops_cron.sh`
## 回滚
- revert 本 commit;cron 条目可保留(行为与旧版兼容,仅多 DB 回退)
- 删除 `.env``NEXUS_TELEGRAM_*` 不影响应用内 DB 配置
+81
View File
@@ -99,6 +99,48 @@
@update:model-value="(v: boolean | null) => toggleNotify(item.key, Boolean(v))" @update:model-value="(v: boolean | null) => toggleNotify(item.key, Boolean(v))"
/> />
</template> </template>
<v-divider class="my-4" />
<div class="text-subtitle-2 mb-1">Layer 3 宿主机巡检</div>
<p class="text-caption text-medium-emphasis mb-3">
每分钟由宿主机 cron 执行 <code>health_monitor.sh</code>:连续 3 次 /health 失败则自动重启并推送 Telegram。
配置保存后会自动同步至 .env;亦可手动同步。未同步时脚本会从数据库读取 Telegram 配置。
</p>
<div class="d-flex flex-wrap align-center ga-2 mb-3">
<v-chip
size="small"
:color="patrolStatus?.telegram_layer3_ready ? 'success' : 'warning'"
variant="tonal"
>
{{ patrolStatus?.telegram_layer3_ready ? '巡检 Telegram 就绪' : '请先配置 Telegram' }}
</v-chip>
<v-chip
v-if="patrolStatus"
size="small"
:color="patrolStatus.telegram_env_synced ? 'success' : 'grey'"
variant="tonal"
>
{{ patrolStatus.telegram_env_synced ? '.env 已同步' : '.env 未同步(可用 DB 回退)' }}
</v-chip>
</div>
<div class="d-flex flex-wrap ga-2">
<v-btn
size="small"
variant="tonal"
prepend-icon="mdi-sync"
@click="syncPatrolTelegram"
:loading="syncingPatrol"
:disabled="!patrolStatus?.telegram_app_configured"
>
同步巡检 Telegram
</v-btn>
<v-btn size="small" variant="text" prepend-icon="mdi-refresh" @click="loadPatrolStatus" :loading="patrolLoading">
刷新状态
</v-btn>
</div>
<p v-if="patrolStatus" class="text-caption text-medium-emphasis mt-2 mb-0">
宿主机安装 cron<code>{{ patrolStatus.cron_install_command }}</code> · 日志:<code>{{ patrolStatus.health_log_path }}</code>
</p>
</v-card-text> </v-card-text>
</v-card> </v-card>
</v-col> </v-col>
@@ -330,6 +372,8 @@ import type {
SettingItem, SettingItem,
TelegramChatItem, TelegramChatItem,
TelegramChatsResponse, TelegramChatsResponse,
OpsPatrolStatusResponse,
OpsPatrolSyncResponse,
SubscriptionParseResponse, SubscriptionParseResponse,
IpAllowlistSaveRequest, IpAllowlistSaveRequest,
} from '@/types/api' } from '@/types/api'
@@ -358,6 +402,9 @@ const showToken = ref(false)
const savingTelegram = ref(false) const savingTelegram = ref(false)
const notifyToggles = ref<Record<string, boolean>>({}) const notifyToggles = ref<Record<string, boolean>>({})
const notifyToggleSaving = ref<string | null>(null) const notifyToggleSaving = ref<string | null>(null)
const patrolStatus = ref<OpsPatrolStatusResponse | null>(null)
const patrolLoading = ref(false)
const syncingPatrol = ref(false)
const notifyToggleGroups = computed(() => { const notifyToggleGroups = computed(() => {
const groups: Record<string, typeof NOTIFY_TOGGLE_ITEMS> = {} const groups: Record<string, typeof NOTIFY_TOGGLE_ITEMS> = {}
@@ -500,6 +547,38 @@ async function persistTelegramConfig(options?: { requireToken?: boolean; require
return true return true
} }
async function loadPatrolStatus() {
patrolLoading.value = true
try {
patrolStatus.value = await http.get<OpsPatrolStatusResponse>('/settings/ops-patrol/status')
} catch {
patrolStatus.value = null
} finally {
patrolLoading.value = false
}
}
async function syncPatrolTelegram() {
syncingPatrol.value = true
try {
if (!await persistTelegramConfig({ requireToken: true, requireChatId: true })) return
const res = await http.post<OpsPatrolSyncResponse>('/settings/ops-patrol/sync-telegram', {})
patrolStatus.value = {
...(patrolStatus.value || {} as OpsPatrolStatusResponse),
telegram_layer3_ready: res.telegram_layer3_ready,
telegram_env_synced: res.env_status.env_synced,
env_status: res.env_status,
telegram_app_configured: true,
}
snackbar('巡检 Telegram 已同步至 .env')
await loadPatrolStatus()
} catch (e: unknown) {
snackbar(e instanceof Error ? e.message : '同步失败', 'error')
} finally {
syncingPatrol.value = false
}
}
async function saveTelegramConfig() { async function saveTelegramConfig() {
savingTelegram.value = true savingTelegram.value = true
try { try {
@@ -510,6 +589,7 @@ async function saveTelegramConfig() {
return return
} }
await persistTelegramConfig() await persistTelegramConfig()
await loadPatrolStatus()
snackbar('Telegram 配置已保存') snackbar('Telegram 配置已保存')
} catch (e: unknown) { } catch (e: unknown) {
snackbar(e instanceof Error ? e.message : '保存失败', 'error') snackbar(e instanceof Error ? e.message : '保存失败', 'error')
@@ -755,6 +835,7 @@ async function saveAllowlist() {
onMounted(() => { onMounted(() => {
loadSettings() loadSettings()
loadAllowlist() loadAllowlist()
loadPatrolStatus()
auth.fetchProfile() auth.fetchProfile()
}) })
</script> </script>
+24
View File
@@ -35,6 +35,30 @@ export interface TelegramChatsResponse {
count: number count: number
} }
export interface OpsPatrolEnvStatus {
app_env_token: boolean
app_env_chat_id: boolean
host_env_prod_token: boolean
host_env_prod_chat_id: boolean
env_synced: boolean
}
export interface OpsPatrolStatusResponse {
telegram_app_configured: boolean
telegram_layer3_ready: boolean
telegram_env_synced: boolean
env_status: OpsPatrolEnvStatus
db_fallback_enabled: boolean
cron_install_command: string
health_log_path: string
}
export interface OpsPatrolSyncResponse {
synced: Record<string, boolean | string>
env_status: OpsPatrolEnvStatus
telegram_layer3_ready: boolean
}
/** Telegram notification toggle (settings key → label) */ /** Telegram notification toggle (settings key → label) */
export interface NotifyToggleItem { export interface NotifyToggleItem {
key: string key: string
+68
View File
@@ -377,6 +377,14 @@ async def set_setting(
ip_address=request.client.host if request.client else "", ip_address=request.client.host if request.client else "",
)) ))
if key in ("telegram_bot_token", "telegram_chat_id"):
try:
from server.application.services.ops_patrol_sync import sync_telegram_to_env
sync_telegram_to_env(_settings.TELEGRAM_BOT_TOKEN, _settings.TELEGRAM_CHAT_ID)
except Exception as exc:
_logger.warning("Layer-3 Telegram env sync failed after %s update: %s", key, exc)
return {"key": setting.key, "value": setting.value} return {"key": setting.key, "value": setting.value}
@@ -898,6 +906,66 @@ async def alert_stats(
} }
# ── Layer-3 Host Patrol (health_monitor.sh) ──
@router.get("/ops-patrol/status", response_model=dict)
async def ops_patrol_status(
admin: Admin = Depends(get_current_admin),
):
"""Report Telegram readiness for Layer-3 cron patrol and env sync state."""
from server.config import settings as _settings
from server.application.services.ops_patrol_sync import read_telegram_env_status
app_configured = bool(_settings.TELEGRAM_BOT_TOKEN and _settings.TELEGRAM_CHAT_ID)
env_status = read_telegram_env_status()
return {
"telegram_app_configured": app_configured,
"telegram_layer3_ready": app_configured,
"telegram_env_synced": env_status["env_synced"],
"env_status": env_status,
"db_fallback_enabled": True,
"cron_install_command": "sudo nx cron",
"health_log_path": "/var/log/nexus_health.log",
}
@router.post("/ops-patrol/sync-telegram", response_model=dict)
async def ops_patrol_sync_telegram(
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Sync DB Telegram credentials to .env files readable by health_monitor.sh."""
from server.config import settings as _settings
from server.application.services.ops_patrol_sync import (
read_telegram_env_status,
sync_telegram_to_env,
)
if not _settings.TELEGRAM_BOT_TOKEN or not _settings.TELEGRAM_CHAT_ID:
raise HTTPException(status_code=400, detail="请先在上方保存 Bot Token 与 Chat ID")
result = sync_telegram_to_env(_settings.TELEGRAM_BOT_TOKEN, _settings.TELEGRAM_CHAT_ID)
if result.get("skipped"):
raise HTTPException(status_code=400, detail="Telegram 未配置,无法同步")
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username,
action="sync_patrol_telegram",
target_type="setting",
detail=f"app_env={result.get('app_env')} host_env_prod={result.get('host_env_prod')}",
ip_address=request.client.host if request.client else "",
))
env_status = read_telegram_env_status()
return {
"synced": result,
"env_status": env_status,
"telegram_layer3_ready": True,
}
# ── Telegram Test + Chat ID Detection ── # ── Telegram Test + Chat ID Detection ──
async def _telegram_clear_webhook_if_set(client, base_url: str) -> None: async def _telegram_clear_webhook_if_set(client, base_url: str) -> None:
@@ -0,0 +1,114 @@
"""Sync Telegram credentials for Layer-3 host patrol (health_monitor.sh)."""
from __future__ import annotations
import logging
import os
import shutil
from pathlib import Path
from server.infrastructure.env_file import read_env_var, upsert_env_vars
logger = logging.getLogger("nexus.ops_patrol")
NEXUS_ROOT = Path(__file__).resolve().parents[3]
ENV_FILE = NEXUS_ROOT / ".env"
_TELEGRAM_ENV_KEYS = (
"NEXUS_TELEGRAM_BOT_TOKEN",
"NEXUS_TELEGRAM_CHAT_ID",
)
def _persist_env_path() -> Path:
raw = os.environ.get("NEXUS_PERSIST_DIR", "/var/lib/nexus").strip() or "/var/lib/nexus"
return Path(raw) / ".env"
def _host_env_prod_path() -> Path | None:
host_root = os.environ.get("NEXUS_HOST_ROOT", "").strip()
if not host_root:
return None
return Path(host_root) / "docker" / ".env.prod"
def _build_updates(token: str | None, chat_id: str | None) -> dict[str, str]:
updates: dict[str, str] = {}
if token and token.strip():
updates["NEXUS_TELEGRAM_BOT_TOKEN"] = token.strip()
if chat_id and str(chat_id).strip():
updates["NEXUS_TELEGRAM_CHAT_ID"] = str(chat_id).strip()
return updates
def _sync_to_persist_volume() -> bool:
if not ENV_FILE.is_file():
return False
dest = _persist_env_path()
try:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(ENV_FILE, dest)
dest.chmod(0o600)
return True
except OSError as exc:
logger.warning("Failed to sync .env to persist volume %s: %s", dest, exc)
return False
def read_telegram_env_status() -> dict[str, bool]:
"""Report whether Layer-3 readable env files contain Telegram credentials."""
app_token = bool(read_env_var(ENV_FILE, "NEXUS_TELEGRAM_BOT_TOKEN"))
app_chat = bool(read_env_var(ENV_FILE, "NEXUS_TELEGRAM_CHAT_ID"))
host_token = False
host_chat = False
host_path = _host_env_prod_path()
if host_path is not None:
host_token = bool(read_env_var(host_path, "NEXUS_TELEGRAM_BOT_TOKEN"))
host_chat = bool(read_env_var(host_path, "NEXUS_TELEGRAM_CHAT_ID"))
env_synced = (app_token and app_chat) or (host_token and host_chat)
return {
"app_env_token": app_token,
"app_env_chat_id": app_chat,
"host_env_prod_token": host_token,
"host_env_prod_chat_id": host_chat,
"env_synced": env_synced,
}
def sync_telegram_to_env(token: str | None, chat_id: str | None) -> dict[str, bool | str]:
"""Write Telegram credentials to /app/.env, persist volume, and host .env.prod."""
updates = _build_updates(token, chat_id)
if not updates:
return {
"app_env": False,
"persist_env": False,
"host_env_prod": False,
"skipped": "telegram_not_configured",
}
result: dict[str, bool | str] = {
"app_env": False,
"persist_env": False,
"host_env_prod": False,
}
try:
if upsert_env_vars(ENV_FILE, updates):
ENV_FILE.chmod(0o600)
result["app_env"] = True
result["persist_env"] = _sync_to_persist_volume()
except OSError as exc:
logger.warning("Failed to update %s: %s", ENV_FILE, exc)
host_path = _host_env_prod_path()
if host_path is not None:
try:
if host_path.is_file() and upsert_env_vars(host_path, updates):
host_path.chmod(0o600)
result["host_env_prod"] = True
except OSError as exc:
logger.warning("Failed to update host .env.prod %s: %s", host_path, exc)
return result
+65
View File
@@ -0,0 +1,65 @@
"""Shared .env read/write helpers (UTF-8 LF, quoted values)."""
from __future__ import annotations
import re
from pathlib import Path
from server.utils.text_io import read_utf8_text, write_utf8_lf
_ENV_KEY_RE = re.compile(r"^([A-Z0-9_]+)=(.*)$")
def escape_env_value(value: str) -> str:
escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
return f'"{escaped}"'
def read_env_var(path: Path, key: str) -> str | None:
"""Return unquoted value for key, or None if missing/empty."""
if not path.is_file():
return None
for line in read_utf8_text(path).splitlines():
m = _ENV_KEY_RE.match(line.strip())
if not m or m.group(1) != key:
continue
raw = m.group(2).strip()
if not raw:
return None
if len(raw) >= 2 and raw[0] == raw[-1] == '"':
inner = raw[1:-1]
return inner.replace("\\n", "\n").replace('\\"', '"').replace("\\\\", "\\")
return raw
return None
def upsert_env_vars(path: Path, updates: dict[str, str]) -> bool:
"""Upsert keys in a .env file. Creates file if missing. Returns True if written."""
if not updates:
return False
lines: list[str] = []
if path.is_file():
lines = read_utf8_text(path).splitlines()
seen: set[str] = set()
out: list[str] = []
for line in lines:
m = _ENV_KEY_RE.match(line.strip())
if m and m.group(1) in updates:
key = m.group(1)
out.append(f"{key}={escape_env_value(updates[key])}")
seen.add(key)
else:
out.append(line)
for key, value in updates.items():
if key not in seen:
out.append(f"{key}={escape_env_value(value)}")
path.parent.mkdir(parents=True, exist_ok=True)
body = "\n".join(out)
if body and not body.endswith("\n"):
body += "\n"
write_utf8_lf(path, body)
return True
+97
View File
@@ -0,0 +1,97 @@
"""Tests for Layer-3 ops patrol Telegram env sync."""
from pathlib import Path
import pytest
from server.infrastructure.env_file import escape_env_value, read_env_var, upsert_env_vars
from server.application.services import ops_patrol_sync
def test_escape_env_value_quotes_special_chars():
assert escape_env_value('abc"def') == '"abc\\"def"'
assert escape_env_value("line\nbreak") == '"line\\nbreak"'
def test_upsert_env_vars_creates_and_updates(tmp_path: Path):
env = tmp_path / ".env"
upsert_env_vars(env, {"NEXUS_TELEGRAM_BOT_TOKEN": "123:ABC"})
assert read_env_var(env, "NEXUS_TELEGRAM_BOT_TOKEN") == "123:ABC"
upsert_env_vars(env, {
"NEXUS_TELEGRAM_BOT_TOKEN": "456:XYZ",
"NEXUS_TELEGRAM_CHAT_ID": "-1001",
})
assert read_env_var(env, "NEXUS_TELEGRAM_BOT_TOKEN") == "456:XYZ"
assert read_env_var(env, "NEXUS_TELEGRAM_CHAT_ID") == "-1001"
def test_sync_telegram_to_env_writes_app_env(tmp_path: Path, monkeypatch):
env_file = tmp_path / ".env"
env_file.write_text("# test\nNEXUS_PORT=8600\n", encoding="utf-8")
persist = tmp_path / "persist"
persist.mkdir()
monkeypatch.setattr(ops_patrol_sync, "ENV_FILE", env_file)
monkeypatch.setenv("NEXUS_PERSIST_DIR", str(persist))
result = ops_patrol_sync.sync_telegram_to_env("111:TOKEN", "-999")
assert result["app_env"] is True
assert result["persist_env"] is True
assert read_env_var(env_file, "NEXUS_TELEGRAM_BOT_TOKEN") == "111:TOKEN"
assert read_env_var(persist / ".env", "NEXUS_TELEGRAM_CHAT_ID") == "-999"
def test_sync_telegram_skips_when_empty():
result = ops_patrol_sync.sync_telegram_to_env("", "")
assert result.get("skipped") == "telegram_not_configured"
def test_read_telegram_env_status(tmp_path: Path, monkeypatch):
env_file = tmp_path / ".env"
upsert_env_vars(env_file, {
"NEXUS_TELEGRAM_BOT_TOKEN": "t",
"NEXUS_TELEGRAM_CHAT_ID": "c",
})
monkeypatch.setattr(ops_patrol_sync, "ENV_FILE", env_file)
status = ops_patrol_sync.read_telegram_env_status()
assert status["env_synced"] is True
assert status["app_env_token"] is True
assert status["app_env_chat_id"] is True
@pytest.mark.asyncio
async def test_ops_patrol_status_endpoint():
from unittest.mock import MagicMock
from server.api.settings import ops_patrol_status
from server.config import settings
settings.TELEGRAM_BOT_TOKEN = "123:ABC"
settings.TELEGRAM_CHAT_ID = "-100"
admin = MagicMock()
res = await ops_patrol_status(admin=admin)
assert res["telegram_app_configured"] is True
assert res["telegram_layer3_ready"] is True
assert res["db_fallback_enabled"] is True
@pytest.mark.asyncio
async def test_ops_patrol_sync_400_when_telegram_missing():
from unittest.mock import MagicMock
from fastapi import HTTPException
from server.api.settings import ops_patrol_sync_telegram
from server.config import settings
settings.TELEGRAM_BOT_TOKEN = ""
settings.TELEGRAM_CHAT_ID = ""
with pytest.raises(HTTPException) as exc:
await ops_patrol_sync_telegram(
request=MagicMock(client=MagicMock(host="127.0.0.1")),
admin=MagicMock(username="admin"),
db=MagicMock(),
)
assert exc.value.status_code == 400