diff --git a/deploy/deploy-production.sh b/deploy/deploy-production.sh
index 18cb3298..cc846dd3 100644
--- a/deploy/deploy-production.sh
+++ b/deploy/deploy-production.sh
@@ -126,8 +126,16 @@ echo " /health → ${HEALTH}"
echo " /app/ → ${SPA}"
if [[ "${HEALTH}" == "ok" && "${SPA}" == "200" ]]; then
echo "✓ Deploy successful"
- ssh_cmd "command -v crontab >/dev/null && crontab -l 2>/dev/null | grep -q nexus-health-monitor" 2>/dev/null || \
- echo " 提示: 远程未安装巡检 cron,可在服务器执行 sudo nx cron"
+ if ssh_cmd "command -v crontab >/dev/null && crontab -l 2>/dev/null | grep -q nexus-health-monitor" 2>/dev/null; then
+ 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
echo "✗ Verification failed — check logs on server" >&2
exit 1
diff --git a/deploy/gate_log.jsonl b/deploy/gate_log.jsonl
index 5ec1cc69..285c1c7f 100644
--- a/deploy/gate_log.jsonl
+++ b/deploy/gate_log.jsonl
@@ -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":"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: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"}
diff --git a/deploy/health_monitor.sh b/deploy/health_monitor.sh
index 00b2ae3c..7cdb1bff 100644
--- a/deploy/health_monitor.sh
+++ b/deploy/health_monitor.sh
@@ -33,6 +33,35 @@ read_env_var() {
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() {
local cname="$1" dotenv
BOT_TOKEN=""
@@ -54,6 +83,8 @@ load_telegram_config() {
NEXUS_TELEGRAM_CHAT_ID=*) CHAT_ID="${line#NEXUS_TELEGRAM_CHAT_ID=}" ;;
esac
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
}
diff --git a/deploy/nexus-1panel.sh b/deploy/nexus-1panel.sh
index 391f4d6b..b5aa7d3c 100755
--- a/deploy/nexus-1panel.sh
+++ b/deploy/nexus-1panel.sh
@@ -363,6 +363,8 @@ keys = (
"NEXUS_ENCRYPTION_KEY",
"NEXUS_REDIS_URL",
"NEXUS_DATABASE_URL",
+ "NEXUS_TELEGRAM_BOT_TOKEN",
+ "NEXUS_TELEGRAM_CHAT_ID",
)
prod = {}
for line in env_file.read_text(encoding="utf-8").splitlines():
diff --git a/docs/audit/2026-06-07-ops-patrol-layer3-telegram.md b/docs/audit/2026-06-07-ops-patrol-layer3-telegram.md
new file mode 100644
index 00000000..da513daf
--- /dev/null
+++ b/docs/audit/2026-06-07-ops-patrol-layer3-telegram.md
@@ -0,0 +1,44 @@
+# 审计 — Layer 3 宿主机巡检 Telegram 打通
+
+## 范围
+
+| 文件 | 变更 |
+|------|------|
+| `server/infrastructure/env_file.py` | `.env` upsert/read(UTF-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 — 记录 warning;DB 回退保底 |
+| H5 | 敏感 Token 泄露至日志 | SAFE — 无 log token;审计仅写布尔标志 |
+
+## Closure
+
+| H | 判定 | 依据 |
+|---|------|------|
+| H1–H5 | 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`
diff --git a/docs/changelog/2026-06-07-ops-patrol-layer3-telegram.md b/docs/changelog/2026-06-07-ops-patrol-layer3-telegram.md
new file mode 100644
index 00000000..b908c826
--- /dev/null
+++ b/docs/changelog/2026-06-07-ops-patrol-layer3-telegram.md
@@ -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
+```
diff --git a/docs/design/plans/2026-06-07-ops-patrol-layer3-telegram.md b/docs/design/plans/2026-06-07-ops-patrol-layer3-telegram.md
new file mode 100644
index 00000000..8e219532
--- /dev/null
+++ b/docs/design/plans/2026-06-07-ops-patrol-layer3-telegram.md
@@ -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 配置
diff --git a/frontend/src/pages/SettingsPage.vue b/frontend/src/pages/SettingsPage.vue
index f17ab0e8..202262a2 100644
--- a/frontend/src/pages/SettingsPage.vue
+++ b/frontend/src/pages/SettingsPage.vue
@@ -99,6 +99,48 @@
@update:model-value="(v: boolean | null) => toggleNotify(item.key, Boolean(v))"
/>
+
+
+
Layer 3 宿主机巡检
+
+ 每分钟由宿主机 cron 执行 health_monitor.sh:连续 3 次 /health 失败则自动重启并推送 Telegram。
+ 配置保存后会自动同步至 .env;亦可手动同步。未同步时脚本会从数据库读取 Telegram 配置。
+
+
+
+ {{ patrolStatus?.telegram_layer3_ready ? '巡检 Telegram 就绪' : '请先配置 Telegram' }}
+
+
+ {{ patrolStatus.telegram_env_synced ? '.env 已同步' : '.env 未同步(可用 DB 回退)' }}
+
+
+
+
+ 同步巡检 Telegram
+
+
+ 刷新状态
+
+
+
+ 宿主机安装 cron:{{ patrolStatus.cron_install_command }} · 日志:{{ patrolStatus.health_log_path }}
+
@@ -330,6 +372,8 @@ import type {
SettingItem,
TelegramChatItem,
TelegramChatsResponse,
+ OpsPatrolStatusResponse,
+ OpsPatrolSyncResponse,
SubscriptionParseResponse,
IpAllowlistSaveRequest,
} from '@/types/api'
@@ -358,6 +402,9 @@ const showToken = ref(false)
const savingTelegram = ref(false)
const notifyToggles = ref>({})
const notifyToggleSaving = ref(null)
+const patrolStatus = ref(null)
+const patrolLoading = ref(false)
+const syncingPatrol = ref(false)
const notifyToggleGroups = computed(() => {
const groups: Record = {}
@@ -500,6 +547,38 @@ async function persistTelegramConfig(options?: { requireToken?: boolean; require
return true
}
+async function loadPatrolStatus() {
+ patrolLoading.value = true
+ try {
+ patrolStatus.value = await http.get('/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('/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() {
savingTelegram.value = true
try {
@@ -510,6 +589,7 @@ async function saveTelegramConfig() {
return
}
await persistTelegramConfig()
+ await loadPatrolStatus()
snackbar('Telegram 配置已保存')
} catch (e: unknown) {
snackbar(e instanceof Error ? e.message : '保存失败', 'error')
@@ -755,6 +835,7 @@ async function saveAllowlist() {
onMounted(() => {
loadSettings()
loadAllowlist()
+ loadPatrolStatus()
auth.fetchProfile()
})
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts
index 6e81392a..d804ec73 100644
--- a/frontend/src/types/api.ts
+++ b/frontend/src/types/api.ts
@@ -35,6 +35,30 @@ export interface TelegramChatsResponse {
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
+ env_status: OpsPatrolEnvStatus
+ telegram_layer3_ready: boolean
+}
+
/** Telegram notification toggle (settings key → label) */
export interface NotifyToggleItem {
key: string
diff --git a/server/api/settings.py b/server/api/settings.py
index 202ce713..98b6db41 100644
--- a/server/api/settings.py
+++ b/server/api/settings.py
@@ -377,6 +377,14 @@ async def set_setting(
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}
@@ -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 ──
async def _telegram_clear_webhook_if_set(client, base_url: str) -> None:
diff --git a/server/application/services/ops_patrol_sync.py b/server/application/services/ops_patrol_sync.py
new file mode 100644
index 00000000..75593d29
--- /dev/null
+++ b/server/application/services/ops_patrol_sync.py
@@ -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
diff --git a/server/infrastructure/env_file.py b/server/infrastructure/env_file.py
new file mode 100644
index 00000000..8fefade6
--- /dev/null
+++ b/server/infrastructure/env_file.py
@@ -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
diff --git a/tests/test_ops_patrol_sync.py b/tests/test_ops_patrol_sync.py
new file mode 100644
index 00000000..1b6d418e
--- /dev/null
+++ b/tests/test_ops_patrol_sync.py
@@ -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