From a8abbbecb8862411fcbc5bdbf9eb284015302c5d Mon Sep 17 00:00:00 2001 From: Robey Pointer Date: Sun, 4 Dec 2005 01:04:14 -0800 Subject: [PATCH] [project @ robey@lag.net-20051204090414-7c8318ab735f6188] experimental util functions for parsing/getting openssh host config, and unit tests (turned out to be pretty easy) --- paramiko/util.py | 83 ++++++++++++++++++++++++++++++++++++++++++++++ test.py | 2 ++ tests/test_util.py | 80 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 165 insertions(+) create mode 100644 tests/test_util.py diff --git a/paramiko/util.py b/paramiko/util.py index a60d3b9..abab825 100644 --- a/paramiko/util.py +++ b/paramiko/util.py @@ -22,6 +22,7 @@ Useful functions used by the rest of paramiko. from __future__ import generators +import fnmatch import sys import struct import traceback @@ -218,6 +219,88 @@ def load_host_keys(filename): f.close() return keys +def parse_ssh_config(file_obj): + """ + Parse a config file of the format used by OpenSSH, and return an object + that can be used to make queries to L{lookup_ssh_host_config}. The + format is described in OpenSSH's C{ssh_config} man page. This method is + provided primarily as a convenience to posix users (since the OpenSSH + format is a de-facto standard on posix) but should work fine on Windows + too. + + The return value is currently a list of dictionaries, each containing + host-specific configuration, but this is considered an implementation + detail and may be subject to change in later versions. + + @param file_obj: a file-like object to read the config file from + @type file_obj: file + @return: opaque configuration object + @rtype: object + """ + ret = [] + config = { 'host': '*' } + ret.append(config) + + for line in file_obj: + line = line.rstrip('\n').lstrip() + if (line == '') or (line[0] == '#'): + continue + if '=' in line: + key, value = line.split('=', 1) + key = key.strip().lower() + else: + # find first whitespace, and split there + i = 0 + while (i < len(line)) and not line[i].isspace(): + i += 1 + if i == len(line): + raise Exception('Unparsable line: %r' % line) + key = line[:i].lower() + value = line[i:].lstrip() + + if key == 'host': + # do we have a pre-existing host config to append to? + matches = [c for c in ret if c['host'] == value] + if len(matches) > 0: + config = matches[0] + else: + config = { 'host': value } + ret.append(config) + else: + config[key] = value + + return ret + +def lookup_ssh_host_config(hostname, config): + """ + Return a dict of config options for a given hostname. The C{config} object + must come from L{parse_ssh_config}. + + The host-matching rules of OpenSSH's C{ssh_config} man page are used, which + means that all configuration options from matching host specifications are + merged, with more specific hostmasks taking precedence. In other words, if + C{"Port"} is set under C{"Host *"} and also C{"Host *.example.com"}, and + the lookup is for C{"ssh.example.com"}, then the port entry for + C{"Host *.example.com"} will win out. + + The keys in the returned dict are all normalized to lowercase (look for + C{"port"}, not C{"Port"}. No other processing is done to the keys or + values. + + @param hostname: the hostname to lookup + @type hostname: str + @param config: the config object to search + @type config: object + """ + matches = [x for x in config if fnmatch.fnmatch(hostname, x['host'])] + # sort in order of shortest match (usually '*') to longest + matches.sort(key=lambda x: len(x['host'])) + ret = {} + for m in matches: + ret.update(m) + del ret['host'] + return ret + def mod_inverse(x, m): # it's crazy how small python can make this function. u1, u2, u3 = 1, 0, m diff --git a/test.py b/test.py index e75e8ef..17b6294 100755 --- a/test.py +++ b/test.py @@ -30,6 +30,7 @@ sys.path.append('tests/') from test_message import MessageTest from test_file import BufferedFileTest +from test_util import UtilTest from test_pkey import KeyTest from test_kex import KexTest from test_packetizer import PacketizerTest @@ -87,6 +88,7 @@ if options.use_sftp: suite = unittest.TestSuite() suite.addTest(unittest.makeSuite(MessageTest)) suite.addTest(unittest.makeSuite(BufferedFileTest)) +suite.addTest(unittest.makeSuite(UtilTest)) if options.use_pkey: suite.addTest(unittest.makeSuite(KeyTest)) suite.addTest(unittest.makeSuite(KexTest)) diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 0000000..fa8c029 --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,80 @@ +#!/usr/bin/python + +# Copyright (C) 2003-2005 Robey Pointer +# +# This file is part of paramiko. +# +# Paramiko is free software; you can redistribute it and/or modify it under the +# terms of the GNU Lesser General Public License as published by the Free +# Software Foundation; either version 2.1 of the License, or (at your option) +# any later version. +# +# Paramiko is distrubuted in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more +# details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with Paramiko; if not, write to the Free Software Foundation, Inc., +# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA. + +""" +Some unit tests for utility functions. +""" + +import cStringIO +import unittest +from Crypto.Hash import SHA +import paramiko.util + + +test_config_file = """\ +Host * + User robey + IdentityFile =~/.ssh/id_rsa + +# comment +Host *.example.com + \tUser bjork +Port=3333 +Host * + \t \t Crazy something dumb +Host spoo.example.com +Crazy something else +""" + + +class UtilTest (unittest.TestCase): + + K = 14730343317708716439807310032871972459448364195094179797249681733965528989482751523943515690110179031004049109375612685505881911274101441415545039654102474376472240501616988799699744135291070488314748284283496055223852115360852283821334858541043710301057312858051901453919067023103730011648890038847384890504L + + def setUp(self): + pass + + def tearDown(self): + pass + + def test_1_parse_config(self): + global test_config_file + f = cStringIO.StringIO(test_config_file) + config = paramiko.util.parse_ssh_config(f) + self.assertEquals(config, [ {'identityfile': '~/.ssh/id_rsa', 'host': '*', 'user': 'robey', + 'crazy': 'something dumb '}, + {'host': '*.example.com', 'user': 'bjork', 'port': '3333'}, + {'host': 'spoo.example.com', 'crazy': 'something else'}]) + + def test_2_host_config(self): + global test_config_file + f = cStringIO.StringIO(test_config_file) + config = paramiko.util.parse_ssh_config(f) + c = paramiko.util.lookup_ssh_host_config('irc.danger.com', config) + self.assertEquals(c, {'identityfile': '~/.ssh/id_rsa', 'user': 'robey', 'crazy': 'something dumb '}) + c = paramiko.util.lookup_ssh_host_config('irc.example.com', config) + self.assertEquals(c, {'identityfile': '~/.ssh/id_rsa', 'user': 'bjork', 'crazy': 'something dumb ', 'port': '3333'}) + c = paramiko.util.lookup_ssh_host_config('spoo.example.com', config) + self.assertEquals(c, {'identityfile': '~/.ssh/id_rsa', 'user': 'bjork', 'crazy': 'something else', 'port': '3333'}) + + def test_3_generate_key_bytes(self): + x = paramiko.util.generate_key_bytes(SHA, 'ABCDEFGH', 'This is my secret passphrase.', 64) + hex = ''.join(['%02x' % ord(c) for c in x]) + self.assertEquals(hex, '9110e2f6793b69363e58173e9436b13a5a4b339005741d5c680e505f57d871347b4239f14fb5c46e857d5e100424873ba849ac699cea98d729e57b3e84378e8b')