PAD-12: per-terminal transport (direct Tailscale SSH or Warpgate, pre-session fallback, pinned host keys, agent key from Vault), one-call win10 launch and one-call collect, remote call timings, agentd ping
This commit is contained in:
@@ -9,6 +9,7 @@ from . import config as config_mod
|
||||
from .core import Agentd
|
||||
from .plane import Plane, brief_to_html, html_to_text, text_to_html
|
||||
from .store import Busy
|
||||
from .transports import CallLog, TransportError, direct_ssh, make_transport
|
||||
|
||||
|
||||
def identity(cfg, wanted=None):
|
||||
@@ -159,6 +160,31 @@ def cmd_smoke(cfg, a):
|
||||
sys.exit(0 if ok else 1)
|
||||
|
||||
|
||||
def cmd_ping(cfg, a):
|
||||
"""Trivial command on a terminal, timed per call (verifies direct-ssh / warpgate-ssh after a deployment). The
|
||||
direct-ssh key comes from the environment like the Plane tokens (agent-access.py exec --secret-env)."""
|
||||
if a.terminal not in cfg.terminals:
|
||||
sys.exit("unknown terminal %s" % a.terminal)
|
||||
t = cfg.terminals[a.terminal]
|
||||
if (a.transport or t["transport"]) == "local":
|
||||
sys.exit("%s is a local terminal: nothing to ping" % a.terminal)
|
||||
if a.transport == "direct-ssh" and "ssh" not in t:
|
||||
sys.exit("terminals.%s has no ssh section" % a.terminal)
|
||||
log = CallLog(event=lambda m: print("event: " + m, flush=True), echo=True)
|
||||
tr = make_transport(cfg, a.terminal, log=log, force=a.transport)
|
||||
ok = True
|
||||
for i in range(a.count):
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
pong = tr.ping()
|
||||
except TransportError as exc:
|
||||
ok = False
|
||||
print("ping %d: FAILED after %d ms: %s" % (i + 1, (time.monotonic() - t0) * 1000, exc), flush=True)
|
||||
continue
|
||||
print("ping %d: %d ms %s" % (i + 1, (time.monotonic() - t0) * 1000, pong), flush=True)
|
||||
sys.exit(0 if ok else 1)
|
||||
|
||||
|
||||
def cmd_check_config(cfg, a):
|
||||
print("config %s is valid" % cfg.path)
|
||||
print("projects: %s" % ", ".join(cfg.projects))
|
||||
@@ -166,6 +192,13 @@ def cmd_check_config(cfg, a):
|
||||
env = cfg.token_env(name)
|
||||
print("terminal %-10s %-13s %-8s executors=%s token %s: %s" % (
|
||||
name, t["transport"], t["os"], ",".join(t["executors"]), env, "set" if os.environ.get(env) else "MISSING"))
|
||||
if t["transport"] == "direct-ssh":
|
||||
s = t["ssh"]
|
||||
print(" direct-ssh %s@%s:%s, fallback %s" % (s["user"], s["host"], s.get("port", 22), t.get("fallback", "none")))
|
||||
if cfg.uses_direct_ssh():
|
||||
print("direct-ssh key %s: %s; ssh_dir %s" % (
|
||||
cfg.ssh_key_env, "set" if direct_ssh.key_present(cfg.ssh_key_env) else "MISSING (direct terminals use their fallback)",
|
||||
cfg.ssh_dir))
|
||||
print("cron/exec secret args: %s" % " ".join(cfg.secret_env_args()))
|
||||
if cfg.webhook:
|
||||
k = cfg.webhook["secret_key"]
|
||||
@@ -221,6 +254,12 @@ def parser():
|
||||
s.add_argument("--port", type=int, help="port (default: webhook.port)")
|
||||
s.set_defaults(fn=cmd_webhook_serve)
|
||||
sub.add_parser("check-config", help="validate the config and list token env vars").set_defaults(fn=cmd_check_config)
|
||||
s = sub.add_parser("ping", help="time a trivial command on a terminal (direct-ssh needs the key env var, see check-config)")
|
||||
s.add_argument("terminal")
|
||||
s.add_argument("--transport", choices=["direct-ssh", "warpgate-ssh"],
|
||||
help="force one channel, no fallback (default: the terminal's transport with its fallback)")
|
||||
s.add_argument("-n", "--count", type=int, default=3, help="calls (default 3: the 2nd+ reuse the SSH connection)")
|
||||
s.set_defaults(fn=cmd_ping)
|
||||
return ap
|
||||
|
||||
|
||||
@@ -230,4 +269,5 @@ def main(argv=None):
|
||||
cfg = config_mod.load(a.config)
|
||||
except config_mod.ConfigError as exc:
|
||||
sys.exit(str(exc))
|
||||
direct_ssh.take_key(cfg.ssh_key_env) # out of os.environ before any child process starts
|
||||
a.fn(cfg, a)
|
||||
|
||||
+93
-17
@@ -2,8 +2,10 @@
|
||||
|
||||
The config only ever holds Vault *key names* (`token_key`). The token itself is read from the environment
|
||||
variable of the same name, which `agent-access.py exec --secret-env KEY=KEY` injects into the process."""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
@@ -11,6 +13,7 @@ import yaml
|
||||
from . import ROOT
|
||||
|
||||
SCHEMA_PATH = ROOT / "config" / "schema.json"
|
||||
DEFAULT_SSH_KEY = "AGENTD_SSH_KEY" # env var (== Vault key name) carrying the direct-ssh private key
|
||||
DEFAULT_PATHS = (ROOT / "config" / "agentd.local.yaml", ROOT / "config" / "agentd.yaml")
|
||||
|
||||
|
||||
@@ -83,6 +86,48 @@ def schema_errors(data, schema):
|
||||
for e in validator.iter_errors(data)]
|
||||
|
||||
|
||||
def host_key_error(value):
|
||||
"""None if `value` is a usable pinned host key ('ssh-ed25519 <base64>' whose blob names the same type)."""
|
||||
parts = str(value).split()
|
||||
if len(parts) < 2 or parts[0] != "ssh-ed25519":
|
||||
return "must be 'ssh-ed25519 <base64>' (the terminal's /etc/ssh/ssh_host_ed25519_key.pub, verified out of band)"
|
||||
try:
|
||||
blob = base64.b64decode(parts[1], validate=True)
|
||||
n = struct.unpack(">I", blob[:4])[0]
|
||||
ok = blob[4:4 + n] == b"ssh-ed25519" and len(blob) == 4 + n + 4 + 32
|
||||
except (ValueError, struct.error):
|
||||
ok = False
|
||||
return None if ok else "the base64 part is not an ssh-ed25519 public key (copy/paste damage?)"
|
||||
|
||||
|
||||
def ssh_dir_of(data):
|
||||
d = (data.get("agentd") or {}).get("ssh_dir")
|
||||
if d:
|
||||
p = Path(os.path.expanduser(d))
|
||||
return p if p.is_absolute() else ROOT / p
|
||||
s = Path(os.path.expanduser((data.get("agentd") or {}).get("state_dir", "state")))
|
||||
return (s if s.is_absolute() else ROOT / s) / "ssh"
|
||||
|
||||
|
||||
def remote_errors(data, tname, t, kind):
|
||||
"""Rules every remote transport (warpgate-ssh, direct-ssh, and the fallback channel) shares."""
|
||||
errs = []
|
||||
if t.get("os") == "windows":
|
||||
if t.get("launch") != "schtasks":
|
||||
errs.append("terminals.%s: %s on windows needs launch=schtasks" % (tname, kind))
|
||||
if kind == "warpgate-ssh" and t.get("target", tname) != "win10":
|
||||
errs.append("terminals.%s: the Windows helper scripts only reach target 'win10' for now" % tname)
|
||||
else:
|
||||
if t.get("launch") != "nohup":
|
||||
errs.append("terminals.%s: %s on %s needs launch=nohup" % (tname, kind, t.get("os")))
|
||||
for key in ("repo", "run_root"):
|
||||
if key in t and not str(t[key]).startswith("/"):
|
||||
errs.append("terminals.%s.%s: must be an absolute path on the remote (no ~)" % (tname, key))
|
||||
if kind == "warpgate-ssh" and not data.get("vault", {}).get("config"):
|
||||
errs.append("terminals.%s: remote POSIX terminals need vault.config (relay identity)" % tname)
|
||||
return errs
|
||||
|
||||
|
||||
def semantic_errors(data):
|
||||
"""Cross-references the schema cannot express."""
|
||||
errs = []
|
||||
@@ -91,21 +136,36 @@ def semantic_errors(data):
|
||||
for ex in t.get("executors", []):
|
||||
if ex not in execs:
|
||||
errs.append("terminals.%s: unknown executor '%s'" % (tname, ex))
|
||||
if t.get("transport") == "warpgate-ssh" and t.get("os") == "windows":
|
||||
if t.get("launch") != "schtasks":
|
||||
errs.append("terminals.%s: warpgate-ssh on windows needs launch=schtasks" % tname)
|
||||
if t.get("target", tname) != "win10":
|
||||
errs.append("terminals.%s: the Windows helper scripts only reach target 'win10' for now" % tname)
|
||||
elif t.get("transport") == "warpgate-ssh":
|
||||
if t.get("launch") != "nohup":
|
||||
errs.append("terminals.%s: warpgate-ssh on %s needs launch=nohup" % (tname, t.get("os")))
|
||||
for key in ("repo", "run_root"):
|
||||
if key in t and not str(t[key]).startswith("/"):
|
||||
errs.append("terminals.%s.%s: must be an absolute path on the remote (no ~)" % (tname, key))
|
||||
if not data.get("vault", {}).get("config"):
|
||||
errs.append("terminals.%s: remote POSIX terminals need vault.config (relay identity)" % tname)
|
||||
if t.get("transport") == "local" and t.get("os") != "linux":
|
||||
kind = t.get("transport")
|
||||
if kind == "warpgate-ssh":
|
||||
errs += remote_errors(data, tname, t, kind)
|
||||
elif kind == "direct-ssh":
|
||||
errs += remote_errors(data, tname, t, kind)
|
||||
s = t.get("ssh")
|
||||
if not s:
|
||||
errs.append("terminals.%s: direct-ssh needs ssh: {host, user, port, host_key}" % tname)
|
||||
else:
|
||||
if not s.get("host") or str(s["host"]).startswith("-") or not s.get("user"):
|
||||
errs.append("terminals.%s.ssh: host and user must be non-empty (host not starting with '-')" % tname)
|
||||
if s.get("port", 22) > 65535:
|
||||
errs.append("terminals.%s.ssh.port: %r > 65535" % (tname, s["port"]))
|
||||
why = host_key_error(s.get("host_key", ""))
|
||||
if why:
|
||||
errs.append("terminals.%s.ssh.host_key: %s" % (tname, why))
|
||||
if t.get("fallback") == "warpgate-ssh":
|
||||
if "target" not in t:
|
||||
errs.append("terminals.%s: fallback warpgate-ssh needs the Warpgate 'target'" % tname)
|
||||
elif t.get("os") == "windows" and t["target"] != "win10":
|
||||
errs.append("terminals.%s: the Windows helper scripts (fallback) only reach target 'win10' for now" % tname)
|
||||
if t.get("os") != "windows" and not data.get("vault", {}).get("config"):
|
||||
errs.append("terminals.%s: fallback warpgate-ssh needs vault.config (relay identity)" % tname)
|
||||
elif t.get("fallback"):
|
||||
errs.append("terminals.%s: 'fallback' only applies to transport direct-ssh" % tname)
|
||||
if kind == "local" and t.get("os") != "linux":
|
||||
errs.append("terminals.%s: local transport supports only os=linux" % tname)
|
||||
ctl = ssh_dir_of(data) # "/<pin8>-<40-char %C>", plus ".<16 chars>" while ssh sets the socket up
|
||||
if any(t.get("transport") == "direct-ssh" for t in terms.values()) and len(str(ctl)) + 1 + 9 + 40 + 17 > 104:
|
||||
errs.append("agentd.ssh_dir: %s is too long for the ControlMaster socket path (UNIX limit 104)" % ctl)
|
||||
for ename, e in execs.items():
|
||||
adapter = e.get("adapter", ename)
|
||||
if adapter not in ("codex", "claude-code", "shell"):
|
||||
@@ -156,6 +216,19 @@ class Config:
|
||||
p = Path(os.path.expanduser(self.agentd.get("state_dir", "state")))
|
||||
return p if p.is_absolute() else ROOT / p
|
||||
|
||||
@property
|
||||
def ssh_dir(self):
|
||||
"""direct-ssh ControlMaster sockets + generated known_hosts (default <state_dir>/ssh)."""
|
||||
return ssh_dir_of(self.data)
|
||||
|
||||
@property
|
||||
def ssh_key_env(self):
|
||||
"""Env var (== Vault key name) holding the direct-ssh private key."""
|
||||
return self.agentd.get("ssh_key") or DEFAULT_SSH_KEY
|
||||
|
||||
def uses_direct_ssh(self):
|
||||
return any(t["transport"] == "direct-ssh" for t in self.terminals.values())
|
||||
|
||||
def helpers_dir(self, terminal):
|
||||
d = self.terminals[terminal].get("helpers_dir") or self.agentd.get("helpers_dir") or "tools/win10"
|
||||
p = Path(os.path.expanduser(d))
|
||||
@@ -185,13 +258,16 @@ class Config:
|
||||
return keys
|
||||
|
||||
def scrubbed_env(self):
|
||||
"""os.environ without the secrets above, for every child process agentd starts."""
|
||||
drop = self.secret_keys()
|
||||
"""os.environ without the secrets above, for every child process agentd starts. The direct-ssh key variable
|
||||
is always dropped, even with no direct-ssh terminal (a rollback may leave the cron script injecting it)."""
|
||||
drop = self.secret_keys() | {self.ssh_key_env}
|
||||
return {k: v for k, v in os.environ.items() if k not in drop}
|
||||
|
||||
def secret_env_args(self):
|
||||
"""`--secret-env` arguments for agent-access.py exec, one per distinct token key."""
|
||||
"""`--secret-env` arguments for agent-access.py exec: one per distinct token key, plus the direct-ssh key."""
|
||||
keys = sorted({t["token_key"] for t in self.terminals.values()})
|
||||
if self.uses_direct_ssh():
|
||||
keys.append(self.ssh_key_env)
|
||||
return ["--secret-env %s=%s" % (k, k) for k in keys]
|
||||
|
||||
def project(self, name):
|
||||
|
||||
+4
-2
@@ -21,7 +21,7 @@ from .executors import ExecResult, Run, make_executor
|
||||
from .executors.base import fill
|
||||
from .plane import Plane, html_to_text, text_to_html
|
||||
from .store import Store
|
||||
from .transports import TransportError, make_transport
|
||||
from .transports import CallLog, TransportError, make_transport
|
||||
|
||||
TAGS = ("review", "question", "blocked", "progress")
|
||||
WORKER = "[worker]"
|
||||
@@ -152,9 +152,11 @@ class Agentd:
|
||||
self.store = store or Store(cfg.state_dir, log_dir=cfg.log_dir)
|
||||
self.now = now
|
||||
self.log = self.store.log
|
||||
# one line per remote call (terminal, channel, operation, ms, rc) -> remote.log; fallback/host-key -> agentd.log
|
||||
self.calllog = CallLog(self.store.log_dir / "remote.log", self.log)
|
||||
self.plane_factory = plane_factory or (lambda term: Plane(
|
||||
cfg.plane["base_url"], cfg.plane["workspace"], cfg.token(term), cfg.plane.get("timeout", 60)))
|
||||
self.transport_factory = transport_factory or (lambda term: make_transport(cfg, term))
|
||||
self.transport_factory = transport_factory or (lambda term: make_transport(cfg, term, log=self.calllog))
|
||||
self.executor_factory = executor_factory or (lambda term, name: make_executor(cfg, term, name, self.transport(term)))
|
||||
self._reset_caches()
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
|
||||
from ..transports import fetch_each
|
||||
|
||||
|
||||
@dataclass
|
||||
class Run:
|
||||
@@ -55,6 +57,11 @@ class Executor:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def parse_exit(text):
|
||||
m = re.search(r"exit=(-?\d+)", text or "")
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
|
||||
def fill(template, params):
|
||||
"""Replace {name} placeholders that exist in params; leave anything else untouched."""
|
||||
return re.sub(r"\{(\w+)\}", lambda m: str(params[m.group(1)]) if m.group(1) in params else m.group(0), template)
|
||||
@@ -101,7 +108,7 @@ class RunnerExecutor(Executor):
|
||||
run_dir, task = h["run_dir"], h["task"]
|
||||
script_name, script = t.runner(run_dir, run.repo, self.command(run, run_dir), self.stdout_name)
|
||||
files = {"prompt.md": run.prompt, script_name: script}
|
||||
if hasattr(t, "start_run"): # remote POSIX: stage + launch in one relay round trip
|
||||
if hasattr(t, "start_run"): # remote: stage + launch in one call (direct-ssh; POSIX also on the relay)
|
||||
t.start_run(task, run_dir, files, script_name)
|
||||
else:
|
||||
t.mkdir(run_dir, clean=True)
|
||||
@@ -121,9 +128,14 @@ class RunnerExecutor(Executor):
|
||||
return "finished" if text and "exit=" in text else "running"
|
||||
|
||||
def exit_code(self, handle):
|
||||
text = self.transport.read_text(self.transport.join(handle["run_dir"], "exit.txt")) or ""
|
||||
m = re.search(r"exit=(-?\d+)", text)
|
||||
return int(m.group(1)) if m else None
|
||||
return parse_exit(self.transport.read_text(self.transport.join(handle["run_dir"], "exit.txt")))
|
||||
|
||||
def fetch(self, handle, ops):
|
||||
"""Reads of the run directory for `collect`: one call on remote transports (see transports.fetch)."""
|
||||
t = self.transport
|
||||
if hasattr(t, "fetch"):
|
||||
return t.fetch(handle["run_dir"], ops)
|
||||
return fetch_each(t, handle["run_dir"], ops)
|
||||
|
||||
def kill(self, handle):
|
||||
self.transport.kill(handle["task"], handle["run_dir"])
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
resume with `--resume <session_id>`. The JSON result carries `result` (last message) and `session_id`."""
|
||||
import json
|
||||
|
||||
from .base import ExecResult, RunnerExecutor
|
||||
from .base import ExecResult, RunnerExecutor, parse_exit
|
||||
|
||||
|
||||
def parse_output(text):
|
||||
@@ -26,13 +26,14 @@ def parse_output(text):
|
||||
class ClaudeCodeExecutor(RunnerExecutor):
|
||||
stdout_name = "out.json"
|
||||
|
||||
COLLECT = [("read", "exit.txt"), ("read", "out.json"), ("tail", "stderr.txt", 5)]
|
||||
|
||||
def collect(self, handle):
|
||||
t, d = self.transport, handle["run_dir"]
|
||||
obj = parse_output(t.read_text(t.join(d, "out.json")))
|
||||
exit_text, out, tail = self.fetch(handle, self.COLLECT) # one remote call
|
||||
obj = parse_output(out)
|
||||
err = bool(obj.get("is_error")) or obj.get("subtype", "success") != "success"
|
||||
last = "" if err else (obj.get("result") or "")
|
||||
tail = self.stderr_tail(handle)
|
||||
if err:
|
||||
tail = ("%s %s" % (obj.get("subtype", ""), str(obj.get("result", ""))[:400]) + "\n" + tail).strip()
|
||||
return ExecResult(last.strip(), self.exit_code(handle), obj.get("session_id") or handle.get("thread_id"),
|
||||
return ExecResult(last.strip(), parse_exit(exit_text), obj.get("session_id") or handle.get("thread_id"),
|
||||
self.logs(handle, "out.json", "stderr.txt"), tail)
|
||||
|
||||
@@ -2,22 +2,22 @@
|
||||
Thread id comes from the `thread.started` event in events.jsonl; the last message from last.md."""
|
||||
import json
|
||||
|
||||
from .base import ExecResult, RunnerExecutor
|
||||
from .base import ExecResult, RunnerExecutor, parse_exit
|
||||
|
||||
|
||||
class CodexExecutor(RunnerExecutor):
|
||||
stdout_name = "events.jsonl"
|
||||
COLLECT = [("read", "exit.txt"), ("read", "last.md"), ("grep", "events.jsonl", "thread.started"),
|
||||
("tail", "stderr.txt", 5), ("tail", "events.jsonl", 1)]
|
||||
|
||||
def collect(self, handle):
|
||||
t, d = self.transport, handle["run_dir"]
|
||||
last = t.read_text(t.join(d, "last.md")) or ""
|
||||
exit_text, last, line, err_tail, last_event = self.fetch(handle, self.COLLECT) # one remote call
|
||||
thread = handle.get("thread_id")
|
||||
line = t.grep_first(t.join(d, "events.jsonl"), "thread.started")
|
||||
if line:
|
||||
try:
|
||||
thread = json.loads(line).get("thread_id") or thread
|
||||
except ValueError:
|
||||
pass
|
||||
tail = (self.stderr_tail(handle) + "\n" + t.tail(t.join(d, "events.jsonl"), 1)[-600:]).strip()
|
||||
return ExecResult(last.strip(), self.exit_code(handle), thread,
|
||||
tail = (err_tail + "\n" + last_event[-600:]).strip()
|
||||
return ExecResult((last or "").strip(), parse_exit(exit_text), thread,
|
||||
self.logs(handle, "events.jsonl", "stderr.txt", "last.md"), tail)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Shell/script executor: runs `{script}` with the prompt on stdin. The last message is `last.md` in the run
|
||||
directory if the script wrote it (its path is passed as {out}), else the tail of stdout. No threads."""
|
||||
from .base import ExecResult, RunnerExecutor
|
||||
from .base import ExecResult, RunnerExecutor, parse_exit
|
||||
|
||||
|
||||
class ShellExecutor(RunnerExecutor):
|
||||
@@ -10,10 +10,11 @@ class ShellExecutor(RunnerExecutor):
|
||||
def resume(self):
|
||||
return False
|
||||
|
||||
COLLECT = [("read", "exit.txt"), ("read", "last.md"), ("read", "stdout.txt"), ("tail", "stderr.txt", 5)]
|
||||
|
||||
def collect(self, handle):
|
||||
t, d = self.transport, handle["run_dir"]
|
||||
last = t.read_text(t.join(d, "last.md"))
|
||||
exit_text, last, stdout, tail = self.fetch(handle, self.COLLECT) # one remote call
|
||||
if last is None:
|
||||
last = (t.read_text(t.join(d, "stdout.txt")) or "")[-6000:]
|
||||
return ExecResult(last.strip(), self.exit_code(handle), None,
|
||||
self.logs(handle, "stdout.txt", "stderr.txt"), self.stderr_tail(handle))
|
||||
last = (stdout or "")[-6000:]
|
||||
return ExecResult(last.strip(), parse_exit(exit_text), None,
|
||||
self.logs(handle, "stdout.txt", "stderr.txt"), tail)
|
||||
|
||||
@@ -4,21 +4,162 @@ Interface (duck-typed, see docs/executor-contract.md):
|
||||
os, join(*parts), quote(s), default_run_root()
|
||||
mkdir(path, clean=False), write_text(path, text), read_text(path) -> str|None,
|
||||
grep_first(path, needle) -> str|None, tail(path, n) -> str
|
||||
fetch(run_dir, ops) -> [result per op] (ops: ("read", name) | ("grep", name, needle) | ("tail", name, n))
|
||||
runner(run_dir, cwd, command, stdout_name) -> (script_name, script_text)
|
||||
launch(task, script_path, run_dir), kill(task, run_dir), forget(task)
|
||||
process_count(name) -> int|None (None = unreachable)"""
|
||||
start_run(task, run_dir, files, script_name) (remote only), launch(task, script_path, run_dir), kill(task, run_dir),
|
||||
forget(task), process_count(name) -> int|None (None = unreachable)
|
||||
|
||||
Remote terminals are reached through a channel (design §9): `warpgate-ssh` (agent-access.py run through the
|
||||
Warpgate gateway) or `direct-ssh` (OpenSSH over Tailscale with a dedicated key, optional warpgate fallback)."""
|
||||
import base64
|
||||
import functools
|
||||
import re
|
||||
import time
|
||||
|
||||
|
||||
class TransportError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def make_transport(cfg, terminal):
|
||||
kind = cfg.terminals[terminal]["transport"]
|
||||
class Unreached(TransportError):
|
||||
"""The remote command certainly did not run (connect / auth / agent failure, direct path unavailable): the
|
||||
only kind of failure a direct-ssh terminal may repeat on its fallback channel."""
|
||||
|
||||
|
||||
class HostKeyError(TransportError):
|
||||
"""The terminal presented a host key other than the pinned one: a security error, never retried elsewhere."""
|
||||
|
||||
|
||||
class CallLog:
|
||||
"""One line per remote call -> `path` (remote.log); notable events (fallback, host key) also -> `event`."""
|
||||
|
||||
def __init__(self, path=None, event=None, echo=False):
|
||||
self.path, self.event_fn, self.echo = path, event, echo
|
||||
self._once = set()
|
||||
|
||||
def call(self, terminal, via, op, ms, rc, note=""):
|
||||
line = "term=%s via=%s op=%s ms=%d rc=%s%s" % (terminal, via, op or "-", ms, rc, " " + note if note else "")
|
||||
if self.echo:
|
||||
print(line, flush=True)
|
||||
if self.path:
|
||||
try:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.path, "a", encoding="utf-8") as fh:
|
||||
fh.write(time.strftime("%Y-%m-%dT%H:%M:%S") + " " + line + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def event(self, msg, once=None):
|
||||
"""`once` = a key: the message is logged only the first time per process."""
|
||||
if once is not None:
|
||||
if once in self._once:
|
||||
return
|
||||
self._once.add(once)
|
||||
if self.event_fn:
|
||||
self.event_fn(msg)
|
||||
|
||||
|
||||
NULL_LOG = CallLog()
|
||||
|
||||
|
||||
def operation(name):
|
||||
"""Method decorator: names the remote operation for the call log (innermost wins, restored on return)."""
|
||||
def deco(fn):
|
||||
@functools.wraps(fn)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
prev, self._op = getattr(self, "_op", None), name
|
||||
try:
|
||||
return fn(self, *args, **kwargs)
|
||||
finally:
|
||||
self._op = prev
|
||||
return wrapper
|
||||
return deco
|
||||
|
||||
|
||||
def fetch_each(t, run_dir, ops):
|
||||
"""`fetch` built from the single-file primitives (one call per op): local transport and fallbacks."""
|
||||
out = []
|
||||
for o in ops:
|
||||
path = t.join(run_dir, o[1])
|
||||
if o[0] == "read":
|
||||
out.append(t.read_text(path))
|
||||
elif o[0] == "grep":
|
||||
out.append(t.grep_first(path, o[2]))
|
||||
elif o[0] == "tail":
|
||||
out.append(t.tail(path, o[2]))
|
||||
else:
|
||||
raise ValueError("unknown fetch op %r" % (o,))
|
||||
return out
|
||||
|
||||
|
||||
def parse_fetch(out, ops, what=""):
|
||||
"""Decode the `F<i>=<base64>` / `M<i>` (missing) lines of a remote fetch script into results per op:
|
||||
read -> text|None, grep -> line|None, tail -> text ("" when missing)."""
|
||||
res = []
|
||||
for i, o in enumerate(ops):
|
||||
m = re.search(r"^F%d=([A-Za-z0-9+/=]*)$" % i, out, re.M)
|
||||
if m:
|
||||
text = base64.b64decode(m.group(1)).decode("utf-8", "replace") if m.group(1) else ""
|
||||
elif re.search(r"^M%d$" % i, out, re.M):
|
||||
text = None
|
||||
else:
|
||||
raise TransportError("fetch %s: unexpected output: %s" % (what, out.strip()[-200:]))
|
||||
if o[0] == "grep":
|
||||
text = (text or "").rstrip("\n") or None
|
||||
elif o[0] == "tail":
|
||||
text = (text or "").rstrip("\n")
|
||||
res.append(text)
|
||||
return res
|
||||
|
||||
|
||||
class RoutedTransport:
|
||||
"""direct-ssh terminal: every operation runs on the primary (direct) transport; when it fails before the remote
|
||||
command ran (`Unreached`) the operation is repeated on the fallback (warpgate-ssh) transport, which then serves
|
||||
the rest of this transport's life (one tick). Each direct operation is a single remote call, so a repeat never
|
||||
doubles a side effect. Host-key failures and failures after the command may have run propagate unchanged."""
|
||||
PURE = ("join", "quote", "default_run_root", "runner")
|
||||
|
||||
def __init__(self, terminal, primary, fallback=None, log=None):
|
||||
self.terminal, self.primary, self.fallback = terminal, primary, fallback
|
||||
self.log = log or NULL_LOG
|
||||
self.on_fallback = False
|
||||
|
||||
def __getattr__(self, name):
|
||||
attr = getattr(self.primary, name)
|
||||
if name in self.PURE or not callable(attr):
|
||||
return attr
|
||||
|
||||
def routed(*args, **kwargs):
|
||||
if self.on_fallback:
|
||||
return getattr(self.fallback, name)(*args, **kwargs)
|
||||
try:
|
||||
return attr(*args, **kwargs)
|
||||
except Unreached as exc:
|
||||
if self.fallback is None:
|
||||
raise
|
||||
self.on_fallback = True
|
||||
self.log.event("%s: direct-ssh failed before the command ran (%s); using the warpgate-ssh fallback "
|
||||
"for the rest of this tick" % (self.terminal, exc))
|
||||
return getattr(self.fallback, name)(*args, **kwargs)
|
||||
return routed
|
||||
|
||||
|
||||
def make_transport(cfg, terminal, log=None, force=None):
|
||||
"""`force` = direct-ssh | warpgate-ssh: that channel only, no fallback (agentd ping)."""
|
||||
t = cfg.terminals[terminal]
|
||||
kind = force or t["transport"]
|
||||
if kind == "local":
|
||||
from .local import LocalTransport
|
||||
return LocalTransport(cfg, terminal)
|
||||
if kind == "warpgate-ssh":
|
||||
from .warpgate_ssh import WarpgateSshTransport
|
||||
return WarpgateSshTransport(cfg, terminal)
|
||||
return WarpgateSshTransport(cfg, terminal, log=log)
|
||||
if kind == "direct-ssh":
|
||||
from .direct_ssh import DirectChannel
|
||||
from .warpgate_ssh import WarpgateSshTransport
|
||||
primary = WarpgateSshTransport(cfg, terminal, channel=DirectChannel(cfg, terminal, log))
|
||||
fallback = None
|
||||
if not force and t.get("fallback") == "warpgate-ssh":
|
||||
fallback = WarpgateSshTransport(cfg, terminal, log=log, label="fallback")
|
||||
return RoutedTransport(terminal, primary, fallback, log)
|
||||
raise TransportError("unknown transport %s" % kind)
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
"""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 # "/<pin8>-<%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 </dev/null; s=$(cat <&3); exec 3<&-; (eval "$s"); r=$?; '
|
||||
'[ $r = 255 ] && r=254; exit $r')
|
||||
|
||||
|
||||
def ps_payload(script):
|
||||
return (base64.b64encode(script.encode("utf-8")).decode("ascii") + "\n").encode("ascii")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- failure classification
|
||||
# A host offering no ssh-ed25519 key at all counts as a host-key failure too (a downgrade is a security signal).
|
||||
HOSTKEY = re.compile(r"Host key verification failed|REMOTE HOST IDENTIFICATION HAS CHANGED|"
|
||||
r"No \S+ host key is known for|host key for \S+ has changed|no matching host key type", re.I)
|
||||
# ssh exit 255 with one of these = no remote session ran the command. Only ssh's own messages, anchored at the start
|
||||
# of a line (stderr also carries the remote command's stderr).
|
||||
UNREACHED = re.compile(r"^(?:ssh: connect to host |ssh: Could not resolve hostname |banner exchange: |"
|
||||
r"Connection timed out during banner exchange|kex_exchange_identification: |"
|
||||
r"Connection (?:closed|reset) by \S+ port \d+\s*$|"
|
||||
r"\S+: Permission denied \((?:publickey|password|keyboard-interactive|hostbased|gssapi)|"
|
||||
r"Received disconnect from .*Too many authentication failures|"
|
||||
r"sign_and_send_pubkey: |error fetching identities|Error connecting to agent|"
|
||||
r"Unable to negotiate with |Session open refused by peer)", re.I | re.M)
|
||||
|
||||
|
||||
def classify(rc, err, out=""):
|
||||
"""-> '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=<vault key>)" % (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)
|
||||
@@ -91,5 +91,5 @@ class LocalTransport:
|
||||
pass
|
||||
|
||||
def process_count(self, name):
|
||||
out = subprocess.run(["pgrep", "-c", "-x", name], capture_output=True, text=True)
|
||||
out = subprocess.run(["pgrep", "-c", "-x", name], capture_output=True, text=True, env=self.cfg.scrubbed_env())
|
||||
return int(out.stdout.strip() or 0)
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
"""Terminals behind Warpgate, reached through the server-access relay client:
|
||||
"""Remote terminals (Windows and Linux/macOS) and the Warpgate channel.
|
||||
|
||||
The transport classes build the scripts; a *channel* carries them (design §9):
|
||||
- WarpgateChannel (`transport: warpgate-ssh`, also the direct-ssh fallback): the server-access relay client
|
||||
|
||||
agent-access.py --config <identity> run --target <target> --timeout N -- <argv...>
|
||||
|
||||
The client shlex-joins argv and the target account's login shell runs it (cmd.exe on Windows); stdin is
|
||||
/dev/null, so everything travels in argv. `relay()` is the single place that builds that call.
|
||||
The client shlex-joins argv and the target account's login shell runs it (cmd.exe on Windows); stdin is
|
||||
/dev/null (agent-access.py run_remote uses stdin=DEVNULL), so everything travels in argv. `relay()` is the single
|
||||
place that builds that call.
|
||||
- DirectChannel (`transport: direct-ssh`, agentd/transports/direct_ssh.py): OpenSSH over Tailscale; the script goes
|
||||
on stdin, so every operation (including a launch with its prompt) is one call.
|
||||
|
||||
- os linux / darwin -> PosixWarpgateTransport: every operation is one `bash -c '<script>'` relay call. Files go up
|
||||
as base64 chunks (`printf %s '<b64>' | base64 -d`), the runner starts detached with `setsid nohup bash run.sh`
|
||||
(its own session: pid == pgid == sid), `poll` reads exit.txt, `kill` signals the process group and session.
|
||||
- os windows -> WindowsWarpgateTransport: still drives the helper scripts win10-ps.sh (PowerShell
|
||||
-EncodedCommand) and win10-put.sh (chunked upload, SHA-256 verified), which hard-code `--target win10`;
|
||||
runs are one-shot scheduled tasks (schtasks) so they outlive the SSH session.
|
||||
TODO: move Windows onto relay() (`powershell -EncodedCommand` as argv) and drop the helper scripts."""
|
||||
- os linux / darwin -> PosixWarpgateTransport: every operation is one `bash -c '<script>'` call. On the relay files go
|
||||
up as base64 chunks (`printf %s '<b64>' | base64 -d`), on the direct channel in the same single call. The runner
|
||||
starts detached with `setsid nohup bash run.sh` (its own session: pid == pgid == sid), `poll` reads exit.txt,
|
||||
`fetch` returns everything `collect` needs in one call, `kill` signals the process group and session.
|
||||
- os windows -> WindowsWarpgateTransport: PowerShell scripts; runs are one-shot scheduled tasks (schtasks) so they
|
||||
outlive the SSH session. On the relay it still drives the helper scripts win10-ps.sh (PowerShell -EncodedCommand,
|
||||
argv-size bound) and win10-put.sh (chunked upload, SHA-256 verified), which hard-code `--target win10`; on the
|
||||
direct channel mkdir + clean + prompt.md + run.ps1 + schtasks is one call.
|
||||
The class names are historical: the channel, not the class, decides the path."""
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
@@ -20,8 +28,9 @@ import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from . import TransportError
|
||||
from . import NULL_LOG, TransportError, Unreached, fetch_each, operation, parse_fetch
|
||||
from .local import RUNNER as POSIX_RUNNER, sh_quote
|
||||
|
||||
DEFAULT_CLIENT = "~/.local/share/server-access-client/agent-access.py"
|
||||
@@ -55,6 +64,55 @@ def relay(cfg, target, argv, timeout=150):
|
||||
return call(cfg, relay_argv(cfg, target, argv, timeout), timeout)
|
||||
|
||||
|
||||
PS_ARGV_MAX = 7600 # win10-ps.sh refuses longer -EncodedCommand values (cmd.exe line limit 8191)
|
||||
PS_PRELUDE = 120 # characters win10-ps.sh puts in front of every script
|
||||
|
||||
|
||||
class WarpgateChannel:
|
||||
"""Calls through agent-access.py run (argv only). Each call is logged with its duration."""
|
||||
stdin = False
|
||||
|
||||
def __init__(self, cfg, terminal, log=None, label="warpgate"):
|
||||
self.cfg, self.terminal, self.log, self.via = cfg, terminal, log or NULL_LOG, label
|
||||
t = cfg.terminals[terminal]
|
||||
self.target = t.get("target", terminal)
|
||||
self.name = "relay %s" % self.target
|
||||
if t["os"] == "windows":
|
||||
helpers = cfg.helpers_dir(terminal)
|
||||
self.ps_script, self.put_script = str(helpers / "win10-ps.sh"), str(helpers / "win10-put.sh")
|
||||
|
||||
def timed(self, op, fn, *args):
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
res = fn(*args)
|
||||
except TransportError:
|
||||
self.log.call(self.terminal, self.via, op, (time.monotonic() - t0) * 1000, "error")
|
||||
raise
|
||||
self.log.call(self.terminal, self.via, op, (time.monotonic() - t0) * 1000, res[0])
|
||||
return res
|
||||
|
||||
def fits(self, script):
|
||||
"""Whether win10-ps.sh accepts the script (its -EncodedCommand stays under PS_ARGV_MAX)."""
|
||||
units = len(script.encode("utf-16-le")) // 2 + PS_PRELUDE
|
||||
return (2 * units + 2) // 3 * 4 <= PS_ARGV_MAX
|
||||
|
||||
def ps(self, script, timeout, op=None):
|
||||
rc, out, err = self.timed(op, call, self.cfg, [self.ps_script, script], timeout, {"WIN10_TIMEOUT": str(timeout)})
|
||||
if rc != 0 and not out.strip():
|
||||
raise TransportError("relay exit %s: %s" % (rc, err.strip()[-300:]))
|
||||
return out
|
||||
|
||||
def put(self, local, remote, op=None):
|
||||
with open(local, "rb") as fh:
|
||||
sha = hashlib.sha256(fh.read()).hexdigest()
|
||||
rc, out, err = self.timed(op, call, self.cfg, [self.put_script, local, remote], 540)
|
||||
if ("remote sha256 " + sha) not in out:
|
||||
raise TransportError("upload of %s failed: %s" % (remote, (out + err).strip()[-300:]))
|
||||
|
||||
def sh(self, script, timeout, op=None):
|
||||
return self.timed(op, relay, self.cfg, self.target, ["bash", "-c", script], timeout)
|
||||
|
||||
|
||||
RUNNER = r"""$ErrorActionPreference = 'Continue'
|
||||
[Console]::OutputEncoding = [Text.Encoding]::UTF8
|
||||
$OutputEncoding = [Text.Encoding]::UTF8
|
||||
@@ -68,6 +126,15 @@ Set-Content -Path '{d}\exit.txt' -Value ("exit=" + $LASTEXITCODE + " at " + (Get
|
||||
"""
|
||||
|
||||
|
||||
# Windows fetch helpers: F<i>=<base64> per result, M<i> = file missing (parse_fetch). Names must not collide with
|
||||
# PowerShell's built-in aliases (aliases win over functions: `r` is Invoke-History).
|
||||
PS_FETCH = r"""function AdRead($i,$n){$p=Join-Path $d $n; if(Test-Path -LiteralPath $p){"F$i="+[Convert]::ToBase64String([IO.File]::ReadAllBytes($p))}else{"M$i"}}
|
||||
function AdOut($i,$t){"F$i="+[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$t))}
|
||||
function AdGrep($i,$n,$x){$p=Join-Path $d $n; $m=$null; if(Test-Path -LiteralPath $p){$m=Select-String -LiteralPath $p -SimpleMatch -Pattern $x|Select-Object -First 1}; if($m){AdOut $i $m.Line}else{"F$i="}}
|
||||
function AdTail($i,$n,$k){$p=Join-Path $d $n; $t=''; if(Test-Path -LiteralPath $p){$t=(Get-Content -LiteralPath $p -Encoding UTF8 -Tail $k) -join "`n"}; AdOut $i $t}
|
||||
"""
|
||||
|
||||
|
||||
def _b64_payload(out, marker):
|
||||
m = re.search(marker + r"=([A-Za-z0-9+/=]*)", out)
|
||||
if not m:
|
||||
@@ -78,12 +145,11 @@ def _b64_payload(out, marker):
|
||||
class WindowsWarpgateTransport:
|
||||
os = "windows"
|
||||
|
||||
def __init__(self, cfg, terminal):
|
||||
def __init__(self, cfg, terminal, channel=None, log=None, label="warpgate"):
|
||||
self.cfg, self.terminal = cfg, terminal
|
||||
t = cfg.terminals[terminal]
|
||||
self.target = t.get("target", terminal)
|
||||
helpers = cfg.helpers_dir(terminal)
|
||||
self.ps_script, self.put_script = str(helpers / "win10-ps.sh"), str(helpers / "win10-put.sh")
|
||||
self.channel = channel or WarpgateChannel(cfg, terminal, log, label)
|
||||
self.repo = t["repo"]
|
||||
|
||||
def default_run_root(self):
|
||||
@@ -95,32 +161,38 @@ class WindowsWarpgateTransport:
|
||||
def quote(self, s):
|
||||
return "'" + str(s).replace("'", "''") + "'"
|
||||
|
||||
# ---------------------------------------------------------------- relay primitives
|
||||
# ---------------------------------------------------------------- channel primitives
|
||||
def ps(self, script, timeout=150):
|
||||
rc, out, err = call(self.cfg, [self.ps_script, script], timeout, {"WIN10_TIMEOUT": str(timeout)})
|
||||
if rc != 0 and not out.strip():
|
||||
raise TransportError("relay exit %s: %s" % (rc, err.strip()[-300:]))
|
||||
return out
|
||||
return self.channel.ps(script, timeout, getattr(self, "_op", None))
|
||||
|
||||
def put(self, local, remote):
|
||||
with open(local, "rb") as fh:
|
||||
sha = hashlib.sha256(fh.read()).hexdigest()
|
||||
rc, out, err = call(self.cfg, [self.put_script, local, remote], 540)
|
||||
if ("remote sha256 " + sha) not in out:
|
||||
raise TransportError("upload of %s failed: %s" % (remote, (out + err).strip()[-300:]))
|
||||
self.channel.put(local, remote, getattr(self, "_op", None))
|
||||
|
||||
@operation("ping")
|
||||
def ping(self):
|
||||
out = self.ps("'PONG=' + $PSVersionTable.PSVersion.ToString() + ' ' + $env:COMPUTERNAME", timeout=60)
|
||||
m = re.search(r"PONG=[^\n]*", out)
|
||||
if not m:
|
||||
raise TransportError("ping %s: unexpected output: %s" % (self.terminal, out.strip()[-200:]))
|
||||
return m.group(0)
|
||||
|
||||
# ---------------------------------------------------------------- file ops
|
||||
def mkdir(self, path, clean=False):
|
||||
def mkdir_script(self, path, clean=False):
|
||||
q = self.quote(path)
|
||||
script = "New-Item -ItemType Directory -Force -Path %s | Out-Null\n" % q
|
||||
if clean: # never wipe a directory a runner has already started in
|
||||
script += ("if ((Test-Path -LiteralPath (Join-Path %s 'pid.txt')) -or (Test-Path -LiteralPath (Join-Path %s 'launch.pid'))) "
|
||||
"{ 'HASPID'; return }\n" % (q, q))
|
||||
script += "Get-ChildItem -LiteralPath %s -File | Remove-Item -Force\n" % q
|
||||
out = self.ps(script + "'MKDIR=ok'")
|
||||
return script
|
||||
|
||||
@operation("mkdir")
|
||||
def mkdir(self, path, clean=False):
|
||||
out = self.ps(self.mkdir_script(path, clean) + "'MKDIR=ok'")
|
||||
if "MKDIR=ok" not in out:
|
||||
raise TransportError("mkdir %s failed: %s" % (path, out.strip()[-300:]))
|
||||
|
||||
@operation("probe")
|
||||
def probe(self, run_dir, names):
|
||||
out = self.ps("$d = %s\n'PROBE=' + ((@(%s) | Where-Object { Test-Path -LiteralPath (Join-Path $d $_) }) -join ',')"
|
||||
% (self.quote(run_dir), ",".join(self.quote(n) for n in names)))
|
||||
@@ -129,7 +201,20 @@ class WindowsWarpgateTransport:
|
||||
raise TransportError("probe %s: unexpected relay output: %s" % (run_dir, out.strip()[-200:]))
|
||||
return [n for n in m.group(1).strip().split(",") if n]
|
||||
|
||||
def write_script(self, path, data):
|
||||
"""Write bytes exactly as given (UTF-8 without BOM for our texts), echo their SHA-256."""
|
||||
return ("$p = %s\n[IO.File]::WriteAllBytes($p, [Convert]::FromBase64String('%s'))\n"
|
||||
"'WROTE=' + (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash.ToLower()\n"
|
||||
% (self.quote(path), base64.b64encode(data).decode("ascii")))
|
||||
|
||||
@operation("write")
|
||||
def write_text(self, path, text):
|
||||
if self.channel.stdin:
|
||||
data = text.encode("utf-8")
|
||||
out = self.ps(self.write_script(path, data))
|
||||
if re.findall(r"WROTE=([0-9a-f]{64})", out) != [hashlib.sha256(data).hexdigest()]:
|
||||
raise TransportError("upload of %s failed: %s" % (path, out.strip()[-300:]))
|
||||
return
|
||||
fd, tmp = tempfile.mkstemp(prefix="agentd-", suffix=".txt")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8", newline="") as fh:
|
||||
@@ -138,6 +223,7 @@ class WindowsWarpgateTransport:
|
||||
finally:
|
||||
os.unlink(tmp)
|
||||
|
||||
@operation("read")
|
||||
def read_text(self, path):
|
||||
q = self.quote(path)
|
||||
out = self.ps("if (Test-Path -LiteralPath %s) { 'B64=' + [Convert]::ToBase64String([IO.File]::ReadAllBytes(%s)) }"
|
||||
@@ -147,8 +233,9 @@ class WindowsWarpgateTransport:
|
||||
text = _b64_payload(out, "B64")
|
||||
if text is None:
|
||||
raise TransportError("read %s: unexpected relay output" % path)
|
||||
return text.lstrip("")
|
||||
return text.lstrip("\ufeff")
|
||||
|
||||
@operation("grep")
|
||||
def grep_first(self, path, needle):
|
||||
q = self.quote(path)
|
||||
out = self.ps("$m = if (Test-Path -LiteralPath %s) { Select-String -LiteralPath %s -SimpleMatch -Pattern %s | Select-Object -First 1 }\n"
|
||||
@@ -156,12 +243,30 @@ class WindowsWarpgateTransport:
|
||||
% (q, q, self.quote(needle)))
|
||||
return _b64_payload(out, "B64") or None
|
||||
|
||||
@operation("tail")
|
||||
def tail(self, path, n):
|
||||
q = self.quote(path)
|
||||
out = self.ps("$t = if (Test-Path -LiteralPath %s) { (Get-Content -LiteralPath %s -Encoding UTF8 -Tail %d) -join \"`n\" } else { '' }\n"
|
||||
"'B64=' + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$t))" % (q, q, n))
|
||||
return _b64_payload(out, "B64") or ""
|
||||
|
||||
def fetch_script(self, run_dir, ops):
|
||||
lines = ["$d = %s" % self.quote(run_dir), PS_FETCH.rstrip("\n")]
|
||||
for i, o in enumerate(ops):
|
||||
fn = {"read": "AdRead", "grep": "AdGrep", "tail": "AdTail"}[o[0]]
|
||||
arg = "" if o[0] == "read" else " " + (self.quote(o[2]) if o[0] == "grep" else str(int(o[2])))
|
||||
lines.append("%s %d %s%s" % (fn, i, self.quote(o[1]), arg))
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
@operation("fetch")
|
||||
def fetch(self, run_dir, ops):
|
||||
"""Several reads of one run directory in one call (collect)."""
|
||||
script = self.fetch_script(run_dir, ops)
|
||||
if not self.channel.stdin and not self.channel.fits(script):
|
||||
return fetch_each(self, run_dir, ops)
|
||||
res = parse_fetch(self.ps(script, timeout=200), ops, run_dir)
|
||||
return [r.lstrip("\ufeff") if o[0] == "read" and r else r for o, r in zip(ops, res)]
|
||||
|
||||
# ---------------------------------------------------------------- processes
|
||||
def runner(self, run_dir, cwd, command, stdout_name):
|
||||
return "run.ps1", RUNNER.format(cwd=self.quote(cwd), d=run_dir.replace("'", "''"), command=command, stdout=stdout_name)
|
||||
@@ -176,23 +281,60 @@ class WindowsWarpgateTransport:
|
||||
"schtasks /Run /TN '%(t)s' | Out-Null\n"
|
||||
"'LAUNCHED=' + $LASTEXITCODE" % dict(t=task.replace("'", "''"), s=script_path.replace("'", "''")))
|
||||
|
||||
@operation("launch")
|
||||
def launch(self, task, script_path, run_dir):
|
||||
out = self.ps(self.launch_script(task, script_path))
|
||||
if "LAUNCHED=0" not in out:
|
||||
raise TransportError("schtasks launch of %s failed: %s" % (task, out.strip()[-300:]))
|
||||
|
||||
def start_script(self, task, run_dir, files, script_name):
|
||||
"""mkdir + clean (HASPID refusal) + every file + schtasks, as one script. -> (script, [sha256 per file])"""
|
||||
script, shas = "$ErrorActionPreference = 'Stop'\n" + self.mkdir_script(run_dir, clean=True), []
|
||||
for name, text in files.items():
|
||||
data = text.encode("utf-8")
|
||||
script += self.write_script(self.join(run_dir, name), data)
|
||||
shas.append(hashlib.sha256(data).hexdigest())
|
||||
script += "$ErrorActionPreference = 'Continue'\n" + self.launch_script(task, self.join(run_dir, script_name))
|
||||
return script, shas
|
||||
|
||||
@operation("start")
|
||||
def start_run(self, task, run_dir, files, script_name):
|
||||
"""Stage the run directory and start the task: one call on the direct channel; on the relay (argv only)
|
||||
mkdir, chunked uploads and the launch go one by one."""
|
||||
if not self.channel.stdin:
|
||||
self.mkdir(run_dir, clean=True)
|
||||
for name, text in files.items():
|
||||
self.write_text(self.join(run_dir, name), text)
|
||||
return self.launch(task, self.join(run_dir, script_name), run_dir)
|
||||
script, shas = self.start_script(task, run_dir, files, script_name)
|
||||
out = self.ps(script)
|
||||
if re.search(r"^HASPID$", out, re.M):
|
||||
raise TransportError("refusing to clean %s: it holds a pid file (HASPID)" % run_dir)
|
||||
wrote = re.findall(r"WROTE=([0-9a-f]{64})", out)
|
||||
if wrote != shas:
|
||||
raise TransportError("staging %s: hash mismatch (%d/%d files): %s" % (run_dir, len(wrote), len(shas),
|
||||
out.strip()[-300:]))
|
||||
if "LAUNCHED=0" not in out:
|
||||
raise TransportError("schtasks launch of %s failed: %s" % (task, out.strip()[-300:]))
|
||||
|
||||
@operation("kill")
|
||||
def kill(self, task, run_dir):
|
||||
t = task.replace("'", "''")
|
||||
self.ps("$p = Join-Path %s 'pid.txt'\n"
|
||||
"if (Test-Path $p) { taskkill /T /F /PID (Get-Content $p | Select-Object -First 1) 2>&1 | Out-Null }\n"
|
||||
"schtasks /End /TN '%s' 2>&1 | Out-Null\n"
|
||||
"schtasks /Delete /F /TN '%s' 2>&1 | Out-Null\n'KILLED=ok'" % (self.quote(run_dir), task, task))
|
||||
"schtasks /Delete /F /TN '%s' 2>&1 | Out-Null\n'KILLED=ok'" % (self.quote(run_dir), t, t))
|
||||
|
||||
@operation("forget")
|
||||
def forget(self, task):
|
||||
self.ps("schtasks /Delete /F /TN '%s' 2>&1 | Out-Null; 'FORGOT=ok'" % task)
|
||||
self.ps("schtasks /Delete /F /TN '%s' 2>&1 | Out-Null; 'FORGOT=ok'" % task.replace("'", "''"))
|
||||
|
||||
@operation("busy")
|
||||
def process_count(self, name):
|
||||
try:
|
||||
out = self.ps("'COUNT=' + @(Get-Process -Name %s -ErrorAction SilentlyContinue).Count" % self.quote(name))
|
||||
except Unreached:
|
||||
raise # a direct-ssh terminal retries this on its fallback
|
||||
except TransportError:
|
||||
return None
|
||||
m = re.search(r"COUNT=(\d+)", out)
|
||||
@@ -210,15 +352,17 @@ DETACH = {
|
||||
|
||||
|
||||
class PosixWarpgateTransport:
|
||||
"""Linux/macOS terminal through the relay. Each primitive is one `bash -c` round trip (~10 s through
|
||||
Vault + Warpgate), so `start_run` stages the run directory and launches it in a single call."""
|
||||
CHUNK = 60000 # base64 chars per call: one argv element must stay below 128 KiB (MAX_ARG_STRLEN) on both ends
|
||||
"""Linux/macOS terminal. Each primitive is one `bash -c` round trip (~10 s through Vault + Warpgate, well under
|
||||
a second on a reused direct connection), so `start_run` stages the run directory and launches it in a single
|
||||
call and `fetch` reads everything `collect` needs in one."""
|
||||
CHUNK = 60000 # relay only: base64 chars per call; one argv element must stay below 128 KiB (MAX_ARG_STRLEN)
|
||||
|
||||
def __init__(self, cfg, terminal):
|
||||
def __init__(self, cfg, terminal, channel=None, log=None, label="warpgate"):
|
||||
self.cfg, self.terminal = cfg, terminal
|
||||
t = cfg.terminals[terminal]
|
||||
self.os = t["os"]
|
||||
self.target = t.get("target", terminal)
|
||||
self.channel = channel or WarpgateChannel(cfg, terminal, log, label)
|
||||
self.repo = t["repo"]
|
||||
|
||||
def default_run_root(self):
|
||||
@@ -230,14 +374,19 @@ class PosixWarpgateTransport:
|
||||
def quote(self, s):
|
||||
return sh_quote(s)
|
||||
|
||||
# ---------------------------------------------------------------- relay primitive
|
||||
# ---------------------------------------------------------------- channel primitive
|
||||
def sh(self, script, timeout=150, marker=None):
|
||||
"""Run a bash script on the target. With `marker`, its absence in stdout is an error."""
|
||||
rc, out, err = relay(self.cfg, self.target, ["bash", "-c", script], timeout)
|
||||
rc, out, err = self.channel.sh(script, timeout, getattr(self, "_op", None))
|
||||
if (marker and marker not in out) or (not marker and rc != 0 and not out.strip()):
|
||||
raise TransportError("relay %s exit %s: %s" % (self.target, rc, (out + err).strip()[-300:]))
|
||||
raise TransportError("%s exit %s: %s" % (self.channel.name, rc, (out + err).strip()[-300:]))
|
||||
return out
|
||||
|
||||
@operation("ping")
|
||||
def ping(self):
|
||||
out = self.sh("echo \"PONG=$(uname -sr) $(uname -n)\"", timeout=60, marker="PONG=")
|
||||
return re.search(r"PONG=[^\n]*", out).group(0)
|
||||
|
||||
# ---------------------------------------------------------------- file ops
|
||||
def mkdir_script(self, path, clean=False):
|
||||
q = self.quote(path)
|
||||
@@ -248,6 +397,7 @@ class PosixWarpgateTransport:
|
||||
s += "find %s -maxdepth 1 -type f -exec rm -f {} + || exit 1\n" % q
|
||||
return s
|
||||
|
||||
@operation("mkdir")
|
||||
def mkdir(self, path, clean=False):
|
||||
self.sh(self.mkdir_script(path, clean) + "echo MKDIR=ok\n", marker="MKDIR=ok")
|
||||
|
||||
@@ -261,10 +411,11 @@ class PosixWarpgateTransport:
|
||||
return ("printf %%s '%s' | base64 -d > %s && mv -f %s %s || exit 1\n"
|
||||
"echo \"WROTE=$(wc -c < %s | tr -d ' ')\"\n" % (b64, part, part, q, q))
|
||||
|
||||
@operation("write")
|
||||
def write_text(self, path, text):
|
||||
data = self.b64(text)
|
||||
size = len(text.encode("utf-8"))
|
||||
if len(data) <= self.CHUNK:
|
||||
if len(data) <= self.CHUNK or self.channel.stdin: # stdin carries the script: no argv limit
|
||||
out = self.sh(self.put_script(path, data), marker="WROTE=")
|
||||
else: # append chunks to <path>.b64, decode on the last call
|
||||
q, qb = self.quote(path), self.quote(path + ".b64")
|
||||
@@ -279,6 +430,7 @@ class PosixWarpgateTransport:
|
||||
if re.findall(r"WROTE=(\d+)", out) != [str(size)]:
|
||||
raise TransportError("upload of %s: size mismatch (%s)" % (path, out.strip()[-200:]))
|
||||
|
||||
@operation("read")
|
||||
def read_text(self, path):
|
||||
q = self.quote(path)
|
||||
out = self.sh("if [ -f %s ]; then printf B64=; base64 < %s | tr -d '\\n'; echo; else echo MISSING; fi" % (q, q),
|
||||
@@ -289,6 +441,7 @@ class PosixWarpgateTransport:
|
||||
raise TransportError("read %s: unexpected relay output" % path)
|
||||
return _b64_payload(out, "B64")
|
||||
|
||||
@operation("probe")
|
||||
def probe(self, run_dir, names):
|
||||
d = self.quote(run_dir)
|
||||
out = self.sh("printf PROBE=; for f in %s; do [ -e %s/\"$f\" ] && printf '%%s,' \"$f\"; done; echo"
|
||||
@@ -302,13 +455,33 @@ class PosixWarpgateTransport:
|
||||
raise TransportError("relay %s: unexpected output" % self.target)
|
||||
return text
|
||||
|
||||
@operation("grep")
|
||||
def grep_first(self, path, needle):
|
||||
line = self._b64_cmd("grep -F -m1 -e %s %s" % (self.quote(needle), self.quote(path)))
|
||||
return line.rstrip("\n") or None
|
||||
|
||||
@operation("tail")
|
||||
def tail(self, path, n):
|
||||
return self._b64_cmd("tail -n %d %s" % (int(n), self.quote(path))).rstrip("\n")
|
||||
|
||||
def fetch_script(self, run_dir, ops):
|
||||
s = "d=%s\n" % self.quote(run_dir)
|
||||
for i, o in enumerate(ops):
|
||||
f = "\"$d\"/%s" % self.quote(o[1])
|
||||
if o[0] == "read":
|
||||
s += ("if [ -f %s ]; then printf F%d=; base64 < %s | tr -d '\\n'; echo; else echo M%d; fi\n"
|
||||
% (f, i, f, i))
|
||||
else:
|
||||
cmd = ("grep -F -m1 -e %s %s" % (self.quote(o[2]), f) if o[0] == "grep"
|
||||
else "tail -n %d %s" % (int(o[2]), f))
|
||||
s += "printf F%d=; { %s; } 2>/dev/null | base64 | tr -d '\\n'; echo\n" % (i, cmd)
|
||||
return s + "echo FETCH=ok\n"
|
||||
|
||||
@operation("fetch")
|
||||
def fetch(self, run_dir, ops):
|
||||
"""Several reads of one run directory in one call (collect)."""
|
||||
return parse_fetch(self.sh(self.fetch_script(run_dir, ops), timeout=200, marker="FETCH=ok"), ops, run_dir)
|
||||
|
||||
# ---------------------------------------------------------------- processes
|
||||
def runner(self, run_dir, cwd, command, stdout_name):
|
||||
return "run.sh", POSIX_RUNNER.format(cwd=self.quote(cwd), d=self.quote(run_dir), command=command,
|
||||
@@ -323,13 +496,15 @@ class PosixWarpgateTransport:
|
||||
"echo $! > launch.pid\n"
|
||||
"echo LAUNCHED=$!\n" % (d, DETACH[self.os] % self.quote(script_name)))
|
||||
|
||||
@operation("launch")
|
||||
def launch(self, task, script_path, run_dir):
|
||||
self.sh(self.launch_script(run_dir, posixpath.basename(script_path)), marker="LAUNCHED=")
|
||||
|
||||
@operation("start")
|
||||
def start_run(self, task, run_dir, files, script_name):
|
||||
"""mkdir + clean, write `files` {name: text}, launch `script_name` -- one relay call unless a file is
|
||||
larger than CHUNK (those go up first through write_text)."""
|
||||
big = {n: t for n, t in files.items() if len(self.b64(t)) > self.CHUNK}
|
||||
"""mkdir + clean, write `files` {name: text}, launch `script_name` -- one call; on the relay a file larger
|
||||
than CHUNK goes up first through write_text."""
|
||||
big = {} if self.channel.stdin else {n: t for n, t in files.items() if len(self.b64(t)) > self.CHUNK}
|
||||
script = self.mkdir_script(run_dir, clean=True)
|
||||
if big:
|
||||
self.mkdir(run_dir, clean=True)
|
||||
@@ -359,23 +534,26 @@ class PosixWarpgateTransport:
|
||||
"kill -KILL -- \"-$p\" 2>/dev/null; pkill -KILL -s \"$p\" 2>/dev/null\n"
|
||||
"sleep 1; echo \"KILLED=ok left=$(pgrep -s \"$p\" 2>/dev/null | wc -l | tr -d ' ')\"\n" % d)
|
||||
|
||||
@operation("kill")
|
||||
def kill(self, task, run_dir):
|
||||
self.sh(self.kill_script(run_dir), marker="KILLED=")
|
||||
|
||||
def forget(self, task):
|
||||
pass
|
||||
|
||||
@operation("busy")
|
||||
def process_count(self, name):
|
||||
try:
|
||||
out = self.sh("echo COUNT=$(pgrep -x %s | wc -l | tr -d ' ')" % self.quote(name), timeout=60)
|
||||
except Unreached:
|
||||
raise # a direct-ssh terminal retries this on its fallback
|
||||
except TransportError:
|
||||
return None
|
||||
m = re.search(r"COUNT=(\d+)", out)
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
|
||||
def WarpgateSshTransport(cfg, terminal):
|
||||
"""Factory: the transport class for a warpgate-ssh terminal follows its `os`."""
|
||||
if cfg.terminals[terminal]["os"] == "windows":
|
||||
return WindowsWarpgateTransport(cfg, terminal)
|
||||
return PosixWarpgateTransport(cfg, terminal)
|
||||
def WarpgateSshTransport(cfg, terminal, channel=None, log=None, label="warpgate"):
|
||||
"""Factory: the transport class for a remote terminal follows its `os`; the channel defaults to Warpgate."""
|
||||
cls = WindowsWarpgateTransport if cfg.terminals[terminal]["os"] == "windows" else PosixWarpgateTransport
|
||||
return cls(cfg, terminal, channel=channel, log=log, label=label)
|
||||
|
||||
Reference in New Issue
Block a user