mirror of
https://github.com/tahoe-lafs/tahoe-lafs.git
synced 2025-01-19 03:06:33 +00:00
Merge PR418: add 'tahoe invite' and 'tahoe create-client --join='
refs ticket:126 , although this is just the first step
This commit is contained in:
commit
3eaf18eba4
@ -13,6 +13,7 @@ Contents:
|
|||||||
about
|
about
|
||||||
INSTALL
|
INSTALL
|
||||||
running
|
running
|
||||||
|
magic-wormhole-invites
|
||||||
configuration
|
configuration
|
||||||
architecture
|
architecture
|
||||||
|
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
.. _magic-folder-howto:
|
||||||
|
|
||||||
=========================
|
=========================
|
||||||
Magic Folder Set-up Howto
|
Magic Folder Set-up Howto
|
||||||
=========================
|
=========================
|
||||||
|
73
docs/magic-wormhole-invites.rst
Normal file
73
docs/magic-wormhole-invites.rst
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
**********************
|
||||||
|
Magic Wormhole Invites
|
||||||
|
**********************
|
||||||
|
|
||||||
|
Magic Wormhole
|
||||||
|
==============
|
||||||
|
|
||||||
|
`magic wormhole`_ is a server and a client which together use Password
|
||||||
|
Authenticated Key Exchange (PAKE) to use a short code to establish a
|
||||||
|
secure channel between two computers. These codes are one-time use and
|
||||||
|
an attacker gets at most one "guess", thus allowing low-entropy codes
|
||||||
|
to be used.
|
||||||
|
|
||||||
|
.. _magic wormhole: https://github.com/warner/magic-wormhole#design
|
||||||
|
|
||||||
|
|
||||||
|
Invites and Joins
|
||||||
|
=================
|
||||||
|
|
||||||
|
Inside Tahoe-LAFS we are using a channel created using `magic
|
||||||
|
wormhole`_ to exchange configuration and the secret fURL of the
|
||||||
|
Introducer with new clients. In the future, we would like to make the
|
||||||
|
Magic Folder (:ref:`Magic Folder HOWTO <magic-folder-howto>`) invites and joins work this way
|
||||||
|
as well.
|
||||||
|
|
||||||
|
This is a two-part process. Alice runs a grid and wishes to have her
|
||||||
|
friend Bob use it as a client. She runs ``tahoe invite bob`` which
|
||||||
|
will print out a short "wormhole code" like ``2-unicorn-quiver``. You
|
||||||
|
may also include some options for total, happy and needed shares if
|
||||||
|
you like.
|
||||||
|
|
||||||
|
Alice then transmits this one-time secret code to Bob. Alice must keep
|
||||||
|
her command running until Bob has done his step as it is waiting until
|
||||||
|
a secure channel is established before sending the data.
|
||||||
|
|
||||||
|
Bob then runs ``tahoe create-client --join <secret code>`` with any
|
||||||
|
other options he likes. This will "use up" the code establishing a
|
||||||
|
secure session with Alice's computer. If an attacker tries to guess
|
||||||
|
the code, they get only once chance to do so (and then Bob's side will
|
||||||
|
fail). Once Bob's computer has connected to Alice's computer, the two
|
||||||
|
computers performs the protocol described below, resulting in some
|
||||||
|
JSON with the Introducer fURL, nickname and any other options being
|
||||||
|
sent to Bob's computer. The ``tahoe create-client`` command then uses
|
||||||
|
these options to set up Bob's client.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Tahoe-LAFS Secret Exchange
|
||||||
|
==========================
|
||||||
|
|
||||||
|
The protocol that the Alice (the one doing the invite) and Bob (the
|
||||||
|
one being invited) sides perform once a magic wormhole secure channel
|
||||||
|
has been established goes as follows:
|
||||||
|
|
||||||
|
Alice and Bob both immediately send an "abilities" message as
|
||||||
|
JSON. For Alice this is ``{"abilities": {"server-v1": {}}}``. For Bob,
|
||||||
|
this is ``{"abilities": {"client-v1": {}}}``.
|
||||||
|
|
||||||
|
After receiving the message from the other side and confirming the
|
||||||
|
expected protocol, Alice transmits the configuration JSON::
|
||||||
|
|
||||||
|
{
|
||||||
|
"needed": 3,
|
||||||
|
"total": 10,
|
||||||
|
"happy": 7,
|
||||||
|
"nickname": "bob",
|
||||||
|
"introducer": "pb://xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@example.com:41505/yyyyyyyyyyyyyyyyyyyyyyy"
|
||||||
|
}
|
||||||
|
|
||||||
|
Both sides then disconnect.
|
||||||
|
|
||||||
|
As you can see, there is room for future revisions of the protocol but
|
||||||
|
as of yet none have been sketched out.
|
@ -26,6 +26,39 @@ grid`_ as you only need to create a client node. When you want to create your
|
|||||||
own grid you'll need to create the introducer and several initial storage
|
own grid you'll need to create the introducer and several initial storage
|
||||||
nodes (see the note about small grids below).
|
nodes (see the note about small grids below).
|
||||||
|
|
||||||
|
|
||||||
|
Being Introduced to a Grid
|
||||||
|
--------------------------
|
||||||
|
|
||||||
|
A collection of Tahoe servers is called a Grid and usually has 1
|
||||||
|
Introducer (but sometimes more, and it's possible to run with zero). The
|
||||||
|
Introducer announces which storage servers constitute the Grid and how to
|
||||||
|
contact them. There is a secret "fURL" you need to know to talk to the
|
||||||
|
Introducer.
|
||||||
|
|
||||||
|
One way to get this secret is using traditional tools such as encrypted
|
||||||
|
email, encrypted instant-messaging, etcetera. It is important to transmit
|
||||||
|
this fURL secretly as knowing it gives you access to the Grid.
|
||||||
|
|
||||||
|
An additional way to share the fURL securely is via `magic
|
||||||
|
wormhole`_. This uses a weak one-time password and a server on the
|
||||||
|
internet (at `wormhole.tahoe-lafs.org`) to open a secure channel between
|
||||||
|
two computers. In Tahoe-LAFS this functions via the commands `tahoe
|
||||||
|
invite` and `tahoe create-client --join`. A person who already has access
|
||||||
|
to a Grid can use `tahoe invite` to create one end of the `magic
|
||||||
|
wormhole`_ and then transmits some JSON (including the Introducer's
|
||||||
|
secret fURL) to the other end. `tahoe invite` will print a one-time
|
||||||
|
secret code; you must then communicate this code to the person who will
|
||||||
|
join the Grid.
|
||||||
|
|
||||||
|
The other end of the `magic wormhole`_ in this case is `tahoe
|
||||||
|
create-client --join <one-time code>`, where the person being invited
|
||||||
|
types in the code they were given. Ideally, this code would be
|
||||||
|
transmitted securely. It is, however, only useful exactly once. Also, it
|
||||||
|
is much easier to transcribe by a human. Codes look like
|
||||||
|
`7-surrender-tunnel` (a short number and two words).
|
||||||
|
|
||||||
|
|
||||||
Running a Client
|
Running a Client
|
||||||
----------------
|
----------------
|
||||||
|
|
||||||
@ -38,6 +71,11 @@ To construct a client node, run “``tahoe create-client``”, which will create
|
|||||||
it will do is connect to the introducer and get itself connected to all other
|
it will do is connect to the introducer and get itself connected to all other
|
||||||
nodes on the grid.
|
nodes on the grid.
|
||||||
|
|
||||||
|
Some Grids use "magic wormhole" one-time codes to configure the basic
|
||||||
|
options. In such a case you use ``tahoe create-client --join
|
||||||
|
<one-time-code>`` and do not have to do any of the ``tahoe.cfg`` editing
|
||||||
|
mentioned above.
|
||||||
|
|
||||||
By default, “``tahoe create-client``” creates a client-only node, that
|
By default, “``tahoe create-client``” creates a client-only node, that
|
||||||
does not offer its disk space to other nodes. To configure other behavior,
|
does not offer its disk space to other nodes. To configure other behavior,
|
||||||
use “``tahoe create-node``” or see :doc:`configuration`.
|
use “``tahoe create-node``” or see :doc:`configuration`.
|
||||||
@ -47,6 +85,7 @@ On Unix, you can run it in the background instead by using the
|
|||||||
“``tahoe start``” command. To stop a node started in this way, use
|
“``tahoe start``” command. To stop a node started in this way, use
|
||||||
“``tahoe stop``”. ``tahoe --help`` gives a summary of all commands.
|
“``tahoe stop``”. ``tahoe --help`` gives a summary of all commands.
|
||||||
|
|
||||||
|
|
||||||
Running a Server or Introducer
|
Running a Server or Introducer
|
||||||
------------------------------
|
------------------------------
|
||||||
|
|
||||||
@ -67,6 +106,13 @@ URL the other nodes must use in order to connect to this introducer.
|
|||||||
(Note that “``tahoe run .``” doesn't work for introducers, this is a
|
(Note that “``tahoe run .``” doesn't work for introducers, this is a
|
||||||
known issue: `#937`_.)
|
known issue: `#937`_.)
|
||||||
|
|
||||||
|
You can distribute your Introducer fURL securely to new clients by using
|
||||||
|
the ``tahoe invite`` command. This will prepare some JSON to send to the
|
||||||
|
other side, request a `magic wormhole`_ code from
|
||||||
|
``wormhole.tahoe-lafs.org`` and print it out to the terminal. This
|
||||||
|
one-time code should be transmitted to the user of the client, who can
|
||||||
|
then run ``tahoe create-client --join <one-time-code>``.
|
||||||
|
|
||||||
Storage servers are created the same way: ``tahoe create-node
|
Storage servers are created the same way: ``tahoe create-node
|
||||||
--hostname=HOSTNAME .`` from a new directory. You'll need to provide the
|
--hostname=HOSTNAME .`` from a new directory. You'll need to provide the
|
||||||
introducer FURL (either as a ``--introducer=`` argument, or by editing
|
introducer FURL (either as a ``--introducer=`` argument, or by editing
|
||||||
@ -79,6 +125,7 @@ Tahoe-LAFS.
|
|||||||
.. _public test grid: https://tahoe-lafs.org/trac/tahoe-lafs/wiki/TestGrid
|
.. _public test grid: https://tahoe-lafs.org/trac/tahoe-lafs/wiki/TestGrid
|
||||||
.. _TestGrid page: https://tahoe-lafs.org/trac/tahoe-lafs/wiki/TestGrid
|
.. _TestGrid page: https://tahoe-lafs.org/trac/tahoe-lafs/wiki/TestGrid
|
||||||
.. _#937: https://tahoe-lafs.org/trac/tahoe-lafs/ticket/937
|
.. _#937: https://tahoe-lafs.org/trac/tahoe-lafs/ticket/937
|
||||||
|
.. _magic wormhole: https://magic-wormhole.io/
|
||||||
|
|
||||||
|
|
||||||
A note about small grids
|
A note about small grids
|
||||||
|
@ -24,8 +24,8 @@ def test_upload_immutable(reactor, temp_dir, introducer_furl, flog_gatherer, sto
|
|||||||
|
|
||||||
node_dir = join(temp_dir, 'edna')
|
node_dir = join(temp_dir, 'edna')
|
||||||
|
|
||||||
print("waiting 5 seconds unil we're maybe ready")
|
print("waiting 10 seconds unil we're maybe ready")
|
||||||
yield task.deferLater(reactor, 5, lambda: None)
|
yield task.deferLater(reactor, 10, lambda: None)
|
||||||
|
|
||||||
# upload a file, which should fail because we have don't have 7
|
# upload a file, which should fail because we have don't have 7
|
||||||
# storage servers (but happiness is set to 7)
|
# storage servers (but happiness is set to 7)
|
||||||
|
@ -91,6 +91,9 @@ install_requires = [
|
|||||||
"PyYAML >= 3.11",
|
"PyYAML >= 3.11",
|
||||||
|
|
||||||
"six >= 1.10.0",
|
"six >= 1.10.0",
|
||||||
|
|
||||||
|
# for 'tahoe invite' and 'tahoe join'
|
||||||
|
"magic-wormhole >= 0.10.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Includes some indirect dependencies, but does not include allmydata.
|
# Includes some indirect dependencies, but does not include allmydata.
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
|
|
||||||
import os, sys, urllib, textwrap
|
import os, sys, urllib, textwrap
|
||||||
import codecs
|
import codecs
|
||||||
|
from ConfigParser import NoSectionError
|
||||||
|
from os.path import join
|
||||||
from twisted.python import usage
|
from twisted.python import usage
|
||||||
from allmydata.util.assertutil import precondition
|
from allmydata.util.assertutil import precondition
|
||||||
from allmydata.util.encodingutil import unicode_to_url, quote_output, \
|
from allmydata.util.encodingutil import unicode_to_url, quote_output, \
|
||||||
@ -103,6 +105,26 @@ class NoDefaultBasedirOptions(BasedirOptions):
|
|||||||
DEFAULT_ALIAS = u"tahoe"
|
DEFAULT_ALIAS = u"tahoe"
|
||||||
|
|
||||||
|
|
||||||
|
def get_introducer_furl(nodedir, config):
|
||||||
|
"""
|
||||||
|
:return: the introducer FURL for the given node (no matter if it's
|
||||||
|
a client-type node or an introducer itself)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
introducer_furl = config.get('client', 'introducer.furl')
|
||||||
|
except NoSectionError:
|
||||||
|
# we're not a client; maybe this is running *on* the introducer?
|
||||||
|
try:
|
||||||
|
with open(join(nodedir, "private", "introducer.furl"), "r") as f:
|
||||||
|
introducer_furl = f.read().strip()
|
||||||
|
except IOError:
|
||||||
|
raise Exception(
|
||||||
|
"Can't find introducer FURL in tahoe.cfg nor "
|
||||||
|
"{}/private/introducer.furl".format(nodedir)
|
||||||
|
)
|
||||||
|
return introducer_furl
|
||||||
|
|
||||||
|
|
||||||
def get_aliases(nodedir):
|
def get_aliases(nodedir):
|
||||||
aliases = {}
|
aliases = {}
|
||||||
aliasfile = os.path.join(nodedir, "private", "aliases")
|
aliasfile = os.path.join(nodedir, "private", "aliases")
|
||||||
|
@ -1,11 +1,14 @@
|
|||||||
import os
|
import os
|
||||||
|
import json
|
||||||
|
|
||||||
from twisted.internet import reactor, defer
|
from twisted.internet import reactor, defer
|
||||||
from twisted.python.usage import UsageError
|
from twisted.python.usage import UsageError
|
||||||
from allmydata.scripts.common import BasedirOptions, NoDefaultBasedirOptions
|
from allmydata.scripts.common import BasedirOptions, NoDefaultBasedirOptions
|
||||||
from allmydata.scripts.default_nodedir import _default_nodedir
|
from allmydata.scripts.default_nodedir import _default_nodedir
|
||||||
from allmydata.util.assertutil import precondition
|
from allmydata.util.assertutil import precondition
|
||||||
from allmydata.util.encodingutil import listdir_unicode, argv_to_unicode, quote_local_unicode_path
|
from allmydata.util.encodingutil import listdir_unicode, argv_to_unicode, quote_local_unicode_path, get_io_encoding
|
||||||
from allmydata.util import fileutil, i2p_provider, iputil, tor_provider
|
from allmydata.util import fileutil, i2p_provider, iputil, tor_provider
|
||||||
|
from wormhole import wormhole
|
||||||
|
|
||||||
|
|
||||||
dummy_tac = """
|
dummy_tac = """
|
||||||
@ -80,7 +83,7 @@ def validate_where_options(o):
|
|||||||
else:
|
else:
|
||||||
# no --location and --port? expect --listen= (maybe the default), and
|
# no --location and --port? expect --listen= (maybe the default), and
|
||||||
# --listen=tcp requires --hostname. But --listen=none is special.
|
# --listen=tcp requires --hostname. But --listen=none is special.
|
||||||
if o['listen'] != "none":
|
if o['listen'] != "none" and o.get('join', None) is None:
|
||||||
listeners = o['listen'].split(",")
|
listeners = o['listen'].split(",")
|
||||||
for l in listeners:
|
for l in listeners:
|
||||||
if l not in ["tcp", "tor", "i2p"]:
|
if l not in ["tcp", "tor", "i2p"]:
|
||||||
@ -155,6 +158,7 @@ class CreateClientOptions(_CreateBaseOptions):
|
|||||||
("shares-needed", None, 3, "Needed shares required for uploaded files."),
|
("shares-needed", None, 3, "Needed shares required for uploaded files."),
|
||||||
("shares-happy", None, 7, "How many servers new files must be placed on."),
|
("shares-happy", None, 7, "How many servers new files must be placed on."),
|
||||||
("shares-total", None, 10, "Total shares required for uploaded files."),
|
("shares-total", None, 10, "Total shares required for uploaded files."),
|
||||||
|
("join", None, None, "Join a grid with the given Invite Code."),
|
||||||
]
|
]
|
||||||
|
|
||||||
# This is overridden in order to ensure we get a "Wrong number of
|
# This is overridden in order to ensure we get a "Wrong number of
|
||||||
@ -325,6 +329,45 @@ def write_client_config(c, config):
|
|||||||
c.write("enabled = false\n")
|
c.write("enabled = false\n")
|
||||||
c.write("\n")
|
c.write("\n")
|
||||||
|
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def _get_config_via_wormhole(config):
|
||||||
|
out = config.stdout
|
||||||
|
print >>out, "Opening wormhole with code '{}'".format(config['join'])
|
||||||
|
relay_url = config.parent['wormhole-server']
|
||||||
|
print >>out, "Connecting to '{}'".format(relay_url)
|
||||||
|
|
||||||
|
wh = wormhole.create(
|
||||||
|
appid=config.parent['wormhole-invite-appid'],
|
||||||
|
relay_url=relay_url,
|
||||||
|
reactor=reactor,
|
||||||
|
)
|
||||||
|
code = unicode(config['join'])
|
||||||
|
wh.set_code(code)
|
||||||
|
yield wh.get_welcome()
|
||||||
|
print >>out, "Connected to wormhole server"
|
||||||
|
|
||||||
|
intro = {
|
||||||
|
u"abilities": {
|
||||||
|
"client-v1": {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wh.send_message(json.dumps(intro))
|
||||||
|
|
||||||
|
server_intro = yield wh.get_message()
|
||||||
|
server_intro = json.loads(server_intro)
|
||||||
|
|
||||||
|
print >>out, " received server introduction"
|
||||||
|
if u'abilities' not in server_intro:
|
||||||
|
raise RuntimeError(" Expected 'abilities' in server introduction")
|
||||||
|
if u'server-v1' not in server_intro['abilities']:
|
||||||
|
raise RuntimeError(" Expected 'server-v1' in server abilities")
|
||||||
|
|
||||||
|
remote_data = yield wh.get_message()
|
||||||
|
print >>out, " received configuration"
|
||||||
|
defer.returnValue(json.loads(remote_data))
|
||||||
|
|
||||||
|
|
||||||
@defer.inlineCallbacks
|
@defer.inlineCallbacks
|
||||||
def create_node(config):
|
def create_node(config):
|
||||||
out = config.stdout
|
out = config.stdout
|
||||||
@ -344,6 +387,37 @@ def create_node(config):
|
|||||||
os.mkdir(basedir)
|
os.mkdir(basedir)
|
||||||
write_tac(basedir, "client")
|
write_tac(basedir, "client")
|
||||||
|
|
||||||
|
# if we're doing magic-wormhole stuff, do it now
|
||||||
|
if config['join'] is not None:
|
||||||
|
try:
|
||||||
|
remote_config = yield _get_config_via_wormhole(config)
|
||||||
|
except RuntimeError as e:
|
||||||
|
print >>err, str(e)
|
||||||
|
defer.returnValue(1)
|
||||||
|
|
||||||
|
# configuration we'll allow the inviter to set
|
||||||
|
whitelist = [
|
||||||
|
'shares-happy', 'shares-needed', 'shares-total',
|
||||||
|
'introducer', 'nickname',
|
||||||
|
]
|
||||||
|
sensitive_keys = ['introducer']
|
||||||
|
|
||||||
|
print >>out, "Encoding: {shares-needed} of {shares-total} shares, on at least {shares-happy} servers".format(**remote_config)
|
||||||
|
print >>out, "Overriding the following config:"
|
||||||
|
|
||||||
|
for k in whitelist:
|
||||||
|
v = remote_config.get(k, None)
|
||||||
|
if v is not None:
|
||||||
|
# we're faking usually argv-supplied options :/
|
||||||
|
if isinstance(v, unicode):
|
||||||
|
v = v.encode(get_io_encoding())
|
||||||
|
config[k] = v
|
||||||
|
if k not in sensitive_keys:
|
||||||
|
if k not in ['shares-happy', 'shares-total', 'shares-needed']:
|
||||||
|
print >>out, " {}: {}".format(k, v)
|
||||||
|
else:
|
||||||
|
print >>out, " {}: [sensitive data; see tahoe.cfg]".format(k)
|
||||||
|
|
||||||
fileutil.make_dirs(os.path.join(basedir, "private"), 0700)
|
fileutil.make_dirs(os.path.join(basedir, "private"), 0700)
|
||||||
with open(os.path.join(basedir, "tahoe.cfg"), "w") as c:
|
with open(os.path.join(basedir, "tahoe.cfg"), "w") as c:
|
||||||
yield write_node_config(c, config)
|
yield write_node_config(c, config)
|
||||||
|
@ -7,7 +7,7 @@ from twisted.internet import defer, task, threads
|
|||||||
|
|
||||||
from allmydata.scripts.common import get_default_nodedir
|
from allmydata.scripts.common import get_default_nodedir
|
||||||
from allmydata.scripts import debug, create_node, startstop_node, cli, \
|
from allmydata.scripts import debug, create_node, startstop_node, cli, \
|
||||||
stats_gatherer, admin, magic_folder_cli
|
stats_gatherer, admin, magic_folder_cli, tahoe_invite
|
||||||
from allmydata.util.encodingutil import quote_output, quote_local_unicode_path, get_io_encoding
|
from allmydata.util.encodingutil import quote_output, quote_local_unicode_path, get_io_encoding
|
||||||
|
|
||||||
def GROUP(s):
|
def GROUP(s):
|
||||||
@ -47,6 +47,8 @@ class Options(usage.Options):
|
|||||||
+ GROUP("Using the file store")
|
+ GROUP("Using the file store")
|
||||||
+ cli.subCommands
|
+ cli.subCommands
|
||||||
+ magic_folder_cli.subCommands
|
+ magic_folder_cli.subCommands
|
||||||
|
+ GROUP("Grid Management")
|
||||||
|
+ tahoe_invite.subCommands
|
||||||
)
|
)
|
||||||
|
|
||||||
optFlags = [
|
optFlags = [
|
||||||
@ -56,6 +58,8 @@ class Options(usage.Options):
|
|||||||
]
|
]
|
||||||
optParameters = [
|
optParameters = [
|
||||||
["node-directory", "d", None, NODEDIR_HELP],
|
["node-directory", "d", None, NODEDIR_HELP],
|
||||||
|
["wormhole-server", None, u"ws://wormhole.tahoe-lafs.org:4000/v1", "The magic wormhole server to use.", unicode],
|
||||||
|
["wormhole-invite-appid", None, u"tahoe-lafs.org/invite", "The appid to use on the wormhole server.", unicode],
|
||||||
]
|
]
|
||||||
|
|
||||||
def opt_version(self):
|
def opt_version(self):
|
||||||
@ -139,6 +143,8 @@ def dispatch(config,
|
|||||||
# same
|
# same
|
||||||
f0 = magic_folder_cli.dispatch[command]
|
f0 = magic_folder_cli.dispatch[command]
|
||||||
f = lambda so: threads.deferToThread(f0, so)
|
f = lambda so: threads.deferToThread(f0, so)
|
||||||
|
elif command in tahoe_invite.dispatch:
|
||||||
|
f = tahoe_invite.dispatch[command]
|
||||||
else:
|
else:
|
||||||
raise usage.UsageError()
|
raise usage.UsageError()
|
||||||
|
|
||||||
|
109
src/allmydata/scripts/tahoe_invite.py
Normal file
109
src/allmydata/scripts/tahoe_invite.py
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import json
|
||||||
|
from os.path import join
|
||||||
|
|
||||||
|
from twisted.python import usage
|
||||||
|
from twisted.internet import defer, reactor
|
||||||
|
|
||||||
|
from wormhole import wormhole
|
||||||
|
|
||||||
|
from allmydata.util import configutil
|
||||||
|
from allmydata.util.encodingutil import argv_to_abspath
|
||||||
|
from allmydata.scripts.common import get_default_nodedir, get_introducer_furl
|
||||||
|
|
||||||
|
|
||||||
|
class InviteOptions(usage.Options):
|
||||||
|
synopsis = "[options] <nickname>"
|
||||||
|
description = "Create a client-only Tahoe-LAFS node (no storage server)."
|
||||||
|
|
||||||
|
optParameters = [
|
||||||
|
("shares-needed", None, None, "How many shares are needed to reconstruct files from this node"),
|
||||||
|
("shares-happy", None, None, "Distinct storage servers new node will upload shares to"),
|
||||||
|
("shares-total", None, None, "Total number of shares new node will upload"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def parseArgs(self, *args):
|
||||||
|
if len(args) != 1:
|
||||||
|
raise usage.UsageError(
|
||||||
|
"Provide a single argument: the new node's nickname"
|
||||||
|
)
|
||||||
|
self['nick'] = args[0].strip()
|
||||||
|
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def _send_config_via_wormhole(options, config):
|
||||||
|
out = options.stdout
|
||||||
|
err = options.stderr
|
||||||
|
relay_url = options.parent['wormhole-server']
|
||||||
|
print >>out, "Connecting to '{}'...".format(relay_url)
|
||||||
|
wh = wormhole.create(
|
||||||
|
appid=options.parent['wormhole-invite-appid'],
|
||||||
|
relay_url=relay_url,
|
||||||
|
reactor=reactor,
|
||||||
|
)
|
||||||
|
yield wh.get_welcome()
|
||||||
|
print >>out, "Connected to wormhole server"
|
||||||
|
|
||||||
|
# must call allocate_code before get_code will ever succeed
|
||||||
|
wh.allocate_code()
|
||||||
|
code = yield wh.get_code()
|
||||||
|
print >>out, "Invite Code for client: {}".format(code)
|
||||||
|
|
||||||
|
wh.send_message(json.dumps({
|
||||||
|
u"abilities": {
|
||||||
|
u"server-v1": {},
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
client_intro = yield wh.get_message()
|
||||||
|
print >>out, " received client introduction"
|
||||||
|
client_intro = json.loads(client_intro)
|
||||||
|
if not u'abilities' in client_intro:
|
||||||
|
print >>err, "No 'abilities' from client"
|
||||||
|
defer.returnValue(1)
|
||||||
|
if not u'client-v1' in client_intro[u'abilities']:
|
||||||
|
print >>err, "No 'client-v1' in abilities from client"
|
||||||
|
defer.returnValue(1)
|
||||||
|
|
||||||
|
print >>out, " transmitting configuration"
|
||||||
|
wh.send_message(json.dumps(config))
|
||||||
|
yield wh.close()
|
||||||
|
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def invite(options):
|
||||||
|
if options.parent['node-directory']:
|
||||||
|
basedir = argv_to_abspath(options.parent['node-directory'])
|
||||||
|
else:
|
||||||
|
basedir = get_default_nodedir()
|
||||||
|
config = configutil.get_config(join(basedir, 'tahoe.cfg'))
|
||||||
|
out = options.stdout
|
||||||
|
err = options.stderr
|
||||||
|
|
||||||
|
try:
|
||||||
|
introducer_furl = get_introducer_furl(basedir, config)
|
||||||
|
except Exception as e:
|
||||||
|
print >>err, "Can't find introducer FURL for node '{}': {}".format(basedir, str(e))
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
nick = options['nick']
|
||||||
|
|
||||||
|
remote_config = {
|
||||||
|
"shares-needed": options["shares-needed"] or config.get('client', 'shares.needed'),
|
||||||
|
"shares-total": options["shares-total"] or config.get('client', 'shares.total'),
|
||||||
|
"shares-happy": options["shares-happy"] or config.get('client', 'shares.happy'),
|
||||||
|
"nickname": nick,
|
||||||
|
"introducer": introducer_furl,
|
||||||
|
}
|
||||||
|
|
||||||
|
yield _send_config_via_wormhole(options, remote_config)
|
||||||
|
print >>out, "Completed successfully"
|
||||||
|
|
||||||
|
|
||||||
|
subCommands = [
|
||||||
|
("invite", None, InviteOptions,
|
||||||
|
"Invite a new node to this grid"),
|
||||||
|
]
|
||||||
|
|
||||||
|
dispatch = {
|
||||||
|
"invite": invite,
|
||||||
|
}
|
346
src/allmydata/test/cli/test_invite.py
Normal file
346
src/allmydata/test/cli/test_invite.py
Normal file
@ -0,0 +1,346 @@
|
|||||||
|
import os
|
||||||
|
import mock
|
||||||
|
import json
|
||||||
|
from os.path import join
|
||||||
|
|
||||||
|
from twisted.trial import unittest
|
||||||
|
from twisted.internet import defer
|
||||||
|
from ..common_util import run_cli
|
||||||
|
from ..no_network import GridTestMixin
|
||||||
|
from .common import CLITestMixin
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeWormhole(object):
|
||||||
|
|
||||||
|
def __init__(self, outgoing_messages):
|
||||||
|
self.messages = []
|
||||||
|
self._outgoing = outgoing_messages
|
||||||
|
|
||||||
|
def get_code(self):
|
||||||
|
return defer.succeed(u"6-alarmist-tuba")
|
||||||
|
|
||||||
|
def set_code(self, code):
|
||||||
|
self._code = code
|
||||||
|
|
||||||
|
def get_welcome(self):
|
||||||
|
return defer.succeed(
|
||||||
|
json.dumps({
|
||||||
|
u"welcome": {},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
def allocate_code(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def send_message(self, msg):
|
||||||
|
self.messages.append(msg)
|
||||||
|
|
||||||
|
def get_message(self):
|
||||||
|
return defer.succeed(self._outgoing.pop(0))
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
return defer.succeed(None)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_fake_wormhole(outgoing_messages):
|
||||||
|
return _FakeWormhole(outgoing_messages)
|
||||||
|
|
||||||
|
|
||||||
|
class Join(GridTestMixin, CLITestMixin, unittest.TestCase):
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def setUp(self):
|
||||||
|
self.basedir = self.mktemp()
|
||||||
|
yield super(Join, self).setUp()
|
||||||
|
yield self.set_up_grid(oneshare=True)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def test_create_node_join(self):
|
||||||
|
"""
|
||||||
|
successfully join after an invite
|
||||||
|
"""
|
||||||
|
node_dir = self.mktemp()
|
||||||
|
|
||||||
|
with mock.patch('allmydata.scripts.create_node.wormhole') as w:
|
||||||
|
fake_wh = _create_fake_wormhole([
|
||||||
|
json.dumps({u"abilities": {u"server-v1": {}}}),
|
||||||
|
json.dumps({
|
||||||
|
u"shares-needed": 1,
|
||||||
|
u"shares-happy": 1,
|
||||||
|
u"shares-total": 1,
|
||||||
|
u"nickname": u"somethinghopefullyunique",
|
||||||
|
u"introducer": u"pb://foo",
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
w.create = mock.Mock(return_value=fake_wh)
|
||||||
|
|
||||||
|
rc, out, err = yield run_cli(
|
||||||
|
"create-client",
|
||||||
|
"--join", "1-abysmal-ant",
|
||||||
|
node_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(0, rc)
|
||||||
|
with open(join(node_dir, 'tahoe.cfg'), 'r') as f:
|
||||||
|
config = f.read()
|
||||||
|
self.assertIn("pb://foo", config)
|
||||||
|
self.assertIn(u"somethinghopefullyunique", config)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def test_create_node_illegal_option(self):
|
||||||
|
"""
|
||||||
|
Server sends JSON with unknown/illegal key
|
||||||
|
"""
|
||||||
|
node_dir = self.mktemp()
|
||||||
|
|
||||||
|
with mock.patch('allmydata.scripts.create_node.wormhole') as w:
|
||||||
|
fake_wh = _create_fake_wormhole([
|
||||||
|
json.dumps({u"abilities": {u"server-v1": {}}}),
|
||||||
|
json.dumps({
|
||||||
|
u"shares-needed": 1,
|
||||||
|
u"shares-happy": 1,
|
||||||
|
u"shares-total": 1,
|
||||||
|
u"nickname": u"somethinghopefullyunique",
|
||||||
|
u"introducer": u"pb://foo",
|
||||||
|
u"something-else": u"not allowed",
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
w.create = mock.Mock(return_value=fake_wh)
|
||||||
|
|
||||||
|
rc, out, err = yield run_cli(
|
||||||
|
"create-client",
|
||||||
|
"--join", "1-abysmal-ant",
|
||||||
|
node_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
# should still succeed -- just ignores the not-whitelisted
|
||||||
|
# "something-else" option
|
||||||
|
self.assertEqual(0, rc)
|
||||||
|
|
||||||
|
|
||||||
|
class Invite(GridTestMixin, CLITestMixin, unittest.TestCase):
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def setUp(self):
|
||||||
|
self.basedir = self.mktemp()
|
||||||
|
yield super(Invite, self).setUp()
|
||||||
|
yield self.set_up_grid(oneshare=True)
|
||||||
|
intro_dir = os.path.join(self.basedir, "introducer")
|
||||||
|
yield run_cli(
|
||||||
|
"create-introducer",
|
||||||
|
"--listen", "none",
|
||||||
|
intro_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def test_invite_success(self):
|
||||||
|
"""
|
||||||
|
successfully send an invite
|
||||||
|
"""
|
||||||
|
intro_dir = os.path.join(self.basedir, "introducer")
|
||||||
|
# we've never run the introducer, so it hasn't created
|
||||||
|
# introducer.furl yet
|
||||||
|
priv_dir = join(intro_dir, "private")
|
||||||
|
with open(join(priv_dir, "introducer.furl"), "w") as f:
|
||||||
|
f.write("pb://fooblam\n")
|
||||||
|
|
||||||
|
with mock.patch('allmydata.scripts.tahoe_invite.wormhole') as w:
|
||||||
|
fake_wh = _create_fake_wormhole([
|
||||||
|
json.dumps({u"abilities": {u"client-v1": {}}}),
|
||||||
|
])
|
||||||
|
w.create = mock.Mock(return_value=fake_wh)
|
||||||
|
|
||||||
|
rc, out, err = yield run_cli(
|
||||||
|
"-d", intro_dir,
|
||||||
|
"invite",
|
||||||
|
"--shares-needed", "1",
|
||||||
|
"--shares-happy", "1",
|
||||||
|
"--shares-total", "1",
|
||||||
|
"foo",
|
||||||
|
)
|
||||||
|
self.assertEqual(2, len(fake_wh.messages))
|
||||||
|
self.assertEqual(
|
||||||
|
json.loads(fake_wh.messages[0]),
|
||||||
|
{
|
||||||
|
"abilities":
|
||||||
|
{
|
||||||
|
"server-v1": {}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
json.loads(fake_wh.messages[1]),
|
||||||
|
{
|
||||||
|
"shares-needed": "1",
|
||||||
|
"shares-total": "1",
|
||||||
|
"nickname": "foo",
|
||||||
|
"introducer": "pb://fooblam",
|
||||||
|
"shares-happy": "1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def test_invite_no_furl(self):
|
||||||
|
"""
|
||||||
|
Invites must include the Introducer FURL
|
||||||
|
"""
|
||||||
|
intro_dir = os.path.join(self.basedir, "introducer")
|
||||||
|
|
||||||
|
with mock.patch('allmydata.scripts.tahoe_invite.wormhole') as w:
|
||||||
|
fake_wh = _create_fake_wormhole([
|
||||||
|
json.dumps({u"abilities": {u"client-v1": {}}}),
|
||||||
|
])
|
||||||
|
w.create = mock.Mock(return_value=fake_wh)
|
||||||
|
|
||||||
|
rc, out, err = yield run_cli(
|
||||||
|
"-d", intro_dir,
|
||||||
|
"invite",
|
||||||
|
"--shares-needed", "1",
|
||||||
|
"--shares-happy", "1",
|
||||||
|
"--shares-total", "1",
|
||||||
|
"foo",
|
||||||
|
)
|
||||||
|
self.assertNotEqual(rc, 0)
|
||||||
|
self.assertIn(u"Can't find introducer FURL", out + err)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def test_invite_wrong_client_abilities(self):
|
||||||
|
"""
|
||||||
|
Send unknown client version
|
||||||
|
"""
|
||||||
|
intro_dir = os.path.join(self.basedir, "introducer")
|
||||||
|
# we've never run the introducer, so it hasn't created
|
||||||
|
# introducer.furl yet
|
||||||
|
priv_dir = join(intro_dir, "private")
|
||||||
|
with open(join(priv_dir, "introducer.furl"), "w") as f:
|
||||||
|
f.write("pb://fooblam\n")
|
||||||
|
|
||||||
|
with mock.patch('allmydata.scripts.tahoe_invite.wormhole') as w:
|
||||||
|
fake_wh = _create_fake_wormhole([
|
||||||
|
json.dumps({u"abilities": {u"client-v9000": {}}}),
|
||||||
|
])
|
||||||
|
w.create = mock.Mock(return_value=fake_wh)
|
||||||
|
|
||||||
|
rc, out, err = yield run_cli(
|
||||||
|
"-d", intro_dir,
|
||||||
|
"invite",
|
||||||
|
"--shares-needed", "1",
|
||||||
|
"--shares-happy", "1",
|
||||||
|
"--shares-total", "1",
|
||||||
|
"foo",
|
||||||
|
)
|
||||||
|
self.assertNotEqual(rc, 0)
|
||||||
|
self.assertIn(u"No 'client-v1' in abilities", out + err)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def test_invite_no_client_abilities(self):
|
||||||
|
"""
|
||||||
|
Client doesn't send any client abilities at all
|
||||||
|
"""
|
||||||
|
intro_dir = os.path.join(self.basedir, "introducer")
|
||||||
|
# we've never run the introducer, so it hasn't created
|
||||||
|
# introducer.furl yet
|
||||||
|
priv_dir = join(intro_dir, "private")
|
||||||
|
with open(join(priv_dir, "introducer.furl"), "w") as f:
|
||||||
|
f.write("pb://fooblam\n")
|
||||||
|
|
||||||
|
with mock.patch('allmydata.scripts.tahoe_invite.wormhole') as w:
|
||||||
|
fake_wh = _create_fake_wormhole([
|
||||||
|
json.dumps({}),
|
||||||
|
])
|
||||||
|
w.create = mock.Mock(return_value=fake_wh)
|
||||||
|
|
||||||
|
rc, out, err = yield run_cli(
|
||||||
|
"-d", intro_dir,
|
||||||
|
"invite",
|
||||||
|
"--shares-needed", "1",
|
||||||
|
"--shares-happy", "1",
|
||||||
|
"--shares-total", "1",
|
||||||
|
"foo",
|
||||||
|
)
|
||||||
|
self.assertNotEqual(rc, 0)
|
||||||
|
self.assertIn(u"No 'abilities' from client", out + err)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def test_invite_wrong_server_abilities(self):
|
||||||
|
"""
|
||||||
|
Server sends unknown version
|
||||||
|
"""
|
||||||
|
intro_dir = os.path.join(self.basedir, "introducer")
|
||||||
|
# we've never run the introducer, so it hasn't created
|
||||||
|
# introducer.furl yet
|
||||||
|
priv_dir = join(intro_dir, "private")
|
||||||
|
with open(join(priv_dir, "introducer.furl"), "w") as f:
|
||||||
|
f.write("pb://fooblam\n")
|
||||||
|
|
||||||
|
with mock.patch('allmydata.scripts.create_node.wormhole') as w:
|
||||||
|
fake_wh = _create_fake_wormhole([
|
||||||
|
json.dumps({u"abilities": {u"server-v9000": {}}}),
|
||||||
|
json.dumps({
|
||||||
|
"shares-needed": "1",
|
||||||
|
"shares-total": "1",
|
||||||
|
"shares-happy": "1",
|
||||||
|
"nickname": "foo",
|
||||||
|
"introducer": "pb://fooblam",
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
w.create = mock.Mock(return_value=fake_wh)
|
||||||
|
|
||||||
|
rc, out, err = yield run_cli(
|
||||||
|
"create-client",
|
||||||
|
"--join", "1-alarmist-tuba",
|
||||||
|
"foo",
|
||||||
|
)
|
||||||
|
self.assertNotEqual(rc, 0)
|
||||||
|
self.assertIn("Expected 'server-v1' in server abilities", out + err)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def test_invite_no_server_abilities(self):
|
||||||
|
"""
|
||||||
|
Server sends unknown version
|
||||||
|
"""
|
||||||
|
intro_dir = os.path.join(self.basedir, "introducer")
|
||||||
|
# we've never run the introducer, so it hasn't created
|
||||||
|
# introducer.furl yet
|
||||||
|
priv_dir = join(intro_dir, "private")
|
||||||
|
with open(join(priv_dir, "introducer.furl"), "w") as f:
|
||||||
|
f.write("pb://fooblam\n")
|
||||||
|
|
||||||
|
with mock.patch('allmydata.scripts.create_node.wormhole') as w:
|
||||||
|
fake_wh = _create_fake_wormhole([
|
||||||
|
json.dumps({}),
|
||||||
|
json.dumps({
|
||||||
|
"shares-needed": "1",
|
||||||
|
"shares-total": "1",
|
||||||
|
"shares-happy": "1",
|
||||||
|
"nickname": "bar",
|
||||||
|
"introducer": "pb://fooblam",
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
w.create = mock.Mock(return_value=fake_wh)
|
||||||
|
|
||||||
|
rc, out, err = yield run_cli(
|
||||||
|
"create-client",
|
||||||
|
"--join", "1-alarmist-tuba",
|
||||||
|
"bar",
|
||||||
|
)
|
||||||
|
self.assertNotEqual(rc, 0)
|
||||||
|
self.assertIn("Expected 'abilities' in server introduction", out + err)
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def test_invite_no_nick(self):
|
||||||
|
"""
|
||||||
|
Should still work if server sends no nickname
|
||||||
|
"""
|
||||||
|
intro_dir = os.path.join(self.basedir, "introducer")
|
||||||
|
|
||||||
|
with mock.patch('allmydata.scripts.tahoe_invite.wormhole'):
|
||||||
|
rc, out, err = yield run_cli(
|
||||||
|
"-d", intro_dir,
|
||||||
|
"invite",
|
||||||
|
"--shares-needed", "1",
|
||||||
|
"--shares-happy", "1",
|
||||||
|
"--shares-total", "1",
|
||||||
|
)
|
||||||
|
self.assertTrue(rc)
|
||||||
|
self.assertIn(u"Provide a single argument", out + err)
|
Loading…
Reference in New Issue
Block a user