diff --git a/hgitaly/ssh.py b/hgitaly/ssh.py new file mode 100644 index 0000000000000000000000000000000000000000..52dd4c16a45ee476bd5984314e7a684192a71a7c_aGdpdGFseS9zc2gucHk= --- /dev/null +++ b/hgitaly/ssh.py @@ -0,0 +1,41 @@ +# Copyright 2021 Georges Racinet <georges.racinet@octobus.net> +# +# This software may be used and distributed according to the terms of the +# GNU General Public License version 2 or any later version. +# +# SPDX-License-Identifier: GPL-2.0-or-later +from contextlib import contextmanager +from tempfile import NamedTemporaryFile + + +@contextmanager +def client_command(priv_key, known_hosts): + """Context manager providing a suitable ssh client command. + + The command references temporary files that get cleaned up + automatically when exiting the context. + + :param str priv_key: non-encrypted private key file content. + """ + with ( + NamedTemporaryFile(mode='w', delete=False) as keyf, + NamedTemporaryFile(mode='w', delete=False) as known_hostsf, + ): + keyf.write(priv_key) + keyf.close() + known_hostsf.write(known_hosts) + known_hostsf.close() + + yield ['ssh', + '-o', 'IdentitiesOnly=yes', + '-o', 'UserKnownHostsFile=%s' % known_hostsf.name, + # do not prompt user if server key is unknown + '-o', 'StrictHostKeyChecking=yes', + '-i', keyf.name + ] + + +@contextmanager +def client_command_str(*args, **kwargs): + with client_command(*args, **kwargs) as cmd: + yield ' '.join(cmd) diff --git a/hgitaly/testing/__init__.py b/hgitaly/testing/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..52dd4c16a45ee476bd5984314e7a684192a71a7c_aGdpdGFseS90ZXN0aW5nL19faW5pdF9fLnB5 --- /dev/null +++ b/hgitaly/testing/__init__.py @@ -0,0 +1,9 @@ +# Copyright 2021 Georges Racinet <georges.racinet@octobus.net> +# +# This software may be used and distributed according to the terms of the +# GNU General Public License version 2 or any later version. +# +# SPDX-License-Identifier: GPL-2.0-or-later +import pathlib + +TEST_DATA_DIR = pathlib.Path(__file__).parent / 'data' diff --git a/hgitaly/testing/data/authorized_keys b/hgitaly/testing/data/authorized_keys new file mode 100644 index 0000000000000000000000000000000000000000..52dd4c16a45ee476bd5984314e7a684192a71a7c_aGdpdGFseS90ZXN0aW5nL2RhdGEvYXV0aG9yaXplZF9rZXlz --- /dev/null +++ b/hgitaly/testing/data/authorized_keys @@ -0,0 +1,1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC0OxPfeeWo979RwTz5iMLfZttkWQHBCP0LZKNkEtu4gfDLzcg+z7crJSVdPTbY3k6ThqMicQUxPFHM77bb+EvQjkIE03WpehSutXfIYiSVkECw7MZpCB+/VbZTRClI7lQEnrKeoxiQABylArtWtyH7yCIMeHT2gkbpmbgoEtTJxiPl3t5ccvSblqqLQGhuNQ5iaOzvpB8lFZjBHa8jbOAHTDm8jgRL/m9AKKcynHiKb4WAb4KozI/I0Bu5uS6dW7oGxmM1g0boGOd9l52ZYXDnRvBvnEzyEAjEMtArm9c1L79ugNmcdOs1jUWqfYHEBkBHKqDpbU88uqeN/WPTvPTYLmn5pNhSbRFuEymbIjRnEVnJsfQhCNfFiqY7bfMvBrXarGm9mwJMLZIiPm07qmApgGxp1a58ME9PIiU2r+9gLesxCtTuHRfCFhomny6psaaOBl6rasOhYITMuPVov1u5WULDKctMAL9s4570hWUawNZzHOsLnEZiDeqbGYU1Yl8= hgitaly-tests@localhost diff --git a/hgitaly/testing/data/id_ecdsa_user b/hgitaly/testing/data/id_ecdsa_user new file mode 100644 index 0000000000000000000000000000000000000000..52dd4c16a45ee476bd5984314e7a684192a71a7c_aGdpdGFseS90ZXN0aW5nL2RhdGEvaWRfZWNkc2FfdXNlcg== --- /dev/null +++ b/hgitaly/testing/data/id_ecdsa_user @@ -0,0 +1,38 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +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 +-----END OPENSSH PRIVATE KEY----- diff --git a/hgitaly/testing/data/known_hosts b/hgitaly/testing/data/known_hosts new file mode 100644 index 0000000000000000000000000000000000000000..52dd4c16a45ee476bd5984314e7a684192a71a7c_aGdpdGFseS90ZXN0aW5nL2RhdGEva25vd25faG9zdHM= --- /dev/null +++ b/hgitaly/testing/data/known_hosts @@ -0,0 +1,1 @@ +[{host}]:{port} ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBK9JxbS6PkBHi4W08qmDyTx7l8ahR5ftxnokWR+Aa1GvuQLKiezakYKSs8iwF1jLQMEI5LazUOdHiPyfuh2OzY0= diff --git a/hgitaly/testing/data/ssh_host_ecdsa_key b/hgitaly/testing/data/ssh_host_ecdsa_key new file mode 100644 index 0000000000000000000000000000000000000000..52dd4c16a45ee476bd5984314e7a684192a71a7c_aGdpdGFseS90ZXN0aW5nL2RhdGEvc3NoX2hvc3RfZWNkc2Ffa2V5 --- /dev/null +++ b/hgitaly/testing/data/ssh_host_ecdsa_key @@ -0,0 +1,9 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS +1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSvScW0uj5AR4uFtPKpg8k8e5fGoUeX +7cZ6JFkfgGtRr7kCyons2pGCkrPIsBdYy0DBCOS2s1DnR4j8n7odjs2NAAAAsBYr1JMWK9 +STAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBK9JxbS6PkBHi4W0 +8qmDyTx7l8ahR5ftxnokWR+Aa1GvuQLKiezakYKSs8iwF1jLQMEI5LazUOdHiPyfuh2OzY +0AAAAgTJswdWy+Yi/En5Fj+IJzZNUk+DHsgc54PDNqrW5JvbUAAAASZ3JhY2luZXRAbXV0 +YXRpb25zAQIDBAUG +-----END OPENSSH PRIVATE KEY----- diff --git a/hgitaly/testing/data/ssh_host_ecdsa_key.pub b/hgitaly/testing/data/ssh_host_ecdsa_key.pub new file mode 100644 index 0000000000000000000000000000000000000000..52dd4c16a45ee476bd5984314e7a684192a71a7c_aGdpdGFseS90ZXN0aW5nL2RhdGEvc3NoX2hvc3RfZWNkc2Ffa2V5LnB1Yg== --- /dev/null +++ b/hgitaly/testing/data/ssh_host_ecdsa_key.pub @@ -0,0 +1,1 @@ +ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBK9JxbS6PkBHi4W08qmDyTx7l8ahR5ftxnokWR+Aa1GvuQLKiezakYKSs8iwF1jLQMEI5LazUOdHiPyfuh2OzY0= gracinet@mutations diff --git a/hgitaly/testing/data/sshd_config b/hgitaly/testing/data/sshd_config new file mode 100644 index 0000000000000000000000000000000000000000..52dd4c16a45ee476bd5984314e7a684192a71a7c_aGdpdGFseS90ZXN0aW5nL2RhdGEvc3NoZF9jb25maWc= --- /dev/null +++ b/hgitaly/testing/data/sshd_config @@ -0,0 +1,17 @@ +AllowUsers {user} +AuthenticationMethods publickey +#HostKey /home/gracinet/heptapod/hdk/dev-0.27/openssh/ssh_host_rsa_key +ListenAddress {host}:{port} +LogLevel VERBOSE +PasswordAuthentication no +PidFile none +Protocol 2 +StrictModes no +UseLogin no +UsePAM no +UsePrivilegeSeparation no + +AcceptEnv GIT_PROTOCOL + +Match User {user} +AuthorizedKeysFile {authorized_keys_path} diff --git a/hgitaly/testing/ssh.py b/hgitaly/testing/ssh.py new file mode 100644 index 0000000000000000000000000000000000000000..52dd4c16a45ee476bd5984314e7a684192a71a7c_aGdpdGFseS90ZXN0aW5nL3NzaC5weQ== --- /dev/null +++ b/hgitaly/testing/ssh.py @@ -0,0 +1,31 @@ +# Copyright 2021 Georges Racinet <georges.racinet@octobus.net> +# +# This software may be used and distributed according to the terms of the +# GNU General Public License version 2 or any later version. +# +# SPDX-License-Identifier: GPL-2.0-or-later +import os +from pathlib import Path +import sys + + +def hg_exe_path(): # pragma no cover + """Return a Mercurial executable path consistent with these tests. + + The executable should be consistent with the `mercurial` package imported + in the tests. + + Most of these tests don't need to invoke Mercurial subprocesses, but + some will have to (SSH push). In that case, we need a path to a version + that is equivalent enough. A simple heuristic to achieve that is to try and + return the path to the Mercurial executable that was installed with the + `mercurial` package (e.g. from the same virtualenv etc.). + + If that is not enough, the `HGITALY_TESTS_HG_EXE_PATH` environment + variable can be set to something suitable. + """ + from_env = os.environ.get('HGITALY_TESTS_HG_EXE_PATH') + if from_env: + return from_env + + return str(Path(sys.executable).parent / 'hg') diff --git a/hgitaly/testing/sshd.py b/hgitaly/testing/sshd.py new file mode 100644 index 0000000000000000000000000000000000000000..52dd4c16a45ee476bd5984314e7a684192a71a7c_aGdpdGFseS90ZXN0aW5nL3NzaGQucHk= --- /dev/null +++ b/hgitaly/testing/sshd.py @@ -0,0 +1,109 @@ +# Copyright 2021 Georges Racinet <georges.racinet@octobus.net> +# +# This software may be used and distributed according to the terms of the +# GNU General Public License version 2 or any later version. +# +# SPDX-License-Identifier: GPL-2.0-or-later +import attr +import os +import pwd +import socket +import subprocess + +from . import TEST_DATA_DIR + + +@attr.s +class SSHServer: + host_pub_key_path = attr.ib() + host_priv_key_path = attr.ib() + config_template_path = attr.ib() + working_dir = attr.ib() + authorized_keys_path = attr.ib() + host_ipv4 = attr.ib(default='127.0.0.1') + """IPv4 local address for the server to bind to. + + Domain names and IPv6 addresses are not supported (not even `::1`). + + Forcing the communication to be over IPv4 is lame, but it is the most + reliable current way: + - only one type of address avoids lots of complications which are not + relevant for these tests. + - there are contexts where IPv6 is not available, whereas those where + the IPv4 loopback is not are excessively rare. + """ + port = attr.ib(default=None) + sshd_exe_path = attr.ib(default='/usr/sbin/sshd') + initialized = False + + def init(self): + self.user_name = pwd.getpwuid(os.getuid()).pw_name + self.init_address() + self.host = self.host_ipv4 + self.init_config() + self.initialized = True + + def init_address(self): # pragma no cover (depends on tests invocation) + """Determine the port for the server to bind to. + + For best reliability in controlled environment such as CI, + the port is read from the `HGITALY_TESTS_SSHD_PORT` environment + variable. + + Otherwise, for developer convenience, we determine an available port. + This is inherently racy, as the port may well not be available any + more when the SSH server actually starts. + """ + from_env = os.environ.get('HGITALY_TESTS_SSHD_PORT') + if from_env is not None: + self.port = int(from_env) + else: + sock = socket.create_server((self.host_ipv4, 0), + family=socket.AF_INET) + self.port = sock.getsockname()[1] + sock.close() + + def init_config(self): + config_path = self.sshd_config_path = self.working_dir / 'sshd_config' + config_path.write_text( + self.config_template_path.read_text().format( + user=self.user_name, + host=self.host, + port=self.port, + authorized_keys_path=self.authorized_keys_path), + encoding='ascii') + + perms = 0o600 # not tracked by hg, won't look as a file modification + self.host_pub_key_path.chmod(perms) + self.host_priv_key_path.chmod(perms) + + def start(self): + self.process = subprocess.Popen((self.sshd_exe_path, + '-h', self.host_priv_key_path, + '-c', self.host_pub_key_path, + '-f', self.sshd_config_path, + '-D', '-e', + ), + ) + + def stop(self): + self.process.terminate() + self.process.wait() + + +def hg_ssh_setup(working_dir): + server = SSHServer( + host_pub_key_path=TEST_DATA_DIR / 'ssh_host_ecdsa_key.pub', + host_priv_key_path=TEST_DATA_DIR / 'ssh_host_ecdsa_key', + config_template_path=TEST_DATA_DIR / 'sshd_config', + authorized_keys_path=TEST_DATA_DIR / 'authorized_keys', + working_dir=working_dir, + ) + server.init() + client_key = (TEST_DATA_DIR / 'id_ecdsa_user').read_text() + known_hosts = (TEST_DATA_DIR / 'known_hosts').read_text().format( + host=server.host, port=server.port) + server.start() + yield server, client_key, known_hosts + + server.stop() diff --git a/hgitaly/testing/tests/__init__.py b/hgitaly/testing/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..52dd4c16a45ee476bd5984314e7a684192a71a7c_aGdpdGFseS90ZXN0aW5nL3Rlc3RzL19faW5pdF9fLnB5 --- /dev/null +++ b/hgitaly/testing/tests/__init__.py @@ -0,0 +1,6 @@ +# Copyright 2021 Georges Racinet <georges.racinet@octobus.net> +# +# This software may be used and distributed according to the terms of the +# GNU General Public License version 2 or any later version. +# +# SPDX-License-Identifier: GPL-2.0-or-later diff --git a/hgitaly/testing/tests/test_sshd.py b/hgitaly/testing/tests/test_sshd.py new file mode 100644 index 0000000000000000000000000000000000000000..52dd4c16a45ee476bd5984314e7a684192a71a7c_aGdpdGFseS90ZXN0aW5nL3Rlc3RzL3Rlc3Rfc3NoZC5weQ== --- /dev/null +++ b/hgitaly/testing/tests/test_sshd.py @@ -0,0 +1,34 @@ +# Copyright 2021 Georges Racinet <georges.racinet@octobus.net> +# +# This software may be used and distributed according to the terms of the +# GNU General Public License version 2 or any later version. +# +# SPDX-License-Identifier: GPL-2.0-or-later +import pytest + +from mercurial_testhelpers.repo_wrapper import RepoWrapper +from ..sshd import hg_ssh_setup +from hgitaly.ssh import client_command_str +from hgitaly.testing.ssh import hg_exe_path + + +@pytest.fixture +def ssh_setup(tmpdir): + working_dir = tmpdir / 'working' + working_dir.mkdir() + yield from hg_ssh_setup(working_dir) + + +def test_ssh_server(ssh_setup, tmpdir): + server, client_key, known_hosts = ssh_setup + src = RepoWrapper.init(tmpdir / 'src') + dest = RepoWrapper.init(tmpdir / 'dst') + ctx = src.commit_file('foo') + + with client_command_str(client_key, known_hosts) as ssh_cmd: + src.command('push', + f'ssh://{server.host}:{server.port}/{dest.path}'.encode(), + ssh=ssh_cmd.encode(), + remotecmd=hg_exe_path().encode(), + ) + assert ctx in dest.repo