From 4b331c64ea6fe3f6b6b35c8e1e1c314c982bad9a Mon Sep 17 00:00:00 2001 From: Samuel FORESTIER Date: Wed, 4 Sep 2024 22:09:16 +0200 Subject: [PATCH] Implements interactive connection (through Terminus), Windows compliant > closes #24 --- CHANGELOG.md | 6 ++ Default.sublime-commands | 7 ++ Main.sublime-menu | 4 + README.md | 11 +-- dependencies.json | 4 + main.py | 4 +- sshubl/actions.py | 172 +++++++++++++++++++++++++++++---------- sshubl/commands.py | 60 ++++++++++++-- sshubl/project_data.py | 4 +- sshubl/ssh_utils.py | 108 ++++++++++++++++++++---- sshubl/st_utils.py | 4 + 11 files changed, 314 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74056a1..65eb612 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Interactive SSH connection (through Terminus) - Disable spellcheck in remote terminal view (Terminus v0.3.32+) ### Fixed +- Plugin loading on Windows - `ssh_host_authentication_for_localhost` cannot be disabled +### Removed + +- `terminus_is_installed` (hidden) setting + ## [0.4.0] - 2024-08-07 ### Added diff --git a/Default.sublime-commands b/Default.sublime-commands index 578877c..9e33ffd 100644 --- a/Default.sublime-commands +++ b/Default.sublime-commands @@ -11,6 +11,13 @@ "command": "ssh_connect", "caption": "SSHubl: Connect to server" }, + { + "command": "ssh_connect_interactive", + "caption": "SSHubl: Connect to server (interactively)" + }, + { + "command": "ssh_interactive_connection_watcher" + }, { "command": "ssh_disconnect", "caption": "SSHubl: Disconnect from server" diff --git a/Main.sublime-menu b/Main.sublime-menu index acfef51..939a04d 100644 --- a/Main.sublime-menu +++ b/Main.sublime-menu @@ -16,6 +16,10 @@ "command": "ssh_connect", "caption": "Connect to server" }, + { + "command": "ssh_connect", + "caption": "Connect to server (interactively)" + }, { "command": "ssh_disconnect", "caption": "Disconnect from server" diff --git a/README.md b/README.md index 639f0a4..92b0053 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,8 @@ It has been inspired by Visual Studio Code [Remote - SSH](https://marketplace.vi * Sublime Text 4081+ * OpenSSH client * sshfs (FUSE) client -* pexpect (Python package) -* [Terminus](https://packagecontrol.io/packages/Terminus) (Sublime Text package, for remote terminal feature) +* pexpect Python package (used for non-interactive SSH connection on Linux/macOS) +* [Terminus](https://packagecontrol.io/packages/Terminus) Sublime Text package (used for remote terminal feature on Linux/macOS, **required** on Windows) On Debian : `apt-get install -y sshfs` @@ -43,8 +43,9 @@ Package Control dedicated page [here](https://packagecontrol.io/packages/SSHubl) 1. Go to the Sublime Text packages folder (usually `$HOME/.config/sublime-text/Packages/` or `%AppData%\Sublime Text\Packages\`) 2. Clone this repository there : `git clone https://github.com/HorlogeSkynet/SSHubl.git` -3. Satisfy `pexpect` and `ptyprocess` third-party dependencies in Sublime Text `Lib/python38/` folder (see [here](https://stackoverflow.com/a/61200528) for further information) -4. Restart Sublime Text and... :tada: +3. \[Linux/macOS\] Satisfy either `pexpect` and `ptyprocess` third-party dependencies in Sublime Text `Lib/python38/` folder (see [here](https://stackoverflow.com/a/61200528) for further information) or [Terminus](https://packagecontrol.io/packages/Terminus) Sublime Text package dependency +4. \[Windows\] Satisfy [Terminus](https://packagecontrol.io/packages/Terminus) Sublime Text package dependency +5. Restart Sublime Text and... :tada: ## Usage @@ -81,7 +82,7 @@ Open your command palette and type in `SSHubl` to select `Connect to server`. On ## Frequently Asked Questions -### Why can I connect to new hosts without accepting their fingerprint ? +### Why can I non-interactively connect to new hosts without accepting their fingerprint ? > `pexpect` package is [known to always accept remotes' public key](https://github.com/pexpect/pexpect/blob/4.8.0/pexpect/pxssh.py#L411-L414), and it isn't configurable. diff --git a/dependencies.json b/dependencies.json index 65e88cb..e14011c 100644 --- a/dependencies.json +++ b/dependencies.json @@ -1,4 +1,8 @@ { + "$schema": "sublime://packagecontrol.io/schemas/dependencies", + "windows": { + "*": [] + }, "*": { ">4000": [ "pexpect", diff --git a/main.py b/main.py index 7292457..7a8387d 100644 --- a/main.py +++ b/main.py @@ -10,8 +10,8 @@ "paths", # utilities "project_data", - "ssh_utils", "st_utils", + "ssh_utils", # controllers "actions", # commands and listeners (at last, as they depend on other modules) @@ -30,7 +30,9 @@ SshCancelForwardCommand, SshCloseDirCommand, SshConnectCommand, + SshConnectInteractiveCommand, SshConnectPasswordCommand, + SshInteractiveConnectionWatcherCommand, SshDisconnectCommand, SshOpenDirCommand, SshRequestForwardCommand, diff --git a/sshubl/actions.py b/sshubl/actions.py index 233710b..e55faab 100644 --- a/sshubl/actions.py +++ b/sshubl/actions.py @@ -19,6 +19,7 @@ mount_sshfs, ssh_check_master, ssh_connect, + ssh_connect_interactive, ssh_disconnect, ssh_forward, umount_sshfs, @@ -32,6 +33,49 @@ _logger = logging.getLogger(__package__) +def _connection_postlude( # pylint: disable=too-many-arguments + view: sublime.View, + ssh_session: SshSession, + mounts: typing.Optional[typing.Dict[str, str]] = None, + forwards: typing.Optional[typing.List[dict]] = None, +) -> None: + # store SSH session metadata in project data + # Development note : on **re-connection**, mounts and forwards are reset here and will be + # directly re-populated by thread actions below + ssh_session.set_in_project_data(view.window()) + + _logger.info("successfully connected to %s !", ssh_session) + + update_window_status(view.window()) + + # re-mount and re-open previous remote folders (if any) + for mount_path, remote_path in (mounts or {}).items(): + SshMountSshfs( + view, + uuid.UUID(ssh_session.identifier), + # here paths are strings due to JSON serialization, infer flavour back for remote + mount_path=Path(mount_path), + remote_path=typing.cast(PurePath, get_absolute_purepath_flavour(remote_path)), + ).start() + + # re-open previous forwards (if any) + for forward in forwards or []: + # infer original forwarding rule from "local" and "remote" targets + is_reverse = forward["is_reverse"] + target_1, target_2 = ( + forward["target_remote"] if is_reverse else forward["target_local"], + forward["target_local"] if is_reverse else forward["target_remote"], + ) + + SshForward( + view, + uuid.UUID(ssh_session.identifier), + is_reverse, + target_1, + target_2, + ).start() + + class SshConnect(Thread): def __init__( # pylint: disable=too-many-arguments self, @@ -91,45 +135,82 @@ def run(self): finally: self.view.erase_status("zz_connection_in_progress") - if identifier is None: - return + if identifier is not None: + ssh_session = SshSession(str(identifier), host, port, login) + _connection_postlude(self.view, ssh_session, self.mounts, self.forwards) - # store SSH session metadata in project data - # Development note : on **re-connection**, mounts and forwards are reset here and will be - # directly re-populated by thread actions below - ssh_session = SshSession(str(identifier), host, port, login) - ssh_session.set_in_project_data(self.view.window()) - _logger.info("successfully connected to %s !", ssh_session) +class SshInteractiveConnectionWatcher(Thread): + __LOOP_PERIOD = 2 - update_window_status(self.view.window()) + def __init__( # pylint: disable=too-many-arguments + self, + view: sublime.View, + identifier: uuid.UUID, + connection_str: str, + mounts: typing.Optional[typing.Dict[str, str]] = None, + forwards: typing.Optional[typing.List[dict]] = None, + ): + self.view = view + self.identifier = identifier + self.connection_str = connection_str - # re-mount and re-open previous remote folders (if any) - for mount_path, remote_path in self.mounts.items(): - SshMountSshfs( - self.view, - identifier, - # here paths are strings due to JSON serialization, infer flavour back for remote - mount_path=Path(mount_path), - remote_path=typing.cast(PurePath, get_absolute_purepath_flavour(remote_path)), - ).start() - - # re-open previous forwards (if any) - for forward in self.forwards: - # infer original forwarding rule from "local" and "remote" targets - is_reverse = forward["is_reverse"] - target_1, target_2 = ( - forward["target_remote"] if is_reverse else forward["target_local"], - forward["target_local"] if is_reverse else forward["target_remote"], - ) + # below attributes are only used in case of re-connection + self.mounts = mounts or {} + self.forwards = forwards or [] + + super().__init__() - SshForward( - self.view, - identifier, - is_reverse, - target_1, - target_2, - ).start() + def run(self): + _logger.debug( + "interactive connection watcher is starting up for %s (view=%d)...", + self.identifier, + self.view.id(), + ) + + host, port, login, _ = parse_ssh_connection(self.connection_str) + + _logger.debug( + "SSH connection string is : %s@%s:%d", + login, + format_ip_addr(host), + port, + ) + + self.view.set_status( + "zz_connection_in_progress", + f"Connecting to ssh://{login}@{format_ip_addr(host)}:{port}...", + ) + try: + # automatically stop when view is closed (i.e. command execution has ended) + while self.view.is_valid(): + # when master is considered "up" (i.e. client successfully connected to server), + # store session in project data and leave + if ssh_check_master(self.identifier): + ssh_session = SshSession( + str(self.identifier), host, port, login, is_interactive=True + ) + _connection_postlude(self.view, ssh_session, self.mounts, self.forwards) + break + + time.sleep(self.__LOOP_PERIOD) + else: + # loop didn't `break`, this means view was closed whereas connection hasn't + # succeeded : we've to update `is_up` session attribute as current reconnection + # attempt failed. + with project_data_lock: + reconnecting_ssh_session = SshSession.get_from_project_data(self.identifier) + if reconnecting_ssh_session is not None: + reconnecting_ssh_session.is_up = False + reconnecting_ssh_session.set_in_project_data(self.view.window()) + finally: + self.view.erase_status("zz_connection_in_progress") + + _logger.debug( + "interactive connection watcher is shutting down for %s (view=%d)...", + self.identifier, + self.view.id(), + ) class SshDisconnect(Thread): @@ -332,13 +413,22 @@ def run(self): continue _logger.warning("%s's master is down : attempting to reconnect...", ssh_session) - SshConnect( - self.window.active_view(), - str(ssh_session), - session_identifier, - ssh_session.mounts, - ssh_session.forwards, - ).start() + if ssh_session.is_interactive: + ssh_connect_interactive( + str(ssh_session), + session_identifier, + ssh_session.mounts, + ssh_session.forwards, + self.window, + ) + else: + SshConnect( + self.window.active_view(), + str(ssh_session), + session_identifier, + ssh_session.mounts, + ssh_session.forwards, + ).start() # set "up" status to `None` so we know a re-connection attempt is in progress ssh_session.is_up = None diff --git a/sshubl/commands.py b/sshubl/commands.py index 13ebd9e..0028264 100644 --- a/sshubl/commands.py +++ b/sshubl/commands.py @@ -14,17 +14,20 @@ SshDisconnect, SshForward, SshMountSshfs, + SshInteractiveConnectionWatcher, schedule_ssh_connect_password_command, ) from .project_data import SshSession from .ssh_utils import ( + IS_NONINTERACTIVE_SUPPORTED, get_base_ssh_cmd, ssh_exec, + ssh_connect_interactive, ) from .st_utils import ( format_ip_addr, get_absolute_purepath_flavour, - get_command_class, + is_terminus_installed, parse_ssh_connection, validate_forward_target, ) @@ -158,18 +161,26 @@ def next_input(self, args): class _ConnectInputHandler(sublime_plugin.TextInputHandler): + def __init__(self, *args, with_password: bool = True, **kwargs): + self.with_password = with_password + + super().__init__(*args, **kwargs) + def name(self): return "connection_str" def placeholder(self): - return "user[:password]@host[:port]" + return f"user{'[:password]' if self.with_password else ''}@host[:port]" def validate(self, text): try: - host, *_ = parse_ssh_connection(text) + host, _, __, password = parse_ssh_connection(text) except ValueError: return False + if not self.with_password and password is not None: + return False + return bool(host) @@ -177,6 +188,9 @@ class SshConnectCommand(sublime_plugin.TextCommand): def run(self, _edit, connection_str: str): SshConnect(self.view, connection_str).start() + def is_enabled(self): + return IS_NONINTERACTIVE_SUPPORTED + def input(self, _args): return _ConnectInputHandler() @@ -325,6 +339,40 @@ def _finish(self, panel_to_open: typing.Optional[str] = None): ssh_connect_password_command_lock.release() +class SshConnectInteractiveCommand(sublime_plugin.TextCommand): + def run(self, _edit, connection_str: str): + ssh_connect_interactive(connection_str, window=self.view.window()) + + def input(self, _args): + return _ConnectInputHandler(with_password=False) + + def input_description(self): + return "SSH: Connect to server" + + +class SshInteractiveConnectionWatcherCommand(sublime_plugin.TextCommand): + """ + (Hidden) command which only purpose is to be called by Terminus (setup via `post_view_hooks` in + `ssh_connect_interactive`) to watch interactive SSH connections and eventually store session in + project data. + """ + + def run( # pylint: disable=too-many-arguments + self, + _edit, + connection_str: str, + identifier: str, + mounts: typing.Optional[typing.Dict[str, str]] = None, + forwards: typing.Optional[typing.List[dict]] = None, + ): + SshInteractiveConnectionWatcher( + self.view, uuid.UUID(identifier), connection_str, mounts, forwards + ).start() + + def is_visible(self): + return False + + class SshDisconnectCommand(sublime_plugin.TextCommand): @_with_session_identifier def run(self, _edit, identifier: str): @@ -708,11 +756,7 @@ def input_description(self): class SshTerminalCommand(sublime_plugin.TextCommand): @_with_session_identifier def run(self, _edit, identifier: str): - # check for Terminus `terminus_open` command support before actually continuing. - # we check for a (hidden) setting which allows package lookup bypass for developers who know - # what they're doing - terminus_open_command = get_command_class("TerminusOpenCommand") - if not _settings().get("terminus_is_installed") and terminus_open_command is None: + if not is_terminus_installed(): sublime.error_message("Please install Terminus package to open a remote terminal !") return diff --git a/sshubl/project_data.py b/sshubl/project_data.py index 152e93e..99769db 100644 --- a/sshubl/project_data.py +++ b/sshubl/project_data.py @@ -96,13 +96,14 @@ def remove_from_project_folders( @dataclasses.dataclass -class SshSession: +class SshSession: # pylint: disable=too-many-instance-attributes identifier: str host: str port: int login: str mounts: typing.Dict[str, str] = dataclasses.field(default_factory=dict) forwards: typing.List[typing.Dict[str, typing.Any]] = dataclasses.field(default_factory=list) + is_interactive: bool = False is_up: typing.Optional[bool] = True def __str__(self) -> str: @@ -167,6 +168,7 @@ def _get_all_raw(cls, window: typing.Optional[sublime.Window] = None) -> typing. # "target_remote": "127.0.0.1:4242", // allocated by remote # }, # ], + # "is_interactive": false, # "is_up": true, # }, }, diff --git a/sshubl/ssh_utils.py b/sshubl/ssh_utils.py index 33de899..7cd2c15 100644 --- a/sshubl/ssh_utils.py +++ b/sshubl/ssh_utils.py @@ -9,11 +9,21 @@ import uuid from pathlib import Path, PurePath +try: + from pexpect import pxssh +except ImportError: + IS_NONINTERACTIVE_SUPPORTED = False +else: + IS_NONINTERACTIVE_SUPPORTED = True import sublime -from pexpect import pxssh from .paths import mounts_path, sockets_path -from .st_utils import pre_parse_forward_target +from .st_utils import ( + format_ip_addr, + is_terminus_installed, + parse_ssh_connection, + pre_parse_forward_target, +) _logger = logging.getLogger(__package__) @@ -62,6 +72,18 @@ def get_base_ssh_cmd( return base_ssh_cmd +def get_ssh_master_options(identifier: uuid.UUID) -> dict: + return { + **_settings().get("ssh_options", {}), + # enforce keep-alive for future sshfs usages (see upstream recommendations) + "ServerAliveInterval": str(_settings().get("ssh_server_alive_interval", 15)), + "ControlMaster": "auto", + "ControlPath": str(sockets_path / str(identifier)), + # keep connection opened for 1 minute (without new connection to control socket) + "ControlPersist": "60", + } + + def ssh_connect( host: str, port: int, @@ -81,21 +103,14 @@ def ssh_connect( """ if ssh_program is None: raise RuntimeError(f"{ssh_program} has not been found !") + if not IS_NONINTERACTIVE_SUPPORTED: + raise RuntimeError("Non-interactive connection isn't supported !") - identifier = identifier or uuid.uuid4() + if identifier is None: + identifier = uuid.uuid4() # run OpenSSH using pexpect to setup connection and non-interactively deal with prompts - ssh = pxssh.pxssh( - options={ - **_settings().get("ssh_options", {}), - # enforce keep-alive for future sshfs usages (see upstream recommendations) - "ServerAliveInterval": str(_settings().get("ssh_server_alive_interval", 15)), - "ControlMaster": "auto", - "ControlPath": str(sockets_path / str(identifier)), - # keep connection opened for 1 minute (without new connection to control socket) - "ControlPersist": "60", - } - ) + ssh = pxssh.pxssh(options=get_ssh_master_options(identifier)) # pexpect v4.8 is currently packaged for Sublime Text so it still specifies old # `RSAAuthentication` option @@ -129,6 +144,71 @@ def ssh_connect( return identifier +def ssh_connect_interactive( # pylint: disable=too-many-arguments + connection_str: str, + identifier: typing.Optional[uuid.UUID] = None, + mounts: typing.Optional[typing.Dict[str, str]] = None, + forwards: typing.Optional[typing.List[dict]] = None, + window: typing.Optional[sublime.Window] = None, +) -> None: + if ssh_program is None: + raise RuntimeError(f"{ssh_program} has not been found !") + + if not is_terminus_installed(): + sublime.error_message("Please install Terminus package to connect interactively !") + return + + if window is None: + window = sublime.active_window() + + if identifier is None: + identifier = uuid.uuid4() + + ssh_options = get_ssh_master_options(identifier) + if not _settings().get("ssh_host_authentication_for_localhost", True): + ssh_options["NoHostAuthenticationForLocalhost"] = "yes" + + host, port, login, _ = parse_ssh_connection(connection_str) + + terminus_open_args: typing.Dict[str, typing.Any] = { + "shell_cmd": shlex.join( + ( + ssh_program, + f"-p{port}", + *[f"-o{key}={value}" for key, value in ssh_options.items()], + f"{login}@{host}", + ) + ), + "title": f"{login}@{format_ip_addr(host)}:{port}", + "auto_close": "on_success", + "post_view_hooks": [ + # makes Terminus executes a command which will wait for SSH connection to actually + # succeed before storing session in project data + ( + "ssh_interactive_connection_watcher", + { + "identifier": str(identifier), + "connection_str": connection_str, + "mounts": mounts, + "forwards": forwards, + }, + ), + ], + } + + # Disable spellcheck in terminal view as it's usually irrelevant and report many misspelled + # words on shells. Moreover, as we're connected to a different host it's even likely local + # and remote locales do not match. Although, as it's an opinionated take, we also check for + # a(nother) hidden setting before doing so :-) + # Development note : `view_settings` argument is only supported by Terminus v0.3.32+ + if not _settings().get("honor_spell_check"): + terminus_open_args["view_settings"] = { + "spell_check": False, + } + + window.run_command("terminus_open", terminus_open_args) + + def ssh_disconnect(identifier: uuid.UUID) -> None: """ Kill a SSH connection master, causing session graceful disconnection. diff --git a/sshubl/st_utils.py b/sshubl/st_utils.py index af40bab..8f8e374 100644 --- a/sshubl/st_utils.py +++ b/sshubl/st_utils.py @@ -58,6 +58,10 @@ def get_command_class(class_name: str) -> typing.Optional[typing.Type[sublime_pl return None +def is_terminus_installed() -> bool: + return get_command_class("TerminusOpenCommand") is not None + + @functools.lru_cache() def format_ip_addr(host: str) -> str: """