Add SQLite login auth for monitor

This commit is contained in:
ZuoZuo 2026-04-23 01:14:03 +08:00
parent 395f72e8cb
commit 3144ec6013
7 changed files with 1263 additions and 9 deletions

362
monitor/auth.py Normal file
View File

@ -0,0 +1,362 @@
from __future__ import annotations
import hashlib
import hmac
import secrets
import sqlite3
import threading
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from http.cookies import SimpleCookie
from .config import (
AUTH_COOKIE_SECURE,
AUTH_DB_PATH,
AUTH_SESSION_COOKIE_NAME,
AUTH_SESSION_TTL_HOURS,
utc_now,
)
# 默认账号只在服务端本地初始化,避免出现在 README 或 .env 模板里。
BOOTSTRAP_USERNAME = "haiyi"
# 初始密码固定按需求内置,登录时由用户在页面手动输入。
BOOTSTRAP_PASSWORD = "haiyi@1234.com"
# 密码统一走 PBKDF2避免把明文写进本地数据库。
PASSWORD_HASH_ALGORITHM = "pbkdf2_sha256"
# 迭代次数拉高一些,降低弱口令被离线撞库的风险。
PASSWORD_HASH_ITERATIONS = 600_000
# Session token 使用 32 字节随机数,足够覆盖当前单机面板场景。
SESSION_TOKEN_BYTES = 32
# 初始化表结构时加锁,避免线程服务器并发首访时重复建表。
INIT_LOCK = threading.Lock()
# 进程内记一个初始化标记,减少每次请求都跑 schema 检查。
INITIALIZED = False
@dataclass(frozen=True)
class SessionRecord:
# 当前登录用户名。
username: str
# 会话过期时间,返回给前端展示或调试。
expires_at: str
# 原始时间戳,服务端判断是否过期时直接复用。
expires_at_ts: int
@dataclass(frozen=True)
class CreatedSession(SessionRecord):
# 登录成功后下发到 Cookie 的随机 token。
token: str
def ensure_auth_store() -> None:
global INITIALIZED
# 已初始化过且文件还在时,直接复用现有状态。
if INITIALIZED and AUTH_DB_PATH.exists():
return
with INIT_LOCK:
# 双重检查,避免多个线程排队后重复建表。
if INITIALIZED and AUTH_DB_PATH.exists():
return
# 先确保数据库目录存在SQLite 才能正常创建文件。
AUTH_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
with connect_db() as connection:
# 单机线程服务读多写少WAL 更适合并发读场景。
connection.execute("PRAGMA journal_mode = WAL")
# 用户表只保存账号和密码摘要。
connection.execute(
"""
CREATE TABLE IF NOT EXISTS users (
username TEXT PRIMARY KEY,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_login_at TEXT NOT NULL DEFAULT ''
)
"""
)
# 会话表按 token hash 存储,数据库泄漏时也不直接暴露真实 token。
connection.execute(
"""
CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY,
username TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at INTEGER NOT NULL,
remote_addr TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
FOREIGN KEY (username) REFERENCES users(username) ON DELETE CASCADE
)
"""
)
# 过期清理主要按 expires_at 查找,这里单独建索引。
connection.execute(
"""
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at
ON sessions(expires_at)
"""
)
# 默认账号只在缺失时自动补,避免覆盖已有密码。
bootstrap_user = connection.execute(
"SELECT username FROM users WHERE username = ?",
(BOOTSTRAP_USERNAME,),
).fetchone()
if bootstrap_user is None:
now = utc_now()
connection.execute(
"""
INSERT INTO users(username, password_hash, created_at, updated_at, last_login_at)
VALUES (?, ?, ?, ?, '')
""",
(
BOOTSTRAP_USERNAME,
hash_password(BOOTSTRAP_PASSWORD),
now,
now,
),
)
connection.commit()
# 只有建表和默认账号都准备好后,才把进程状态切成已初始化。
INITIALIZED = True
def connect_db() -> sqlite3.Connection:
# SQLite 每次请求单独建连接,避免线程间复用同一连接。
connection = sqlite3.connect(AUTH_DB_PATH, timeout=5.0)
# 结果行按列名读取,后续代码更直观。
connection.row_factory = sqlite3.Row
return connection
def hash_password(password: str, *, salt: str | None = None) -> str:
# 没传 salt 时生成新的随机盐值。
actual_salt = salt or secrets.token_hex(16)
# PBKDF2 输出二进制摘要,最后统一转十六进制文本保存。
digest = hashlib.pbkdf2_hmac(
"sha256",
password.encode("utf-8"),
actual_salt.encode("utf-8"),
PASSWORD_HASH_ITERATIONS,
).hex()
# 统一用固定格式拼接,校验时便于解析算法、轮数和盐值。
return f"{PASSWORD_HASH_ALGORITHM}${PASSWORD_HASH_ITERATIONS}${actual_salt}${digest}"
def verify_password(password: str, stored_hash: str) -> bool:
try:
# 历史格式不正确时直接按失败处理,避免抛内部异常到接口。
algorithm, raw_iterations, salt, expected_digest = stored_hash.split("$", 3)
except ValueError:
return False
# 当前版本只接受约定的 PBKDF2 格式。
if algorithm != PASSWORD_HASH_ALGORITHM:
return False
try:
# 迭代次数也从存储值里解析,后续如果升级策略兼容性更好。
iterations = int(raw_iterations)
except ValueError:
return False
actual_digest = hashlib.pbkdf2_hmac(
"sha256",
password.encode("utf-8"),
salt.encode("utf-8"),
iterations,
).hex()
# 结果比较走常量时间比对,减少时序侧信道。
return hmac.compare_digest(actual_digest, expected_digest)
def authenticate_user(username: str, password: str) -> str | None:
ensure_auth_store()
# 空账号或空密码都直接拒绝,避免继续查库。
if not username or not password:
return None
with connect_db() as connection:
# 当前账号只支持精确匹配,避免大小写模糊带来歧义。
row = connection.execute(
"SELECT username, password_hash FROM users WHERE username = ?",
(username,),
).fetchone()
# 用户不存在时返回空,后续统一走登录失败。
if row is None:
return None
# 密码摘要校验失败也返回空,不向外暴露更多信息。
if not verify_password(password, str(row["password_hash"])):
return None
# 认证成功时返回规范用户名。
return str(row["username"])
def create_session(username: str, *, remote_addr: str = "", user_agent: str = "") -> CreatedSession:
ensure_auth_store()
# 登录成功后立刻生成新的随机 token避免复用旧会话。
token = secrets.token_urlsafe(SESSION_TOKEN_BYTES)
# 真实 token 只放到 Cookie库里只保留它的哈希值。
token_hash = hash_token(token)
# 过期时间直接按小时配置换算成秒。
expires_at_ts = int(time.time()) + max(1, AUTH_SESSION_TTL_HOURS) * 3600
now = utc_now()
with connect_db() as connection:
# 每次登录前顺手清理一遍过期会话,避免本地库无限增长。
purge_expired_sessions(connection=connection)
# 新会话和来源信息一起落库,后续排查时能知道来源。
connection.execute(
"""
INSERT INTO sessions(token_hash, username, created_at, expires_at, remote_addr, user_agent)
VALUES (?, ?, ?, ?, ?, ?)
""",
(token_hash, username, now, expires_at_ts, remote_addr, user_agent),
)
# 登录成功时更新最后登录时间。
connection.execute(
"UPDATE users SET last_login_at = ? WHERE username = ?",
(now, username),
)
connection.commit()
# 把可直接写回前端的会话信息一起返回。
return CreatedSession(
username=username,
expires_at=format_timestamp(expires_at_ts),
expires_at_ts=expires_at_ts,
token=token,
)
def get_session(token: str) -> SessionRecord | None:
ensure_auth_store()
# 没有 token 时直接视为未登录。
if not token:
return None
now_ts = int(time.time())
token_hash = hash_token(token)
with connect_db() as connection:
# 先删掉已过期的历史会话,减少后续查询噪音。
purge_expired_sessions(connection=connection, now_ts=now_ts)
row = connection.execute(
"""
SELECT username, expires_at
FROM sessions
WHERE token_hash = ? AND expires_at > ?
""",
(token_hash, now_ts),
).fetchone()
connection.commit()
# 没查到时说明 token 不存在或已经过期。
if row is None:
return None
expires_at_ts = int(row["expires_at"])
return SessionRecord(
username=str(row["username"]),
expires_at=format_timestamp(expires_at_ts),
expires_at_ts=expires_at_ts,
)
def delete_session(token: str) -> None:
ensure_auth_store()
# 空 token 不需要继续删库。
if not token:
return
with connect_db() as connection:
# 删除不存在的 token 也视为成功,便于前端幂等退出。
connection.execute("DELETE FROM sessions WHERE token_hash = ?", (hash_token(token),))
connection.commit()
def purge_expired_sessions(*, connection: sqlite3.Connection | None = None, now_ts: int | None = None) -> None:
# 没传时间时按当前时间清理。
actual_now_ts = now_ts if now_ts is not None else int(time.time())
# 外部没传连接时自行开一个短连接完成清理。
if connection is None:
with connect_db() as owned_connection:
owned_connection.execute("DELETE FROM sessions WHERE expires_at <= ?", (actual_now_ts,))
owned_connection.commit()
return
# 复用外部事务时只执行删除,不在这里额外提交。
connection.execute("DELETE FROM sessions WHERE expires_at <= ?", (actual_now_ts,))
def hash_token(token: str) -> str:
# token 哈希统一走 SHA-256足够覆盖当前随机 token 长度。
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def parse_session_token(cookie_header: str | None) -> str:
# 没 Cookie 头时直接返回空。
if not cookie_header:
return ""
cookie = SimpleCookie()
try:
# 非法 Cookie 头统一按未登录处理。
cookie.load(cookie_header)
except Exception: # noqa: BLE001
return ""
morsel = cookie.get(AUTH_SESSION_COOKIE_NAME)
# 没找到目标 Cookie 时同样返回空串。
return morsel.value if morsel is not None else ""
def build_session_cookie(token: str, *, max_age: int) -> str:
# 登录成功后写入新的 HttpOnly Cookie。
return build_cookie_header(token, max_age=max_age)
def build_clear_session_cookie() -> str:
# 退出登录或 token 无效时,把 Cookie 直接清空。
return build_cookie_header("", max_age=0, expires="Thu, 01 Jan 1970 00:00:00 GMT")
def build_cookie_header(value: str, *, max_age: int, expires: str | None = None) -> str:
cookie = SimpleCookie()
cookie[AUTH_SESSION_COOKIE_NAME] = value
morsel = cookie[AUTH_SESSION_COOKIE_NAME]
# 所有页面都复用同一套登录态。
morsel["path"] = "/"
# 登录态不允许被前端脚本直接读取。
morsel["httponly"] = True
# 面板是同域使用Strict 可以把 CSRF 风险压低。
morsel["samesite"] = "Strict"
# 当前仍有 HTTP 直连场景,因此只在显式配置时才打开 Secure。
if AUTH_COOKIE_SECURE:
morsel["secure"] = True
# max-age 统一写秒数,浏览器过期处理更直接。
morsel["max-age"] = str(max_age)
# 需要立即清空时同时补 expires兼容性更稳。
if expires:
morsel["expires"] = expires
return morsel.OutputString()
def format_timestamp(timestamp: int) -> str:
# Session 返回给前端时统一输出 UTC ISO 时间。
return datetime.fromtimestamp(timestamp, timezone.utc).replace(microsecond=0).isoformat()

