1
0
mirror of https://github.com/HorlogeSkynet/SSHubl.git synced 2025-05-13 12:00:19 +02:00

Merge 41e7e0076b2fdb80d106df894f5fa6520cb91fbb into b3092d61e839d32e37b55627b4f66201a933f2b4

This commit is contained in:
Samuel FORESTIER 2024-10-06 20:07:56 +00:00 committed by GitHub
commit 12e3f998c5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 233 additions and 36 deletions

@ -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

@ -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"

@ -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"

@ -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, optional\] Satisfy `pexpect` and `ptyprocess` third-party dependencies in Sublime Text `Lib/python38/` folder (see [here](https://stackoverflow.com/a/61200528) for further information)
4. \[Windows, required\] 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.

@ -1,4 +1,8 @@
{
"$schema": "sublime://packagecontrol.io/schemas/dependencies",
"windows": {
"*": []
},
"*": {
">4000": [
"pexpect",

@ -10,8 +10,8 @@ for suffix in (
"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 @@ else:
SshCancelForwardCommand,
SshCloseDirCommand,
SshConnectCommand,
SshConnectInteractiveCommand,
SshConnectPasswordCommand,
SshInteractiveConnectionWatcherCommand,
SshDisconnectCommand,
SshOpenDirCommand,
SshRequestForwardCommand,

@ -132,6 +132,66 @@ class SshConnect(Thread):
).start()
class SshInteractiveConnectionWatcher(Thread):
__LOOP_PERIOD = 2
def __init__( # pylint: disable=too-many-arguments
self,
view: sublime.View,
identifier: uuid.UUID,
host: str,
port: int,
login: str,
):
self.view = view
self.identifier = identifier
self.host = host
self.port = port
self.login = login
super().__init__()
def run(self):
_logger.debug(
"interactive connection watcher is starting up for %s (view=%d)...",
self.identifier,
self.view.id(),
)
self.view.set_status(
"zz_connection_in_progress",
f"Connecting to ssh://{self.login}@{format_ip_addr(self.host)}:{self.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), self.host, self.port, self.login, is_interactive=True
)
ssh_session.set_in_project_data(self.view.window())
_logger.info("successfully connected to %s !", ssh_session)
update_window_status(self.view.window())
# TODO : mounts + forwards on reconnection
break
time.sleep(self.__LOOP_PERIOD)
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):
def __init__(self, view: sublime.View, identifier: uuid.UUID):
self.view = view
@ -332,13 +392,16 @@ class SshKeepaliveThread(Thread):
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:
pass # TODO
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

@ -14,17 +14,20 @@ from .actions import (
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 @@ class _SshSessionInputHandler(sublime_plugin.ListInputHandler):
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,32 @@ class SshConnectPasswordCommand(sublime_plugin.WindowCommand):
ssh_connect_password_command_lock.release()
class SshConnectInteractiveCommand(sublime_plugin.TextCommand):
def run(self, _edit, connection_str: str):
host, port, login, _ = parse_ssh_connection(connection_str)
ssh_connect_interactive(host, port, login, 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(self, _edit, identifier: str, host: str, port: int, login: str):
SshInteractiveConnectionWatcher(self.view, uuid.UUID(identifier), host, port, login).start()
def is_visible(self):
return False
class SshDisconnectCommand(sublime_plugin.TextCommand):
@_with_session_identifier
def run(self, _edit, identifier: str):
@ -708,11 +748,7 @@ class SshCloseDirCommand(sublime_plugin.TextCommand):
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

@ -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 @@ class SshSession:
# "target_remote": "127.0.0.1:4242", // allocated by remote
# },
# ],
# "is_interactive": false,
# "is_up": true,
# },
},

@ -9,11 +9,16 @@ import typing
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, pre_parse_forward_target
_logger = logging.getLogger(__package__)
@ -62,6 +67,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 +98,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 +139,64 @@ def ssh_connect(
return identifier
def ssh_connect_interactive(
host: str,
port: int,
login: str,
window: typing.Optional[sublime.Window] = None,
identifier: typing.Optional[uuid.UUID] = 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"
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), "host": host, "port": port, "login": login},
),
],
}
# 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.

@ -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:
"""