HY APP MONITOR
+ DEPLOY +diff --git a/monitor/auth.py b/monitor/auth.py new file mode 100644 index 0000000..c0f0155 --- /dev/null +++ b/monitor/auth.py @@ -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() diff --git a/monitor/config.py b/monitor/config.py index 5ff5fd3..764ce03 100644 --- a/monitor/config.py +++ b/monitor/config.py @@ -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")), diff --git a/monitor/http_app.py b/monitor/http_app.py index 6a7aaa8..268d7f4 100644 --- a/monitor/http_app.py +++ b/monitor/http_app.py @@ -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) # 打印监听地址便于排查。 diff --git a/monitor/service_logs.py b/monitor/service_logs.py new file mode 100644 index 0000000..31f1ecb --- /dev/null +++ b/monitor/service_logs.py @@ -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 diff --git a/monitor/ssh_remote.py b/monitor/ssh_remote.py new file mode 100644 index 0000000..24e9ea3 --- /dev/null +++ b/monitor/ssh_remote.py @@ -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}'}") diff --git a/static/login.html b/static/login.html new file mode 100644 index 0000000..66fafd6 --- /dev/null +++ b/static/login.html @@ -0,0 +1,46 @@ + + + +
+ + +HY APP MONITOR
+ DEPLOY +