View File

@ -10,6 +10,8 @@ from .env import env_float, env_int, env_int_list, env_str, load_env_file
# 项目根目录。
ROOT = Path(__file__).resolve().parents[1]
# 运行期产物目录。
RUNTIME_DIR = ROOT / "run"
# 常见工作区根目录,便于本地开发时自动发现业务仓。
WORKSPACE_ROOT = ROOT.parent
# 静态资源目录。
@ -26,6 +28,14 @@ CONFIG_PATH = Path(env_str("TARGETS_FILE", str(ROOT / "config" / "targets.json")
APP_BIND = env_str("APP_BIND", "0.0.0.0") or "0.0.0.0"
# HTTP 服务监听端口。
APP_PORT = env_int("APP_PORT", 2026)
# 本地账号库统一落到 run 目录,避免写入版本库。
AUTH_DB_PATH = Path(env_str("AUTH_DB_PATH", str(RUNTIME_DIR / "auth.sqlite3"))).expanduser()
# 会话 Cookie 名称固定成单值,前后端都按这个名字取。
AUTH_SESSION_COOKIE_NAME = env_str("AUTH_SESSION_COOKIE_NAME", "hy_app_monitor_session") or "hy_app_monitor_session"
# 简单本地账号默认保活 24 小时,过期后重新登录。
AUTH_SESSION_TTL_HOURS = env_int("AUTH_SESSION_TTL_HOURS", 24)
# 部署在 HTTPS 反代后时,可通过 .env 把 Cookie 切到 Secure。
AUTH_COOKIE_SECURE = env_str("AUTH_COOKIE_SECURE", "0").lower() in {"1", "true", "yes", "on"}
# 服务健康检查超时时间。
PROBE_TIMEOUT_SECONDS = env_float("PROBE_TIMEOUT_SECONDS", 3.0)
@ -34,20 +44,43 @@ PROBE_CACHE_TTL_SECONDS = env_float("PROBE_CACHE_TTL_SECONDS", 5.0)
# 服务健康检查并发数。
PROBE_MAX_WORKERS = env_int("PROBE_MAX_WORKERS", 12)
# TAT 主机指标缓存秒数。
# 主机指标缓存秒数。
HOST_METRIC_CACHE_TTL_SECONDS = env_float("HOST_METRIC_CACHE_TTL_SECONDS", 25.0)
# TAT 远端命令超时时间。
TAT_METRIC_TIMEOUT_SECONDS = env_int("TAT_METRIC_TIMEOUT_SECONDS", 30)
# TAT CPU 采样间隔。
TAT_CPU_SAMPLE_SECONDS = env_float("TAT_CPU_SAMPLE_SECONDS", 1.0)
# 主机指标远端采集超时时间;兼容旧的 TAT_* 变量名。
HOST_METRIC_TIMEOUT_SECONDS = env_int("HOST_METRIC_TIMEOUT_SECONDS", env_int("TAT_METRIC_TIMEOUT_SECONDS", 30))
# 主机指标 CPU 采样间隔;兼容旧的 TAT_* 变量名。
HOST_METRIC_CPU_SAMPLE_SECONDS = env_float("HOST_METRIC_CPU_SAMPLE_SECONDS", env_float("TAT_CPU_SAMPLE_SECONDS", 1.0))
# 兼容旧代码里仍在引用的变量名。
TAT_METRIC_TIMEOUT_SECONDS = HOST_METRIC_TIMEOUT_SECONDS
TAT_CPU_SAMPLE_SECONDS = HOST_METRIC_CPU_SAMPLE_SECONDS
# 业务运行目录根路径。
RUNTIME_BASE_DIR = env_str("RUNTIME_BASE_DIR", "/opt/likei")
# 远端宿主机默认 SSH 用户。
SSH_USER = env_str("SSH_USER", "root") or "root"
# 非 root 用户时,允许统一在远端命令前补 sudo -n。
SSH_USE_SUDO = env_str("SSH_USE_SUDO", "0").lower() in {"1", "true", "yes", "on"}
# 远端宿主机默认 SSH 端口。
SSH_PORT = env_int("SSH_PORT", 22)
# SSH 建连超时秒数。
SSH_CONNECT_TIMEOUT_SECONDS = env_int("SSH_CONNECT_TIMEOUT_SECONDS", 10)
# SSH 严格主机检查策略,默认首次连接自动接收。
SSH_STRICT_HOST_KEY_CHECKING = env_str("SSH_STRICT_HOST_KEY_CHECKING", "accept-new") or "accept-new"
# 运行期默认使用 deploy 机项目目录下的专用私钥。
SSH_PRIVATE_KEY_PATH = Path(env_str("SSH_PRIVATE_KEY_PATH", str(ROOT / "run" / "ssh" / "id_ed25519"))).expanduser()
# 已知主机文件同样放到项目 run 目录,避免污染系统级 known_hosts。
SSH_KNOWN_HOSTS_PATH = Path(
env_str("SSH_KNOWN_HOSTS_PATH", str(ROOT / "run" / "ssh" / "known_hosts"))
).expanduser()
# 滚动重启单实例最大等待秒数。
ROLLING_RESTART_TIMEOUT_SECONDS = env_int("ROLLING_RESTART_TIMEOUT_SECONDS", 180)
# 滚动重启探测轮询间隔。
ROLLING_RESTART_POLL_SECONDS = env_float("ROLLING_RESTART_POLL_SECONDS", 2.0)
# 单实例恢复后额外稳定等待秒数。
ROLLING_RESTART_STABILIZE_SECONDS = env_float("ROLLING_RESTART_STABILIZE_SECONDS", 2.0)
# 日志查看固定读取最近多少条。
SERVICE_LOG_PREVIEW_LINES = env_int("SERVICE_LOG_PREVIEW_LINES", 1000)
# 日志导出最大允许条数,避免单次下载打爆页面进程。
SERVICE_LOG_EXPORT_MAX_LINES = env_int("SERVICE_LOG_EXPORT_MAX_LINES", 10000)
# 服务更新时本地构建最长等待秒数。
SERVICE_BUILD_TIMEOUT_SECONDS = env_int("SERVICE_BUILD_TIMEOUT_SECONDS", 3600)
# 服务更新时目标机预拉镜像最长等待秒数。
@ -59,7 +92,7 @@ JAVA_MAVEN_THREADS = env_str("JAVA_MAVEN_THREADS", "1C")
# Java 镜像 build/push 最大并行数。
JAVA_IMAGE_BUILD_MAX_WORKERS = env_int("JAVA_IMAGE_BUILD_MAX_WORKERS", 3)
# TAT 所在区域
# 腾讯云区域SSH 引导和带外脚本会复用
DEPLOY_REGION = env_str("DEPLOY_REGION", "me-saudi-arabia")
# 腾讯云 SecretId。
TENCENT_SECRET_ID = env_str("TENCENT_SECRET_ID", "")
@ -82,6 +115,17 @@ def env_or_candidate_path(env_key: str, candidates: list[Path]) -> Path:
return Path(override).expanduser()
return pick_existing_path(candidates)
def truthy(value: Any, default: bool = False) -> bool:
# 配置里布尔值可能来自 JSON 布尔或字符串,统一在这里规范化。
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
# 页面默认刷新间隔。
DEFAULT_REFRESH_INTERVAL_SECONDS = env_int("DEFAULT_REFRESH_INTERVAL_SECONDS", 10)
# 页面可选刷新间隔。
@ -122,6 +166,8 @@ BUILD_DOCKER_PLATFORM = env_str("BUILD_DOCKER_PLATFORM", "linux/amd64")
UPDATE_DEFAULT_JAVA_GIT_REF = env_str("UPDATE_DEFAULT_JAVA_GIT_REF", "main")
# Go 服务默认拉取的 Git ref。
UPDATE_DEFAULT_GO_GIT_REF = env_str("UPDATE_DEFAULT_GO_GIT_REF", "main")
# Admin 前端默认拉取的 Git ref。
UPDATE_DEFAULT_ADMIN_GIT_REF = env_str("UPDATE_DEFAULT_ADMIN_GIT_REF", "main")
# Java 仓库路径,默认兼容本地工作区和远端 deploy 机。
JAVA_REPO_ROOT = env_or_candidate_path(
"JAVA_REPO_ROOT",
@ -138,6 +184,70 @@ GOLANG_REPO_ROOT = env_or_candidate_path(
Path("/opt/deploy/sources/chatapp3-golang"),
],
)
# Admin 前端仓库地址;仓库缺失时允许按这里自动 clone。
ADMIN_FRONTEND_REPO_URL = env_str("ADMIN_FRONTEND_REPO_URL", "")
# Admin 前端仓库路径,默认兼容本地工作区和远端 deploy 机。
ADMIN_FRONTEND_REPO_ROOT = env_or_candidate_path(
"ADMIN_FRONTEND_REPO_ROOT",
[
WORKSPACE_ROOT / "chatapp3" / "chatapp3-admin",
Path("/opt/deploy/sources/chatapp3-admin"),
],
)
# Admin 前端在发布页里的服务名。
ADMIN_FRONTEND_SERVICE_NAME = env_str("ADMIN_FRONTEND_SERVICE_NAME", "admin").strip() or "admin"
# Admin 前端是否启用;未显式指定时,只要仓库和目标机信息齐备就自动开启。
ADMIN_FRONTEND_HOST = env_str("ADMIN_FRONTEND_HOST", "")
# Admin 前端目标机私网 IP。
ADMIN_FRONTEND_IP = env_str("ADMIN_FRONTEND_IP", "")
# Admin 前端目标机实例 ID主要用于 TAT 引导 SSH。
ADMIN_FRONTEND_INSTANCE_ID = env_str("ADMIN_FRONTEND_INSTANCE_ID", "")
# Admin 前端 SSH 主机,默认沿用私网 IP。
ADMIN_FRONTEND_SSH_HOST = env_str("ADMIN_FRONTEND_SSH_HOST", ADMIN_FRONTEND_IP).strip() or ADMIN_FRONTEND_IP
# Admin 前端 SSH 用户。
ADMIN_FRONTEND_SSH_USER = env_str("ADMIN_FRONTEND_SSH_USER", SSH_USER).strip() or SSH_USER
# Admin 前端 SSH 端口。
ADMIN_FRONTEND_SSH_PORT = env_int("ADMIN_FRONTEND_SSH_PORT", SSH_PORT)
# Admin 前端是否需要 sudo。
ADMIN_FRONTEND_SSH_USE_SUDO = env_str("ADMIN_FRONTEND_SSH_USE_SUDO", "0").lower() in {"1", "true", "yes", "on"}
# Admin 前端远端运行目录。
ADMIN_FRONTEND_REMOTE_DIR = env_str("ADMIN_FRONTEND_REMOTE_DIR", "/opt/yumi-admin/frontend-admin").strip() or "/opt/yumi-admin/frontend-admin"
# Admin 前端静态站点目录。
ADMIN_FRONTEND_SITE_DIR = (
env_str("ADMIN_FRONTEND_SITE_DIR", f"{ADMIN_FRONTEND_REMOTE_DIR.rstrip('/')}/site").strip()
or f"{ADMIN_FRONTEND_REMOTE_DIR.rstrip('/')}/site"
)
# Admin 前端 compose 文件路径。
ADMIN_FRONTEND_COMPOSE_FILE = (
env_str("ADMIN_FRONTEND_COMPOSE_FILE", f"{ADMIN_FRONTEND_REMOTE_DIR.rstrip('/')}/docker-compose.yml").strip()
or f"{ADMIN_FRONTEND_REMOTE_DIR.rstrip('/')}/docker-compose.yml"
)
# Admin 前端 compose 服务名。
ADMIN_FRONTEND_COMPOSE_SERVICE = env_str("ADMIN_FRONTEND_COMPOSE_SERVICE", "caddy").strip() or "caddy"
# Admin 前端当前发布元数据文件路径。
ADMIN_FRONTEND_RELEASE_META_PATH = (
env_str(
"ADMIN_FRONTEND_RELEASE_META_PATH",
f"{ADMIN_FRONTEND_REMOTE_DIR.rstrip('/')}/.hy-app-monitor-admin-release.json",
).strip()
or f"{ADMIN_FRONTEND_REMOTE_DIR.rstrip('/')}/.hy-app-monitor-admin-release.json"
)
# Admin 前端构建脚本。
ADMIN_FRONTEND_BUILD_SCRIPT = env_str("ADMIN_FRONTEND_BUILD_SCRIPT", "build:antdv-next").strip() or "build:antdv-next"
# Admin 前端构建产物目录。
ADMIN_FRONTEND_DIST_DIR = env_str("ADMIN_FRONTEND_DIST_DIR", "apps/dist").strip() or "apps/dist"
# Admin 前端对外访问地址,仅用于结果展示和排查。
ADMIN_FRONTEND_PUBLIC_URL = env_str("ADMIN_FRONTEND_PUBLIC_URL", "").strip()
# 未显式指定 ADMIN_FRONTEND_ENABLED 时,只要关键参数齐备就默认启用。
ADMIN_FRONTEND_ENABLED = truthy(
os.environ.get("ADMIN_FRONTEND_ENABLED"),
bool(
ADMIN_FRONTEND_SERVICE_NAME
and ADMIN_FRONTEND_HOST
and (ADMIN_FRONTEND_SSH_HOST or ADMIN_FRONTEND_IP)
and (ADMIN_FRONTEND_REPO_URL or str(ADMIN_FRONTEND_REPO_ROOT).strip())
),
)
# Mongo 连接串。
MONGO_URI = env_str("MONGO_URI", "")
@ -218,6 +328,10 @@ def load_hosts() -> list[dict[str, Any]]:
"group": str(host.get("group") or "").strip(),
"ip": str(host.get("ip") or "").strip(),
"instanceId": str(host.get("instanceId") or "").strip(),
"sshHost": str(host.get("sshHost") or host.get("ip") or "").strip(),
"sshUser": str(host.get("sshUser") or SSH_USER).strip() or SSH_USER,
"sshUseSudo": truthy(host.get("sshUseSudo"), SSH_USE_SUDO),
"sshPort": int(host.get("sshPort") or SSH_PORT),
"services": host.get("services") or [],
}
)
@ -239,6 +353,10 @@ def flatten_services(hosts: list[dict[str, Any]]) -> list[dict[str, Any]]:
"group": host["group"],
"ip": host["ip"],
"instanceId": host["instanceId"],
"sshHost": host["sshHost"],
"sshUser": host["sshUser"],
"sshUseSudo": truthy(host.get("sshUseSudo"), SSH_USE_SUDO),
"sshPort": int(host["sshPort"]),
"service": str(service.get("name") or "").strip(),
"kind": str(service.get("kind") or "java").strip(),
"port": int(service.get("port")),

