Add SQLite login auth for monitor
This commit is contained in:
parent
395f72e8cb
commit
3144ec6013
362
monitor/auth.py
Normal file
362
monitor/auth.py
Normal 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()
|
||||
@ -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")),
|
||||
|
||||
@ -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
83
monitor/service_logs.py
Normal 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
317
monitor/ssh_remote.py
Normal 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
46
static/login.html
Normal 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
93
static/login.js
Normal 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user