Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -278,9 +279,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

Expand Down
2 changes: 1 addition & 1 deletion src/netbox_ssh/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""NetBox SSH Browser."""

__version__ = "0.1.3"
__version__ = "0.1.4"
9 changes: 5 additions & 4 deletions src/netbox_ssh/terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
24 changes: 19 additions & 5 deletions src/netbox_ssh/tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/test_entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__":
Expand Down
14 changes: 13 additions & 1 deletion tests/test_terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down Expand Up @@ -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:
Expand Down
Loading