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

Last change on this file was f3f667d, checked in by meejah <meejah@…>, at 2024-09-26T02:30:50Z

correctly interpret --storage-dir as an option

  • Property mode set to 100644
File size: 21.0 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        ("helper", None, "Enable helper"),
238    ] + TOR_FLAGS + I2P_FLAGS
239
240    synopsis = "[options] [NODEDIR]"
241    description = "Create a full Tahoe-LAFS node (client+server)."
242
243    optParameters = [
244        ("storage-dir", None, None, "Path where the storage will be placed."),
245    ] + CreateClientOptions.optParameters + WHERE_OPTS + TOR_OPTS + I2P_OPTS
246
247    def parseArgs(self, basedir=None):
248        CreateClientOptions.parseArgs(self, basedir)
249        validate_where_options(self)
250        validate_tor_options(self)
251        validate_i2p_options(self)
252
253
254class CreateIntroducerOptions(NoDefaultBasedirOptions):
255    subcommand_name = "create-introducer"
256    description = "Create a Tahoe-LAFS introducer."
257    optFlags = [
258        ("hide-ip", None, "prohibit any configuration that would reveal the node's IP address"),
259    ] + TOR_FLAGS + I2P_FLAGS
260    optParameters = NoDefaultBasedirOptions.optParameters + WHERE_OPTS + TOR_OPTS + I2P_OPTS
261    def parseArgs(self, basedir=None):
262        NoDefaultBasedirOptions.parseArgs(self, basedir)
263        validate_where_options(self)
264        validate_tor_options(self)
265        validate_i2p_options(self)
266
267
268def merge_config(
269        left: Optional[ListenerConfig],
270        right: Optional[ListenerConfig],
271) -> Optional[ListenerConfig]:
272    """
273    Merge two listener configurations into one configuration representing
274    both of them.
275
276    If either is ``None`` then the result is ``None``.  This supports the
277    "disable listeners" functionality.
278
279    :raise ValueError: If the keys in the node configs overlap.
280    """
281    if left is None or right is None:
282        return None
283
284    overlap = set(left.node_config) & set(right.node_config)
285    if overlap:
286        raise ValueError(f"Node configs overlap: {overlap}")
287
288    return ListenerConfig(
289        list(left.tub_ports) + list(right.tub_ports),
290        list(left.tub_locations) + list(right.tub_locations),
291        dict(list(left.node_config.items()) + list(right.node_config.items())),
292    )
293
294
295async def write_node_config(c, config):
296    # this is shared between clients and introducers
297    c.write("# -*- mode: conf; coding: {c.encoding} -*-\n".format(c=c))
298    c.write("\n")
299    c.write("# This file controls the configuration of the Tahoe node that\n")
300    c.write("# lives in this directory. It is only read at node startup.\n")
301    c.write("# For details about the keys that can be set here, please\n")
302    c.write("# read the 'docs/configuration.rst' file that came with your\n")
303    c.write("# Tahoe installation.\n")
304    c.write("\n\n")
305
306    if config["hide-ip"]:
307        c.write("[connections]\n")
308        if _LISTENERS["tor"].is_available():
309            c.write("tcp = tor\n")
310        else:
311            # XXX What about i2p?
312            c.write("tcp = disabled\n")
313        c.write("\n")
314
315    c.write("[node]\n")
316    nickname = argv_to_unicode(config.get("nickname") or "")
317    c.write("nickname = %s\n" % (nickname,))
318    if config["hide-ip"]:
319        c.write("reveal-IP-address = false\n")
320    else:
321        c.write("reveal-IP-address = true\n")
322
323    # TODO: validate webport
324    webport = argv_to_unicode(config.get("webport") or "none")
325    if webport.lower() == "none":
326        webport = ""
327    c.write("web.port = %s\n" % (webport,))
328    c.write("web.static = public_html\n")
329
330    listener_config = ListenerConfig([], [], {})
331    for listener_name in config['listen'].split(","):
332        listener = _LISTENERS[listener_name]
333        listener_config = merge_config(
334            (await listener.create_config(reactor, config)),
335            listener_config,
336        )
337
338    if listener_config is None:
339        tub_ports = ["disabled"]
340        tub_locations = ["disabled"]
341    else:
342        tub_ports = listener_config.tub_ports
343        tub_locations = listener_config.tub_locations
344
345    c.write("tub.port = %s\n" % ",".join(tub_ports))
346    c.write("tub.location = %s\n" % ",".join(tub_locations))
347    c.write("\n")
348
349    c.write("#log_gatherer.furl =\n")
350    c.write("#timeout.keepalive =\n")
351    c.write("#timeout.disconnect =\n")
352    c.write("#ssh.port = 8022\n")
353    c.write("#ssh.authorized_keys_file = ~/.ssh/authorized_keys\n")
354    c.write("\n")
355
356    if listener_config is not None:
357        for section, items in listener_config.node_config.items():
358            c.write(f"[{section}]\n")
359            for k, v in items:
360                c.write(f"{k} = {v}\n")
361            c.write("\n")
362
363
364def write_client_config(c, config):
365    introducer = config.get("introducer", None)
366    if introducer is not None:
367        write_introducer(
368            FilePath(config["basedir"]),
369            "default",
370            introducer,
371        )
372
373    c.write("[client]\n")
374    c.write("helper.furl =\n")
375    c.write("\n")
376    c.write("# Encoding parameters this client will use for newly-uploaded files\n")
377    c.write("# This can be changed at any time: the encoding is saved in\n")
378    c.write("# each filecap, and we can download old files with any encoding\n")
379    c.write("# settings\n")
380    c.write("shares.needed = {}\n".format(config['shares-needed']))
381    c.write("shares.happy = {}\n".format(config['shares-happy']))
382    c.write("shares.total = {}\n".format(config['shares-total']))
383    c.write("\n")
384
385    boolstr = {True:"true", False:"false"}
386    c.write("[storage]\n")
387    c.write("# Shall this node provide storage service?\n")
388    storage_enabled = not config.get("no-storage", None)
389    c.write("enabled = %s\n" % boolstr[storage_enabled])
390    c.write("#readonly =\n")
391    c.write("reserved_space = 1G\n")
392    storage_dir = config.get("storage-dir")
393    if storage_dir:
394        c.write("storage_dir = %s\n" % (storage_dir,))
395    else:
396        c.write("#storage_dir =\n")
397    c.write("#expire.enabled =\n")
398    c.write("#expire.mode =\n")
399    c.write("\n")
400
401    c.write("[helper]\n")
402    c.write("# Shall this node run a helper service that clients can use?\n")
403    if config.get("helper"):
404        c.write("enabled = true\n")
405    else:
406        c.write("enabled = false\n")
407    c.write("\n")
408
409
410@defer.inlineCallbacks
411def _get_config_via_wormhole(config):
412    out = config.stdout
413    print("Opening wormhole with code '{}'".format(config['join']), file=out)
414    relay_url = config.parent['wormhole-server']
415    print("Connecting to '{}'".format(relay_url), file=out)
416
417    wh = config.parent.wormhole.create(
418        appid=config.parent['wormhole-invite-appid'],
419        relay_url=relay_url,
420        reactor=reactor,
421    )
422    code = str(config['join'])
423    wh.set_code(code)
424    yield wh.get_welcome()
425    print("Connected to wormhole server", file=out)
426
427    intro = {
428        u"abilities": {
429            "client-v1": {},
430        }
431    }
432    wh.send_message(json.dumps_bytes(intro))
433
434    server_intro = yield wh.get_message()
435    server_intro = json.loads(server_intro)
436
437    print("  received server introduction", file=out)
438    if u'abilities' not in server_intro:
439        raise RuntimeError("  Expected 'abilities' in server introduction")
440    if u'server-v1' not in server_intro['abilities']:
441        raise RuntimeError("  Expected 'server-v1' in server abilities")
442
443    remote_data = yield wh.get_message()
444    print("  received configuration", file=out)
445    defer.returnValue(json.loads(remote_data))
446
447
448@defer.inlineCallbacks
449def create_node(config):
450    out = config.stdout
451    err = config.stderr
452    basedir = config['basedir']
453    # This should always be called with an absolute Unicode basedir.
454    precondition(isinstance(basedir, str), basedir)
455
456    if os.path.exists(basedir):
457        if listdir_unicode(basedir):
458            print("The base directory %s is not empty." % quote_local_unicode_path(basedir), file=err)
459            print("To avoid clobbering anything, I am going to quit now.", file=err)
460            print("Please use a different directory, or empty this one.", file=err)
461            defer.returnValue(-1)
462        # we're willing to use an empty directory
463    else:
464        os.mkdir(basedir)
465    write_tac(basedir, "client")
466
467    # if we're doing magic-wormhole stuff, do it now
468    if config['join'] is not None:
469        try:
470            remote_config = yield _get_config_via_wormhole(config)
471        except RuntimeError as e:
472            print(str(e), file=err)
473            defer.returnValue(1)
474
475        # configuration we'll allow the inviter to set
476        whitelist = [
477            'shares-happy', 'shares-needed', 'shares-total',
478            'introducer', 'nickname',
479        ]
480        sensitive_keys = ['introducer']
481
482        print("Encoding: {shares-needed} of {shares-total} shares, on at least {shares-happy} servers".format(**remote_config), file=out)
483        print("Overriding the following config:", file=out)
484
485        for k in whitelist:
486            v = remote_config.get(k, None)
487            if v is not None:
488                # we're faking usually argv-supplied options :/
489                v_orig = v
490                if isinstance(v, str):
491                    v = v.encode(get_io_encoding())
492                config[k] = v
493                if k not in sensitive_keys:
494                    if k not in ['shares-happy', 'shares-total', 'shares-needed']:
495                        print("  {}: {}".format(k, v_orig), file=out)
496                else:
497                    print("  {}: [sensitive data; see tahoe.cfg]".format(k), file=out)
498
499    fileutil.make_dirs(os.path.join(basedir, "private"), 0o700)
500    cfg_name = os.path.join(basedir, "tahoe.cfg")
501    with io.open(cfg_name, "w", encoding='utf-8') as c:
502        yield defer.Deferred.fromCoroutine(write_node_config(c, config))
503        write_client_config(c, config)
504
505    print("Node created in %s" % quote_local_unicode_path(basedir), file=out)
506    tahoe_cfg = quote_local_unicode_path(os.path.join(basedir, "tahoe.cfg"))
507    introducers_yaml = quote_local_unicode_path(
508        os.path.join(basedir, "private", "introducers.yaml"),
509    )
510    if not config.get("introducer", ""):
511        print(" Please add introducers to %s!" % (introducers_yaml,), file=out)
512        print(" The node cannot connect to a grid without it.", file=out)
513    if not config.get("nickname", ""):
514        print(" Please set [node]nickname= in %s" % tahoe_cfg, file=out)
515    defer.returnValue(0)
516
517def create_client(config):
518    config['no-storage'] = True
519    config['listen'] = "none"
520    return create_node(config)
521
522
523@defer.inlineCallbacks
524def create_introducer(config):
525    out = config.stdout
526    err = config.stderr
527    basedir = config['basedir']
528    # This should always be called with an absolute Unicode basedir.
529    precondition(isinstance(basedir, str), basedir)
530
531    if os.path.exists(basedir):
532        if listdir_unicode(basedir):
533            print("The base directory %s is not empty." % quote_local_unicode_path(basedir), file=err)
534            print("To avoid clobbering anything, I am going to quit now.", file=err)
535            print("Please use a different directory, or empty this one.", file=err)
536            defer.returnValue(-1)
537        # we're willing to use an empty directory
538    else:
539        os.mkdir(basedir)
540    write_tac(basedir, "introducer")
541
542    fileutil.make_dirs(os.path.join(basedir, "private"), 0o700)
543    cfg_name = os.path.join(basedir, "tahoe.cfg")
544    with io.open(cfg_name, "w", encoding='utf-8') as c:
545        yield defer.Deferred.fromCoroutine(write_node_config(c, config))
546
547    print("Introducer created in %s" % quote_local_unicode_path(basedir), file=out)
548    defer.returnValue(0)
549
550
551subCommands : SubCommands = [
552    ("create-node", None, CreateNodeOptions, "Create a node that acts as a client, server or both."),
553    ("create-client", None, CreateClientOptions, "Create a client node (with storage initially disabled)."),
554    ("create-introducer", None, CreateIntroducerOptions, "Create an introducer node."),
555]
556
557dispatch = {
558    "create-node": create_node,
559    "create-client": create_client,
560    "create-introducer": create_introducer,
561    }
Note: See TracBrowser for help on using the repository browser.