From 5ff71cb9be3f9d53ec14a468df49d670ca8fc238 Mon Sep 17 00:00:00 2001 From: Raphael Roberts Date: Mon, 19 Aug 2019 22:59:57 -0500 Subject: [PATCH] Added all base components but need to start on tests --- .gitignore | 2 + MANIFEST.in | 1 + requirements.txt | 0 setup.py | 15 +++++ ssh_config_utils/__init__.py | 1 + ssh_config_utils/client_keywords.txt | 90 ++++++++++++++++++++++++++++ ssh_config_utils/config_file.py | 57 ++++++++++++++++++ ssh_config_utils/host.py | 34 +++++++++++ ssh_config_utils/parser.py | 40 +++++++++++++ ssh_config_utils/serializer.py | 31 ++++++++++ 10 files changed, 271 insertions(+) create mode 100644 .gitignore create mode 100644 MANIFEST.in create mode 100644 requirements.txt create mode 100644 setup.py create mode 100644 ssh_config_utils/__init__.py create mode 100644 ssh_config_utils/client_keywords.txt create mode 100644 ssh_config_utils/config_file.py create mode 100644 ssh_config_utils/host.py create mode 100644 ssh_config_utils/parser.py create mode 100644 ssh_config_utils/serializer.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..944cfd2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/tests +__pycache__ diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..aa38830 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include ssh_config_utils/client_keywords.txt \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..0d3aa17 --- /dev/null +++ b/setup.py @@ -0,0 +1,15 @@ +from setuptools import setup, find_packages + +with open("requirements.txt") as file: + INSTALL_REQUIRES = file.read().rstrip().split("\n") + +setup( + name="ssh-config-utils", + version="1.0", + description="Easy way to modify and sync ssh config files", + install_requires=INSTALL_REQUIRES, + author="Raphael Roberts", + author_email="raphael.roberts48@gmail.com", + packages=find_packages(), + entry_points={"console_scripts": ["ssh-config=ssh_config_utils:main"]}, +) diff --git a/ssh_config_utils/__init__.py b/ssh_config_utils/__init__.py new file mode 100644 index 0000000..1b64752 --- /dev/null +++ b/ssh_config_utils/__init__.py @@ -0,0 +1 @@ +import argparse diff --git a/ssh_config_utils/client_keywords.txt b/ssh_config_utils/client_keywords.txt new file mode 100644 index 0000000..9c37b1b --- /dev/null +++ b/ssh_config_utils/client_keywords.txt @@ -0,0 +1,90 @@ +AddKeysToAgent +AddressFamily +BatchMode +BindAddress +BindInterface +CASignatureAlgorithms +CanonicalDomains +CanonicalizeFallbackLocal +CanonicalizeHostname +CanonicalizeMaxDots +CanonicalizePermittedCNAMEs +CertificateFile +ChallengeResponseAuthentication +CheckHostIP +Ciphers +ClearAllForwardings +Compression +ConnectTimeout +ConnectionAttempts +ControlMaster +ControlPath +ControlPersist +DynamicForward +EnableSSHKeysign +EscapeChar +ExitOnForwardFailure +FingerprintHash +ForwardAgent +ForwardX11 +ForwardX11Timeout +ForwardX11Trusted +GSSAPIAuthentication +GSSAPIDelegateCredentials +GatewayPorts +GlobalKnownHostsFile +HashKnownHosts +Host +HostKeyAlgorithms +HostKeyAlias +HostbasedAuthentication +HostbasedKeyTypes +Hostname +IPQoS +IdentitiesOnly +IdentityAgent +IdentityFile +IgnoreUnknown +Include +KbdInteractiveAuthentication +KbdInteractiveDevices +KexAlgorithms +LocalCommand +LocalForward +LogLevel +MACs +Match +NoHostAuthenticationForLocalhost +NumberOfPasswordPrompts +PKCS11Provider +PasswordAuthentication +PermitLocalCommand +Port +PreferredAuthentications +ProxyCommand +ProxyJump +ProxyUseFdpass +PubkeyAcceptedKeyTypes +PubkeyAuthentication +RekeyLimit +RemoteCommand +RemoteForward +RequestTTY +RevokedHostKeys +SendEnv +ServerAliveCountMax +ServerAliveInterval +SetEnv +StreamLocalBindMask +StreamLocalBindUnlink +StrictHostKeyChecking +SyslogFacility +TCPKeepAlive +Tunnel +TunnelDevice +UpdateHostKeys +User +UserKnownHostsFile +VerifyHostKeyDNS +VisualHostKey +XAuthLocation diff --git a/ssh_config_utils/config_file.py b/ssh_config_utils/config_file.py new file mode 100644 index 0000000..aa23779 --- /dev/null +++ b/ssh_config_utils/config_file.py @@ -0,0 +1,57 @@ +from pathlib import Path + +from ssh_config_utils.host import GlobalHost, Host +from ssh_config_utils.parser import parse_config_text + + +class ConfigFile: + def __init__(self, global_host, hosts): + self.hosts = {} + global_host = global_host + for host in hosts: + self.hosts.setdefault(host.name, host) + + @classmethod + def read_file(cls, fp): + if isinstance(fp, (str, bytes, Path)): + with open(fp) as file: + text = file.read() + else: + text = fp.read() + + data = parse_config_text(text) + global_host = None + hosts = [] + + for host_data in data: + for key, values in host_data.items(): + if len(values) == 1: + host_data[key] = values[0] + if host_data["host"] == "*": + if global_host is None: + del host_data["host"] + global_host = GlobalHost(host_data) + else: + name = host_data.pop("host") + hosts.append(Host(name, host_data)) + return cls(global_host, hosts) + + def __str__(self): + return self.format(indent=" " * 2, host_seperator="\n" * 2) + + def format(self, **format_data): + hosts = sorted(self.hosts.values(), key=lambda host: host.name) + return format_data["host_seperator"].join( + map(lambda host: host.format(**format_data), hosts) + ) + + def write_file(self, fp, **format_data): + default_data = {"indent": " " * 2, "host_seperator": "\n" * 2} + default_data.update(format_data) + text = self.format(**default_data) + + if isinstance(fp, (str, bytes, Path)): + with open(fp, "w") as file: + file.write(text) + else: + text = fp.write(text) diff --git a/ssh_config_utils/host.py b/ssh_config_utils/host.py new file mode 100644 index 0000000..defa39c --- /dev/null +++ b/ssh_config_utils/host.py @@ -0,0 +1,34 @@ +from ssh_config_utils.serializer import serialize_host, get_proper_name +from ssh_config_utils.parser import CamelCase_to_snake, KEYWORDS_LOWER_TRANSLATE + + +class Host: + def __init__(self, name, options): + self.options = options + self.name = name + + def __getattr__(self, name): + + if name in {"name", "options"}: + return super().__getattribute__(name) + return self.options[name] + + def __setattr__(self, name, value): + + if name in {"options", "name"}: + super().__setattr__(name, value) + else: + + proper_name = get_proper_name(name) + self.options[CamelCase_to_snake(proper_name)] = value + + def __str__(self): + return serialize_host(self.name, self.options, dict(indent=" " * 2)) + + def format(self, **format_data): + return serialize_host(self.name, self.options, format_data) + + +class GlobalHost(Host): + def __init__(self, options): + super().__init__("*", options) diff --git a/ssh_config_utils/parser.py b/ssh_config_utils/parser.py new file mode 100644 index 0000000..d9df5a2 --- /dev/null +++ b/ssh_config_utils/parser.py @@ -0,0 +1,40 @@ +import os +import re +import shlex + +with open( + os.path.join(os.path.dirname(__file__), "client_keywords.txt") +) as keywords_file: + KEYWORDS = list(filter(bool, keywords_file.read().split("\n"))) +KEYWORDS_LOWER = list(map(str.lower, KEYWORDS)) +KEYWORDS_LOWER_TRANSLATE = dict(zip(KEYWORDS_LOWER, KEYWORDS)) + + +HOST_SPLITTER = re.compile( + r"host(?:.(?!\bhost\b))+", flags=re.MULTILINE | re.DOTALL | re.IGNORECASE +) +KEY_VALUE = re.compile(r"(?P\S+)(?:[ \t]+|[ \t]*=[ \t]*)(?P.*)") + + +def CamelCase_to_snake(text): # noqa + return "_".join(map(str.lower, re.findall(r"[A-Z][a-z]*", text))) + + +def parse_config_text(text): + text_no_leading_whitespace = re.sub(r"^[ \t]+", "", text, flags=re.MULTILINE) + text_no_comments = re.sub( + r"^\#.*", "", text_no_leading_whitespace, flags=re.MULTILINE + ) + hosts = HOST_SPLITTER.finditer(text_no_comments) + for host in hosts: + host_dict = {} + for line in host.group(0).split("\n"): + match = KEY_VALUE.match(line) + if match: + key = match.group("key").lower() + if key in KEYWORDS_LOWER_TRANSLATE.keys(): + snake_case_key = CamelCase_to_snake(KEYWORDS_LOWER_TRANSLATE[key]) + if snake_case_key not in host_dict.keys(): + value = shlex.split(match.group("values")) + host_dict[snake_case_key] = value + yield host_dict diff --git a/ssh_config_utils/serializer.py b/ssh_config_utils/serializer.py new file mode 100644 index 0000000..56f214b --- /dev/null +++ b/ssh_config_utils/serializer.py @@ -0,0 +1,31 @@ +from ssh_config_utils.parser import KEYWORDS_LOWER_TRANSLATE + +WHITESPACE = (" ", "\t") + + +def get_proper_name(name): + return KEYWORDS_LOWER_TRANSLATE[name.lower().replace("_", "")] + + +def quote(term): + + term = str(term) + if any(thing in term for thing in WHITESPACE): + term = '"{}"'.format(term) + return term + + +def serialize_host(name, data, format_data): + lines = ["Host {}".format(name)] + for key, value in sorted(data.items(), key=lambda item: item[0]): + + if isinstance(value, list): + for index in range(len(value)): + value[index] = quote(value[index]) + value = " ".join(value) + else: + value = quote(value) + lines.append( + "{}{} {}".format(format_data["indent"], get_proper_name(key), value) + ) + return "\n".join(lines)