2025-11-18 12:11:31 +01:00
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
|
2026-03-17 14:45:18 +01:00
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
import os, abc, shlex, sys
|
2025-11-18 12:11:31 +01:00
|
|
|
|
2026-03-20 12:53:07 +01:00
|
|
|
from ..util import run_cmd, pretty_cmd
|
|
|
|
|
from ..log import *
|
|
|
|
|
from ..ExecContext import ExecContext, Result
|
2026-03-18 07:09:01 +01:00
|
|
|
from urllib.parse import urlparse
|
2025-11-18 12:11:31 +01:00
|
|
|
|
2026-03-18 05:52:42 +01:00
|
|
|
class SSHClient(ExecContext):
|
2025-11-18 12:11:31 +01:00
|
|
|
|
2026-03-18 07:09:01 +01:00
|
|
|
def __init__(self, uri: str, *args, **kwargs) -> None:
|
|
|
|
|
super().__init__(uri=uri, *args, **kwargs)
|
2026-03-18 07:27:59 +01:00
|
|
|
try:
|
|
|
|
|
parsed = urlparse(uri)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
log(ERR, f'Failed to parse SSH URI "{uri}"')
|
|
|
|
|
raise
|
2026-03-18 07:09:01 +01:00
|
|
|
self.__hostname = parsed.hostname
|
|
|
|
|
self.__password: parsed.password
|
|
|
|
|
self.__username = parsed.username
|
2025-11-18 12:11:31 +01:00
|
|
|
|
|
|
|
|
@abc.abstractmethod
|
2026-03-18 05:57:54 +01:00
|
|
|
async def _run_ssh(self, cmd: list[str]) -> Result:
|
2025-11-18 12:11:31 +01:00
|
|
|
pass
|
|
|
|
|
|
2026-03-18 05:52:42 +01:00
|
|
|
async def _run(
|
2026-03-18 10:22:21 +01:00
|
|
|
self,
|
lib.ExecContext: Align .sudo() prototype to .run()
ExecContext's .sudo() omits many of run()'s parameters, and this
commit adds them. To avoid redundancy around repeating and massaging
the long parameter list of both functions and their return values, it
also adds some deeper changes:
- Make run(), _run(), sudo() and _sudo() always return instances of
Result. Before it was allowed to return a triplet of stdout,
stderr, and exit status.
- Have ExecContext stay out of the business of decoding the result
entirely. Result provides a convenience method .decode()
operating on stdout and stderr and leaves the decision to the
caller.
This entails miniscule adaptations in calling code, namely in
App.os_release, util.get_profile_env() and CmdListRepos._run().
- Wrap the _run() and _sudo() callbacks in a context manager object
of type CallContext to avoid code duplication.
- Consistently name the first argument to run(), _run(), sudo() and
_sudo() "cmd", not "args". The latter suggests that the caller is
omitting the executable, which is not the case.
Signed-off-by: Jan Lindemann <jan@janware.com>
2026-03-19 11:38:16 +01:00
|
|
|
cmd: list[str],
|
2026-03-18 10:22:21 +01:00
|
|
|
wd: str|None,
|
|
|
|
|
verbose: bool,
|
|
|
|
|
cmd_input: str|None,
|
|
|
|
|
env: dict[str, str]|None,
|
|
|
|
|
interactive: bool,
|
|
|
|
|
log_prefix: str
|
|
|
|
|
) -> Result:
|
|
|
|
|
|
2026-03-19 07:13:12 +01:00
|
|
|
def __log(prio: int, *args):
|
2026-03-18 10:22:21 +01:00
|
|
|
log(prio, log_prefix, *args)
|
2026-03-18 05:14:07 +01:00
|
|
|
|
2026-03-19 07:13:12 +01:00
|
|
|
def __log_block(prio: int, title: str, block: str):
|
|
|
|
|
encoding = sys.stdout.encoding or 'utf-8'
|
|
|
|
|
block = block.decode(encoding).strip()
|
|
|
|
|
if not block:
|
|
|
|
|
return
|
|
|
|
|
delim = f'---- {title} ----'
|
|
|
|
|
__log(prio, f',{delim}')
|
|
|
|
|
for line in block.splitlines():
|
|
|
|
|
__log(prio, '|', line)
|
|
|
|
|
__log(prio, f'`{delim}')
|
|
|
|
|
|
2026-03-18 05:14:07 +01:00
|
|
|
if wd is not None:
|
lib.ExecContext: Align .sudo() prototype to .run()
ExecContext's .sudo() omits many of run()'s parameters, and this
commit adds them. To avoid redundancy around repeating and massaging
the long parameter list of both functions and their return values, it
also adds some deeper changes:
- Make run(), _run(), sudo() and _sudo() always return instances of
Result. Before it was allowed to return a triplet of stdout,
stderr, and exit status.
- Have ExecContext stay out of the business of decoding the result
entirely. Result provides a convenience method .decode()
operating on stdout and stderr and leaves the decision to the
caller.
This entails miniscule adaptations in calling code, namely in
App.os_release, util.get_profile_env() and CmdListRepos._run().
- Wrap the _run() and _sudo() callbacks in a context manager object
of type CallContext to avoid code duplication.
- Consistently name the first argument to run(), _run(), sudo() and
_sudo() "cmd", not "args". The latter suggests that the caller is
omitting the executable, which is not the case.
Signed-off-by: Jan Lindemann <jan@janware.com>
2026-03-19 11:38:16 +01:00
|
|
|
cmd = ['cd', wd, '&&', *cmd]
|
2026-03-18 05:14:07 +01:00
|
|
|
|
|
|
|
|
if interactive:
|
|
|
|
|
raise NotImplementedError('Interactive SSH is not yet implemented')
|
|
|
|
|
|
|
|
|
|
if env is not None:
|
|
|
|
|
raise NotImplementedError('Passing an environment to SSH commands is not yet implemented')
|
|
|
|
|
|
lib.ExecContext: Align .sudo() prototype to .run()
ExecContext's .sudo() omits many of run()'s parameters, and this
commit adds them. To avoid redundancy around repeating and massaging
the long parameter list of both functions and their return values, it
also adds some deeper changes:
- Make run(), _run(), sudo() and _sudo() always return instances of
Result. Before it was allowed to return a triplet of stdout,
stderr, and exit status.
- Have ExecContext stay out of the business of decoding the result
entirely. Result provides a convenience method .decode()
operating on stdout and stderr and leaves the decision to the
caller.
This entails miniscule adaptations in calling code, namely in
App.os_release, util.get_profile_env() and CmdListRepos._run().
- Wrap the _run() and _sudo() callbacks in a context manager object
of type CallContext to avoid code duplication.
- Consistently name the first argument to run(), _run(), sudo() and
_sudo() "cmd", not "args". The latter suggests that the caller is
omitting the executable, which is not the case.
Signed-off-by: Jan Lindemann <jan@janware.com>
2026-03-19 11:38:16 +01:00
|
|
|
ret = await self._run_ssh(cmd, cmd_input=cmd_input)
|
2026-03-19 07:13:12 +01:00
|
|
|
if verbose:
|
|
|
|
|
__log_block(NOTICE, 'stdout', ret.stdout)
|
|
|
|
|
__log_block(NOTICE, 'stderr', ret.stderr)
|
|
|
|
|
if ret.status != 0:
|
|
|
|
|
__log(WARNING, f'Exit code {ret.status}')
|
|
|
|
|
return ret
|
2026-03-17 14:45:18 +01:00
|
|
|
|
2026-03-18 05:52:42 +01:00
|
|
|
async def _sudo(self, cmd: list[str], mod_env: dict[str, str], opts: list[str], *args, **kwargs) -> Result:
|
|
|
|
|
if self.username != 'root':
|
|
|
|
|
cmd = ['sudo', *opts, *cmd]
|
|
|
|
|
if mod_env:
|
|
|
|
|
log(WARNING, f'Modifying environment over SSH is not implemented, ignored')
|
|
|
|
|
return await self._run(cmd, *args, **kwargs)
|
|
|
|
|
|
2026-03-18 05:58:12 +01:00
|
|
|
@property
|
|
|
|
|
def hostname(self):
|
|
|
|
|
return self.__hostname
|
|
|
|
|
|
|
|
|
|
def set_password(self, password: str) -> None:
|
|
|
|
|
self.__password = password
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def password(self) -> str:
|
|
|
|
|
return self.__password
|
|
|
|
|
|
|
|
|
|
def set_username(self, username: str) -> None:
|
|
|
|
|
self.__username = username
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def username(self) -> str:
|
|
|
|
|
return self.__username
|
|
|
|
|
|
2025-11-18 12:11:31 +01:00
|
|
|
class SSHClientInternal(SSHClient): # export
|
|
|
|
|
|
2026-03-18 07:09:01 +01:00
|
|
|
def __init__(self, *args, **kwargs) -> None:
|
|
|
|
|
super().__init__(*args, **kwargs)
|
2026-03-17 14:45:18 +01:00
|
|
|
self.__timeout: float|None = None # Untested
|
|
|
|
|
self.___ssh: Any|None = None
|
2025-11-18 12:11:31 +01:00
|
|
|
|
|
|
|
|
def __ssh_connect(self):
|
|
|
|
|
import paramiko # type: ignore # error: Library stubs not installed for "paramiko"
|
|
|
|
|
ret = paramiko.SSHClient()
|
|
|
|
|
ret.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
2026-03-18 07:27:59 +01:00
|
|
|
try:
|
2026-03-20 08:14:59 +00:00
|
|
|
ret.connect(
|
|
|
|
|
hostname=self.hostname,
|
lib.ExecContext: Align .sudo() prototype to .run()
ExecContext's .sudo() omits many of run()'s parameters, and this
commit adds them. To avoid redundancy around repeating and massaging
the long parameter list of both functions and their return values, it
also adds some deeper changes:
- Make run(), _run(), sudo() and _sudo() always return instances of
Result. Before it was allowed to return a triplet of stdout,
stderr, and exit status.
- Have ExecContext stay out of the business of decoding the result
entirely. Result provides a convenience method .decode()
operating on stdout and stderr and leaves the decision to the
caller.
This entails miniscule adaptations in calling code, namely in
App.os_release, util.get_profile_env() and CmdListRepos._run().
- Wrap the _run() and _sudo() callbacks in a context manager object
of type CallContext to avoid code duplication.
- Consistently name the first argument to run(), _run(), sudo() and
_sudo() "cmd", not "args". The latter suggests that the caller is
omitting the executable, which is not the case.
Signed-off-by: Jan Lindemann <jan@janware.com>
2026-03-19 11:38:16 +01:00
|
|
|
username=self.username,
|
2026-03-20 08:14:59 +00:00
|
|
|
allow_agent=True
|
|
|
|
|
)
|
2026-03-18 07:27:59 +01:00
|
|
|
except Exception as e:
|
|
|
|
|
log(ERR, f'Failed to connect to {self.hostname} with key file {path_to_key} ({str(e)})')
|
|
|
|
|
raise
|
2025-11-18 12:11:31 +01:00
|
|
|
s = ret.get_transport().open_session()
|
|
|
|
|
# set up the agent request handler to handle agent requests from the server
|
|
|
|
|
paramiko.agent.AgentRequestHandler(s)
|
|
|
|
|
return ret
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def __ssh(self):
|
|
|
|
|
if self.___ssh is None:
|
2026-03-17 14:45:18 +01:00
|
|
|
self.___ssh = self.__ssh_connect()
|
2025-11-18 12:11:31 +01:00
|
|
|
return self.___ssh
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def __scp(self):
|
|
|
|
|
return SCPClient(self.__ssh.get_transport())
|
|
|
|
|
|
2026-03-18 05:57:54 +01:00
|
|
|
async def _run_ssh(self, cmd: list[str], cmd_input: str|None) -> Result:
|
2026-03-18 07:27:59 +01:00
|
|
|
try:
|
|
|
|
|
stdin, stdout, stderr = self.__ssh.exec_command(shlex.join(cmd), timeout=self.__timeout)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
log(ERR, f'Command failed for {self.uri}: "{shlex.join(cmd)}"')
|
|
|
|
|
raise
|
2026-03-18 05:14:07 +01:00
|
|
|
if cmd_input is not None:
|
|
|
|
|
stdin.write(cmd_input)
|
2026-03-17 14:45:18 +01:00
|
|
|
exit_status = stdout.channel.recv_exit_status()
|
2026-03-18 05:52:42 +01:00
|
|
|
return Result(stdout.read(), stderr.read(), exit_status)
|
2025-11-18 12:11:31 +01:00
|
|
|
|
|
|
|
|
class SSHClientCmd(SSHClient): # export
|
|
|
|
|
|
2026-03-18 07:09:01 +01:00
|
|
|
def __init__(self, *args, **kwargs) -> None:
|
2025-11-18 12:11:31 +01:00
|
|
|
self.__askpass: str|None = None
|
|
|
|
|
self.__askpass_orig: dict[str, str|None] = dict()
|
2026-03-18 07:09:01 +01:00
|
|
|
super().__init__(*args, **kwargs)
|
2025-11-18 12:11:31 +01:00
|
|
|
|
|
|
|
|
def __del__(self):
|
|
|
|
|
for key, val in self.__askpass_orig.items():
|
2025-11-20 15:48:13 +01:00
|
|
|
if val is None:
|
2025-11-18 12:11:31 +01:00
|
|
|
del os.environ[key]
|
|
|
|
|
else:
|
2025-11-20 15:48:13 +01:00
|
|
|
os.environ[key] = val
|
2025-11-18 12:11:31 +01:00
|
|
|
if self.__askpass is not None:
|
|
|
|
|
os.remove(self.__askpass)
|
|
|
|
|
|
|
|
|
|
def __init_askpass(self):
|
|
|
|
|
if self.__askpass is None and self.password is not None:
|
2025-11-20 12:42:45 +01:00
|
|
|
import sys, tempfile
|
2025-11-18 12:11:31 +01:00
|
|
|
prefix = os.path.basename(sys.argv[0]) + '-'
|
2025-11-20 12:42:45 +01:00
|
|
|
f = tempfile.NamedTemporaryFile(mode='w+t', prefix=prefix, delete=False)
|
|
|
|
|
os.chmod(f.name, 0o0700)
|
2025-11-18 12:11:31 +01:00
|
|
|
self.__askpass = f.name
|
|
|
|
|
f.write(f'#!/bin/bash\n\necho -n "{self.password}\n"')
|
|
|
|
|
f.close()
|
2025-11-20 12:42:45 +01:00
|
|
|
for key, val in {'SSH_ASKPASS': self.__askpass, 'SSH_ASKPASS_REQUIRE': 'force'}.items():
|
2025-11-18 12:11:31 +01:00
|
|
|
self.__askpass_orig[key] = os.getenv(key)
|
2025-11-20 12:42:45 +01:00
|
|
|
os.environ[key] = val
|
2025-11-18 12:11:31 +01:00
|
|
|
|
2026-03-18 05:57:54 +01:00
|
|
|
async def _run_ssh(self, cmd: list[str], cmd_input: str|None) -> Result:
|
2025-11-18 12:11:31 +01:00
|
|
|
self.__init_askpass()
|
2026-03-18 05:14:07 +01:00
|
|
|
return await run_cmd(['ssh', self.hostname, shlex.join(cmd)],
|
|
|
|
|
output_encoding='bytes', cmd_input=cmd_input)
|
2026-03-17 14:45:18 +01:00
|
|
|
|
|
|
|
|
def ssh_client(*args, **kwargs) -> SSHClient: # export
|
|
|
|
|
try:
|
|
|
|
|
return SSHClientInternal(*args, **kwargs)
|
|
|
|
|
except:
|
|
|
|
|
pass
|
|
|
|
|
return SSHClientCmd(*args, **kwargs)
|