ssm-cli 1.0.2.dev0__tar.gz → 1.1.0.dev0__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.
- {ssm_cli-1.0.2.dev0/ssm_cli.egg-info → ssm_cli-1.1.0.dev0}/PKG-INFO +2 -2
- {ssm_cli-1.0.2.dev0 → ssm_cli-1.1.0.dev0}/pyproject.toml +2 -2
- {ssm_cli-1.0.2.dev0 → ssm_cli-1.1.0.dev0}/ssm_cli/__main__.py +1 -1
- ssm_cli-1.1.0.dev0/ssm_cli/app.py +186 -0
- {ssm_cli-1.0.2.dev0 → ssm_cli-1.1.0.dev0}/ssm_cli/aws.py +15 -11
- ssm_cli-1.1.0.dev0/ssm_cli/cli.py +49 -0
- ssm_cli-1.1.0.dev0/ssm_cli/click.py +77 -0
- {ssm_cli-1.0.2.dev0 → ssm_cli-1.1.0.dev0}/ssm_cli/config.py +3 -12
- {ssm_cli-1.0.2.dev0 → ssm_cli-1.1.0.dev0}/ssm_cli/logging.py +3 -1
- ssm_cli-1.1.0.dev0/ssm_cli/selectors/tui.py +22 -0
- ssm_cli-1.1.0.dev0/ssm_cli/ssh_proxy/__init__.py +0 -0
- {ssm_cli-1.0.2.dev0/ssm_cli/commands → ssm_cli-1.1.0.dev0/ssm_cli}/ssh_proxy/forwarding.py +15 -12
- {ssm_cli-1.0.2.dev0/ssm_cli/commands → ssm_cli-1.1.0.dev0/ssm_cli}/ssh_proxy/server.py +4 -5
- {ssm_cli-1.0.2.dev0/ssm_cli/commands → ssm_cli-1.1.0.dev0/ssm_cli}/ssh_proxy/shell.py +1 -1
- ssm_cli-1.1.0.dev0/ssm_cli/ui.py +54 -0
- {ssm_cli-1.0.2.dev0 → ssm_cli-1.1.0.dev0/ssm_cli.egg-info}/PKG-INFO +2 -2
- ssm_cli-1.1.0.dev0/ssm_cli.egg-info/SOURCES.txt +29 -0
- {ssm_cli-1.0.2.dev0 → ssm_cli-1.1.0.dev0}/ssm_cli.egg-info/requires.txt +1 -1
- ssm_cli-1.0.2.dev0/ssm_cli/cli.py +0 -96
- ssm_cli-1.0.2.dev0/ssm_cli/cli_args.py +0 -116
- ssm_cli-1.0.2.dev0/ssm_cli/commands/__init__.py +0 -14
- ssm_cli-1.0.2.dev0/ssm_cli/commands/base.py +0 -16
- ssm_cli-1.0.2.dev0/ssm_cli/commands/list.py +0 -37
- ssm_cli-1.0.2.dev0/ssm_cli/commands/setup.py +0 -88
- ssm_cli-1.0.2.dev0/ssm_cli/commands/shell.py +0 -34
- ssm_cli-1.0.2.dev0/ssm_cli/commands/ssh_proxy/__init__.py +0 -30
- ssm_cli-1.0.2.dev0/ssm_cli/console.py +0 -2
- ssm_cli-1.0.2.dev0/ssm_cli/selectors/tui/__init__.py +0 -61
- ssm_cli-1.0.2.dev0/ssm_cli/selectors/tui/posix.py +0 -24
- ssm_cli-1.0.2.dev0/ssm_cli/selectors/tui/win.py +0 -18
- ssm_cli-1.0.2.dev0/ssm_cli.egg-info/SOURCES.txt +0 -35
- {ssm_cli-1.0.2.dev0 → ssm_cli-1.1.0.dev0}/LICENCE +0 -0
- {ssm_cli-1.0.2.dev0 → ssm_cli-1.1.0.dev0}/README.md +0 -0
- {ssm_cli-1.0.2.dev0 → ssm_cli-1.1.0.dev0}/setup.cfg +0 -0
- {ssm_cli-1.0.2.dev0 → ssm_cli-1.1.0.dev0}/ssm_cli/__init__.py +0 -0
- {ssm_cli-1.0.2.dev0 → ssm_cli-1.1.0.dev0}/ssm_cli/instances.py +1 -1
- {ssm_cli-1.0.2.dev0 → ssm_cli-1.1.0.dev0}/ssm_cli/selectors/__init__.py +0 -0
- {ssm_cli-1.0.2.dev0 → ssm_cli-1.1.0.dev0}/ssm_cli/selectors/first.py +0 -0
- {ssm_cli-1.0.2.dev0/ssm_cli/commands → ssm_cli-1.1.0.dev0/ssm_cli}/ssh_proxy/channels.py +0 -0
- {ssm_cli-1.0.2.dev0/ssm_cli/commands → ssm_cli-1.1.0.dev0/ssm_cli}/ssh_proxy/socket.py +0 -0
- {ssm_cli-1.0.2.dev0 → ssm_cli-1.1.0.dev0}/ssm_cli/xdg.py +0 -0
- {ssm_cli-1.0.2.dev0 → ssm_cli-1.1.0.dev0}/ssm_cli.egg-info/dependency_links.txt +0 -0
- {ssm_cli-1.0.2.dev0 → ssm_cli-1.1.0.dev0}/ssm_cli.egg-info/entry_points.txt +0 -0
- {ssm_cli-1.0.2.dev0 → ssm_cli-1.1.0.dev0}/ssm_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ssm-cli
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.1.0.dev0
|
|
4
4
|
Summary: CLI tool to help with SSM functionality, aimed at adminstrators
|
|
5
5
|
Author-email: Simon Fletcher <simon.fletcher@lexisnexisrisk.com>
|
|
6
6
|
License: MIT License
|
|
@@ -35,10 +35,10 @@ Description-Content-Type: text/markdown
|
|
|
35
35
|
License-File: LICENCE
|
|
36
36
|
Requires-Dist: boto3
|
|
37
37
|
Requires-Dist: paramiko
|
|
38
|
-
Requires-Dist: rich-argparse
|
|
39
38
|
Requires-Dist: rich
|
|
40
39
|
Requires-Dist: confclasses>=0.3.2
|
|
41
40
|
Requires-Dist: xdg_base_dirs
|
|
41
|
+
Requires-Dist: rich-click
|
|
42
42
|
Dynamic: license-file
|
|
43
43
|
|
|
44
44
|
# SSM CLI
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import rich_click as click
|
|
2
|
+
import confclasses
|
|
3
|
+
|
|
4
|
+
from ssm_cli.click import PluginGroup, version_option
|
|
5
|
+
from ssm_cli.logging import set_log_level
|
|
6
|
+
from ssm_cli.xdg import get_conf_root, get_conf_file, get_ssh_hostkey
|
|
7
|
+
from ssm_cli.config import CONFIG
|
|
8
|
+
from ssm_cli.ui import console, Table
|
|
9
|
+
|
|
10
|
+
GREY = "grey50"
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
@click.group(cls=PluginGroup)
|
|
16
|
+
@click.option_panel("Global Options", options=['--profile', '--log-level', '--version', '--help'])
|
|
17
|
+
@click.option('--profile', type=str, required=False, help="Which AWS profile to use")
|
|
18
|
+
@click.option('--log-level', type=str, required=False, help="Set the logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)")
|
|
19
|
+
@version_option('--version')
|
|
20
|
+
def app(profile, log_level):
|
|
21
|
+
# Allow setup stuff to log at the right level if passed in
|
|
22
|
+
if log_level:
|
|
23
|
+
set_log_level(log_level)
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
with open(get_conf_file(), 'r') as file:
|
|
27
|
+
confclasses.load(CONFIG, file)
|
|
28
|
+
logger.debug(f"Config: {CONFIG}")
|
|
29
|
+
except EnvironmentError as e:
|
|
30
|
+
console.print(f"Invalid config: {e}", style="red")
|
|
31
|
+
|
|
32
|
+
if log_level:
|
|
33
|
+
CONFIG.log.level = log_level
|
|
34
|
+
if profile:
|
|
35
|
+
CONFIG.aws_profile = profile
|
|
36
|
+
|
|
37
|
+
if not log_level:
|
|
38
|
+
set_log_level(CONFIG.log.level)
|
|
39
|
+
elif log_level != logging.DEBUG:
|
|
40
|
+
for logger_name, level in CONFIG.log.loggers.items():
|
|
41
|
+
set_log_level(level, name=logger_name)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.command(help='list all instances in a group, if no group provided, will list all available groups')
|
|
45
|
+
@click.argument('group', required=False, type=str, help="group to run against")
|
|
46
|
+
def list(group):
|
|
47
|
+
import ssm_cli.instances
|
|
48
|
+
logger.info(f"running list action (group: {group})")
|
|
49
|
+
|
|
50
|
+
instances = ssm_cli.instances.Instances()
|
|
51
|
+
table = Table()
|
|
52
|
+
|
|
53
|
+
if group:
|
|
54
|
+
table.add_column("ID")
|
|
55
|
+
table.add_column("Name")
|
|
56
|
+
table.add_column("IP")
|
|
57
|
+
table.add_column("Ping")
|
|
58
|
+
for instance in instances.list_instances(group, True):
|
|
59
|
+
table.add_row(instance.id, instance.name, instance.ip, instance.ping)
|
|
60
|
+
console.print(table)
|
|
61
|
+
else:
|
|
62
|
+
table.add_column("Group")
|
|
63
|
+
table.add_column("Total")
|
|
64
|
+
table.add_column("Online")
|
|
65
|
+
for group in sorted(instances.list_groups(), key=lambda x: x['name']):
|
|
66
|
+
table.add_row(group['name'], str(group['total']), str(group['online']))
|
|
67
|
+
console.print(table)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.command(help='connects to instance using AWS-StartSession')
|
|
71
|
+
@click.argument('group', type=str, help="group to run against")
|
|
72
|
+
def shell(group):
|
|
73
|
+
import ssm_cli.instances
|
|
74
|
+
|
|
75
|
+
logger.info(f"running shell action (group: {group})")
|
|
76
|
+
|
|
77
|
+
instances = ssm_cli.instances.Instances()
|
|
78
|
+
try:
|
|
79
|
+
instance = instances.select_instance(group, "tui")
|
|
80
|
+
except KeyboardInterrupt:
|
|
81
|
+
logger.error("user cancelled")
|
|
82
|
+
console.print(f":x: [bold red]user cancelled[/bold red]")
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
if instance is None:
|
|
86
|
+
logger.error("failed to select host")
|
|
87
|
+
console.print(f":x: [bold red]failed to select host[/bold red]")
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
logger.info(f"connecting to {repr(instance)}")
|
|
91
|
+
console.print(f":computer: [bold green]connecting to {instance.name} ({instance.id})[/bold green]")
|
|
92
|
+
|
|
93
|
+
instance.start_session()
|
|
94
|
+
|
|
95
|
+
@app.command(help="ssh proxy, used for automatic tunnels without ssh auth")
|
|
96
|
+
@click.argument('group', type=str, help="group to run against")
|
|
97
|
+
def sshproxy(group):
|
|
98
|
+
import ssm_cli.instances
|
|
99
|
+
import ssm_cli.ssh_proxy.server
|
|
100
|
+
|
|
101
|
+
logger.info(f"running proxycommand action (group: {group})")
|
|
102
|
+
|
|
103
|
+
instances = ssm_cli.instances.Instances()
|
|
104
|
+
instance = instances.select_instance(group, "first")
|
|
105
|
+
|
|
106
|
+
if instance is None:
|
|
107
|
+
logger.error("failed to select host")
|
|
108
|
+
raise RuntimeError("failed to select host")
|
|
109
|
+
|
|
110
|
+
logger.info(f"connecting to {repr(instance)}")
|
|
111
|
+
|
|
112
|
+
server = ssm_cli.ssh_proxy.server.SshServer(instance)
|
|
113
|
+
server.start()
|
|
114
|
+
|
|
115
|
+
@app.command(help="setups up ssm-cli, can be rerun safely")
|
|
116
|
+
@click.option("--replace-config", is_flag=True, help="if we should replace existing config file")
|
|
117
|
+
@click.option("--replace-hostkey", is_flag=True, help="if we should replace existing hostkey file (be careful with this option)")
|
|
118
|
+
def setup(replace_config, replace_hostkey):
|
|
119
|
+
# Create the root config directory
|
|
120
|
+
root = get_conf_root(False)
|
|
121
|
+
logger.debug(f"Checking if {root} exists")
|
|
122
|
+
if root.exists():
|
|
123
|
+
logger.debug(f"{root} exists")
|
|
124
|
+
if not root.is_dir():
|
|
125
|
+
logger.error(f"{root} already exists and is not a directory. Manual cleanup is likely needed.")
|
|
126
|
+
console.print(f"{root} already exists and is not a directory. Manual cleanup is likely needed.", style="red bold")
|
|
127
|
+
return
|
|
128
|
+
console.print(f"{root} - skipping (already exists)", style=GREY)
|
|
129
|
+
else:
|
|
130
|
+
root.mkdir(511, True, True)
|
|
131
|
+
console.print(f"{root} created", style="green")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# Create the config file
|
|
135
|
+
path = get_conf_file(False)
|
|
136
|
+
logger.debug(f"Checking if {path} exists")
|
|
137
|
+
create_config = False
|
|
138
|
+
if path.exists():
|
|
139
|
+
logger.debug(f"{path} exists")
|
|
140
|
+
if replace_config:
|
|
141
|
+
logger.info(f"{path} exists and --replace-config was set, unlink {path}")
|
|
142
|
+
console.print(f"{path} removing", style="green")
|
|
143
|
+
path.unlink(True)
|
|
144
|
+
create_config = True
|
|
145
|
+
else:
|
|
146
|
+
logger.debug(f"{path} does not exist")
|
|
147
|
+
create_config = True
|
|
148
|
+
|
|
149
|
+
if create_config:
|
|
150
|
+
import rich.markup
|
|
151
|
+
|
|
152
|
+
logger.info(f"{path} creating")
|
|
153
|
+
console.print(f"{path} creating", style="green")
|
|
154
|
+
confclasses.from_dict(CONFIG, {})
|
|
155
|
+
text = rich.markup.escape(f"What tag to use to split up the instances [{CONFIG.group_tag_key}]: ")
|
|
156
|
+
tag_key = console.input(text)
|
|
157
|
+
CONFIG.group_tag_key = tag_key or CONFIG.group_tag_key
|
|
158
|
+
console.print(f"Using '{CONFIG.group_tag_key}' as the group tag", style=GREY)
|
|
159
|
+
logger.info(f"Writing config to {path}")
|
|
160
|
+
|
|
161
|
+
with path.open("w+") as f:
|
|
162
|
+
confclasses.save(CONFIG, f)
|
|
163
|
+
console.print(f"{path} created", style="green")
|
|
164
|
+
|
|
165
|
+
# Create the ssh hostkey
|
|
166
|
+
path = get_ssh_hostkey(False)
|
|
167
|
+
create_key = False
|
|
168
|
+
if path.exists():
|
|
169
|
+
logger.debug(f"{path} exists")
|
|
170
|
+
console.print(f"{path} skipping (already exists)")
|
|
171
|
+
if replace_hostkey:
|
|
172
|
+
logger.info(f"{path} exists and --replace-hostkey was set, unlink {path}")
|
|
173
|
+
console.print(f"{path} removing", style="green")
|
|
174
|
+
path.unlink(True)
|
|
175
|
+
create_key = True
|
|
176
|
+
else:
|
|
177
|
+
logger.debug(f"{path} does not exist")
|
|
178
|
+
create_key = True
|
|
179
|
+
|
|
180
|
+
if create_key:
|
|
181
|
+
import paramiko
|
|
182
|
+
|
|
183
|
+
logger.info(f"{path} creating")
|
|
184
|
+
host_key = paramiko.RSAKey.generate(1024)
|
|
185
|
+
host_key.write_private_key_file(path)
|
|
186
|
+
console.print(f"{path} created")
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import boto3
|
|
2
2
|
import botocore
|
|
3
3
|
import contextlib
|
|
4
|
-
from
|
|
4
|
+
from threading import local
|
|
5
|
+
|
|
6
|
+
from ssm_cli.config import CONFIG
|
|
7
|
+
|
|
8
|
+
_cache = local()
|
|
9
|
+
_cache.client_cache = {}
|
|
10
|
+
_cache.session_cache = None
|
|
5
11
|
|
|
6
12
|
class AWSError(Exception):
|
|
7
13
|
""" A generic exception for any AWS errors """
|
|
@@ -15,22 +21,20 @@ class AWSAccessDeniedError(AWSError):
|
|
|
15
21
|
""" An exception for when the AWS credentials do not have the required permissions """
|
|
16
22
|
pass
|
|
17
23
|
|
|
18
|
-
_session_cache = []
|
|
19
|
-
_client_cache = {}
|
|
20
|
-
|
|
21
24
|
@contextlib.contextmanager
|
|
22
25
|
def aws_session(use_cache=True):
|
|
23
26
|
""" A context manager for creating a boto3 session with caching built in """
|
|
24
27
|
try:
|
|
25
|
-
|
|
26
|
-
|
|
28
|
+
# TODO, check its still valid
|
|
29
|
+
if _cache.session_cache is not None and use_cache:
|
|
30
|
+
yield _cache.session_cache
|
|
27
31
|
return
|
|
28
32
|
|
|
29
|
-
session = boto3.Session(profile_name=
|
|
33
|
+
session = boto3.Session(profile_name=CONFIG.aws_profile)
|
|
30
34
|
if session.region_name is None:
|
|
31
35
|
raise AWSAuthError(f"AWS config missing region for profile {session.profile_name}")
|
|
32
36
|
|
|
33
|
-
|
|
37
|
+
_cache.session_cache = session
|
|
34
38
|
yield session
|
|
35
39
|
except botocore.exceptions.ProfileNotFound as e:
|
|
36
40
|
raise AWSAuthError(f"profile invalid") from e
|
|
@@ -45,10 +49,10 @@ def aws_session(use_cache=True):
|
|
|
45
49
|
def aws_client(service_name, use_cache=True):
|
|
46
50
|
""" A context manager for creating a boto3 client with caching built in """
|
|
47
51
|
with aws_session(use_cache) as session:
|
|
48
|
-
if service_name in
|
|
49
|
-
yield
|
|
52
|
+
if service_name in _cache.client_cache and use_cache:
|
|
53
|
+
yield _cache.client_cache[service_name]
|
|
50
54
|
return
|
|
51
55
|
|
|
52
56
|
client = session.client(service_name)
|
|
53
|
-
|
|
57
|
+
_cache.client_cache[service_name] = client
|
|
54
58
|
yield client
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from typing import List
|
|
3
|
+
from ssm_cli.xdg import get_log_file
|
|
4
|
+
from ssm_cli.aws import AWSAuthError, AWSAccessDeniedError
|
|
5
|
+
from ssm_cli.ui import console
|
|
6
|
+
from ssm_cli.logging import setup_logging, set_log_level
|
|
7
|
+
from rich.markup import escape
|
|
8
|
+
from ssm_cli.app import app
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
def cli(argv:List[str]=None) -> int:
|
|
14
|
+
""" Entry point for ssm-cli, we use this wrapper to put all the aws errors in one place. Otherwise we would need to exit in the aws class and avoid finally statements being hit. """
|
|
15
|
+
if argv is None:
|
|
16
|
+
argv = sys.argv[1:]
|
|
17
|
+
|
|
18
|
+
setup_logging()
|
|
19
|
+
|
|
20
|
+
# Manually set the log level now, so we get accurate logging during startup
|
|
21
|
+
for i, arg in enumerate(argv):
|
|
22
|
+
if arg == '--log-level':
|
|
23
|
+
set_log_level(argv[i+1])
|
|
24
|
+
if arg.startswith('--log-level='):
|
|
25
|
+
set_log_level(arg.split('=')[1])
|
|
26
|
+
|
|
27
|
+
logger.info(f"ssm cli called")
|
|
28
|
+
logger.debug(f"sys.argv {sys.argv}")
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
app(argv)
|
|
32
|
+
logger.info(f"Command completed successfully")
|
|
33
|
+
return 0
|
|
34
|
+
except AWSAuthError as e:
|
|
35
|
+
console.print(f"AWS Authentication error: {e}", style="red")
|
|
36
|
+
return 1
|
|
37
|
+
except AWSAccessDeniedError as e:
|
|
38
|
+
logger.error(f"access denied: {e}")
|
|
39
|
+
console.print(f"Access denied, see README for details on required permissions", style="bold red")
|
|
40
|
+
console.print(escape(str(e.__cause__)), style="grey50")
|
|
41
|
+
return 1
|
|
42
|
+
except Exception as e:
|
|
43
|
+
logger.error(f"Unhandled exception")
|
|
44
|
+
log_path = str(get_log_file())
|
|
45
|
+
console.print(f"Unhandled exception, check [link=file://{log_path}]{log_path}[/link] for more information", style="red")
|
|
46
|
+
console.print(f"Error: {e}", style="red bold")
|
|
47
|
+
logger.exception(e, stack_info=True, stacklevel=20)
|
|
48
|
+
return 1
|
|
49
|
+
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Keeping click (or rich_click) lower level logic in one place.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from rich_click import RichCommand, RichContext, RichGroup, RichHelpFormatter, Context, Parameter, option
|
|
6
|
+
|
|
7
|
+
def version_callback(ctx: Context, param: Parameter, value: bool) -> None:
|
|
8
|
+
"""
|
|
9
|
+
Custom version printing logic to go and get install path and the smp version
|
|
10
|
+
"""
|
|
11
|
+
if not value or ctx.resilient_parsing:
|
|
12
|
+
return
|
|
13
|
+
|
|
14
|
+
import sys
|
|
15
|
+
import subprocess
|
|
16
|
+
import importlib.metadata
|
|
17
|
+
import os
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
results = subprocess.run(["session-manager-plugin", "--version"], capture_output=True, text=True)
|
|
21
|
+
except FileNotFoundError:
|
|
22
|
+
print("session-manager-plugin not found", file=sys.stderr)
|
|
23
|
+
|
|
24
|
+
pkg_dir = os.path.join(os.path.dirname(__file__), "..")
|
|
25
|
+
pkg_dir = os.path.abspath(pkg_dir)
|
|
26
|
+
print(f"ssm-cli {importlib.metadata.version('ssm-cli')} from {pkg_dir}")
|
|
27
|
+
v = sys.version_info
|
|
28
|
+
print(f"python {v.major}.{v.minor}.{v.micro}")
|
|
29
|
+
print(f"session-manager-plugin {results.stdout.strip()}")
|
|
30
|
+
ctx.exit()
|
|
31
|
+
|
|
32
|
+
def version_option(*param_decls):
|
|
33
|
+
"""
|
|
34
|
+
Use like the click.version_option.
|
|
35
|
+
"""
|
|
36
|
+
return option(*param_decls, is_flag=True, expose_value=False, is_eager=True, help="Show the version and exit", callback=version_callback)
|
|
37
|
+
|
|
38
|
+
class PluginCommand(RichCommand):
|
|
39
|
+
def format_help(self, ctx: RichContext, formatter: RichHelpFormatter) -> None:
|
|
40
|
+
# Subcommand help doesn't contain the options of global, so pull them in now
|
|
41
|
+
if ctx.parent is not None:
|
|
42
|
+
ctx.command.panels += ctx.parent.command.panels
|
|
43
|
+
ctx.command.params += ctx.parent.command.params
|
|
44
|
+
|
|
45
|
+
super().format_help(ctx, formatter)
|
|
46
|
+
|
|
47
|
+
class PluginGroup(RichGroup):
|
|
48
|
+
def __init__(self, **kwargs) -> None:
|
|
49
|
+
self.command_class = PluginCommand
|
|
50
|
+
|
|
51
|
+
super().__init__(context_settings={
|
|
52
|
+
"allow_interspersed_args": True,
|
|
53
|
+
# When developing the allow_interspersed_args, I kept finding these other settings in examples.
|
|
54
|
+
# I haven't found them necessary, so leaving commented if they become useful:
|
|
55
|
+
# "allow_extra_args": True,
|
|
56
|
+
# "ignore_unknown_options": True
|
|
57
|
+
}, **kwargs)
|
|
58
|
+
|
|
59
|
+
def parse_args(self, ctx, args):
|
|
60
|
+
# Intercept parsing args in the case of help because of the allow_interspersed_args setting.
|
|
61
|
+
# All we need to do is find out if a command is in the args as well and then pass the context down to that command.
|
|
62
|
+
# This is not perfect as it doesnt handle this example well:
|
|
63
|
+
# `--help --profile shell`
|
|
64
|
+
# Work arounds could be done but cannot see this happening in the wild. This is a complex fix for little gain.
|
|
65
|
+
# Time spent so far: 2 hours
|
|
66
|
+
args_set = set(args)
|
|
67
|
+
help_args = args_set & set(self.get_help_option_names(ctx))
|
|
68
|
+
if help_args:
|
|
69
|
+
cmd_args = args_set & set(self.list_commands(ctx))
|
|
70
|
+
if cmd_args:
|
|
71
|
+
cmd = self.get_command(ctx, cmd_args.pop())
|
|
72
|
+
if cmd:
|
|
73
|
+
parent_ctx = ctx
|
|
74
|
+
args.remove(cmd.name)
|
|
75
|
+
ctx = cmd.make_context(cmd.name, args, parent=parent_ctx)
|
|
76
|
+
|
|
77
|
+
return super().parse_args(ctx, args)
|
|
@@ -1,16 +1,6 @@
|
|
|
1
1
|
from typing import Dict
|
|
2
2
|
from confclasses import confclass
|
|
3
3
|
|
|
4
|
-
# See cli_args for more info but this needs a rethink to be easier to use/add to
|
|
5
|
-
|
|
6
|
-
@confclass
|
|
7
|
-
class ProxyCommandConfig:
|
|
8
|
-
selector: str = "first"
|
|
9
|
-
|
|
10
|
-
@confclass
|
|
11
|
-
class ActionsConfig:
|
|
12
|
-
proxycommand: ProxyCommandConfig
|
|
13
|
-
|
|
14
4
|
@confclass
|
|
15
5
|
class LoggingConfig:
|
|
16
6
|
level: str = "info"
|
|
@@ -23,8 +13,9 @@ class LoggingConfig:
|
|
|
23
13
|
@confclass
|
|
24
14
|
class Config:
|
|
25
15
|
log: LoggingConfig
|
|
26
|
-
actions: ActionsConfig
|
|
27
16
|
group_tag_key: str = "group"
|
|
28
17
|
"""Tag key to use when filtering, this is usually set during ssm setup."""
|
|
29
|
-
|
|
18
|
+
aws_profile: str = "default"
|
|
19
|
+
"""AWS profile to use when connecting to AWS services, often overridden by --profile"""
|
|
20
|
+
|
|
30
21
|
CONFIG = Config()
|
|
@@ -4,6 +4,7 @@ from datetime import datetime, timedelta
|
|
|
4
4
|
from typing import Optional
|
|
5
5
|
from ssm_cli.xdg import get_log_file, get_all_log_files
|
|
6
6
|
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
7
8
|
|
|
8
9
|
def setup_logging(name:str = "cli"):
|
|
9
10
|
"""Set up basic logging configuration with date-based rotation."""
|
|
@@ -17,8 +18,9 @@ def setup_logging(name:str = "cli"):
|
|
|
17
18
|
start_log_cleanup()
|
|
18
19
|
|
|
19
20
|
|
|
20
|
-
def
|
|
21
|
+
def set_log_level(level: str, name: Optional[str] = None):
|
|
21
22
|
"""Configure logger level."""
|
|
23
|
+
logger.debug(f"setting logger {name} to {level}")
|
|
22
24
|
logging.getLogger(name).setLevel(level.upper())
|
|
23
25
|
|
|
24
26
|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from ssm_cli.ui import console, LiveTableWithNavigation
|
|
2
|
+
|
|
3
|
+
def select(instances: list):
|
|
4
|
+
with LiveTableWithNavigation(console=console, screen=True) as live:
|
|
5
|
+
table = live.table
|
|
6
|
+
table.add_column("Id")
|
|
7
|
+
table.add_column("Name")
|
|
8
|
+
table.add_column("Ping")
|
|
9
|
+
table.add_column("IP")
|
|
10
|
+
for instance in instances:
|
|
11
|
+
table.add_row(
|
|
12
|
+
instance.id,
|
|
13
|
+
instance.name,
|
|
14
|
+
instance.ping,
|
|
15
|
+
instance.ip,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
live.refresh()
|
|
19
|
+
while live.handle_input():
|
|
20
|
+
live.refresh()
|
|
21
|
+
|
|
22
|
+
return instances[live.table._selected_row]
|
|
File without changes
|
|
@@ -14,6 +14,7 @@ The manager side is the code that runs in a separate process and is responsible
|
|
|
14
14
|
|
|
15
15
|
"""
|
|
16
16
|
|
|
17
|
+
import io
|
|
17
18
|
import socket
|
|
18
19
|
from typing import Dict, Any, List
|
|
19
20
|
import time
|
|
@@ -22,10 +23,10 @@ import multiprocessing
|
|
|
22
23
|
import threading
|
|
23
24
|
from multiprocessing.connection import Connection
|
|
24
25
|
|
|
25
|
-
from ssm_cli.aws import aws_client
|
|
26
|
-
from ssm_cli.cli_args import ARGS
|
|
27
|
-
from ssm_cli.config import CONFIG
|
|
26
|
+
from ssm_cli.aws import aws_client, setup_aws, get_profile
|
|
28
27
|
from ssm_cli.instances import Instance, SessionManagerPluginError, SessionManagerPluginPortError
|
|
28
|
+
from ssm_cli.config import CONFIG
|
|
29
|
+
from confclasses import load, save
|
|
29
30
|
|
|
30
31
|
import logging
|
|
31
32
|
|
|
@@ -208,23 +209,25 @@ class PortForwardingManagerProcess(multiprocessing.Process):
|
|
|
208
209
|
def __init__(self, pipe: Connection, instance: Instance):
|
|
209
210
|
self.pipe = pipe
|
|
210
211
|
self.instance = instance
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
212
|
+
|
|
213
|
+
with io.StringIO() as f:
|
|
214
|
+
save(CONFIG, f)
|
|
215
|
+
|
|
216
|
+
self.config_yaml = f.getvalue()
|
|
214
217
|
super().__init__()
|
|
215
218
|
|
|
216
219
|
def run(self):
|
|
217
220
|
# Rebuild some global state that ssm_cli.cli normally deals with, the manager process is a clean slate,
|
|
218
221
|
# We can be selective about what we rebuild
|
|
219
222
|
# when config/args are refactored, this should be taken into account.
|
|
220
|
-
from ssm_cli.logging import setup_logging,
|
|
223
|
+
from ssm_cli.logging import setup_logging, set_log_level
|
|
221
224
|
setup_logging("manager")
|
|
222
|
-
|
|
223
|
-
for logger_name, level in
|
|
224
|
-
|
|
225
|
+
set_log_level(CONFIG.log.level)
|
|
226
|
+
for logger_name, level in CONFIG.log.loggers.items():
|
|
227
|
+
set_log_level(level, name=logger_name)
|
|
225
228
|
|
|
226
|
-
|
|
227
|
-
|
|
229
|
+
with io.StringIO(self.config_yaml) as f:
|
|
230
|
+
load(CONFIG, f)
|
|
228
231
|
self.sessions = []
|
|
229
232
|
self.pipe.send("ready")
|
|
230
233
|
while True:
|
|
@@ -2,13 +2,12 @@ import threading
|
|
|
2
2
|
import paramiko
|
|
3
3
|
import socket
|
|
4
4
|
import time
|
|
5
|
-
from typing import Dict
|
|
6
5
|
import select
|
|
7
6
|
|
|
8
|
-
from ssm_cli.
|
|
9
|
-
from ssm_cli.
|
|
10
|
-
from ssm_cli.
|
|
11
|
-
from ssm_cli.
|
|
7
|
+
from ssm_cli.ssh_proxy.socket import StdIoSocket
|
|
8
|
+
from ssm_cli.ssh_proxy.shell import ShellThread
|
|
9
|
+
from ssm_cli.ssh_proxy.channels import Channels
|
|
10
|
+
from ssm_cli.ssh_proxy.forwarding import PortForwarding, PortForwardingSession
|
|
12
11
|
from ssm_cli.xdg import get_ssh_hostkey
|
|
13
12
|
from ssm_cli.instances import Instance
|
|
14
13
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from rich import box
|
|
2
|
+
from rich.live import Live
|
|
3
|
+
from rich.table import Table as RichTable
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.style import StyleType
|
|
6
|
+
from readchar import readkey, key
|
|
7
|
+
|
|
8
|
+
console = Console()
|
|
9
|
+
|
|
10
|
+
class Table(RichTable):
|
|
11
|
+
def __init__(self, *args, **kwargs):
|
|
12
|
+
kwargs.setdefault('box', box.ROUNDED)
|
|
13
|
+
super().__init__(*args, **kwargs)
|
|
14
|
+
|
|
15
|
+
class TableWithNavigation(Table):
|
|
16
|
+
def __init__(self, *args, selected_row=0, **kwargs):
|
|
17
|
+
super().__init__(*args, **kwargs)
|
|
18
|
+
self._selected_row = selected_row
|
|
19
|
+
|
|
20
|
+
def up(self):
|
|
21
|
+
if self._selected_row > 0:
|
|
22
|
+
self._selected_row -= 1
|
|
23
|
+
|
|
24
|
+
def down(self):
|
|
25
|
+
if self._selected_row < len(self.rows) - 1:
|
|
26
|
+
self._selected_row += 1
|
|
27
|
+
|
|
28
|
+
def get_row_style(self, console: Console, index: int) -> StyleType:
|
|
29
|
+
style = super().get_row_style(console, index)
|
|
30
|
+
if index == self._selected_row:
|
|
31
|
+
style += console.get_style("on blue")
|
|
32
|
+
return style
|
|
33
|
+
|
|
34
|
+
class LiveTableWithNavigation(Live):
|
|
35
|
+
def __init__(self, *args, **kwargs):
|
|
36
|
+
self.table = TableWithNavigation()
|
|
37
|
+
kwargs.setdefault('auto_refresh', False)
|
|
38
|
+
super().__init__(*args, **kwargs)
|
|
39
|
+
|
|
40
|
+
def get_renderable(self):
|
|
41
|
+
return self.table
|
|
42
|
+
|
|
43
|
+
def handle_input(self) -> bool:
|
|
44
|
+
""" Return False to exit """
|
|
45
|
+
ch=readkey()
|
|
46
|
+
if ch == key.UP:
|
|
47
|
+
self.table.up()
|
|
48
|
+
elif ch == key.DOWN:
|
|
49
|
+
self.table.down()
|
|
50
|
+
elif ch == key.CTRL_C:
|
|
51
|
+
raise KeyboardInterrupt()
|
|
52
|
+
elif ch in key.ENTER:
|
|
53
|
+
return False
|
|
54
|
+
return True
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ssm-cli
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.1.0.dev0
|
|
4
4
|
Summary: CLI tool to help with SSM functionality, aimed at adminstrators
|
|
5
5
|
Author-email: Simon Fletcher <simon.fletcher@lexisnexisrisk.com>
|
|
6
6
|
License: MIT License
|
|
@@ -35,10 +35,10 @@ Description-Content-Type: text/markdown
|
|
|
35
35
|
License-File: LICENCE
|
|
36
36
|
Requires-Dist: boto3
|
|
37
37
|
Requires-Dist: paramiko
|
|
38
|
-
Requires-Dist: rich-argparse
|
|
39
38
|
Requires-Dist: rich
|
|
40
39
|
Requires-Dist: confclasses>=0.3.2
|
|
41
40
|
Requires-Dist: xdg_base_dirs
|
|
41
|
+
Requires-Dist: rich-click
|
|
42
42
|
Dynamic: license-file
|
|
43
43
|
|
|
44
44
|
# SSM CLI
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
LICENCE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
ssm_cli/__init__.py
|
|
5
|
+
ssm_cli/__main__.py
|
|
6
|
+
ssm_cli/app.py
|
|
7
|
+
ssm_cli/aws.py
|
|
8
|
+
ssm_cli/cli.py
|
|
9
|
+
ssm_cli/click.py
|
|
10
|
+
ssm_cli/config.py
|
|
11
|
+
ssm_cli/instances.py
|
|
12
|
+
ssm_cli/logging.py
|
|
13
|
+
ssm_cli/ui.py
|
|
14
|
+
ssm_cli/xdg.py
|
|
15
|
+
ssm_cli.egg-info/PKG-INFO
|
|
16
|
+
ssm_cli.egg-info/SOURCES.txt
|
|
17
|
+
ssm_cli.egg-info/dependency_links.txt
|
|
18
|
+
ssm_cli.egg-info/entry_points.txt
|
|
19
|
+
ssm_cli.egg-info/requires.txt
|
|
20
|
+
ssm_cli.egg-info/top_level.txt
|
|
21
|
+
ssm_cli/selectors/__init__.py
|
|
22
|
+
ssm_cli/selectors/first.py
|
|
23
|
+
ssm_cli/selectors/tui.py
|
|
24
|
+
ssm_cli/ssh_proxy/__init__.py
|
|
25
|
+
ssm_cli/ssh_proxy/channels.py
|
|
26
|
+
ssm_cli/ssh_proxy/forwarding.py
|
|
27
|
+
ssm_cli/ssh_proxy/server.py
|
|
28
|
+
ssm_cli/ssh_proxy/shell.py
|
|
29
|
+
ssm_cli/ssh_proxy/socket.py
|