okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
c9b9960
main
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 9albedo_ssh.py probe <[user@]host> 10albedo_ssh.py commands <[user@]host> <home> 11albedo_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 49socket paths are capped (~104 bytes on macOS) and %C hashes to 40 more, 50so neither ALBEDO_HOME nor macOS's deep $TMPDIR may be on the path.""" 51control = os .path .join ("/tmp" ,"albedo-ssh-cm" ) 52if len (control )+ 42 > 104 : 53control = os .path .join (tempfile .gettempdir (),"albedo-ssh-cm" ) 54return control 55 56 57def multiplexing (target :str )-> tuple [str ,str ]: 58"""The control path and ControlPersist for `target`: the user's own when 59their ssh config names a ControlPath, so a master they or their tools 60opened carries albedo too and albedo's carries theirs; else albedo's. 61`ssh -G` only reads the config.""" 62try : 63done = subprocess .run ( 64 ["ssh" ,"-G" ,target ], 65stdin = subprocess .DEVNULL , 66capture_output = True , 67text = True , 68timeout = 5 , 69 ) 70lines = done .stdout .splitlines ()if done .returncode == 0 else [] 71except (OSError ,subprocess .SubprocessError ): 72lines = [] 73settings = {} 74for line in lines : 75key ,_ ,value = line .partition (" " ) 76settings [key ]= value 77path = settings .get ("controlpath" ,"none" ) 78if path == "none" : 79return f" { control_dir () } /%C" ,"600" 80persist = settings .get ("controlpersist" ,"no" ) 81# -G expanded the path's tokens; a % left in it is literal 82return 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 87does: `ssh_options` are `-o` values, `environment` is added for ssh and the 88command it spawns, which is how a token stays out of the command line.""" 89ALIASES [host ]= (ssh_options ,environment ) 90 91 92def unalias (host :str )-> None : 93ALIASES .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 98options come first: ssh keeps the first value it is given for an option.""" 99argv = options (* multiplexing (target )) 100named = ALIASES .get (target , ([], {}))[0 ] 101return [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 106ask them: it opens the master albedo rides, which outlives it.""" 107control ,persist = multiplexing (target ) 108return [ 109"ssh" , 110"-M" , 111"-fN" , 112"-o" , 113f"ControlPath= { control } " , 114"-o" , 115f"ControlPersist= { persist } " , 116target , 117 ] 118 119 120def options (control :str ,persist :str )-> list [str ]: 121"""The ssh argv prefix riding the master at `control`.""" 122os .makedirs (control_dir (),exist_ok = True ) 123return [ 124"ssh" , 125"-o" , 126"BatchMode=yes" , 127"-o" , 128f"ConnectTimeout= { CONNECT_TIMEOUT } " , 129"-o" , 130"StrictHostKeyChecking=accept-new" , 131"-o" , 132"ControlMaster=auto" , 133"-o" , 134f"ControlPath= { control } " , 135"-o" , 136f"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 142hardware keys, 1Password, and ssh-agent work out of the box.""" 143if "SSH_AUTH_SOCK" in os .environ : 144return os .environ ["SSH_AUTH_SOCK" ] 145for candidate in ( 146os .path .expanduser ("~/.ssh/agent.sock" ), 147os .path .expanduser ( 148"~/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock" 149 ), 150 ): 151if os .path .exists (candidate ): 152return candidate 153return 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`.""" 158environment = {** os .environ ,** ALIASES .get (target or "" , ([], {}))[1 ]} 159socket = agent_socket () 160if socket is not None : 161environment ["SSH_AUTH_SOCK" ]= socket 162return environment 163 164 165def in_login_shell (script :str )-> str : 166"""The remote command that runs `script` under the user's login shell. 167 168Non-interactive SSH commands run under `$SHELL -c` without sourcing 169`/etc/profile` or user profiles, which leaves PATH minimal (missing 170Nix, Homebrew, or user tool paths); a login shell finds python3 and the 171user's tools. Profiles often print (a motd, a greeting), which would 172corrupt a framed stdout and fill the daemon's log on every command, so 173the login shell starts with both on /dev/null and the real ones kept on 174fds 3 and 4, and `script` gets them back. The outer `/bin/sh` makes the 175redirections work whatever the user's shell is. 176""" 177login = f"exec 1>&3 2>&4 3>&- 4>&-; { script } " 178outer = ( 179"exec 3>&1 4>&2 1>/dev/null 2>&1; " 180f'exec "${{SHELL:-/bin/sh}}" -l -c { shlex . quote ( login ) } ' 181 ) 182return 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 187remote commands: it never quotes anything itself. Variable inputs travel 188on stdin (the bridge's first frame, the ladder's request line, the run 189directory to remove), never in the command line.""" 190bundle = f" { home } / { staged_name () } " 191python = "exec python3 -u " 192control ,persist = multiplexing (target ) 193answer :dict [str ,object ]= { 194"host" :target , 195"argv" : [* options (control ,persist ),target ], 196"control" :control , 197"bundle" :bundle , 198"bridge" :in_login_shell ( 199python + shlex .quote (f" { bundle } /albedo_bridge.py" )+ " --frame" 200 ), 201"signal" :in_login_shell ( 202python + 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 } 207socket = agent_socket () 208if socket is not None : 209answer ["auth_sock" ]= socket 210return answer 211 212 213def staged_name (digest :str | None = None )-> str : 214"""The bundle's directory under the remote home.""" 215return 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).""" 220parts = info .name .split ("/" ) 221skipped = any (p == "__pycache__" or p .startswith ("." )and p != "." for p in parts ) 222return None if skipped else info 223 224 225def archive ()-> bytes : 226import tarfile # staging a host is rare; not worth every kernel's boot 227 228data = io .BytesIO () 229with tarfile .open (fileobj = data ,mode = "w" )as tar : 230tar .add (str (albedo_bundle .ROOT ),arcname = "." ,filter = _shipped ) 231return data .getvalue () 232 233 234def stage_script (remote :str )-> str : 235"""Unpack beside the target, then move into place, so a bundle directory 236that exists is always complete.""" 237return ( 238f'mkdir -p " { remote } .part.$$" && tar -C " { remote } .part.$$" -xf - && ' 239f'{{ mv " { remote } .part.$$" " { remote } " 2>/dev/null || rm -rf " { remote } .part.$$"; }}' 240 ) 241 242 243def run ( 244target :str ,script :str ,timeout :float ,data :bytes | None = None 245)-> subprocess .CompletedProcess [bytes ]: 246return subprocess .run ( 247 [* base (target ),target ,script ], 248input = data , 249stdin = None if data is not None else subprocess .DEVNULL , 250capture_output = True , 251timeout = timeout , 252env = 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 258help, or one we could not reach at all.""" 259text = stderr .strip () 260lines = [line for line in text .splitlines ()if line .strip ()] 261detail = lines [- 1 ]if lines else "ssh failed" 262lowered = text .lower () 263interactive = ( 264"permission denied" , 265"host key verification failed" , 266"passphrase" , 267"keyboard-interactive" , 268"verification code" , 269"too many authentication failures" , 270 ) 271if any (phrase in lowered for phrase in interactive ): 272return "needs_auth" ,detail 273return "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 ( 286target :str ,step :Callable [[str ],None ]= lambda _ :None 287)-> dict [str ,object ]: 288control ,persist = multiplexing (target ) 289answer :dict [str ,object ]= { 290"host" :target , 291"argv" : [* options (control ,persist ),target ], 292"control" :control , 293 } 294bundle = staged_name () 295try : 296done = run (target ,in_login_shell (PROBE .format (bundle = bundle )),PROBE_TIMEOUT ) 297except subprocess .TimeoutExpired : 298return {** answer ,"state" :"unreachable" ,"detail" :"ssh timed out" } 299except OSError as error : 300return {** answer ,"state" :"unreachable" ,"detail" :f"ssh: { error } " } 301fields = done .stdout .decode (errors = "replace" ).splitlines () 302if done .returncode == 255 or len (fields )!= 6 : 303state ,detail = failure (done .stderr .decode (errors = "replace" )) 304return {** answer ,"state" :state ,"detail" :detail } 305system ,arch ,home ,cpus ,python ,staged = (field .strip ()for field in fields ) 306answer .update ( 307commands (target ,home ), 308os = system , 309arch = arch , 310home = home , 311cpus = int (cpus )if cpus .isdigit ()and int (cpus )> 0 else 1 , 312python = python , 313 ) 314version = ( 315tuple (int (part )for part in python .split ("." ))if python [:1 ].isdigit ()else None 316 ) 317if version is None : 318return { 319** answer , 320"state" :"unsupported" , 321"detail" :"needs python >= 3.11 (python3 not found)" , 322 } 323if version < MINIMUM_PYTHON : 324return { 325** answer , 326"state" :"unsupported" , 327"detail" :f"needs python >= 3.11 (found { python } )" , 328 } 329if staged != "staged" : 330step ("staging" ) 331try : 332staging = run ( 333target , 334in_login_shell (stage_script (f"$HOME/ { bundle } " )), 335STAGE_TIMEOUT , 336archive (), 337 ) 338except subprocess .TimeoutExpired : 339return {** answer ,"state" :"unreachable" ,"detail" :"staging timed out" } 340if staging .returncode != 0 : 341detail = staging .stderr .decode (errors = "replace" ).strip ()[- 500 :] 342return { 343** answer , 344"state" :"unreachable" , 345"detail" :f"staging failed: { detail } " , 346 } 347return {** 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, 352without patterns (`*`, `?`, `!`): the hosts a person can name.""" 353path = path or os .path .expanduser ("~/.ssh/config" ) 354seen = set ()if seen is None else seen 355if path in seen : 356return [] 357seen .add (path ) 358try : 359with open (path ,encoding = "utf-8" ,errors = "replace" )as handle : 360lines = handle .read ().splitlines () 361except OSError : 362return [] 363names :list [str ]= [] 364for line in lines : 365words = shlex .split (line ,comments = True )if line .strip ()else [] 366if len (words )< 2 : 367continue 368keyword = words [0 ].lower () 369if keyword == "host" : 370names += [w for w in words [1 :]if not any (c in w for c in "*?!" )] 371elif keyword == "include" : 372for pattern in words [1 :]: 373pattern = os .path .expanduser (pattern ) 374if not os .path .isabs (pattern ): 375pattern = os .path .join (os .path .expanduser ("~/.ssh" ),pattern ) 376for included in sorted (glob .glob (pattern )): 377names += config_hosts (included ,seen ) 378return list (dict .fromkeys (names )) 379 380 381def announce (step :str )-> None : 382json .dumps ({"step" :step }),flush = True ) 383 384 385def main (argv :list [str ])-> int : 386if len (argv )== 3 and argv [1 ]== "probe" : 387json .dumps (probe (argv [2 ],announce ))) 388return 0 389if len (argv )== 2 and argv [1 ]== "hosts" : 390json .dumps (config_hosts ())) 391return 0 392if len (argv )== 4 and argv [1 ]== "commands" : 393json .dumps (commands (argv [2 ],argv [3 ]))) 394return 0 395"usage: albedo_ssh.py probe <[user@]host> | commands <[user@]host> <home>" , 397file = sys .stderr , 398 ) 399return 2 400 401 402if __name__ == "__main__" : 403sys .exit (main (sys .argv ))