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