summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--.travis.yml17
-rw-r--r--MANIFEST.in1
-rw-r--r--dev-requirements.txt4
-rw-r--r--paramiko/__init__.py15
-rw-r--r--paramiko/_version.py2
-rw-r--r--paramiko/config.py541
-rw-r--r--paramiko/dsskey.py15
-rw-r--r--paramiko/ecdsakey.py33
-rw-r--r--paramiko/ed25519key.py28
-rw-r--r--paramiko/message.py2
-rw-r--r--paramiko/pkey.py214
-rw-r--r--paramiko/proxy.py26
-rw-r--r--paramiko/rsakey.py29
-rw-r--r--paramiko/ssh_exception.py24
-rw-r--r--paramiko/ssh_gss.py2
-rw-r--r--paramiko/util.py3
-rw-r--r--setup.py29
-rw-r--r--sites/docs/api/config.rst125
-rw-r--r--sites/docs/conf.py12
-rw-r--r--sites/www/changelog.rst79
-rw-r--r--sites/www/installing.rst27
-rw-r--r--tests/configs/basic4
-rw-r--r--tests/configs/canon8
-rw-r--r--tests/configs/canon-always5
-rw-r--r--tests/configs/canon-ipv46
-rw-r--r--tests/configs/canon-local6
-rw-r--r--tests/configs/canon-local-always6
-rw-r--r--tests/configs/deep-canon11
-rw-r--r--tests/configs/deep-canon-maxdots12
-rw-r--r--tests/configs/empty-canon6
-rw-r--r--tests/configs/fallback-no6
-rw-r--r--tests/configs/fallback-yes6
-rw-r--r--tests/configs/hostname-exec-tokenized2
-rw-r--r--tests/configs/hostname-tokenized1
-rw-r--r--tests/configs/invalid1
-rw-r--r--tests/configs/match-all2
-rw-r--r--tests/configs/match-all-after-canonical5
-rw-r--r--tests/configs/match-all-and-more2
-rw-r--r--tests/configs/match-all-and-more-before2
-rw-r--r--tests/configs/match-all-before-canonical5
-rw-r--r--tests/configs/match-canonical-no7
-rw-r--r--tests/configs/match-canonical-yes5
-rw-r--r--tests/configs/match-complex17
-rw-r--r--tests/configs/match-exec16
-rw-r--r--tests/configs/match-exec-canonical10
-rw-r--r--tests/configs/match-exec-negation5
-rw-r--r--tests/configs/match-exec-no-arg2
-rw-r--r--tests/configs/match-host2
-rw-r--r--tests/configs/match-host-canonicalized8
-rw-r--r--tests/configs/match-host-from-match5
-rw-r--r--tests/configs/match-host-glob2
-rw-r--r--tests/configs/match-host-glob-list8
-rw-r--r--tests/configs/match-host-name4
-rw-r--r--tests/configs/match-host-negated2
-rw-r--r--tests/configs/match-host-no-arg2
-rw-r--r--tests/configs/match-localuser14
-rw-r--r--tests/configs/match-localuser-no-arg2
-rw-r--r--tests/configs/match-orighost16
-rw-r--r--tests/configs/match-orighost-canonical5
-rw-r--r--tests/configs/match-orighost-no-arg2
-rw-r--r--tests/configs/match-user14
-rw-r--r--tests/configs/match-user-explicit4
-rw-r--r--tests/configs/match-user-no-arg2
-rw-r--r--tests/configs/multi-canon-domains5
-rw-r--r--tests/configs/no-canon5
-rw-r--r--tests/configs/robey17
-rw-r--r--tests/configs/zero-maxdots9
-rw-r--r--tests/test_client.py10
-rw-r--r--tests/test_config.py996
-rw-r--r--tests/test_dss_openssh.key22
-rw-r--r--tests/test_ecdsa_384_openssh.key11
-rw-r--r--tests/test_gssapi.py4
-rw-r--r--tests/test_pkey.py63
-rw-r--r--tests/test_proxy.py144
-rw-r--r--tests/test_rsa_openssh.key28
-rw-r--r--tests/test_rsa_openssh_nopad.key27
-rw-r--r--tests/test_util.py514
-rw-r--r--tests/util.py9
79 files changed, 2631 insertions, 712 deletions
diff --git a/.gitignore b/.gitignore
index 4345d86c..89a9eee0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,4 @@ demos/*.log
_build
.coverage
.cache
+.idea
diff --git a/.travis.yml b/.travis.yml
index 84b73bd6..58a3ac42 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -10,12 +10,10 @@ python:
- "3.5"
- "3.6"
- "3.7"
- - "3.8-dev"
+ - "3.8"
- "pypy"
- "pypy3"
matrix:
- allow_failures:
- - python: "3.8-dev"
# Explicitly test against our oldest supported cryptography.io, in addition
# to whatever the latest default is.
include:
@@ -36,10 +34,13 @@ install:
if [[ -n "$OLDEST_CRYPTO" ]]; then
pip install "cryptography==${OLDEST_CRYPTO}"
fi
- # Self-install for setup.py-driven deps
- - pip install -e .
+ # Self-install for setup.py-driven deps (plus additional
+ # safe-enough-for-all-matrix-cells optional deps)
+ # TODO: additional matrices or test steps to test all the entrypoints
+ - pip install -e ".[ed25519,invoke]"
# Dev (doc/test running) requirements
- # TODO: use pipenv + whatever contexty-type stuff it has
+ # TODO: use poetry + whatever contexty-type stuff it has, should be more than
+ # just prod/dev split. Also apply to the above re: extras_require.
- pip install codecov # For codecov specifically
- pip install -r dev-requirements.txt
- |
@@ -71,6 +72,10 @@ script:
- inv coverage
# Ensure documentation builds, both sites, maxxed nitpicking
- inv sites
+ # Sanity check ability to build sdist
+ - python setup.py sdist
+ # And ability to run tests from within the result
+ - cd dist && tar xzvf *.tar.gz && rm *.tar.gz && cd paramiko-* && ls -l && pip install -e . && inv test
notifications:
irc:
channels: "irc.freenode.org#paramiko"
diff --git a/MANIFEST.in b/MANIFEST.in
index 62239689..c7de9097 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,4 +1,5 @@
include LICENSE setup_helper.py
recursive-include docs *
recursive-include tests *.py *.key *.pub
+recursive-include tests/configs *
recursive-include demos *.py *.key user_rsa_key user_rsa_key.pub
diff --git a/dev-requirements.txt b/dev-requirements.txt
index 22ac76ad..56f53ad9 100644
--- a/dev-requirements.txt
+++ b/dev-requirements.txt
@@ -7,9 +7,9 @@ pytest-relaxed==1.1.5
pytest-xdist==1.28.0
mock==2.0.0
# Linting!
-flake8==3.6.0
+flake8==3.8.3
# Coverage!
-coverage==3.7.1
+coverage==4.5.4
codecov==1.6.3
# Documentation tools
sphinx>=1.4,<1.7
diff --git a/paramiko/__init__.py b/paramiko/__init__.py
index 8ac52579..8642f84a 100644
--- a/paramiko/__init__.py
+++ b/paramiko/__init__.py
@@ -36,13 +36,15 @@ from paramiko.channel import (
ChannelStdinFile,
)
from paramiko.ssh_exception import (
- SSHException,
- PasswordRequiredException,
+ AuthenticationException,
BadAuthenticationType,
- ChannelException,
BadHostKeyException,
- AuthenticationException,
+ ChannelException,
+ ConfigParseError,
+ CouldNotCanonicalize,
+ PasswordRequiredException,
ProxyCommandFailure,
+ SSHException,
)
from paramiko.server import ServerInterface, SubsystemHandler, InteractiveQuery
from paramiko.rsakey import RSAKey
@@ -62,7 +64,7 @@ from paramiko.file import BufferedFile
from paramiko.agent import Agent, AgentKey
from paramiko.pkey import PKey, PublicBlob
from paramiko.hostkeys import HostKeys
-from paramiko.config import SSHConfig
+from paramiko.config import SSHConfig, SSHConfigDict
from paramiko.proxy import ProxyCommand
from paramiko.common import (
@@ -104,6 +106,8 @@ __all__ = [
"BufferedFile",
"Channel",
"ChannelException",
+ "ConfigParseError",
+ "CouldNotCanonicalize",
"DSSKey",
"ECDSAKey",
"Ed25519Key",
@@ -126,6 +130,7 @@ __all__ = [
"SFTPServerInterface",
"SSHClient",
"SSHConfig",
+ "SSHConfigDict",
"SSHException",
"SecurityOptions",
"ServerInterface",
diff --git a/paramiko/_version.py b/paramiko/_version.py
index 2d128dd7..8b3ae0e7 100644
--- a/paramiko/_version.py
+++ b/paramiko/_version.py
@@ -1,2 +1,2 @@
-__version_info__ = (2, 6, 0)
+__version_info__ = (2, 7, 2)
__version__ = ".".join(map(str, __version_info__))
diff --git a/paramiko/config.py b/paramiko/config.py
index aeb59593..e6877d01 100644
--- a/paramiko/config.py
+++ b/paramiko/config.py
@@ -22,10 +22,23 @@ Configuration file (aka ``ssh_config``) support.
"""
import fnmatch
+import getpass
import os
import re
import shlex
import socket
+from functools import partial
+
+from .py3compat import StringIO
+
+invoke, invoke_import_error = None, None
+try:
+ import invoke
+except ImportError as e:
+ invoke_import_error = e
+
+from .ssh_exception import CouldNotCanonicalize, ConfigParseError
+
SSH_PORT = 22
@@ -43,40 +56,113 @@ class SSHConfig(object):
SETTINGS_REGEX = re.compile(r"(\w+)(?:\s*=\s*|\s+)(.+)")
+ # TODO: do a full scan of ssh.c & friends to make sure we're fully
+ # compatible across the board, e.g. OpenSSH 8.1 added %n to ProxyCommand.
+ TOKENS_BY_CONFIG_KEY = {
+ "controlpath": ["%h", "%l", "%L", "%n", "%p", "%r", "%u"],
+ "hostname": ["%h"],
+ "identityfile": ["~", "%d", "%h", "%l", "%u", "%r"],
+ "proxycommand": ["~", "%h", "%p", "%r"],
+ # Doesn't seem worth making this 'special' for now, it will fit well
+ # enough (no actual match-exec config key to be confused with).
+ "match-exec": ["%d", "%h", "%L", "%l", "%n", "%p", "%r", "%u"],
+ }
+
def __init__(self):
"""
Create a new OpenSSH config object.
+
+ Note: the newer alternate constructors `from_path`, `from_file` and
+ `from_text` are simpler to use, as they parse on instantiation. For
+ example, instead of::
+
+ config = SSHConfig()
+ config.parse(open("some-path.config")
+
+ you could::
+
+ config = SSHConfig.from_file(open("some-path.config"))
+ # Or more directly:
+ config = SSHConfig.from_path("some-path.config")
+ # Or if you have arbitrary ssh_config text from some other source:
+ config = SSHConfig.from_text("Host foo\\n\\tUser bar")
"""
self._config = []
+ @classmethod
+ def from_text(cls, text):
+ """
+ Create a new, parsed `SSHConfig` from ``text`` string.
+
+ .. versionadded:: 2.7
+ """
+ return cls.from_file(StringIO(text))
+
+ @classmethod
+ def from_path(cls, path):
+ """
+ Create a new, parsed `SSHConfig` from the file found at ``path``.
+
+ .. versionadded:: 2.7
+ """
+ with open(path) as flo:
+ return cls.from_file(flo)
+
+ @classmethod
+ def from_file(cls, flo):
+ """
+ Create a new, parsed `SSHConfig` from file-like object ``flo``.
+
+ .. versionadded:: 2.7
+ """
+ obj = cls()
+ obj.parse(flo)
+ return obj
+
def parse(self, file_obj):
"""
Read an OpenSSH config from the given file object.
:param file_obj: a file-like object to read the config file from
"""
- host = {"host": ["*"], "config": {}}
+ # Start out w/ implicit/anonymous global host-like block to hold
+ # anything not contained by an explicit one.
+ context = {"host": ["*"], "config": {}}
for line in file_obj:
# Strip any leading or trailing whitespace from the line.
# Refer to https://github.com/paramiko/paramiko/issues/499
line = line.strip()
+ # Skip blanks, comments
if not line or line.startswith("#"):
continue
+ # Parse line into key, value
match = re.match(self.SETTINGS_REGEX, line)
if not match:
- raise Exception("Unparsable line {}".format(line))
+ raise ConfigParseError("Unparsable line {}".format(line))
key = match.group(1).lower()
value = match.group(2)
- if key == "host":
- self._config.append(host)
- host = {"host": self._get_hosts(value), "config": {}}
+ # Host keyword triggers switch to new block/context
+ if key in ("host", "match"):
+ self._config.append(context)
+ context = {"config": {}}
+ if key == "host":
+ # TODO 3.0: make these real objects or at least name this
+ # "hosts" to acknowledge it's an iterable. (Doing so prior
+ # to 3.0, despite it being a private API, feels bad -
+ # surely such an old codebase has folks actually relying on
+ # these keys.)
+ context["host"] = self._get_hosts(value)
+ else:
+ context["matches"] = self._get_matches(value)
+ # Special-case for noop ProxyCommands
elif key == "proxycommand" and value.lower() == "none":
# Store 'none' as None; prior to 3.x, it will get stripped out
# at the end (for compatibility with issue #415). After 3.x, it
# will simply not get stripped, leaving a nice explicit marker.
- host["config"][key] = None
+ context["config"][key] = None
+ # All other keywords get stored, directly or via append
else:
if value.startswith('"') and value.endswith('"'):
value = value[1:-1]
@@ -85,13 +171,14 @@ class SSHConfig(object):
# cases, since they are allowed to be specified multiple times
# and they should be tried in order of specification.
if key in ["identityfile", "localforward", "remoteforward"]:
- if key in host["config"]:
- host["config"][key].append(value)
+ if key in context["config"]:
+ context["config"][key].append(value)
else:
- host["config"][key] = [value]
- elif key not in host["config"]:
- host["config"][key] = value
- self._config.append(host)
+ context["config"][key] = [value]
+ elif key not in context["config"]:
+ context["config"][key] = value
+ # Store last 'open' block and we're done
+ self._config.append(context)
def lookup(self, hostname):
"""
@@ -99,9 +186,9 @@ class SSHConfig(object):
The host-matching rules of OpenSSH's ``ssh_config`` man page are used:
For each parameter, the first obtained value will be used. The
- configuration files contain sections separated by ``Host``
- specifications, and that section is only applied for hosts that match
- one of the patterns given in the specification.
+ configuration files contain sections separated by ``Host`` and/or
+ ``Match`` specifications, and that section is only applied for hosts
+ which match the given patterns or keywords
Since the first obtained value for each parameter is used, more host-
specific declarations should be given near the beginning of the file,
@@ -118,33 +205,112 @@ class SSHConfig(object):
assert conf['passwordauthentication'] == 'yes'
assert conf.as_bool('passwordauthentication') is True
+ .. note::
+ If there is no explicitly configured ``HostName`` value, it will be
+ set to the being-looked-up hostname, which is as close as we can
+ get to OpenSSH's behavior around that particular option.
+
:param str hostname: the hostname to lookup
.. versionchanged:: 2.5
Returns `SSHConfigDict` objects instead of dict literals.
+ .. versionchanged:: 2.7
+ Added canonicalization support.
+ .. versionchanged:: 2.7
+ Added ``Match`` support.
"""
- matches = [
- config
- for config in self._config
- if self._allowed(config["host"], hostname)
- ]
-
- ret = SSHConfigDict()
- for match in matches:
- for key, value in match["config"].items():
- if key not in ret:
+ # First pass
+ options = self._lookup(hostname=hostname)
+ # Inject HostName if it was not set (this used to be done incidentally
+ # during tokenization, for some reason).
+ if "hostname" not in options:
+ options["hostname"] = hostname
+ # Handle canonicalization
+ canon = options.get("canonicalizehostname", None) in ("yes", "always")
+ maxdots = int(options.get("canonicalizemaxdots", 1))
+ if canon and hostname.count(".") <= maxdots:
+ # NOTE: OpenSSH manpage does not explicitly state this, but its
+ # implementation for CanonicalDomains is 'split on any whitespace'.
+ domains = options["canonicaldomains"].split()
+ hostname = self.canonicalize(hostname, options, domains)
+ # Overwrite HostName again here (this is also what OpenSSH does)
+ options["hostname"] = hostname
+ options = self._lookup(hostname, options, canonical=True)
+ return options
+
+ def _lookup(self, hostname, options=None, canonical=False):
+ # Init
+ if options is None:
+ options = SSHConfigDict()
+ # Iterate all stanzas, applying any that match, in turn (so that things
+ # like Match can reference currently understood state)
+ for context in self._config:
+ if not (
+ self._pattern_matches(context.get("host", []), hostname)
+ or self._does_match(
+ context.get("matches", []), hostname, canonical, options
+ )
+ ):
+ continue
+ for key, value in context["config"].items():
+ if key not in options:
# Create a copy of the original value,
# else it will reference the original list
# in self._config and update that value too
# when the extend() is being called.
- ret[key] = value[:] if value is not None else value
+ options[key] = value[:] if value is not None else value
elif key == "identityfile":
- ret[key].extend(value)
- ret = self._expand_variables(ret, hostname)
+ options[key].extend(
+ x for x in value if x not in options[key]
+ )
+ # Expand variables in resulting values (besides 'Match exec' which was
+ # already handled above)
+ options = self._expand_variables(options, hostname)
# TODO: remove in 3.x re #670
- if "proxycommand" in ret and ret["proxycommand"] is None:
- del ret["proxycommand"]
- return ret
+ if "proxycommand" in options and options["proxycommand"] is None:
+ del options["proxycommand"]
+ return options
+
+ def canonicalize(self, hostname, options, domains):
+ """
+ Return canonicalized version of ``hostname``.
+
+ :param str hostname: Target hostname.
+ :param options: An `SSHConfigDict` from a previous lookup pass.
+ :param domains: List of domains (e.g. ``["paramiko.org"]``).
+
+ :returns: A canonicalized hostname if one was found, else ``None``.
+
+ .. versionadded:: 2.7
+ """
+ found = False
+ for domain in domains:
+ candidate = "{}.{}".format(hostname, domain)
+ family_specific = _addressfamily_host_lookup(candidate, options)
+ if family_specific is not None:
+ # TODO: would we want to dig deeper into other results? e.g. to
+ # find something that satisfies PermittedCNAMEs when that is
+ # implemented?
+ found = family_specific[0]
+ else:
+ # TODO: what does ssh use here and is there a reason to use
+ # that instead of gethostbyname?
+ try:
+ found = socket.gethostbyname(candidate)
+ except socket.gaierror:
+ pass
+ if found:
+ # TODO: follow CNAME (implied by found != candidate?) if
+ # CanonicalizePermittedCNAMEs allows it
+ return candidate
+ # If we got here, it means canonicalization failed.
+ # When CanonicalizeFallbackLocal is undefined or 'yes', we just spit
+ # back the original hostname.
+ if options.get("canonicalizefallbacklocal", "yes") == "yes":
+ return hostname
+ # And here, we failed AND fallback was set to a non-yes value, so we
+ # need to get mad.
+ raise CouldNotCanonicalize(hostname)
def get_hostnames(self):
"""
@@ -156,86 +322,173 @@ class SSHConfig(object):
hosts.update(entry["host"])
return hosts
- def _allowed(self, hosts, hostname):
+ def _pattern_matches(self, patterns, target):
+ # Convenience auto-splitter if not already a list
+ if hasattr(patterns, "split"):
+ patterns = patterns.split(",")
match = False
- for host in hosts:
- if host.startswith("!") and fnmatch.fnmatch(hostname, host[1:]):
+ for pattern in patterns:
+ # Short-circuit if target matches a negated pattern
+ if pattern.startswith("!") and fnmatch.fnmatch(
+ target, pattern[1:]
+ ):
return False
- elif fnmatch.fnmatch(hostname, host):
+ # Flag a match, but continue (in case of later negation) if regular
+ # match occurs
+ elif fnmatch.fnmatch(target, pattern):
match = True
return match
- def _expand_variables(self, config, hostname):
- """
- Return a dict of config options with expanded substitutions
- for a given hostname.
+ # TODO 3.0: remove entirely (is now unused internally)
+ def _allowed(self, hosts, hostname):
+ return self._pattern_matches(hosts, hostname)
+
+ def _does_match(self, match_list, target_hostname, canonical, options):
+ matched = []
+ candidates = match_list[:]
+ local_username = getpass.getuser()
+ while candidates:
+ candidate = candidates.pop(0)
+ passed = None
+ # Obtain latest host/user value every loop, so later Match may
+ # reference values assigned within a prior Match.
+ configured_host = options.get("hostname", None)
+ configured_user = options.get("user", None)
+ type_, param = candidate["type"], candidate["param"]
+ # Canonical is a hard pass/fail based on whether this is a
+ # canonicalized re-lookup.
+ if type_ == "canonical":
+ if self._should_fail(canonical, candidate):
+ return False
+ # The parse step ensures we only see this by itself or after
+ # canonical, so it's also an easy hard pass. (No negation here as
+ # that would be uh, pretty weird?)
+ elif type_ == "all":
+ return True
+ # From here, we are testing various non-hard criteria,
+ # short-circuiting only on fail
+ elif type_ == "host":
+ hostval = configured_host or target_hostname
+ passed = self._pattern_matches(param, hostval)
+ elif type_ == "originalhost":
+ passed = self._pattern_matches(param, target_hostname)
+ elif type_ == "user":
+ user = configured_user or local_username
+ passed = self._pattern_matches(param, user)
+ elif type_ == "localuser":
+ passed = self._pattern_matches(param, local_username)
+ elif type_ == "exec":
+ exec_cmd = self._tokenize(
+ options, target_hostname, "match-exec", param
+ )
+ # This is the laziest spot in which we can get mad about an
+ # inability to import Invoke.
+ if invoke is None:
+ raise invoke_import_error
+ # Like OpenSSH, we 'redirect' stdout but let stderr bubble up
+ passed = invoke.run(exec_cmd, hide="stdout", warn=True).ok
+ # Tackle any 'passed, but was negated' results from above
+ if passed is not None and self._should_fail(passed, candidate):
+ return False
+ # Made it all the way here? Everything matched!
+ matched.append(candidate)
+ # Did anything match? (To be treated as bool, usually.)
+ return matched
- Please refer to man ``ssh_config`` for the parameters that
- are replaced.
+ def _should_fail(self, would_pass, candidate):
+ return would_pass if candidate["negate"] else not would_pass
- :param dict config: the config for the hostname
- :param str hostname: the hostname that the config belongs to
+ def _tokenize(self, config, target_hostname, key, value):
"""
+ Tokenize a string based on current config/hostname data.
- if "hostname" in config:
- config["hostname"] = config["hostname"].replace("%h", hostname)
- else:
- config["hostname"] = hostname
+ :param config: Current config data.
+ :param target_hostname: Original target connection hostname.
+ :param key: Config key being tokenized (used to filter token list).
+ :param value: Config value being tokenized.
+ :returns: The tokenized version of the input ``value`` string.
+ """
+ allowed_tokens = self._allowed_tokens(key)
+ # Short-circuit if no tokenization possible
+ if not allowed_tokens:
+ return value
+ # Obtain potentially configured hostname, for use with %h.
+ # Special-case where we are tokenizing the hostname itself, to avoid
+ # replacing %h with a %h-bearing value, etc.
+ configured_hostname = target_hostname
+ if key != "hostname":
+ configured_hostname = config.get("hostname", configured_hostname)
+ # Ditto the rest of the source values
if "port" in config:
port = config["port"]
else:
port = SSH_PORT
-
- user = os.getenv("USER")
+ user = getpass.getuser()
if "user" in config:
remoteuser = config["user"]
else:
remoteuser = user
-
- host = socket.gethostname().split(".")[0]
- fqdn = LazyFqdn(config, host)
+ local_hostname = socket.gethostname().split(".")[0]
+ local_fqdn = LazyFqdn(config, local_hostname)
homedir = os.path.expanduser("~")
+ # The actual tokens!
replacements = {
- "controlpath": [
- ("%h", config["hostname"]),
- ("%l", fqdn),
- ("%L", host),
- ("%n", hostname),
- ("%p", port),
- ("%r", remoteuser),
- ("%u", user),
- ],
- "identityfile": [
- ("~", homedir),
- ("%d", homedir),
- ("%h", config["hostname"]),
- ("%l", fqdn),
- ("%u", user),
- ("%r", remoteuser),
- ],
- "proxycommand": [
- ("~", homedir),
- ("%h", config["hostname"]),
- ("%p", port),
- ("%r", remoteuser),
- ],
+ # TODO: %%???
+ # TODO: %C?
+ "%d": homedir,
+ "%h": configured_hostname,
+ # TODO: %i?
+ "%L": local_hostname,
+ "%l": local_fqdn,
+ # also this is pseudo buggy when not in Match exec mode so document
+ # that. also WHY is that the case?? don't we do all of this late?
+ "%n": target_hostname,
+ "%p": port,
+ "%r": remoteuser,
+ # TODO: %T? don't believe this is possible however
+ "%u": user,
+ "~": homedir,
}
+ # Do the thing with the stuff
+ tokenized = value
+ for find, replace in replacements.items():
+ if find not in allowed_tokens:
+ continue
+ tokenized = tokenized.replace(find, str(replace))
+ # TODO: log? eg that value -> tokenized
+ return tokenized
+
+ def _allowed_tokens(self, key):
+ """
+ Given config ``key``, return list of token strings to tokenize.
+
+ .. note::
+ This feels like it wants to eventually go away, but is used to
+ preserve as-strict-as-possible compatibility with OpenSSH, which
+ for whatever reason only applies some tokens to some config keys.
+ """
+ return self.TOKENS_BY_CONFIG_KEY.get(key, [])
+
+ def _expand_variables(self, config, target_hostname):
+ """
+ Return a dict of config options with expanded substitutions
+ for a given original & current target hostname.
+
+ Please refer to :doc:`/api/config` for details.
+ :param dict config: the currently parsed config
+ :param str hostname: the hostname whose config is being looked up
+ """
for k in config:
if config[k] is None:
continue
- if k in replacements:
- for find, replace in replacements[k]:
- if isinstance(config[k], list):
- for item in range(len(config[k])):
- if find in config[k][item]:
- config[k][item] = config[k][item].replace(
- find, str(replace)
- )
- else:
- if find in config[k]:
- config[k] = config[k].replace(find, str(replace))
+ tokenizer = partial(self._tokenize, config, target_hostname, k)
+ if isinstance(config[k], list):
+ for i, value in enumerate(config[k]):
+ config[k][i] = tokenizer(value)
+ else:
+ config[k] = tokenizer(config[k])
return config
def _get_hosts(self, host):
@@ -245,7 +498,88 @@ class SSHConfig(object):
try:
return shlex.split(host)
except ValueError:
- raise Exception("Unparsable host {}".format(host))
+ raise ConfigParseError("Unparsable host {}".format(host))
+
+ def _get_matches(self, match):
+ """
+ Parse a specific Match config line into a list-of-dicts for its values.
+
+ Performs some parse-time validation as well.
+ """
+ matches = []
+ tokens = shlex.split(match)
+ while tokens:
+ match = {"type": None, "param": None, "negate": False}
+ type_ = tokens.pop(0)
+ # Handle per-keyword negation
+ if type_.startswith("!"):
+ match["negate"] = True
+ type_ = type_[1:]
+ match["type"] = type_
+ # all/canonical have no params (everything else does)
+ if type_ in ("all", "canonical"):
+ matches.append(match)
+ continue
+ if not tokens:
+ raise ConfigParseError(
+ "Missing parameter to Match '{}' keyword".format(type_)
+ )
+ match["param"] = tokens.pop(0)
+ matches.append(match)
+ # Perform some (easier to do now than in the middle) validation that is
+ # better handled here than at lookup time.
+ keywords = [x["type"] for x in matches]
+ if "all" in keywords:
+ allowable = ("all", "canonical")
+ ok, bad = (
+ list(filter(lambda x: x in allowable, keywords)),
+ list(filter(lambda x: x not in allowable, keywords)),
+ )
+ err = None
+ if any(bad):
+ err = "Match does not allow 'all' mixed with anything but 'canonical'" # noqa
+ elif "canonical" in ok and ok.index("canonical") > ok.index("all"):
+ err = "Match does not allow 'all' before 'canonical'"
+ if err is not None:
+ raise ConfigParseError(err)
+ return matches
+
+
+def _addressfamily_host_lookup(hostname, options):
+ """
+ Try looking up ``hostname`` in an IPv4 or IPv6 specific manner.
+
+ This is an odd duck due to needing use in two divergent use cases. It looks
+ up ``AddressFamily`` in ``options`` and if it is ``inet`` or ``inet6``,
+ this function uses `socket.getaddrinfo` to perform a family-specific
+ lookup, returning the result if successful.
+
+ In any other situation -- lookup failure, or ``AddressFamily`` being
+ unspecified or ``any`` -- ``None`` is returned instead and the caller is
+ expected to do something situation-appropriate like calling
+ `socket.gethostbyname`.
+
+ :param str hostname: Hostname to look up.
+ :param options: `SSHConfigDict` instance w/ parsed options.
+ :returns: ``getaddrinfo``-style tuples, or ``None``, depending.
+ """
+ address_family = options.get("addressfamily", "any").lower()
+ if address_family == "any":
+ return
+ try:
+ family = socket.AF_INET6
+ if address_family == "inet":
+ family = socket.AF_INET
+ return socket.getaddrinfo(
+ hostname,
+ None,
+ family,
+ socket.SOCK_DGRAM,
+ socket.IPPROTO_IP,
+ socket.AI_CANONNAME,
+ )
+ except socket.gaierror:
+ pass
class LazyFqdn(object):
@@ -271,31 +605,14 @@ class LazyFqdn(object):
# Handle specific option
fqdn = None
- address_family = self.config.get("addressfamily", "any").lower()
- if address_family != "any":
- try:
- family = socket.AF_INET6
- if address_family == "inet":
- socket.AF_INET
- results = socket.getaddrinfo(
- self.host,
- None,
- family,
- socket.SOCK_DGRAM,
- socket.IPPROTO_IP,
- socket.AI_CANONNAME,
- )
- for res in results:
- af, socktype, proto, canonname, sa = res
- if canonname and "." in canonname:
- fqdn = canonname
- break
- # giaerror -> socket.getaddrinfo() can't resolve self.host
- # (which is from socket.gethostname()). Fall back to the
- # getfqdn() call below.
- except socket.gaierror:
- pass
- # Handle 'any' / unspecified
+ results = _addressfamily_host_lookup(self.host, self.config)
+ if results is not None:
+ for res in results:
+ af, socktype, proto, canonname, sa = res
+ if canonname and "." in canonname:
+ fqdn = canonname
+ break
+ # Handle 'any' / unspecified / lookup failure
if fqdn is None:
fqdn = socket.getfqdn()
# Cache
diff --git a/paramiko/dsskey.py b/paramiko/dsskey.py
index ec358ee2..7e74836c 100644
--- a/paramiko/dsskey.py
+++ b/paramiko/dsskey.py
@@ -229,12 +229,19 @@ class DSSKey(PKey):
self._decode_key(data)
def _decode_key(self, data):
+ pkformat, data = data
# private key file contains:
# DSAPrivateKey = { version = 0, p, q, g, y, x }
- try:
- keylist = BER(data).decode()
- except BERException as e:
- raise SSHException("Unable to parse key file: " + str(e))
+ if pkformat == self._PRIVATE_KEY_FORMAT_ORIGINAL:
+ try:
+ keylist = BER(data).decode()
+ except BERException as e:
+ raise SSHException("Unable to parse key file: {}".format(e))
+ elif pkformat == self._PRIVATE_KEY_FORMAT_OPENSSH:
+ keylist = self._uint32_cstruct_unpack(data, "iiiii")
+ keylist = [0] + list(keylist)
+ else:
+ self._got_bad_key_format_id(pkformat)
if type(keylist) is not list or len(keylist) < 6 or keylist[0] != 0:
raise SSHException(
"not a valid DSA private key file (bad ber encoding)"
diff --git a/paramiko/ecdsakey.py b/paramiko/ecdsakey.py
index 353c5f9e..3d3d09be 100644
--- a/paramiko/ecdsakey.py
+++ b/paramiko/ecdsakey.py
@@ -283,12 +283,33 @@ class ECDSAKey(PKey):
self._decode_key(data)
def _decode_key(self, data):
- try:
- key = serialization.load_der_private_key(
- data, password=None, backend=default_backend()
- )
- except (ValueError, AssertionError) as e:
- raise SSHException(str(e))
+ pkformat, data = data
+ if pkformat == self._PRIVATE_KEY_FORMAT_ORIGINAL:
+ try:
+ key = serialization.load_der_private_key(
+ data, password=None, backend=default_backend()
+ )
+ except (ValueError, AssertionError) as e:
+ raise SSHException(str(e))
+ elif pkformat == self._PRIVATE_KEY_FORMAT_OPENSSH:
+ try:
+ msg = Message(data)
+ curve_name = msg.get_text()
+ verkey = msg.get_binary() # noqa: F841
+ sigkey = msg.get_mpint()
+ name = "ecdsa-sha2-" + curve_name
+ curve = self._ECDSA_CURVES.get_by_key_format_identifier(name)
+ if not curve:
+ raise SSHException("Invalid key curve identifier")
+ key = ec.derive_private_key(
+ sigkey, curve.curve_class(), default_backend()
+ )
+ except Exception as e:
+ # PKey._read_private_key_openssh() should check or return
+ # keytype - parsing could fail for any reason due to wrong type
+ raise SSHException(str(e))
+ else:
+ self._got_bad_key_format_id(pkformat)
self.signing_key = key
self.verifying_key = key.public_key()
diff --git a/paramiko/ed25519key.py b/paramiko/ed25519key.py
index 6e0d3ccf..b584f521 100644
--- a/paramiko/ed25519key.py
+++ b/paramiko/ed25519key.py
@@ -21,32 +21,12 @@ from cryptography.hazmat.primitives.ciphers import Cipher
import nacl.signing
-import six
-
from paramiko.message import Message
-from paramiko.pkey import PKey
+from paramiko.pkey import PKey, OPENSSH_AUTH_MAGIC, _unpad_openssh
from paramiko.py3compat import b
from paramiko.ssh_exception import SSHException, PasswordRequiredException
-OPENSSH_AUTH_MAGIC = b"openssh-key-v1\x00"
-
-
-def unpad(data):
- # At the moment, this is only used for unpadding private keys on disk. This
- # really ought to be made constant time (possibly by upstreaming this logic
- # into pyca/cryptography).
- padding_length = six.indexbytes(data, -1)
- if 0x20 <= padding_length < 0x7f:
- return data # no padding, last byte part comment (printable ascii)
- if padding_length > 15:
- raise SSHException("Invalid key")
- for i in range(padding_length):
- if six.indexbytes(data, i - padding_length) != i + 1:
- raise SSHException("Invalid key")
- return data[:-padding_length]
-
-
class Ed25519Key(PKey):
"""
Representation of an `Ed25519 <https://ed25519.cr.yp.to/>`_ key.
@@ -75,9 +55,9 @@ class Ed25519Key(PKey):
verifying_key = nacl.signing.VerifyKey(msg.get_binary())
elif filename is not None:
with open(filename, "r") as f:
- data = self._read_private_key("OPENSSH", f)
+ pkformat, data = self._read_private_key("OPENSSH", f)
elif file_obj is not None:
- data = self._read_private_key("OPENSSH", file_obj)
+ pkformat, data = self._read_private_key("OPENSSH", file_obj)
if filename or file_obj:
signing_key = self._parse_signing_key_data(data, password)
@@ -155,7 +135,7 @@ class Ed25519Key(PKey):
decryptor.update(private_ciphertext) + decryptor.finalize()
)
- message = Message(unpad(private_data))
+ message = Message(_unpad_openssh(private_data))
if message.get_int() != message.get_int():
raise SSHException("Invalid key")
diff --git a/paramiko/message.py b/paramiko/message.py
index dead3508..9771cfbc 100644
--- a/paramiko/message.py
+++ b/paramiko/message.py
@@ -275,7 +275,7 @@ class Message(object):
self.packet.write(s)
return self
- def add_list(self, l):
+ def add_list(self, l): # noqa: E741
"""
Add a list of strings to the stream. They are encoded identically to
a single string of values separated by commas. (Yes, really, that's
diff --git a/paramiko/pkey.py b/paramiko/pkey.py
index e8e65589..a54d502d 100644
--- a/paramiko/pkey.py
+++ b/paramiko/pkey.py
@@ -24,6 +24,11 @@ import base64
from binascii import unhexlify
import os
from hashlib import md5
+import re
+import struct
+
+import six
+import bcrypt
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
@@ -31,11 +36,29 @@ from cryptography.hazmat.primitives.ciphers import algorithms, modes, Cipher
from paramiko import util
from paramiko.common import o600
-from paramiko.py3compat import u, encodebytes, decodebytes, b, string_types
+from paramiko.py3compat import u, b, encodebytes, decodebytes, string_types
from paramiko.ssh_exception import SSHException, PasswordRequiredException
from paramiko.message import Message
+OPENSSH_AUTH_MAGIC = b"openssh-key-v1\x00"
+
+
+def _unpad_openssh(data):
+ # At the moment, this is only used for unpadding private keys on disk. This
+ # really ought to be made constant time (possibly by upstreaming this logic
+ # into pyca/cryptography).
+ padding_length = six.indexbytes(data, -1)
+ if 0x20 <= padding_length < 0x7f:
+ return data # no padding, last byte part comment (printable ascii)
+ if padding_length > 15:
+ raise SSHException("Invalid key")
+ for i in range(padding_length):
+ if six.indexbytes(data, i - padding_length) != i + 1:
+ raise SSHException("Invalid key")
+ return data[:-padding_length]
+
+
class PKey(object):
"""
Base class for public keys.
@@ -62,6 +85,12 @@ class PKey(object):
"mode": modes.CBC,
},
}
+ _PRIVATE_KEY_FORMAT_ORIGINAL = 1
+ _PRIVATE_KEY_FORMAT_OPENSSH = 2
+ BEGIN_TAG = re.compile(
+ r"^-{5}BEGIN (RSA|DSA|EC|OPENSSH) PRIVATE KEY-{5}\s*$"
+ )
+ END_TAG = re.compile(r"^-{5}END (RSA|DSA|EC|OPENSSH) PRIVATE KEY-{5}\s*$")
def __init__(self, msg=None, data=None):
"""
@@ -281,12 +310,45 @@ class PKey(object):
def _read_private_key(self, tag, f, password=None):
lines = f.readlines()
+
+ # find the BEGIN tag
start = 0
- beginning_of_key = "-----BEGIN " + tag + " PRIVATE KEY-----"
- while start < len(lines) and lines[start].strip() != beginning_of_key:
+ m = self.BEGIN_TAG.match(lines[start])
+ line_range = len(lines) - 1
+ while start < line_range and not m:
start += 1
- if start >= len(lines):
- raise SSHException("not a valid " + tag + " private key file")
+ m = self.BEGIN_TAG.match(lines[start])
+ start += 1
+ keytype = m.group(1) if m else None
+ if start >= len(lines) or keytype is None:
+ raise SSHException("not a valid {} private key file".format(tag))
+
+ # find the END tag
+ end = start
+ m = self.END_TAG.match(lines[end])
+ while end < line_range and not m:
+ end += 1
+ m = self.END_TAG.match(lines[end])
+
+ if keytype == tag:
+ data = self._read_private_key_pem(lines, end, password)
+ pkformat = self._PRIVATE_KEY_FORMAT_ORIGINAL
+ elif keytype == "OPENSSH":
+ data = self._read_private_key_openssh(lines[start:end], password)
+ pkformat = self._PRIVATE_KEY_FORMAT_OPENSSH
+ else:
+ raise SSHException(
+ "encountered {} key, expected {} key".format(keytype, tag)
+ )
+
+ return pkformat, data
+
+ def _got_bad_key_format_id(self, id_):
+ err = "{}._read_private_key() spat out an unknown key format id '{}'"
+ raise SSHException(err.format(self.__class__.__name__, id_))
+
+ def _read_private_key_pem(self, lines, end, password):
+ start = 0
# parse any headers first
headers = {}
start += 1
@@ -296,16 +358,11 @@ class PKey(object):
break
headers[line[0].lower()] = line[1].strip()
start += 1
- # find end
- end = start
- ending_of_key = "-----END " + tag + " PRIVATE KEY-----"
- while end < len(lines) and lines[end].strip() != ending_of_key:
- end += 1
# if we trudged to the end of the file, just try to cope.
try:
data = decodebytes(b("".join(lines[start:end])))
except base64.binascii.Error as e:
- raise SSHException("base64 decoding error: " + str(e))
+ raise SSHException("base64 decoding error: {}".format(e))
if "proc-type" not in headers:
# unencryped: done
return data
@@ -337,6 +394,141 @@ class PKey(object):
).decryptor()
return decryptor.update(data) + decryptor.finalize()
+ def _read_private_key_openssh(self, lines, password):
+ """
+ Read the new OpenSSH SSH2 private key format available
+ since OpenSSH version 6.5
+ Reference:
+ https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.key
+ """
+ try:
+ data = decodebytes(b("".join(lines)))
+ except base64.binascii.Error as e:
+ raise SSHException("base64 decoding error: {}".format(e))
+
+ # read data struct
+ auth_magic = data[:15]
+ if auth_magic != OPENSSH_AUTH_MAGIC:
+ raise SSHException("unexpected OpenSSH key header encountered")
+
+ cstruct = self._uint32_cstruct_unpack(data[15:], "sssur")
+ cipher, kdfname, kdf_options, num_pubkeys, remainder = cstruct
+ # For now, just support 1 key.
+ if num_pubkeys > 1:
+ raise SSHException(
+ "unsupported: private keyfile has multiple keys"
+ )
+ pubkey, privkey_blob = self._uint32_cstruct_unpack(remainder, "ss")
+
+ if kdfname == b("bcrypt"):
+ if cipher == b("aes256-cbc"):
+ mode = modes.CBC
+ elif cipher == b("aes256-ctr"):
+ mode = modes.CTR
+ else:
+ raise SSHException(
+ "unknown cipher `{}` used in private key file".format(
+ cipher.decode("utf-8")
+ )
+ )
+ # Encrypted private key.
+ # If no password was passed in, raise an exception pointing
+ # out that we need one
+ if password is None:
+ raise PasswordRequiredException(
+ "private key file is encrypted"
+ )
+
+ # Unpack salt and rounds from kdfoptions
+ salt, rounds = self._uint32_cstruct_unpack(kdf_options, "su")
+
+ # run bcrypt kdf to derive key and iv/nonce (32 + 16 bytes)
+ key_iv = bcrypt.kdf(
+ b(password),
+ b(salt),
+ 48,
+ rounds,
+ # We can't control how many rounds are on disk, so no sense
+ # warning about it.
+ ignore_few_rounds=True,
+ )
+ key = key_iv[:32]
+ iv = key_iv[32:]
+
+ # decrypt private key blob
+ decryptor = Cipher(
+ algorithms.AES(key), mode(iv), default_backend()
+ ).decryptor()
+ decrypted_privkey = decryptor.update(privkey_blob)
+ decrypted_privkey += decryptor.finalize()
+ elif cipher == b("none") and kdfname == b("none"):
+ # Unencrypted private key
+ decrypted_privkey = privkey_blob
+ else:
+ raise SSHException(
+ "unknown cipher or kdf used in private key file"
+ )
+
+ # Unpack private key and verify checkints
+ cstruct = self._uint32_cstruct_unpack(decrypted_privkey, "uusr")
+ checkint1, checkint2, keytype, keydata = cstruct
+
+ if checkint1 != checkint2:
+ raise SSHException(
+ "OpenSSH private key file checkints do not match"
+ )
+
+ return _unpad_openssh(keydata)
+
+ def _uint32_cstruct_unpack(self, data, strformat):
+ """
+ Used to read new OpenSSH private key format.
+ Unpacks a c data structure containing a mix of 32-bit uints and
+ variable length strings prefixed by 32-bit uint size field,
+ according to the specified format. Returns the unpacked vars
+ in a tuple.
+ Format strings:
+ s - denotes a string
+ i - denotes a long integer, encoded as a byte string
+ u - denotes a 32-bit unsigned integer
+ r - the remainder of the input string, returned as a string
+ """
+ arr = []
+ idx = 0
+ try:
+ for f in strformat:
+ if f == "s":
+ # string
+ s_size = struct.unpack(">L", data[idx : idx + 4])[0]
+ idx += 4
+ s = data[idx : idx + s_size]
+ idx += s_size
+ arr.append(s)
+ if f == "i":
+ # long integer
+ s_size = struct.unpack(">L", data[idx : idx + 4])[0]
+ idx += 4
+ s = data[idx : idx + s_size]
+ idx += s_size
+ i = util.inflate_long(s, True)
+ arr.append(i)
+ elif f == "u":
+ # 32-bit unsigned int
+ u = struct.unpack(">L", data[idx : idx + 4])[0]
+ idx += 4
+ arr.append(u)
+ elif f == "r":
+ # remainder as string
+ s = data[idx:]
+ arr.append(s)
+ break
+ except Exception as e:
+ # PKey-consuming code frequently wants to save-and-skip-over issues
+ # with loading keys, and uses SSHException as the (really friggin
+ # awful) signal for this. So for now...we do this.
+ raise SSHException(str(e))
+ return tuple(arr)
+
def _write_private_key_file(self, filename, key, format, password=None):
"""
Write an SSH2-format private key file in a form that can be read by
diff --git a/paramiko/proxy.py b/paramiko/proxy.py
index 444c47b6..077e8e35 100644
--- a/paramiko/proxy.py
+++ b/paramiko/proxy.py
@@ -18,12 +18,20 @@
import os
-from shlex import split as shlsplit
+import shlex
import signal
from select import select
import socket
import time
+# Try-and-ignore import so platforms w/o subprocess (eg Google App Engine) can
+# still import paramiko.
+subprocess, subprocess_import_error = None, None
+try:
+ import subprocess
+except ImportError as e:
+ subprocess_import_error = e
+
from paramiko.ssh_exception import ProxyCommandFailure
from paramiko.util import ClosingContextManager
@@ -48,13 +56,15 @@ class ProxyCommand(ClosingContextManager):
:param str command_line:
the command that should be executed and used as the proxy.
"""
- # NOTE: subprocess import done lazily so platforms without it (e.g.
- # GAE) can still import us during overall Paramiko load.
- from subprocess import Popen, PIPE
-
- self.cmd = shlsplit(command_line)
- self.process = Popen(
- self.cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE, bufsize=0
+ if subprocess is None:
+ raise subprocess_import_error
+ self.cmd = shlex.split(command_line)
+ self.process = subprocess.Popen(
+ self.cmd,
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ bufsize=0,
)
self.timeout = None
diff --git a/paramiko/rsakey.py b/paramiko/rsakey.py
index 442bfe1f..172f42d4 100644
--- a/paramiko/rsakey.py
+++ b/paramiko/rsakey.py
@@ -180,12 +180,27 @@ class RSAKey(PKey):
self._decode_key(data)
def _decode_key(self, data):
- try:
- key = serialization.load_der_private_key(
- data, password=None, backend=default_backend()
- )
- except ValueError as e:
- raise SSHException(str(e))
-
+ pkformat, data = data
+ if pkformat == self._PRIVATE_KEY_FORMAT_ORIGINAL:
+ try:
+ key = serialization.load_der_private_key(
+ data, password=None, backend=default_backend()
+ )
+ except ValueError as e:
+ raise SSHException(str(e))
+ elif pkformat == self._PRIVATE_KEY_FORMAT_OPENSSH:
+ n, e, d, iqmp, p, q = self._uint32_cstruct_unpack(data, "iiiiii")
+ public_numbers = rsa.RSAPublicNumbers(e=e, n=n)
+ key = rsa.RSAPrivateNumbers(
+ p=p,
+ q=q,
+ d=d,
+ dmp1=d % (p - 1),
+ dmq1=d % (q - 1),
+ iqmp=iqmp,
+ public_numbers=public_numbers,
+ ).private_key(default_backend())
+ else:
+ self._got_bad_key_format_id(pkformat)
assert isinstance(key, rsa.RSAPrivateKey)
self.key = key
diff --git a/paramiko/ssh_exception.py b/paramiko/ssh_exception.py
index b525468a..2789be99 100644
--- a/paramiko/ssh_exception.py
+++ b/paramiko/ssh_exception.py
@@ -196,3 +196,27 @@ class NoValidConnectionsError(socket.error):
def __reduce__(self):
return (self.__class__, (self.errors,))
+
+
+class CouldNotCanonicalize(SSHException):
+ """
+ Raised when hostname canonicalization fails & fallback is disabled.
+
+ .. versionadded:: 2.7
+ """
+
+ pass
+
+
+class ConfigParseError(SSHException):
+ """
+ A fatal error was encountered trying to parse SSH config data.
+
+ Typically this means a config file violated the ``ssh_config``
+ specification in a manner that requires exiting immediately, such as not
+ matching ``key = value`` syntax or misusing certain ``Match`` keywords.
+
+ .. versionadded:: 2.7
+ """
+
+ pass
diff --git a/paramiko/ssh_gss.py b/paramiko/ssh_gss.py
index eaafe94b..5d4cb416 100644
--- a/paramiko/ssh_gss.py
+++ b/paramiko/ssh_gss.py
@@ -658,7 +658,7 @@ class _SSH_SSPI(_SSH_GSSAuth):
error, token = self._gss_ctxt.authorize(recv_token)
token = token[0].Buffer
except pywintypes.error as e:
- e.strerror += ", Target: {}".format(e, self._gss_host)
+ e.strerror += ", Target: {}".format(self._gss_host)
raise
if error == 0:
diff --git a/paramiko/util.py b/paramiko/util.py
index 29c52bfb..93970289 100644
--- a/paramiko/util.py
+++ b/paramiko/util.py
@@ -194,6 +194,9 @@ def load_host_keys(filename):
def parse_ssh_config(file_obj):
"""
Provided only as a backward-compatible wrapper around `.SSHConfig`.
+
+ .. deprecated:: 2.7
+ Use `SSHConfig.from_file` instead.
"""
config = SSHConfig()
config.parse(file_obj)
diff --git a/setup.py b/setup.py
index cf063c44..d439fd03 100644
--- a/setup.py
+++ b/setup.py
@@ -30,9 +30,6 @@ Emphasis is on using SSH2 as an alternative to SSL for making secure
connections between python scripts. All major ciphers and hash methods
are supported. SFTP client and server mode are both supported too.
-Required packages:
- Cryptography
-
To install the development version, ``pip install -e
git+https://github.com/paramiko/paramiko/#egg=paramiko``.
"""
@@ -44,6 +41,22 @@ with open("paramiko/_version.py") as fp:
exec(fp.read(), None, _locals)
version = _locals["__version__"]
+# Have to build extras_require dynamically because it doesn't allow
+# self-referencing and I hate repeating myself.
+extras_require = {
+ "gssapi": [
+ "pyasn1>=0.1.7",
+ 'gssapi>=1.4.1;platform_system!="Windows"',
+ 'pywin32>=2.1.8;platform_system=="Windows"',
+ ],
+ "ed25519": ["pynacl>=1.0.1", "bcrypt>=3.1.3"],
+ "invoke": ["invoke>=1.3"],
+}
+everything = []
+for subdeps in extras_require.values():
+ everything.extend(subdeps)
+extras_require["all"] = everything
+
setup(
name="paramiko",
version=version,
@@ -73,12 +86,8 @@ setup(
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
],
+ # TODO 3.0: remove bcrypt, pynacl and update installation docs noting that
+ # use of the extras_require(s) is now required for those
install_requires=["bcrypt>=3.1.3", "cryptography>=2.5", "pynacl>=1.0.1"],
- extras_require={
- "gssapi": [
- "pyasn1>=0.1.7",
- 'gssapi>=1.4.1;platform_system!="Windows"',
- 'pywin32>=2.1.8;platform_system=="Windows"',
- ]
- },
+ extras_require=extras_require,
)
diff --git a/sites/docs/api/config.rst b/sites/docs/api/config.rst
index afb004c9..ea4939b2 100644
--- a/sites/docs/api/config.rst
+++ b/sites/docs/api/config.rst
@@ -1,5 +1,130 @@
+=============
Configuration
=============
+Paramiko **does not itself** leverage `OpenSSH-style config file directives
+<ssh_config>`_, but it **does** implement a parser for the format, which users
+can honor themselves (and is used by higher-level libraries, such as
+`Fabric`_).
+
+The API for this is `.SSHConfig`, which loads SSH config files from disk,
+file-like object, or string and exposes a "look up a hostname, get a dict of
+applicable keywords/values back" functionality.
+
+As with OpenSSH's own support, this dict will contain values from across the
+parsed file, depending on the order in which keywords were encountered and how
+specific or generic the ``Host`` or ``Match`` directives were.
+
+.. note:;
+ Result keys are lowercased for consistency and ease of deduping, as the
+ overall parsing/matching is itself case-insensitive. Thus, a source file
+ containing e.g. ``ProxyCommand`` will result in lookup results like
+ ``{"proxycommand": "shell command here"}``.
+
+
+.. _ssh-config-support:
+
+Keywords currently supported
+============================
+
+The following is an alphabetical list of which `ssh_config`_ directives
+Paramiko interprets during the parse/lookup process (as above, actual SSH
+connections **do not** reference parsed configs). Departures from `OpenSSH's
+implementation <ssh_config>`_ (e.g. to support backwards compat with older
+Paramiko releases) are included. A keyword by itself means no known departures.
+
+- ``AddressFamily``: used when looking up the local hostname for purposes of
+ expanding the ``%l``/``%L`` :ref:`tokens <TOKENS>` (this is actually a minor
+ value-add on top of OpenSSH, which doesn't actually honor this setting when
+ expanding ``%l``).
+- ``CanonicalDomains``
+
+ .. versionadded:: 2.7
+
+- ``CanonicalizeFallbackLocal``: when ``no``, triggers raising of
+ `.CouldNotCanonicalize` for target hostnames which do not successfully
+ canonicalize.
+
+ .. versionadded:: 2.7
+
+- ``CanonicalizeHostname``: along with the other ``Canonicaliz*`` settings
+ (sans ``CanonicalizePermittedCNAMEs``, which is not yet implemented), enables
+ hostname canonicalization, insofar as calling `.SSHConfig.lookup` with a
+ given hostname will return a canonicalized copy of the config data, including
+ an updated ``HostName`` value.
+
+ .. versionadded:: 2.7
+
+- ``CanonicalizeMaxDots``
+
+ .. versionadded:: 2.7
+
+- ``Host``
+- ``HostName``: used in ``%h`` :ref:`token expansion <TOKENS>`
+- ``Match``: fully supported, with the following caveats:
+
+ - You must have the optional dependency Invoke installed; see :ref:`the
+ installation docs <paramiko-itself>` (in brief: install
+ ``paramiko[invoke]`` or ``paramiko[all]``).
+ - As usual, connection-time information is not present during config
+ lookup, and thus cannot be used to determine matching. This primarily
+ impacts ``Match user``, which can match against loaded ``User`` values
+ but has no knowledge about connection-time usernames.
+
+ .. versionadded:: 2.7
+
+- ``Port``: supplies potential values for ``%p`` :ref:`token expansion
+ <TOKENS>`.
+- ``ProxyCommand``: see our `.ProxyCommand` class for an easy
+ way to honor this keyword from a config you've parsed.
+
+ - Honors :ref:`token expansion <TOKENS>`.
+ - When a lookup would result in an effective ``ProxyCommand none``,
+ Paramiko (as of 1.x-2.x) strips it from the resulting dict entirely. A
+ later major version may retain the ``"none"`` marker for clarity's sake.
+
+- ``User``: supplies potential values for ``%u`` :ref:`token expansion
+ <TOKENS>`.
+
+.. _TOKENS:
+
+Expansion tokens
+----------------
+
+We support most SSH config expansion tokens where possible, so when they are
+present in a config file source, the result of a `.SSHConfig.lookup` will
+contain the expansions/substitutions (based on the rest of the config or
+properties of the local system).
+
+Specifically, we are known to support the below, where applicable (e.g. as in
+OpenSSH, ``%L`` works in ``ControlPath`` but not elsewhere):
+
+- ``%d``
+- ``%h``
+- ``%l``
+- ``%L``
+- ``%n``
+- ``%p``
+- ``%r``
+- ``%u``: substitutes the configured ``User`` value, or the local user (as seen
+ by ``getpass.getuser``) if not specified.
+
+In addition, we extend OpenSSH's tokens as follows:
+
+- ``~`` is treated like ``%d`` (expands to the local user's home directory
+ path) when expanding ``ProxyCommand`` values, since ``ProxyCommand`` does not
+ natively support ``%d`` for some reason.
+
+
+.. _ssh_config: https://man.openbsd.org/ssh_config
+.. _Fabric: http://fabfile.org
+
+
+``config`` module API documentation
+===================================
+
+Mostly of interest to contributors; see previous section for behavioral
+details.
+
.. automodule:: paramiko.config
:member-order: bysource
diff --git a/sites/docs/conf.py b/sites/docs/conf.py
index eb895804..4805a03c 100644
--- a/sites/docs/conf.py
+++ b/sites/docs/conf.py
@@ -1,8 +1,9 @@
# Obtain shared config values
import os, sys
+from os.path import abspath, join, dirname
-sys.path.append(os.path.abspath(".."))
-sys.path.append(os.path.abspath("../.."))
+sys.path.append(abspath(".."))
+sys.path.append(abspath("../.."))
from shared_conf import *
# Enable autodoc, intersphinx
@@ -11,6 +12,13 @@ extensions.extend(["sphinx.ext.autodoc"])
# Autodoc settings
autodoc_default_flags = ["members", "special-members"]
+# Default is 'local' building, but reference the public www site when building
+# under RTD.
+target = join(dirname(__file__), "..", "www", "_build")
+if os.environ.get("READTHEDOCS") == "True":
+ target = "http://paramiko.org"
+intersphinx_mapping["www"] = (target, None)
+
# Sister-site links to WWW
html_theme_options["extra_nav_links"] = {
"Main website": "http://www.paramiko.org"
diff --git a/sites/www/changelog.rst b/sites/www/changelog.rst
index e664f7d4..f3ff0749 100644
--- a/sites/www/changelog.rst
+++ b/sites/www/changelog.rst
@@ -2,6 +2,85 @@
Changelog
=========
+- :release:`2.7.2 <2020-08-30>`
+- :support:`- backported` Update our CI to catch issues with sdist generation,
+ installation and testing.
+- :support:`1727 backported` Add missing test suite fixtures directory to
+ MANIFEST.in, reinstating the ability to run Paramiko's tests from an sdist
+ tarball. Thanks to Sandro Tosi for reporting the issue and to Blazej Michalik
+ for the PR.
+- :support:`1722 backported` Remove leading whitespace from OpenSSH RSA test
+ suite static key fixture, to conform better to spec. Credit: Alex Gaynor.
+- :bug:`-` Fix incorrect string formatting causing unhelpful error message
+ annotation when using Kerberos/GSSAPI. (Thanks, newer version of flake8!)
+- :bug:`1723` Fix incorrectly swapped order of ``p`` and ``q`` numbers when
+ loading OpenSSH-format RSA private keys. At minimum this should address a
+ slowdown when using such keys, and it also means Paramiko works with
+ Cryptography 3.1 and above (which complains strenuously when this problem
+ appears). Thanks to Alex Gaynor for the patch.
+- :release:`2.7.1 <2019-12-09>`
+- :bug:`1567` The new-style private key format (added in 2.7) suffered from an
+ unpadding bug which had been fixed earlier for Ed25519 (as that key type has
+ always used the newer format). That fix has been refactored and applied to
+ the base key class, courtesy of Pierce Lopez.
+- :bug:`1565` (via :issue:`1566`) Fix a bug in support for ECDSA keys under the
+ newly supported OpenSSH key format. Thanks to Pierce Lopez for the patch.
+- :release:`2.7.0 <2019-12-03>`
+- :feature:`602` (via :issue:`1343`, :issue:`1313`, :issue:`618`) Implement
+ support for OpenSSH 6.5-style private key files (typically denoted as having
+ ``BEGIN OPENSSH PRIVATE KEY`` headers instead of PEM format's ``BEGIN RSA
+ PRIVATE KEY`` or similar). If you were getting any sort of weird auth error
+ from "modern" keys generated on newer operating system releases (such as
+ macOS Mojave), this is the first update to try.
+
+ Major thanks to everyone who contributed or tested versions of the patch,
+ including but not limited to: Kevin Abel, Michiel Tiller, Pierce Lopez, and
+ Jared Hobbs.
+- :bug:`- major` ``ssh_config`` :ref:`token expansion <TOKENS>` used a
+ different method of determining the local username (``$USER`` env var),
+ compared to what the (much older) client connection code does
+ (``getpass.getuser``, which includes ``$USER`` but may check other variables
+ first, and is generally much more comprehensive). Both modules now use
+ ``getpass.getuser``.
+- :feature:`-` A couple of outright `~paramiko.config.SSHConfig` parse errors
+ were previously represented as vanilla ``Exception`` instances; as part of
+ recent feature work a more specific exception class,
+ `~paramiko.ssh_exception.ConfigParseError`, has been created. It is now also
+ used in those older spots, which is naturally backwards compatible.
+- :feature:`717` Implement support for the ``Match`` keyword in ``ssh_config``
+ files. Previously, this keyword was simply ignored & keywords inside such
+ blocks were treated as if they were part of the previous block. Thanks to
+ Michael Leinartas for the initial patchset.
+
+ .. note::
+ This feature adds a new :doc:`optional install dependency </installing>`,
+ `Invoke <https://www.pyinvoke.org>`_, for managing ``Match exec``
+ subprocesses.
+
+- :support:`-` Additional :doc:`installation </installing>` ``extras_require``
+ "flavors" (``ed25519``, ``invoke``, and ``all``) have been added to
+ our packaging metadata; see the install docs for details.
+- :bug:`- major` Paramiko's use of ``subprocess`` for ``ProxyCommand`` support
+ is conditionally imported to prevent issues on limited interpreter platforms
+ like Google Compute Engine. However, any resulting ``ImportError`` was lost
+ instead of preserved for raising (in the rare cases where a user tried
+ leveraging ``ProxyCommand`` in such an environment). This has been fixed.
+- :bug:`- major` Perform deduplication of ``IdentityFile`` contents during
+ ``ssh_config`` parsing; previously, if your config would result in the same
+ value being encountered more than once, ``IdentityFile`` would contain that
+ many copies of the same string.
+- :feature:`897` Implement most 'canonical hostname' ``ssh_config``
+ functionality (``CanonicalizeHostname``, ``CanonicalDomains``,
+ ``CanonicalizeFallbackLocal``, and ``CanonicalizeMaxDots``;
+ ``CanonicalizePermittedCNAMEs`` has **not** yet been implemented). All were
+ previously silently ignored. Reported by Michael Leinartas.
+- :support:`-` Explicitly document :ref:`which ssh_config features we
+ currently support <ssh-config-support>`. Previously users just had to guess,
+ which is simply no good.
+- :feature:`-` Add new convenience classmethod constructors to
+ `~paramiko.config.SSHConfig`: `~paramiko.config.SSHConfig.from_text`,
+ `~paramiko.config.SSHConfig.from_file`, and
+ `~paramiko.config.SSHConfig.from_path`. No more annoying two-step process!
- :release:`2.6.0 <2019-06-23>`
- :feature:`1463` Add a new keyword argument to `SSHClient.connect
<paramiko.client.SSHClient.connect>` and `~paramiko.transport.Transport`,
diff --git a/sites/www/installing.rst b/sites/www/installing.rst
index 2e2f639c..b50efc4b 100644
--- a/sites/www/installing.rst
+++ b/sites/www/installing.rst
@@ -22,15 +22,31 @@ via `pip <http://pip-installer.org>`_::
We currently support **Python 2.7, 3.4+, and PyPy**. Users on Python 2.6 or
older (or 3.3 or older) are urged to upgrade.
-Paramiko has only a few direct dependencies:
+Paramiko has only a few **direct dependencies**:
- The big one, with its own sub-dependencies, is Cryptography; see :ref:`its
- specific note below <cryptography>` for more details.
+ specific note below <cryptography>` for more details;
- `bcrypt <https://pypi.org/project/bcrypt/>`_, for Ed25519 key support;
- `pynacl <https://pypi.org/project/PyNaCl/>`_, also for Ed25519 key support.
-If you need GSS-API / SSPI support, see :ref:`the below subsection on it
-<gssapi>` for details on its optional dependencies.
+There are also a number of **optional dependencies** you may install using
+`setuptools 'extras'
+<https://packaging.python.org/tutorials/installing-packages/#installing-setuptools-extras>`_:
+
+.. TODO 3.0: tweak the invoke line to mention proxycommand too
+.. TODO 3.0: tweak the ed25519 line to remove the caveat
+
+- If you want all optional dependencies at once, use ``paramiko[all]``.
+- For ``Match exec`` config support, use ``paramiko[invoke]`` (which installs
+ `Invoke <https://www.pyinvoke.org>`_).
+- For GSS-API / SSPI support, use ``paramiko[gssapi]``, though also see
+ :ref:`the below subsection on it <gssapi>` for details.
+- ``paramiko[ed25519]`` references the dependencies for Ed25519 key support.
+
+ - As of Paramiko 2.x this doesn't technically do anything, as those
+ dependencies are core installation requirements.
+ - However, you should use this for forwards compatibility; 3.0 will drop
+ those dependencies from core, leaving them purely optional.
.. _release-lines:
@@ -122,6 +138,9 @@ optional Python package requirements by changing your installation to refer to
(Or update your ``requirements.txt``, or etc.)
+
+.. TODO: just axe this once legacy gssapi support is gone, no point reiterating
+
Manual dependency installation
------------------------------
diff --git a/tests/configs/basic b/tests/configs/basic
new file mode 100644
index 00000000..93fe3beb
--- /dev/null
+++ b/tests/configs/basic
@@ -0,0 +1,4 @@
+CanonicalDomains paramiko.org
+
+Host www.paramiko.org
+ User rando
diff --git a/tests/configs/canon b/tests/configs/canon
new file mode 100644
index 00000000..7b979408
--- /dev/null
+++ b/tests/configs/canon
@@ -0,0 +1,8 @@
+CanonicalizeHostname yes
+CanonicalDomains paramiko.org
+
+IdentityFile base.key
+
+Host www.paramiko.org
+ User rando
+ IdentityFile canonicalized.key
diff --git a/tests/configs/canon-always b/tests/configs/canon-always
new file mode 100644
index 00000000..f3f56b70
--- /dev/null
+++ b/tests/configs/canon-always
@@ -0,0 +1,5 @@
+CanonicalDomains paramiko.org
+CanonicalizeHostname always
+
+Host www.paramiko.org
+ User rando
diff --git a/tests/configs/canon-ipv4 b/tests/configs/canon-ipv4
new file mode 100644
index 00000000..92c3875f
--- /dev/null
+++ b/tests/configs/canon-ipv4
@@ -0,0 +1,6 @@
+CanonicalDomains paramiko.org
+CanonicalizeHostname yes
+AddressFamily inet
+
+Host www.paramiko.org
+ User rando
diff --git a/tests/configs/canon-local b/tests/configs/canon-local
new file mode 100644
index 00000000..dde9f77b
--- /dev/null
+++ b/tests/configs/canon-local
@@ -0,0 +1,6 @@
+Host www.paramiko.org
+ User rando
+
+Host www
+ CanonicalDomains paramiko.org
+ CanonicalizeHostname yes
diff --git a/tests/configs/canon-local-always b/tests/configs/canon-local-always
new file mode 100644
index 00000000..0ad0535a
--- /dev/null
+++ b/tests/configs/canon-local-always
@@ -0,0 +1,6 @@
+Host www.paramiko.org
+ User rando
+
+Host www
+ CanonicalDomains paramiko.org
+ CanonicalizeHostname always
diff --git a/tests/configs/deep-canon b/tests/configs/deep-canon
new file mode 100644
index 00000000..483823d5
--- /dev/null
+++ b/tests/configs/deep-canon
@@ -0,0 +1,11 @@
+CanonicalizeHostname yes
+CanonicalDomains paramiko.org
+
+Host www.paramiko.org
+ User rando
+
+Host sub.www.paramiko.org
+ User deep
+
+Host subber.sub.www.paramiko.org
+ User deeper
diff --git a/tests/configs/deep-canon-maxdots b/tests/configs/deep-canon-maxdots
new file mode 100644
index 00000000..7785f660
--- /dev/null
+++ b/tests/configs/deep-canon-maxdots
@@ -0,0 +1,12 @@
+CanonicalizeHostname yes
+CanonicalDomains paramiko.org
+CanonicalizeMaxDots 2
+
+Host www.paramiko.org
+ User rando
+
+Host sub.www.paramiko.org
+ User deep
+
+Host subber.sub.www.paramiko.org
+ User deeper
diff --git a/tests/configs/empty-canon b/tests/configs/empty-canon
new file mode 100644
index 00000000..19743ad6
--- /dev/null
+++ b/tests/configs/empty-canon
@@ -0,0 +1,6 @@
+CanonicalizeHostname yes
+CanonicalDomains
+AddressFamily inet
+
+Host www.paramiko.org
+ User rando
diff --git a/tests/configs/fallback-no b/tests/configs/fallback-no
new file mode 100644
index 00000000..ec8d13ee
--- /dev/null
+++ b/tests/configs/fallback-no
@@ -0,0 +1,6 @@
+CanonicalizeHostname yes
+CanonicalDomains paramiko.org
+CanonicalizeFallbackLocal no
+
+Host www.paramiko.org
+ User rando
diff --git a/tests/configs/fallback-yes b/tests/configs/fallback-yes
new file mode 100644
index 00000000..bc4f4eee
--- /dev/null
+++ b/tests/configs/fallback-yes
@@ -0,0 +1,6 @@
+CanonicalizeHostname yes
+CanonicalDomains paramiko.org
+CanonicalizeFallbackLocal yes
+
+Host www.paramiko.org
+ User rando
diff --git a/tests/configs/hostname-exec-tokenized b/tests/configs/hostname-exec-tokenized
new file mode 100644
index 00000000..1cae2c03
--- /dev/null
+++ b/tests/configs/hostname-exec-tokenized
@@ -0,0 +1,2 @@
+Match exec "ping %h"
+ HostName pingable.%h
diff --git a/tests/configs/hostname-tokenized b/tests/configs/hostname-tokenized
new file mode 100644
index 00000000..1905c0cc
--- /dev/null
+++ b/tests/configs/hostname-tokenized
@@ -0,0 +1 @@
+HostName prefix.%h
diff --git a/tests/configs/invalid b/tests/configs/invalid
new file mode 100644
index 00000000..81332fe8
--- /dev/null
+++ b/tests/configs/invalid
@@ -0,0 +1 @@
+lolwut
diff --git a/tests/configs/match-all b/tests/configs/match-all
new file mode 100644
index 00000000..7673e0a0
--- /dev/null
+++ b/tests/configs/match-all
@@ -0,0 +1,2 @@
+Match all
+ User awesome
diff --git a/tests/configs/match-all-after-canonical b/tests/configs/match-all-after-canonical
new file mode 100644
index 00000000..531112cb
--- /dev/null
+++ b/tests/configs/match-all-after-canonical
@@ -0,0 +1,5 @@
+CanonicalizeHostname yes
+CanonicalDomains paramiko.org
+
+Match canonical all
+ User awesome
diff --git a/tests/configs/match-all-and-more b/tests/configs/match-all-and-more
new file mode 100644
index 00000000..bb50696e
--- /dev/null
+++ b/tests/configs/match-all-and-more
@@ -0,0 +1,2 @@
+Match all exec "lol nope"
+ HostName whatever
diff --git a/tests/configs/match-all-and-more-before b/tests/configs/match-all-and-more-before
new file mode 100644
index 00000000..4d5b2e34
--- /dev/null
+++ b/tests/configs/match-all-and-more-before
@@ -0,0 +1,2 @@
+Match exec "lol nope" all
+ HostName whatever
diff --git a/tests/configs/match-all-before-canonical b/tests/configs/match-all-before-canonical
new file mode 100644
index 00000000..35e3b0e2
--- /dev/null
+++ b/tests/configs/match-all-before-canonical
@@ -0,0 +1,5 @@
+CanonicalizeHostname yes
+CanonicalDomains paramiko.org
+
+Match all canonical
+ User oops
diff --git a/tests/configs/match-canonical-no b/tests/configs/match-canonical-no
new file mode 100644
index 00000000..e528dc64
--- /dev/null
+++ b/tests/configs/match-canonical-no
@@ -0,0 +1,7 @@
+CanonicalizeHostname no
+
+Match canonical all
+ User awesome
+
+Match !canonical host specific
+ User overload
diff --git a/tests/configs/match-canonical-yes b/tests/configs/match-canonical-yes
new file mode 100644
index 00000000..d6c20928
--- /dev/null
+++ b/tests/configs/match-canonical-yes
@@ -0,0 +1,5 @@
+CanonicalizeHostname yes
+CanonicalDomains paramiko.org
+
+Match !canonical host www*
+ User hidden
diff --git a/tests/configs/match-complex b/tests/configs/match-complex
new file mode 100644
index 00000000..63634039
--- /dev/null
+++ b/tests/configs/match-complex
@@ -0,0 +1,17 @@
+HostName bogus
+
+Match originalhost target host bogus
+ User rand
+
+Match originalhost remote localuser rando
+ User calrissian
+
+# Just to set user for subsequent match
+Match originalhost www
+ User calrissian
+
+Match !canonical originalhost www host bogus localuser rando user calrissian
+ Port 7777
+
+Match !canonical !originalhost www host bogus localuser rando !user calrissian
+ Port 1234
diff --git a/tests/configs/match-exec b/tests/configs/match-exec
new file mode 100644
index 00000000..763346ea
--- /dev/null
+++ b/tests/configs/match-exec
@@ -0,0 +1,16 @@
+Match exec "quoted"
+ User benjamin
+
+Match exec unquoted
+ User rando
+
+Match exec "quoted spaced"
+ User neil
+
+# Just to prepopulate values for tokenizing subsequent exec
+Host target
+ User intermediate
+ HostName configured
+
+Match exec "%d %h %L %l %n %p %r %u"
+ Port 1337
diff --git a/tests/configs/match-exec-canonical b/tests/configs/match-exec-canonical
new file mode 100644
index 00000000..794ee9d5
--- /dev/null
+++ b/tests/configs/match-exec-canonical
@@ -0,0 +1,10 @@
+CanonicalDomains paramiko.org
+CanonicalizeHostname always
+
+# This will match in the first, uncanonicalized pass
+Match !canonical exec uncanonicalized
+ User defenseless
+
+# And this will match the second time
+Match canonical exec canonicalized
+ Port 8007
diff --git a/tests/configs/match-exec-negation b/tests/configs/match-exec-negation
new file mode 100644
index 00000000..937c910e
--- /dev/null
+++ b/tests/configs/match-exec-negation
@@ -0,0 +1,5 @@
+Match !exec "this succeeds"
+ User nope
+
+Match !exec "this fails"
+ User yup
diff --git a/tests/configs/match-exec-no-arg b/tests/configs/match-exec-no-arg
new file mode 100644
index 00000000..20c16d16
--- /dev/null
+++ b/tests/configs/match-exec-no-arg
@@ -0,0 +1,2 @@
+Match exec
+ User uh-oh
diff --git a/tests/configs/match-host b/tests/configs/match-host
new file mode 100644
index 00000000..86cbff5d
--- /dev/null
+++ b/tests/configs/match-host
@@ -0,0 +1,2 @@
+Match host target
+ User rand
diff --git a/tests/configs/match-host-canonicalized b/tests/configs/match-host-canonicalized
new file mode 100644
index 00000000..52dadeae
--- /dev/null
+++ b/tests/configs/match-host-canonicalized
@@ -0,0 +1,8 @@
+CanonicalizeHostname yes
+CanonicalDomains paramiko.org
+
+Match host www.paramiko.org
+ User rand
+
+Match canonical host docs.paramiko.org
+ User eric
diff --git a/tests/configs/match-host-from-match b/tests/configs/match-host-from-match
new file mode 100644
index 00000000..172ee116
--- /dev/null
+++ b/tests/configs/match-host-from-match
@@ -0,0 +1,5 @@
+Match host original-host
+ HostName substituted-host
+
+Match host substituted-host
+ User inner
diff --git a/tests/configs/match-host-glob b/tests/configs/match-host-glob
new file mode 100644
index 00000000..3d53cf48
--- /dev/null
+++ b/tests/configs/match-host-glob
@@ -0,0 +1,2 @@
+Match host *ever
+ User matrim
diff --git a/tests/configs/match-host-glob-list b/tests/configs/match-host-glob-list
new file mode 100644
index 00000000..3617d136
--- /dev/null
+++ b/tests/configs/match-host-glob-list
@@ -0,0 +1,8 @@
+Match host *ever
+ User matrim
+
+Match host somehost,someotherhost
+ User thom
+
+Match host goo*,!goof
+ User perrin
diff --git a/tests/configs/match-host-name b/tests/configs/match-host-name
new file mode 100644
index 00000000..783d939e
--- /dev/null
+++ b/tests/configs/match-host-name
@@ -0,0 +1,4 @@
+HostName default-host
+
+Match host default-host
+ User silly
diff --git a/tests/configs/match-host-negated b/tests/configs/match-host-negated
new file mode 100644
index 00000000..7c5d3f3e
--- /dev/null
+++ b/tests/configs/match-host-negated
@@ -0,0 +1,2 @@
+Match !host www
+ User jeff
diff --git a/tests/configs/match-host-no-arg b/tests/configs/match-host-no-arg
new file mode 100644
index 00000000..191cebb5
--- /dev/null
+++ b/tests/configs/match-host-no-arg
@@ -0,0 +1,2 @@
+Match host
+ User oops
diff --git a/tests/configs/match-localuser b/tests/configs/match-localuser
new file mode 100644
index 00000000..fe4a276c
--- /dev/null
+++ b/tests/configs/match-localuser
@@ -0,0 +1,14 @@
+Match localuser gandalf
+ HostName gondor
+
+Match localuser b*
+ HostName shire
+
+Match localuser aragorn,frodo
+ HostName moria
+
+Match localuser gimli,!legolas
+ Port 7373
+
+Match !localuser sauron
+ HostName mordor
diff --git a/tests/configs/match-localuser-no-arg b/tests/configs/match-localuser-no-arg
new file mode 100644
index 00000000..6623553a
--- /dev/null
+++ b/tests/configs/match-localuser-no-arg
@@ -0,0 +1,2 @@
+Match localuser
+ User oops
diff --git a/tests/configs/match-orighost b/tests/configs/match-orighost
new file mode 100644
index 00000000..10541993
--- /dev/null
+++ b/tests/configs/match-orighost
@@ -0,0 +1,16 @@
+HostName bogus
+
+Match originalhost target
+ User tuon
+
+Match originalhost what*
+ User matrim
+
+Match originalhost comma,sep*
+ User chameleon
+
+Match originalhost yep,!nope
+ User skipped
+
+Match !originalhost www !originalhost nope
+ User thom
diff --git a/tests/configs/match-orighost-canonical b/tests/configs/match-orighost-canonical
new file mode 100644
index 00000000..737345e8
--- /dev/null
+++ b/tests/configs/match-orighost-canonical
@@ -0,0 +1,5 @@
+CanonicalizeHostname yes
+CanonicalDomains paramiko.org
+
+Match originalhost www
+ User tuon
diff --git a/tests/configs/match-orighost-no-arg b/tests/configs/match-orighost-no-arg
new file mode 100644
index 00000000..427382ba
--- /dev/null
+++ b/tests/configs/match-orighost-no-arg
@@ -0,0 +1,2 @@
+Match originalhost
+ User oops
diff --git a/tests/configs/match-user b/tests/configs/match-user
new file mode 100644
index 00000000..14d6ac12
--- /dev/null
+++ b/tests/configs/match-user
@@ -0,0 +1,14 @@
+Match user gandalf
+ HostName gondor
+
+Match user b*
+ HostName shire
+
+Match user aragorn,frodo
+ HostName moria
+
+Match user gimli,!legolas
+ Port 7373
+
+Match !user sauron
+ HostName mordor
diff --git a/tests/configs/match-user-explicit b/tests/configs/match-user-explicit
new file mode 100644
index 00000000..9a2b1d82
--- /dev/null
+++ b/tests/configs/match-user-explicit
@@ -0,0 +1,4 @@
+User explicit
+
+Match user explicit
+ HostName dumb
diff --git a/tests/configs/match-user-no-arg b/tests/configs/match-user-no-arg
new file mode 100644
index 00000000..65a11ab4
--- /dev/null
+++ b/tests/configs/match-user-no-arg
@@ -0,0 +1,2 @@
+Match user
+ User oops
diff --git a/tests/configs/multi-canon-domains b/tests/configs/multi-canon-domains
new file mode 100644
index 00000000..5674b442
--- /dev/null
+++ b/tests/configs/multi-canon-domains
@@ -0,0 +1,5 @@
+CanonicalizeHostname yes
+CanonicalDomains not-a-real-tld paramiko.org
+
+Host www.paramiko.org
+ User rando
diff --git a/tests/configs/no-canon b/tests/configs/no-canon
new file mode 100644
index 00000000..033f8c53
--- /dev/null
+++ b/tests/configs/no-canon
@@ -0,0 +1,5 @@
+CanonicalizeHostname no
+CanonicalDomains paramiko.org
+
+Host www.paramiko.org
+ User rando
diff --git a/tests/configs/robey b/tests/configs/robey
new file mode 100644
index 00000000..b2026224
--- /dev/null
+++ b/tests/configs/robey
@@ -0,0 +1,17 @@
+# A timeless classic?
+# NOTE: some lines in here have 'extra' whitespace (incl trailing, and mixed
+# tabs/spaces!) on purpose.
+
+Host *
+ User robey
+ IdentityFile =~/.ssh/id_rsa
+
+# comment
+Host *.example.com
+ User bjork
+Port=3333
+Host *
+ Crazy something dumb
+Host spoo.example.com
+Crazy something else
+
diff --git a/tests/configs/zero-maxdots b/tests/configs/zero-maxdots
new file mode 100644
index 00000000..dc00054c
--- /dev/null
+++ b/tests/configs/zero-maxdots
@@ -0,0 +1,9 @@
+CanonicalizeHostname yes
+CanonicalDomains paramiko.org
+CanonicalizeMaxDots 0
+
+Host www.paramiko.org
+ User rando
+
+Host sub.www.paramiko.org
+ User deep
diff --git a/tests/test_client.py b/tests/test_client.py
index ad5c36ad..60ad310c 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -407,15 +407,15 @@ class SSHClientTest(ClientTest):
self.tc = SSHClient()
self.tc.set_missing_host_key_policy(paramiko.AutoAddPolicy())
- self.assertEqual(0, len(self.tc.get_host_keys()))
+ assert len(self.tc.get_host_keys()) == 0
self.tc.connect(**dict(self.connect_kwargs, password="pygmalion"))
self.event.wait(1.0)
- self.assertTrue(self.event.is_set())
- self.assertTrue(self.ts.is_active())
+ assert self.event.is_set()
+ assert self.ts.is_active()
p = weakref.ref(self.tc._transport.packetizer)
- self.assertTrue(p() is not None)
+ assert p() is not None
self.tc.close()
del self.tc
@@ -425,7 +425,7 @@ class SSHClientTest(ClientTest):
gc.collect()
gc.collect()
- self.assertTrue(p() is None)
+ assert p() is None
def test_client_can_be_used_as_context_manager(self):
"""
diff --git a/tests/test_config.py b/tests/test_config.py
index cbd3f623..5e9aa059 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -1,74 +1,992 @@
# This file is part of Paramiko and subject to the license in /LICENSE in this
# repository
-import pytest
+from os.path import expanduser
+from socket import gaierror
-from paramiko import config
-from paramiko.util import parse_ssh_config
-from paramiko.py3compat import StringIO
+from paramiko.py3compat import string_types
+from invoke import Result
+from mock import patch
+from pytest import raises, mark, fixture
-def test_SSHConfigDict_construct_empty():
- assert not config.SSHConfigDict()
+from paramiko import (
+ SSHConfig,
+ SSHConfigDict,
+ CouldNotCanonicalize,
+ ConfigParseError,
+)
+from .util import _config
-def test_SSHConfigDict_construct_from_list():
- assert config.SSHConfigDict([(1, 2)])[1] == 2
+@fixture
+def socket():
+ """
+ Patch all of socket.* in our config module to prevent eg real DNS lookups.
+
+ Also forces getaddrinfo (used in our addressfamily lookup stuff) to always
+ fail by default to mimic usual lack of AddressFamily related crap.
-def test_SSHConfigDict_construct_from_dict():
- assert config.SSHConfigDict({1: 2})[1] == 2
+ Callers who want to mock DNS lookups can then safely assume gethostbyname()
+ will be in use.
+ """
+ with patch("paramiko.config.socket") as mocket:
+ # Reinstate gaierror as an actual exception and not a sub-mock.
+ # (Presumably this would work with any exception, but why not use the
+ # real one?)
+ mocket.gaierror = gaierror
+ # Patch out getaddrinfo, used to detect family-specific IP lookup -
+ # only useful for a few specific tests.
+ mocket.getaddrinfo.side_effect = mocket.gaierror
+ # Patch out getfqdn to return some real string for when it gets called;
+ # some code (eg tokenization) gets mad w/ MagicMocks
+ mocket.getfqdn.return_value = "some.fake.fqdn"
+ yield mocket
-@pytest.mark.parametrize("true_ish", ("yes", "YES", "Yes", True))
-def test_SSHConfigDict_as_bool_true_ish(true_ish):
- assert config.SSHConfigDict({"key": true_ish}).as_bool("key") is True
+def load_config(name):
+ return SSHConfig.from_path(_config(name))
-@pytest.mark.parametrize("false_ish", ("no", "NO", "No", False))
-def test_SSHConfigDict_as_bool(false_ish):
- assert config.SSHConfigDict({"key": false_ish}).as_bool("key") is False
+class TestSSHConfig(object):
+ def setup(self):
+ self.config = load_config("robey")
+ def test_init(self):
+ # No args!
+ with raises(TypeError):
+ SSHConfig("uh oh!")
+ # No args.
+ assert not SSHConfig()._config
-@pytest.mark.parametrize("int_val", ("42", 42))
-def test_SSHConfigDict_as_int(int_val):
- assert config.SSHConfigDict({"key": int_val}).as_int("key") == 42
+ def test_from_text(self):
+ config = SSHConfig.from_text("User foo")
+ assert config.lookup("foo.example.com")["user"] == "foo"
+ def test_from_file(self):
+ with open(_config("robey")) as flo:
+ config = SSHConfig.from_file(flo)
+ assert config.lookup("whatever")["user"] == "robey"
-@pytest.mark.parametrize("non_int", ("not an int", None, object()))
-def test_SSHConfigDict_as_int_failures(non_int):
- conf = config.SSHConfigDict({"key": non_int})
+ def test_from_path(self):
+ # NOTE: DO NOT replace with use of load_config() :D
+ config = SSHConfig.from_path(_config("robey"))
+ assert config.lookup("meh.example.com")["port"] == "3333"
- try:
- int(non_int)
- except Exception as e:
- exception_type = type(e)
+ def test_parse_config(self):
+ expected = [
+ {"host": ["*"], "config": {}},
+ {
+ "host": ["*"],
+ "config": {"identityfile": ["~/.ssh/id_rsa"], "user": "robey"},
+ },
+ {
+ "host": ["*.example.com"],
+ "config": {"user": "bjork", "port": "3333"},
+ },
+ {"host": ["*"], "config": {"crazy": "something dumb"}},
+ {
+ "host": ["spoo.example.com"],
+ "config": {"crazy": "something else"},
+ },
+ ]
+ assert self.config._config == expected
- with pytest.raises(exception_type):
- conf.as_int("key")
+ @mark.parametrize(
+ "host,values",
+ (
+ (
+ "irc.danger.com",
+ {
+ "crazy": "something dumb",
+ "hostname": "irc.danger.com",
+ "user": "robey",
+ },
+ ),
+ (
+ "irc.example.com",
+ {
+ "crazy": "something dumb",
+ "hostname": "irc.example.com",
+ "user": "robey",
+ "port": "3333",
+ },
+ ),
+ (
+ "spoo.example.com",
+ {
+ "crazy": "something dumb",
+ "hostname": "spoo.example.com",
+ "user": "robey",
+ "port": "3333",
+ },
+ ),
+ ),
+ )
+ def test_host_config(self, host, values):
+ expected = dict(
+ values, hostname=host, identityfile=[expanduser("~/.ssh/id_rsa")]
+ )
+ assert self.config.lookup(host) == expected
+ def test_fabric_issue_33(self):
+ config = SSHConfig.from_text(
+ """
+Host www13.*
+ Port 22
-def test_SSHConfig_host_dicts_are_SSHConfigDict_instances():
- test_config_file = """
Host *.example.com
Port 2222
Host *
Port 3333
- """
- f = StringIO(test_config_file)
- config = parse_ssh_config(f)
- assert config.lookup("foo.example.com").as_int("port") == 2222
+"""
+ )
+ host = "www13.example.com"
+ expected = {"hostname": host, "port": "22"}
+ assert config.lookup(host) == expected
+
+ def test_proxycommand_config_equals_parsing(self):
+ """
+ ProxyCommand should not split on equals signs within the value.
+ """
+ config = SSHConfig.from_text(
+ """
+Host space-delimited
+ ProxyCommand foo bar=biz baz
+
+Host equals-delimited
+ ProxyCommand=foo bar=biz baz
+"""
+ )
+ for host in ("space-delimited", "equals-delimited"):
+ value = config.lookup(host)["proxycommand"]
+ assert value == "foo bar=biz baz"
+
+ def test_proxycommand_interpolation(self):
+ """
+ ProxyCommand should perform interpolation on the value
+ """
+ config = SSHConfig.from_text(
+ """
+Host specific
+ Port 37
+ ProxyCommand host %h port %p lol
+
+Host portonly
+ Port 155
+
+Host *
+ Port 25
+ ProxyCommand host %h port %p
+"""
+ )
+ for host, val in (
+ ("foo.com", "host foo.com port 25"),
+ ("specific", "host specific port 37 lol"),
+ ("portonly", "host portonly port 155"),
+ ):
+ assert config.lookup(host)["proxycommand"] == val
+
+ def test_proxycommand_tilde_expansion(self):
+ """
+ Tilde (~) should be expanded inside ProxyCommand
+ """
+ config = SSHConfig.from_text(
+ """
+Host test
+ ProxyCommand ssh -F ~/.ssh/test_config bastion nc %h %p
+"""
+ )
+ expected = "ssh -F {}/.ssh/test_config bastion nc test 22".format(
+ expanduser("~")
+ )
+ got = config.lookup("test")["proxycommand"]
+ assert got == expected
+
+ @patch("paramiko.config.getpass")
+ def test_controlpath_token_expansion(self, getpass):
+ getpass.getuser.return_value = "gandalf"
+ config = SSHConfig.from_text(
+ """
+Host explicit_user
+ User root
+ ControlPath user %u remoteuser %r
+
+Host explicit_host
+ HostName ohai
+ ControlPath remoteuser %r host %h orighost %n
+ """
+ )
+ result = config.lookup("explicit_user")["controlpath"]
+ # Remote user is User val, local user is User val
+ assert result == "user gandalf remoteuser root"
+ result = config.lookup("explicit_host")["controlpath"]
+ # Remote user falls back to local user; host and orighost may differ
+ assert result == "remoteuser gandalf host ohai orighost explicit_host"
+
+ def test_negation(self):
+ config = SSHConfig.from_text(
+ """
+Host www13.* !*.example.com
+ Port 22
+
+Host *.example.com !www13.*
+ Port 2222
+
+Host www13.*
+ Port 8080
+
+Host *
+ Port 3333
+"""
+ )
+ host = "www13.example.com"
+ expected = {"hostname": host, "port": "8080"}
+ assert config.lookup(host) == expected
+
+ def test_proxycommand(self):
+ config = SSHConfig.from_text(
+ """
+Host proxy-with-equal-divisor-and-space
+ProxyCommand = foo=bar
+
+Host proxy-with-equal-divisor-and-no-space
+ProxyCommand=foo=bar
+
+Host proxy-without-equal-divisor
+ProxyCommand foo=bar:%h-%p
+"""
+ )
+ for host, values in {
+ "proxy-with-equal-divisor-and-space": {
+ "hostname": "proxy-with-equal-divisor-and-space",
+ "proxycommand": "foo=bar",
+ },
+ "proxy-with-equal-divisor-and-no-space": {
+ "hostname": "proxy-with-equal-divisor-and-no-space",
+ "proxycommand": "foo=bar",
+ },
+ "proxy-without-equal-divisor": {
+ "hostname": "proxy-without-equal-divisor",
+ "proxycommand": "foo=bar:proxy-without-equal-divisor-22",
+ },
+ }.items():
+
+ assert config.lookup(host) == values
+
+ def test_identityfile(self):
+ config = SSHConfig.from_text(
+ """
+
+IdentityFile id_dsa0
+
+Host *
+IdentityFile id_dsa1
+
+Host dsa2
+IdentityFile id_dsa2
+
+Host dsa2*
+IdentityFile id_dsa22
+"""
+ )
+ for host, values in {
+ "foo": {"hostname": "foo", "identityfile": ["id_dsa0", "id_dsa1"]},
+ "dsa2": {
+ "hostname": "dsa2",
+ "identityfile": ["id_dsa0", "id_dsa1", "id_dsa2", "id_dsa22"],
+ },
+ "dsa22": {
+ "hostname": "dsa22",
+ "identityfile": ["id_dsa0", "id_dsa1", "id_dsa22"],
+ },
+ }.items():
+
+ assert config.lookup(host) == values
+
+ def test_config_addressfamily_and_lazy_fqdn(self):
+ """
+ Ensure the code path honoring non-'all' AddressFamily doesn't asplode
+ """
+ config = SSHConfig.from_text(
+ """
+AddressFamily inet
+IdentityFile something_%l_using_fqdn
+"""
+ )
+ assert config.lookup(
+ "meh"
+ ) # will die during lookup() if bug regresses
+
+ def test_config_dos_crlf_succeeds(self):
+ config = SSHConfig.from_text(
+ """
+Host abcqwerty\r\nHostName 127.0.0.1\r\n
+"""
+ )
+ assert config.lookup("abcqwerty")["hostname"] == "127.0.0.1"
+
+ def test_get_hostnames(self):
+ expected = {"*", "*.example.com", "spoo.example.com"}
+ assert self.config.get_hostnames() == expected
+
+ def test_quoted_host_names(self):
+ config = SSHConfig.from_text(
+ """
+Host "param pam" param "pam"
+ Port 1111
+
+Host "param2"
+ Port 2222
+
+Host param3 parara
+ Port 3333
+Host param4 "p a r" "p" "par" para
+ Port 4444
+"""
+ )
+ res = {
+ "param pam": {"hostname": "param pam", "port": "1111"},
+ "param": {"hostname": "param", "port": "1111"},
+ "pam": {"hostname": "pam", "port": "1111"},
+ "param2": {"hostname": "param2", "port": "2222"},
+ "param3": {"hostname": "param3", "port": "3333"},
+ "parara": {"hostname": "parara", "port": "3333"},
+ "param4": {"hostname": "param4", "port": "4444"},
+ "p a r": {"hostname": "p a r", "port": "4444"},
+ "p": {"hostname": "p", "port": "4444"},
+ "par": {"hostname": "par", "port": "4444"},
+ "para": {"hostname": "para", "port": "4444"},
+ }
+ for host, values in res.items():
+ assert config.lookup(host) == values
-def test_SSHConfig_wildcard_host_dicts_are_SSHConfigDict_instances():
- test_config_file = """\
+ def test_quoted_params_in_config(self):
+ config = SSHConfig.from_text(
+ """
+Host "param pam" param "pam"
+ IdentityFile id_rsa
+
+Host "param2"
+ IdentityFile "test rsa key"
+
+Host param3 parara
+ IdentityFile id_rsa
+ IdentityFile "test rsa key"
+"""
+ )
+ res = {
+ "param pam": {"hostname": "param pam", "identityfile": ["id_rsa"]},
+ "param": {"hostname": "param", "identityfile": ["id_rsa"]},
+ "pam": {"hostname": "pam", "identityfile": ["id_rsa"]},
+ "param2": {"hostname": "param2", "identityfile": ["test rsa key"]},
+ "param3": {
+ "hostname": "param3",
+ "identityfile": ["id_rsa", "test rsa key"],
+ },
+ "parara": {
+ "hostname": "parara",
+ "identityfile": ["id_rsa", "test rsa key"],
+ },
+ }
+ for host, values in res.items():
+ assert config.lookup(host) == values
+
+ def test_quoted_host_in_config(self):
+ conf = SSHConfig()
+ correct_data = {
+ "param": ["param"],
+ '"param"': ["param"],
+ "param pam": ["param", "pam"],
+ '"param" "pam"': ["param", "pam"],
+ '"param" pam': ["param", "pam"],
+ 'param "pam"': ["param", "pam"],
+ 'param "pam" p': ["param", "pam", "p"],
+ '"param" pam "p"': ["param", "pam", "p"],
+ '"pa ram"': ["pa ram"],
+ '"pa ram" pam': ["pa ram", "pam"],
+ 'param "p a m"': ["param", "p a m"],
+ }
+ incorrect_data = ['param"', '"param', 'param "pam', 'param "pam" "p a']
+ for host, values in correct_data.items():
+ assert conf._get_hosts(host) == values
+ for host in incorrect_data:
+ with raises(ConfigParseError):
+ conf._get_hosts(host)
+
+ def test_invalid_line_format_excepts(self):
+ with raises(ConfigParseError):
+ load_config("invalid")
+
+ def test_proxycommand_none_issue_418(self):
+ config = SSHConfig.from_text(
+ """
+Host proxycommand-standard-none
+ ProxyCommand None
+
+Host proxycommand-with-equals-none
+ ProxyCommand=None
+"""
+ )
+ for host, values in {
+ "proxycommand-standard-none": {
+ "hostname": "proxycommand-standard-none"
+ },
+ "proxycommand-with-equals-none": {
+ "hostname": "proxycommand-with-equals-none"
+ },
+ }.items():
+
+ assert config.lookup(host) == values
+
+ def test_proxycommand_none_masking(self):
+ # Re: https://github.com/paramiko/paramiko/issues/670
+ config = SSHConfig.from_text(
+ """
+Host specific-host
+ ProxyCommand none
+
+Host other-host
+ ProxyCommand other-proxy
+
+Host *
+ ProxyCommand default-proxy
+"""
+ )
+ # When bug is present, the full stripping-out of specific-host's
+ # ProxyCommand means it actually appears to pick up the default
+ # ProxyCommand value instead, due to cascading. It should (for
+ # backwards compatibility reasons in 1.x/2.x) appear completely blank,
+ # as if the host had no ProxyCommand whatsoever.
+ # Threw another unrelated host in there just for sanity reasons.
+ assert "proxycommand" not in config.lookup("specific-host")
+ assert config.lookup("other-host")["proxycommand"] == "other-proxy"
+ cmd = config.lookup("some-random-host")["proxycommand"]
+ assert cmd == "default-proxy"
+
+ def test_hostname_tokenization(self):
+ result = load_config("hostname-tokenized").lookup("whatever")
+ assert result["hostname"] == "prefix.whatever"
+
+
+class TestSSHConfigDict(object):
+ def test_SSHConfigDict_construct_empty(self):
+ assert not SSHConfigDict()
+
+ def test_SSHConfigDict_construct_from_list(self):
+ assert SSHConfigDict([(1, 2)])[1] == 2
+
+ def test_SSHConfigDict_construct_from_dict(self):
+ assert SSHConfigDict({1: 2})[1] == 2
+
+ @mark.parametrize("true_ish", ("yes", "YES", "Yes", True))
+ def test_SSHConfigDict_as_bool_true_ish(self, true_ish):
+ assert SSHConfigDict({"key": true_ish}).as_bool("key") is True
+
+ @mark.parametrize("false_ish", ("no", "NO", "No", False))
+ def test_SSHConfigDict_as_bool(self, false_ish):
+ assert SSHConfigDict({"key": false_ish}).as_bool("key") is False
+
+ @mark.parametrize("int_val", ("42", 42))
+ def test_SSHConfigDict_as_int(self, int_val):
+ assert SSHConfigDict({"key": int_val}).as_int("key") == 42
+
+ @mark.parametrize("non_int", ("not an int", None, object()))
+ def test_SSHConfigDict_as_int_failures(self, non_int):
+ conf = SSHConfigDict({"key": non_int})
+
+ try:
+ int(non_int)
+ except Exception as e:
+ exception_type = type(e)
+
+ with raises(exception_type):
+ conf.as_int("key")
+
+ def test_SSHConfig_host_dicts_are_SSHConfigDict_instances(self):
+ config = SSHConfig.from_text(
+ """
+Host *.example.com
+ Port 2222
+
+Host *
+ Port 3333
+"""
+ )
+ assert config.lookup("foo.example.com").as_int("port") == 2222
+
+ def test_SSHConfig_wildcard_host_dicts_are_SSHConfigDict_instances(self):
+ config = SSHConfig.from_text(
+ """
Host *.example.com
Port 2222
Host *
Port 3333
+"""
+ )
+ assert config.lookup("anything-else").as_int("port") == 3333
+
+
+class TestHostnameCanonicalization(object):
+ # NOTE: this class uses on-disk configs, and ones with real (at time of
+ # writing) DNS names, so that one can easily test OpenSSH's behavior using
+ # "ssh -F path/to/file.config -G <target>".
+
+ def test_off_by_default(self, socket):
+ result = load_config("basic").lookup("www")
+ assert result["hostname"] == "www"
+ assert "user" not in result
+ assert not socket.gethostbyname.called
+
+ def test_explicit_no_same_as_default(self, socket):
+ result = load_config("no-canon").lookup("www")
+ assert result["hostname"] == "www"
+ assert "user" not in result
+ assert not socket.gethostbyname.called
+
+ @mark.parametrize(
+ "config_name",
+ ("canon", "canon-always", "canon-local", "canon-local-always"),
+ )
+ def test_canonicalization_base_cases(self, socket, config_name):
+ result = load_config(config_name).lookup("www")
+ assert result["hostname"] == "www.paramiko.org"
+ assert result["user"] == "rando"
+ socket.gethostbyname.assert_called_once_with("www.paramiko.org")
+
+ def test_uses_getaddrinfo_when_AddressFamily_given(self, socket):
+ # Undo default 'always fails' mock
+ socket.getaddrinfo.side_effect = None
+ socket.getaddrinfo.return_value = [True] # just need 1st value truthy
+ result = load_config("canon-ipv4").lookup("www")
+ assert result["hostname"] == "www.paramiko.org"
+ assert result["user"] == "rando"
+ assert not socket.gethostbyname.called
+ gai_args = socket.getaddrinfo.call_args[0]
+ assert gai_args[0] == "www.paramiko.org"
+ assert gai_args[2] is socket.AF_INET # Mocked, but, still useful
+
+ @mark.skip
+ def test_empty_CanonicalDomains_canonicalizes_despite_noop(self, socket):
+ # Confirmed this is how OpenSSH behaves as well. Bit silly, but.
+ # TODO: this requires modifying SETTINGS_REGEX, which is a mite scary
+ # (honestly I'd prefer to move to a real parser lib anyhow) and since
+ # this is a very dumb corner case, it's marked skip for now.
+ result = load_config("empty-canon").lookup("www")
+ assert result["hostname"] == "www" # no paramiko.org
+ assert "user" not in result # did not discover canonicalized block
+
+ def test_CanonicalDomains_may_be_set_to_space_separated_list(self, socket):
+ # Test config has a bogus domain, followed by paramiko.org
+ socket.gethostbyname.side_effect = [socket.gaierror, True]
+ result = load_config("multi-canon-domains").lookup("www")
+ assert result["hostname"] == "www.paramiko.org"
+ assert result["user"] == "rando"
+ assert [x[0][0] for x in socket.gethostbyname.call_args_list] == [
+ "www.not-a-real-tld",
+ "www.paramiko.org",
+ ]
+
+ def test_canonicalization_applies_to_single_dot_by_default(self, socket):
+ result = load_config("deep-canon").lookup("sub.www")
+ assert result["hostname"] == "sub.www.paramiko.org"
+ assert result["user"] == "deep"
+
+ def test_canonicalization_not_applied_to_two_dots_by_default(self, socket):
+ result = load_config("deep-canon").lookup("subber.sub.www")
+ assert result["hostname"] == "subber.sub.www"
+ assert "user" not in result
+
+ def test_hostname_depth_controllable_with_max_dots_directive(self, socket):
+ # This config sets MaxDots of 2, so now canonicalization occurs
+ result = load_config("deep-canon-maxdots").lookup("subber.sub.www")
+ assert result["hostname"] == "subber.sub.www.paramiko.org"
+ assert result["user"] == "deeper"
+
+ def test_max_dots_may_be_zero(self, socket):
+ result = load_config("zero-maxdots").lookup("sub.www")
+ assert result["hostname"] == "sub.www"
+ assert "user" not in result
+
+ def test_fallback_yes_does_not_canonicalize_or_error(self, socket):
+ socket.gethostbyname.side_effect = socket.gaierror
+ result = load_config("fallback-yes").lookup("www")
+ assert result["hostname"] == "www"
+ assert "user" not in result
+
+ def test_fallback_no_causes_errors_for_unresolvable_names(self, socket):
+ socket.gethostbyname.side_effect = socket.gaierror
+ with raises(CouldNotCanonicalize) as info:
+ load_config("fallback-no").lookup("doesnotexist")
+ assert str(info.value) == "doesnotexist"
+
+ def test_identityfile_continues_being_appended_to(self, socket):
+ result = load_config("canon").lookup("www")
+ assert result["identityfile"] == ["base.key", "canonicalized.key"]
+
+
+@mark.skip
+class TestCanonicalizationOfCNAMEs(object):
+ def test_permitted_cnames_may_be_one_to_one_mapping(self):
+ # CanonicalizePermittedCNAMEs *.foo.com:*.bar.com
+ pass
+
+ def test_permitted_cnames_may_be_one_to_many_mapping(self):
+ # CanonicalizePermittedCNAMEs *.foo.com:*.bar.com,*.biz.com
+ pass
+
+ def test_permitted_cnames_may_be_many_to_one_mapping(self):
+ # CanonicalizePermittedCNAMEs *.foo.com,*.bar.com:*.biz.com
+ pass
+
+ def test_permitted_cnames_may_be_many_to_many_mapping(self):
+ # CanonicalizePermittedCNAMEs *.foo.com,*.bar.com:*.biz.com,*.baz.com
+ pass
+
+ def test_permitted_cnames_may_be_multiple_mappings(self):
+ # CanonicalizePermittedCNAMEs *.foo.com,*.bar.com *.biz.com:*.baz.com
+ pass
+
+ def test_permitted_cnames_may_be_multiple_complex_mappings(self):
+ # Same as prev but with multiple patterns on both ends in both args
+ pass
+
+
+class TestMatchAll(object):
+ def test_always_matches(self):
+ result = load_config("match-all").lookup("general")
+ assert result["user"] == "awesome"
+
+ def test_may_not_mix_with_non_canonical_keywords(self):
+ for config in ("match-all-and-more", "match-all-and-more-before"):
+ with raises(ConfigParseError):
+ load_config(config).lookup("whatever")
+
+ def test_may_come_after_canonical(self, socket):
+ result = load_config("match-all-after-canonical").lookup("www")
+ assert result["user"] == "awesome"
+
+ def test_may_not_come_before_canonical(self, socket):
+ with raises(ConfigParseError):
+ load_config("match-all-before-canonical")
+
+ def test_after_canonical_not_loaded_when_non_canonicalized(self, socket):
+ result = load_config("match-canonical-no").lookup("a-host")
+ assert "user" not in result
+
+
+def _expect(success_on):
+ """
+ Returns a side_effect-friendly Invoke success result for given command(s).
+
+ Ensures that any other commands fail; this is useful for testing 'Match
+ exec' because it means all other such clauses under test act like no-ops.
+
+ :param success_on:
+ Single string or list of strings, noting commands that should appear to
+ succeed.
"""
- f = StringIO(test_config_file)
- config = parse_ssh_config(f)
- assert config.lookup("anything-else").as_int("port") == 3333
+ if isinstance(success_on, string_types):
+ success_on = [success_on]
+
+ def inner(command, *args, **kwargs):
+ # Sanity checking - we always expect that invoke.run is called with
+ # these.
+ assert kwargs.get("hide", None) == "stdout"
+ assert kwargs.get("warn", None) is True
+ # Fake exit
+ exit = 0 if command in success_on else 1
+ return Result(exited=exit)
+
+ return inner
+
+
+class TestMatchExec(object):
+ @patch("paramiko.config.invoke", new=None)
+ @patch("paramiko.config.invoke_import_error", new=ImportError("meh"))
+ def test_raises_invoke_ImportErrors_at_runtime(self):
+ # Not an ideal test, but I don't know of a non-bad way to fake out
+ # module-time ImportErrors. So we mock the symptoms. Meh!
+ with raises(ImportError) as info:
+ load_config("match-exec").lookup("oh-noes")
+ assert str(info.value) == "meh"
+
+ @patch("paramiko.config.invoke.run")
+ @mark.parametrize(
+ "cmd,user",
+ [
+ ("unquoted", "rando"),
+ ("quoted", "benjamin"),
+ ("quoted spaced", "neil"),
+ ],
+ )
+ def test_accepts_single_possibly_quoted_argument(self, run, cmd, user):
+ run.side_effect = _expect(cmd)
+ result = load_config("match-exec").lookup("whatever")
+ assert result["user"] == user
+
+ @patch("paramiko.config.invoke.run")
+ def test_does_not_match_nonzero_exit_codes(self, run):
+ # Nothing will succeed -> no User ever gets loaded
+ run.return_value = Result(exited=1)
+ result = load_config("match-exec").lookup("whatever")
+ assert "user" not in result
+
+ @patch("paramiko.config.getpass")
+ @patch("paramiko.config.invoke.run")
+ def test_tokenizes_argument(self, run, getpass, socket):
+ socket.gethostname.return_value = "local.fqdn"
+ getpass.getuser.return_value = "gandalf"
+ # Actual exec value is "%d %h %L %l %n %p %r %u"
+ parts = (
+ expanduser("~"),
+ "configured",
+ "local",
+ "some.fake.fqdn",
+ "target",
+ "22",
+ "intermediate",
+ "gandalf",
+ )
+ run.side_effect = _expect(" ".join(parts))
+ result = load_config("match-exec").lookup("target")
+ assert result["port"] == "1337"
+
+ @patch("paramiko.config.invoke.run")
+ def test_works_with_canonical(self, run, socket):
+ # Ensure both stanzas' exec components appear to match
+ run.side_effect = _expect(["uncanonicalized", "canonicalized"])
+ result = load_config("match-exec-canonical").lookup("who-cares")
+ # Prove both config values got loaded up, across the two passes
+ assert result["user"] == "defenseless"
+ assert result["port"] == "8007"
+
+ @patch("paramiko.config.invoke.run")
+ def test_may_be_negated(self, run):
+ run.side_effect = _expect("this succeeds")
+ result = load_config("match-exec-negation").lookup("so-confusing")
+ # If negation did not work, the first of the two Match exec directives
+ # would have set User to 'nope' (and/or the second would have NOT set
+ # User to 'yup')
+ assert result["user"] == "yup"
+
+ def test_requires_an_argument(self):
+ with raises(ConfigParseError):
+ load_config("match-exec-no-arg")
+
+ @patch("paramiko.config.invoke.run")
+ def test_works_with_tokenized_hostname(self, run):
+ run.side_effect = _expect("ping target")
+ result = load_config("hostname-exec-tokenized").lookup("target")
+ assert result["hostname"] == "pingable.target"
+
+
+class TestMatchHost(object):
+ def test_matches_target_name_when_no_hostname(self):
+ result = load_config("match-host").lookup("target")
+ assert result["user"] == "rand"
+
+ def test_matches_hostname_from_global_setting(self):
+ # Also works for ones set in regular Host stanzas
+ result = load_config("match-host-name").lookup("anything")
+ assert result["user"] == "silly"
+
+ def test_matches_hostname_from_earlier_match(self):
+ # Corner case: one Match matches original host, sets HostName,
+ # subsequent Match matches the latter.
+ result = load_config("match-host-from-match").lookup("original-host")
+ assert result["user"] == "inner"
+
+ def test_may_be_globbed(self):
+ result = load_config("match-host-glob-list").lookup("whatever")
+ assert result["user"] == "matrim"
+
+ def test_may_be_comma_separated_list(self):
+ for target in ("somehost", "someotherhost"):
+ result = load_config("match-host-glob-list").lookup(target)
+ assert result["user"] == "thom"
+
+ def test_comma_separated_list_may_have_internal_negation(self):
+ conf = load_config("match-host-glob-list")
+ assert conf.lookup("good")["user"] == "perrin"
+ assert "user" not in conf.lookup("goof")
+
+ def test_matches_canonicalized_name(self, socket):
+ # Without 'canonical' explicitly declared, mind.
+ result = load_config("match-host-canonicalized").lookup("www")
+ assert result["user"] == "rand"
+
+ def test_works_with_canonical_keyword(self, socket):
+ # NOTE: distinct from 'happens to be canonicalized' above
+ result = load_config("match-host-canonicalized").lookup("docs")
+ assert result["user"] == "eric"
+
+ def test_may_be_negated(self):
+ conf = load_config("match-host-negated")
+ assert conf.lookup("docs")["user"] == "jeff"
+ assert "user" not in conf.lookup("www")
+
+ def test_requires_an_argument(self):
+ with raises(ConfigParseError):
+ load_config("match-host-no-arg")
+
+
+class TestMatchOriginalHost(object):
+ def test_matches_target_host_not_hostname(self):
+ result = load_config("match-orighost").lookup("target")
+ assert result["hostname"] == "bogus"
+ assert result["user"] == "tuon"
+
+ def test_matches_target_host_not_canonicalized_name(self, socket):
+ result = load_config("match-orighost-canonical").lookup("www")
+ assert result["hostname"] == "www.paramiko.org"
+ assert result["user"] == "tuon"
+
+ def test_may_be_globbed(self):
+ result = load_config("match-orighost").lookup("whatever")
+ assert result["user"] == "matrim"
+
+ def test_may_be_comma_separated_list(self):
+ for target in ("comma", "separated"):
+ result = load_config("match-orighost").lookup(target)
+ assert result["user"] == "chameleon"
+
+ def test_comma_separated_list_may_have_internal_negation(self):
+ result = load_config("match-orighost").lookup("nope")
+ assert "user" not in result
+
+ def test_may_be_negated(self):
+ result = load_config("match-orighost").lookup("docs")
+ assert result["user"] == "thom"
+
+ def test_requires_an_argument(self):
+ with raises(ConfigParseError):
+ load_config("match-orighost-no-arg")
+
+
+class TestMatchUser(object):
+ def test_matches_configured_username(self):
+ result = load_config("match-user-explicit").lookup("anything")
+ assert result["hostname"] == "dumb"
+
+ @patch("paramiko.config.getpass.getuser")
+ def test_matches_local_username_by_default(self, getuser):
+ getuser.return_value = "gandalf"
+ result = load_config("match-user").lookup("anything")
+ assert result["hostname"] == "gondor"
+
+ @patch("paramiko.config.getpass.getuser")
+ def test_may_be_globbed(self, getuser):
+ for user in ("bilbo", "bombadil"):
+ getuser.return_value = user
+ result = load_config("match-user").lookup("anything")
+ assert result["hostname"] == "shire"
+
+ @patch("paramiko.config.getpass.getuser")
+ def test_may_be_comma_separated_list(self, getuser):
+ for user in ("aragorn", "frodo"):
+ getuser.return_value = user
+ result = load_config("match-user").lookup("anything")
+ assert result["hostname"] == "moria"
+
+ @patch("paramiko.config.getpass.getuser")
+ def test_comma_separated_list_may_have_internal_negation(self, getuser):
+ getuser.return_value = "legolas"
+ result = load_config("match-user").lookup("anything")
+ assert "port" not in result
+ getuser.return_value = "gimli"
+ result = load_config("match-user").lookup("anything")
+ assert result["port"] == "7373"
+
+ @patch("paramiko.config.getpass.getuser")
+ def test_may_be_negated(self, getuser):
+ getuser.return_value = "saruman"
+ result = load_config("match-user").lookup("anything")
+ assert result["hostname"] == "mordor"
+
+ def test_requires_an_argument(self):
+ with raises(ConfigParseError):
+ load_config("match-user-no-arg")
+
+
+# NOTE: highly derivative of previous suite due to the former's use of
+# localuser fallback. Doesn't seem worth conflating/refactoring right now.
+class TestMatchLocalUser(object):
+ @patch("paramiko.config.getpass.getuser")
+ def test_matches_local_username(self, getuser):
+ getuser.return_value = "gandalf"
+ result = load_config("match-localuser").lookup("anything")
+ assert result["hostname"] == "gondor"
+
+ @patch("paramiko.config.getpass.getuser")
+ def test_may_be_globbed(self, getuser):
+ for user in ("bilbo", "bombadil"):
+ getuser.return_value = user
+ result = load_config("match-localuser").lookup("anything")
+ assert result["hostname"] == "shire"
+
+ @patch("paramiko.config.getpass.getuser")
+ def test_may_be_comma_separated_list(self, getuser):
+ for user in ("aragorn", "frodo"):
+ getuser.return_value = user
+ result = load_config("match-localuser").lookup("anything")
+ assert result["hostname"] == "moria"
+
+ @patch("paramiko.config.getpass.getuser")
+ def test_comma_separated_list_may_have_internal_negation(self, getuser):
+ getuser.return_value = "legolas"
+ result = load_config("match-localuser").lookup("anything")
+ assert "port" not in result
+ getuser.return_value = "gimli"
+ result = load_config("match-localuser").lookup("anything")
+ assert result["port"] == "7373"
+
+ @patch("paramiko.config.getpass.getuser")
+ def test_may_be_negated(self, getuser):
+ getuser.return_value = "saruman"
+ result = load_config("match-localuser").lookup("anything")
+ assert result["hostname"] == "mordor"
+
+ def test_requires_an_argument(self):
+ with raises(ConfigParseError):
+ load_config("match-localuser-no-arg")
+
+
+class TestComplexMatching(object):
+ # NOTE: this is still a cherry-pick of a few levels of complexity, there's
+ # no point testing literally all possible combinations.
+
+ def test_originalhost_host(self):
+ result = load_config("match-complex").lookup("target")
+ assert result["hostname"] == "bogus"
+ assert result["user"] == "rand"
+
+ @patch("paramiko.config.getpass.getuser")
+ def test_originalhost_localuser(self, getuser):
+ getuser.return_value = "rando"
+ result = load_config("match-complex").lookup("remote")
+ assert result["user"] == "calrissian"
+
+ @patch("paramiko.config.getpass.getuser")
+ def test_everything_but_all(self, getuser):
+ getuser.return_value = "rando"
+ result = load_config("match-complex").lookup("www")
+ assert result["port"] == "7777"
+
+ @patch("paramiko.config.getpass.getuser")
+ def test_everything_but_all_with_some_negated(self, getuser):
+ getuser.return_value = "rando"
+ result = load_config("match-complex").lookup("docs")
+ assert result["port"] == "1234"
+
+ def test_negated_canonical(self, socket):
+ # !canonical in a config that is not canonicalized - does match
+ result = load_config("match-canonical-no").lookup("specific")
+ assert result["user"] == "overload"
+ # !canonical in a config that is canonicalized - does NOT match
+ result = load_config("match-canonical-yes").lookup("www")
+ assert result["user"] == "hidden"
diff --git a/tests/test_dss_openssh.key b/tests/test_dss_openssh.key
new file mode 100644
index 00000000..2a9f8922
--- /dev/null
+++ b/tests/test_dss_openssh.key
@@ -0,0 +1,22 @@
+-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABAsyq4pxL
+R5sOprPDHGpvzxAAAAEAAAAAEAAAGxAAAAB3NzaC1kc3MAAACBAL8XEx7F9xuwBNles+vW
+pNF+YcofrBhjX1r5QhpBe0eoYWLHRcroN6lxwCdGYRfgOoRjTncBiixQX/uUxAY96zDh3i
+r492s2BcJt4ihvNn/AY0I0OTuX/2IwGk9CGzafjaeZNVYxMa8lcVt0hSOTjkPQ7gVuk6bJ
+zMInvie+VWKLAAAAFQDUgYdY+rhR0SkKbC09BS/SIHcB+wAAAIB44+4zpCNcd0CGvZlowH
+99zyPX8uxQtmTLQFuR2O8O0FgVVuCdDgD0D9W8CLOp32oatpM0jyyN89EdvSWzjHzZJ+L6
+H1FtZps7uhpDFWHdva1R25vyGecLMUuXjo5t/D7oCDih+HwHoSAxoi0QvsPd8/qqHQVznN
+JKtR6thUpXEwAAAIAG4DCBjbgTTgpBw0egRkJwBSz0oTt+1IcapNU2jA6N8urMSk9YXHEQ
+HKN68BAF3YJ59q2Ujv3LOXmBqGd1T+kzwUszfMlgzq8MMu19Yfzse6AIK1Agn1Vj6F7YXL
+sXDN+T4KszX5+FJa7t/Zsp3nALWy6l0f4WKivEF5Y2QpEFcQAAAgCH6XUl1hYWB6kgCSHV
+a4C+vQHrgFNgNwEQnE074LXHXlAhxC+Dm8XTGqVPX1KRPWzadq9/+v6pqLFqiRueB86uRb
+J5WtAbUs3WwxAaC5Mi+mn42MBfL9PIwWPWCvstrAq9Nyj3EBMeX3XFLxN3RuGXIQnY/5rF
+f5hriUVxhWDQGIVbBKhkpn7Geqg6nLpn7iqQhzFmFGjPmAdrllgdVGJRLyIN6BRsaltDdy
+vxufkvGzKudvQ85QvsaoFJQ6K1d0S7907pexvxmWpcO7zchXb6i09BITWOAKIcHpVkbNQw
++8pzSdpggsAwCRbfk/Jkezz8sXVUCfmmJ23NFUw04/0ZbilCADRsUaPfafgVPeDznBnuCm
+tfXa4JSrVUvPdwoex3SKZmYsFXwsuOEQnFkhUGHfWwTbmOmxzy6dtC24KYhnWG5OGFVJXh
+3B8jQJGGs2ANfusI/Z0o15tAnQy5fqsLf9TT3RX7RG2ujIiDBsU+A1g//IXmSxxkUOQMZs
+v+cMI8KfODAXmQtB30+yAgoV03Zb/bdptv+HqPT4eeecstJUxzEGYADt1mDq3uV7fQbNmo
+80bppU52JjztrJb7hBmXsXHPRRK6spQ1FCatqvu1ggZeXZpEifNsHeqCljt87ueXsQsORY
+pvhLzjTbTKZmjLDPuB+GxUNLEKh1ZNyAqKng==
+-----END OPENSSH PRIVATE KEY-----
diff --git a/tests/test_ecdsa_384_openssh.key b/tests/test_ecdsa_384_openssh.key
new file mode 100644
index 00000000..8a160ce2
--- /dev/null
+++ b/tests/test_ecdsa_384_openssh.key
@@ -0,0 +1,11 @@
+-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABDwIHkBEZ
+75XuqQS6/7daAIAAAAEAAAAAEAAACIAAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlz
+dHAzODQAAABhBIch5LXTq/L/TWsTGG6dIktxD8DIMh7EfvoRmWsks6CuNDTvFvbQNtY4QO
+1mn5OXegHbS0M5DPIS++wpKGFP3suDEH08O35vZQasLNrL0tO2jyyEnzB2ZEx3PPYci811
+ygAAAOBKGxFl+JcMHjldOdTA9iwv88gxoelCwln/NATglUuyzHMLJwx53n8NLqrnHALvbz
+RHjyTmjU4dbSM9o9Vjhcvq+1aipjAQg2qx825f7T4BMoKyhLBS/qTg7RfyW/h0Sbequ1wl
+PhBfwhv0LUphRFsGdnOgrXWfZqWqxOP1WhJWIh1p+ja5va/Ii/+hD6RORQjvzbHTPJA53c
+OguISImkx0vdqPuFTLyclaC3eO4Px68Ki0b8cdyivExbAWLkNOtBdIAgeO7Egbruu4O5Sn
+I6bn1Kc+kZlWtO02IkwSA5DaKw==
+-----END OPENSSH PRIVATE KEY-----
diff --git a/tests/test_gssapi.py b/tests/test_gssapi.py
index 30ffb56d..308caa93 100644
--- a/tests/test_gssapi.py
+++ b/tests/test_gssapi.py
@@ -204,13 +204,13 @@ class GSSAPITest(KerberosTestCase):
c_token = token[0].Buffer
self.assertNotEquals(0, error)
- def test_2_gssapi_sspi_client(self):
+ def test_gssapi_sspi_client(self):
"""
Test the used methods of python-gssapi or sspi, sspicon from pywin32.
"""
self._gssapi_sspi_test()
- def test_3_gssapi_sspi_server(self):
+ def test_gssapi_sspi_server(self):
"""
Test the used methods of python-gssapi or sspi, sspicon from pywin32.
"""
diff --git a/tests/test_pkey.py b/tests/test_pkey.py
index 0e60126a..18c27bbe 100644
--- a/tests/test_pkey.py
+++ b/tests/test_pkey.py
@@ -29,6 +29,9 @@ from hashlib import md5
from paramiko import RSAKey, DSSKey, ECDSAKey, Ed25519Key, Message, util
from paramiko.py3compat import StringIO, byte_chr, b, bytes, PY2
+from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateNumbers
+from mock import patch
+
from .util import _support
@@ -38,6 +41,11 @@ PUB_DSS = "ssh-dss AAAAB3NzaC1kc3MAAACBAOeBpgNnfRzr/twmAQRu2XwWAp3CFtrVnug6s6fgw
PUB_ECDSA_256 = "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBJSPZm3ZWkvk/Zx8WP+fZRZ5/NBBHnGQwR6uIC6XHGPDIHuWUzIjAwA0bzqkOUffEsbLe+uQgKl5kbc/L8KA/eo=" # noqa
PUB_ECDSA_384 = "ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBBbGibQLW9AAZiGN2hEQxWYYoFaWKwN3PKSaDJSMqmIn1Z9sgRUuw8Y/w502OGvXL/wFk0i2z50l3pWZjD7gfMH7gX5TUiCzwrQkS+Hn1U2S9aF5WJp0NcIzYxXw2r4M2A==" # noqa
PUB_ECDSA_521 = "ecdsa-sha2-nistp521 AAAAE2VjZHNhLXNoYTItbmlzdHA1MjEAAAAIbmlzdHA1MjEAAACFBACaOaFLZGuxa5AW16qj6VLypFbLrEWrt9AZUloCMefxO8bNLjK/O5g0rAVasar1TnyHE9qj4NwzANZASWjQNbc4MAG8vzqezFwLIn/kNyNTsXNfqEko9OgHZknlj2Z79dwTJcRAL4QLcT5aND0EHZLB2fAUDXiWIb2j4rg1mwPlBMiBXA==" # noqa
+PUB_RSA_2K_OPENSSH = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDF+Dpr54DX0WdeTDpNAMdkCWEkl3OXtNgf58qlN1gX572OLBqLf0zT4bHstUEpU3piazph/rSWcUMuBoD46tZ6jiH7H9b9Pem2eYQWaELDDkM+v9BMbEy5rMbFRLol5OtEvPFqneyEAanPOgvd8t3yyhSev9QVusakzJ8j8LGgrA8huYZ+Srnw0shEWLG70KUKCh3rG0QIvA8nfhtUOisr2Gp+F0YxMGb5gwBlQYAYE5l6u1SjZ7hNjyNosjK+wRBFgFFBYVpkZKJgWoK9w4ijFyzMZTucnZMqKOKAjIJvHfKBf2/cEfYxSq1EndqTqjYsd9T7/s2vcn1OH5a0wkER" # noqa
+RSA_2K_OPENSSH_P = 161773687847617758886803946572654778625119997081005961935077336594287351354258259920334554906235187683459069634729972458348855793639393524799865799559575414247668746919721196359908321800753913350455861871582087986355637886875933045224711827701526739934602161222599672381604211130651397331775901258858869418853 # noqa
+RSA_2K_OPENSSH_Q = 154483416325630619558401349033571772244816915504195060221073502923720741119664820208064202825686848103224453777955988437823797692957091438442833606009978046057345917301441832647551208158342812551003395417862260727795454409459089912659057393394458150862012620127030757893820711157099494238156383382454310199869 # noqa
+PUB_DSS_1K_OPENSSH = "ssh-dss AAAAB3NzaC1kc3MAAACBAL8XEx7F9xuwBNles+vWpNF+YcofrBhjX1r5QhpBe0eoYWLHRcroN6lxwCdGYRfgOoRjTncBiixQX/uUxAY96zDh3ir492s2BcJt4ihvNn/AY0I0OTuX/2IwGk9CGzafjaeZNVYxMa8lcVt0hSOTjkPQ7gVuk6bJzMInvie+VWKLAAAAFQDUgYdY+rhR0SkKbC09BS/SIHcB+wAAAIB44+4zpCNcd0CGvZlowH99zyPX8uxQtmTLQFuR2O8O0FgVVuCdDgD0D9W8CLOp32oatpM0jyyN89EdvSWzjHzZJ+L6H1FtZps7uhpDFWHdva1R25vyGecLMUuXjo5t/D7oCDih+HwHoSAxoi0QvsPd8/qqHQVznNJKtR6thUpXEwAAAIAG4DCBjbgTTgpBw0egRkJwBSz0oTt+1IcapNU2jA6N8urMSk9YXHEQHKN68BAF3YJ59q2Ujv3LOXmBqGd1T+kzwUszfMlgzq8MMu19Yfzse6AIK1Agn1Vj6F7YXLsXDN+T4KszX5+FJa7t/Zsp3nALWy6l0f4WKivEF5Y2QpEFcQ==" # noqa
+PUB_EC_384_OPENSSH = "ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBIch5LXTq/L/TWsTGG6dIktxD8DIMh7EfvoRmWsks6CuNDTvFvbQNtY4QO1mn5OXegHbS0M5DPIS++wpKGFP3suDEH08O35vZQasLNrL0tO2jyyEnzB2ZEx3PPYci811yg==" # noqa
FINGER_RSA = "1024 60:73:38:44:cb:51:86:65:7f:de:da:a2:2b:5a:57:d5"
FINGER_DSS = "1024 44:78:f0:b9:a2:3c:c5:18:20:09:ff:75:5b:c1:d2:6c"
@@ -45,6 +53,9 @@ FINGER_ECDSA_256 = "256 25:19:eb:55:e6:a1:47:ff:4f:38:d2:75:6f:a5:d5:60"
FINGER_ECDSA_384 = "384 c1:8d:a0:59:09:47:41:8e:a8:a6:07:01:29:23:b4:65"
FINGER_ECDSA_521 = "521 44:58:22:52:12:33:16:0e:ce:0e:be:2c:7c:7e:cc:1e"
SIGNED_RSA = "20:d7:8a:31:21:cb:f7:92:12:f2:a4:89:37:f5:78:af:e6:16:b6:25:b9:97:3d:a2:cd:5f:ca:20:21:73:4c:ad:34:73:8f:20:77:28:e2:94:15:08:d8:91:40:7a:85:83:bf:18:37:95:dc:54:1a:9b:88:29:6c:73:ca:38:b4:04:f1:56:b9:f2:42:9d:52:1b:29:29:b4:4f:fd:c9:2d:af:47:d2:40:76:30:f3:63:45:0c:d9:1d:43:86:0f:1c:70:e2:93:12:34:f3:ac:c5:0a:2f:14:50:66:59:f1:88:ee:c1:4a:e9:d1:9c:4e:46:f0:0e:47:6f:38:74:f1:44:a8" # noqa
+FINGER_RSA_2K_OPENSSH = "2048 68:d1:72:01:bf:c0:0c:66:97:78:df:ce:75:74:46:d6"
+FINGER_DSS_1K_OPENSSH = "1024 cf:1d:eb:d7:61:d3:12:94:c6:c0:c6:54:35:35:b0:82"
+FINGER_EC_384_OPENSSH = "384 72:14:df:c1:9a:c3:e6:0e:11:29:d6:32:18:7b:ea:9b"
RSA_PRIVATE_OUT = """\
-----BEGIN RSA PRIVATE KEY-----
@@ -437,6 +448,54 @@ class KeyTest(unittest.TestCase):
pub = ECDSAKey(data=key.asbytes())
self.assertTrue(pub.verify_ssh_sig(b"ice weasels", msg))
+ def test_load_openssh_format_RSA_key(self):
+ key = RSAKey.from_private_key_file(
+ _support("test_rsa_openssh.key"), b"television"
+ )
+ self.assertEqual("ssh-rsa", key.get_name())
+ self.assertEqual(PUB_RSA_2K_OPENSSH.split()[1], key.get_base64())
+ self.assertEqual(2048, key.get_bits())
+ exp_rsa = b(FINGER_RSA_2K_OPENSSH.split()[1].replace(":", ""))
+ my_rsa = hexlify(key.get_fingerprint())
+ self.assertEqual(exp_rsa, my_rsa)
+
+ def test_loading_openssh_RSA_keys_uses_correct_p_q(self):
+ # Re #1723 - not the most elegant test but given how deep it is...
+ with patch(
+ "paramiko.rsakey.rsa.RSAPrivateNumbers", wraps=RSAPrivateNumbers
+ ) as spy:
+ # Load key
+ RSAKey.from_private_key_file(
+ _support("test_rsa_openssh.key"), b"television"
+ )
+ # Ensure spy saw the correct P and Q values as derived from
+ # hardcoded test private key value
+ kwargs = spy.call_args[1]
+ assert kwargs["p"] == RSA_2K_OPENSSH_P
+ assert kwargs["q"] == RSA_2K_OPENSSH_Q
+
+ def test_load_openssh_format_DSS_key(self):
+ key = DSSKey.from_private_key_file(
+ _support("test_dss_openssh.key"), b"television"
+ )
+ self.assertEqual("ssh-dss", key.get_name())
+ self.assertEqual(PUB_DSS_1K_OPENSSH.split()[1], key.get_base64())
+ self.assertEqual(1024, key.get_bits())
+ exp_rsa = b(FINGER_DSS_1K_OPENSSH.split()[1].replace(":", ""))
+ my_rsa = hexlify(key.get_fingerprint())
+ self.assertEqual(exp_rsa, my_rsa)
+
+ def test_load_openssh_format_EC_key(self):
+ key = ECDSAKey.from_private_key_file(
+ _support("test_ecdsa_384_openssh.key"), b"television"
+ )
+ self.assertEqual("ecdsa-sha2-nistp384", key.get_name())
+ self.assertEqual(PUB_EC_384_OPENSSH.split()[1], key.get_base64())
+ self.assertEqual(384, key.get_bits())
+ exp_fp = b(FINGER_EC_384_OPENSSH.split()[1].replace(":", ""))
+ my_fp = hexlify(key.get_fingerprint())
+ self.assertEqual(exp_fp, my_fp)
+
def test_salt_size(self):
# Read an existing encrypted private key
file_ = _support("test_rsa_password.key")
@@ -455,6 +514,10 @@ class KeyTest(unittest.TestCase):
finally:
os.remove(newfile)
+ def test_load_openssh_format_RSA_nopad(self):
+ # check just not exploding with 'Invalid key'
+ RSAKey.from_private_key_file(_support("test_rsa_openssh_nopad.key"))
+
def test_stringification(self):
key = RSAKey.from_private_key_file(_support("test_rsa.key"))
comparable = TEST_KEY_BYTESTR_2 if PY2 else TEST_KEY_BYTESTR_3
diff --git a/tests/test_proxy.py b/tests/test_proxy.py
new file mode 100644
index 00000000..2e0b0e51
--- /dev/null
+++ b/tests/test_proxy.py
@@ -0,0 +1,144 @@
+import signal
+import socket
+
+from mock import patch
+from pytest import raises
+
+from paramiko import ProxyCommand, ProxyCommandFailure
+
+
+class TestProxyCommand(object):
+ @patch("paramiko.proxy.subprocess")
+ def test_init_takes_command_string(self, subprocess):
+ ProxyCommand(command_line="do a thing")
+ subprocess.Popen.assert_called_once_with(
+ ["do", "a", "thing"],
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ bufsize=0,
+ )
+
+ @patch("paramiko.proxy.subprocess.Popen")
+ def test_send_writes_to_process_stdin_returning_length(self, Popen):
+ proxy = ProxyCommand("hi")
+ written = proxy.send(b"data")
+ Popen.return_value.stdin.write.assert_called_once_with(b"data")
+ assert written == len(b"data")
+
+ @patch("paramiko.proxy.subprocess.Popen")
+ def test_send_raises_ProxyCommandFailure_on_error(self, Popen):
+ Popen.return_value.stdin.write.side_effect = IOError(0, "whoops")
+ with raises(ProxyCommandFailure) as info:
+ ProxyCommand("hi").send("data")
+ assert info.value.command == "hi"
+ assert info.value.error == "whoops"
+
+ @patch("paramiko.proxy.subprocess.Popen")
+ @patch("paramiko.proxy.os.read")
+ @patch("paramiko.proxy.select")
+ def test_recv_reads_from_process_stdout_returning_bytes(
+ self, select, os_read, Popen
+ ):
+ stdout = Popen.return_value.stdout
+ select.return_value = [stdout], None, None
+ fileno = stdout.fileno.return_value
+ # Intentionally returning <5 at a time sometimes
+ os_read.side_effect = [b"was", b"te", b"of ti", b"me"]
+ proxy = ProxyCommand("hi")
+ data = proxy.recv(5)
+ assert data == b"waste"
+ assert [x[0] for x in os_read.call_args_list] == [
+ (fileno, 5),
+ (fileno, 2),
+ ]
+
+ @patch("paramiko.proxy.subprocess.Popen")
+ @patch("paramiko.proxy.os.read")
+ @patch("paramiko.proxy.select")
+ def test_recv_returns_buffer_on_timeout_if_any_read(
+ self, select, os_read, Popen
+ ):
+ stdout = Popen.return_value.stdout
+ select.return_value = [stdout], None, None
+ fileno = stdout.fileno.return_value
+ os_read.side_effect = [b"was", socket.timeout]
+ proxy = ProxyCommand("hi")
+ data = proxy.recv(5)
+ assert data == b"was" # not b"waste"
+ assert os_read.call_args[0] == (fileno, 2)
+
+ @patch("paramiko.proxy.subprocess.Popen")
+ @patch("paramiko.proxy.os.read")
+ @patch("paramiko.proxy.select")
+ def test_recv_raises_timeout_if_nothing_read(self, select, os_read, Popen):
+ stdout = Popen.return_value.stdout
+ select.return_value = [stdout], None, None
+ fileno = stdout.fileno.return_value
+ os_read.side_effect = socket.timeout
+ proxy = ProxyCommand("hi")
+ with raises(socket.timeout):
+ proxy.recv(5)
+ assert os_read.call_args[0] == (fileno, 5)
+
+ @patch("paramiko.proxy.subprocess.Popen")
+ @patch("paramiko.proxy.os.read")
+ @patch("paramiko.proxy.select")
+ def test_recv_raises_ProxyCommandFailure_on_non_timeout_error(
+ self, select, os_read, Popen
+ ):
+ select.return_value = [Popen.return_value.stdout], None, None
+ os_read.side_effect = IOError(0, "whoops")
+ with raises(ProxyCommandFailure) as info:
+ ProxyCommand("hi").recv(5)
+ assert info.value.command == "hi"
+ assert info.value.error == "whoops"
+
+ @patch("paramiko.proxy.subprocess.Popen")
+ @patch("paramiko.proxy.os.kill")
+ def test_close_kills_subprocess(self, os_kill, Popen):
+ proxy = ProxyCommand("hi")
+ proxy.close()
+ os_kill.assert_called_once_with(Popen.return_value.pid, signal.SIGTERM)
+
+ @patch("paramiko.proxy.subprocess.Popen")
+ def test_closed_exposes_whether_subprocess_has_exited(self, Popen):
+ proxy = ProxyCommand("hi")
+ Popen.return_value.returncode = None
+ assert proxy.closed is False
+ assert proxy._closed is False
+ Popen.return_value.returncode = 0
+ assert proxy.closed is True
+ assert proxy._closed is True
+
+ @patch("paramiko.proxy.time.time")
+ @patch("paramiko.proxy.subprocess.Popen")
+ @patch("paramiko.proxy.os.read")
+ @patch("paramiko.proxy.select")
+ def test_timeout_affects_whether_timeout_is_raised(
+ self, select, os_read, Popen, time
+ ):
+ stdout = Popen.return_value.stdout
+ select.return_value = [stdout], None, None
+ # Base case: None timeout means no timing out
+ os_read.return_value = b"meh"
+ proxy = ProxyCommand("yello")
+ assert proxy.timeout is None
+ # Implicit 'no raise' check
+ assert proxy.recv(3) == b"meh"
+ # Use settimeout to set timeout, and it is honored
+ time.side_effect = [0, 10] # elapsed > 7
+ proxy = ProxyCommand("ohnoz")
+ proxy.settimeout(7)
+ assert proxy.timeout == 7
+ with raises(socket.timeout):
+ proxy.recv(3)
+
+ @patch("paramiko.proxy.subprocess", new=None)
+ @patch("paramiko.proxy.subprocess_import_error", new=ImportError("meh"))
+ def test_raises_subprocess_ImportErrors_at_runtime(self):
+ # Not an ideal test, but I don't know of a non-bad way to fake out
+ # module-time ImportErrors. So we mock the symptoms. Meh!
+ with raises(ImportError) as info:
+ ProxyCommand("hi!!!")
+ assert str(info.value) == "meh"
diff --git a/tests/test_rsa_openssh.key b/tests/test_rsa_openssh.key
new file mode 100644
index 00000000..6077c103
--- /dev/null
+++ b/tests/test_rsa_openssh.key
@@ -0,0 +1,28 @@
+-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jYmMAAAAGYmNyeXB0AAAAGAAAABD0R3hOFS
+FMb2SJeo5h8QPNAAAAEAAAAAEAAAEXAAAAB3NzaC1yc2EAAAADAQABAAABAQDF+Dpr54DX
+0WdeTDpNAMdkCWEkl3OXtNgf58qlN1gX572OLBqLf0zT4bHstUEpU3piazph/rSWcUMuBo
+D46tZ6jiH7H9b9Pem2eYQWaELDDkM+v9BMbEy5rMbFRLol5OtEvPFqneyEAanPOgvd8t3y
+yhSev9QVusakzJ8j8LGgrA8huYZ+Srnw0shEWLG70KUKCh3rG0QIvA8nfhtUOisr2Gp+F0
+YxMGb5gwBlQYAYE5l6u1SjZ7hNjyNosjK+wRBFgFFBYVpkZKJgWoK9w4ijFyzMZTucnZMq
+KOKAjIJvHfKBf2/cEfYxSq1EndqTqjYsd9T7/s2vcn1OH5a0wkERAAAD0JnzCJYfDeiUQ6
+9LOAb6/NnhKvFjCdBYal60MfLcLBHvzHLJvTneQ4f1Vknq8xEVmRba7SDSfwaEybP/1FsP
+SGH6FNKA5gKllemgmcaUVr3wtNPtjX4WgsyHcwCRgHmOiyNrUj0OZR5wbZabHIIyirl4wa
+LBz8Jb3GalKEagtyWsBKDCKHCFNzh8xmsT1SWhnC7baRyC8e3krQm9hGbNhpj6Q5AtN3ql
+wBVamUp0eKxkt70mKBKI4v3DR8KqrEndeK6d0cegVEkE67fqa99a5J3uSDC8mglKrHiKEs
+dU1dh/bOF/H3aFpINlRwvlZ95Opby7rG0BHgbZONq0+VUnABVzNTM5Xd5UKjjCF28CrQBf
+XS6WeHeUx2zHtOmL1xdePk+Bii+SSUl3pLa4SDwX4nV95cSPx8vMm8dJEruxad6+MPoSuy
+Oyho89jqUTSgC/RPejuTgrnB3WbzE5SJb+V3zMata0J1wxbNfYKG9U+VucUZhP4+jzfNqH
+B/v8JqtuxnqR8NjPsK2+8wJxebL2KVNjKOm//6P3KSDsavpscGpVWOM06zUlwWCB26W3pP
+X/+xO9aR5wiBteFKoJG1waziIjqhOJSmvq+I/texUKEUd/eEFNt10Ubc0zy0sRYVN8rIRJ
+masQzCYuUylDzCa4ar1s4qngBZzWL2PRkPuXuhoHuT0J5no174GR6+6EAYZZhnq0tkYrhZ
+Ar0tQ4CDlI235a3MPHzvABuwYuWys1tBuLAb+6Gc6CmCiQ+mhojfQUBYG5T65iRFA5UQsH
+O1RLEC3yasxGcBI6d0J/fwOP/YLktNu3AeUumr0N9Xgf02DlBNwd+4GOI0LcQvl/3J8ppo
+bamTppKPEZ2d32VNEO+Z6Zx5DlIVm5gDeMvIvdwap445VnhL3ZZH2NCkAcXM9+0WH+Quas
+JCAMgPYiP9FzF+8Onmj2OmhgIVj/9eanhS3/GLrRC4xCvER2V7PwgB0I5qY110BPEttDyo
+IvYE51kvtdW447SK7HZywJnkyw2RNm+29dvWJJwSQckUHuZkXEtmEPk0ePL3yf2NH5XYJc
+pXX6Zac0KemCPIHr8l7GogE4Rb2BBTqddkegb9piz6QTAPcQnn+GuMFG06IBhUrgcMEQ8x
+UOXYUUrT5HvSxWUcgaYH1nfC3bTWmDaodw8/HQKyF6c44rujO2s2NLFOCAyQMUNdhh3lfD
+yHYLO7xYkP6xzzkpk+2lwBoeYdQdAwlKN/XqC8ZhBfwTdem/1hh1BpQJFbbFftWxU8gxxi
+iuI+vmlsuIsxKoGCq8YXuophx62lo=
+-----END OPENSSH PRIVATE KEY-----
diff --git a/tests/test_rsa_openssh_nopad.key b/tests/test_rsa_openssh_nopad.key
new file mode 100644
index 00000000..61ac1b19
--- /dev/null
+++ b/tests/test_rsa_openssh_nopad.key
@@ -0,0 +1,27 @@
+-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn
+NhAAAAAwEAAQAAAQEAnyMwWSwrbJxxQZWMJO5xR6eAA9De4t3GViqDRaQt/BgsvzZ14SUz
+aOL/A370fKxhx/JLIOOGA0o5B0/ct+CL7XFqMi5r5+iA9VcIeYKKtoAkrEvRnagNW0WVWx
+thTnE01g8Pb7fDqzI2cBuBNZ2vGNm2m4UTGC8/kl/0ES1V3KqA7lPlTrkTYg9L/ornvVHc
+c8gEbMwx9XXVRzbWiuDE176ojrudY9CZduVSOgW+HK3rKkqLBs/91jv0zUK0oqTQBLR7E2
+V2GWPDU4BjlHTtYr0jpKOGDr1DLu4+NiD/mX+tGMdH6ehbDii0kXmOUaZjs4OxuK3XA/gi
+KZLdj1jQQwAAA7iNnvAVjZ7wFQAAAAdzc2gtcnNhAAABAQCfIzBZLCtsnHFBlYwk7nFHp4
+AD0N7i3cZWKoNFpC38GCy/NnXhJTNo4v8DfvR8rGHH8ksg44YDSjkHT9y34IvtcWoyLmvn
+6ID1Vwh5goq2gCSsS9GdqA1bRZVbG2FOcTTWDw9vt8OrMjZwG4E1na8Y2babhRMYLz+SX/
+QRLVXcqoDuU+VOuRNiD0v+iue9UdxzyARszDH1ddVHNtaK4MTXvqiOu51j0Jl25VI6Bb4c
+resqSosGz/3WO/TNQrSipNAEtHsTZXYZY8NTgGOUdO1ivSOko4YOvUMu7j42IP+Zf60Yx0
+fp6FsOKLSReY5RpmOzg7G4rdcD+CIpkt2PWNBDAAAAAwEAAQAAAQEAnmMbn+VCYxth7fC2
+R5u6y6J+201sSUiKOwCdHxdFXX+CKd4+fRPVkzM6tXQKSnwX5jXVaKqLm4KoOArYl3q6Sl
+1zYParF2plz8oL+URgYzwvQ/1CaDP29zzOZptdwgESoWrj5kF0UlPrsrDtbTvAJm+qPCe6
+1XtRPpKaDO6eYr0PM2QTElZy3mDBUBvu816LdG/ZtnB9g5UsocT5mmhpHTHdjrpwNu5TBe
+ACVodDn5Fu66OlrrnQi4IPCAWKJ1YuzEkZqLhs1L3oMHACsmzrLjzW74SjY4kWTTvGiC6i
+tDoycycThk9EGLGNso99Q1fe84/OZUff7aI3yK9KvLL7oQAAAIEAh2+XrJXSBx/v9E3aJH
+ncgQH1snXr7LcSRqcWicHdbm8JsOTT3TkyXHGlSZ2rr/Y0u5V1ZSO6roJLrAHsDJzx0x0U
+xE/5mpzhD+yIKQwnWkZFLzYEnYDFdXDMzmghUIik9AW7n9dtS8UtVFGaL6Vs2YCOuLqeT9
+nZUkm3UUZ+7QIAAACBAM23DFjQ0/Op2ri7fJA2qFBdXqoJdNHuyYEIrKbB6XaaSUz52+IB
+MbccxEz3vPsHh69tZoJ+xZNbFJe9wdmbF+DQpoukHkJnzpk/pUq8LjQMzZfwv41X8zqaq4
+AOA7g27Rk8aKewhCXjhkr0hHEaSiuqIIindFaFti5sQMi2mtkXAAAAgQDGCXkpuKZK61p9
+L6G5yZSQBCgVtm0iQEbyDXWHjy/GqLtxJjqdyaRK57hXGjbzgJJraSy+sNP9uv2QOvyZvB
+3XaPWwUYVQ34WyibCqqUaPiHxX7T1lZV+asbwgbmSqYtH5dUEJ8zT572mCwxnRjX63PwDo
+5vBbR/qAW5lvRYsltQAAAAFh
+-----END OPENSSH PRIVATE KEY-----
diff --git a/tests/test_util.py b/tests/test_util.py
index 465e75b8..8ce260d1 100644
--- a/tests/test_util.py
+++ b/tests/test_util.py
@@ -28,32 +28,10 @@ import unittest
import paramiko
import paramiko.util
-from paramiko import SSHConfig
-from paramiko.util import lookup_ssh_host_config as host_config, safe_string
-from paramiko.py3compat import StringIO, byte_ord
+from paramiko.util import safe_string
+from paramiko.py3compat import byte_ord
-# Note some lines in this configuration have trailing spaces on purpose
-test_config_file = """\
-Host *
- User robey
- IdentityFile =~/.ssh/id_rsa
-
-# comment
-Host *.example.com
- \tUser bjork
-Port=3333
-Host *
-"""
-
-dont_strip_whitespace_please = "\t \t Crazy something dumb "
-
-test_config_file += dont_strip_whitespace_please
-test_config_file += """
-Host spoo.example.com
-Crazy something else
-"""
-
test_hosts_file = """\
secure.example.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAIEA1PD6U2/TVxET6lkpKhOk5r\
9q/kAYG6sP9f5zuUYP8i7FOFp/6ncCEbbtg/lB+A3iidyxoSWl+9jtoyyDOOVX4UIDV9G11Ml8om3\
@@ -65,150 +43,74 @@ BGQ3GQ/Fc7SX6gkpXkwcZryoi4kNFhHu5LvHcZPdxXV1D+uTMfGS1eyd2Yz/DoNWXNAl8TI0cAsW\
class UtilTest(unittest.TestCase):
- def test_import(self):
+ def test_imports(self):
"""
verify that all the classes can be imported from paramiko.
"""
- symbols = paramiko.__all__
- self.assertTrue("Transport" in symbols)
- self.assertTrue("SSHClient" in symbols)
- self.assertTrue("MissingHostKeyPolicy" in symbols)
- self.assertTrue("AutoAddPolicy" in symbols)
- self.assertTrue("RejectPolicy" in symbols)
- self.assertTrue("WarningPolicy" in symbols)
- self.assertTrue("SecurityOptions" in symbols)
- self.assertTrue("SubsystemHandler" in symbols)
- self.assertTrue("Channel" in symbols)
- self.assertTrue("RSAKey" in symbols)
- self.assertTrue("DSSKey" in symbols)
- self.assertTrue("Message" in symbols)
- self.assertTrue("SSHException" in symbols)
- self.assertTrue("AuthenticationException" in symbols)
- self.assertTrue("PasswordRequiredException" in symbols)
- self.assertTrue("BadAuthenticationType" in symbols)
- self.assertTrue("ChannelException" in symbols)
- self.assertTrue("SFTP" in symbols)
- self.assertTrue("SFTPFile" in symbols)
- self.assertTrue("SFTPHandle" in symbols)
- self.assertTrue("SFTPClient" in symbols)
- self.assertTrue("SFTPServer" in symbols)
- self.assertTrue("SFTPError" in symbols)
- self.assertTrue("SFTPAttributes" in symbols)
- self.assertTrue("SFTPServerInterface" in symbols)
- self.assertTrue("ServerInterface" in symbols)
- self.assertTrue("BufferedFile" in symbols)
- self.assertTrue("Agent" in symbols)
- self.assertTrue("AgentKey" in symbols)
- self.assertTrue("HostKeys" in symbols)
- self.assertTrue("SSHConfig" in symbols)
- self.assertTrue("util" in symbols)
-
- def test_parse_config(self):
- global test_config_file
- f = StringIO(test_config_file)
- config = paramiko.util.parse_ssh_config(f)
- self.assertEqual(
- config._config,
- [
- {"host": ["*"], "config": {}},
- {
- "host": ["*"],
- "config": {
- "identityfile": ["~/.ssh/id_rsa"],
- "user": "robey",
- },
- },
- {
- "host": ["*.example.com"],
- "config": {"user": "bjork", "port": "3333"},
- },
- {"host": ["*"], "config": {"crazy": "something dumb"}},
- {
- "host": ["spoo.example.com"],
- "config": {"crazy": "something else"},
- },
- ],
- )
-
- def test_host_config(self):
- global test_config_file
- f = StringIO(test_config_file)
- config = paramiko.util.parse_ssh_config(f)
-
- for host, values in {
- "irc.danger.com": {
- "crazy": "something dumb",
- "hostname": "irc.danger.com",
- "user": "robey",
- },
- "irc.example.com": {
- "crazy": "something dumb",
- "hostname": "irc.example.com",
- "user": "robey",
- "port": "3333",
- },
- "spoo.example.com": {
- "crazy": "something dumb",
- "hostname": "spoo.example.com",
- "user": "robey",
- "port": "3333",
- },
- }.items():
- values = dict(
- values,
- hostname=host,
- identityfile=[os.path.expanduser("~/.ssh/id_rsa")],
- )
- self.assertEqual(
- paramiko.util.lookup_ssh_host_config(host, config), values
- )
+ for name in (
+ "Agent",
+ "AgentKey",
+ "AuthenticationException",
+ "AutoAddPolicy",
+ "BadAuthenticationType",
+ "BufferedFile",
+ "Channel",
+ "ChannelException",
+ "ConfigParseError",
+ "CouldNotCanonicalize",
+ "DSSKey",
+ "HostKeys",
+ "Message",
+ "MissingHostKeyPolicy",
+ "PasswordRequiredException",
+ "RSAKey",
+ "RejectPolicy",
+ "SFTP",
+ "SFTPAttributes",
+ "SFTPClient",
+ "SFTPError",
+ "SFTPFile",
+ "SFTPHandle",
+ "SFTPServer",
+ "SFTPServerInterface",
+ "SSHClient",
+ "SSHConfig",
+ "SSHConfigDict",
+ "SSHException",
+ "SecurityOptions",
+ "ServerInterface",
+ "SubsystemHandler",
+ "Transport",
+ "WarningPolicy",
+ "util",
+ ):
+ assert name in paramiko.__all__
def test_generate_key_bytes(self):
x = paramiko.util.generate_key_bytes(
sha1, b"ABCDEFGH", "This is my secret passphrase.", 64
)
hex = "".join(["%02x" % byte_ord(c) for c in x])
- self.assertEqual(
- hex,
- "9110e2f6793b69363e58173e9436b13a5a4b339005741d5c680e505f57d871347b4239f14fb5c46e857d5e100424873ba849ac699cea98d729e57b3e84378e8b", # noqa
- )
+ hexpected = "9110e2f6793b69363e58173e9436b13a5a4b339005741d5c680e505f57d871347b4239f14fb5c46e857d5e100424873ba849ac699cea98d729e57b3e84378e8b" # noqa
+ assert hex == hexpected
def test_host_keys(self):
with open("hostfile.temp", "w") as f:
f.write(test_hosts_file)
try:
hostdict = paramiko.util.load_host_keys("hostfile.temp")
- self.assertEqual(2, len(hostdict))
- self.assertEqual(1, len(list(hostdict.values())[0]))
- self.assertEqual(1, len(list(hostdict.values())[1]))
+ assert 2 == len(hostdict)
+ assert 1 == len(list(hostdict.values())[0])
+ assert 1 == len(list(hostdict.values())[1])
fp = hexlify(
hostdict["secure.example.com"]["ssh-rsa"].get_fingerprint()
).upper()
- self.assertEqual(b"E6684DB30E109B67B70FF1DC5C7F1363", fp)
+ assert b"E6684DB30E109B67B70FF1DC5C7F1363" == fp
finally:
os.unlink("hostfile.temp")
- def test_host_config_expose_issue_33(self):
- test_config_file = """
-Host www13.*
- Port 22
-
-Host *.example.com
- Port 2222
-
-Host *
- Port 3333
- """
- f = StringIO(test_config_file)
- config = paramiko.util.parse_ssh_config(f)
- host = "www13.example.com"
- self.assertEqual(
- paramiko.util.lookup_ssh_host_config(host, config),
- {"hostname": host, "port": "22"},
- )
-
def test_eintr_retry(self):
- self.assertEqual("foo", paramiko.util.retry_on_signal(lambda: "foo"))
+ assert "foo" == paramiko.util.retry_on_signal(lambda: "foo")
# Variables that are set by raises_intr
intr_errors_remaining = [3]
@@ -221,8 +123,8 @@ Host *
raise IOError(errno.EINTR, "file", "interrupted system call")
self.assertTrue(paramiko.util.retry_on_signal(raises_intr) is None)
- self.assertEqual(0, intr_errors_remaining[0])
- self.assertEqual(4, call_count[0])
+ assert 0 == intr_errors_remaining[0]
+ assert 4 == call_count[0]
def raises_ioerror_not_eintr():
raise IOError(errno.ENOENT, "file", "file not found")
@@ -240,269 +142,10 @@ Host *
lambda: paramiko.util.retry_on_signal(raises_other_exception),
)
- def test_proxycommand_config_equals_parsing(self):
- """
- ProxyCommand should not split on equals signs within the value.
- """
- conf = """
-Host space-delimited
- ProxyCommand foo bar=biz baz
-
-Host equals-delimited
- ProxyCommand=foo bar=biz baz
-"""
- f = StringIO(conf)
- config = paramiko.util.parse_ssh_config(f)
- for host in ("space-delimited", "equals-delimited"):
- self.assertEqual(
- host_config(host, config)["proxycommand"], "foo bar=biz baz"
- )
-
- def test_proxycommand_interpolation(self):
- """
- ProxyCommand should perform interpolation on the value
- """
- config = paramiko.util.parse_ssh_config(
- StringIO(
- """
-Host specific
- Port 37
- ProxyCommand host %h port %p lol
-
-Host portonly
- Port 155
-
-Host *
- Port 25
- ProxyCommand host %h port %p
-"""
- )
- )
- for host, val in (
- ("foo.com", "host foo.com port 25"),
- ("specific", "host specific port 37 lol"),
- ("portonly", "host portonly port 155"),
- ):
- self.assertEqual(host_config(host, config)["proxycommand"], val)
-
- def test_proxycommand_tilde_expansion(self):
- """
- Tilde (~) should be expanded inside ProxyCommand
- """
- config = paramiko.util.parse_ssh_config(
- StringIO(
- """
-Host test
- ProxyCommand ssh -F ~/.ssh/test_config bastion nc %h %p
-"""
- )
- )
- self.assertEqual(
- "ssh -F %s/.ssh/test_config bastion nc test 22"
- % os.path.expanduser("~"),
- host_config("test", config)["proxycommand"],
- )
-
- def test_host_config_test_negation(self):
- test_config_file = """
-Host www13.* !*.example.com
- Port 22
-
-Host *.example.com !www13.*
- Port 2222
-
-Host www13.*
- Port 8080
-
-Host *
- Port 3333
- """
- f = StringIO(test_config_file)
- config = paramiko.util.parse_ssh_config(f)
- host = "www13.example.com"
- self.assertEqual(
- paramiko.util.lookup_ssh_host_config(host, config),
- {"hostname": host, "port": "8080"},
- )
-
- def test_host_config_test_proxycommand(self):
- test_config_file = """
-Host proxy-with-equal-divisor-and-space
-ProxyCommand = foo=bar
-
-Host proxy-with-equal-divisor-and-no-space
-ProxyCommand=foo=bar
-
-Host proxy-without-equal-divisor
-ProxyCommand foo=bar:%h-%p
- """
- for host, values in {
- "proxy-with-equal-divisor-and-space": {
- "hostname": "proxy-with-equal-divisor-and-space",
- "proxycommand": "foo=bar",
- },
- "proxy-with-equal-divisor-and-no-space": {
- "hostname": "proxy-with-equal-divisor-and-no-space",
- "proxycommand": "foo=bar",
- },
- "proxy-without-equal-divisor": {
- "hostname": "proxy-without-equal-divisor",
- "proxycommand": "foo=bar:proxy-without-equal-divisor-22",
- },
- }.items():
-
- f = StringIO(test_config_file)
- config = paramiko.util.parse_ssh_config(f)
- self.assertEqual(
- paramiko.util.lookup_ssh_host_config(host, config), values
- )
-
- def test_host_config_test_identityfile(self):
- test_config_file = """
-
-IdentityFile id_dsa0
-
-Host *
-IdentityFile id_dsa1
-
-Host dsa2
-IdentityFile id_dsa2
-
-Host dsa2*
-IdentityFile id_dsa22
- """
- for host, values in {
- "foo": {"hostname": "foo", "identityfile": ["id_dsa0", "id_dsa1"]},
- "dsa2": {
- "hostname": "dsa2",
- "identityfile": ["id_dsa0", "id_dsa1", "id_dsa2", "id_dsa22"],
- },
- "dsa22": {
- "hostname": "dsa22",
- "identityfile": ["id_dsa0", "id_dsa1", "id_dsa22"],
- },
- }.items():
-
- f = StringIO(test_config_file)
- config = paramiko.util.parse_ssh_config(f)
- self.assertEqual(
- paramiko.util.lookup_ssh_host_config(host, config), values
- )
-
- def test_config_addressfamily_and_lazy_fqdn(self):
- """
- Ensure the code path honoring non-'all' AddressFamily doesn't asplode
- """
- test_config = """
-AddressFamily inet
-IdentityFile something_%l_using_fqdn
-"""
- config = paramiko.util.parse_ssh_config(StringIO(test_config))
- assert config.lookup(
- "meh"
- ) # will die during lookup() if bug regresses
-
def test_clamp_value(self):
- self.assertEqual(32768, paramiko.util.clamp_value(32767, 32768, 32769))
- self.assertEqual(32767, paramiko.util.clamp_value(32767, 32765, 32769))
- self.assertEqual(32769, paramiko.util.clamp_value(32767, 32770, 32769))
-
- def test_config_dos_crlf_succeeds(self):
- config_file = StringIO("host abcqwerty\r\nHostName 127.0.0.1\r\n")
- config = paramiko.SSHConfig()
- config.parse(config_file)
- self.assertEqual(config.lookup("abcqwerty")["hostname"], "127.0.0.1")
-
- def test_get_hostnames(self):
- f = StringIO(test_config_file)
- config = paramiko.util.parse_ssh_config(f)
- self.assertEqual(
- config.get_hostnames(), {"*", "*.example.com", "spoo.example.com"}
- )
-
- def test_quoted_host_names(self):
- test_config_file = """\
-Host "param pam" param "pam"
- Port 1111
-
-Host "param2"
- Port 2222
-
-Host param3 parara
- Port 3333
-
-Host param4 "p a r" "p" "par" para
- Port 4444
-"""
- res = {
- "param pam": {"hostname": "param pam", "port": "1111"},
- "param": {"hostname": "param", "port": "1111"},
- "pam": {"hostname": "pam", "port": "1111"},
- "param2": {"hostname": "param2", "port": "2222"},
- "param3": {"hostname": "param3", "port": "3333"},
- "parara": {"hostname": "parara", "port": "3333"},
- "param4": {"hostname": "param4", "port": "4444"},
- "p a r": {"hostname": "p a r", "port": "4444"},
- "p": {"hostname": "p", "port": "4444"},
- "par": {"hostname": "par", "port": "4444"},
- "para": {"hostname": "para", "port": "4444"},
- }
- f = StringIO(test_config_file)
- config = paramiko.util.parse_ssh_config(f)
- for host, values in res.items():
- assert paramiko.util.lookup_ssh_host_config(host, config) == values
-
- def test_quoted_params_in_config(self):
- test_config_file = """\
-Host "param pam" param "pam"
- IdentityFile id_rsa
-
-Host "param2"
- IdentityFile "test rsa key"
-
-Host param3 parara
- IdentityFile id_rsa
- IdentityFile "test rsa key"
-"""
- res = {
- "param pam": {"hostname": "param pam", "identityfile": ["id_rsa"]},
- "param": {"hostname": "param", "identityfile": ["id_rsa"]},
- "pam": {"hostname": "pam", "identityfile": ["id_rsa"]},
- "param2": {"hostname": "param2", "identityfile": ["test rsa key"]},
- "param3": {
- "hostname": "param3",
- "identityfile": ["id_rsa", "test rsa key"],
- },
- "parara": {
- "hostname": "parara",
- "identityfile": ["id_rsa", "test rsa key"],
- },
- }
- f = StringIO(test_config_file)
- config = paramiko.util.parse_ssh_config(f)
- for host, values in res.items():
- assert paramiko.util.lookup_ssh_host_config(host, config) == values
-
- def test_quoted_host_in_config(self):
- conf = SSHConfig()
- correct_data = {
- "param": ["param"],
- '"param"': ["param"],
- "param pam": ["param", "pam"],
- '"param" "pam"': ["param", "pam"],
- '"param" pam': ["param", "pam"],
- 'param "pam"': ["param", "pam"],
- 'param "pam" p': ["param", "pam", "p"],
- '"param" pam "p"': ["param", "pam", "p"],
- '"pa ram"': ["pa ram"],
- '"pa ram" pam': ["pa ram", "pam"],
- 'param "p a m"': ["param", "p a m"],
- }
- incorrect_data = ['param"', '"param', 'param "pam', 'param "pam" "p a']
- for host, values in correct_data.items():
- assert conf._get_hosts(host) == values
- for host in incorrect_data:
- self.assertRaises(Exception, conf._get_hosts, host)
+ assert 32768 == paramiko.util.clamp_value(32767, 32768, 32769)
+ assert 32767 == paramiko.util.clamp_value(32767, 32765, 32769)
+ assert 32769 == paramiko.util.clamp_value(32767, 32770, 32769)
def test_safe_string(self):
vanilla = b"vanilla"
@@ -515,54 +158,3 @@ Host param3 parara
assert safe_vanilla == vanilla, msg
msg = err.format(safe_has_bytes, expected_bytes)
assert safe_has_bytes == expected_bytes, msg
-
- def test_proxycommand_none_issue_418(self):
- test_config_file = """
-Host proxycommand-standard-none
- ProxyCommand None
-
-Host proxycommand-with-equals-none
- ProxyCommand=None
- """
- for host, values in {
- "proxycommand-standard-none": {
- "hostname": "proxycommand-standard-none"
- },
- "proxycommand-with-equals-none": {
- "hostname": "proxycommand-with-equals-none"
- },
- }.items():
-
- f = StringIO(test_config_file)
- config = paramiko.util.parse_ssh_config(f)
- self.assertEqual(
- paramiko.util.lookup_ssh_host_config(host, config), values
- )
-
- def test_proxycommand_none_masking(self):
- # Re: https://github.com/paramiko/paramiko/issues/670
- source_config = """
-Host specific-host
- ProxyCommand none
-
-Host other-host
- ProxyCommand other-proxy
-
-Host *
- ProxyCommand default-proxy
-"""
- config = paramiko.SSHConfig()
- config.parse(StringIO(source_config))
- # When bug is present, the full stripping-out of specific-host's
- # ProxyCommand means it actually appears to pick up the default
- # ProxyCommand value instead, due to cascading. It should (for
- # backwards compatibility reasons in 1.x/2.x) appear completely blank,
- # as if the host had no ProxyCommand whatsoever.
- # Threw another unrelated host in there just for sanity reasons.
- self.assertFalse("proxycommand" in config.lookup("specific-host"))
- self.assertEqual(
- config.lookup("other-host")["proxycommand"], "other-proxy"
- )
- self.assertEqual(
- config.lookup("some-random-host")["proxycommand"], "default-proxy"
- )
diff --git a/tests/util.py b/tests/util.py
index cdc835c9..9057f516 100644
--- a/tests/util.py
+++ b/tests/util.py
@@ -9,8 +9,15 @@ from paramiko.py3compat import builtins
from paramiko.ssh_gss import GSS_AUTH_AVAILABLE
+tests_dir = dirname(realpath(__file__))
+
+
def _support(filename):
- return join(dirname(realpath(__file__)), filename)
+ return join(tests_dir, filename)
+
+
+def _config(name):
+ return join(tests_dir, "configs", name)
needs_gssapi = pytest.mark.skipif(