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 +1 -0
- ssm_cli/__main__.py +5 -0
- ssm_cli/cli.py +105 -0
- ssm_cli/cli_args.py +84 -0
- ssm_cli/commands/__init__.py +14 -0
- ssm_cli/commands/base.py +15 -0
- ssm_cli/commands/list.py +22 -0
- ssm_cli/commands/proxycommand.py +67 -0
- ssm_cli/commands/setup.py +54 -0
- ssm_cli/commands/shell.py +32 -0
- ssm_cli/config.py +28 -0
- ssm_cli/instances.py +221 -0
- ssm_cli/selectors/__init__.py +7 -0
- ssm_cli/selectors/first.py +2 -0
- ssm_cli/selectors/tui.py +14 -0
- ssm_cli/ssh/__init__.py +0 -0
- ssm_cli/ssh/channels.py +37 -0
- ssm_cli/ssh/forward.py +34 -0
- ssm_cli/ssh/server.py +99 -0
- ssm_cli/ssh/shell.py +38 -0
- ssm_cli/ssh/transport.py +22 -0
- ssm_cli/xdg.py +29 -0
- ssm_cli-0.0.2.dist-info/METADATA +75 -0
- ssm_cli-0.0.2.dist-info/RECORD +28 -0
- ssm_cli-0.0.2.dist-info/WHEEL +5 -0
- ssm_cli-0.0.2.dist-info/entry_points.txt +2 -0
- ssm_cli-0.0.2.dist-info/licenses/LICENCE +21 -0
- ssm_cli-0.0.2.dist-info/top_level.txt +1 -0
ssm_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.0.2"
|
ssm_cli/__main__.py
ADDED
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
|
+
}
|
ssm_cli/commands/base.py
ADDED
|
@@ -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
|
ssm_cli/commands/list.py
ADDED
|
@@ -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))
|
ssm_cli/selectors/tui.py
ADDED
|
@@ -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"]
|
ssm_cli/ssh/__init__.py
ADDED
|
File without changes
|
ssm_cli/ssh/channels.py
ADDED
|
@@ -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()
|
ssm_cli/ssh/transport.py
ADDED
|
@@ -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,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
|