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:
commit
12e3f998c5
@ -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"
|
||||
|
11
README.md
11
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, 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",
|
||||
|
4
main.py
4
main.py
@ -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:
|
||||
"""
|
||||
|
Loading…
x
Reference in New Issue
Block a user