"""direct-ssh channel (PAD-12): OpenSSH from the dispatcher straight to the terminal over Tailscale, no Warpgate. - Auth: one dedicated agentd key. Its private text arrives in the env var named by `agentd.ssh_key` (default AGENTD_SSH_KEY, injected by `agent-access.py exec --secret-env`), is popped from os.environ (children never see it) and loaded with `ssh-add -` into a per-process ssh-agent on a socket in a private 0700 temp dir. The key is never written to disk, argv, logs or exceptions. No key / agent failure -> the channel is unavailable (`Unreached`), so a terminal with `fallback: warpgate-ssh` uses Warpgate instead. - Host key: pinned per terminal (`ssh.host_key`); a known_hosts file is generated from it (public data) and checked strictly. A mismatch raises HostKeyError: never a fallback. - Reuse: ControlMaster=auto / ControlPersist=60 under `agentd.ssh_dir` (root-only 0700), so the second call of a tick (and the next tick within a minute) skips the handshake. The socket name carries a fingerprint of the pinned host key: a changed pin never rides an open master that was verified against the old one. - Payload on stdin: Windows runs a fixed PowerShell bootstrap (-EncodedCommand) that reads the base64 of the real script from stdin; POSIX runs a fixed bash loader that reads the script from stdin. One call per operation, no argv size limit.""" import atexit import base64 import hashlib import os import re import shutil import signal import subprocess import tempfile import time from . import NULL_LOG, HostKeyError, TransportError, Unreached from .local import sh_quote CONNECT_TIMEOUT = 8 CONTROL_PERSIST = 60 SOCKET_MAX = 104 # sun_path; ssh adds ".<16 random chars>" while it sets up the master socket CTL_NAME = 1 + 9 + 40 + 17 # "/-<%C: 40 hex>" + that suffix # Fixed PowerShell bootstrap: the operation's script arrives on stdin as base64 of its UTF-8 text (ASCII on the # wire, so the console input code page does not matter). BOOTSTRAP = ("$ProgressPreference='SilentlyContinue'\n" "$ErrorActionPreference='Continue'\n" "[Console]::OutputEncoding=[Text.Encoding]::UTF8\n" "$s=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String([Console]::In.ReadToEnd().Trim()))\n" "& ([ScriptBlock]::Create($s))\n") def encoded_command(script): """PowerShell -EncodedCommand value: base64 of the UTF-16LE text.""" return base64.b64encode(script.encode("utf-16-le")).decode("ascii") PS_COMMAND = "powershell.exe -NoProfile -NonInteractive -EncodedCommand " + encoded_command(BOOTSTRAP) # POSIX loader: the script is read from stdin into a variable (no MAX_ARG_STRLEN), then runs in a subshell with # stdin = /dev/null. Its exit 255 is reported as 254: 255 stays ssh's own "no session" code. (The PowerShell # bootstrap exits 0/1 by $?; none of our PowerShell scripts calls `exit`.) SH_COMMAND = "bash -c " + sh_quote('exec 3<&0 'ok' (the remote command ran, whatever its exit code), 'hostkey', 'unreached' (certainly not run) or 'unknown' (ssh exit 255 without a recognisable pre-session error: the command may have run). Any stdout proves that a session ran the command, so it is never 'unreached'.""" if rc != 255: return "ok" if (out or "").strip(): return "unknown" if HOSTKEY.search(err or ""): return "hostkey" if UNREACHED.search(err or ""): return "unreached" return "unknown" # ---------------------------------------------------------------- per-process key agent def _die_with_parent(): """preexec: the agent gets SIGTERM when agentd dies, even by SIGKILL (Linux PR_SET_PDEATHSIG).""" try: import ctypes ctypes.CDLL("libc.so.6", use_errno=True).prctl(1, signal.SIGTERM) except Exception: pass def normalise_key(text): """Vault may hand the key with literal `\\n` escapes or without the final newline.""" if "\n" not in text.strip() and "\\n" in text: text = text.replace("\\n", "\n") return text.strip() + "\n" _KEYS = {} # env var name -> key text, taken out of os.environ at CLI start until the agent loads it def take_key(env_name): """Move the key out of os.environ right away (called by the CLI before anything else runs), so no child -- not even one started before the first direct call -- can inherit it.""" if env_name not in _KEYS: _KEYS[env_name] = os.environ.pop(env_name, None) else: os.environ.pop(env_name, None) def key_present(env_name): return bool((_KEYS.get(env_name) or os.environ.get(env_name) or "").strip()) class KeyAgent: """ssh-agent of this process holding the agentd key. `error` set = direct-ssh unavailable here.""" def __init__(self, env_name): self.env_name = env_name self.dir = self.sock = self.pub = self.proc = None self.error = None def start(self): key = os.environ.pop(self.env_name, None) # popped even if loading fails: children never inherit it key = _KEYS.pop(self.env_name, None) or key if not key or not key.strip(): self.error = "%s is not set (agent-access.py exec --secret-env %s=)" % (self.env_name, self.env_name) return self agent_bin, add_bin = shutil.which("ssh-agent"), shutil.which("ssh-add") if not (agent_bin and add_bin and shutil.which("ssh")): self.error = "ssh / ssh-agent / ssh-add not installed" return self env = {"PATH": os.environ.get("PATH", "/usr/bin:/bin"), "LANG": "C"} try: self.dir = tempfile.mkdtemp(prefix="agentd-agent-") # 0700 self.sock = os.path.join(self.dir, "agent.sock") atexit.register(self.close) self.proc = subprocess.Popen([agent_bin, "-D", "-a", self.sock], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=env, close_fds=True, preexec_fn=_die_with_parent) end = time.monotonic() + 5 while not os.path.exists(self.sock): if self.proc.poll() is not None or time.monotonic() > end: self.error = "ssh-agent did not start" return self time.sleep(0.02) env["SSH_AUTH_SOCK"] = self.sock add = subprocess.run([add_bin, "-q", "-"], input=normalise_key(key).encode("utf-8"), env=env, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=15) key = None if add.returncode != 0: why = add.stderr.decode("utf-8", "replace").strip().splitlines()[:1] why = why[0][:120] if why and "-----" not in why[0] else "" self.error = "ssh-add failed (rc %d) %s" % (add.returncode, why) return self listed = subprocess.run([add_bin, "-L"], env=env, capture_output=True, timeout=15) pub = next((l for l in listed.stdout.decode("ascii", "replace").splitlines() if l.startswith("ssh-")), None) if not pub: self.error = "ssh-agent holds no key after ssh-add" return self self.pub = os.path.join(self.dir, "agentd.pub") # public half: selects the agent key (IdentitiesOnly) with open(self.pub, "w", encoding="ascii") as fh: fh.write(pub + "\n") except (OSError, subprocess.SubprocessError) as exc: self.error = "ssh-agent setup failed: %s" % type(exc).__name__ finally: key = None if self.error: self.close() return self def close(self): if self.proc and self.proc.poll() is None: self.proc.terminate() try: self.proc.wait(3) except subprocess.TimeoutExpired: self.proc.kill() if self.dir: shutil.rmtree(self.dir, ignore_errors=True) _AGENTS = {} def key_agent(cfg, log=NULL_LOG): """The process-wide agent for cfg's key env var, started on first use; raises Unreached when unavailable.""" name = cfg.ssh_key_env agent = _AGENTS.get(name) if agent is None: agent = _AGENTS[name] = KeyAgent(name).start() if agent.error: log.event("direct-ssh unavailable in this process: %s" % agent.error, once="agent:" + name) raise Unreached("direct-ssh unavailable: %s" % agent.error) return agent def control_dir(cfg): """agentd.ssh_dir: ControlMaster sockets + generated known_hosts; created 0700, must be ours.""" d = cfg.ssh_dir try: d.mkdir(parents=True, exist_ok=True) os.chmod(d, 0o700) if os.stat(d).st_uid != os.geteuid(): raise OSError("not owned by this user") except OSError as exc: raise Unreached("ssh_dir %s unusable: %s" % (d, exc)) from None return d # ---------------------------------------------------------------- channel class DirectChannel: """One terminal's direct OpenSSH path. ps() / sh() mirror the warpgate channel; payloads go on stdin.""" stdin = True def __init__(self, cfg, terminal, log=None, label="direct"): self.cfg, self.terminal, self.log, self.via = cfg, terminal, log or NULL_LOG, label s = cfg.terminals[terminal]["ssh"] self.host, self.user, self.port = s["host"], s["user"], int(s.get("port", 22)) self.host_key = " ".join(s["host_key"].split()[:2]) self.pin = hashlib.sha256(self.host_key.encode("ascii")).hexdigest()[:8] self.alias = "agentd-" + terminal self.hostkey_failed = None self.name = "ssh %s@%s" % (self.user, self.host) def known_hosts(self, d): path = d / ("known_hosts." + self.terminal) line = "%s %s\n" % (self.alias, self.host_key) try: if not path.exists() or path.read_text(encoding="ascii") != line: tmp = path.with_name("%s.%d.tmp" % (path.name, os.getpid())) # per terminal and process tmp.write_text(line, encoding="ascii") os.replace(tmp, path) except OSError as exc: raise Unreached("known_hosts %s: %s" % (path, exc)) from None return path def argv(self, agent, remote_command): d = control_dir(self.cfg) ctl = str(d / (self.pin + "-%C")) # pin fingerprint: a new pin never reuses a master verified on the old one if len(str(d)) + CTL_NAME > SOCKET_MAX: self.log.event("agentd.ssh_dir %s is too long for a control socket; connection reuse off" % d, once="ctl") mux = ["-o", "ControlMaster=no", "-o", "ControlPath=none"] else: mux = ["-o", "ControlMaster=auto", "-o", "ControlPersist=%d" % CONTROL_PERSIST, "-o", "ControlPath=" + ctl] return (["ssh", "-F", "/dev/null", "-T", "-p", str(self.port), "-l", self.user, "-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes", "-o", "IdentityAgent=" + agent.sock, "-o", "IdentityFile=" + agent.pub, "-o", "PreferredAuthentications=publickey", "-o", "PasswordAuthentication=no", "-o", "KbdInteractiveAuthentication=no", "-o", "StrictHostKeyChecking=yes", "-o", "UserKnownHostsFile=" + str(self.known_hosts(d)), "-o", "GlobalKnownHostsFile=/dev/null", "-o", "HostKeyAlias=" + self.alias, "-o", "HostKeyAlgorithms=" + self.host_key.split()[0], "-o", "UpdateHostKeys=no", "-o", "ConnectTimeout=%d" % CONNECT_TIMEOUT, "-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=3", "-o", "ForwardAgent=no", "-o", "ForwardX11=no", "-o", "ClearAllForwardings=yes", "-o", "LogLevel=ERROR", "-o", "EscapeChar=none"] + mux + ["--", self.host, remote_command]) def run(self, remote_command, payload, timeout, op): """-> (rc, stdout, stderr) of a call whose command ran; raises HostKeyError / Unreached / TransportError.""" if self.hostkey_failed: raise HostKeyError(self.hostkey_failed) agent = key_agent(self.cfg, self.log) argv = self.argv(agent, remote_command) env = self.cfg.scrubbed_env() env.pop("SSH_AUTH_SOCK", None) t0 = time.monotonic() ms = lambda: (time.monotonic() - t0) * 1000 # noqa: E731 # output goes to files, not pipes: a ControlPersist master forked by this call must never hold our pipes open with tempfile.TemporaryFile() as fo, tempfile.TemporaryFile() as fe: try: p = subprocess.run(argv, input=payload, stdout=fo, stderr=fe, env=env, timeout=timeout) except subprocess.TimeoutExpired: self.log.call(self.terminal, self.via, op, ms(), "timeout") raise TransportError("%s timeout after %ss" % (self.name, timeout)) from None # may have run except OSError as exc: self.log.call(self.terminal, self.via, op, ms(), "launch-failed") raise Unreached("%s: ssh launch failed: %s" % (self.name, type(exc).__name__)) from None fo.seek(0) fe.seek(0) dec = lambda b: b.decode("utf-8", "replace").replace("\r", "") # noqa: E731 out, err = dec(fo.read()), dec(fe.read()) kind = classify(p.returncode, err, out) self.log.call(self.terminal, self.via, op, ms(), p.returncode, "" if kind == "ok" else kind) if kind == "hostkey": self.hostkey_failed = ("%s:%d host key verification FAILED (pinned %s...; other key or no such key type " "offered): not falling back; check terminals.%s.ssh.host_key out of band" % (self.host, self.port, self.host_key[:40], self.terminal)) self.log.event("SECURITY %s: %s" % (self.terminal, self.hostkey_failed), once="hostkey:" + self.terminal) raise HostKeyError(self.hostkey_failed) if kind == "unreached": raise Unreached("%s exit 255: %s" % (self.name, err.strip()[-300:])) if kind == "unknown": raise TransportError("%s exit 255: %s" % (self.name, err.strip()[-300:] or "(no message)")) return p.returncode, out, err def ps(self, script, timeout, op=None): rc, out, err = self.run(PS_COMMAND, ps_payload(script), timeout, op) if rc != 0 and not out.strip(): raise TransportError("%s exit %s: %s" % (self.name, rc, err.strip()[-300:])) return out def sh(self, script, timeout, op=None): return self.run(SH_COMMAND, script.encode("utf-8"), timeout, op)