301 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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仅依赖 bwrapunprivileged 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_<username>。"""
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),
}