source: trunk/src/allmydata/scripts/create_node.py

Last change on this file was 02a696d7, checked in by Jean-Paul Calderone <exarkun@…>, at 2023-07-21T12:13:33Z

Make merge_config fail on overlapping configs

This isn't expected to happen. If it does it would be nice to see it instead
of silently continue working with some config dropped on the floor.

  • Property mode set to 100644
File size: 20.9 KB
Line 
1
2from __future__ import annotations
3
4from typing import Optional
5
6import io
7import os
8
9from allmydata.scripts.types_ import (
10    SubCommands,
11    Parameters,
12    Flags,
13)
14
15from twisted.internet import reactor, defer
16from twisted.python.usage import UsageError
17from twisted.python.filepath import (
18    FilePath,
19)
20
21from allmydata.scripts.common import (
22    BasedirOptions,
23    NoDefaultBasedirOptions,
24    write_introducer,
25)
26from allmydata.scripts.default_nodedir import _default_nodedir
27from allmydata.util import dictutil
28from allmydata.util.assertutil import precondition
29from allmydata.util.encodingutil import listdir_unicode, argv_to_unicode, quote_local_unicode_path, get_io_encoding
30
31i2p_provider: Listener
32tor_provider: Listener
33
34from allmydata.util import fileutil, i2p_provider, tor_provider, jsonbytes as json
35
36from ..listeners import ListenerConfig, Listener, TCPProvider, StaticProvider
37
38def _get_listeners() -> dict[str, Listener]:
39    """
40    Get all of the kinds of listeners we might be able to use.
41    """
42    return {
43        "tor": tor_provider,
44        "i2p": i2p_provider,
45        "tcp": TCPProvider(),
46        "none": StaticProvider(
47            available=True,
48            hide_ip=False,
49            config=defer.succeed(None),
50            # This is supposed to be an IAddressFamily but we have none for
51            # this kind of provider.  We could implement new client and server
52            # endpoint types that always fail and pass an IAddressFamily here
53            # that uses those.  Nothing would ever even ask for them (at
54            # least, yet), let alone try to use them, so that's a lot of extra
55            # work for no practical result so I'm not doing it now.
56            address=None, # type: ignore[arg-type]
57        ),
58    }
59
60_LISTENERS = _get_listeners()
61
62dummy_tac = """
63import sys
64print("Nodes created by Tahoe-LAFS v1.11.0 or later cannot be run by")
65print("releases of Tahoe-LAFS before v1.10.0.")
66sys.exit(1)
67"""
68
69def write_tac(basedir, nodetype):
70    fileutil.write(os.path.join(basedir, "tahoe-%s.tac" % (nodetype,)), dummy_tac)
71
72
73WHERE_OPTS : Parameters = [
74    ("location", None, None,
75     "Server location to advertise (e.g. tcp:example.org:12345)"),
76    ("port", None, None,
77     "Server endpoint to listen on (e.g. tcp:12345, or tcp:12345:interface=127.0.0.1."),
78    ("hostname", None, None,
79     "Hostname to automatically set --location/--port when --listen=tcp"),
80    ("listen", None, "tcp",
81     "Comma-separated list of listener types (tcp,tor,i2p,none)."),
82]
83
84TOR_OPTS : Parameters = [
85    ("tor-control-port", None, None,
86     "Tor's control port endpoint descriptor string (e.g. tcp:127.0.0.1:9051 or unix:/var/run/tor/control)"),
87    ("tor-executable", None, None,
88     "The 'tor' executable to run (default is to search $PATH)."),
89]
90
91TOR_FLAGS : Flags = [
92    ("tor-launch", None, "Launch a tor instead of connecting to a tor control port."),
93]
94
95I2P_OPTS : Parameters = [
96    ("i2p-sam-port", None, None,
97     "I2P's SAM API port endpoint descriptor string (e.g. tcp:127.0.0.1:7656)"),
98    ("i2p-executable", None, None,
99     "(future) The 'i2prouter' executable to run (default is to search $PATH)."),
100]
101
102I2P_FLAGS : Flags = [
103    ("i2p-launch", None, "(future) Launch an I2P router instead of connecting to a SAM API port."),
104]
105
106def validate_where_options(o):
107    if o['listen'] == "none":
108        # no other arguments are accepted
109        if o['hostname']:
110            raise UsageError("--hostname cannot be used when --listen=none")
111        if o['port'] or o['location']:
112            raise UsageError("--port/--location cannot be used when --listen=none")
113    # --location and --port: overrides all others, rejects all others
114    if o['location'] and not o['port']:
115        raise UsageError("--location must be used with --port")
116    if o['port'] and not o['location']:
117        raise UsageError("--port must be used with --location")
118
119    if o['location'] and o['port']:
120        if o['hostname']:
121            raise UsageError("--hostname cannot be used with --location/--port")
122        # TODO: really, we should reject an explicit --listen= option (we
123        # want them to omit it entirely, because --location/--port would
124        # override anything --listen= might allocate). For now, just let it
125        # pass, because that allows us to use --listen=tcp as the default in
126        # optParameters, which (I think) gets included in the rendered --help
127        # output, which is useful. In the future, let's reconsider the value
128        # of that --help text (or achieve that documentation in some other
129        # way), change the default to None, complain here if it's not None,
130        # then change parseArgs() to transform the None into "tcp"
131    else:
132        # no --location and --port? expect --listen= (maybe the default), and
133        # --listen=tcp requires --hostname. But --listen=none is special.
134        if o['listen'] != "none" and o.get('join', None) is None:
135            listeners = o['listen'].split(",")
136            for l in listeners:
137                if l not in _LISTENERS:
138                    raise UsageError(
139                        "--listen= must be one/some of: "
140                        f"{', '.join(sorted(_LISTENERS))}",
141                    )
142            if 'tcp' in listeners and not o['hostname']:
143                raise UsageError("--listen=tcp requires --hostname=")
144            if 'tcp' not in listeners and o['hostname']:
145                raise UsageError("--listen= must be tcp to use --hostname")
146
147def validate_tor_options(o):
148    use_tor = "tor" in o["listen"].split(",")
149    if use_tor or any((o["tor-launch"], o["tor-control-port"])):
150        if not _LISTENERS["tor"].is_available():
151            raise UsageError(
152                "Specifying any Tor options requires the 'txtorcon' module"
153            )
154    if not use_tor:
155        if o["tor-launch"]:
156            raise UsageError("--tor-launch requires --listen=tor")
157        if o["tor-control-port"]:
158            raise UsageError("--tor-control-port= requires --listen=tor")
159    if o["tor-launch"] and o["tor-control-port"]:
160        raise UsageError("use either --tor-launch or --tor-control-port=, not both")
161
162def validate_i2p_options(o):
163    use_i2p = "i2p" in o["listen"].split(",")
164    if use_i2p or any((o["i2p-launch"], o["i2p-sam-port"])):
165        if not _LISTENERS["i2p"].is_available():
166            raise UsageError(
167                "Specifying any I2P options requires the 'txi2p' module"
168            )
169    if not use_i2p:
170        if o["i2p-launch"]:
171            raise UsageError("--i2p-launch requires --listen=i2p")
172        if o["i2p-sam-port"]:
173            raise UsageError("--i2p-sam-port= requires --listen=i2p")
174    if o["i2p-launch"] and o["i2p-sam-port"]:
175        raise UsageError("use either --i2p-launch or --i2p-sam-port=, not both")
176    if o["i2p-launch"]:
177        raise UsageError("--i2p-launch is under development")
178
179class _CreateBaseOptions(BasedirOptions):
180    optFlags = [
181        ("hide-ip", None, "prohibit any configuration that would reveal the node's IP address"),
182        ]
183
184    def postOptions(self):
185        super(_CreateBaseOptions, self).postOptions()
186        if self['hide-ip']:
187            ip_hiders = dictutil.filter(lambda v: v.can_hide_ip(), _LISTENERS)
188            available = dictutil.filter(lambda v: v.is_available(), ip_hiders)
189            if not available:
190                raise UsageError(
191                    "--hide-ip was specified but no IP-hiding listener is installed.\n"
192                    "Try one of these:\n" +
193                    "".join([
194                        f"\tpip install tahoe-lafs[{name}]\n"
195                        for name
196                        in ip_hiders
197                    ])
198                )
199
200class CreateClientOptions(_CreateBaseOptions):
201    synopsis = "[options] [NODEDIR]"
202    description = "Create a client-only Tahoe-LAFS node (no storage server)."
203
204    optParameters = [
205        # we provide 'create-node'-time options for the most common
206        # configuration knobs. The rest can be controlled by editing
207        # tahoe.cfg before node startup.
208
209        ("nickname", "n", None, "Specify the nickname for this node."),
210        ("introducer", "i", None, "Specify the introducer FURL to use."),
211        ("webport", "p", "tcp:3456:interface=127.0.0.1",
212         "Specify which TCP port to run the HTTP interface on. Use 'none' to disable."),
213        ("basedir", "C", None, "Specify which Tahoe base directory should be used. This has the same effect as the global --node-directory option. [default: %s]"
214         % quote_local_unicode_path(_default_nodedir)),
215        ("shares-needed", None, 3, "Needed shares required for uploaded files."),
216        ("shares-happy", None, 7, "How many servers new files must be placed on."),
217        ("shares-total", None, 10, "Total shares required for uploaded files."),
218        ("join", None, None, "Join a grid with the given Invite Code."),
219        ] # type: Parameters
220
221    # This is overridden in order to ensure we get a "Wrong number of
222    # arguments." error when more than one argument is given.
223    def parseArgs(self, basedir=None):
224        BasedirOptions.parseArgs(self, basedir)
225        for name in ["shares-needed", "shares-happy", "shares-total"]:
226            try:
227                int(self[name])
228            except ValueError:
229                raise UsageError(
230                    "--{} must be an integer".format(name)
231                )
232
233
234class CreateNodeOptions(CreateClientOptions):
235    optFlags = [
236        ("no-storage", None, "Do not offer storage service to other nodes."),
237        ("storage-dir", None, "Path where the storage will be placed."),
238        ("helper", None, "Enable helper"),
239    ] + TOR_FLAGS + I2P_FLAGS
240
241    synopsis = "[options] [NODEDIR]"
242    description = "Create a full Tahoe-LAFS node (client+server)."
243    optParameters = CreateClientOptions.optParameters + WHERE_OPTS + TOR_OPTS + I2P_OPTS
244
245    def parseArgs(self, basedir=None):
246        CreateClientOptions.parseArgs(self, basedir)
247        validate_where_options(self)
248        validate_tor_options(self)
249        validate_i2p_options(self)
250
251
252class CreateIntroducerOptions(NoDefaultBasedirOptions):
253    subcommand_name = "create-introducer"
254    description = "Create a Tahoe-LAFS introducer."
255    optFlags = [
256        ("hide-ip", None, "prohibit any configuration that would reveal the node's IP address"),
257    ] + TOR_FLAGS + I2P_FLAGS
258    optParameters = NoDefaultBasedirOptions.optParameters + WHERE_OPTS + TOR_OPTS + I2P_OPTS
259    def parseArgs(self, basedir=None):
260        NoDefaultBasedirOptions.parseArgs(self, basedir)
261        validate_where_options(self)
262        validate_tor_options(self)
263        validate_i2p_options(self)
264
265
266def merge_config(
267        left: Optional[ListenerConfig],
268        right: Optional[ListenerConfig],
269) -> Optional[ListenerConfig]:
270    """
271    Merge two listener configurations into one configuration representing
272    both of them.
273
274    If either is ``None`` then the result is ``None``.  This supports the
275    "disable listeners" functionality.
276
277    :raise ValueError: If the keys in the node configs overlap.
278    """
279    if left is None or right is None:
280        return None
281
282    overlap = set(left.node_config) & set(right.node_config)
283    if overlap:
284        raise ValueError(f"Node configs overlap: {overlap}")
285
286    return ListenerConfig(
287        list(left.tub_ports) + list(right.tub_ports),
288        list(left.tub_locations) + list(right.tub_locations),
289        dict(list(left.node_config.items()) + list(right.node_config.items())),
290    )
291
292
293async def write_node_config(c, config):
294    # this is shared between clients and introducers
295    c.write("# -*- mode: conf; coding: {c.encoding} -*-\n".format(c=c))
296    c.write("\n")
297    c.write("# This file controls the configuration of the Tahoe node that\n")
298    c.write("# lives in this directory. It is only read at node startup.\n")
299    c.write("# For details about the keys that can be set here, please\n")
300    c.write("# read the 'docs/configuration.rst' file that came with your\n")
301    c.write("# Tahoe installation.\n")
302    c.write("\n\n")
303
304    if config["hide-ip"]:
305        c.write("[connections]\n")
306        if _LISTENERS["tor"].is_available():
307            c.write("tcp = tor\n")
308        else:
309            # XXX What about i2p?
310            c.write("tcp = disabled\n")
311        c.write("\n")
312
313    c.write("[node]\n")
314    nickname = argv_to_unicode(config.get("nickname") or "")
315    c.write("nickname = %s\n" % (nickname,))
316    if config["hide-ip"]:
317        c.write("reveal-IP-address = false\n")
318    else:
319        c.write("reveal-IP-address = true\n")
320
321    # TODO: validate webport
322    webport = argv_to_unicode(config.get("webport") or "none")
323    if webport.lower() == "none":
324        webport = ""
325    c.write("web.port = %s\n" % (webport,))
326    c.write("web.static = public_html\n")
327
328    listener_config = ListenerConfig([], [], {})
329    for listener_name in config['listen'].split(","):
330        listener = _LISTENERS[listener_name]
331        listener_config = merge_config(
332            (await listener.create_config(reactor, config)),
333            listener_config,
334        )
335
336    if listener_config is None:
337        tub_ports = ["disabled"]
338        tub_locations = ["disabled"]
339    else:
340        tub_ports = listener_config.tub_ports
341        tub_locations = listener_config.tub_locations
342
343    c.write("tub.port = %s\n" % ",".join(tub_ports))
344    c.write("tub.location = %s\n" % ",".join(tub_locations))
345    c.write("\n")
346
347    c.write("#log_gatherer.furl =\n")
348    c.write("#timeout.keepalive =\n")
349    c.write("#timeout.disconnect =\n")
350    c.write("#ssh.port = 8022\n")
351    c.write("#ssh.authorized_keys_file = ~/.ssh/authorized_keys\n")
352    c.write("\n")
353
354    if listener_config is not None:
355        for section, items in listener_config.node_config.items():
356            c.write(f"[{section}]\n")
357            for k, v in items:
358                c.write(f"{k} = {v}\n")
359            c.write("\n")
360
361
362def write_client_config(c, config):
363    introducer = config.get("introducer", None)
364    if introducer is not None:
365        write_introducer(
366            FilePath(config["basedir"]),
367            "default",
368            introducer,
369        )
370
371    c.write("[client]\n")
372    c.write("helper.furl =\n")
373    c.write("\n")
374    c.write("# Encoding parameters this client will use for newly-uploaded files\n")
375    c.write("# This can be changed at any time: the encoding is saved in\n")
376    c.write("# each filecap, and we can download old files with any encoding\n")
377    c.write("# settings\n")
378    c.write("shares.needed = {}\n".format(config['shares-needed']))
379    c.write("shares.happy = {}\n".format(config['shares-happy']))
380    c.write("shares.total = {}\n".format(config['shares-total']))
381    c.write("\n")
382
383    boolstr = {True:"true", False:"false"}
384    c.write("[storage]\n")
385    c.write("# Shall this node provide storage service?\n")
386    storage_enabled = not config.get("no-storage", None)
387    c.write("enabled = %s\n" % boolstr[storage_enabled])
388    c.write("#readonly =\n")
389    c.write("reserved_space = 1G\n")
390    storage_dir = config.get("storage-dir")
391    if storage_dir:
392        c.write("storage_dir = %s\n" % (storage_dir,))
393    else:
394        c.write("#storage_dir =\n")
395    c.write("#expire.enabled =\n")
396    c.write("#expire.mode =\n")
397    c.write("\n")
398
399    c.write("[helper]\n")
400    c.write("# Shall this node run a helper service that clients can use?\n")
401    if config.get("helper"):
402        c.write("enabled = true\n")
403    else:
404        c.write("enabled = false\n")
405    c.write("\n")
406
407
408@defer.inlineCallbacks
409def _get_config_via_wormhole(config):
410    out = config.stdout
411    print("Opening wormhole with code '{}'".format(config['join']), file=out)
412    relay_url = config.parent['wormhole-server']
413    print("Connecting to '{}'".format(relay_url), file=out)
414
415    wh = config.parent.wormhole.create(
416        appid=config.parent['wormhole-invite-appid'],
417        relay_url=relay_url,
418        reactor=reactor,
419    )
420    code = str(config['join'])
421    wh.set_code(code)
422    yield wh.get_welcome()
423    print("Connected to wormhole server", file=out)
424
425    intro = {
426        u"abilities": {
427            "client-v1": {},
428        }
429    }
430    wh.send_message(json.dumps_bytes(intro))
431
432    server_intro = yield wh.get_message()
433    server_intro = json.loads(server_intro)
434
435    print("  received server introduction", file=out)
436    if u'abilities' not in server_intro:
437        raise RuntimeError("  Expected 'abilities' in server introduction")
438    if u'server-v1' not in server_intro['abilities']:
439        raise RuntimeError("  Expected 'server-v1' in server abilities")
440
441    remote_data = yield wh.get_message()
442    print("  received configuration", file=out)
443    defer.returnValue(json.loads(remote_data))
444
445
446@defer.inlineCallbacks
447def create_node(config):
448    out = config.stdout
449    err = config.stderr
450    basedir = config['basedir']
451    # This should always be called with an absolute Unicode basedir.
452    precondition(isinstance(basedir, str), basedir)
453
454    if os.path.exists(basedir):
455        if listdir_unicode(basedir):
456            print("The base directory %s is not empty." % quote_local_unicode_path(basedir), file=err)
457            print("To avoid clobbering anything, I am going to quit now.", file=err)
458            print("Please use a different directory, or empty this one.", file=err)
459            defer.returnValue(-1)
460        # we're willing to use an empty directory
461    else:
462        os.mkdir(basedir)
463    write_tac(basedir, "client")
464
465    # if we're doing magic-wormhole stuff, do it now
466    if config['join'] is not None:
467        try:
468            remote_config = yield _get_config_via_wormhole(config)
469        except RuntimeError as e:
470            print(str(e), file=err)
471            defer.returnValue(1)
472
473        # configuration we'll allow the inviter to set
474        whitelist = [
475            'shares-happy', 'shares-needed', 'shares-total',
476            'introducer', 'nickname',
477        ]
478        sensitive_keys = ['introducer']
479
480        print("Encoding: {shares-needed} of {shares-total} shares, on at least {shares-happy} servers".format(**remote_config), file=out)
481        print("Overriding the following config:", file=out)
482
483        for k in whitelist:
484            v = remote_config.get(k, None)
485            if v is not None:
486                # we're faking usually argv-supplied options :/
487                v_orig = v
488                if isinstance(v, str):
489                    v = v.encode(get_io_encoding())
490                config[k] = v
491                if k not in sensitive_keys:
492                    if k not in ['shares-happy', 'shares-total', 'shares-needed']:
493                        print("  {}: {}".format(k, v_orig), file=out)
494                else:
495                    print("  {}: [sensitive data; see tahoe.cfg]".format(k), file=out)
496
497    fileutil.make_dirs(os.path.join(basedir, "private"), 0o700)
498    cfg_name = os.path.join(basedir, "tahoe.cfg")
499    with io.open(cfg_name, "w", encoding='utf-8') as c:
500        yield defer.Deferred.fromCoroutine(write_node_config(c, config))
501        write_client_config(c, config)
502
503    print("Node created in %s" % quote_local_unicode_path(basedir), file=out)
504    tahoe_cfg = quote_local_unicode_path(os.path.join(basedir, "tahoe.cfg"))
505    introducers_yaml = quote_local_unicode_path(
506        os.path.join(basedir, "private", "introducers.yaml"),
507    )
508    if not config.get("introducer", ""):
509        print(" Please add introducers to %s!" % (introducers_yaml,), file=out)
510        print(" The node cannot connect to a grid without it.", file=out)
511    if not config.get("nickname", ""):
512        print(" Please set [node]nickname= in %s" % tahoe_cfg, file=out)
513    defer.returnValue(0)
514
515def create_client(config):
516    config['no-storage'] = True
517    config['listen'] = "none"
518    return create_node(config)
519
520
521@defer.inlineCallbacks
522def create_introducer(config):
523    out = config.stdout
524    err = config.stderr
525    basedir = config['basedir']
526    # This should always be called with an absolute Unicode basedir.
527    precondition(isinstance(basedir, str), basedir)
528
529    if os.path.exists(basedir):
530        if listdir_unicode(basedir):
531            print("The base directory %s is not empty." % quote_local_unicode_path(basedir), file=err)
532            print("To avoid clobbering anything, I am going to quit now.", file=err)
533            print("Please use a different directory, or empty this one.", file=err)
534            defer.returnValue(-1)
535        # we're willing to use an empty directory
536    else:
537        os.mkdir(basedir)
538    write_tac(basedir, "introducer")
539
540    fileutil.make_dirs(os.path.join(basedir, "private"), 0o700)
541    cfg_name = os.path.join(basedir, "tahoe.cfg")
542    with io.open(cfg_name, "w", encoding='utf-8') as c:
543        yield defer.Deferred.fromCoroutine(write_node_config(c, config))
544
545    print("Introducer created in %s" % quote_local_unicode_path(basedir), file=out)
546    defer.returnValue(0)
547
548
549subCommands : SubCommands = [
550    ("create-node", None, CreateNodeOptions, "Create a node that acts as a client, server or both."),
551    ("create-client", None, CreateClientOptions, "Create a client node (with storage initially disabled)."),
552    ("create-introducer", None, CreateIntroducerOptions, "Create an introducer node."),
553]
554
555dispatch = {
556    "create-node": create_node,
557    "create-client": create_client,
558    "create-introducer": create_introducer,
559    }
Note: See TracBrowser for help on using the repository browser.