From d5ac68f5d4038a15544eb108b60d7a3775009014 Mon Sep 17 00:00:00 2001 From: Soren Atmakuri Davidsen <soren@atmakuridavidsen.com> Date: Sat, 21 Jan 2017 08:29:22 +0100 Subject: [PATCH] initial import. --- .gitignore | 6 ++ .python-version | 1 + .travis.yml | 7 ++ LICENSE | 21 ++++++ README.md | 50 +++++++++++++ setup.py | 20 ++++++ sshconfig.py | 156 ++++++++++++++++++++++++++++++++++++++++ tests/test_config | 13 ++++ tests/test_sshconfig.py | 103 ++++++++++++++++++++++++++ 9 files changed, 377 insertions(+) create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 .travis.yml create mode 100644 LICENSE create mode 100644 README.md create mode 100755 setup.py create mode 100644 sshconfig.py create mode 100644 tests/test_config create mode 100644 tests/test_sshconfig.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e9676cc --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.pyc +__pycache__ +venv +.cache +*.egg-info +.eggs diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..7fc0361 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +sshconfig diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..a1fa5cf --- /dev/null +++ b/.travis.yml @@ -0,0 +1,7 @@ +language: python +python: + - "2.7" +install: + - python setup.py install +script: + - "py.test ." diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..093f172 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2017 Søren A. Davidsen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4c6c7c7 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ + +sshconfig +=========== + +Sshconfig is a library for reading and modifying your ssh/config file in a non-intrusive way, meaning +your file should look more or less the same after modifications. Idea is to keep it simple, +so you can modify it for your needs. + +Read more about ssh config files here: [Create SSH config file on Linux](https://www.cyberciti.biz/faq/create-ssh-config-file-on-linux-unix/) + + +Installation and usage +--------------------------- + +Install through pip is the most easy way. You can install from the Git source directly: + + pip install -e https://github.com/sorend/sshconfig.git + +Below is some example use: + + from __future__ import print_function + from sshconfig import read_ssh_config, empty_ssh_config + from os.path import expanduser + + c = read_ssh_config(expanduser("~/.ssh/config")) + print("hosts", c.hosts()) + + # assuming you have a host "svu" + print("svu host", c.host("svu")) # print the settings + c.update("svu", Hostname="ssh.svu.local", Port=1234) + print("svu host now", c.host("svu")) + + c.add("newsvu", Hostname="ssh-new.svu.local", Port=22, User="stud1234") + print("newsvu", c.host("newsvu")) + + c.rename("newsvu", "svu-new") + print("svu-new", c.host("svu-new")) + + c.write(expanduser("~/.ssh/newconfig")) # write to new file + + # creating a new config file. + c2 = empty_ssh_config() + c2.add("svu", Hostname="ssh.svu.local", User="teachmca", Port=22) + c2.write("newconfig") + + +About +----- + +sshconfig is created at the Department of Computer Science at Sri Venkateswara University, Tirupati, INDIA by a student as part of his projects. diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..d4e8779 --- /dev/null +++ b/setup.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from setuptools import setup + +MY_VERSION = '0.0.1' + +setup( + name='sshconfig', + version=MY_VERSION, + description='Lightweight SSH config library', + author='Søren Atmakuri Davidsen', + author_email='sorend@cs.svuni.in', + url='https://github.com/sorend/sshconfig', + download_url='https://github.com/sorend/sshconfig/tarball/%s' % (MY_VERSION,), + license='MIT', + keywords=['ssh', 'config'], + install_requires=[ + ], + setup_requires=['pytest-runner'], + tests_require=['pytest'], +) diff --git a/sshconfig.py b/sshconfig.py new file mode 100644 index 0000000..6c161bf --- /dev/null +++ b/sshconfig.py @@ -0,0 +1,156 @@ + +import re + +def read_ssh_config(path): + """ + Read ssh config file and return parsed SshConfig + """ + with open(path, "r") as f: + lines = f.read().splitlines() + return SshConfig(lines) + +def empty_ssh_config(): + """ + Creates a new empty ssh configuration. + """ + return SshConfig([]) + +def _key_value(line): + no_comment = line.split("#")[0] + return [ x.strip() for x in re.split(r"\s+", no_comment.strip(), 1) ] + +class SshConfig(object): + """ + Class for manipulating SSH configuration. + """ + def __init__(self, lines): + self.lines_ = [] + self.hosts_ = set() + self.parse(lines) + + def parse(self, lines): + cur_entry = None + for line in lines: + kv = _key_value(line) + if len(kv) > 1: + k, v = kv + if k == "Host": + cur_entry = v + self.hosts_.add(v) + self.lines_.append(dict(line=line, host=cur_entry, key=k, value=v)) + else: + self.lines_.append(dict(line=line, host=None)) + + def hosts(self): + """ + Return the hosts found in the configuration. + + Returns + ------- + Tuple of Host entries (including "*" if found) + """ + return tuple(self.hosts_) + + def host(self, host): + """ + Return the configuration of a specific host as a dictionary. + + Parameters + ---------- + host : the host to return values for. + + Returns + ------- + dict of key value pairs, excluding "Host" + """ + if host in self.hosts_: + return { k: v for k, v in [ (x["key"], x["value"]) for x in self.lines_ + if x["host"] == host and x["key"] != "Host" ]} + else: + return {} + + def update(self, host, **kwargs): + """ + Update configuration for an existing host. Note, you can update the "Host" value, but + it will still be referred to by the old "Host" value. + + Parameters + ---------- + host : the Host to modify. + **kwargs : The new configuration parameters + """ + if host not in self.hosts_: + raise ValueError("Host %s: not found." % host) + + if "Host" in kwargs: + raise ValueError("Cannot modify Host value with update, use rename.") + + def update_line(k, v): + return "\t%s\t%s" % (k, v) + + for key, value in kwargs.items(): + found = False + for line in self.lines_: + if line["host"] == host and line["key"] == key: + line["value"] = value + line["line"] = update_line(key, value) + found = True + + if not found: + max_idx = max([ idx for idx, line in enumerate(self.lines_) if line["host"] == host ]) + self.lines_.insert(max_idx + 1, dict(line=update_line(key, value), + host=host, key=key, value=value)) + + def rename(self, old_host, new_host): + """ + Renames a host configuration. + + Parameters + ---------- + old_host : the host to rename. + new_host : the new host value + """ + if new_host in self.hosts_: + raise ValueError("Host %s: already exists." % new_host) + for line in self.lines_: # update lines + if line["host"] == old_host: + line["host"] = new_host + if line["key"] == "Host": + line["value"] = new_host + line["line"] = "Host\t%s" % new_host + self.hosts_.remove(old_host) # update host cache + self.hosts_.add(new_host) + + def add(self, host, **kwargs): + """ + Add another host to the SSH configuration. + + Parameters + ---------- + host: The Host entry to add. + **kwargs: The parameters for the host (without "Host" parameter itself) + """ + if host in self.hosts_: + raise ValueError("Host %s: exists (use update)." % host) + self.hosts_.add(host) + self.lines_.append(dict(line="", host=None)) + self.lines_.append(dict(line="Host\t%s" % host, host=host, key="Host", value=host)) + for k, v in kwargs.items(): + self.lines_.append(dict(line="\t%s\t%s" % (k, str(v)), host=host, key=k, value=v)) + + def config(self): + """ + Return the configuration as a string. + """ + return "\n".join([ x["line"] for x in self.lines_ ]) + + def write(self, path): + """ + Writes ssh config file + + Parameters + ---------- + path : The file to write to + """ + with open(path, "w") as f: + f.write(self.config()) diff --git a/tests/test_config b/tests/test_config new file mode 100644 index 0000000..0b91d14 --- /dev/null +++ b/tests/test_config @@ -0,0 +1,13 @@ + +# comment +Host * + User something + +# comment 2 +Host svu + Hostname www.svuniversity.ac.in + Port 22 + ProxyCommand nc -w 300 -x localhost:9050 %h %p + +# another comment +# bla bla diff --git a/tests/test_sshconfig.py b/tests/test_sshconfig.py new file mode 100644 index 0000000..15f4aa8 --- /dev/null +++ b/tests/test_sshconfig.py @@ -0,0 +1,103 @@ +from __future__ import print_function +import sshconfig +import pytest +import os + +test_config = os.path.join(os.path.dirname(__file__), "test_config") + +def test_parsing(): + c = sshconfig.read_ssh_config(test_config) + assert len(c.hosts()) == 2 + assert c.host("*")["User"] == "something" + assert c.host("svu")["ProxyCommand"] == "nc -w 300 -x localhost:9050 %h %p" + + s1 = c.config().splitlines() + s2 = open(test_config).readlines() + assert len(s1) == len(s2) + +def test_update(): + c = sshconfig.read_ssh_config(test_config) + + c.update("svu", Compression="no", Port=2222) + assert "\tCompression\tno" in c.config() + assert "\tPort\t2222" in c.config() + +def test_update_host_failed(): + c = sshconfig.read_ssh_config(test_config) + + with pytest.raises(ValueError): + c.update("svu", Host="svu-new") + +def test_rename(): + + c = sshconfig.read_ssh_config(test_config) + + assert c.host("svu")["Hostname"] == "www.svuniversity.ac.in" + + c.rename("svu", "svu-new") + + assert "Host\tsvu-new" in c.config() + assert "Host\tsvu\n" not in c.config() + assert "svu" not in c.hosts() + assert "svu-new" in c.hosts() + + c.update("svu-new", Port=123) # has to be success + assert c.host("svu-new")["Port"] == 123 + assert c.host("svu-new")["Hostname"] == "www.svuniversity.ac.in" # still same + + with pytest.raises(ValueError): # we can't refer to the renamed host + c.update("svu", Port=123) + +def test_update_fail(): + c = sshconfig.read_ssh_config(test_config) + + with pytest.raises(ValueError): + c.update("notfound", Port=1234) + +def test_add(): + + c = sshconfig.read_ssh_config(test_config) + + c.add("venkateswara", Hostname="venkateswara.onion", User="other", Port=22, + ProxyCommand="nc -w 300 -x localhost:9050 %h %p") + + assert "venkateswara" in c.hosts() + assert c.host("venkateswara")["ProxyCommand"] == "nc -w 300 -x localhost:9050 %h %p" + + assert "Host\tvenkateswara" in c.config() + + with pytest.raises(ValueError): + c.add("svu") + + with pytest.raises(ValueError): + c.add("venkateswara") + +def test_save(): + import tempfile + + tc = os.path.join(tempfile.gettempdir(), "temp_ssh_config-4123") + try: + c = sshconfig.read_ssh_config(test_config) + + c.update("svu", Hostname="ssh.svuniversity.ac.in", User="mca") + c.write(tc) + + c2 = sshconfig.read_ssh_config(tc) + assert c2.host("svu")["Hostname"] == "ssh.svuniversity.ac.in" + assert c2.host("svu")["User"] == "mca" + + finally: + os.remove(tc) + +def test_empty(): + import tempfile + tc = os.path.join(tempfile.gettempdir(), "temp_ssh_config-123") + try: + c = sshconfig.empty_ssh_config() + c.add("svu33", Hostname="ssh33.svu.local", User="mca", Port=22) + c.write(tc) + c2 = sshconfig.read_ssh_config(tc) + assert 1 == len(c2.hosts()) + assert c2.host("svu33")["Hostname"] == "ssh33.svu.local" + finally: + os.remove(tc)