diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..70bd07c --- /dev/null +++ b/.gitignore @@ -0,0 +1,60 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ +.venv/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..554d60e --- /dev/null +++ b/.travis.yml @@ -0,0 +1,11 @@ +language: python +python: + - "2.7" + - "3.3" + - "3.4" + - "3.5" +sudo: false # use container-based infrastructure (pre-requisite for caching) +install: + - pip install -e .[dev] +script: + py.test --cov=tmuxpair.py --doctest-modules tmuxpair.py test_tmuxpair.py diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c1a40e7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Michael Goerz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e58fd79 --- /dev/null +++ b/Makefile @@ -0,0 +1,72 @@ +PROJECT_NAME = tmuxpair +PACKAGES = pip pytest coverage +TESTPYPI = https://testpypi.python.org/pypi + +TESTOPTIONS = -v -s -x --doctest-modules --cov=tmuxpair.py +TESTS = tmuxpair.py test_tmuxpair.py + + +install: + pip install . + +develop: + pip install -e .[dev] + +uninstall: + pip uninstall $(PROJECT_NAME) + +upload: + python setup.py register + python setup.py sdist upload + +test-upload: + python setup.py register -r $(TESTPYPI) + python setup.py sdist upload -r $(TESTPYPI) + +test-install: + pip install -i $(TESTPYPI) $(PROJECT_NAME) + +.venv/py27/bin/py.test: + @conda create -y -m -p .venv/py27 python=2.7 $(PACKAGES) + @.venv/py27/bin/pip install -e .[dev] + +.venv/py33/bin/py.test: + @conda create -y -m -p .venv/py33 python=3.3 $(PACKAGES) + @.venv/py33/bin/pip install -e .[dev] + +.venv/py34/bin/py.test: + @conda create -y -m -p .venv/py34 python=3.4 $(PACKAGES) + @.venv/py34/bin/pip install -e .[dev] + +.venv/py35/bin/py.test: + @conda create -y -m -p .venv/py35 python=3.5 $(PACKAGES) + @.venv/py35/bin/pip install -e .[dev] + +test27: .venv/py27/bin/py.test + $< -v $(TESTOPTIONS) $(TESTS) + +test33: .venv/py33/bin/py.test + $< -v $(TESTOPTIONS) $(TESTS) + +test34: .venv/py34/bin/py.test + $< -v $(TESTOPTIONS) $(TESTS) + +test35: .venv/py35/bin/py.test + $< -v $(TESTOPTIONS) $(TESTS) + +test: test27 test33 test34 test35 + +coverage: test35 + @rm -rf htmlcov/index.html + .venv/py35/bin/coverage html + +clean: + @rm -f *.pyc + @rm -rf __pycache__ + @rm -rf *.egg-info + @rm -rf htmlcov + +distclean: clean + @rm -rf .venv + +.PHONY: install develop uninstall upload test-upload test-install test clean distclean coverage diff --git a/README.markdown b/README.markdown new file mode 100644 index 0000000..3604a75 --- /dev/null +++ b/README.markdown @@ -0,0 +1,72 @@ +# tmuxpair # + +[![Build Status](https://travis-ci.org/goerz/tmuxpair.svg)](https://travis-ci.org/goerz/tmuxpair) + +Command line script for setting up a temporary tmux session for pair +programming. + +© 2016 by [Michael Goerz](http://michaelgoerz.net). This software is available +under the terms of the [MIT license][LICENSE]. + +[LICENSE]: LICENSE + +## Installation & Usage ## + +Using [virtualenv][]/[pipsi][]/[conda env][] is recommended. Install the +`tmuxpair` executable with + + pip install tmuxpair + +[virtualenv]: http://docs.python-guide.org/en/latest/dev/virtualenvs/ +[pipsi]: https://github.com/mitsuhiko/pipsi#pipsi +[conda env]: http://conda.pydata.org/docs/using/envs.html + +Run `tmuxpair -h` for usage details. + +## Reasonably Secure Pair Programming ## + +[A simple yet powerful way][1] for pair programming is to have a partner connect +to a tmux session on your machine. However, this implies that you give them SSH +access, with the obvious security implications: Your partner now +has complete control over your user account. They could use `scp` to copy any of +your files, read your email, or [pull a prank on you][2] by messing with your +`.bashrc`. + +While you should probably only engage in pair programming with people that you +place a certain minimum amount of trust in, it would be nice to eliminate at +least the obvious ways for your partner to do anything behind your back. +`tmuxpair` achieves this by using [key-based authentication][3] together with the +feature of SSH to [limit a key to a specific command][4]. This follows the +approach outlined in [Tres Trantham’s blog post][5]. Note that we also lock down +`scp` access and port forwarding (which could allow your partner to access any +firewalled server on your network). + +We can go a little further in ensuring only supervised access by strictly +limiting your partner’s connection to the duration of the pair-programming +session.`tmuxpair` does this by by wrapping `tmux`; when you run e.g. + + tmuxpair partner_key.pub + +the public key in the file `partner_key.pub` is *temporarily* appended to your +`~/.ssh/authorized_keys` file (with the appropriate options), and `tmuxpair` +attaches to a tmux session “pair”. As soon as the session ends – or you detach +from it! – your partner’s SSH connection is closed and the original +`authorized_keys` file is restored. + +Of course, if your partner were sufficiently determined to break into your +system, they could still manage to do so. Our assumption here is that +*supervised* access is “reasonably secure” in most environments. If your partner +does not need write access, `tmuxpair` provides a view-only mode that should not +leave any security loopholes. + +Security could be enhanced further by running the tmux session under a separate +and dedicated account instead of your normal user account. However, the purpose +of the `tmuxpair` script is to provide a robust solution that allows you to +quickly share a terminal with a colleague, *without* any further setup. + + +[1]: https://blog.pivotal.io/pivotal-labs/labs/how-we-use-tmux-for-remote-pair-programming +[2]: http://unix.stackexchange.com/questions/232/unix-linux-pranks +[3]: https://www.digitalocean.com/community/tutorials/how-to-configure-ssh-key-based-authentication-on-a-linux-server +[4]: https://en.m.wikibooks.org/wiki/OpenSSH/Client_Configuration_Files#.7E.2F.ssh.2Fauthorized_keys +[5]: http://collectiveidea.com/blog/archives/2014/02/18/a-simple-pair-programming-setup-with-ssh-and-tmux/ diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..0b50457 --- /dev/null +++ b/setup.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python +import setuptools + + +def get_version(filename): + with open(filename) as in_fh: + for line in in_fh: + if line.startswith('__version__'): + return line.split('=')[1].strip()[1:-1] + raise ValueError("Cannot extract version from %s" % filename) + + +setuptools.setup( + name="tmuxpair", + version=get_version("tmuxpair.py"), + url="https://github.com/goerz/tmuxpair#tmuxpair", + author="Michael Goerz", + author_email="goerz@stanford.edu", + description="Command line script for setting up a temporary tmux session for pair programming", + install_requires=[ + 'Click>=5', 'sshkeys>=0.5', + ], + extras_require={'dev': ['pytest', 'coverage', 'pytest-cov']}, + py_modules=['tmuxpair'], + entry_points=''' + [console_scripts] + tmuxpair=tmuxpair:main + ''', + classifiers=[ + 'Environment :: Console', + 'Natural Language :: English', + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + ], +) diff --git a/test_tmuxpair.py b/test_tmuxpair.py new file mode 100644 index 0000000..9d48803 --- /dev/null +++ b/test_tmuxpair.py @@ -0,0 +1,178 @@ +"""Collection of tests for tmuxpair.py""" +from distutils.version import LooseVersion +from textwrap import dedent +from copy import copy +from multiprocessing import Process +import subprocess +import shutil +import os +import time +import signal +import filecmp +import tmuxpair +from click.testing import CliRunner +import sshkeys +import pytest +# standard fixtures: tmpdir, monkeypatch, request + + +@pytest.fixture +def authorized_keys(tmpdir): + """Return the filename of an authorized_keys file""" + authorized_keys_file = tmpdir.join("authorized_keys") + authorized_keys_content= dedent(r''' + ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA6N/eQ1L0MxzzXgVpnnJCn6x+g+3434/ABLgG6IXbekYqBDWFOUfjslt90eTXRv5IVex1eY5RpR1d7dnFhYxW6bCZdrAryu9fPYSidFL3MGWTtijFRmSc9nCJVAP5+DY1xjA5aCtYq0MbhQMTRtBvOGPxFjXeG6sZ3dP698/am7KYjCUSqS2RBInEJ9J9Ym4lpCVptmnHWEJM8mc2PEa0PsuGBtxp2IaD7WO56ekaxy0+FlH2F93GsLDDqksxbcVp0UWoDW111CwFU3218z4TvjnftGoyLHMRDc6UmJallbpv/Ru+WeGCuCbzvzeoGVROxfBhLUji4idtMZlnWy3trQ== user@host + ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA6clJaaa/7QtvyeTtD23AGEBau0BKePGtewVnoQjZ3UxAkJPUYslOIr4tyHbRZFB6mf8U2xUDgVSd99QwIJQIDpA5jHT6ro0lb9hhUGqqaqX0UKKm0s2w3LscuiSgUY+dfBQAhX48T8YNG2MLtx7fCHigV7lTUgJZci44QvcoHkUM9W89SmG1qb7Z4lFE/WFQWkymH+JPnwC4fkKYxBq5FcwoHvn2+Jf0uhHlxnrGbg+xJJjUFbCkL6OdH4XZjkK1Tg5FqS8vL6Wbl7NY7NG0MSDQrVzzDbDSmqvLc7vHnbkENJSg3p/pLTY5ILXL2SOVJOuvBqWgIVjU/AjX18UcYQ== user@host2 + ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA5dm/9BAeahUX5kQD90/2TMppY/mNoBHyie2RsKvAptjJBDtq4n9JQz0gKYKUAeeek5blrsoRTsobbDdjvZp4M4PJ1959sNvkyrNgqu9OtkxJRa8l+gpGBxq2bTJ5+UXHmYLCjCtVR+Ln/1BznV525LZac5s9hrtobJrLvFFAuvuIQXdetkJ2FKH+ZL8IJhDUNrPJznaYcHRlCxPfxZmfp6HBByWce5pN1s+p7NkqVFCjdusxr/a+SxeZr6f/yJGBGiIOnxc9tVl2bZ97MbwJ02ayCaTJCXRCtiAs+oKtD4Ev8wTXuLghvT2YiFV0focpRSgV0BMG3uzuklLLyjSLdQ== user@@host3 + ''').strip() + authorized_keys_file.write(authorized_keys_content) + return str(authorized_keys_file) + +@pytest.fixture +def guest_key(tmpdir): + guest_key_file = tmpdir.join("guest.pub") + guest_key_content= dedent(r''' + ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQ23sfV/f3dfAdfsPfs33edsfa039c3434/ABLgG6IXbekYqBDWFOUfjslt90eTXRv5IVex1eY5RpR1d7dnFhYxW6bCZdrAryu9fPYSidFL3MGWTtijFRmSc9nCJVAP5+DY1xjA5aCtYq0MbhQMTRtBvOGPxFjXeG6sZ3dP698/am7KYjCUSqS2RBInEJ9J9Ym4lpCVptmnHWEJM8mc2PEa0PsuGBtxp2IaD7WO56ekaxy0+FlH2F93GsLDDqksxbcVp0UWoDW111CwFU3218z4TvjnftGoyLHMRDc6UmJallbpv/Ru+WeGCuCbzvzeoGVROxfBhLUji4idtMZlnWy3trQ== guest@host + ''').strip() + guest_key_file.write(guest_key_content) + return str(guest_key_file) + + +@pytest.fixture(params=[None, signal.SIGTERM]) +def sig(request): + return request.param + + +def test_tmuxpair(tmpdir, authorized_keys, guest_key, monkeypatch, sig): + shutil.copy(authorized_keys, authorized_keys+".orig") + check_output_log = os.path.join(str(tmpdir), "check_output.log") + def mocked_check_output(*args, **kwargs): + cmd = " ".join(args[0]) + with open(check_output_log, 'a') as log: + log.write(cmd + "\n") + if cmd == 'tmux attach -t pair': + raise subprocess.CalledProcessError(returncode=1, cmd=cmd, + output="No existing session") + elif cmd == 'tmux new-session -s pair': + if sig is None: + time.sleep(2) + else: + time.sleep(60) + elif cmd == 'tmux detach-client -s pair': + pass + else: + raise ValueError("Unexpeced command: %s" % cmd) + monkeypatch.setattr(subprocess, "check_output", mocked_check_output) + + runner = CliRunner() + args = ['--debug', '--authorized_keys', authorized_keys, '--tmux', 'tmux', guest_key] + p = Process(target=runner.invoke, args=(tmuxpair.main, ), + kwargs={'args': args}) + p.start() + # has the authorized_keys file been modified as expected? + time.sleep(1) + shutil.copy(authorized_keys, authorized_keys+".modified") + with open(authorized_keys) as in_fh: + authorized_keys_content = in_fh.read().strip() + assert authorized_keys_content == dedent(r''' + ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA6N/eQ1L0MxzzXgVpnnJCn6x+g+3434/ABLgG6IXbekYqBDWFOUfjslt90eTXRv5IVex1eY5RpR1d7dnFhYxW6bCZdrAryu9fPYSidFL3MGWTtijFRmSc9nCJVAP5+DY1xjA5aCtYq0MbhQMTRtBvOGPxFjXeG6sZ3dP698/am7KYjCUSqS2RBInEJ9J9Ym4lpCVptmnHWEJM8mc2PEa0PsuGBtxp2IaD7WO56ekaxy0+FlH2F93GsLDDqksxbcVp0UWoDW111CwFU3218z4TvjnftGoyLHMRDc6UmJallbpv/Ru+WeGCuCbzvzeoGVROxfBhLUji4idtMZlnWy3trQ== user@host + ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA6clJaaa/7QtvyeTtD23AGEBau0BKePGtewVnoQjZ3UxAkJPUYslOIr4tyHbRZFB6mf8U2xUDgVSd99QwIJQIDpA5jHT6ro0lb9hhUGqqaqX0UKKm0s2w3LscuiSgUY+dfBQAhX48T8YNG2MLtx7fCHigV7lTUgJZci44QvcoHkUM9W89SmG1qb7Z4lFE/WFQWkymH+JPnwC4fkKYxBq5FcwoHvn2+Jf0uhHlxnrGbg+xJJjUFbCkL6OdH4XZjkK1Tg5FqS8vL6Wbl7NY7NG0MSDQrVzzDbDSmqvLc7vHnbkENJSg3p/pLTY5ILXL2SOVJOuvBqWgIVjU/AjX18UcYQ== user@host2 + ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA5dm/9BAeahUX5kQD90/2TMppY/mNoBHyie2RsKvAptjJBDtq4n9JQz0gKYKUAeeek5blrsoRTsobbDdjvZp4M4PJ1959sNvkyrNgqu9OtkxJRa8l+gpGBxq2bTJ5+UXHmYLCjCtVR+Ln/1BznV525LZac5s9hrtobJrLvFFAuvuIQXdetkJ2FKH+ZL8IJhDUNrPJznaYcHRlCxPfxZmfp6HBByWce5pN1s+p7NkqVFCjdusxr/a+SxeZr6f/yJGBGiIOnxc9tVl2bZ97MbwJ02ayCaTJCXRCtiAs+oKtD4Ev8wTXuLghvT2YiFV0focpRSgV0BMG3uzuklLLyjSLdQ== user@@host3 + command="tmux attach -t pair",no-port-forwarding ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQ23sfV/f3dfAdfsPfs33edsfa039c3434/ABLgG6IXbekYqBDWFOUfjslt90eTXRv5IVex1eY5RpR1d7dnFhYxW6bCZdrAryu9fPYSidFL3MGWTtijFRmSc9nCJVAP5+DY1xjA5aCtYq0MbhQMTRtBvOGPxFjXeG6sZ3dP698/am7KYjCUSqS2RBInEJ9J9Ym4lpCVptmnHWEJM8mc2PEa0PsuGBtxp2IaD7WO56ekaxy0+FlH2F93GsLDDqksxbcVp0UWoDW111CwFU3218z4TvjnftGoyLHMRDc6UmJallbpv/Ru+WeGCuCbzvzeoGVROxfBhLUji4idtMZlnWy3trQ== guest@host + ''').strip() + if sig is not None: + pid = p.pid + os.kill(pid, sig) + p.join(60) + # has the authorized_keys file been restored to the original? + assert filecmp.cmp(authorized_keys, authorized_keys+".orig") + + # Did we make the expected calls to tmux? + with open(check_output_log) as log: + assert log.readlines() == [ + "tmux attach -t pair\n", + "tmux new-session -s pair\n", + "tmux detach-client -s pair\n" ] + + +def test_process_error(authorized_keys, guest_key, monkeypatch): + def mocked_check_output(*args, **kwargs): + cmd = " ".join(args[0]) + raise subprocess.CalledProcessError(returncode=1, cmd=cmd, + output="test failure") + monkeypatch.setattr(subprocess, "check_output", mocked_check_output) + runner = CliRunner() + args = ['--authorized_keys', authorized_keys, guest_key] + result = runner.invoke(tmuxpair.main, args=args) + assert "Cannot start tmux: test failure" in result.output + assert result.exit_code == 1 + + +def test_existing_backup(authorized_keys, guest_key, monkeypatch): + shutil.copy(authorized_keys, authorized_keys+".tmuxpair_bak") + runner = CliRunner() + args = ['--authorized_keys', authorized_keys, guest_key] + result = runner.invoke(tmuxpair.main, args=args) + assert "already exists" in result.output + assert result.exit_code == 1 + + +def test_authorized_keys(tmpdir): + file_in = tmpdir.join("authorized_keys") + authorized_keys_content = dedent(r''' + command="/home/git/gitolite/src/gitolite-shell aXXXXXXXger",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDGfut9fyOSO2TTGM1DiqTQml2pULlxAet++yy0B2HdHismtZ8k71HrqhLt/AflOKQmEcyuYNtomKfV1K1wOtx7yBc2YsRiXhmPlH8QTL+a1KmDn7AEsFvN8vd9T27kqEPcVzRZqGgIOgKNu7ut8pP4Bl0gbbNgS+BNzjc0IQeieOmnOLmSWPwQ7Z/oR9FJvrsumNaAo4WnuOQa/R5EjkuAQbDbAUQvBmlXy9KtHo4izt0GUokenA2wS0PiEgRd2uj8dWWC3Uf3ZTnKo7jvXOBmPEAUOTA/65vwRNIc8K9XaekWzpItz1zsj9NQuKGCqLflCWzo1PpA5FUCI/xZsLgr andreas@XXXXXz + command="/home/git/gitolite/src/gitolite-shell aXXXXXXXger",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDt+lvMRniCqRZxYvY2LqmYyUoD0K6RYFcPHNQSMn9d511dJVLhvtXFdn5kZCjSHVPaW2GWbX1CQYe8THFTu0wsKCv37c6S21jINljikoUwSXgl7vDJPgOZhLFYTEeSRVFNLkPJ/stNkBf847XbXT65Bf3t3VFhsnUuAsImJbANQEK9X2Xu3gMaeLOEsDHzAKr9/ZbDKisArAn7CoR2ZU3xt7MgcbywROoee9mPd9oI1ljYCOn8qOAFjyPigTVNlAiFYonISgxEAzBD18Nc35Yi+KfngLyCfsvfqmcCsSiSTywERetjJ/MtOjsdGN0nGspy3mWd8Ljfe3F7RSBQuXJl uk003019@rXXXvia + command="/home/git/gitolite/src/gitolite-shell beXX",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDHF5ebpGGJLQFQsQnLX0qWJ7ehlH1OGMgMiY0FZiUXSpFjNZvVw/dn0hHHuO7HJIfa35KMgV8u+okVD6RUxlGdbRnJ3DFz+BrM7EdsRool9jIabjz65NaWdg0HDScQNpZneQn+HAd292Nq0UZSfP8lB1s8o8nwehvNLBqKJE9E/siblm/50rqEqXqhop96nPFBL5mVnYGhSYGxm4U3vNkIBR+x42Lsj2/LO1JGSZ7B2sDioZOlHHq3D4ep5ev2d/6rrzQkWJWt5CWWQzPuYcyvMc3yQehtqBKGAQUjbX6dG8nKuP7CnAweP62RO68/izHYTn9zLqsAQhwL3GLh+kTv uk0084XX@buXXXXim + command="/home/git/gitolite/src/gitolite-shell beXX",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDLWsuwQnUxWW1ySWrb7t/k2Lrrr7px/QYIhNn0jxbfmba83bhCUTm+hczcLsH67SMqmYMhtZ2n+g1QZlEUQTr8q3N9fWcSMac9WL+Xks9x+umwmFuPMQq4kpPwugcLf1wGRZ2hDNi0zSh9Baw2BoS4Wx7fbngRG3CyxRQbdKKANn++HY+o/DL9hXcr8OQInaNxsRz7vLnhS3IxMsHmih0cvebhG1cyNNu0QIcV7ukThh7SUZDH+smB705jvAjfvYZH8Z1bPZjAXcekBe16heCbsGpi26MRVTYCzkaQuvnfQzkYpma8Ss7+G8GACu4cVuKU78a1oz6psmiXQqulvr+l uk0084XX@XXXXmon + command="/home/git/gitolite/src/gitolite-shell beXX",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCu7n/7/CaucI/+PhF2rHvbwdny7Y75ddt9F/9fuXJlOM7hI5JUgZDajbeUO/+9bPzk/Vn1QfNfGJw/H00NMh2qGAFruu7ikHWDk2qBfYJH2pWIE5go0d226vu/PhcBYawaFd50wKG7fzCg4M4FG6SW+YwuzMonWU/IrYUECB34TgvW52AdC33T48eii289DAuq5k2T0pbZG4NhSip3XQxYOBp6vpQnFiX26PKvYC+UgB4ujj5527PD7WtCFFb2g1uhP0f7DHwE0GpevYdhnpeRwlEuJqnGmAlYvNW/NT5ECJ45GSKRXb6FHVG7IQ4ycD537ap+vC2bhR3wdTI6yxJt ben@loXXXXost + command="/home/git/gitolite/src/gitolite-shell beXX",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDt/z1umZolo6OEnXzR+WzJYx4v5HufL+UkGt/Chb2RgwN4ntzxFaSMDITMd5VofZ/+dzqPTQIc3SlBW7WrOnwDsLkysarHzRJEyopVYJAaj5+tW9BQfeIn7H6fAQBCtve/w1W8pkK+Z4a8AIGv9UZ6eHPSPK5HYsYFaYVUikf9aCGhzgqYkQ6r+ekNWJw/m4LxIyA60avcanBHMS4oktCf2QgzbIlN8jNJ6Oz04GHPdqQ3xepba34pVOP4ICrVGVPfgabGihl4sJyA/Hw8UIaQranFeGI7IedNZdzvgGF8MgV7nHbZ7XDji2A+prFuX4juhZ1pzTTTdYnJFB2kE0xH Ben@BeXXXXc.local + command="/home/git/gitolite/src/gitolite-shell beXX",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDzpU6DIuODjMkb2K3tDR3Nx1rv0OTT3+IzFyY3UXG3aZNVqzyepKOMSAd3K/u15tNUi4ajNUdEo1d4nVvt8g8RSKM3nCx/qApWvvvv/7kxMRKLMmdJB8CyfUws+WnlZQtpEbwq5w+aW2DQm04k4y4YCM6HJd/JTHmRB0MIG8adO5bLGsr8K75tXmVRkqZkGIr6soWS8H7N1o424UavqzdVf2Up1ZHmNJmw7Ly5Q59nf2oulDwFsPqp828t6kUa/jw+923v0jfT4aUt/AtHPwX8VEvkbZbeskTXAXpSmPthUjCwnKrhy1k5siez6bbfZQ6qODwdd3tValOmJdFt9f29 uk008XX7@XXXXiyot + command="/home/git/gitolite/src/gitolite-shell ckXXXX",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDWcUKSLS8B7a9NyWDNFT9XJqC1vskT+DDRHHKCghTSBhyIfcUowwFm6rH0mCZXhT8bx4MLU7aE589cuWmI4tVII4Xq+NavKbsEamrpUpY4QGGX8843DNUsc7UhAYiCGW5IRX5JRlACB0rgqA8c9egMlnHc3n4Q45id1RuznM23U38nqyYEx6PHuVBnl5s8SrSgasMVb7zfYphINqx4nfW3qr8QDVsQ+4B6dfM5VadDTvg+wDHUNIp038aax56F2b4h0ArRybWarmT/OHXMBIYWXPq6DnQg+P4W8mOQ7HMSyJLSCXzaDzNjuL9O9zt3GcZ5SbMbcPEyDViekcGq9AkD uk003XXX@hXXXXem + ''').strip() + file_in.write(authorized_keys_content) + ak1 = tmuxpair.AuthorizedKeys.read(str(file_in)) + assert len(ak1) == 8 + assert str(ak1) == authorized_keys_content + key = sshkeys.Key.from_pubkey_line(r'command="/home/git/gitolite/src/gitolite-shell beXX",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDzpU6DIuODjMkb2K3tDR3Nx1rv0OTT3+IzFyY3UXG3aZNVqzyepKOMSAd3K/u15tNUi4ajNUdEo1d4nVvt8g8RSKM3nCx/qApWvvvv/7kxMRKLMmdJB8CyfUws+WnlZQtpEbwq5w+aW2DQm04k4y4YCM6HJd/JTHmRB0MIG8adO5bLGsr8K75tXmVRkqZkGIr6soWS8H7N1o424UavqzdVf2Up1ZHmNJmw7Ly5Q59nf2oulDwFsPqp828t6kUa/jw+923v0jfT4aUt/AtHPwX8VEvkbZbeskTXAXpSmPthUjCwnKrhy1k5siez6bbfZQ6qODwdd3tValOmJdFt9f29 uk008XX7@XXXXiyot') + assert key in ak1 + assert key.data in ak1 + file_out = tmpdir.join("authorized_keys.out") + ak1.write(str(file_out)) + ak2 = tmuxpair.AuthorizedKeys.read(str(file_out)) + for key in ak1: + assert key in ak2 + key_file = tmpdir.join("key.pub") + key_file_content = r'command="/home/git/gitolite/src/gitolite-shell beXX",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDE7TqUmphizXTvCOuRK2Xp0xFPHDGUL/2dduz00o4hZybdJsHNv5AqIvIeJWNbAZQX9kO0J8Offc2vk8aE0ab45hu2Csc4ektcjZ9uIsGslwHMrLXfTGi4PiTK9ddDYqNafIEtwvYW/89g+viHbIRZRcYkg0UeLvcTrm+pOCAjhGsVACs1s8m4e84zO+rvIeWVu6c4jrNy1XoOiElum68k60DXsiCYy6wo1eiIy0b4znU3i09wOnvINvLZ+STnXmmgizS2bNKw5wMxHbrKg9Y9lyuc0PX6ea2Mc5D1dupj1K57BXm8mYtBPgKsj+SPIoJKzMzmI6kq/6OJayk5XLp1 Ben@Ben-MXXXXr.local' + key_file.write(key_file_content) + key = sshkeys.Key.from_pubkey_file(str(key_file)) + assert key not in ak1 + ak1.add_key_file(str(key_file)) + assert (str(ak1)) == authorized_keys_content + "\n" + key_file_content + assert key in ak1 + assert len(ak1) == 9 + ak1.extend(ak2) + assert len(ak1) == 17 + ak2_copy = copy(ak2) + assert ak2_copy is not ak2 + assert str(ak2_copy) == str(ak2) + + +def test_version(): + """Ensure that --version shows a version number that matches the + __version__ defined in the module, and is parsable by LooseVersion""" + runner = CliRunner() + result = runner.invoke(tmuxpair.main, args=['--version']) + assert result.exit_code == 0 + version = result.output.split()[-1] + assert version == tmuxpair.__version__ + assert LooseVersion(version) >= LooseVersion('0.1.0') + + +def test_help(): + """Ensure that -h and --help display the help""" + runner = CliRunner() + result = runner.invoke(tmuxpair.main, args=['-h']) + result2 = runner.invoke(tmuxpair.main, args=['--help']) + assert result.exit_code == 0 + assert result.output.startswith("Usage:") + assert result.output == result2.output diff --git a/tmuxpair.py b/tmuxpair.py new file mode 100644 index 0000000..028977d --- /dev/null +++ b/tmuxpair.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python +"""Command line script for setting up a temporary tmux session for pair +programming""" +# Copyright (C) 2016 Michael Goerz. See LICENSE for terms of use. + +import logging +import sys +import os +import shutil +import contextlib +import signal +import subprocess as sp +from collections import OrderedDict +from copy import copy + +import sshkeys +import click +from click import echo + +__version__ = '1.0.0' + + +class AuthorizedKeys(object): + """Representation of an authorized_keys file, consisting of a list of keys, + with associated options (optionally)""" + def __init__(self): + self.keys = [] + + def add_key(self, key): + """Add a key (instance of sshkeys.Key)""" + self.keys.append(key) + + def add_key_file(self, file, options=None): + """Add the public key stored in the given file""" + key = sshkeys.Key.from_pubkey_file(file) + self.add_key(key) + + def extend(self, other): + """Extend with keys and options of another AuthorizedKeys instance""" + self.keys.extend(other.keys) + + def __len__(self): + """Number of keys""" + return len(self.keys) + + def __iter__(self): + """Return iterator over keys""" + return iter(self.keys) + + def __contains__(self, key): + """Check whether the given key (instance of sshkeys.Key) is used + (ignoring the options setting.""" + if isinstance(key, sshkeys.Key): + for k in self.keys: + if k.data == key.data: + return True + else: # key is assumed to be the data directly + for k in self.keys: + if k.data == key: + return True + return False + + @classmethod + def read(cls, file): + """Read an authorized_keys file""" + authorized_keys = cls() + with click.open_file(file) as in_fh: + for line in in_fh: + line = line.strip() + if not line.startswith("#"): + key = sshkeys.Key.from_pubkey_line(line) + authorized_keys.add_key(key) + return authorized_keys + + def __copy__(self): + """Return copy of object""" + new_copy = AuthorizedKeys() + for key in self.keys: + new_copy.add_key(key) + return new_copy + + def __str__(self): + """Content of an authorized_keys file, as a string""" + return "\n".join([k.to_pubkey_line() for k in self.keys]) + + def write(self, file): + """Write an authorized_keys file""" + with click.open_file(file, 'w') as out_fh: + out_fh.write(str(self)) + + +def _sigterm_handler(signum, frame): + sys.exit(0) +_sigterm_handler.__enter_ctx__ = False + + +@contextlib.contextmanager +def handle_exit(callback=None, append=False): + """A context manager which properly handles SIGTERM and SIGINT + (KeyboardInterrupt) signals, registering a function which is + guaranteed to be called after signals are received. + Also, it makes sure to execute previously registered signal + handlers as well (if any). + + If append == False raise RuntimeError if there's already a handler + registered for SIGTERM, otherwise both new and old handlers are + executed in this order. + """ + # http://code.activestate.com/recipes/577997-handle-exit-context-manager/ + old_handler = signal.signal(signal.SIGTERM, _sigterm_handler) + if (old_handler != signal.SIG_DFL) and (old_handler != _sigterm_handler): + if not append: + raise RuntimeError("there is already a handler registered for " + "SIGTERM: %r" % old_handler) + + def handler(signum, frame): + try: + _sigterm_handler(signum, frame) + finally: + old_handler(signum, frame) + signal.signal(signal.SIGTERM, handler) + + if _sigterm_handler.__enter_ctx__: + raise RuntimeError("can't use nested contexts") + _sigterm_handler.__enter_ctx__ = True + + try: + yield + except KeyboardInterrupt: + pass + except SystemExit as err: + # code != 0 refers to an application error (e.g. explicit + # sys.exit('some error') call). + # We don't want that to pass silently. + # Nevertheless, the 'finally' clause below will always + # be executed. + if err.code != 0: + raise + finally: + _sigterm_handler.__enter_ctx__ = False + if callback is not None: + callback() + + +@click.command() +@click.help_option('-h', '--help') +@click.version_option(version=__version__) +@click.option('--authorized_keys', + default=os.path.expanduser('~/.ssh/authorized_keys'), + show_default=True, + help='authorized_keys file to temporarily modify.', + type=click.Path(exists=True, dir_okay=False)) +@click.option('--session', '-s', default='pair', show_default=True, + metavar='SESSION_NAME', help="Name of tmux session to use.") +@click.option('--read-only', '-r', is_flag=True, default=False, + help='Allow read-only access only for remote users.') +@click.option('--tmux', default='tmux', metavar='TMUX', show_default=True, + help='Executable to be used for tmux') +@click.option('--debug', is_flag=True, + help='enable debug logging') +@click.argument('keys', nargs=-1, type=click.Path(exists=True, dir_okay=False), + required=True) +def main(authorized_keys, keys, session, read_only, tmux, debug): + """Run a new tmux session, or attach to an existing tmux session, for pair + programming. + + Each file given as KEYS may contain an arbitrary number of public SSH keys. + These keys are temporarily added to the SSH authorized_keys file. Any user + connecting via SSH with a matching key will automatically be attached to + the tmux session. + + When the tmux session ends or is detached, the original authorized_keys + file will be restored. Also, any remote connections are immediately + severed. This ensures that at no point, the remote user has unsupervised + access to the system. Any interaction besides through the tmux session is + forbidden. Port forwarding or use of scp is forbidden. + """ + logging.basicConfig(level=logging.WARNING) + logger = logging.getLogger(__name__) + if debug: + logger.setLevel(logging.DEBUG) + logger.debug("Enabled debug output") + + authorized_keys = os.path.expanduser(authorized_keys) + authorized_keys_backup = authorized_keys+'.tmuxpair_bak' + if os.path.isfile(authorized_keys_backup): + click.echo("Error: Backup file %s already exists. This means that " + "either an instance of this script is already running, or " + "an earlier instance has crashed." % authorized_keys_backup, + err=True) + sys.exit(1) + shutil.copy(authorized_keys, authorized_keys_backup) + def cleanup(): + click.echo("Cleaning up, restoring %s..." % authorized_keys) + shutil.move(authorized_keys_backup, authorized_keys) + try: + sp.check_output([tmux, 'detach-client', '-s', session], + stderr=sp.STDOUT) + click.echo("all remote clients have been disconnected...") + except sp.CalledProcessError: + pass # Usually, this is because the session did no longer exist + click.echo("Done.") + with handle_exit(callback=cleanup): + authorized_data = AuthorizedKeys.read(authorized_keys) + if read_only: + connect_cmd = '{tmux} attach -r -t {session}'.format( + tmux=tmux, session=session) + else: + connect_cmd = '{tmux} attach -t {session}'.format( + tmux=tmux, session=session) + for file in keys: + keys_data = AuthorizedKeys.read(file) + for key in keys_data: + key.options = OrderedDict([ + ('command', connect_cmd), + ('no-port-forwarding', True), + ]) + authorized_data.extend(keys_data) + authorized_data.write(authorized_keys) + try: + sp.check_output([tmux, 'attach', '-t', session], stderr=sp.STDOUT) + except sp.CalledProcessError: + try: + sp.check_output([tmux, 'new-session', '-s', session], + stderr=sp.STDOUT) + except sp.CalledProcessError as e: + click.echo("Error: Cannot start tmux: %s" % e.output, err=True) + sys.exit(1) +