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
|
|
|
|
|
|
|
|
from .util import run_cmd
|
2026-03-18 05:14:07 +01:00
|
|
|
from .log import *
|
2026-03-18 05:52:42 +01:00
|
|
|
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)
|
|
|
|
|
parsed = urlparse(uri)
|
|
|
|
|
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-17 14:45:18 +01:00
|
|
|
self,
|
2026-03-18 05:14:07 +01:00
|
|
|
args: list[str],
|
2026-03-18 05:52:42 +01:00
|
|
|
wd: str|None,
|
|
|
|
|
throw: bool,
|
|
|
|
|
verbose: bool,
|
|
|
|
|
cmd_input: str|None,
|
|
|
|
|
env: dict[str, str]|None,
|
|
|
|
|
title: str,
|
|
|
|
|
output_encoding: str|None, # None => unchanged; "bytes" => return raw bytes
|
2026-03-17 14:45:18 +01:00
|
|
|
) -> Result:
|
2026-03-18 05:14:07 +01:00
|
|
|
|
|
|
|
|
if wd is not None:
|
|
|
|
|
args = ['cd', wd, '&&', *args]
|
|
|
|
|
|
|
|
|
|
if verbose:
|
|
|
|
|
log(WARNING, f'Verbose SSH commands are not yet implemented')
|
|
|
|
|
|
|
|
|
|
interactive = (
|
|
|
|
|
cmd_input == "mode:interactive"
|
|
|
|
|
or (cmd_input == "mode:auto" and sys.stdin.isatty())
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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')
|
|
|
|
|
|
2026-03-18 05:57:54 +01:00
|
|
|
stdout_b, stderr_b, status = await self._run_ssh(args, cmd_input=cmd_input)
|
2026-03-18 05:14:07 +01:00
|
|
|
|
|
|
|
|
if throw and status:
|
|
|
|
|
raise Exception(f'SSH command returned error {status}')
|
|
|
|
|
|
2026-03-17 14:45:18 +01:00
|
|
|
if output_encoding == 'bytes':
|
|
|
|
|
return stdout_b, stderr_b, status
|
|
|
|
|
|
|
|
|
|
if output_encoding is None:
|
|
|
|
|
output_encoding = sys.stdout.encoding or "utf-8"
|
|
|
|
|
stdout_s = stdout_b.decode(output_encoding, errors="replace") if stdout_b is not None else None
|
|
|
|
|
stderr_s = stderr_b.decode(output_encoding, errors="replace") if stderr_b is not None else None
|
2026-03-18 05:14:07 +01:00
|
|
|
|
2026-03-17 14:45:18 +01:00
|
|
|
return stdout_s, stderr_s, status
|
|
|
|
|
|
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())
|
|
|
|
|
path_to_key=os.path.join(os.environ['HOME'], '.ssh', 'id_rsa')
|
2026-03-17 14:45:18 +01:00
|
|
|
ret.connect(self.hostname, key_filename=path_to_key, allow_agent=True)
|
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-17 14:45:18 +01:00
|
|
|
stdin, stdout, stderr = self.__ssh.exec_command(shlex.join(cmd), timeout=self.__timeout)
|
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)
|