""" pipeline_service/deploy_account.py - 应用部署主机逻辑账号系统 每个平台用户默认拥有一个逻辑部署账号(ag_ 前缀),实现: 1. 隔离部署目录(零 root,不建真实 Linux 账号) 2. 免密访问(部署进程直接文件系统读写,无需 SSH/密码) 3. bwrap 沙箱执行(user namespace 隔离文件/进程/IPC/UTS) 4. 特权能力(沙箱内 root-like 映射,受 user namespace 约束) 全程零 root:运行时不 sudo、不 useradd,仅依赖 bwrap(unprivileged user namespaces)。 """ import os import re import json import shutil import asyncio import logging logger = logging.getLogger("pipeline.deploy_account") # 部署账号根目录 DEPLOY_ENV_BASE = "/d/pipeline/deploy_envs" # bwrap 可执行文件(未安装时为 None,降级为目录隔离) # 检测顺序:系统 PATH → 环境变量 → 已知安装位置 BWRAP = shutil.which("bwrap") for _p in ( os.environ.get("BWRAP_PATH", ""), "/d/pipeline/bin/bwrap", "/d/pipeline/pipeline-app/bin/bwrap", ): if not BWRAP and _p and os.path.exists(_p): BWRAP = _p # 账号名合法字符(目录安全,防路径穿越) _SAFE_RE = re.compile(r"[^a-zA-Z0-9_-]") def sanitize_username(username: str) -> str: """把用户 username 清洗成安全的账号名片段。""" s = _SAFE_RE.sub("_", username or "").strip("_") return s or "user" def account_name_for(username: str) -> str: """生成账号名:ag_。""" return f"ag_{sanitize_username(username)}" def deploy_dir_for(account_name: str) -> str: """账号隔离目录。""" return os.path.join(DEPLOY_ENV_BASE, account_name) def _ensure_account_dirs(account_name: str) -> str: """创建账号目录结构,返回 deploy_dir。幂等。""" d = deploy_dir_for(account_name) for sub in ("", "home", "workspace", "data", "logs"): os.makedirs(os.path.join(d, sub), exist_ok=True) return d def _resolve_deploy_dir(sor, account_name): """从 DB 查 deploy_dir,无记录则返回 None。""" return deploy_dir_for(account_name) def _is_safe_relpath(relpath: str) -> bool: """防路径穿越:相对路径不得包含 .. 或绝对路径。""" if not relpath: return True if relpath.startswith("/") or relpath.startswith("\\"): return False if ".." in relpath.split("/"): return False return True # ═══════════════════════════════════════════════════════════ # 账号管理 # ═══════════════════════════════════════════════════════════ async def ensure_account(sor, user_id: str, username: str) -> dict: """确保用户拥有逻辑部署账号。幂等:已存在则返回现有记录。 Returns: {"account_name": str, "deploy_dir": str, "created": bool} """ account_name = account_name_for(username) # 查现有记录 recs = await sor.sqlExe( "SELECT id, account_name, deploy_dir, status FROM sd_deploy_accounts " "WHERE user_id=${uid}$ LIMIT 1", {"uid": user_id}) if recs: r = recs[0] deploy_dir = getattr(r, "deploy_dir", "") or deploy_dir_for(account_name) _ensure_account_dirs(account_name) return { "account_name": getattr(r, "account_name", account_name), "deploy_dir": deploy_dir, "created": False, } # 新建 from appPublic.uniqueID import getID deploy_dir = _ensure_account_dirs(account_name) rec_id = getID() await sor.C("sd_deploy_accounts", { "id": rec_id, "user_id": user_id, "account_name": account_name, "deploy_dir": deploy_dir, "status": "active", "sandbox_config": json.dumps({"network": "shared", "readonly_system": True}), }) logger.info("deploy account created: %s (%s)", account_name, user_id) return {"account_name": account_name, "deploy_dir": deploy_dir, "created": True} async def remove_account(sor, account_name: str) -> dict: """删除逻辑账号(DB 记录 + 目录)。""" account_name = sanitize_username(account_name) if not account_name.startswith("ag_"): return {"ok": False, "error": "非法账号名"} recs = await sor.sqlExe( "SELECT deploy_dir FROM sd_deploy_accounts WHERE account_name=${n}$ LIMIT 1", {"n": account_name}) if not recs: return {"ok": False, "error": "账号不存在"} deploy_dir = getattr(recs[0], "deploy_dir", "") or deploy_dir_for(account_name) # 删目录(限定在 DEPLOY_ENV_BASE 内,防误删) if deploy_dir.startswith(DEPLOY_ENV_BASE + "/") and os.path.isdir(deploy_dir): shutil.rmtree(deploy_dir, ignore_errors=True) await sor.sqlExe( "DELETE FROM sd_deploy_accounts WHERE account_name=${n}$", {"n": account_name}) logger.info("deploy account removed: %s", account_name) return {"ok": True, "account_name": account_name} async def list_accounts(sor, user_id: str = "") -> list: """列出部署账号。user_id 为空则列全部。""" if user_id: recs = await sor.sqlExe( "SELECT account_name, user_id, deploy_dir, status, created_at " "FROM sd_deploy_accounts WHERE user_id=${uid}$ ORDER BY created_at DESC", {"uid": user_id}) else: recs = await sor.sqlExe( "SELECT account_name, user_id, deploy_dir, status, created_at " "FROM sd_deploy_accounts ORDER BY created_at DESC") out = [] for r in recs: out.append({ "account_name": getattr(r, "account_name", ""), "user_id": getattr(r, "user_id", ""), "deploy_dir": getattr(r, "deploy_dir", ""), "status": getattr(r, "status", ""), "created_at": str(getattr(r, "created_at", "")), }) return out # ═══════════════════════════════════════════════════════════ # 免密目录访问(部署进程直接读写,无需认证) # ═══════════════════════════════════════════════════════════ async def write_file(sor, account_name: str, relpath: str, content: str) -> dict: """部署进程免密写入文件到账号目录。""" if not _is_safe_relpath(relpath): return {"ok": False, "error": "非法路径"} deploy_dir = _resolve_deploy_dir(sor, account_name) full = os.path.abspath(os.path.join(deploy_dir, relpath)) if not full.startswith(deploy_dir + "/"): return {"ok": False, "error": "路径越界"} try: os.makedirs(os.path.dirname(full), exist_ok=True) with open(full, "w", encoding="utf-8") as f: f.write(content or "") return {"ok": True, "path": full} except Exception as e: return {"ok": False, "error": str(e)} async def read_file(sor, account_name: str, relpath: str, limit: int = 20000) -> dict: """免密读取账号目录文件。""" if not _is_safe_relpath(relpath): return {"ok": False, "error": "非法路径"} deploy_dir = _resolve_deploy_dir(sor, account_name) full = os.path.abspath(os.path.join(deploy_dir, relpath)) if not full.startswith(deploy_dir + "/"): return {"ok": False, "error": "路径越界"} try: with open(full, "r", encoding="utf-8", errors="replace") as f: content = f.read(limit) return {"ok": True, "content": content} except Exception as e: return {"ok": False, "error": str(e)} async def list_dir(sor, account_name: str, relpath: str = "") -> dict: """免密列出账号目录。""" if not _is_safe_relpath(relpath): return {"ok": False, "error": "非法路径"} deploy_dir = _resolve_deploy_dir(sor, account_name) full = os.path.abspath(os.path.join(deploy_dir, relpath)) if relpath else deploy_dir if not full.startswith(deploy_dir + "/") and full != deploy_dir: return {"ok": False, "error": "路径越界"} try: entries = [] for name in sorted(os.listdir(full)): p = os.path.join(full, name) entries.append({ "name": name, "is_dir": os.path.isdir(p), "size": os.path.getsize(p) if os.path.isfile(p) else 0, }) return {"ok": True, "entries": entries} except Exception as e: return {"ok": False, "error": str(e)} # ═══════════════════════════════════════════════════════════ # bwrap 沙箱执行 # ═══════════════════════════════════════════════════════════ def _build_bwrap_cmd(deploy_dir: str, command: str, workdir: str = "", network: str = "shared") -> list: """构建 bwrap 命令。 - 隔离 user/pid/ipc/uts namespace(保留网络,部署应用通常需要) - 只读挂载系统目录,可写挂载 deploy_dir - tmpfs /tmp(会话内隔离) """ parts = [ BWRAP, "--unshare-user", "--unshare-pid", "--unshare-ipc", "--unshare-uts", "--die-with-parent", "--ro-bind", "/usr", "/usr", "--ro-bind", "/bin", "/bin", "--ro-bind", "/sbin", "/sbin", "--ro-bind", "/lib", "/lib", "--ro-bind", "/lib64", "/lib64", "--ro-bind", "/etc", "/etc", "--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--bind", deploy_dir, "/home", "--setenv", "HOME", "/home", ] if network == "isolated": parts.insert(1, "--unshare-net") chdir = "/home" if workdir and _is_safe_relpath(workdir): chdir = "/home/" + workdir.strip("/") parts += ["--chdir", chdir, "--", "bash", "-c", command] return parts async def run_in_sandbox(sor, account_name: str, command: str, workdir: str = "", timeout: int = 120, network: str = "shared") -> dict: """在账号沙箱内执行命令。返回 {rc, stdout, stderr, sandbox}。""" if not command: return {"rc": -1, "stdout": "", "stderr": "缺少命令", "sandbox": bool(BWRAP)} deploy_dir = _resolve_deploy_dir(sor, account_name) if not os.path.isdir(deploy_dir): return {"rc": -1, "stdout": "", "stderr": f"账号目录不存在: {deploy_dir}", "sandbox": bool(BWRAP)} if BWRAP: cmd = _build_bwrap_cmd(deploy_dir, command, workdir, network) # bwrap 用列表直接执行(非 shell,防注入) proc = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) else: # 降级:无 bwrap,目录 + chdir 弱隔离 cwd = os.path.join(deploy_dir, workdir) if workdir and _is_safe_relpath(workdir) else deploy_dir if not cwd.startswith(deploy_dir + "/") and cwd != deploy_dir: return {"rc": -1, "stdout": "", "stderr": "路径越界", "sandbox": False} proc = await asyncio.create_subprocess_shell( command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, cwd=cwd) try: stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) except asyncio.TimeoutError: proc.kill() await proc.wait() return {"rc": -1, "stdout": "", "stderr": f"命令超时({timeout}s)", "sandbox": bool(BWRAP)} return { "rc": proc.returncode or 0, "stdout": stdout.decode("utf-8", "replace")[-8000:], "stderr": stderr.decode("utf-8", "replace")[-4000:], "sandbox": bool(BWRAP), }