aws-ssh-utils 0.4.0__tar.gz → 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/PKG-INFO +7 -2
- {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/README.md +6 -1
- aws_ssh_utils-0.5.0/aws_ssh_utils/__init__.py +1 -0
- {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/aws_ssh_utils/commands/app.py +22 -1
- aws_ssh_utils-0.5.0/aws_ssh_utils/commands/custom_connection.py +132 -0
- {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/aws_ssh_utils/connection.py +30 -0
- aws_ssh_utils-0.4.0/aws_ssh_utils/__init__.py +0 -1
- {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/.gitignore +0 -0
- {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/LICENSE +0 -0
- {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/aws_ssh_utils/cli_utils.py +0 -0
- {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/aws_ssh_utils/commands/__init__.py +0 -0
- {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/aws_ssh_utils/commands/ec2.py +0 -0
- {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/aws_ssh_utils/commands/emr.py +0 -0
- {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/aws_ssh_utils/commands/emr_all.py +0 -0
- {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/aws_ssh_utils/commands/healthcheck.py +0 -0
- {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/aws_ssh_utils/ec2_utils.py +0 -0
- {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/aws_ssh_utils/emr_utils.py +0 -0
- {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/aws_ssh_utils/logging_utils.py +0 -0
- {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/aws_ssh_utils/py.typed +0 -0
- {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/aws_ssh_utils/session.py +0 -0
- {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/aws_ssh_utils/ssh.py +0 -0
- {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/aws_ssh_utils/terminal.py +0 -0
- {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/pyproject.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: aws-ssh-utils
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Easy AWS SSHing
|
|
5
5
|
Project-URL: Homepage, https://github.com/mvanderlee/aws-ssh-utils
|
|
6
6
|
Author-email: Michiel Vanderlee <jmt.vanderlee@gmail.com>
|
|
@@ -86,9 +86,14 @@ Options:
|
|
|
86
86
|
```
|
|
87
87
|
|
|
88
88
|
Scroll back with the mouse wheel or `shift+PgUp`/`shift+PgDn`; typing jumps back to the prompt.
|
|
89
|
-
`F12` shows/hides the sidebar, `ctrl+w` closes a failed tab, `ctrl+q` quits.
|
|
89
|
+
`F5` refreshes the EC2 and EMR lists, `F12` shows/hides the sidebar, `ctrl+w` closes a failed tab, `ctrl+q` quits.
|
|
90
|
+
Every other key goes to the focused shell.
|
|
90
91
|
Logs are viewable with `textual console`.
|
|
91
92
|
|
|
93
|
+
Right-click an EC2 instance and choose **Custom…** to select an SSH key, private or public IP, and username.
|
|
94
|
+
The form selects the instance's matching private key from `~/.ssh` and detects the username from its AMI.
|
|
95
|
+
**Connect** opens a shell using those settings directly over SSH.
|
|
96
|
+
|
|
92
97
|
To keep diagnostics and crash tracebacks in a file, use the global `--log-file` option:
|
|
93
98
|
|
|
94
99
|
```shell
|
|
@@ -48,9 +48,14 @@ Options:
|
|
|
48
48
|
```
|
|
49
49
|
|
|
50
50
|
Scroll back with the mouse wheel or `shift+PgUp`/`shift+PgDn`; typing jumps back to the prompt.
|
|
51
|
-
`F12` shows/hides the sidebar, `ctrl+w` closes a failed tab, `ctrl+q` quits.
|
|
51
|
+
`F5` refreshes the EC2 and EMR lists, `F12` shows/hides the sidebar, `ctrl+w` closes a failed tab, `ctrl+q` quits.
|
|
52
|
+
Every other key goes to the focused shell.
|
|
52
53
|
Logs are viewable with `textual console`.
|
|
53
54
|
|
|
55
|
+
Right-click an EC2 instance and choose **Custom…** to select an SSH key, private or public IP, and username.
|
|
56
|
+
The form selects the instance's matching private key from `~/.ssh` and detects the username from its AMI.
|
|
57
|
+
**Connect** opens a shell using those settings directly over SSH.
|
|
58
|
+
|
|
54
59
|
To keep diagnostics and crash tracebacks in a file, use the global `--log-file` option:
|
|
55
60
|
|
|
56
61
|
```shell
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.5.0"
|
|
@@ -25,6 +25,7 @@ from textual.widgets.option_list import Option
|
|
|
25
25
|
from textual.widgets.tree import TreeNode
|
|
26
26
|
|
|
27
27
|
from aws_ssh_utils.cli_utils import detect_environment, detect_scrollback, render_attempts, scrollback_option
|
|
28
|
+
from aws_ssh_utils.commands.custom_connection import CustomConnection
|
|
28
29
|
from aws_ssh_utils.connection import Attempt, Environment, Target, connect, open_shell_channel
|
|
29
30
|
from aws_ssh_utils.ec2_utils import ec2_name, ec2_target, get_running_ec2_instances, get_vpc_names
|
|
30
31
|
from aws_ssh_utils.emr_utils import emr_target, get_emr_clusters, get_emr_instances, group_role, group_sort_key
|
|
@@ -251,6 +252,7 @@ class SSHApp(TerminalApp):
|
|
|
251
252
|
"""
|
|
252
253
|
BINDINGS: ClassVar[list[BindingType]] = [
|
|
253
254
|
Binding('ctrl+q', 'quit', 'Quit', priority=True),
|
|
255
|
+
Binding('f5', 'refresh_instances', 'Refresh', priority=True),
|
|
254
256
|
Binding('f12', 'toggle_sidebar', 'Toggle sidebar', priority=True),
|
|
255
257
|
Binding('ctrl+w', 'close_tab', 'Close tab'),
|
|
256
258
|
]
|
|
@@ -320,6 +322,18 @@ class SSHApp(TerminalApp):
|
|
|
320
322
|
if shells.active:
|
|
321
323
|
shells.remove_pane(shells.active)
|
|
322
324
|
|
|
325
|
+
def action_refresh_instances(self):
|
|
326
|
+
# Let in-flight list requests finish before invalidating their results.
|
|
327
|
+
if any(worker.node is self and not worker.is_finished for worker in self.workers):
|
|
328
|
+
return
|
|
329
|
+
for prefix in ('ec2', 'emr'):
|
|
330
|
+
self.query_one(f'#{prefix}-error').display = False
|
|
331
|
+
self.query_one(f'#{prefix}-loading').display = True
|
|
332
|
+
self.cluster_instances.clear()
|
|
333
|
+
self.filter_emr()
|
|
334
|
+
self.load_ec2()
|
|
335
|
+
self.load_emr()
|
|
336
|
+
|
|
323
337
|
def on_tabbed_content_tab_activated(self, event: TabbedContent.TabActivated):
|
|
324
338
|
if event.tabbed_content.id != 'shells' or event.pane.id != event.tabbed_content.active:
|
|
325
339
|
return
|
|
@@ -400,10 +414,17 @@ class SSHApp(TerminalApp):
|
|
|
400
414
|
options = [
|
|
401
415
|
Option('Connect via private IP', id='private'),
|
|
402
416
|
Option('Connect via public IP', id='public', disabled=not instance.get('PublicIpAddress')),
|
|
417
|
+
Option('Custom…', id='custom'),
|
|
403
418
|
]
|
|
404
419
|
|
|
420
|
+
def on_custom(target: Target | None):
|
|
421
|
+
if target is not None:
|
|
422
|
+
self.open_shell(f'{target.user}@{ec2_name(instance)}', lambda: target)
|
|
423
|
+
|
|
405
424
|
def on_choice(choice: str | None):
|
|
406
|
-
if choice:
|
|
425
|
+
if choice == 'custom':
|
|
426
|
+
self.push_screen(CustomConnection(self.ec2, instance), on_custom)
|
|
427
|
+
elif choice:
|
|
407
428
|
self.open_ec2_shell(instance, public=choice == 'public')
|
|
408
429
|
|
|
409
430
|
self.push_screen(ContextMenu(event.screen_offset, options), on_choice)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Custom SSH connection form for an EC2 instance."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
5
|
+
|
|
6
|
+
from botocore.exceptions import BotoCoreError, ClientError
|
|
7
|
+
from textual import work
|
|
8
|
+
from textual.app import ComposeResult
|
|
9
|
+
from textual.binding import Binding, BindingType
|
|
10
|
+
from textual.containers import Horizontal, Vertical
|
|
11
|
+
from textual.screen import ModalScreen
|
|
12
|
+
from textual.widgets import Button, Input, Label, RadioButton, RadioSet, Select, Static
|
|
13
|
+
|
|
14
|
+
from aws_ssh_utils.connection import Target, list_ssh_key_files
|
|
15
|
+
from aws_ssh_utils.ec2_utils import ec2_name, guess_ec2_user
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from mypy_boto3_ec2 import EC2Client
|
|
19
|
+
from mypy_boto3_ec2.type_defs import InstanceTypeDef
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CustomConnection(ModalScreen[Target | None]):
|
|
23
|
+
DEFAULT_CSS = """
|
|
24
|
+
CustomConnection { align: center middle; }
|
|
25
|
+
CustomConnection > Vertical {
|
|
26
|
+
width: 64;
|
|
27
|
+
max-width: 95%;
|
|
28
|
+
height: auto;
|
|
29
|
+
max-height: 95%;
|
|
30
|
+
overflow-y: auto;
|
|
31
|
+
padding: 1 2;
|
|
32
|
+
border: round $accent;
|
|
33
|
+
background: $surface;
|
|
34
|
+
}
|
|
35
|
+
CustomConnection Label { margin-top: 1; }
|
|
36
|
+
CustomConnection RadioSet { width: 1fr; height: auto; }
|
|
37
|
+
CustomConnection Horizontal { height: auto; margin-top: 1; align-horizontal: right; }
|
|
38
|
+
CustomConnection Button { margin-left: 1; }
|
|
39
|
+
CustomConnection #custom-error { color: $error; height: auto; }
|
|
40
|
+
"""
|
|
41
|
+
BINDINGS: ClassVar[list[BindingType]] = [Binding('escape', 'cancel', 'Cancel')]
|
|
42
|
+
|
|
43
|
+
def __init__(self, ec2: 'EC2Client', instance: 'InstanceTypeDef'):
|
|
44
|
+
super().__init__()
|
|
45
|
+
self.ec2 = ec2
|
|
46
|
+
self.instance = instance
|
|
47
|
+
|
|
48
|
+
def compose(self) -> ComposeResult:
|
|
49
|
+
private = self.instance.get('PrivateIpAddress', '')
|
|
50
|
+
public = self.instance.get('PublicIpAddress', '')
|
|
51
|
+
with Vertical():
|
|
52
|
+
yield Static(f'Custom connection — {ec2_name(self.instance)}', markup=False)
|
|
53
|
+
yield Label('SSH key')
|
|
54
|
+
yield Select([('Default keys / SSH agent', '')], allow_blank=False, id='custom-key', disabled=True)
|
|
55
|
+
yield Label('Address')
|
|
56
|
+
with RadioSet(id='custom-address'):
|
|
57
|
+
yield RadioButton(f'Private: {private or "unavailable"}', value=bool(private), disabled=not private, id='private')
|
|
58
|
+
yield RadioButton(
|
|
59
|
+
f'Public: {public or "unavailable"}',
|
|
60
|
+
value=bool(public and not private),
|
|
61
|
+
disabled=not public,
|
|
62
|
+
id='public',
|
|
63
|
+
)
|
|
64
|
+
yield Label('User')
|
|
65
|
+
yield Input(placeholder='Detecting username…', id='custom-user', disabled=True)
|
|
66
|
+
yield Static('', id='custom-error', markup=False)
|
|
67
|
+
with Horizontal():
|
|
68
|
+
yield Button('Cancel', id='custom-cancel')
|
|
69
|
+
yield Button('Connect', variant='primary', id='custom-connect', disabled=True)
|
|
70
|
+
|
|
71
|
+
def on_mount(self):
|
|
72
|
+
self.load_defaults()
|
|
73
|
+
|
|
74
|
+
@work(thread=True, exit_on_error=False)
|
|
75
|
+
def load_defaults(self):
|
|
76
|
+
keys = list_ssh_key_files()
|
|
77
|
+
error = ''
|
|
78
|
+
try:
|
|
79
|
+
user = guess_ec2_user(self.ec2, self.instance)
|
|
80
|
+
except (BotoCoreError, ClientError):
|
|
81
|
+
user = ''
|
|
82
|
+
error = 'Could not detect the username. Enter the SSH user.'
|
|
83
|
+
self.app.call_from_thread(self.set_defaults, keys, user, error)
|
|
84
|
+
|
|
85
|
+
def set_defaults(self, keys: list[str], user: str, error: str):
|
|
86
|
+
if not self.is_mounted:
|
|
87
|
+
return
|
|
88
|
+
select = self.query_one('#custom-key', Select)
|
|
89
|
+
select.set_options([('Default keys / SSH agent', ''), *((key, key) for key in keys)])
|
|
90
|
+
key_name = self.instance.get('KeyName')
|
|
91
|
+
select.value = next((key for key in keys if key_name in (Path(key).name, Path(key).stem)), '')
|
|
92
|
+
select.disabled = False
|
|
93
|
+
user_input = self.query_one('#custom-user', Input)
|
|
94
|
+
user_input.value = user
|
|
95
|
+
user_input.placeholder = 'SSH username'
|
|
96
|
+
user_input.disabled = False
|
|
97
|
+
self.query_one('#custom-error', Static).update(error)
|
|
98
|
+
self.query_one('#custom-connect', Button).disabled = False
|
|
99
|
+
|
|
100
|
+
def on_input_changed(self, event: Input.Changed):
|
|
101
|
+
event.stop()
|
|
102
|
+
|
|
103
|
+
def action_cancel(self):
|
|
104
|
+
self.dismiss(None)
|
|
105
|
+
|
|
106
|
+
def on_button_pressed(self, event: Button.Pressed):
|
|
107
|
+
event.stop()
|
|
108
|
+
if event.button.id == 'custom-cancel':
|
|
109
|
+
self.action_cancel()
|
|
110
|
+
return
|
|
111
|
+
user_input = self.query_one('#custom-user', Input)
|
|
112
|
+
user = user_input.value.strip()
|
|
113
|
+
error = self.query_one('#custom-error', Static)
|
|
114
|
+
if not user or any(char.isspace() for char in user):
|
|
115
|
+
error.update('Enter an SSH username without spaces.')
|
|
116
|
+
user_input.focus()
|
|
117
|
+
return
|
|
118
|
+
address = self.query_one('#custom-address', RadioSet).pressed_button
|
|
119
|
+
ip = self.instance.get('PublicIpAddress' if address and address.id == 'public' else 'PrivateIpAddress', '')
|
|
120
|
+
if not ip:
|
|
121
|
+
error.update('This instance has no available IP address.')
|
|
122
|
+
return
|
|
123
|
+
key = self.query_one('#custom-key', Select).value
|
|
124
|
+
self.dismiss(
|
|
125
|
+
Target(
|
|
126
|
+
instance_id=self.instance.get('InstanceId', ''),
|
|
127
|
+
ip=ip,
|
|
128
|
+
user=user,
|
|
129
|
+
key_file=key if isinstance(key, str) and key else None,
|
|
130
|
+
ssh_only=True,
|
|
131
|
+
),
|
|
132
|
+
)
|
|
@@ -44,6 +44,8 @@ class Target:
|
|
|
44
44
|
key_file: str | None = None
|
|
45
45
|
"""Explicit key file, used instead of looking up `key_name` in ~/.ssh."""
|
|
46
46
|
opkssh_provider: str | None = None
|
|
47
|
+
ssh_only: bool = False
|
|
48
|
+
"""Connect directly with this user, IP and key, bypassing automatic connection strategies."""
|
|
47
49
|
|
|
48
50
|
|
|
49
51
|
@dataclass(frozen=True)
|
|
@@ -94,6 +96,7 @@ def connect(
|
|
|
94
96
|
|
|
95
97
|
`on_progress` gets a snapshot of all attempts whenever one changes state.
|
|
96
98
|
Unknown host keys are trusted, unless `confirm_host_key` declines them.
|
|
99
|
+
With `target.ssh_only`, only the supplied SSH settings are tried.
|
|
97
100
|
"""
|
|
98
101
|
open_ssh_ = partial(open_ssh, confirm_host_key=confirm_host_key)
|
|
99
102
|
attempts: list[Attempt] = []
|
|
@@ -116,6 +119,17 @@ def connect(
|
|
|
116
119
|
on_progress(list(attempts))
|
|
117
120
|
return Connection(client, attempt.via)
|
|
118
121
|
|
|
122
|
+
if target.ssh_only:
|
|
123
|
+
keys = [target.key_file] if target.key_file else []
|
|
124
|
+
attempts.append(
|
|
125
|
+
Attempt(
|
|
126
|
+
Path(target.key_file).stem if target.key_file else 'default key',
|
|
127
|
+
ssh_command(target.user, target.ip, keys, identities_only=bool(keys)),
|
|
128
|
+
lambda: open_ssh_(target.ip, target.user, keys, use_defaults=not keys),
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
return try_attempt(0)
|
|
132
|
+
|
|
119
133
|
config = find_ssh_config(target)
|
|
120
134
|
if config is not None:
|
|
121
135
|
alias, options = config
|
|
@@ -369,6 +383,22 @@ def proxy_socket(command: str | list[str], on_stderr: OnOutput) -> socket.socket
|
|
|
369
383
|
|
|
370
384
|
|
|
371
385
|
# region - keys
|
|
386
|
+
def list_ssh_key_files() -> list[str]:
|
|
387
|
+
"""Find OpenSSH/PEM private keys in ~/.ssh, including nested directories."""
|
|
388
|
+
keys = []
|
|
389
|
+
for dirpath, _, filenames in os.walk(SSH_DIR):
|
|
390
|
+
for name in filenames:
|
|
391
|
+
path = Path(dirpath) / name
|
|
392
|
+
try:
|
|
393
|
+
with path.open('rb') as key:
|
|
394
|
+
header = key.readline(128).strip()
|
|
395
|
+
except OSError:
|
|
396
|
+
continue
|
|
397
|
+
if header.startswith(b'-----BEGIN ') and header.endswith(b'PRIVATE KEY-----'):
|
|
398
|
+
keys.append(str(path))
|
|
399
|
+
return sorted(keys, key=str.casefold)
|
|
400
|
+
|
|
401
|
+
|
|
372
402
|
def find_ssh_key_file(key_name: str | None) -> str | None:
|
|
373
403
|
"""Recursively search ~/.ssh for a file named `key_name`, with or without extension."""
|
|
374
404
|
if not key_name:
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.4.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|