okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.petssh: stage only sources on a remote host, not tool cachesc9b9960

main
14.4 KiB403 linesraw
1"""One ssh layer for albedo: the daemon's remote kernels and the model's remote plugin.
2
3Both ride the same multiplexed control connection per host: the ControlPath
4the user's ssh config names, so their own ssh, colmena and git share it too,
5or `/tmp/albedo-ssh-cm/%C` when it names none. BatchMode stays on: a host
6that needs a passphrase, a second factor or a new host key answers
7`needs_auth`, and the tui opens the master in the terminal instead.
8
9    albedo_ssh.py probe <[user@]host>
10    albedo_ssh.py commands <[user@]host> <home>
11    albedo_ssh.py hosts
12
13`probe` prints `{"step": "staging"}` on a line of its own before it copies
14the bundle over, so a waiting client can say so, then one JSON object:
15`{state, detail, os, arch, home, cpus, python}` and, once the home is known,
16the `commands` answer: `{argv, control, bundle, bridge, signal, remove,
17gather, auth_sock}`, the complete remote commands the daemon runs there.
18state is ready, needs_auth, unreachable or unsupported. A ready probe has checked python >= 3.11 and staged the content-hashed bundle
19under `~/.albedo-remote/<digest>`. `commands` needs no network, for a host
20known only from a kernel's record while ssh is down. `hosts` lists the `Host`
21names of ~/.ssh/config and its includes, patterns left out.
22"""
23
24from __future__ import annotations
25
26import glob
27import io
28import json
29import os
30import shlex
31import subprocess
32import sys
33import tempfile
34from collections.abc import Callable
35
36import albedo_bundle
37
38CONNECT_TIMEOUT = 10  # seconds ssh may take to reach a host
39PROBE_TIMEOUT = 30  # seconds for the whole probe command
40STAGE_TIMEOUT = 120  # seconds to move the bundle over one ssh stream
41MINIMUM_PYTHON = (3, 11)
42# host -> (ssh -o values, environment) for hosts named by alias()
43ALIASES: dict[str, tuple[list[str], dict[str, str]]] = {}
44STAGED_DIR = ".albedo-remote"
45
46
47def control_dir() -> str:
48    """Control sockets live under /tmp when it fits, else the temp dir: unix
49    socket paths are capped (~104 bytes on macOS) and %C hashes to 40 more,
50    so neither ALBEDO_HOME nor macOS's deep $TMPDIR may be on the path."""
51    control = os.path.join("/tmp", "albedo-ssh-cm")
52    if len(control) + 42 > 104:
53        control = os.path.join(tempfile.gettempdir(), "albedo-ssh-cm")
54    return control
55
56
57def multiplexing(target: str) -> tuple[str, str]:
58    """The control path and ControlPersist for `target`: the user's own when
59    their ssh config names a ControlPath, so a master they or their tools
60    opened carries albedo too and albedo's carries theirs; else albedo's.
61    `ssh -G` only reads the config."""
62    try:
63        done = subprocess.run(
64            ["ssh", "-G", target],
65            stdin=subprocess.DEVNULL,
66            capture_output=True,
67            text=True,
68            timeout=5,
69        )
70        lines = done.stdout.splitlines() if done.returncode == 0 else []
71    except (OSError, subprocess.SubprocessError):
72        lines = []
73    settings = {}
74    for line in lines:
75        key, _, value = line.partition(" ")
76        settings[key] = value
77    path = settings.get("controlpath", "none")
78    if path == "none":
79        return f"{control_dir()}/%C", "600"
80    persist = settings.get("controlpersist", "no")
81    # -G expanded the path's tokens; a % left in it is literal
82    return path.replace("%", "%%"), "600" if persist in ("no", "0") else persist
83
84
85def alias(host: str, ssh_options: list[str], environment: dict[str, str]) -> None:
86    """Name a host this process reaches without ~/.ssh/config, as a ProxyCommand
87    does: `ssh_options` are `-o` values, `environment` is added for ssh and the
88    command it spawns, which is how a token stays out of the command line."""
89    ALIASES[host] = (ssh_options, environment)
90
91
92def unalias(host: str) -> None:
93    ALIASES.pop(host, None)
94
95
96def base(target: str) -> list[str]:
97    """The ssh argv prefix for `target`, up to the target itself. An alias's
98    options come first: ssh keeps the first value it is given for an option."""
99    argv = options(*multiplexing(target))
100    named = ALIASES.get(target, ([], {}))[0]
101    return [argv[0], *(arg for value in named for arg in ("-o", value)), *argv[1:]]
102
103
104def sign_in(target: str) -> list[str]:
105    """What a person runs in a terminal to sign in to `target` where ssh can
106    ask them: it opens the master albedo rides, which outlives it."""
107    control, persist = multiplexing(target)
108    return [
109        "ssh",
110        "-M",
111        "-fN",
112        "-o",
113        f"ControlPath={control}",
114        "-o",
115        f"ControlPersist={persist}",
116        target,
117    ]
118
119
120def options(control: str, persist: str) -> list[str]:
121    """The ssh argv prefix riding the master at `control`."""
122    os.makedirs(control_dir(), exist_ok=True)
123    return [
124        "ssh",
125        "-o",
126        "BatchMode=yes",
127        "-o",
128        f"ConnectTimeout={CONNECT_TIMEOUT}",
129        "-o",
130        "StrictHostKeyChecking=accept-new",
131        "-o",
132        "ControlMaster=auto",
133        "-o",
134        f"ControlPath={control}",
135        "-o",
136        f"ControlPersist={persist}",
137    ]
138
139
140def agent_socket() -> str | None:
141    """SSH_AUTH_SOCK, or a standard agent socket when it was not inherited, so
142    hardware keys, 1Password, and ssh-agent work out of the box."""
143    if "SSH_AUTH_SOCK" in os.environ:
144        return os.environ["SSH_AUTH_SOCK"]
145    for candidate in (
146        os.path.expanduser("~/.ssh/agent.sock"),
147        os.path.expanduser(
148            "~/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock"
149        ),
150    ):
151        if os.path.exists(candidate):
152            return candidate
153    return None
154
155
156def env(target: str | None = None) -> dict[str, str]:
157    """The local environment for ssh client processes, with the alias's for `target`."""
158    environment = {**os.environ, **ALIASES.get(target or "", ([], {}))[1]}
159    socket = agent_socket()
160    if socket is not None:
161        environment["SSH_AUTH_SOCK"] = socket
162    return environment
163
164
165def in_login_shell(script: str) -> str:
166    """The remote command that runs `script` under the user's login shell.
167
168    Non-interactive SSH commands run under `$SHELL -c` without sourcing
169    `/etc/profile` or user profiles, which leaves PATH minimal (missing
170    Nix, Homebrew, or user tool paths); a login shell finds python3 and the
171    user's tools. Profiles often print (a motd, a greeting), which would
172    corrupt a framed stdout and fill the daemon's log on every command, so
173    the login shell starts with both on /dev/null and the real ones kept on
174    fds 3 and 4, and `script` gets them back. The outer `/bin/sh` makes the
175    redirections work whatever the user's shell is.
176    """
177    login = f"exec 1>&3 2>&4 3>&- 4>&-; {script}"
178    outer = (
179        "exec 3>&1 4>&2 1>/dev/null 2>&1; "
180        f'exec "${{SHELL:-/bin/sh}}" -l -c {shlex.quote(login)}'
181    )
182    return f"/bin/sh -c {shlex.quote(outer)}"
183
184
185def commands(target: str, home: str) -> dict[str, object]:
186    """Everything the daemon runs on a host whose home is known, as complete
187    remote commands: it never quotes anything itself. Variable inputs travel
188    on stdin (the bridge's first frame, the ladder's request line, the run
189    directory to remove), never in the command line."""
190    bundle = f"{home}/{staged_name()}"
191    python = "exec python3 -u "
192    control, persist = multiplexing(target)
193    answer: dict[str, object] = {
194        "host": target,
195        "argv": [*options(control, persist), target],
196        "control": control,
197        "bundle": bundle,
198        "bridge": in_login_shell(
199            python + shlex.quote(f"{bundle}/albedo_bridge.py") + " --frame"
200        ),
201        "signal": in_login_shell(
202            python + shlex.quote(f"{bundle}/albedo_signal.py") + " -"
203        ),
204        "remove": in_login_shell('read -r run && rm -rf -- "$run"'),
205        "gather": in_login_shell(python + shlex.quote(f"{bundle}/albedo_gather.py")),
206    }
207    socket = agent_socket()
208    if socket is not None:
209        answer["auth_sock"] = socket
210    return answer
211
212
213def staged_name(digest: str | None = None) -> str:
214    """The bundle's directory under the remote home."""
215    return f"{STAGED_DIR}/{(digest or albedo_bundle.digest())[:16]}"
216
217
218def _shipped(info):
219    """Sources only: no bytecode, and no tool cache (.ruff_cache, .mypy_cache)."""
220    parts = info.name.split("/")
221    skipped = any(p == "__pycache__" or p.startswith(".") and p != "." for p in parts)
222    return None if skipped else info
223
224
225def archive() -> bytes:
226    import tarfile  # staging a host is rare; not worth every kernel's boot
227
228    data = io.BytesIO()
229    with tarfile.open(fileobj=data, mode="w") as tar:
230        tar.add(str(albedo_bundle.ROOT), arcname=".", filter=_shipped)
231    return data.getvalue()
232
233
234def stage_script(remote: str) -> str:
235    """Unpack beside the target, then move into place, so a bundle directory
236    that exists is always complete."""
237    return (
238        f'mkdir -p "{remote}.part.$$" && tar -C "{remote}.part.$$" -xf - && '
239        f'{{ mv "{remote}.part.$$" "{remote}" 2>/dev/null || rm -rf "{remote}.part.$$"; }}'
240    )
241
242
243def run(
244    target: str, script: str, timeout: float, data: bytes | None = None
245) -> subprocess.CompletedProcess[bytes]:
246    return subprocess.run(
247        [*base(target), target, script],
248        input=data,
249        stdin=None if data is not None else subprocess.DEVNULL,
250        capture_output=True,
251        timeout=timeout,
252        env=env(),
253    )
254
255
256def failure(stderr: str) -> tuple[str, str]:
257    """What ssh's own exit (255) means: a host we could reach with a person's
258    help, or one we could not reach at all."""
259    text = stderr.strip()
260    lines = [line for line in text.splitlines() if line.strip()]
261    detail = lines[-1] if lines else "ssh failed"
262    lowered = text.lower()
263    interactive = (
264        "permission denied",
265        "host key verification failed",
266        "passphrase",
267        "keyboard-interactive",
268        "verification code",
269        "too many authentication failures",
270    )
271    if any(phrase in lowered for phrase in interactive):
272        return "needs_auth", detail
273    return "unreachable", detail
274
275
276PROBE = (
277    "uname -s; uname -m; printf '%s\\n' \"$HOME\"; "
278    "getconf _NPROCESSORS_ONLN 2>/dev/null || nproc 2>/dev/null || echo 1; "
279    "python3 -c 'import sys; print(\"%d.%d\" % sys.version_info[:2])' 2>/dev/null "
280    "|| echo none; "
281    'test -f "$HOME/{bundle}/albedo_bridge.py" && echo staged || echo missing'
282)
283
284
285def probe(
286    target: str, step: Callable[[str], None] = lambda _: None
287) -> dict[str, object]:
288    control, persist = multiplexing(target)
289    answer: dict[str, object] = {
290        "host": target,
291        "argv": [*options(control, persist), target],
292        "control": control,
293    }
294    bundle = staged_name()
295    try:
296        done = run(target, in_login_shell(PROBE.format(bundle=bundle)), PROBE_TIMEOUT)
297    except subprocess.TimeoutExpired:
298        return {**answer, "state": "unreachable", "detail": "ssh timed out"}
299    except OSError as error:
300        return {**answer, "state": "unreachable", "detail": f"ssh: {error}"}
301    fields = done.stdout.decode(errors="replace").splitlines()
302    if done.returncode == 255 or len(fields) != 6:
303        state, detail = failure(done.stderr.decode(errors="replace"))
304        return {**answer, "state": state, "detail": detail}
305    system, arch, home, cpus, python, staged = (field.strip() for field in fields)
306    answer.update(
307        commands(target, home),
308        os=system,
309        arch=arch,
310        home=home,
311        cpus=int(cpus) if cpus.isdigit() and int(cpus) > 0 else 1,
312        python=python,
313    )
314    version = (
315        tuple(int(part) for part in python.split(".")) if python[:1].isdigit() else None
316    )
317    if version is None:
318        return {
319            **answer,
320            "state": "unsupported",
321            "detail": "needs python >= 3.11 (python3 not found)",
322        }
323    if version < MINIMUM_PYTHON:
324        return {
325            **answer,
326            "state": "unsupported",
327            "detail": f"needs python >= 3.11 (found {python})",
328        }
329    if staged != "staged":
330        step("staging")
331        try:
332            staging = run(
333                target,
334                in_login_shell(stage_script(f"$HOME/{bundle}")),
335                STAGE_TIMEOUT,
336                archive(),
337            )
338        except subprocess.TimeoutExpired:
339            return {**answer, "state": "unreachable", "detail": "staging timed out"}
340        if staging.returncode != 0:
341            detail = staging.stderr.decode(errors="replace").strip()[-500:]
342            return {
343                **answer,
344                "state": "unreachable",
345                "detail": f"staging failed: {detail}",
346            }
347    return {**answer, "state": "ready", "detail": ""}
348
349
350def config_hosts(path: str | None = None, seen: set[str] | None = None) -> list[str]:
351    """`Host` names in ~/.ssh/config and the files it includes, in order,
352    without patterns (`*`, `?`, `!`): the hosts a person can name."""
353    path = path or os.path.expanduser("~/.ssh/config")
354    seen = set() if seen is None else seen
355    if path in seen:
356        return []
357    seen.add(path)
358    try:
359        with open(path, encoding="utf-8", errors="replace") as handle:
360            lines = handle.read().splitlines()
361    except OSError:
362        return []
363    names: list[str] = []
364    for line in lines:
365        words = shlex.split(line, comments=True) if line.strip() else []
366        if len(words) < 2:
367            continue
368        keyword = words[0].lower()
369        if keyword == "host":
370            names += [w for w in words[1:] if not any(c in w for c in "*?!")]
371        elif keyword == "include":
372            for pattern in words[1:]:
373                pattern = os.path.expanduser(pattern)
374                if not os.path.isabs(pattern):
375                    pattern = os.path.join(os.path.expanduser("~/.ssh"), pattern)
376                for included in sorted(glob.glob(pattern)):
377                    names += config_hosts(included, seen)
378    return list(dict.fromkeys(names))
379
380
381def announce(step: str) -> None:
382    print(json.dumps({"step": step}), flush=True)
383
384
385def main(argv: list[str]) -> int:
386    if len(argv) == 3 and argv[1] == "probe":
387        print(json.dumps(probe(argv[2], announce)))
388        return 0
389    if len(argv) == 2 and argv[1] == "hosts":
390        print(json.dumps(config_hosts()))
391        return 0
392    if len(argv) == 4 and argv[1] == "commands":
393        print(json.dumps(commands(argv[2], argv[3])))
394        return 0
395    print(
396        "usage: albedo_ssh.py probe <[user@]host> | commands <[user@]host> <home>",
397        file=sys.stderr,
398    )
399    return 2
400
401
402if __name__ == "__main__":
403    sys.exit(main(sys.argv))