Skip to content

Commit

Permalink
Implements interactive connection (through Terminus), Windows compliant
Browse files Browse the repository at this point in the history
> closes #24
  • Loading branch information
HorlogeSkynet committed Oct 11, 2024
1 parent 41a1bdd commit 0bacfac
Show file tree
Hide file tree
Showing 11 changed files with 320 additions and 71 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions Default.sublime-commands
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions Main.sublime-menu
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

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

Expand Down Expand Up @@ -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.9/pexpect/pxssh.py#L411-L414), and it isn't configurable.
Expand Down
4 changes: 4 additions & 0 deletions dependencies.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
{
"$schema": "sublime://packagecontrol.io/schemas/dependencies",
"windows": {
"*": []
},
"*": {
">4000": [
"pexpect",
Expand Down
4 changes: 3 additions & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -30,7 +30,9 @@
SshCancelForwardCommand,
SshCloseDirCommand,
SshConnectCommand,
SshConnectInteractiveCommand,
SshConnectPasswordCommand,
SshInteractiveConnectionWatcherCommand,
SshDisconnectCommand,
SshOpenDirCommand,
SshRequestForwardCommand,
Expand Down
181 changes: 140 additions & 41 deletions sshubl/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
mount_sshfs,
ssh_check_master,
ssh_connect,
ssh_connect_interactive,
ssh_disconnect,
ssh_forward,
umount_sshfs,
Expand All @@ -32,6 +33,49 @@
_logger = logging.getLogger(__package__)


def _on_connection( # 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,
Expand Down Expand Up @@ -91,45 +135,91 @@ def run(self):
finally:
self.view.erase_status("zz_connection_in_progress")

if identifier is None:
return
if identifier is not None:
_on_connection(
self.view,
SshSession(str(identifier), host, port, login),
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):
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

update_window_status(self.view.window())
# below attributes are only used in case of re-connection
self.mounts = mounts or {}
self.forwards = forwards or []

# 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"],
)
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:
while True:
# we fetch view "validity" _here_ to prevent a race condition when user closes the
# view right *after* we actually checked whether connection succeeded.
is_view_valid = self.view.is_valid()

# when master is considered "up" (i.e. client successfully connected to server), run
# connection postlude and leave
if ssh_check_master(self.identifier):
_on_connection(
self.view,
SshSession(str(self.identifier), host, port, login, is_interactive=True),
self.mounts,
self.forwards,
)
break

# stop this thread if view was closed (i.e. client has terminated)
if not is_view_valid:
# if view corresponded to a reconnection attempt, we have to update `is_up`
# session attribute as current attempt failed
with project_data_lock:
ssh_session = SshSession.get_from_project_data(self.identifier)
if ssh_session is not None:
ssh_session.is_up = False
ssh_session.set_in_project_data(self.view.window())
break

time.sleep(2)
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):
Expand Down Expand Up @@ -332,13 +422,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
Expand Down
Loading

0 comments on commit 0bacfac

Please sign in to comment.