Merge branch 'pr57': add experimental ssh-setup mode

There are still some rough edges, but I think I'll file a new ticket to
cover them. Note that the CLI syntax may change before this gets into a
release.
This commit is contained in:
Brian Warner 2016-08-15 17:38:29 -07:00
commit d16b1e888d
6 changed files with 404 additions and 2 deletions

View File

@ -40,6 +40,7 @@ setup(name="magic-wormhole",
"dev": [ "dev": [
"mock", "mock",
"tox", "tox",
"pyflakes",
], ],
}, },
test_suite="wormhole.test", test_suite="wormhole.test",

View File

@ -3,7 +3,6 @@ from __future__ import print_function
import os import os
import time import time
start = time.time() start = time.time()
import traceback
from textwrap import fill, dedent from textwrap import fill, dedent
from sys import stdout, stderr from sys import stdout, stderr
from . import public_relay from . import public_relay
@ -11,6 +10,7 @@ from .. import __version__
from ..timing import DebugTiming from ..timing import DebugTiming
from ..errors import WrongPasswordError, WelcomeError, KeyFormatError from ..errors import WrongPasswordError, WelcomeError, KeyFormatError
from twisted.internet.defer import inlineCallbacks, maybeDeferred from twisted.internet.defer import inlineCallbacks, maybeDeferred
from twisted.python.failure import Failure
from twisted.internet.task import react from twisted.internet.task import react
import click import click
@ -29,6 +29,7 @@ class Config(object):
self.cwd = os.getcwd() self.cwd = os.getcwd()
self.stdout = stdout self.stdout = stdout
self.stderr = stderr self.stderr = stderr
self.tor = False # XXX?
def _compose(*decorators): def _compose(*decorators):
def decorate(f): def decorate(f):
@ -111,7 +112,10 @@ def _dispatch_command(reactor, cfg, command):
msg = fill("ERROR: " + dedent(e.__doc__)) msg = fill("ERROR: " + dedent(e.__doc__))
print(msg, file=stderr) print(msg, file=stderr)
except Exception as e: except Exception as e:
traceback.print_exc() # this prints a proper traceback, whereas
# traceback.print_exc() just prints a TB to the "yield"
# line above ...
Failure().printTraceback(file=stderr)
print("ERROR:", e, file=stderr) print("ERROR:", e, file=stderr)
raise SystemExit(1) raise SystemExit(1)
@ -213,3 +217,67 @@ def receive(cfg, code, **kwargs):
cfg.code = None cfg.code = None
return go(cmd_receive.receive, cfg) return go(cmd_receive.receive, cfg)
@wormhole.group()
def ssh():
"""
Facilitate sending/receiving SSH public keys
"""
pass
@ssh.command(name="invite")
@click.option(
"-c", "--code-length", default=2,
metavar="NUMWORDS",
help="length of code (in bytes/words)",
)
@click.option(
"--user", "-u",
default=None,
metavar="USER",
help="Add to USER's ~/.ssh/authorized_keys",
)
@click.pass_context
def ssh_invite(ctx, code_length, user):
"""
Add a public-key to a ~/.ssh/authorized_keys file
"""
from . import cmd_ssh
ctx.obj.code_length = code_length
ctx.obj.ssh_user = user
return go(cmd_ssh.invite, ctx.obj)
@ssh.command(name="accept")
@click.argument(
"code", nargs=1, required=True,
)
@click.option(
"--key-file", "-F",
default=None,
type=click.Path(exists=True),
)
@click.option(
"--yes", "-y", is_flag=True,
help="Skip confirmation prompt to send key",
)
@click.pass_obj
def ssh_accept(cfg, code, key_file, yes):
"""
Send your SSH public-key
In response to a 'wormhole ssh invite' this will send public-key
you specify (if there's only one in ~/.ssh/* that will be sent).
"""
from . import cmd_ssh
kind, keyid, pubkey = cmd_ssh.find_public_key(key_file)
print("Sending public key type='{}' keyid='{}'".format(kind, keyid))
if yes is not True:
click.confirm("Really send public key '{}' ?".format(keyid), abort=True)
cfg.public_key = (kind, keyid, pubkey)
cfg.code = code
return go(cmd_ssh.accept, cfg)

124
src/wormhole/cli/cmd_ssh.py Normal file
View File

