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.
Files changed (23) hide show
  1. {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/PKG-INFO +7 -2
  2. {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/README.md +6 -1
  3. aws_ssh_utils-0.5.0/aws_ssh_utils/__init__.py +1 -0
  4. {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/aws_ssh_utils/commands/app.py +22 -1
  5. aws_ssh_utils-0.5.0/aws_ssh_utils/commands/custom_connection.py +132 -0
  6. {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/aws_ssh_utils/connection.py +30 -0
  7. aws_ssh_utils-0.4.0/aws_ssh_utils/__init__.py +0 -1
  8. {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/.gitignore +0 -0
  9. {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/LICENSE +0 -0
  10. {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/aws_ssh_utils/cli_utils.py +0 -0
  11. {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/aws_ssh_utils/commands/__init__.py +0 -0
  12. {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/aws_ssh_utils/commands/ec2.py +0 -0
  13. {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/aws_ssh_utils/commands/emr.py +0 -0
  14. {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/aws_ssh_utils/commands/emr_all.py +0 -0
  15. {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/aws_ssh_utils/commands/healthcheck.py +0 -0
  16. {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/aws_ssh_utils/ec2_utils.py +0 -0
  17. {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/aws_ssh_utils/emr_utils.py +0 -0
  18. {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/aws_ssh_utils/logging_utils.py +0 -0
  19. {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/aws_ssh_utils/py.typed +0 -0
  20. {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/aws_ssh_utils/session.py +0 -0
  21. {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/aws_ssh_utils/ssh.py +0 -0
  22. {aws_ssh_utils-0.4.0 → aws_ssh_utils-0.5.0}/aws_ssh_utils/terminal.py +0 -0
  23. {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.4.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. Every other key goes to the focused shell.
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. Every other key goes to the focused shell.
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