feat: 应用部署主机逻辑账号系统(零root+bwrap沙箱)
- deploy_account.py: 逻辑账号(ag_前缀)创建/删除/列表,bwrap沙箱执行,免密目录读写 - 零root:不建真实Linux账号,逻辑隔离目录 + unprivileged user namespace - init.py: sd_deploy_accounts 表 DDL 自动初始化
This commit is contained in:
parent
49338b4fc7
commit
08ae248c2a
@ -28,4 +28,18 @@ from .state import (
|
|||||||
# v2: Agent 执行引擎(对照 Hermes Agent)
|
# v2: Agent 执行引擎(对照 Hermes Agent)
|
||||||
from .agent_loop_v2 import AgentExecutor, run_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"
|
__version__ = "3.5.0"
|
||||||
|
|||||||
291
pipeline_service/deploy_account.py
Normal file
291
pipeline_service/deploy_account.py
Normal 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),
|
||||||
|
}
|
||||||
@ -635,6 +635,19 @@ def load_pipeline_service():
|
|||||||
"KEY idx_category_priority (category, priority)"
|
"KEY idx_category_priority (category, priority)"
|
||||||
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", {})
|
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", {})
|
||||||
_debug("pipeline_user_memory table ready")
|
_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
|
# agent_config columns
|
||||||
try:
|
try:
|
||||||
await sor.sqlExe("ALTER TABLE pipelines ADD COLUMN agent_config text", {})
|
await sor.sqlExe("ALTER TABLE pipelines ADD COLUMN agent_config text", {})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user