@ -0,0 +1,124 @@
from __future__ import print_function
import os
from os.path import expanduser, exists, join
from twisted.internet.defer import inlineCallbacks
from twisted.internet import reactor
import click
from .. import xfer_util
class PubkeyError(Exception):
pass
def find_public_key(hint=None):
"""
This looks for an appropriate SSH key to send, possibly querying
the user in the meantime. DO NOT CALL after reactor.run as this
(possibly) does blocking stuff like asking the user questions (via
click.prompt())
Returns a 3-tuple: kind, keyid, pubkey_data
"""
if hint is None:
hint = expanduser('~/.ssh/')
else:
if not exists(hint):
raise PubkeyError("Can't find '{}'".format(hint))
pubkeys = [f for f in os.listdir(hint) if f.endswith('.pub')]
if len(pubkeys) == 0:
raise PubkeyError("No public keys in '{}'".format(hint))
elif len(pubkeys) > 1:
got_key = False
while not got_key:
ans = click.prompt(
"Multiple public-keys found:\n" + \
"\n".join([" {}: {}".format(a, b) for a, b in enumerate(pubkeys)]) + \
"\nSend which one?"
)
try:
ans = int(ans)
if ans < 0 or ans >= len(pubkeys):
ans = None
else:
got_key = True
with open(join(hint, pubkeys[ans]), 'r') as f:
pubkey = f.read()
except Exception:
got_key = False
else:
with open(join(hint, pubkeys[0]), 'r') as f:
pubkey = f.read()
parts = pubkey.strip().split()
kind = parts[0]
keyid = 'unknown' if len(parts) <= 2 else parts[2]
return kind, keyid, pubkey
@inlineCallbacks
def accept(cfg, reactor=reactor):
yield xfer_util.send(
reactor,
u"lothar.com/wormhole/ssh-add",
cfg.relay_url,
data=cfg.public_key[2],
code=cfg.code,
use_tor=cfg.tor,
)
print("Key sent.")
@inlineCallbacks
def invite(cfg, reactor=reactor):
def on_code_created(code):
print("Now tell the other user to run:")
print()
print("wormhole ssh accept {}".format(code))
print()
if cfg.ssh_user is None:
ssh_path = expanduser('~/.ssh/'.format(cfg.ssh_user))
else:
ssh_path = expanduser('~{}/.ssh/'.format(cfg.ssh_user))
auth_key_path = join(ssh_path, 'authorized_keys')
if not exists(auth_key_path):
print("Note: '{}' not found; will be created".format(auth_key_path))
if not exists(ssh_path):
print(" '{}' doesn't exist either".format(ssh_path))
else:
try:
open(auth_key_path, 'a').close()
except OSError:
print("No write permission on '{}'".format(auth_key_path))
return
try:
os.listdir(ssh_path)
except OSError:
print("Can't read '{}'".format(ssh_path))
return
pubkey = yield xfer_util.receive(
reactor,
u"lothar.com/wormhole/ssh-add",
cfg.relay_url,
None, # allocate a code for us
use_tor=cfg.tor,
on_code=on_code_created,
)
parts = pubkey.split()
kind = parts[0]
keyid = 'unknown' if len(parts) <= 2 else parts[2]
if not exists(auth_key_path):
if not exists(ssh_path):
os.mkdir(ssh_path, mode=0o700)
with open(auth_key_path, 'a', 0o600) as f:
f.write('{}\n'.format(pubkey.strip()))
print("Appended key type='{kind}' id='{key_id}' to '{auth_file}'".format(
kind=kind, key_id=keyid, auth_file=auth_key_path))

View File

@ -0,0 +1,59 @@
import os, io
import mock
from twisted.trial import unittest
from ..cli import cmd_ssh
OTHERS = ["config", "config~", "known_hosts", "known_hosts~"]
class FindPubkey(unittest.TestCase):
def test_find_one(self):
files = OTHERS + ["id_rsa.pub", "id_rsa"]
pubkey_data = u"ssh-rsa AAAAkeystuff email@host\n"
pubkey_file = io.StringIO(pubkey_data)
with mock.patch("wormhole.cli.cmd_ssh.exists", return_value=True):
with mock.patch("os.listdir", return_value=files) as ld:
with mock.patch("wormhole.cli.cmd_ssh.open",
return_value=pubkey_file):
res = cmd_ssh.find_public_key()
self.assertEqual(ld.mock_calls,
[mock.call(os.path.expanduser("~/.ssh/"))])
self.assertEqual(len(res), 3, res)
kind, keyid, pubkey = res
self.assertEqual(kind, "ssh-rsa")
self.assertEqual(keyid, "email@host")
self.assertEqual(pubkey, pubkey_data)
def test_find_none(self):
files = OTHERS # no pubkey
with mock.patch("wormhole.cli.cmd_ssh.exists", return_value=True):
with mock.patch("os.listdir", return_value=files):
e = self.assertRaises(cmd_ssh.PubkeyError,
cmd_ssh.find_public_key)
dot_ssh = os.path.expanduser("~/.ssh/")
self.assertEqual(str(e), "No public keys in '{}'".format(dot_ssh))
def test_bad_hint(self):
with mock.patch("wormhole.cli.cmd_ssh.exists", return_value=False):
e = self.assertRaises(cmd_ssh.PubkeyError,
cmd_ssh.find_public_key,
hint="bogus/path")
self.assertEqual(str(e), "Can't find 'bogus/path'")
def test_find_multiple(self):
files = OTHERS + ["id_rsa.pub", "id_rsa", "id_dsa.pub", "id_dsa"]
pubkey_data = u"ssh-rsa AAAAkeystuff email@host\n"
pubkey_file = io.StringIO(pubkey_data)
with mock.patch("wormhole.cli.cmd_ssh.exists", return_value=True):
with mock.patch("os.listdir", return_value=files):
responses = iter(["frog", "NaN", "-1", "0"])
with mock.patch("click.prompt",
side_effect=lambda p: next(responses)):
with mock.patch("wormhole.cli.cmd_ssh.open",
return_value=pubkey_file):
res = cmd_ssh.find_public_key()
self.assertEqual(len(res), 3, res)
kind, keyid, pubkey = res
self.assertEqual(kind, "ssh-rsa")
self.assertEqual(keyid, "email@host")
self.assertEqual(pubkey, pubkey_data)

View File

@ -0,0 +1,49 @@
from twisted.trial import unittest
from twisted.internet import reactor, defer
from twisted.internet.defer import inlineCallbacks
from .. import xfer_util
from .common import ServerBase
APPID = u"appid"
class Xfer(ServerBase, unittest.TestCase):
@inlineCallbacks
def test_xfer(self):
code = u"1-code"
data = u"data"
d1 = xfer_util.send(reactor, APPID, self.relayurl, data, code)
d2 = xfer_util.receive(reactor, APPID, self.relayurl, code)
send_result = yield d1
receive_result = yield d2
self.assertEqual(send_result, None)
self.assertEqual(receive_result, data)
@inlineCallbacks
def test_on_code(self):
code = u"1-code"
data = u"data"
send_code = []
receive_code = []
d1 = xfer_util.send(reactor, APPID, self.relayurl, data, code,
on_code=send_code.append)
d2 = xfer_util.receive(reactor, APPID, self.relayurl, code,
on_code=receive_code.append)
send_result = yield d1
receive_result = yield d2
self.assertEqual(send_code, [code])
self.assertEqual(receive_code, [code])
self.assertEqual(send_result, None)
self.assertEqual(receive_result, data)
@inlineCallbacks
def test_make_code(self):
data = u"data"
got_code = defer.Deferred()
d1 = xfer_util.send(reactor, APPID, self.relayurl, data, code=None,
on_code=got_code.callback)
code = yield got_code
d2 = xfer_util.receive(reactor, APPID, self.relayurl, code)
send_result = yield d1
receive_result = yield d2
self.assertEqual(send_result, None)
self.assertEqual(receive_result, data)

101
src/wormhole/xfer_util.py Normal file
View File

@ -0,0 +1,101 @@
import json
from twisted.internet.defer import inlineCallbacks, returnValue
from .wormhole import wormhole
@inlineCallbacks
def receive(reactor, appid, relay_url, code, use_tor=None, on_code=None):
"""
This is a convenience API which returns a Deferred that callbacks
with a single chunk of data from another wormhole (and then closes
the wormhole). Under the hood, it's just using an instance
returned from :func:`wormhole.wormhole`. This is similar to the
`wormhole receive` command.
:param unicode appid: our application ID
:param unicode relay_url: the relay URL to use
:param unicode code: a pre-existing code to use, or None
:param bool use_tor: True if we should use Tor, False to not use it (None for default)
:param on_code: if not None, this is called when we have a code (even if you passed in one explicitly)
:type on_code: single-argument callable
"""
wh = wormhole(appid, relay_url, reactor, use_tor)
if code is None:
code = yield wh.get_code()
else:
wh.set_code(code)
# we'll call this no matter what, even if you passed in a code --
# maybe it should be only in the 'if' block above?
if on_code:
on_code(code)
data = yield wh.get()
data = json.loads(data.decode("utf-8"))
offer = data.get('offer', None)
if not offer:
raise Exception(
"Do not understand response: {}".format(data)
)
msg = None
if 'message' in offer:
msg = offer['message']
wh.send(json.dumps({"answer": {"message_ack": "ok"}}).encode("utf-8"))
else:
raise Exception(
"Unknown offer type: {}".format(offer.keys())
)
yield wh.close()
returnValue(msg)
@inlineCallbacks
def send(reactor, appid, relay_url, data, code, use_tor=None, on_code=None):
"""
This is a convenience API which returns a Deferred that callbacks
after a single chunk of data has been sent to another
wormhole. Under the hood, it's just using an instance returned
from :func:`wormhole.wormhole`. This is similar to the `wormhole
send` command.
:param unicode appid: the application ID
:param unicode relay_url: the relay URL to use
:param unicode code: a pre-existing code to use, or None
:param bool use_tor: True if we should use Tor, False to not use it (None for default)
:param on_code: if not None, this is called when we have a code (even if you passed in one explicitly)
:type on_code: single-argument callable
"""
wh = wormhole(appid, relay_url, reactor, use_tor)
if code is None:
code = yield wh.get_code()
else:
wh.set_code(code)
if on_code:
on_code(code)
wh.send(
json.dumps({
"offer": {
"message": data
}
}).encode("utf-8")
)
data = yield wh.get()
data = json.loads(data.decode("utf-8"))
answer = data.get('answer', None)
yield wh.close()
if answer:
returnValue(None)
else:
raise Exception(
"Unknown answer: {}".format(data)
)