ssm-cli 0.0.2__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.0.2"
ssm_cli/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+ from ssm_cli.cli import cli
3
+
4
+ if __name__ == "__main__":
5
+ sys.exit(cli())
ssm_cli/cli.py ADDED
@@ -0,0 +1,105 @@
1
+ import sys
2
+
3
+ import boto3
4
+ import botocore
5
+ from rich_argparse import ArgumentDefaultsRichHelpFormatter
6
+
7
+ from confclasses import load_config
8
+ from ssm_cli.config import config
9
+ from ssm_cli.xdg import get_log_file, get_conf_file
10
+ from ssm_cli.commands import COMMANDS
11
+ from ssm_cli.cli_args import CliArgumentParser
12
+
13
+ import logging
14
+ logging.basicConfig(
15
+ level=logging.WARNING,
16
+ filename=get_log_file(),
17
+ filemode='+wt',
18
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
19
+ datefmt='%Y-%m-%d %H:%M:%S'
20
+ )
21
+ logger = logging.getLogger(__name__)
22
+
23
+ def cli(argv: list = None) -> int:
24
+ if argv is None:
25
+ argv = sys.argv[1:]
26
+
27
+ # Getting everything ready for config has useful logs, this helps develop that area
28
+ for i, arg in enumerate(argv):
29
+ if arg == '--log-level':
30
+ logging.getLogger().setLevel(argv[i+1].upper())
31
+ if arg.startswith('--log-level='):
32
+ logging.getLogger().setLevel(arg.split('=')[1].upper())
33
+
34
+ logger.debug(f"CLI called with {argv}")
35
+
36
+ # Build the actual parser
37
+ parser = CliArgumentParser(
38
+ prog="ssm",
39
+ description="tool to manage AWS SSM",
40
+ formatter_class=ArgumentDefaultsRichHelpFormatter,
41
+ )
42
+ parser.add_global_argument("--version", action="version", version="0.1.0") #TODO: swap to pull dynamically
43
+ parser.add_global_argument("--profile", type=str, help="Which AWS profile to use")
44
+
45
+ for name, command in COMMANDS.items():
46
+ command_parser = parser.add_command_parser(name, command.HELP)
47
+ command.add_arguments(command_parser)
48
+
49
+ args = parser.parse_args(argv)
50
+
51
+ logger.debug(f"Arguments: {args}")
52
+
53
+ if not args.command:
54
+ parser.print_help()
55
+ return 1
56
+
57
+ # Setup is a special case, we cannot load config if we dont have any.
58
+ if args.command == "setup":
59
+ COMMANDS['setup'].run(args, None)
60
+ return 0
61
+
62
+ try:
63
+ with open(get_conf_file(), 'r') as file:
64
+ load_config(config, file)
65
+ args.update_config()
66
+ logger.debug(f"Config: {config}")
67
+ except EnvironmentError as e:
68
+ eprint(f"Invalid config: {e}")
69
+ return 1
70
+
71
+ logging.getLogger().setLevel(config.log.level.upper())
72
+
73
+ for logger_name, level in config.log.loggers.items():
74
+ logger.debug(f"setting logger {logger_name} to {level}")
75
+ logging.getLogger(logger_name).setLevel(level.upper())
76
+
77
+ try:
78
+ session = boto3.Session(profile_name=args.global_args.profile)
79
+ if session.region_name is None:
80
+ eprint(f"AWS config missing region for profile {session.profile_name}")
81
+ logger.error(f"AWS config missing region for profile {session.profile_name}")
82
+ return 2
83
+ except botocore.exceptions.ProfileNotFound as e:
84
+ eprint(f"AWS profile invalid")
85
+ logger.error(f"AWS profile invalid: {e}")
86
+ return 2
87
+
88
+ try:
89
+ if args.command not in COMMANDS:
90
+ eprint(f"failed to find action {args.action}")
91
+ return 3
92
+ COMMANDS[args.command].run(args, session)
93
+ except botocore.exceptions.ClientError as e:
94
+ if e.response['Error']['Code'] == 'ExpiredTokenException':
95
+ eprint(f"AWS credentials expired")
96
+ logger.error(f"AWS credentials expired")
97
+ return 2
98
+ except Exception as e:
99
+ eprint(e)
100
+ return 5
101
+
102
+ return 0
103
+
104
+ def eprint(*args, **kwargs):
105
+ print(file=sys.stderr, *args, **kwargs)
ssm_cli/cli_args.py ADDED
@@ -0,0 +1,84 @@
1
+ import argparse
2
+ import sys
3
+ from confclasses import fields, is_confclass
4
+ from ssm_cli.config import config
5
+
6
+ class CliArgumentParser(argparse.ArgumentParser):
7
+ def __init__(self, *args, help_as_global=True, **kwargs):
8
+ self.global_args_parser = argparse.ArgumentParser(add_help=False)
9
+ self.global_args_parser_group = self.global_args_parser.add_argument_group("Global Options")
10
+
11
+ self.help_as_global = help_as_global
12
+ if help_as_global:
13
+ kwargs['add_help'] = False
14
+ # we cannot use help action here because it will just return the global arguments
15
+ self.global_args_parser_group.add_argument('--help', '-h', action="store_true", help="show this help message and exit")
16
+
17
+ super().__init__(*args, **kwargs)
18
+ self._command_subparsers = self.add_subparsers(title="Commands", dest="command", metavar="<command>", parser_class=argparse.ArgumentParser)
19
+ self._command_subparsers_map = {}
20
+
21
+ self.add_config_args(config)
22
+
23
+ def parse_args(self, args=None):
24
+ # we have to manually do the parents logic here because arguments are added after init
25
+ self._add_container_actions(self.global_args_parser)
26
+ defaults = self.global_args_parser._defaults
27
+ self._defaults.update(defaults)
28
+
29
+ if args is None:
30
+ args = sys.argv[1:]
31
+ global_args, unknown = self.global_args_parser.parse_known_args(args, CliNamespace())
32
+
33
+ args = super().parse_args(unknown, CliNamespace(global_args=global_args))
34
+
35
+ if self.help_as_global and global_args.help:
36
+ if args.command and args.command in self._command_subparsers_map:
37
+ self._command_subparsers_map[args.command].print_help()
38
+ self.exit()
39
+ self.print_help()
40
+ self.exit()
41
+
42
+ # Clean up from parents and help
43
+ for arg in vars(global_args):
44
+ if hasattr(args, arg):
45
+ delattr(args, arg)
46
+ if hasattr(global_args, 'help'):
47
+ delattr(global_args, 'help')
48
+
49
+ return args
50
+
51
+ def add_global_argument(self, *args, **kwargs):
52
+ self.global_args_parser_group.add_argument(*args, **kwargs)
53
+
54
+ def add_command_parser(self, name, help):
55
+ parser = self._command_subparsers.add_parser(name, help=help, formatter_class=self.formatter_class, parents=[self.global_args_parser], add_help=not self.help_as_global)
56
+ self._command_subparsers_map[name] = parser
57
+ return parser
58
+
59
+ def add_config_args(self, config, prefix=""):
60
+ for field in fields(config):
61
+ if is_confclass(field.type):
62
+ self.add_config_args(field.type, f"{field.name}-")
63
+ else:
64
+ self.global_args_parser.add_argument(f"--{prefix}{field.name.replace('_','-')}", type=field.type, help=field.metadata.get('help', None))
65
+
66
+
67
+
68
+ class CliNamespace(argparse.Namespace):
69
+ def update_config(self):
70
+ self._do_update_config(config, vars(self.global_args))
71
+
72
+ def _do_update_config(self, config, data: dict):
73
+ for field in fields(config):
74
+ name = field.name
75
+ if is_confclass(field.type):
76
+ # If default value in the confclass
77
+ if not hasattr(config, name):
78
+ raise RuntimeError("Config not loaded before injecting arg overrides")
79
+
80
+ prefix = f"{name}_"
81
+ data = {k.replace(prefix, ""): v for k, v in data.items() if k.startswith(prefix)}
82
+ self._do_update_config(getattr(config, name), data)
83
+ elif name in data and data[name] is not None:
84
+ setattr(config, name, data[name])
@@ -0,0 +1,14 @@
1
+ from typing import Dict
2
+ from ssm_cli.commands.base import BaseCommand
3
+ from ssm_cli.commands.list import ListCommand
4
+ from ssm_cli.commands.shell import ShellCommand
5
+ from ssm_cli.commands.proxycommand import ProxyCommandCommand
6
+ from ssm_cli.commands.setup import SetupCommand
7
+
8
+
9
+ COMMANDS : Dict[str, BaseCommand] = {
10
+ 'list': ListCommand,
11
+ 'shell': ShellCommand,
12
+ 'proxycommand': ProxyCommandCommand,
13
+ 'setup': SetupCommand
14
+ }
@@ -0,0 +1,15 @@
1
+ from abc import ABC, abstractmethod
2
+ import argparse
3
+
4
+ import boto3
5
+
6
+ class BaseCommand(ABC):
7
+ HELP: str = None
8
+ CONFIG: type = None
9
+
10
+ @abstractmethod
11
+ def add_arguments(parser: argparse.ArgumentParser):
12
+ pass
13
+ @abstractmethod
14
+ def run(args: list, session: boto3.Session):
15
+ pass
@@ -0,0 +1,22 @@
1
+ from ssm_cli.instances import Instances
2
+ from ssm_cli.commands.base import BaseCommand
3
+
4
+ import logging
5
+ logger = logging.getLogger(__name__)
6
+
7
+ class ListCommand(BaseCommand):
8
+ HELP = """List all instances in a group, if no group provided, will list all available groups"""
9
+ def add_arguments(parser):
10
+ parser.add_argument("group", type=str, nargs="?", help="group to run against")
11
+
12
+ def run(args, session):
13
+ logger.info("running list action")
14
+
15
+ instances = Instances(session)
16
+
17
+ if args.group:
18
+ for instance in instances.list_instances(args.group):
19
+ print(instance)
20
+ else:
21
+ for group in instances.list_groups():
22
+ print(group)
@@ -0,0 +1,67 @@
1
+ import socket
2
+ import time
3
+
4
+ from ssm_cli.ssh.server import SshServer
5
+ from ssm_cli.instances import Instances
6
+ from ssm_cli.config import config
7
+ from ssm_cli.commands.base import BaseCommand
8
+
9
+ import logging
10
+ logger = logging.getLogger(__name__)
11
+
12
+ class ProxyCommandCommand(BaseCommand):
13
+ HELP="SSH ProxyCommand feature"
14
+ def add_arguments(parser):
15
+ parser.add_argument("group", type=str, help="group to run against")
16
+
17
+ def run(args, session):
18
+ logger.info("running proxycommand action")
19
+
20
+
21
+ instances = Instances(session)
22
+ instance = instances.select_instance(args.group, config.actions.proxycommand.selector)
23
+
24
+ if instance is None:
25
+ logger.error("failed to select host")
26
+ raise RuntimeError("failed to select host")
27
+
28
+ logger.info(f"connecting to {repr(instance)}")
29
+
30
+ server = SshServer(direct_tcpip_callback(instance, session))
31
+ server.start()
32
+
33
+ def direct_tcpip_callback(instance, session):
34
+ def callback(host, remote_port) -> socket.socket:
35
+ logger.debug(f"connect to {host}:{remote_port}")
36
+ internal_port = get_next_free_port(remote_port + 3000, 20)
37
+ logger.debug(f"got internal port {internal_port}")
38
+ try:
39
+ instance.start_port_forwarding_session_to_remote_host(session, host, remote_port, internal_port)
40
+ except Exception as e:
41
+ logger.error(f"failed to open port forward: {e}")
42
+ return None
43
+
44
+ logger.debug(f"connecting to session manager plugin on 127.0.0.1:{internal_port}")
45
+ # Even though we wait for the process to say its connected, we STILL need to wait for it
46
+ for attempt in range(10):
47
+ try:
48
+ sock = socket.create_connection(('127.0.0.1', internal_port))
49
+ logger.info(f"connected to 127.0.0.1:{internal_port}")
50
+ except Exception as e:
51
+ logger.warning(f"connection attempt {attempt} failed: {e}")
52
+ time.sleep(0.1)
53
+
54
+ return sock
55
+
56
+ return callback
57
+
58
+ def get_next_free_port(port: int, tries: int) -> int:
59
+ max_port = port + tries
60
+ while port < max_port:
61
+ logger.debug(f"attempting port {port}")
62
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
63
+ s.settimeout(1)
64
+ result = s.connect_ex(('127.0.0.1', port))
65
+ if result != 0:
66
+ return port
67
+ port += 1
@@ -0,0 +1,54 @@
1
+ import argparse
2
+ from ssm_cli.commands.base import BaseCommand
3
+ from ssm_cli.xdg import get_conf_root, get_conf_file, get_log_file, get_ssh_hostkey
4
+
5
+ import logging
6
+ logger = logging.getLogger(__name__)
7
+
8
+ class SetupCommand(BaseCommand):
9
+ HELP = "Setups up ssm-cli"
10
+
11
+ def add_arguments(parser):
12
+ parser.add_argument("--replace", action=argparse.BooleanOptionalAction, default=False, help="if we should replace existing")
13
+
14
+ def run(args, session):
15
+ logger.info("running setup action")
16
+
17
+ create_conf_dir()
18
+ create_conf_file(args.replace)
19
+
20
+ def create_conf_dir():
21
+ root = get_conf_root(False)
22
+ if root.exists():
23
+ if not root.is_dir():
24
+ raise FileExistsError(f"{root} already exists and is not a directory. Cleanup is likely needed.")
25
+ print(f"{root} - skipping (already exists)")
26
+ return
27
+
28
+ print(f"{root} - creating directory")
29
+ root.mkdir(511, True, True)
30
+
31
+ def create_conf_file(replace):
32
+ from confclasses import load_config
33
+ from confclasses_comments import save_config
34
+ from ssm_cli.config import Config
35
+
36
+ path = get_conf_file(False)
37
+ if path.exists() and not replace:
38
+ print(f"{path} - skipping (already exists)")
39
+ return
40
+
41
+ # stop accidental polution of the config object, cannot see
42
+ # this being a real issue but better to be safe
43
+ config = Config()
44
+ load_config(config, "")
45
+
46
+ config.group_tag_key = input(f"What tag to use to split up the instances [{config.group_tag_key}]: ") or config.group_tag_key
47
+
48
+ try:
49
+ with path.open("w+") as file:
50
+ save_config(config, file)
51
+ print(f"{path} - created")
52
+ except Exception as e:
53
+ logger.error(e)
54
+ path.unlink(True)
@@ -0,0 +1,32 @@
1
+ from ssm_cli.instances import Instances
2
+ from ssm_cli.config import config
3
+ from ssm_cli.commands.base import BaseCommand
4
+ from dataclasses import dataclass
5
+
6
+ import logging
7
+ logger = logging.getLogger(__name__)
8
+
9
+ @dataclass
10
+ class ShellConfig:
11
+ selector: str = "tui"
12
+
13
+ class ShellCommand(BaseCommand):
14
+ CONFIG = ShellConfig
15
+ HELP = "Connects to instances"
16
+
17
+ def add_arguments(parser):
18
+ parser.add_argument("group", type=str, help="group to run against")
19
+
20
+ def run(args, session):
21
+ logger.info("running shell action")
22
+
23
+ instances = Instances(session)
24
+ instance = instances.select_instance(args.group, "tui")
25
+
26
+ if instance is None:
27
+ logger.error("failed to select host")
28
+ raise RuntimeError("failed to select host")
29
+
30
+ logger.info(f"connecting to {repr(instance)}")
31
+
32
+ instance.start_session(session)
ssm_cli/config.py ADDED
@@ -0,0 +1,28 @@
1
+ from typing import Dict
2
+ from confclasses import confclass
3
+
4
+
5
+ @confclass
6
+ class ProxyCommandConfig:
7
+ selector: str = "first"
8
+
9
+ @confclass
10
+ class ActionsConfig:
11
+ proxycommand: ProxyCommandConfig
12
+
13
+ @confclass
14
+ class LoggingConfig:
15
+ level: str = "info"
16
+ loggers: Dict[str, str] = {
17
+ "botocore": "warn"
18
+ }
19
+ """key value dictionary to override log level on, some modules make a lot of noise, botocore for example"""
20
+
21
+ @confclass
22
+ class Config:
23
+ log: LoggingConfig
24
+ actions: ActionsConfig
25
+ group_tag_key: str = "group"
26
+ """Tag key to use when filtering, this is usually set during ssm setup."""
27
+
28
+ config = Config()
ssm_cli/instances.py ADDED
@@ -0,0 +1,221 @@
1
+ from dataclasses import dataclass, field
2
+ from functools import cache
3
+ import json
4
+ import re
5
+ import signal
6
+ import subprocess
7
+ import sys
8
+ from typing import Any, List
9
+
10
+ import boto3
11
+ from ssm_cli.selectors import SELECTORS
12
+ from ssm_cli.config import config
13
+
14
+ import logging
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ @dataclass
19
+ class Instance:
20
+ """ Save passing around giant objects, we only need handful of details for this tool """
21
+ id: str
22
+ name: str
23
+ ip: str
24
+ ping: str
25
+
26
+ def __str__(self):
27
+ return f"{self.id} {self.ip:<15} {self.ping:<7} {self.name}"
28
+
29
+ def start_session(self, session):
30
+ logger.debug(f"start session instance={self.id}")
31
+ client = session.client('ssm')
32
+
33
+ parameters = dict(
34
+ Target=self.id
35
+ )
36
+
37
+ logger.info("calling out to ssm:StartSession")
38
+ response = client.start_session(**parameters)
39
+ logger.info(f"starting session: {response['SessionId']}")
40
+ result = _session_manager_plugin([
41
+ json.dumps({
42
+ "SessionId": response["SessionId"],
43
+ "TokenValue": response["TokenValue"],
44
+ "StreamUrl": response["StreamUrl"]
45
+ }),
46
+ session.region_name,
47
+ "StartSession",
48
+ session.profile_name,
49
+ json.dumps(parameters),
50
+ f"https://ssm.{session.region_name}.amazonaws.com"
51
+ ])
52
+ if result != 0:
53
+ logger.error(f"Failed to connect to session: {result.stderr.decode()}")
54
+ raise RuntimeError(f"Failed to connect to session: {result.stderr.decode()}")
55
+
56
+ def start_port_forwarding_session_to_remote_host(self, session, host: str, remote_port: int, internal_port: int):
57
+ logger.debug(f"start port forwarding between localhost:{internal_port} and {host}:{remote_port} via {self.id}")
58
+ client = session.client('ssm')
59
+
60
+ parameters = dict(
61
+ Target=self.id,
62
+ DocumentName='AWS-StartPortForwardingSessionToRemoteHost',
63
+ Parameters={
64
+ 'host': [host],
65
+ 'portNumber': [str(remote_port)],
66
+ 'localPortNumber': [str(internal_port)]
67
+ }
68
+ )
69
+ logger.info("calling out to ssm:StartSession")
70
+ response = client.start_session(**parameters)
71
+
72
+ logger.info(f"starting session: {response['SessionId']}")
73
+ proc = subprocess.Popen(
74
+ [
75
+ "session-manager-plugin",
76
+ json.dumps({
77
+ "SessionId": response["SessionId"],
78
+ "TokenValue": response["TokenValue"],
79
+ "StreamUrl": response["StreamUrl"]
80
+ }),
81
+ session.region_name,
82
+ "StartSession",
83
+ session.profile_name,
84
+ json.dumps(parameters),
85
+ f"https://ssm.{session.region_name}.amazonaws.com"
86
+ ],
87
+ stdout=subprocess.PIPE
88
+ )
89
+
90
+ for line in proc.stdout.readline():
91
+ if line == b'Waiting for connections...':
92
+ return
93
+
94
+
95
+
96
+ def _session_manager_plugin( command: list) -> int:
97
+ """ Call out to subprocess and ignore interrupts """
98
+ if sys.platform == "win32":
99
+ signals_to_ignore = [signal.SIGINT]
100
+ else:
101
+ signals_to_ignore = [signal.SIGINT, signal.SIGQUIT, signal.SIGTSTP]
102
+
103
+ original_signal_handlers = {}
104
+ for sig in signals_to_ignore:
105
+ original_signal_handlers[sig] = signal.signal(sig, signal.SIG_IGN)
106
+ try:
107
+ return subprocess.check_call(["session-manager-plugin", *command])
108
+ finally:
109
+ for sig, handler in original_signal_handlers.items():
110
+ signal.signal(sig, handler)
111
+
112
+
113
+ class Instances:
114
+ session: boto3.Session
115
+
116
+ def __init__(self, session):
117
+ self.session = session
118
+
119
+ def select_instance(self, group_tag_value: str, selector: str) -> Instance:
120
+ instances = sorted(self.list_instances(group_tag_value), key=lambda x: ip_as_int(x.ip))
121
+ count = len(instances)
122
+ if count == 1:
123
+ return instances[0]
124
+ if count < 1:
125
+ return
126
+
127
+ if selector not in SELECTORS:
128
+ raise ValueError(f"invalid selector {selector}")
129
+
130
+ self.selector = SELECTORS[selector]
131
+ return self.selector(instances)
132
+
133
+ def list_groups(self) -> List[str]:
134
+ groups = set()
135
+ for resource in self._get_resources():
136
+ value = get_tag(resource['Tags'], config.group_tag_key)
137
+ if value:
138
+ groups.add(value)
139
+ return sorted(list(groups))
140
+
141
+ def list_instances(self, group_tag_value: str) -> List[Instance]:
142
+ instances = []
143
+ instances_info = self._describe_instance_information(group_tag_value)
144
+ resources = self._get_resources(group_tag_value)
145
+
146
+ for resource in resources:
147
+ id = arn_to_instance_id(resource['ResourceARN'])
148
+ name = get_tag(resource['Tags'], 'Name')
149
+ for instance in instances_info:
150
+ if instance['InstanceId'] == id:
151
+ instances.append(Instance(
152
+ id,
153
+ name,
154
+ instance['IPAddress'],
155
+ instance['PingStatus']
156
+ ))
157
+
158
+ return instances
159
+
160
+ def _get_resources(self, group_tag_value: str = None):
161
+ logger.info("calling out to resourcegroupstaggingapi:GetResources")
162
+
163
+ client = self.session.client('resourcegroupstaggingapi')
164
+ paginator = client.get_paginator('get_resources')
165
+ tag_filter = {
166
+ 'Key': config.group_tag_key
167
+ }
168
+ if group_tag_value is not None:
169
+ tag_filter['Values'] = [group_tag_value]
170
+
171
+ logger.debug(f"filtering on {tag_filter}")
172
+ page_iter = paginator.paginate(
173
+ ResourceTypeFilters=[
174
+ "ec2:instance"
175
+ ],
176
+ TagFilters=[tag_filter]
177
+ )
178
+ total = 0
179
+ for page in page_iter:
180
+ for resource in page['ResourceTagMappingList']:
181
+ total += 1
182
+ yield resource
183
+ logger.debug(f"yielded {total} resources")
184
+
185
+
186
+ def _describe_instance_information(self, group_tag_value: str):
187
+ logger.info("calling out to ssm:DescribeInstanceInformation")
188
+
189
+ client = self.session.client('ssm')
190
+ response = client.describe_instance_information(
191
+ Filters=[
192
+ {
193
+ 'Key': f'tag:{config.group_tag_key}',
194
+ 'Values': [group_tag_value]
195
+ }
196
+ ]
197
+ )
198
+ logger.debug(f"found {len(response['InstanceInformationList'])} instances")
199
+ return response['InstanceInformationList']
200
+
201
+
202
+
203
+ def get_tag(tags: list, key: str) -> str:
204
+ for tag in tags:
205
+ if tag['Key'] == key:
206
+ return tag['Value']
207
+ return None
208
+
209
+ @cache
210
+ def arn_to_instance_id(arn: str) -> str:
211
+ parts = arn.split('/')
212
+ if len(parts) != 2:
213
+ raise ValueError(f"invalid instance arn {arn}")
214
+ return parts[1]
215
+
216
+
217
+ def ip_as_int(ip: str) -> int:
218
+ m = re.match(r'(\d+)\.(\d+)\.(\d+)\.(\d+)', ip)
219
+ if not m:
220
+ raise ValueError(f"Invalid IP address: {ip}")
221
+ return (int(m.group(1)) << 24) + (int(m.group(2)) << 16) + (int(m.group(3)) << 8) + int(m.group(4))
@@ -0,0 +1,7 @@
1
+ import ssm_cli.selectors.tui as tui
2
+ import ssm_cli.selectors.first as first
3
+
4
+ SELECTORS = {
5
+ 'tui': tui.select,
6
+ 'first': first.select
7
+ }
@@ -0,0 +1,2 @@
1
+ def select(instances):
2
+ return instances[0]
@@ -0,0 +1,14 @@
1
+ import inquirer
2
+
3
+ def select(instances: list) -> dict:
4
+ questions = [
5
+ inquirer.List(
6
+ "host",
7
+ message="Which host?",
8
+ choices=instances,
9
+ ),
10
+ ]
11
+ answers = inquirer.prompt(questions)
12
+ if answers is None:
13
+ return None
14
+ return answers["host"]
File without changes
@@ -0,0 +1,37 @@
1
+ import threading
2
+ import paramiko
3
+
4
+ import logging
5
+ logger = logging.getLogger(__name__)
6
+
7
+ class Channels:
8
+ """
9
+ This class was needed because of the multitheading and not always getting the channel we accept in the right order
10
+ """
11
+ def __init__(self, transport: paramiko.Transport, timeout=10):
12
+ self.transport = transport
13
+ self.timeout = timeout
14
+ self._channels = {}
15
+ self._channels_lock = threading.Lock()
16
+
17
+ def get_channel(self, chanid: int):
18
+ logger.debug(f"getting channel {chanid}")
19
+ with self._channels_lock:
20
+ if chanid in self._channels:
21
+ chan = self._channels[chanid]
22
+ del self._channels[chanid]
23
+ logger.debug(f"got channel from buffer")
24
+ return chan
25
+
26
+ for attempt in range(3):
27
+ chan = self.transport.accept(self.timeout)
28
+ if chan is None: # Timeout
29
+ logger.error(f"no channel available")
30
+ return None
31
+
32
+ if chan.get_id() != chanid:
33
+ self._channels[chan.get_id()] = chan
34
+ logger.error(f"channel ID mismatch, attempt {attempt}, trying again")
35
+ continue
36
+ logger.debug("got channel from transport")
37
+ return chan
ssm_cli/ssh/forward.py ADDED
@@ -0,0 +1,34 @@
1
+ import select
2
+ import threading
3
+
4
+ import logging
5
+ logger = logging.getLogger(__name__)
6
+
7
+ class ForwardThread(threading.Thread):
8
+ def __init__(self, sock, chanid, channels, chunk_size=1024):
9
+ threading.Thread.__init__(self)
10
+
11
+ logger.debug(f"setting up forward thread chan={chanid}")
12
+ self.sock = sock
13
+ self.chanid = chanid
14
+ self.channels = channels
15
+ self.chunk_size = chunk_size
16
+
17
+ def run(self):
18
+ logger.info(f"starting forward thread chan={self.chanid}")
19
+
20
+ chan = self.channels.get_channel(self.chanid)
21
+ while True:
22
+ r, _, _ = select.select([chan, self.sock], [], [])
23
+ if self.sock in r:
24
+ data = self.sock.recv(self.chunk_size)
25
+ if len(data) == 0:
26
+ break
27
+ chan.send(data)
28
+
29
+ if chan in r:
30
+ data = chan.recv(self.chunk_size)
31
+ if len(data) == 0:
32
+ break
33
+ self.sock.send(data)
34
+
ssm_cli/ssh/server.py ADDED
@@ -0,0 +1,99 @@
1
+ import threading
2
+ import paramiko
3
+
4
+ from ssm_cli.ssh.transport import StdIoSocket
5
+ from ssm_cli.ssh.shell import ShellThread
6
+ from ssm_cli.ssh.forward import ForwardThread
7
+ from ssm_cli.ssh.channels import Channels
8
+ from ssm_cli.xdg import get_ssh_hostkey
9
+
10
+ import logging
11
+ logger = logging.getLogger(__name__)
12
+
13
+ class SshServer(paramiko.ServerInterface):
14
+ """
15
+ Creates ssh server using StdIoSocket
16
+ """
17
+ event: threading.Event
18
+ direct_tcpip_callback: callable
19
+
20
+ def __init__(self, direct_tcpip_callback: callable):
21
+ logger.debug("creating server")
22
+ self.event = threading.Event()
23
+ self.direct_tcpip_callback = direct_tcpip_callback
24
+
25
+ def start(self):
26
+ logger.info("starting server")
27
+
28
+ sock = StdIoSocket()
29
+ self.transport = paramiko.Transport(sock)
30
+ self.channels = Channels(self.transport)
31
+
32
+ key_path = get_ssh_hostkey()
33
+ if key_path.exists():
34
+ host_key = paramiko.RSAKey(filename=key_path)
35
+ logger.info("Loaded existing host key")
36
+ else:
37
+ host_key = paramiko.RSAKey.generate(1024)
38
+ host_key.write_private_key_file(key_path)
39
+ logger.info("Generated new host key and saved to file")
40
+
41
+ self.transport.add_server_key(host_key)
42
+ self.transport.start_server(server=self)
43
+
44
+ self.event.wait()
45
+
46
+ # Auth handlers, just allow anything. The only use of this code is ProxyCommand and auth is not needed
47
+ def get_allowed_auths(self, username):
48
+ logger.info(f"allowing all auths: username={username}")
49
+ return "password,publickey,none"
50
+ def check_auth_none(self, username):
51
+ logger.info(f"accepting auth none: username={username}")
52
+ return paramiko.AUTH_SUCCESSFUL
53
+ def check_auth_password(self, username, password):
54
+ logger.info(f"accepting auth password: username={username}")
55
+ return paramiko.AUTH_SUCCESSFUL
56
+ def check_auth_publickey(self, username, key):
57
+ logger.info(f"accepting auth public key: username={username}")
58
+ return paramiko.AUTH_SUCCESSFUL
59
+
60
+ # Allow sessions
61
+ def check_channel_request(self, kind, chanid):
62
+ logger.info(f"received channel request: kind={kind} chanid={chanid}")
63
+ if kind == 'session':
64
+ return paramiko.OPEN_SUCCEEDED
65
+ logger.error(f"we only accept session")
66
+ return paramiko.OPEN_FAILED_ADMINISTRATIVELY
67
+
68
+ # Just accept the PTY request
69
+ def check_channel_pty_request(self, channel, term, width, height, pixelwidth, pixelheight, modes):
70
+ return True
71
+ # Start a echo shell if requested
72
+ def check_channel_shell_request(self, channel):
73
+ logger.info(f"shell request: {channel.get_id()}")
74
+ t = ShellThread(channel, self.channels)
75
+ t.start()
76
+ return True
77
+
78
+ # The real meat and potatos here!
79
+ def check_channel_direct_tcpip_request(self, chanid, origin, destination):
80
+ logger.info(f"direct TCP/IP request: chan={chanid} origin={origin} destination={destination}")
81
+ host = destination[0]
82
+ remote_port = destination[1]
83
+
84
+ sock = self.direct_tcpip_callback(host, remote_port)
85
+
86
+ if not sock:
87
+ logger.error("failed to connect to session manager plugin")
88
+ return paramiko.OPEN_FAILED_CONNECT_FAILED
89
+
90
+ # Start thread to open the channel and forward data
91
+ t = ForwardThread(sock, chanid, self.channels)
92
+ t.start()
93
+
94
+ logger.debug("started forwarding thread")
95
+ return paramiko.OPEN_SUCCEEDED
96
+
97
+ def get_banner(self):
98
+ return ("SSM CLI - ProxyCommand SSH server\r\n", "en-US")
99
+
ssm_cli/ssh/shell.py ADDED
@@ -0,0 +1,38 @@
1
+ import threading
2
+ import paramiko
3
+ import select
4
+
5
+ from ssm_cli.ssh.channels import Channels
6
+
7
+ import logging
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class ShellThread(threading.Thread):
12
+ daemon = True
13
+
14
+ def __init__(self, chan: paramiko.Channel, channels: Channels):
15
+ threading.Thread.__init__(self)
16
+
17
+ logger.debug(f"setting up shell thread chan={chan.get_id()}")
18
+ self.chan = chan
19
+ self.channels = channels
20
+
21
+ def run(self):
22
+ logger.info(f"starting shell thread chan={self.chan.get_id()}")
23
+
24
+ # Even though a channel object is passed in here, we STILL have to do this bit to avoid
25
+ # it being the first channel accepted when doing forwarding.
26
+ chan = self.channels.get_channel(self.chan.get_id())
27
+
28
+ self.chan.send(f"\r\nShell Requested for fake SSH Ctl+C or EOF (Ctl+D) to quit\r\n")
29
+
30
+ while True:
31
+ r, _, _ = select.select([self.chan], [], [])
32
+ if self.chan in r:
33
+ data = self.chan.recv(1024)
34
+ if len(data) == 0 or data in [b'\x03', b'\x04']: # Ctl+C or Ctl+D
35
+ break
36
+ self.chan.send(f"echo {data}\r\n")
37
+
38
+ self.chan.close()
@@ -0,0 +1,22 @@
1
+ import sys
2
+
3
+ import logging
4
+ logger = logging.getLogger(__name__)
5
+
6
+ class StdIoSocket:
7
+ _closed = True
8
+
9
+ def send(self, bytes):
10
+ n = sys.stdout.buffer.write(bytes)
11
+ sys.stdout.flush()
12
+ return n
13
+ def recv(self, length):
14
+ return sys.stdin.buffer.read(length)
15
+
16
+ # ignore close for now, we can probably handle it
17
+ def close(self):
18
+ logger.debug("Ignoring close")
19
+
20
+ # timeout should be used, for now we are ignoring it
21
+ def settimeout(self, timeout):
22
+ logger.debug("Ignoring settimeout")
ssm_cli/xdg.py ADDED
@@ -0,0 +1,29 @@
1
+ # All XDG path logic here
2
+ from pathlib import Path
3
+ from xdg_base_dirs import xdg_config_home
4
+
5
+
6
+ def get_conf_root(check=True) -> Path:
7
+ root = xdg_config_home() / 'ssm-cli'
8
+ if check and not root.exists():
9
+ from ssm_cli.commands.setup import create_conf_dir
10
+ create_conf_dir()
11
+ return root
12
+
13
+ def get_conf_file(check=True) -> Path:
14
+ path = get_conf_root(check) / 'ssm.yaml'
15
+ if check and not path.exists():
16
+ raise EnvironmentError(f"{path} missing, run `ssm setup` to create")
17
+ return path
18
+
19
+ def get_log_file(check=False) -> Path:
20
+ path = get_conf_root(True) / 'ssm.log'
21
+ if check and not path.exists():
22
+ raise EnvironmentError(f"{path} missing, run `ssm setup` to create")
23
+ return path
24
+
25
+ def get_ssh_hostkey(check=True) -> Path:
26
+ path = get_conf_root(check) / 'hostkey.pem'
27
+ if check and not path.exists():
28
+ raise EnvironmentError(f"{path} missing, run `ssm setup` to create")
29
+ return path
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: ssm-cli
3
+ Version: 0.0.2
4
+ Summary: CLI tool to help with SSM functionality, aimed at adminstrators
5
+ Author-email: Simon Fletcher <simon.fletcher@lexisnexisrisk.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2025 Simon Fletcher
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/simonfletcher-ln/ssm-cli
29
+ Project-URL: Issues, https://github.com/simonfletcher-ln/ssm-cli/issues
30
+ Classifier: Programming Language :: Python :: 3
31
+ Classifier: Operating System :: OS Independent
32
+ Classifier: License :: OSI Approved :: MIT License
33
+ Requires-Python: >=3.8
34
+ Description-Content-Type: text/markdown
35
+ License-File: LICENCE
36
+ Requires-Dist: boto3
37
+ Requires-Dist: inquirer
38
+ Requires-Dist: paramiko
39
+ Requires-Dist: rich-argparse
40
+ Requires-Dist: confclasses
41
+ Requires-Dist: xdg_base_dirs
42
+ Dynamic: license-file
43
+
44
+ # SSM CLI
45
+
46
+ A tool to make common tasks with SSM easier. The goal of this project is to help with the Session Manager, the tool tries to keep the access it requires to a minimum.
47
+
48
+ ## Installation
49
+
50
+ It can be installed with `pip install ssm-cli`, however most features rely on the session-manager-plugin being installed as well, this is the standard way to make SSM connections.
51
+
52
+ [AWS install guide](https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html)
53
+
54
+ ## AWS permission
55
+
56
+ The tool uses boto3, so any standard AWS_ environment variables can be used. Also the `--profile` option can be used.
57
+
58
+ You will need access to a few aws actions, below is a policy which should cover all features used by the tool.
59
+ ```json
60
+ {
61
+ "Version": "2012-10-17",
62
+ "Statement": [
63
+ {
64
+ "Sid": "FirstStatement",
65
+ "Effect": "Allow",
66
+ "Action": [
67
+ "resourcegroupstaggingapi:GetResources",
68
+ "ssm:DescribeInstanceInformation",
69
+ "ssm:StartSession"
70
+ ],
71
+ "Resource": "*"
72
+ }
73
+ ]
74
+ }
75
+ ```
@@ -0,0 +1,28 @@
1
+ ssm_cli/__init__.py,sha256=xy5icTVL_-T_uZleOq5SPoLjDLVLWIK0U8whN-ij6kI,21
2
+ ssm_cli/__main__.py,sha256=3sQX718hwpIMiuyDOHWXKoBxcvQ01816dkxQbHozApA,86
3
+ ssm_cli/cli.py,sha256=-0vMsfS8zZ6I6O-ihcnTYzepoVe2GYABtkA69kpcXvg,3459
4
+ ssm_cli/cli_args.py,sha256=1-BUhyO7yMHd4AaTm7-vmMBxe2OBcHMAM0-XqXw0QJI,3679
5
+ ssm_cli/config.py,sha256=4rX24YAzbvoDtjHnKMAr9TI6supkGfJlUZEZVnNtFX0,644
6
+ ssm_cli/instances.py,sha256=-H07hJocp5u54wvwrw3xAPiosPSM2-t1MvyFKLo2Di8,7304
7
+ ssm_cli/xdg.py,sha256=pbGAX6OYPQ510KZYpgVxylz9ofI8C452r9xUzE5iEA8,957
8
+ ssm_cli/commands/__init__.py,sha256=jk6ZXNuVZWq3rGw4JkCN-hzh_Xl6KcVjVw1CfHemjuE,434
9
+ ssm_cli/commands/base.py,sha256=_TncEahZMnA1xQITFvf5zA_zvUA0ondg-N9YOzKe9S4,311
10
+ ssm_cli/commands/list.py,sha256=izHi0qKfuVx_6LkJGxvNsct2f_2Iqbzmb3nYzyBSITI,722
11
+ ssm_cli/commands/proxycommand.py,sha256=15glDyaEfkROncHPPXrwtMMzyZ1X89v1GKRGbQXtZCE,2407
12
+ ssm_cli/commands/setup.py,sha256=Pet2mz-2ZvKj12Uz5GSO9LjD9nQLVkKWfqIIbtK9wew,1739
13
+ ssm_cli/commands/shell.py,sha256=ZvyK_CwAxZiT5dijpe85LTWEIfRkXrvHqlTgz97hkNc,887
14
+ ssm_cli/selectors/__init__.py,sha256=usmQjV4YN9so1CI3AdbDVK-sQawdpio16UbmaodBDaw,141
15
+ ssm_cli/selectors/first.py,sha256=EnwcztpeohIQAjsOhWipSdw9OaqETc_skHtCCTDFOAs,46
16
+ ssm_cli/selectors/tui.py,sha256=KneigVhEBYWCA6JWWwo6oe2Nd1wKfoyNDpQ3C59IGD0,310
17
+ ssm_cli/ssh/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
+ ssm_cli/ssh/channels.py,sha256=YagZobN9lnwyjs4o5eYu6JposRmt7yIYOEkeNcdP-cE,1324
19
+ ssm_cli/ssh/forward.py,sha256=Y71g8-xA4BLenqbBN0Hz_RMac1QVyTgZDrvdczQ6LiM,1010
20
+ ssm_cli/ssh/server.py,sha256=hwveSRRBldurwC77SfDjWVZmu4GI0G62Pg78xKE2Zbs,3652
21
+ ssm_cli/ssh/shell.py,sha256=3WtBNvRPbjsxo2lcpr2gI42W107jIrS4ZOTkYklrw0o,1213
22
+ ssm_cli/ssh/transport.py,sha256=IARq-4mIT3pY3ZUMiMs-Uu-0XkcZ2AcooXna3VvcSsw,547
23
+ ssm_cli-0.0.2.dist-info/licenses/LICENCE,sha256=7R7m4xx5vJHEfQIeHswksFvaYZvllKUIlRf23aDhTGo,1071
24
+ ssm_cli-0.0.2.dist-info/METADATA,sha256=ok6fSd--oKcyjLo9vnfjhjOn45sqi_nhYE8KjdZB1ok,3079
25
+ ssm_cli-0.0.2.dist-info/WHEEL,sha256=1tXe9gY0PYatrMPMDd6jXqjfpz_B-Wqm32CPfRC58XU,91
26
+ ssm_cli-0.0.2.dist-info/entry_points.txt,sha256=RM34aPLcnd2Tn4S_faCQ87ZnGSEbDtj0mvDGVaqharU,40
27
+ ssm_cli-0.0.2.dist-info/top_level.txt,sha256=XU6k4a4BcH64MrGYG6KqGI3T_qlNAKjz29gaBS7JFUs,8
28
+ ssm_cli-0.0.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (77.0.3)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ssm = ssm_cli.cli:cli
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Simon Fletcher
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ ssm_cli