View File

@ -1,11 +1,22 @@
from __future__ import annotations
import json
import time
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
from urllib.parse import parse_qs, urlparse
from .auth import (
build_clear_session_cookie,
build_session_cookie,
create_session,
delete_session,
ensure_auth_store,
get_session,
parse_session_token,
authenticate_user,
)
from .config import APP_BIND, APP_PORT, STATIC_DIR, utc_now
from .dashboard import build_monitor_payload
from .nacos import (
@ -15,6 +26,7 @@ from .nacos import (
validate_nacos_config_payload,
)
from .runtime_config import get_go_config_payload, save_go_config_payload, validate_go_config_payload
from .service_logs import get_service_log_export_payload, get_service_log_preview_payload
from .service_release import build_update_targets_payload
from .service_update_jobs import get_update_job_log_text, get_update_job_payload, start_update_job
from .service_ops import build_restart_targets_payload, rolling_restart_payload
@ -33,6 +45,18 @@ class MonitorHandler(BaseHTTPRequestHandler):
# 关闭默认访问日志,避免轮询刷屏。
return
def current_session_token(self) -> str:
# 同一个请求对象里只解析一次 Cookie。
if not hasattr(self, "_cached_session_token"):
self._cached_session_token = parse_session_token(self.headers.get("Cookie"))
return str(self._cached_session_token)
def current_session(self) -> Any:
# 同一个请求对象里也只查一次会话库。
if not hasattr(self, "_cached_session"):
self._cached_session = get_session(self.current_session_token())
return self._cached_session
def do_GET(self) -> None: # noqa: N802
# GET 请求需要响应头和响应体。
self.dispatch(send_body=True)
@ -51,14 +75,26 @@ class MonitorHandler(BaseHTTPRequestHandler):
# 顺手解析 query 参数,后面接口直接取。
query = parse_qs(parsed.query, keep_blank_values=True)
# 首页返回静态 HTML。
# 登录页对未登录用户开放,已登录时直接回总览。
if parsed.path in {"/login", "/login.html"}:
if self.current_session():
return self.redirect("/", send_body=send_body)
return self.serve_static("login.html", "text/html; charset=utf-8", send_body=send_body)
# 总览页需要先过登录校验,未登录统一跳到登录页。
if parsed.path in {"/", "/index.html"}:
if not self.current_session():
return self.redirect("/login", send_body=send_body)
return self.serve_static("index.html", "text/html; charset=utf-8", send_body=send_body)
# CSS 文件。
if parsed.path == "/app.css":
return self.serve_static("app.css", "text/css; charset=utf-8", send_body=send_body)
# 登录页脚本。
if parsed.path == "/login.js":
return self.serve_static("login.js", "application/javascript; charset=utf-8", send_body=send_body)
# 前端业务 JS。
if parsed.path == "/app.js":
return self.serve_static("app.js", "application/javascript; charset=utf-8", send_body=send_body)
@ -67,6 +103,14 @@ class MonitorHandler(BaseHTTPRequestHandler):
if parsed.path == "/vue.js":
return self.serve_static("vue.js", "application/javascript; charset=utf-8", send_body=send_body)
# 会话查询接口单独开放给登录页和主页面做状态判断。
if parsed.path == "/api/auth/session":
return self.handle_auth_session(send_body=send_body)
# 除鉴权接口外,其余 API 一律要求带有效会话。
if parsed.path.startswith("/api/") and not self.require_authentication(send_body=send_body):
return
# 轻量健康检查。
if parsed.path == "/api/health":
return self.send_payload(
@ -213,6 +257,61 @@ class MonitorHandler(BaseHTTPRequestHandler):
send_body=send_body,
)
# 返回单个服务实例最近 1000 条容器日志。
if parsed.path == "/api/ops/service-log":
service_name = self.query_value(query, "service")
host_name = self.query_value(query, "host")
if not service_name or not host_name:
return self.send_error_payload(
HTTPStatus.BAD_REQUEST,
"service and host are required",
send_body=send_body,
)
try:
payload = get_service_log_preview_payload(service_name, host_name)
except KeyError:
return self.send_error_payload(HTTPStatus.NOT_FOUND, "service log target not found", send_body=send_body)
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
return self.send_payload(
HTTPStatus.OK,
response_json(payload),
"application/json; charset=utf-8",
send_body=send_body,
)
# 导出单个服务实例指定条数的容器日志。
if parsed.path == "/api/ops/service-log/export":
service_name = self.query_value(query, "service")
host_name = self.query_value(query, "host")
lines = self.query_value(query, "lines")
if not service_name or not host_name:
return self.send_error_payload(
HTTPStatus.BAD_REQUEST,
"service and host are required",
send_body=send_body,
)
try:
meta, content = get_service_log_export_payload(service_name, host_name, lines)
except KeyError:
return self.send_error_payload(HTTPStatus.NOT_FOUND, "service log target not found", send_body=send_body)
except ValueError as exc:
return self.send_error_payload(HTTPStatus.BAD_REQUEST, str(exc), send_body=send_body)
except Exception as exc: # noqa: BLE001
return self.send_error_payload(HTTPStatus.BAD_GATEWAY, str(exc), send_body=send_body)
filename = f"{meta['service']}-{meta['host']}-{meta['lines']}.log"
return self.send_payload(
HTTPStatus.OK,
content.encode("utf-8"),
"text/plain; charset=utf-8",
send_body=send_body,
extra_headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# favicon 直接返回空。
if parsed.path == "/favicon.ico":
return self.send_payload(HTTPStatus.NO_CONTENT, b"", "image/x-icon", send_body=send_body)
@ -224,6 +323,22 @@ class MonitorHandler(BaseHTTPRequestHandler):
# 只解析 path 段POST 不处理复杂 query。
parsed = urlparse(self.path)
# 退出接口允许空请求体,先单独处理。
if parsed.path == "/api/auth/logout":
return self.handle_logout()
# 登录接口对未登录用户开放,但要求标准 JSON 请求体。
if parsed.path == "/api/auth/login":
try:
payload = self.read_json_body()
except ValueError as exc:
return self.send_error_payload(HTTPStatus.BAD_REQUEST, str(exc), send_body=True)
return self.handle_login(payload)
# 其余 POST 接口都要求先通过会话校验。
if not self.require_authentication(send_body=True):
return
try:
payload = self.read_json_body()
except ValueError as exc:
@ -249,6 +364,105 @@ class MonitorHandler(BaseHTTPRequestHandler):
return self.send_error_payload(HTTPStatus.NOT_FOUND, "not found", send_body=True)
def handle_auth_session(self, send_body: bool) -> None:
session = self.current_session()
# 未登录时返回 401前端据此回到登录页。
if session is None:
return self.send_error_payload(
HTTPStatus.UNAUTHORIZED,
"authentication required",
send_body=send_body,
extra_headers=self.auth_cookie_cleanup_headers(),
)
return self.send_payload(
HTTPStatus.OK,
response_json(self.session_payload(session)),
"application/json; charset=utf-8",
send_body=send_body,
)
def handle_login(self, payload: dict[str, Any]) -> None:
username = str(payload.get("username") or "").strip()
password = payload.get("password")
# 登录接口要求账号密码都以字符串传入。
if not isinstance(password, str):
return self.send_error_payload(HTTPStatus.BAD_REQUEST, "password must be a string", send_body=True)
canonical_username = authenticate_user(username, password)
if not canonical_username:
return self.send_error_payload(HTTPStatus.UNAUTHORIZED, "invalid username or password", send_body=True)
# 同一浏览器重复登录时,先清掉旧 token 对应会话。
delete_session(self.current_session_token())
session = create_session(
canonical_username,
remote_addr=self.client_address[0] if self.client_address else "",
user_agent=self.headers.get("User-Agent", ""),
)
cookie_header = build_session_cookie(
session.token,
max_age=max(1, session.expires_at_ts - int(time.time())),
)
return self.send_payload(
HTTPStatus.OK,
response_json(self.session_payload(session)),
"application/json; charset=utf-8",
send_body=True,
extra_headers={"Set-Cookie": cookie_header},
)
def handle_logout(self) -> None:
# 退出时带不带当前 Cookie 都按成功处理,方便前端幂等调用。
delete_session(self.current_session_token())
return self.send_payload(
HTTPStatus.OK,
response_json({"ok": True}),
"application/json; charset=utf-8",
send_body=True,
extra_headers={"Set-Cookie": build_clear_session_cookie()},
)
def session_payload(self, session: Any) -> dict[str, Any]:
# 当前只给前端暴露最小必要字段。
return {
"authenticated": True,
"username": session.username,
"expiresAt": session.expires_at,
}
def require_authentication(self, send_body: bool) -> bool:
# 查到有效会话时,当前请求可以继续执行。
if self.current_session() is not None:
return True
# token 缺失或失效时统一打回 401。
self.send_error_payload(
HTTPStatus.UNAUTHORIZED,
"authentication required",
send_body=send_body,
extra_headers=self.auth_cookie_cleanup_headers(),
)
return False
def auth_cookie_cleanup_headers(self) -> dict[str, str] | None:
# 只有请求里真的带了旧 token才下发清 Cookie 头。
if not self.current_session_token():
return None
return {"Set-Cookie": build_clear_session_cookie()}
def redirect(self, location: str, *, send_body: bool) -> None:
# 页面跳转只依赖 Location响应体保持空即可。
self.send_payload(
HTTPStatus.FOUND,
b"",
"text/plain; charset=utf-8",
send_body=send_body,
extra_headers={"Location": location},
)
def handle_nacos_validate(self, payload: dict[str, Any]) -> None:
group_name = str(payload.get("group") or "").strip()
data_id = str(payload.get("dataId") or "").strip()
@ -421,22 +635,41 @@ class MonitorHandler(BaseHTTPRequestHandler):
# 文件存在时返回原始二进制内容。
self.send_payload(HTTPStatus.OK, path.read_bytes(), content_type, send_body=send_body)
def send_error_payload(self, status: HTTPStatus, message: str, send_body: bool) -> None:
def send_error_payload(
self,
status: HTTPStatus,
message: str,
send_body: bool,
*,
extra_headers: dict[str, str] | None = None,
) -> None:
# 错误响应也统一走 JSON。
self.send_payload(
status,
response_json({"status": int(status), "error": message}),
"application/json; charset=utf-8",
send_body=send_body,
extra_headers=extra_headers,
)
def send_payload(self, status: HTTPStatus, body: bytes, content_type: str, send_body: bool) -> None:
def send_payload(
self,
status: HTTPStatus,
body: bytes,
content_type: str,
send_body: bool,
*,
extra_headers: dict[str, str] | None = None,
) -> None:
# 写状态码。
self.send_response(status)
# 写内容类型。
self.send_header("Content-Type", content_type)
# 页面和接口都禁用缓存。
self.send_header("Cache-Control", "no-store")
# 允许调用方补充下载文件名等额外头。
for key, value in (extra_headers or {}).items():
self.send_header(key, value)
# 写入长度,便于 HEAD 请求工作正常。
self.send_header("Content-Length", str(len(body)))
# 结束响应头。
@ -448,6 +681,8 @@ class MonitorHandler(BaseHTTPRequestHandler):
def main() -> None:
# 启动前先把账号库和默认账号准备好。
ensure_auth_store()
# 创建线程型 HTTP 服务。
server = ThreadingHTTPServer((APP_BIND, APP_PORT), MonitorHandler)
# 打印监听地址便于排查。

83
monitor/service_logs.py Normal file
View File

@ -0,0 +1,83 @@
from __future__ import annotations
# 容器日志命令里需要安全拼接容器名。
import shlex
from typing import Any
from .compose_templates import container_name
from .config import SERVICE_LOG_EXPORT_MAX_LINES, SERVICE_LOG_PREVIEW_LINES, flatten_services, load_hosts, utc_now
from .ssh_remote import run_host_script
def loggable_service_records() -> list[dict[str, Any]]:
# 当前日志查看只开放业务服务实例,不扩展到 nacos / mongo 这类非业务容器。
return [service for service in flatten_services(load_hosts()) if service.get("kind") in {"java", "golang"}]
def find_service_record(service_name: str, host_name: str) -> dict[str, Any]:
# 日志查看必须定位到单实例,避免多机部署时把不同节点的日志混到一起。
normalized_service = str(service_name or "").strip()
normalized_host = str(host_name or "").strip()
for service in loggable_service_records():
if service.get("service") == normalized_service and service.get("host") == normalized_host:
return service
raise KeyError(f"service instance not found: {normalized_service}@{normalized_host}")
def normalize_export_lines(raw_value: str | int | None) -> int:
# 导出条数允许用户自定义,但要限制上限,避免一次把过多日志塞进页面进程。
if raw_value in {None, ""}:
return SERVICE_LOG_PREVIEW_LINES
value = int(raw_value)
if value <= 0:
raise ValueError("lines must be greater than 0")
return min(value, SERVICE_LOG_EXPORT_MAX_LINES)
def fetch_service_log_text(service_name: str, host_name: str, lines: int) -> tuple[dict[str, Any], str]:
# 统一执行 docker logs并返回实例元数据给接口层复用。
service = find_service_record(service_name, host_name)
container = container_name(service["host"], service["service"])
script = f"""set -eu
docker logs --timestamps --tail {int(lines)} {shlex.quote(container)} 2>&1
"""
result = run_host_script(
service,
script,
command_name=f"service-log-{service['host']}-{service['service']}",
timeout_seconds=120,
)
if result.get("status") != "SUCCESS":
raise RuntimeError(str(result.get("output") or result.get("status") or "docker logs failed").strip())
return (
{
"service": service["service"],
"host": service["host"],
"ip": service["ip"],
"kind": service["kind"],
"container": container,
"updatedAt": utc_now(),
"lines": int(lines),
},
str(result.get("output") or ""),
)
def get_service_log_preview_payload(service_name: str, host_name: str) -> dict[str, Any]:
# 页面预览固定最近 1000 条,避免日志面板过重。
meta, content = fetch_service_log_text(service_name, host_name, SERVICE_LOG_PREVIEW_LINES)
return {
"ok": True,
**meta,
"content": content,
"previewLines": SERVICE_LOG_PREVIEW_LINES,
"exportMaxLines": SERVICE_LOG_EXPORT_MAX_LINES,
}
def get_service_log_export_payload(service_name: str, host_name: str, raw_lines: str | int | None) -> tuple[dict[str, Any], str]:
# 导出接口允许指定条数,但会按统一上限裁剪。
lines = normalize_export_lines(raw_lines)
meta, content = fetch_service_log_text(service_name, host_name, lines)
return meta, content

317
monitor/ssh_remote.py Normal file
View File

@ -0,0 +1,317 @@
from __future__ import annotations
# 远端文本读写统一走 base64避免 shell 转义和编码问题。
import base64
# 远端脚本里会拼接路径参数,需要显式转义。
import shlex
from pathlib import Path
# SSH / SCP 都直接复用系统 OpenSSH 客户端。
import subprocess
from typing import Any
from .config import (
SSH_CONNECT_TIMEOUT_SECONDS,
SSH_KNOWN_HOSTS_PATH,
SSH_PORT,
SSH_PRIVATE_KEY_PATH,
SSH_STRICT_HOST_KEY_CHECKING,
SSH_USER,
SSH_USE_SUDO,
load_hosts,
utc_now,
)
# 远端文件缺失时统一打印这个标记,避免和普通命令输出混淆。
REMOTE_FILE_MISSING_MARKER = "__HY_APP_MONITOR_SSH_FILE_MISSING__"
def merged_host_record(host: dict[str, Any]) -> dict[str, Any]:
# 某些调用方只传 host 名,先尽量回填配置里的 IP / SSH 字段。
candidate = dict(host or {})
if candidate.get("ip") or candidate.get("sshHost"):
return candidate
host_name = str(candidate.get("host") or "").strip()
if not host_name:
return candidate
for item in load_hosts():
if item.get("host") != host_name:
continue
merged = dict(item)
merged.update({key: value for key, value in candidate.items() if value not in {"", None}})
return merged
return candidate
def ensure_ssh_runtime_files() -> None:
# known_hosts 放到项目 run 目录,首次连接时也能自动落盘。
SSH_KNOWN_HOSTS_PATH.parent.mkdir(parents=True, exist_ok=True)
if not SSH_KNOWN_HOSTS_PATH.exists():
SSH_KNOWN_HOSTS_PATH.touch()
def require_private_key() -> Path:
# SSH 主链路必须拿到 deploy 机上的专用私钥。
key_path = Path(SSH_PRIVATE_KEY_PATH).expanduser()
if not key_path.exists():
raise RuntimeError(
f"missing ssh private key: {key_path} ; run ops/bootstrap_ssh_via_tat.py on the deploy chain first"
)
return key_path
def host_ssh_user(host: dict[str, Any]) -> str:
# 主机可单独覆盖 SSH 用户,未配置时走全局默认值。
merged = merged_host_record(host)
return str(merged.get("sshUser") or SSH_USER).strip() or SSH_USER
def host_ssh_host(host: dict[str, Any]) -> str:
# 优先取显式 sshHost再回退到业务 IP。
merged = merged_host_record(host)
target = str(merged.get("sshHost") or merged.get("ip") or "").strip()
if not target:
raise RuntimeError(f"missing ssh host for {merged.get('host') or 'unknown host'}")
return target
def host_ssh_port(host: dict[str, Any]) -> int:
# 端口同样允许按主机覆写。
merged = merged_host_record(host)
return int(merged.get("sshPort") or SSH_PORT)
def host_ssh_use_sudo(host: dict[str, Any]) -> bool:
# root 登录时通常不需要 sudo非 root 用户时可以通过配置统一启用 sudo -n。
merged = merged_host_record(host)
value = merged.get("sshUseSudo")
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return SSH_USE_SUDO
def target_display(host: dict[str, Any]) -> str:
# 错误信息里统一带上 user@host:port排查更直接。
return f"{host_ssh_user(host)}@{host_ssh_host(host)}:{host_ssh_port(host)}"
def ssh_base_args(host: dict[str, Any]) -> list[str]:
# 所有 SSH 命令统一带上专用 key、known_hosts 和超时配置。
ensure_ssh_runtime_files()
key_path = require_private_key()
return [
"ssh",
"-i",
str(key_path),
"-p",
str(host_ssh_port(host)),
"-o",
"BatchMode=yes",
"-o",
f"ConnectTimeout={SSH_CONNECT_TIMEOUT_SECONDS}",
"-o",
"ServerAliveInterval=15",
"-o",
"ServerAliveCountMax=3",
"-o",
f"StrictHostKeyChecking={SSH_STRICT_HOST_KEY_CHECKING}",
"-o",
f"UserKnownHostsFile={SSH_KNOWN_HOSTS_PATH}",
f"{host_ssh_user(host)}@{host_ssh_host(host)}",
]
def scp_base_args(host: dict[str, Any]) -> list[str]:
# SCP 也沿用同一套 key、known_hosts 和端口配置。
ensure_ssh_runtime_files()
key_path = require_private_key()
return [
"-i",
str(key_path),
"-P",
str(host_ssh_port(host)),
"-o",
"BatchMode=yes",
"-o",
f"ConnectTimeout={SSH_CONNECT_TIMEOUT_SECONDS}",
"-o",
f"StrictHostKeyChecking={SSH_STRICT_HOST_KEY_CHECKING}",
"-o",
f"UserKnownHostsFile={SSH_KNOWN_HOSTS_PATH}",
]
def run_host_script(
host: dict[str, Any],
script: str,
*,
command_name: str,
timeout_seconds: int,
) -> dict[str, Any]:
# 所有远端动作都通过 bash -se 执行stdin 直接传脚本可以避开复杂转义。
remote_shell = ["bash", "-se"]
if host_ssh_use_sudo(host):
remote_shell = ["sudo", "-n", *remote_shell]
args = [*ssh_base_args(host), *remote_shell]
try:
result = subprocess.run(
args,
input=script,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
timeout=timeout_seconds,
check=False,
)
except subprocess.TimeoutExpired as exc:
# 超时时仍把已有输出保留下来,页面和日志更容易定位卡在哪一步。
output = (exc.stdout or "") if isinstance(exc.stdout, str) else ""
return {
"status": "TIMEOUT",
"output": output,
"updatedAt": utc_now(),
"commandName": command_name,
"target": target_display(host),
}
except FileNotFoundError as exc:
raise RuntimeError("ssh command not found in PATH") from exc
output = str(result.stdout or "")
status = "SUCCESS" if result.returncode == 0 else "FAILED"
if status != "SUCCESS" and not output.strip():
output = f"{command_name} failed on {target_display(host)}: exit {result.returncode}"
return {
"status": status,
"output": output,
"updatedAt": utc_now(),
"commandName": command_name,
"target": target_display(host),
}
def run_host_script_checked(
host: dict[str, Any],
script: str,
*,
command_name: str,
timeout_seconds: int,
) -> dict[str, Any]:
# 需要强失败语义的调用方统一走这里,避免每个模块重复判断状态码。
result = run_host_script(host, script, command_name=command_name, timeout_seconds=timeout_seconds)
if result.get("status") != "SUCCESS":
detail = str(result.get("output") or result.get("status") or "remote command failed").strip()
raise RuntimeError(f"{command_name} failed on {target_display(host)}: {detail}")
return result
def read_remote_text(host: dict[str, Any], path: str, *, timeout_seconds: int = 60) -> str:
# 文本读取直接在远端整体 base64 后返回,本项目里的 env / compose 文件体积都足够小。
quoted_path = shlex.quote(path)
script = f"""set -eu
FILE={quoted_path}
if [ ! -f "$FILE" ]; then
printf '%s' {shlex.quote(REMOTE_FILE_MISSING_MARKER)}
exit 0
fi
python3 - "$FILE" <<'PY'
from pathlib import Path
import base64
import sys
print(base64.b64encode(Path(sys.argv[1]).read_bytes()).decode("ascii"), end="")
PY
"""
result = run_host_script_checked(host, script, command_name=f"read-{merged_host_record(host).get('host') or 'remote'}", timeout_seconds=timeout_seconds)
output = str(result.get("output") or "").strip()
if output == REMOTE_FILE_MISSING_MARKER:
raise RuntimeError(f"missing remote file on {target_display(host)}: {path}")
if not output:
return ""
return base64.b64decode(output).decode("utf-8")
def write_remote_text(host: dict[str, Any], path: str, content: str, *, timeout_seconds: int = 60) -> None:
# 写文件时先落临时文件再 mv避免中途失败把线上文件写坏。
encoded = base64.b64encode(content.encode("utf-8")).decode("ascii")
quoted_path = shlex.quote(path)
script = f"""set -eu
FILE={quoted_path}
TMP="${{FILE}}.hyapp.$$"
mkdir -p "$(dirname "$FILE")"
printf '%s' {shlex.quote(encoded)} | base64 -d > "$TMP"
mv "$TMP" "$FILE"
"""
run_host_script_checked(
host,
script,
command_name=f"write-{merged_host_record(host).get('host') or 'remote'}",
timeout_seconds=timeout_seconds,
)
def copy_remote_file(host: dict[str, Any], source_path: str, target_path: str, *, timeout_seconds: int = 60) -> None:
# 线上 compose 备份直接在远端复制即可,不需要再把原文件拉回本机。
script = f"""set -eu
SOURCE={shlex.quote(source_path)}
TARGET={shlex.quote(target_path)}
if [ ! -f "$SOURCE" ]; then
printf '%s' {shlex.quote(REMOTE_FILE_MISSING_MARKER)}
exit 0
fi
mkdir -p "$(dirname "$TARGET")"
cp "$SOURCE" "$TARGET"
"""
result = run_host_script_checked(
host,
script,
command_name=f"copy-{merged_host_record(host).get('host') or 'remote'}",
timeout_seconds=timeout_seconds,
)
if str(result.get("output") or "").strip() == REMOTE_FILE_MISSING_MARKER:
raise RuntimeError(f"missing remote file on {target_display(host)}: {source_path}")
def copy_local_path_to_host(
host: dict[str, Any],
source_path: str | Path,
target_path: str,
*,
recursive: bool = False,
timeout_seconds: int = 120,
) -> None:
# Admin 前端发布会把本地 dist 目录直接传到远端临时目录。
args = ["scp", *scp_base_args(host)]
if recursive:
args.append("-r")
args.extend(
[
str(source_path),
f"{host_ssh_user(host)}@{host_ssh_host(host)}:{target_path}",
]
)
try:
result = subprocess.run(
args,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
timeout=timeout_seconds,
check=False,
)
except subprocess.TimeoutExpired as exc:
output = (exc.stdout or "") if isinstance(exc.stdout, str) else ""
raise RuntimeError(f"scp timeout on {target_display(host)}: {output.strip() or source_path}") from exc
except FileNotFoundError as exc:
raise RuntimeError("scp command not found in PATH") from exc
if result.returncode != 0:
detail = str(result.stdout or "").strip()
raise RuntimeError(f"scp failed on {target_display(host)}: {detail or f'exit {result.returncode}'}")

46
static/login.html Normal file
View File

@ -0,0 +1,46 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HY App Monitor Login</title>
<link rel="stylesheet" href="/app.css" />
<script defer src="/login.js"></script>
</head>
<body class="auth-page">
<div id="boot-indicator" class="boot-indicator">检查登录态...</div>
<noscript>
<div class="boot-indicator">请启用 JavaScript</div>
</noscript>
<main id="login-root" class="auth-shell" hidden>
<section class="auth-panel">
<div class="auth-panel-head">
<div class="brand-row">
<p class="brand-mark">HY APP MONITOR</p>
<span class="env-pill">DEPLOY</span>
</div>
<h1 class="auth-title">登录</h1>
</div>
<form id="login-form" class="auth-form" novalidate>
<label class="auth-field">
<span>账号</span>
<input id="username" name="username" class="auth-input" type="text" autocomplete="username" required />
</label>
<label class="auth-field">
<span>密码</span>
<input id="password" name="password" class="auth-input" type="password" autocomplete="current-password" required />
</label>
<p id="login-error" class="auth-error" hidden aria-live="polite"></p>
<button id="login-submit" class="refresh auth-submit" type="submit">登录</button>
</form>
</section>
</main>
</body>
</html>

93
static/login.js Normal file
View File

@ -0,0 +1,93 @@
async function readJsonResponse(response) {
const text = await response.text();
if (!text) {
return {};
}
try {
return JSON.parse(text);
} catch (error) {
throw new Error(text);
}
}
window.addEventListener("DOMContentLoaded", async () => {
const bootIndicator = document.getElementById("boot-indicator");
const root = document.getElementById("login-root");
const form = document.getElementById("login-form");
const usernameInput = document.getElementById("username");
const passwordInput = document.getElementById("password");
const submitButton = document.getElementById("login-submit");
const errorNode = document.getElementById("login-error");
const showForm = () => {
if (bootIndicator) {
bootIndicator.hidden = true;
}
if (root) {
root.hidden = false;
}
};
const setError = (message) => {
if (!errorNode) {
return;
}
errorNode.textContent = message || "";
errorNode.hidden = !message;
};
try {
const response = await fetch("/api/auth/session", {
cache: "no-store",
credentials: "same-origin",
});
if (response.ok) {
window.location.replace("/");
return;
}
if (response.status !== 401) {
const payload = await readJsonResponse(response);
setError(payload.error || `HTTP ${response.status}`);
}
} catch (error) {
setError(error instanceof Error ? error.message : String(error));
} finally {
showForm();
}
if (!form || !(usernameInput instanceof HTMLInputElement) || !(passwordInput instanceof HTMLInputElement)) {
return;
}
usernameInput.focus();
form.addEventListener("submit", async (event) => {
event.preventDefault();
setError("");
submitButton.disabled = true;
try {
const response = await fetch("/api/auth/login", {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username: usernameInput.value.trim(),
password: passwordInput.value,
}),
});
const payload = await readJsonResponse(response);
if (!response.ok) {
throw new Error(payload.error || `HTTP ${response.status}`);
}
window.location.replace("/");
} catch (error) {
setError(error instanceof Error ? error.message : String(error));
} finally {
submitButton.disabled = false;
}
});
});