feat: 应用部署主机逻辑账号系统(零root+bwrap沙箱)

- deploy_account.py: 逻辑账号(ag_前缀)创建/删除/列表,bwrap沙箱执行,免密目录读写
- 零root:不建真实Linux账号,逻辑隔离目录 + unprivileged user namespace
- init.py: sd_deploy_accounts 表 DDL 自动初始化
This commit is contained in:
ymq 2026-08-13 17:57:09 +08:00
parent 49338b4fc7
commit 08ae248c2a
3 changed files with 318 additions and 0 deletions

View File

@ -28,4 +28,18 @@ from .state import (
# v2: Agent 执行引擎(对照 Hermes Agent)
from .agent_loop_v2 import AgentExecutor, run_agent
# 应用部署主机逻辑账号系统(零 root 逻辑隔离 + bwrap 沙箱)
from .deploy_account import (
ensure_account,
remove_account,
list_accounts,
run_in_sandbox,
write_file,
read_file,
list_dir,
account_name_for,
deploy_dir_for,
sanitize_username,
)
__version__ = "3.5.0"

View File

@ -0,0 +1,291 @@
"""
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,降级为目录隔离)
BWRAP = shutil.which("bwrap")
# 账号名合法字符(目录安全,防路径穿越)
_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",
]
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),
}

View File

@ -635,6 +635,19 @@ def load_pipeline_service():
"KEY idx_category_priority (category, priority)"
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", {})
_debug("pipeline_user_memory table ready")
# 应用部署主机逻辑账号表
await sor.sqlExe(
"CREATE TABLE IF NOT EXISTS sd_deploy_accounts ("
"id varchar(32) NOT NULL, user_id varchar(32) NOT NULL,"
"account_name varchar(64) NOT NULL, deploy_dir varchar(500) NOT NULL,"
"status varchar(20) NOT NULL DEFAULT 'active',"
"sandbox_config text,"
"created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,"
"updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,"
"PRIMARY KEY (id), UNIQUE KEY uk_account_name (account_name),"
"UNIQUE KEY uk_user_id (user_id), KEY idx_status (status)"
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", {})
_debug("sd_deploy_accounts table ready")
# agent_config columns
try:
await sor.sqlExe("ALTER TABLE pipelines ADD COLUMN agent_config text", {})