From 5337586df80b59c5d8e0173ff88b05710f833058 Mon Sep 17 00:00:00 2001 From: Bleblas2 Date: Thu, 13 Aug 2026 00:23:00 +0200 Subject: [PATCH 1/2] Update README and version to 0.1.4; enhance SSH jump host functionality and add system command descriptions --- README.md | 11 +++++++---- src/netbox_ssh/__init__.py | 2 +- src/netbox_ssh/terminal.py | 9 +++++---- src/netbox_ssh/tui.py | 24 +++++++++++++++++++----- tests/test_entrypoint.py | 2 +- tests/test_terminal.py | 14 +++++++++++++- 6 files changed, 46 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 5d6d363..9e646d3 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ the background. `NETBOX_API_TOKEN`. It is never written to cache or logs. - The NetBox token and URL are removed from the child SSH process environment. - SSH is started as an argument list without `shell=True`. -- Host keys, SSH Agent, ProxyJump, keys, and connection options remain managed +- Host keys, SSH Agent, keys, and connection options remain managed by the system OpenSSH client and `~/.ssh/config`. - A failed or interrupted sync never overwrites the previous valid cache. - The cache directory is mode `0700` and the cache file is mode `0600`. @@ -278,9 +278,12 @@ jump_host = "jump-host" The value may be a hostname, IP address, `user@host`, or an alias defined in `~/.ssh/config`. Highlight a device and press `J` to persistently enable or -disable the jump host for it. Marked devices display `J` and are opened with -OpenSSH ProxyJump (`ssh -J jump-host target`). SSH keys remain managed by the -local OpenSSH client. +disable the jump host for it. Marked devices display `J`. The application first +connects to the jump host and runs a second SSH client there (`ssh -tt jump-host +"ssh target"`). This supports jump hosts that prohibit TCP forwarding: the +local client can authenticate to the jump host with a key, while the target can +prompt interactively for a password. Passwords are never stored by the +application. ## First Run diff --git a/src/netbox_ssh/__init__.py b/src/netbox_ssh/__init__.py index ea4e313..5695159 100644 --- a/src/netbox_ssh/__init__.py +++ b/src/netbox_ssh/__init__.py @@ -1,3 +1,3 @@ """NetBox SSH Browser.""" -__version__ = "0.1.3" +__version__ = "0.1.4" diff --git a/src/netbox_ssh/terminal.py b/src/netbox_ssh/terminal.py index 5320cb0..536842b 100644 --- a/src/netbox_ssh/terminal.py +++ b/src/netbox_ssh/terminal.py @@ -33,13 +33,14 @@ def is_iterm2() -> bool: def ssh_arguments(device: Device, jump_host: str | None = None) -> list[str]: - arguments = ["ssh"] if device.use_jump_host: if not jump_host: raise ValueError("No SSH jump host is configured.") - arguments.extend(["-J", jump_host]) - arguments.append(device.ssh_target) - return arguments + # The second client runs on the jump host. This works on bastions that + # prohibit TCP forwarding and lets the target prompt for a password. + remote_command = f"ssh {shlex.quote(device.ssh_target)}" + return ["ssh", "-tt", jump_host, remote_command] + return ["ssh", device.ssh_target] def run_system_ssh( diff --git a/src/netbox_ssh/tui.py b/src/netbox_ssh/tui.py index a8fa24d..9e43b32 100644 --- a/src/netbox_ssh/tui.py +++ b/src/netbox_ssh/tui.py @@ -8,10 +8,10 @@ from rich.text import Text from textual import on -from textual.app import App, ComposeResult +from textual.app import App, ComposeResult, SystemCommand from textual.binding import Binding from textual.containers import Horizontal, Vertical -from textual.screen import ModalScreen +from textual.screen import ModalScreen, Screen from textual.widgets import Button, Footer, Header, Input, Label, ListItem, ListView, Static from .cache import Cache @@ -147,8 +147,8 @@ class NetBoxSSHApp(App[None]): BINDINGS = [ Binding("q", "quit", "Quit"), Binding("escape", "back", "Back"), - Binding("s", "sync", "Sync from NetBox"), - Binding("slash", "search", "Device search"), + Binding("s", "sync", "Sync", tooltip="Sync from NetBox"), + Binding("slash", "search", "Search", tooltip="Device search"), Binding("plus", "add_device", "Add manual device"), Binding("c", "edit_config", "Edit config"), Binding("m", "edit_manual", "Edit manual"), @@ -189,6 +189,20 @@ def compose(self) -> ComposeResult: yield Static(id="status") yield Footer() + def get_system_commands(self, screen: Screen): + """Adds full action names to the palette while the footer stays compact.""" + yield from super().get_system_commands(screen) + yield SystemCommand( + "Sync from NetBox", + "Refresh the local device inventory from NetBox", + self.action_sync, + ) + yield SystemCommand( + "Device search", + "Search all cached devices by name or primary IP", + self.action_search, + ) + async def on_mount(self) -> None: await self._render_entries() self.query_one(ListView).focus() @@ -391,7 +405,7 @@ async def action_clear_selection(self) -> None: self._set_status("Device selection cleared.") async def action_toggle_jump_host(self) -> None: - """Trwale przełącza ProxyJump dla wskazanego urządzenia.""" + """Trwale przełącza połączenie przez SSH uruchamiane na jump hoście.""" list_view = self.query_one(ListView) if list_view.index is None or list_view.index >= len(self.visible_entries): return diff --git a/tests/test_entrypoint.py b/tests/test_entrypoint.py index b9f44f0..b2533da 100644 --- a/tests/test_entrypoint.py +++ b/tests/test_entrypoint.py @@ -11,7 +11,7 @@ def test_version_does_not_start_tui(self) -> None: with contextlib.redirect_stdout(output), self.assertRaises(SystemExit) as exit_result: main(["--version"]) self.assertEqual(exit_result.exception.code, 0) - self.assertEqual(output.getvalue().strip(), "nssh 0.1.3") + self.assertEqual(output.getvalue().strip(), "nssh 0.1.4") if __name__ == "__main__": diff --git a/tests/test_terminal.py b/tests/test_terminal.py index 63c53cf..fff7bb1 100644 --- a/tests/test_terminal.py +++ b/tests/test_terminal.py @@ -32,6 +32,17 @@ def test_opens_quoted_ssh_commands_without_secrets(self, _is_iterm2, run) -> Non self.assertNotIn("NETBOX_URL", run.call_args.kwargs["env"]) self.assertFalse(run.call_args.kwargs["check"]) + @patch("netbox_ssh.terminal.subprocess.run") + @patch("netbox_ssh.terminal.is_iterm2", return_value=True) + def test_opens_nested_ssh_command_in_iterm(self, _is_iterm2, run) -> None: + run.return_value.returncode = 0 + run.return_value.stderr = "" + device = Device("switch-one", "Core", "192.0.2.1", use_jump_host=True) + open_iterm_tabs([device], "jump-alias") + self.assertEqual( + run.call_args.args[0][-1], "ssh -tt jump-alias 'ssh 192.0.2.1'" + ) + @patch("netbox_ssh.terminal.is_iterm2", return_value=False) def test_rejects_batch_outside_iterm2(self, _is_iterm2) -> None: with self.assertRaisesRegex(RuntimeError, "requires iTerm2"): @@ -60,7 +71,8 @@ def test_runs_marked_device_through_jump_host(self, run) -> None: device = Device("switch-one", "Core", "192.0.2.1", use_jump_host=True) run_system_ssh([device], "jump-alias") self.assertEqual( - run.call_args.args[0], ["ssh", "-J", "jump-alias", "192.0.2.1"] + run.call_args.args[0], + ["ssh", "-tt", "jump-alias", "ssh 192.0.2.1"], ) def test_rejects_marked_device_without_configured_jump_host(self) -> None: From e9a4263cede55cfb3ea323e4ee3f21659a62da34 Mon Sep 17 00:00:00 2001 From: Bleblas2 Date: Thu, 13 Aug 2026 00:27:43 +0200 Subject: [PATCH 2/2] Add newline for improved readability in README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 9e646d3..43a6fb0 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,7 @@ Store the NetBox URL and API token in the private user configuration: url = "https://netbox.example.com" api_token = "your-token" verify_ssl = true + ``` The token must contain only its value, without the `Bearer` or `Token` prefix.