494 lines
21 KiB
Python
494 lines
21 KiB
Python
"""
|
||
pipeline_service/work_env.py - 工作环境管理(沙箱的本地/远程切换 + 目录迁移)
|
||
|
||
工作环境以「用户 / 机构」为单位组织,缺省本地模式(bwrap 沙箱)。
|
||
远程模式 = 免密 SSH 到其他主机,远程主机同样部署 bwrap 走沙箱。
|
||
|
||
目录层级约定:项目目录位于「工作目录」(逻辑账号 deploy_dir)之下,
|
||
所以迁移只需同步工作目录顶层,即可覆盖所有项目内容。
|
||
|
||
- get_work_env: 查询用户/机构工作环境(user 优先 → org 兜底 → 缺省 local)
|
||
- set_work_env: 设置工作环境(切换 mode 时触发目录迁移)
|
||
- migrate_work_dir: 目录迁移(rsync 本地 deploy_dir ↔ 远程 remote_dir)
|
||
- run_remote_sandbox: 远程免密 SSH + bwrap 沙箱执行
|
||
"""
|
||
|
||
import os
|
||
import re
|
||
import shlex
|
||
import shutil
|
||
import asyncio
|
||
import logging
|
||
import subprocess
|
||
|
||
logger = logging.getLogger("pipeline.work_env")
|
||
|
||
from .deploy_account import (
|
||
BWRAP, deploy_dir_for, sanitize_username, account_name_for,
|
||
)
|
||
|
||
# rsync 可执行文件(迁移必需)
|
||
RSYNC = shutil.which("rsync")
|
||
|
||
|
||
def _row_to_env(row) -> dict:
|
||
"""DB 行 → 工作环境 dict。"""
|
||
return {
|
||
"mode": getattr(row, "mode", "local") or "local",
|
||
"remote_host": getattr(row, "remote_host", "") or "",
|
||
"remote_port": getattr(row, "remote_port", 22) or 22,
|
||
"remote_user": getattr(row, "remote_user", "") or "",
|
||
"remote_key_path": getattr(row, "remote_key_path", "") or "",
|
||
"remote_dir": getattr(row, "remote_dir", "") or "",
|
||
}
|
||
|
||
|
||
async def get_work_env(sor, user_id: str, org_id: str = "") -> dict:
|
||
"""获取工作环境。优先级:user 级 → org 级 → 缺省 local。"""
|
||
if user_id:
|
||
recs = await sor.sqlExe(
|
||
"SELECT * FROM sd_work_envs WHERE owner_type='user' AND owner_id=${o}$ LIMIT 1",
|
||
{"o": user_id})
|
||
if recs:
|
||
return _row_to_env(recs[0])
|
||
if org_id:
|
||
recs = await sor.sqlExe(
|
||
"SELECT * FROM sd_work_envs WHERE owner_type='org' AND owner_id=${o}$ LIMIT 1",
|
||
{"o": org_id})
|
||
if recs:
|
||
return _row_to_env(recs[0])
|
||
return {"mode": "local", "remote_host": "", "remote_port": 22,
|
||
"remote_user": "", "remote_key_path": "", "remote_dir": ""}
|
||
|
||
|
||
def _ssh_target(env: dict) -> str:
|
||
"""构造 scp/rsync 的 user@host 目标。"""
|
||
user = env.get("remote_user", "")
|
||
host = env.get("remote_host", "")
|
||
if user:
|
||
return f"{user}@{host}"
|
||
return host
|
||
|
||
|
||
def _ssh_common_args(env: dict) -> list:
|
||
"""ssh/scp/rsync 共用的 SSH 参数。
|
||
|
||
StrictHostKeyChecking=accept-new:首次连接记录 host key,之后严格校验,
|
||
防 MITM(host key 变化会拒绝),替代原来的 =no(完全不校验)。
|
||
"""
|
||
args = ["-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=10"]
|
||
key = env.get("remote_key_path", "")
|
||
if key:
|
||
args += ["-i", key]
|
||
port = env.get("remote_port", 22) or 22
|
||
args += ["-p", str(port)]
|
||
return args
|
||
|
||
|
||
# ── 机构独立 SSH key(每机构一把,防单点泄露) ────────────────
|
||
|
||
SSH_KEYS_DIR = os.path.expanduser("~/.ssh/org_keys")
|
||
|
||
|
||
def _org_key_dir(org_id: str) -> str:
|
||
"""机构 key 目录(org_id sanitize 防路径穿越)。"""
|
||
safe = re.sub(r"[^a-zA-Z0-9_-]", "_", org_id or "")
|
||
if not safe:
|
||
safe = "_"
|
||
return os.path.join(SSH_KEYS_DIR, safe)
|
||
|
||
|
||
def ensure_org_key(org_id: str) -> dict:
|
||
"""确保机构有独立 SSH key pair(ed25519),没有则生成。
|
||
|
||
Returns: {"key_path", "pubkey_path", "pubkey"}
|
||
"""
|
||
d = _org_key_dir(org_id)
|
||
os.makedirs(d, mode=0o700, exist_ok=True)
|
||
priv = os.path.join(d, "id_ed25519")
|
||
pub = priv + ".pub"
|
||
if not (os.path.exists(priv) and os.path.exists(pub)):
|
||
subprocess.run(
|
||
["ssh-keygen", "-t", "ed25519", "-N", "", "-f", priv,
|
||
"-C", "pipeline-org-" + (re.sub(r"[^a-zA-Z0-9_-]", "_", org_id) or "_")],
|
||
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||
os.chmod(priv, 0o600)
|
||
pubkey = ""
|
||
if os.path.exists(pub):
|
||
with open(pub, "r", encoding="utf-8") as f:
|
||
pubkey = f.read().strip()
|
||
return {"key_path": priv, "pubkey_path": pub, "pubkey": pubkey}
|
||
|
||
|
||
def get_org_pubkey(org_id: str) -> dict:
|
||
"""获取机构 public key(供 admin 复制到远程主机 authorized_keys)。"""
|
||
info = ensure_org_key(org_id)
|
||
return {"ok": True, "org_id": org_id, "pubkey": info["pubkey"],
|
||
"key_path": info["key_path"]}
|
||
|
||
|
||
# ── 远程配置安全校验 ───────────────────────────────────────
|
||
|
||
# 复制粘贴易带入的不可见字符:零宽空格/连字符/BOM 等,str.strip() 去不掉(非 isspace)
|
||
_INVISIBLE_RE = re.compile(r"[\s\u200b\u200c\u200d\u2060\ufeff]+")
|
||
|
||
|
||
def _clean_field(s) -> str:
|
||
"""清理字段:去所有空白(含 Unicode)与零宽/BOM 不可见字符。"""
|
||
return _INVISIBLE_RE.sub("", s or "")
|
||
|
||
|
||
_HOST_RE = re.compile(r"^[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?$")
|
||
_USER_RE = re.compile(r"^[a-zA-Z0-9._-]+$")
|
||
|
||
# remote_dir 现在是相对路径(从远程登录用户的 home 目录开始)。
|
||
# 相对路径天然限制在 home 内,无需系统目录黑名单,只需防路径穿越(..)与 shell 注入。
|
||
|
||
_REL_DIR_RE = re.compile(r"^[a-zA-Z0-9._/-]+$")
|
||
|
||
|
||
def _rel_remote_dir(remote_dir: str) -> str:
|
||
"""规范化相对路径(strip 前后 /),返回纯相对路径(不含 ~,避免 shlex.quote 后 ~ 不展开)。"""
|
||
return (remote_dir or "").strip().strip("/")
|
||
|
||
|
||
def _validate_remote_dir(remote_dir: str) -> str:
|
||
"""校验 remote_dir(相对路径),返回错误消息(None 表示通过)。"""
|
||
d = _clean_field(remote_dir)
|
||
if not d:
|
||
return "缺少 remote_dir"
|
||
if d.startswith("/"):
|
||
return "remote_dir 必须是相对路径(从远程用户 home 目录开始)"
|
||
parts = [p for p in d.split("/") if p]
|
||
if ".." in parts:
|
||
return "remote_dir 不能包含 .."
|
||
if not parts:
|
||
return "remote_dir 不能为空"
|
||
if not _REL_DIR_RE.match(d):
|
||
return "remote_dir 只能含字母数字和 . _ - / 字符"
|
||
return None
|
||
|
||
|
||
def _validate_remote(remote_config: dict) -> str:
|
||
"""校验远程配置,返回错误消息(None 表示通过)。
|
||
|
||
防注入(host/user/port)与防破坏(remote_dir 不能是系统目录、
|
||
remote_key_path 不能读任意私钥文件)。
|
||
"""
|
||
host = _clean_field(remote_config.get("remote_host"))
|
||
if not host:
|
||
return "缺少 remote_host"
|
||
# 允许主机名/IPv4;IPv6 用 [] 包裹后取内部
|
||
_host = host[1:-1] if host.startswith("[") and host.endswith("]") else host
|
||
if not _HOST_RE.match(_host):
|
||
return "remote_host 格式非法"
|
||
|
||
user = _clean_field(remote_config.get("remote_user"))
|
||
if not user:
|
||
return "缺少 remote_user"
|
||
if not _USER_RE.match(user):
|
||
return "remote_user 格式非法"
|
||
|
||
try:
|
||
port = int(remote_config.get("remote_port", 22) or 22)
|
||
if not (1 <= port <= 65535):
|
||
return "remote_port 必须在 1-65535"
|
||
except (ValueError, TypeError):
|
||
return "remote_port 必须是数字"
|
||
|
||
err = _validate_remote_dir(remote_config.get("remote_dir", ""))
|
||
if err:
|
||
return err
|
||
|
||
key = (remote_config.get("remote_key_path") or "").strip()
|
||
if key:
|
||
ssh_dir = os.path.expanduser("~/.ssh")
|
||
if key.startswith("/"):
|
||
# 绝对路径必须位于 ~/.ssh/ 下,防读任意私钥
|
||
if not key.startswith(ssh_dir.rstrip("/") + "/"):
|
||
return "remote_key_path 必须位于 ~/.ssh/ 目录下"
|
||
elif "/" in key or key.startswith(".."):
|
||
return "remote_key_path 必须是 ~/.ssh/ 下的文件名"
|
||
|
||
return None
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# 目录迁移
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
async def _remote_mkdir(env: dict, remote_dir: str) -> bool:
|
||
"""远程 mkdir -p(相对路径可能多级,rsync 不会递归建父目录)。失败返回 False。
|
||
|
||
remote_dir 已校验为安全字符(_REL_DIR_RE),用 $HOME 拼接让远程 shell 展开
|
||
(不用 ~ 前缀,因 shlex.quote 会把 ~ 包进单引号导致不展开)。
|
||
"""
|
||
try:
|
||
target = _ssh_target(env)
|
||
ssh_cmd = ["ssh"] + _ssh_common_args(env) + [target, 'mkdir -p "$HOME/' + remote_dir + '"']
|
||
proc = await asyncio.create_subprocess_exec(
|
||
*ssh_cmd, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL)
|
||
await asyncio.wait_for(proc.communicate(), timeout=30)
|
||
return proc.returncode == 0
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
async def migrate_work_dir(account_name: str, from_mode: str, to_mode: str, env: dict) -> dict:
|
||
"""把工作目录内容从旧环境迁移到新环境。
|
||
|
||
from_mode/to_mode ∈ {'local', 'remote'}
|
||
- local → remote: rsync 本地 deploy_dir → remote_dir
|
||
- remote → local: rsync remote_dir → 本地 deploy_dir
|
||
"""
|
||
local_dir = deploy_dir_for(account_name)
|
||
if to_mode == "local" and not os.path.isdir(local_dir):
|
||
os.makedirs(local_dir, exist_ok=True)
|
||
|
||
if not RSYNC:
|
||
return {"ok": False, "error": "rsync 不可用,无法迁移"}
|
||
|
||
# remote_dir 安全校验(纵深防御:DB 脏数据/历史记录也拦一道)
|
||
if from_mode == "remote" or to_mode == "remote":
|
||
err = _validate_remote_dir(env.get("remote_dir", ""))
|
||
if err:
|
||
return {"ok": False, "error": err}
|
||
|
||
# 构造 rsync 命令
|
||
try:
|
||
if from_mode == "local" and to_mode == "remote":
|
||
# 本地 → 远程
|
||
remote_dir = env.get("remote_dir", "")
|
||
if not remote_dir:
|
||
return {"ok": False, "error": "缺少远程目录 remote_dir"}
|
||
await _remote_mkdir(env, _rel_remote_dir(remote_dir))
|
||
target = _ssh_target(env) + ":~/" + _rel_remote_dir(remote_dir)
|
||
cmd = ["rsync", "-az", "--delete", "-e",
|
||
"ssh " + " ".join(_ssh_common_args(env)),
|
||
local_dir.rstrip("/") + "/", target.rstrip("/") + "/"]
|
||
elif from_mode == "remote" and to_mode == "local":
|
||
# 远程 → 本地
|
||
remote_dir = env.get("remote_dir", "")
|
||
if not remote_dir:
|
||
return {"ok": False, "error": "缺少远程目录 remote_dir"}
|
||
src = _ssh_target(env) + ":~/" + _rel_remote_dir(remote_dir)
|
||
cmd = ["rsync", "-az", "--delete", "-e",
|
||
"ssh " + " ".join(_ssh_common_args(env)),
|
||
src.rstrip("/") + "/", local_dir.rstrip("/") + "/"]
|
||
else:
|
||
# 同模式或无效,无需迁移
|
||
return {"ok": True, "migrated": False}
|
||
|
||
proc = await asyncio.create_subprocess_exec(
|
||
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=300)
|
||
if proc.returncode != 0:
|
||
return {"ok": False, "error": "迁移失败: " + stderr.decode("utf-8", "replace")[-1000:]}
|
||
return {"ok": True, "migrated": True,
|
||
"detail": stdout.decode("utf-8", "replace")[-500:]}
|
||
except asyncio.TimeoutError:
|
||
return {"ok": False, "error": "迁移超时(300s)"}
|
||
except Exception as e:
|
||
return {"ok": False, "error": str(e)[:500]}
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# 设置工作环境
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
async def set_work_env(sor, owner_type: str, owner_id: str, mode: str,
|
||
remote_config: dict = None, account_name: str = None) -> dict:
|
||
"""设置工作环境。切换 mode 时触发目录迁移。
|
||
|
||
Returns: {"ok": bool, "mode": str, "migrated": {...}}
|
||
"""
|
||
if owner_type not in ("user", "org"):
|
||
return {"ok": False, "error": "owner_type 必须是 user 或 org"}
|
||
if mode not in ("local", "remote"):
|
||
return {"ok": False, "error": "mode 必须是 local 或 remote"}
|
||
|
||
remote_config = remote_config or {}
|
||
if owner_type == "org" and mode == "remote":
|
||
# 机构级远程:自动使用机构独立 key(每机构一把,防单点泄露),
|
||
# 不允许 admin 指定任意 key
|
||
_key_info = ensure_org_key(owner_id)
|
||
remote_config["remote_key_path"] = _key_info["key_path"]
|
||
if mode == "remote":
|
||
err = _validate_remote(remote_config)
|
||
if err:
|
||
return {"ok": False, "error": err}
|
||
|
||
# 查旧环境(完整记录,迁移时需要用旧的远程配置)
|
||
old_recs = await sor.sqlExe(
|
||
"SELECT * FROM sd_work_envs WHERE owner_type=${t}$ AND owner_id=${o}$ LIMIT 1",
|
||
{"t": owner_type, "o": owner_id})
|
||
old_mode = getattr(old_recs[0], "mode", "local") if old_recs else "local"
|
||
old_env = _row_to_env(old_recs[0]) if old_recs else None
|
||
|
||
# upsert
|
||
from appPublic.uniqueID import getID
|
||
data = {
|
||
"owner_type": owner_type,
|
||
"owner_id": owner_id,
|
||
"mode": mode,
|
||
"remote_host": remote_config.get("remote_host", ""),
|
||
"remote_port": int(remote_config.get("remote_port", 22) or 22),
|
||
"remote_user": remote_config.get("remote_user", ""),
|
||
"remote_key_path": remote_config.get("remote_key_path", ""),
|
||
"remote_dir": remote_config.get("remote_dir", ""),
|
||
}
|
||
# 先迁移(切换模式时),成功后再更新 DB(保证状态一致,迁移失败不回写 mode)
|
||
migrated = {"migrated": False}
|
||
if account_name and old_mode != mode:
|
||
# remote → local 用旧的远程配置;local → remote 用新的远程配置
|
||
if old_mode == "remote" and mode == "local":
|
||
migrate_env = old_env or _row_to_env_from_dict(data)
|
||
else:
|
||
migrate_env = _row_to_env_from_dict(data)
|
||
migrated = await migrate_work_dir(account_name, old_mode, mode, migrate_env)
|
||
if not migrated.get("ok"):
|
||
return {"ok": False, "error": "迁移失败,未切换模式: " + migrated.get("error", ""),
|
||
"migration": migrated}
|
||
|
||
# upsert(迁移成功后才写 DB)
|
||
if old_recs:
|
||
await sor.sqlExe(
|
||
"UPDATE sd_work_envs SET mode=${mode}$, remote_host=${h}$, remote_port=${p}$, "
|
||
"remote_user=${u}$, remote_key_path=${k}$, remote_dir=${d}$ "
|
||
"WHERE owner_type=${t}$ AND owner_id=${o}$",
|
||
{"mode": mode, "h": data["remote_host"], "p": data["remote_port"],
|
||
"u": data["remote_user"], "k": data["remote_key_path"],
|
||
"d": data["remote_dir"], "t": owner_type, "o": owner_id})
|
||
else:
|
||
data["id"] = getID()
|
||
await sor.C("sd_work_envs", data)
|
||
|
||
return {"ok": True, "mode": mode, "old_mode": old_mode, "migration": migrated}
|
||
|
||
|
||
def _row_to_env_from_dict(data: dict) -> dict:
|
||
return {
|
||
"mode": data.get("mode", "local"),
|
||
"remote_host": data.get("remote_host", ""),
|
||
"remote_port": data.get("remote_port", 22),
|
||
"remote_user": data.get("remote_user", ""),
|
||
"remote_key_path": data.get("remote_key_path", ""),
|
||
"remote_dir": data.get("remote_dir", ""),
|
||
}
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# 远程沙箱执行
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
def _bwrap_static_args() -> list:
|
||
"""bwrap 沙箱固定参数(不含 --bind 目录和 command,由调用方拼接)。"""
|
||
return [
|
||
"--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",
|
||
]
|
||
|
||
|
||
async def run_remote_sandbox(env: dict, deploy_dir: str, command: str,
|
||
workdir: str = "", timeout: int = 120) -> dict:
|
||
"""远程免密 SSH + bwrap 沙箱执行命令。"""
|
||
ssh_args = _ssh_common_args(env)
|
||
target = _ssh_target(env)
|
||
|
||
# deploy_dir 是相对路径(已校验安全字符),用 $HOME 拼接让远程 shell 展开
|
||
rel = _rel_remote_dir(deploy_dir)
|
||
inner = ('BW=$(command -v bwrap 2>/dev/null || echo "$HOME/bin/bwrap"); '
|
||
'RD="$HOME/' + rel + '"; '
|
||
'exec "$BW" ' + " ".join(_bwrap_static_args()) + ' '
|
||
'--bind "$RD" /home --chdir /home --setenv HOME /home '
|
||
'-- bash -c ' + shlex.quote(command))
|
||
|
||
ssh_cmd = ["ssh"] + ssh_args + [target, inner]
|
||
|
||
try:
|
||
proc = await asyncio.create_subprocess_exec(
|
||
*ssh_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout + 30)
|
||
except asyncio.TimeoutError:
|
||
return {"rc": -1, "stdout": "", "stderr": f"远程命令超时({timeout}s)", "sandbox": True, "remote": True}
|
||
except Exception as e:
|
||
return {"rc": -1, "stdout": "", "stderr": f"SSH 连接失败: {str(e)[:300]}", "sandbox": True, "remote": True}
|
||
|
||
return {
|
||
"rc": proc.returncode or 0,
|
||
"stdout": stdout.decode("utf-8", "replace")[-8000:],
|
||
"stderr": stderr.decode("utf-8", "replace")[-4000:],
|
||
"sandbox": True,
|
||
"remote": True,
|
||
}
|
||
|
||
|
||
async def run_in_work_env(sor, account_name: str, user_id: str, org_id: str,
|
||
command: str, workdir: str = "", timeout: int = 120,
|
||
network: str = "shared") -> dict:
|
||
"""按工作环境 mode 分发执行:本地 bwrap 或远程 SSH+bwrap。"""
|
||
env = await get_work_env(sor, user_id, org_id)
|
||
if env.get("mode") == "remote":
|
||
remote_dir = env.get("remote_dir", "")
|
||
if not remote_dir:
|
||
return {"rc": -1, "stdout": "", "stderr": "远程模式缺少 remote_dir",
|
||
"sandbox": True, "remote": True}
|
||
return await run_remote_sandbox(env, remote_dir, command, workdir, timeout)
|
||
from .deploy_account import run_in_sandbox
|
||
return await run_in_sandbox(sor, account_name, command, workdir, timeout, network)
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════
|
||
# 远程 bwrap 部署(远程主机装 bwrap,零 root)
|
||
# ═══════════════════════════════════════════════════════════
|
||
|
||
async def ensure_remote_bwrap(env: dict) -> dict:
|
||
"""确保远程主机已安装 bwrap(零 root:apt download + dpkg -x)。"""
|
||
# 校验(host/user/port/key,无需 remote_dir)
|
||
host = _clean_field(env.get("remote_host"))
|
||
_host = host[1:-1] if host.startswith("[") and host.endswith("]") else host
|
||
if not host or not _HOST_RE.match(_host):
|
||
return {"ok": False, "error": "remote_host 格式非法"}
|
||
user = _clean_field(env.get("remote_user"))
|
||
if not user or not _USER_RE.match(user):
|
||
return {"ok": False, "error": "remote_user 格式非法"}
|
||
key = (env.get("remote_key_path") or "").strip()
|
||
if key and key.startswith("/"):
|
||
ssh_dir = os.path.expanduser("~/.ssh")
|
||
if not key.startswith(ssh_dir.rstrip("/") + "/"):
|
||
return {"ok": False, "error": "remote_key_path 必须位于 ~/.ssh/ 目录下"}
|
||
|
||
ssh_args = _ssh_common_args(env)
|
||
target = _ssh_target(env)
|
||
|
||
script = (
|
||
'mkdir -p ~/bin && '
|
||
'if ! command -v bwrap >/dev/null 2>&1 && [ ! -x ~/bin/bwrap ]; then '
|
||
'tmpd=$(mktemp -d) && (cd "$tmpd" && apt download bubblewrap >/dev/null 2>&1 '
|
||
'&& dpkg -x bubblewrap*.deb . && cp usr/bin/bwrap ~/bin/bwrap) 2>/dev/null; '
|
||
'rm -rf "$tmpd"; fi; '
|
||
'if command -v bwrap >/dev/null 2>&1; then echo PATH_BWRAP; '
|
||
'elif [ -x ~/bin/bwrap ]; then echo HOME_BWRAP; '
|
||
'else echo NO_BWRAP; fi'
|
||
)
|
||
ssh_cmd = ["ssh"] + ssh_args + [target, script]
|
||
try:
|
||
proc = await asyncio.create_subprocess_exec(
|
||
*ssh_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=60)
|
||
out = stdout.decode("utf-8", "replace").strip()
|
||
if "NO_BWRAP" in out:
|
||
return {"ok": False, "error": "远程主机无法获取 bwrap: " + stderr.decode("utf-8", "replace")[-300:]}
|
||
return {"ok": True, "location": out.strip().splitlines()[-1] if out else "unknown"}
|
||
except Exception as e:
|
||
return {"ok": False, "error": str(e)[:300]}
|