diff --git a/pipeline_service/__init__.py b/pipeline_service/__init__.py index 3695bc5..82c7484 100644 --- a/pipeline_service/__init__.py +++ b/pipeline_service/__init__.py @@ -50,6 +50,8 @@ from .work_env import ( run_remote_sandbox, run_in_work_env, ensure_remote_bwrap, + ensure_org_key, + get_org_pubkey, ) __version__ = "3.5.0" diff --git a/pipeline_service/work_env.py b/pipeline_service/work_env.py index a6b1a5e..b606bdc 100644 --- a/pipeline_service/work_env.py +++ b/pipeline_service/work_env.py @@ -19,6 +19,7 @@ import shlex import shutil import asyncio import logging +import subprocess logger = logging.getLogger("pipeline.work_env") @@ -70,8 +71,12 @@ def _ssh_target(env: dict) -> str: def _ssh_common_args(env: dict) -> list: - """ssh/scp/rsync 共用的 SSH 参数。""" - args = ["-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10"] + """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] @@ -80,6 +85,48 @@ def _ssh_common_args(env: dict) -> list: 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"]} + + # ── 远程配置安全校验 ─────────────────────────────────────── _HOST_RE = re.compile(r"^[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?$") @@ -232,6 +279,11 @@ async def set_work_env(sor, owner_type: str, owner_id: str, mode: str, 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: