Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Issue32 ssh setup.3 #57

Merged
merged 4 commits into from Aug 16, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions setup.py
Expand Up @@ -40,6 +40,7 @@
"dev": [
"mock",
"tox",
"pyflakes",
],
},
test_suite="wormhole.test",
Expand Down
72 changes: 70 additions & 2 deletions src/wormhole/cli/cli.py
Expand Up @@ -3,14 +3,14 @@
import os
import time
start = time.time()
import traceback
from textwrap import fill, dedent
from sys import stdout, stderr
from . import public_relay
from .. import __version__
from ..timing import DebugTiming
from ..errors import WrongPasswordError, WelcomeError, KeyFormatError
from twisted.internet.defer import inlineCallbacks, maybeDeferred
from twisted.python.failure import Failure
from twisted.internet.task import react

import click
Expand All @@ -29,6 +29,7 @@ def __init__(self):
self.cwd = os.getcwd()
self.stdout = stdout
self.stderr = stderr
self.tor = False # XXX?

def _compose(*decorators):
def decorate(f):
Expand Down Expand Up @@ -111,7 +112,10 @@ def _dispatch_command(reactor, cfg, command):
msg = fill("ERROR: " + dedent(e.__doc__))
print(msg, file=stderr)
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)
raise SystemExit(1)

Expand Down Expand Up @@ -213,3 +217,67 @@ def receive(cfg, code, **kwargs):
cfg.code = None

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)
122 changes: 122 additions & 0 deletions src/wormhole/cli/cmd_ssh.py
@@ -0,0 +1,122 @@
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


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 RuntimeError("Can't find '{}'".format(hint))

pubkeys = [f for f in os.listdir(hint) if f.endswith('.pub')]
if len(pubkeys) == 0:
raise RuntimeError("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 = '/home/{}/.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))
101 changes: 101 additions & 0 deletions src/wormhole/xfer_util.py
@@ -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)
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"}}))

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
}
})
)
data = yield wh.get()
data = json.loads(data)
answer = data.get('answer', None)
yield wh.close()
if answer:
returnValue(None)
else:
raise Exception(
"Unknown answer: {}".format(data)
)