source: trunk/src/allmydata/node.py

Last change on this file was 53084f7, checked in by Alexandre Detiste <alexandre.detiste@…>, at 2024-02-27T23:49:07Z

remove more Python2 compatibility

  • Property mode set to 100644
File size: 38.8 KB
Line 
1"""
2This module contains classes and functions to implement and manage
3a node for Tahoe-LAFS.
4
5Ported to Python 3.
6"""
7from __future__ import annotations
8
9from six import ensure_str, ensure_text
10
11import json
12import datetime
13import os.path
14import re
15import types
16import errno
17from base64 import b32decode, b32encode
18from errno import ENOENT, EPERM
19from warnings import warn
20from typing import Union, Iterable
21
22import attr
23
24# On Python 2 this will be the backported package.
25import configparser
26
27from twisted.python.filepath import (
28    FilePath,
29)
30from twisted.python import log as twlog
31from twisted.application import service
32from twisted.python.failure import Failure
33from foolscap.api import Tub
34
35import foolscap.logging.log
36
37from allmydata.util import log
38from allmydata.util import fileutil, iputil
39from allmydata.util.fileutil import abspath_expanduser_unicode
40from allmydata.util.encodingutil import get_filesystem_encoding, quote_output
41from allmydata.util import configutil
42from allmydata.util.yamlutil import (
43    safe_load,
44)
45
46from . import (
47    __full_version__,
48)
49from .protocol_switch import create_tub_with_https_support
50
51
52def _common_valid_config():
53    return configutil.ValidConfiguration({
54        "connections": (
55            "tcp",
56        ),
57        "node": (
58            "log_gatherer.furl",
59            "nickname",
60            "reveal-ip-address",
61            "tempdir",
62            "timeout.disconnect",
63            "timeout.keepalive",
64            "tub.location",
65            "tub.port",
66            "web.port",
67            "web.static",
68        ),
69        "i2p": (
70            "enabled",
71            "i2p.configdir",
72            "i2p.executable",
73            "launch",
74            "sam.port",
75            "dest",
76            "dest.port",
77            "dest.private_key_file",
78        ),
79        "tor": (
80            "control.port",
81            "enabled",
82            "launch",
83            "socks.port",
84            "tor.executable",
85            "onion",
86            "onion.local_port",
87            "onion.external_port",
88            "onion.private_key_file",
89        ),
90    })
91
92# group 1 will be addr (dotted quad string), group 3 if any will be portnum (string)
93ADDR_RE = re.compile("^([1-9][0-9]*\.[1-9][0-9]*\.[1-9][0-9]*\.[1-9][0-9]*)(:([1-9][0-9]*))?$")
94
95# this is put into README in new node-directories (for client and introducers)
96PRIV_README = """
97This directory contains files which contain private data for the Tahoe node,
98such as private keys.  On Unix-like systems, the permissions on this directory
99are set to disallow users other than its owner from reading the contents of
100the files.   See the 'configuration.rst' documentation file for details.
101"""
102
103
104def formatTimeTahoeStyle(self, when):
105    """
106    Format the given (UTC) timestamp in the way Tahoe-LAFS expects it,
107    for example: 2007-10-12 00:26:28.566Z
108
109    :param when: UTC POSIX timestamp
110    :type when: float
111    :returns: datetime.datetime
112    """
113    d = datetime.datetime.utcfromtimestamp(when)
114    if d.microsecond:
115        return d.isoformat(" ")[:-3]+"Z"
116    return d.isoformat(" ") + ".000Z"
117
118PRIV_README = """
119This directory contains files which contain private data for the Tahoe node,
120such as private keys.  On Unix-like systems, the permissions on this directory
121are set to disallow users other than its owner from reading the contents of
122the files.   See the 'configuration.rst' documentation file for details."""
123
124class _None(object):
125    """
126    This class is to be used as a marker in get_config()
127    """
128    pass
129
130class MissingConfigEntry(Exception):
131    """ A required config entry was not found. """
132
133class OldConfigError(Exception):
134    """ An obsolete config file was found. See
135    docs/historical/configuration.rst. """
136    def __str__(self):
137        return ("Found pre-Tahoe-LAFS-v1.3 configuration file(s):\n"
138                "%s\n"
139                "See docs/historical/configuration.rst."
140                % "\n".join([quote_output(fname) for fname in self.args[0]]))
141
142class OldConfigOptionError(Exception):
143    """Indicate that outdated configuration options are being used."""
144    pass
145
146class UnescapedHashError(Exception):
147    """Indicate that a configuration entry contains an unescaped '#' character."""
148    def __str__(self):
149        return ("The configuration entry %s contained an unescaped '#' character."
150                % quote_output("[%s]%s = %s" % self.args))
151
152class PrivacyError(Exception):
153    """reveal-IP-address = false, but the node is configured in such a way
154    that the IP address could be revealed"""
155
156
157def create_node_dir(basedir, readme_text):
158    """
159    Create new new 'node directory' at 'basedir'. This includes a
160    'private' subdirectory. If basedir (and privdir) already exists,
161    nothing is done.
162
163    :param readme_text: text to put in <basedir>/private/README
164    """
165    if not os.path.exists(basedir):
166        fileutil.make_dirs(basedir)
167    privdir = os.path.join(basedir, "private")
168    if not os.path.exists(privdir):
169        fileutil.make_dirs(privdir, 0o700)
170        readme_text = ensure_text(readme_text)
171        with open(os.path.join(privdir, 'README'), 'w') as f:
172            f.write(readme_text)
173
174
175def read_config(basedir, portnumfile, generated_files: Iterable = (), _valid_config=None):
176    """
177    Read and validate configuration.
178
179    :param unicode basedir: directory where configuration data begins
180
181    :param unicode portnumfile: filename fragment for "port number" files
182
183    :param list generated_files: a list of automatically-generated
184        configuration files.
185
186    :param ValidConfiguration _valid_config: (internal use, optional) a
187        structure defining valid configuration sections and keys
188
189    :returns: :class:`allmydata.node._Config` instance
190    """
191    basedir = abspath_expanduser_unicode(ensure_text(basedir))
192    if _valid_config is None:
193        _valid_config = _common_valid_config()
194
195    # complain if there's bad stuff in the config dir
196    _error_about_old_config_files(basedir, generated_files)
197
198    # canonicalize the portnum file
199    portnumfile = os.path.join(basedir, portnumfile)
200
201    config_path = FilePath(basedir).child("tahoe.cfg")
202    try:
203        config_bytes = config_path.getContent()
204    except EnvironmentError as e:
205        if e.errno != errno.ENOENT:
206            raise
207        # The file is missing, just create empty ConfigParser.
208        config_str = u""
209    else:
210        config_str = config_bytes.decode("utf-8-sig")
211
212    return config_from_string(
213        basedir,
214        portnumfile,
215        config_str,
216        _valid_config,
217        config_path,
218    )
219
220
221def config_from_string(basedir, portnumfile, config_str, _valid_config=None, fpath=None):
222    """
223    load and validate configuration from in-memory string
224    """
225    if _valid_config is None:
226        _valid_config = _common_valid_config()
227
228    if isinstance(config_str, bytes):
229        config_str = config_str.decode("utf-8")
230
231    # load configuration from in-memory string
232    parser = configutil.get_config_from_string(config_str)
233
234    configutil.validate_config(
235        "<string>" if fpath is None else fpath.path,
236        parser,
237        _valid_config,
238    )
239
240    return _Config(
241        parser,
242        portnumfile,
243        basedir,
244        fpath,
245        _valid_config,
246    )
247
248
249def _error_about_old_config_files(basedir, generated_files):
250    """
251    If any old configuration files are detected, raise
252    OldConfigError.
253    """
254    oldfnames = set()
255    old_names = [
256        'nickname', 'webport', 'keepalive_timeout', 'log_gatherer.furl',
257        'disconnect_timeout', 'advertised_ip_addresses', 'introducer.furl',
258        'helper.furl', 'key_generator.furl', 'stats_gatherer.furl',
259        'no_storage', 'readonly_storage', 'sizelimit',
260        'debug_discard_storage', 'run_helper'
261    ]
262    for fn in generated_files:
263        old_names.remove(fn)
264    for name in old_names:
265        fullfname = os.path.join(basedir, name)
266        if os.path.exists(fullfname):
267            oldfnames.add(fullfname)
268    if oldfnames:
269        e = OldConfigError(oldfnames)
270        twlog.msg(e)
271        raise e
272
273
274def ensure_text_and_abspath_expanduser_unicode(basedir: Union[bytes, str]) -> str:
275    return abspath_expanduser_unicode(ensure_text(basedir))
276
277
278@attr.s
279class _Config(object):
280    """
281    Manages configuration of a Tahoe 'node directory'.
282
283    Note: all this code and functionality was formerly in the Node
284    class; names and funtionality have been kept the same while moving
285    the code. It probably makes sense for several of these APIs to
286    have better names.
287
288    :ivar ConfigParser config: The actual configuration values.
289
290    :ivar str portnum_fname: filename to use for the port-number file (a
291        relative path inside basedir).
292
293    :ivar str _basedir: path to our "node directory", inside which all
294        configuration is managed.
295
296    :ivar (FilePath|NoneType) config_path: The path actually used to create
297        the configparser (might be ``None`` if using in-memory data).
298
299    :ivar ValidConfiguration valid_config_sections: The validator for the
300        values in this configuration.
301    """
302    config = attr.ib(validator=attr.validators.instance_of(configparser.ConfigParser))
303    portnum_fname = attr.ib()
304    _basedir = attr.ib(
305        converter=ensure_text_and_abspath_expanduser_unicode,
306    )  # type: str
307    config_path = attr.ib(
308        validator=attr.validators.optional(
309            attr.validators.instance_of(FilePath),
310        ),
311    )
312    valid_config_sections = attr.ib(
313        default=configutil.ValidConfiguration.everything(),
314        validator=attr.validators.instance_of(configutil.ValidConfiguration),
315    )
316
317    @property
318    def nickname(self):
319        nickname = self.get_config("node", "nickname", u"<unspecified>")
320        assert isinstance(nickname, str)
321        return nickname
322
323    @property
324    def _config_fname(self):
325        if self.config_path is None:
326            return "<string>"
327        return self.config_path.path
328
329    def write_config_file(self, name, value, mode="w"):
330        """
331        writes the given 'value' into a file called 'name' in the config
332        directory
333        """
334        fn = os.path.join(self._basedir, name)
335        try:
336            fileutil.write(fn, value, mode)
337        except EnvironmentError:
338            log.err(
339                Failure(),
340                "Unable to write config file '{}'".format(fn),
341            )
342
343    def enumerate_section(self, section):
344        """
345        returns a dict containing all items in a configuration section. an
346        empty dict is returned if the section doesn't exist.
347        """
348        answer = dict()
349        try:
350            for k in self.config.options(section):
351                answer[k] = self.config.get(section, k)
352        except configparser.NoSectionError:
353            pass
354        return answer
355
356    def items(self, section, default=_None):
357        try:
358            return self.config.items(section)
359        except configparser.NoSectionError:
360            if default is _None:
361                raise
362            return default
363
364    def get_config(self, section, option, default=_None, boolean=False):
365        try:
366            if boolean:
367                return self.config.getboolean(section, option)
368
369            item = self.config.get(section, option)
370            if option.endswith(".furl") and '#' in item:
371                raise UnescapedHashError(section, option, item)
372
373            return item
374        except (configparser.NoOptionError, configparser.NoSectionError):
375            if default is _None:
376                raise MissingConfigEntry(
377                    "{} is missing the [{}]{} entry".format(
378                        quote_output(self._config_fname),
379                        section,
380                        option,
381                    )
382                )
383            return default
384
385    def set_config(self, section, option, value):
386        """
387        Set a config option in a section and re-write the tahoe.cfg file
388
389        :param str section: The name of the section in which to set the
390            option.
391
392        :param str option: The name of the option to set.
393
394        :param str value: The value of the option.
395
396        :raise UnescapedHashError: If the option holds a fURL and there is a
397            ``#`` in the value.
398        """
399        if option.endswith(".furl") and "#" in value:
400            raise UnescapedHashError(section, option, value)
401
402        copied_config = configutil.copy_config(self.config)
403        configutil.set_config(copied_config, section, option, value)
404        configutil.validate_config(
405            self._config_fname,
406            copied_config,
407            self.valid_config_sections,
408        )
409        if self.config_path is not None:
410            configutil.write_config(self.config_path, copied_config)
411        self.config = copied_config
412
413    def get_config_from_file(self, name, required=False):
414        """Get the (string) contents of a config file, or None if the file
415        did not exist. If required=True, raise an exception rather than
416        returning None. Any leading or trailing whitespace will be stripped
417        from the data."""
418        fn = os.path.join(self._basedir, name)
419        try:
420            return fileutil.read(fn).strip()
421        except EnvironmentError as e:
422            if e.errno != errno.ENOENT:
423                raise  # we only care about "file doesn't exist"
424            if not required:
425                return None
426            raise
427
428    def get_or_create_private_config(self, name, default=_None):
429        """Try to get the (string) contents of a private config file (which
430        is a config file that resides within the subdirectory named
431        'private'), and return it. Any leading or trailing whitespace will be
432        stripped from the data.
433
434        If the file does not exist, and default is not given, report an error.
435        If the file does not exist and a default is specified, try to create
436        it using that default, and then return the value that was written.
437        If 'default' is a string, use it as a default value. If not, treat it
438        as a zero-argument callable that is expected to return a string.
439        """
440        privname = os.path.join(self._basedir, "private", name)
441        try:
442            value = fileutil.read(privname, mode="r")
443        except EnvironmentError as e:
444            if e.errno != errno.ENOENT:
445                raise  # we only care about "file doesn't exist"
446            if default is _None:
447                raise MissingConfigEntry("The required configuration file %s is missing."
448                                         % (quote_output(privname),))
449            if isinstance(default, bytes):
450                default = str(default, "utf-8")
451            if isinstance(default, str):
452                value = default
453            else:
454                value = default()
455            fileutil.write(privname, value)
456        return value.strip()
457
458    def write_private_config(self, name, value):
459        """Write the (string) contents of a private config file (which is a
460        config file that resides within the subdirectory named 'private'), and
461        return it.
462        """
463        if isinstance(value, str):
464            value = value.encode("utf-8")
465        privname = os.path.join(self._basedir, "private", name)
466        with open(privname, "wb") as f:
467            f.write(value)
468
469    def get_private_config(self, name, default=_None):
470        """Read the (native string) contents of a private config file (a
471        config file that resides within the subdirectory named 'private'),
472        and return it. Return a default, or raise an error if one was not
473        given.
474        """
475        privname = os.path.join(self._basedir, "private", name)
476        try:
477            return fileutil.read(privname, mode="r").strip()
478        except EnvironmentError as e:
479            if e.errno != errno.ENOENT:
480                raise  # we only care about "file doesn't exist"
481            if default is _None:
482                raise MissingConfigEntry("The required configuration file %s is missing."
483                                         % (quote_output(privname),))
484            return default
485
486    def get_private_path(self, *args):
487        """
488        returns an absolute path inside the 'private' directory with any
489        extra args join()-ed
490
491        This exists for historical reasons. New code should ideally
492        not call this because it makes it harder for e.g. a SQL-based
493        _Config object to exist. Code that needs to call this method
494        should probably be a _Config method itself. See
495        e.g. get_grid_manager_certificates()
496        """
497        return os.path.join(self._basedir, "private", *args)
498
499    def get_config_path(self, *args):
500        """
501        returns an absolute path inside the config directory with any
502        extra args join()-ed
503
504        This exists for historical reasons. New code should ideally
505        not call this because it makes it harder for e.g. a SQL-based
506        _Config object to exist. Code that needs to call this method
507        should probably be a _Config method itself. See
508        e.g. get_grid_manager_certificates()
509        """
510        # note: we re-expand here (_basedir already went through this
511        # expanduser function) in case the path we're being asked for
512        # has embedded ".."'s in it
513        return abspath_expanduser_unicode(
514            os.path.join(self._basedir, *args)
515        )
516
517    def get_grid_manager_certificates(self):
518        """
519        Load all Grid Manager certificates in the config.
520
521        :returns: A list of all certificates. An empty list is
522            returned if there are none.
523        """
524        grid_manager_certificates = []
525
526        cert_fnames = list(self.enumerate_section("grid_manager_certificates").values())
527        for fname in cert_fnames:
528            fname = self.get_config_path(fname)
529            if not os.path.exists(fname):
530                raise ValueError(
531                    "Grid Manager certificate file '{}' doesn't exist".format(
532                        fname
533                    )
534                )
535            with open(fname, 'r') as f:
536                cert = json.load(f)
537            if set(cert.keys()) != {"certificate", "signature"}:
538                raise ValueError(
539                    "Unknown key in Grid Manager certificate '{}'".format(
540                        fname
541                    )
542                )
543            grid_manager_certificates.append(cert)
544        return grid_manager_certificates
545
546    def get_introducer_configuration(self):
547        """
548        Get configuration for introducers.
549
550        :return {unicode: (unicode, FilePath)}: A mapping from introducer
551            petname to a tuple of the introducer's fURL and local cache path.
552        """
553        introducers_yaml_filename = self.get_private_path("introducers.yaml")
554        introducers_filepath = FilePath(introducers_yaml_filename)
555
556        def get_cache_filepath(petname):
557            return FilePath(
558                self.get_private_path("introducer_{}_cache.yaml".format(petname)),
559            )
560
561        try:
562            with introducers_filepath.open() as f:
563                introducers_yaml = safe_load(f)
564                if introducers_yaml is None:
565                    raise EnvironmentError(
566                        EPERM,
567                        "Can't read '{}'".format(introducers_yaml_filename),
568                        introducers_yaml_filename,
569                    )
570                introducers = {
571                    petname: config["furl"]
572                    for petname, config
573                    in introducers_yaml.get("introducers", {}).items()
574                }
575                non_strs = list(
576                    k
577                    for k
578                    in introducers.keys()
579                    if not isinstance(k, str)
580                )
581                if non_strs:
582                    raise TypeError(
583                        "Introducer petnames {!r} should have been str".format(
584                            non_strs,
585                        ),
586                    )
587                non_strs = list(
588                    v
589                    for v
590                    in introducers.values()
591                    if not isinstance(v, str)
592                )
593                if non_strs:
594                    raise TypeError(
595                        "Introducer fURLs {!r} should have been str".format(
596                            non_strs,
597                        ),
598                    )
599                log.msg(
600                    "found {} introducers in {!r}".format(
601                        len(introducers),
602                        introducers_yaml_filename,
603                    )
604                )
605        except EnvironmentError as e:
606            if e.errno != ENOENT:
607                raise
608            introducers = {}
609
610        # supported the deprecated [client]introducer.furl item in tahoe.cfg
611        tahoe_cfg_introducer_furl = self.get_config("client", "introducer.furl", None)
612        if tahoe_cfg_introducer_furl == "None":
613            raise ValueError(
614                "tahoe.cfg has invalid 'introducer.furl = None':"
615                " to disable it omit the key entirely"
616            )
617        if tahoe_cfg_introducer_furl:
618            warn(
619                "tahoe.cfg [client]introducer.furl is deprecated; "
620                "use private/introducers.yaml instead.",
621                category=DeprecationWarning,
622                stacklevel=-1,
623            )
624            if "default" in introducers:
625                raise ValueError(
626                    "'default' introducer furl cannot be specified in tahoe.cfg and introducers.yaml;"
627                    " please fix impossible configuration."
628                )
629            introducers['default'] = tahoe_cfg_introducer_furl
630
631        return {
632            petname: (furl, get_cache_filepath(petname))
633            for (petname, furl)
634            in introducers.items()
635        }
636
637
638def create_tub_options(config):
639    """
640    :param config: a _Config instance
641
642    :returns: dict containing all Foolscap Tub-related options,
643        overriding defaults with appropriate config from `config`
644        instance.
645    """
646    # We can't unify the camelCase vs. dashed-name divide here,
647    # because these are options for Foolscap
648    tub_options = {
649        "logLocalFailures": True,
650        "logRemoteFailures": True,
651        "expose-remote-exception-types": False,
652        "accept-gifts": False,
653    }
654
655    # see #521 for a discussion of how to pick these timeout values.
656    keepalive_timeout_s = config.get_config("node", "timeout.keepalive", "")
657    if keepalive_timeout_s:
658        tub_options["keepaliveTimeout"] = int(keepalive_timeout_s)
659    disconnect_timeout_s = config.get_config("node", "timeout.disconnect", "")
660    if disconnect_timeout_s:
661        # N.B.: this is in seconds, so use "1800" to get 30min
662        tub_options["disconnectTimeout"] = int(disconnect_timeout_s)
663    return tub_options
664
665
666def _make_tcp_handler():
667    """
668    :returns: a Foolscap default TCP handler
669    """
670    # this is always available
671    from foolscap.connections.tcp import default
672    return default()
673
674
675def create_default_connection_handlers(config, handlers):
676    """
677    :return: A dictionary giving the default connection handlers.  The keys
678        are strings like "tcp" and the values are strings like "tor" or
679        ``None``.
680    """
681    reveal_ip = config.get_config("node", "reveal-IP-address", True, boolean=True)
682
683    # Remember the default mappings from tahoe.cfg
684    default_connection_handlers = {
685        name: name
686        for name
687        in handlers
688    }
689    tcp_handler_name = config.get_config("connections", "tcp", "tcp").lower()
690    if tcp_handler_name == "disabled":
691        default_connection_handlers["tcp"] = None
692    else:
693        if tcp_handler_name not in handlers:
694            raise ValueError(
695                "'tahoe.cfg [connections] tcp=' uses "
696                "unknown handler type '{}'".format(
697                    tcp_handler_name
698                )
699            )
700        if not handlers[tcp_handler_name]:
701            raise ValueError(
702                "'tahoe.cfg [connections] tcp=' uses "
703                "unavailable/unimportable handler type '{}'. "
704                "Please pip install tahoe-lafs[{}] to fix.".format(
705                    tcp_handler_name,
706                    tcp_handler_name,
707                )
708            )
709        default_connection_handlers["tcp"] = tcp_handler_name
710
711    if not reveal_ip:
712        if default_connection_handlers.get("tcp") == "tcp":
713            raise PrivacyError(
714                "Privacy requested with `reveal-IP-address = false` "
715                "but `tcp = tcp` conflicts with this.",
716            )
717    return default_connection_handlers
718
719
720def create_connection_handlers(config, i2p_provider, tor_provider):
721    """
722    :returns: 2-tuple of default_connection_handlers, foolscap_connection_handlers
723    """
724    # We store handlers for everything. None means we were unable to
725    # create that handler, so hints which want it will be ignored.
726    handlers = {
727        "tcp": _make_tcp_handler(),
728        "tor": tor_provider.get_client_endpoint(),
729        "i2p": i2p_provider.get_client_endpoint(),
730    }
731    log.msg(
732        format="built Foolscap connection handlers for: %(known_handlers)s",
733        known_handlers=sorted([k for k,v in handlers.items() if v]),
734        facility="tahoe.node",
735        umid="PuLh8g",
736    )
737    return create_default_connection_handlers(
738        config,
739        handlers,
740    ), handlers
741
742
743def create_tub(tub_options, default_connection_handlers, foolscap_connection_handlers,
744               handler_overrides=None, force_foolscap=False, **kwargs):
745    """
746    Create a Tub with the right options and handlers. It will be
747    ephemeral unless the caller provides certFile= in kwargs
748
749    :param handler_overrides: anything in this will override anything
750        in `default_connection_handlers` for just this call.
751
752    :param dict tub_options: every key-value pair in here will be set in
753        the new Tub via `Tub.setOption`
754
755    :param bool force_foolscap: If True, only allow Foolscap, not just HTTPS
756        storage protocol.
757    """
758    if handler_overrides is None:
759        handler_overrides = {}
760    # We listen simultaneously for both Foolscap and HTTPS on the same port,
761    # so we have to create a special Foolscap Tub for that to work:
762    if force_foolscap:
763        tub = Tub(**kwargs)
764    else:
765        tub = create_tub_with_https_support(**kwargs)
766
767    for (name, value) in list(tub_options.items()):
768        tub.setOption(name, value)
769    handlers = default_connection_handlers.copy()
770    handlers.update(handler_overrides)
771    tub.removeAllConnectionHintHandlers()
772    for hint_type, handler_name in list(handlers.items()):
773        handler = foolscap_connection_handlers.get(handler_name)
774        if handler:
775            tub.addConnectionHintHandler(hint_type, handler)
776    return tub
777
778
779def _convert_tub_port(s):
780    """
781    :returns: a proper Twisted endpoint string like (`tcp:X`) is `s`
782        is a bare number, or returns `s` as-is
783    """
784    us = s
785    if isinstance(s, bytes):
786        us = s.decode("utf-8")
787    if re.search(r'^\d+$', us):
788        return "tcp:{}".format(int(us))
789    return us
790
791
792class PortAssignmentRequired(Exception):
793    """
794    A Tub port number was configured to be 0 where this is not allowed.
795    """
796
797
798def _tub_portlocation(config, get_local_addresses_sync, allocate_tcp_port):
799    """
800    Figure out the network location of the main tub for some configuration.
801
802    :param get_local_addresses_sync: A function like
803        ``iputil.get_local_addresses_sync``.
804
805    :param allocate_tcp_port: A function like ``iputil.allocate_tcp_port``.
806
807    :returns: None or tuple of (port, location) for the main tub based
808        on the given configuration. May raise ValueError or PrivacyError
809        if there are problems with the config
810    """
811    cfg_tubport = config.get_config("node", "tub.port", None)
812    cfg_location = config.get_config("node", "tub.location", None)
813    reveal_ip = config.get_config("node", "reveal-IP-address", True, boolean=True)
814    tubport_disabled = False
815
816    if cfg_tubport is not None:
817        cfg_tubport = cfg_tubport.strip()
818        if cfg_tubport == "":
819            raise ValueError("tub.port must not be empty")
820        if cfg_tubport == "disabled":
821            tubport_disabled = True
822
823    location_disabled = False
824    if cfg_location is not None:
825        cfg_location = cfg_location.strip()
826        if cfg_location == "":
827            raise ValueError("tub.location must not be empty")
828        if cfg_location == "disabled":
829            location_disabled = True
830
831    if tubport_disabled and location_disabled:
832        return None
833    if tubport_disabled and not location_disabled:
834        raise ValueError("tub.port is disabled, but not tub.location")
835    if location_disabled and not tubport_disabled:
836        raise ValueError("tub.location is disabled, but not tub.port")
837
838    if cfg_tubport is None:
839        # For 'tub.port', tahoe.cfg overrides the individual file on
840        # disk. So only read config.portnum_fname if tahoe.cfg doesn't
841        # provide a value.
842        if os.path.exists(config.portnum_fname):
843            file_tubport = fileutil.read(config.portnum_fname).strip()
844            tubport = _convert_tub_port(file_tubport)
845        else:
846            tubport = "tcp:%d" % (allocate_tcp_port(),)
847            fileutil.write_atomically(config.portnum_fname, tubport + "\n",
848                                      mode="")
849    else:
850        tubport = _convert_tub_port(cfg_tubport)
851
852    for port in tubport.split(","):
853        if port in ("0", "tcp:0", "tcp:port=0", "tcp:0:interface=127.0.0.1"):
854            raise PortAssignmentRequired()
855
856    if cfg_location is None:
857        cfg_location = "AUTO"
858
859    local_portnum = None # needed to hush lgtm.com static analyzer
860    # Replace the location "AUTO", if present, with the detected local
861    # addresses. Don't probe for local addresses unless necessary.
862    split_location = cfg_location.split(",")
863    if "AUTO" in split_location:
864        if not reveal_ip:
865            raise PrivacyError("tub.location uses AUTO")
866        local_addresses = get_local_addresses_sync()
867        # tubport must be like "tcp:12345" or "tcp:12345:morestuff"
868        local_portnum = int(tubport.split(":")[1])
869    new_locations = []
870    for loc in split_location:
871        if loc == "AUTO":
872            new_locations.extend(["tcp:%s:%d" % (ip, local_portnum)
873                                  for ip in local_addresses])
874        else:
875            if not reveal_ip:
876                # Legacy hints are "host:port". We use Foolscap's utility
877                # function to convert all hints into the modern format
878                # ("tcp:host:port") because that's what the receiving
879                # client will probably do. We test the converted hint for
880                # TCP-ness, but publish the original hint because that
881                # was the user's intent.
882                from foolscap.connections.tcp import convert_legacy_hint
883                converted_hint = convert_legacy_hint(loc)
884                hint_type = converted_hint.split(":")[0]
885                if hint_type == "tcp":
886                    raise PrivacyError("tub.location includes tcp: hint")
887            new_locations.append(loc)
888    location = ",".join(new_locations)
889
890    # Lacking this, Python 2 blows up in Foolscap when it is confused by a
891    # Unicode FURL.
892    location = location.encode("utf-8")
893
894    return tubport, location
895
896
897def tub_listen_on(i2p_provider, tor_provider, tub, tubport, location):
898    """
899    Assign a Tub its listener locations.
900
901    :param i2p_provider: See ``allmydata.util.i2p_provider.create``.
902    :param tor_provider: See ``allmydata.util.tor_provider.create``.
903    """
904    for port in tubport.split(","):
905        if port == "listen:i2p":
906            # the I2P provider will read its section of tahoe.cfg and
907            # return either a fully-formed Endpoint, or a descriptor
908            # that will create one, so we don't have to stuff all the
909            # options into the tub.port string (which would need a lot
910            # of escaping)
911            port_or_endpoint = i2p_provider.get_listener()
912        elif port == "listen:tor":
913            port_or_endpoint = tor_provider.get_listener()
914        else:
915            port_or_endpoint = port
916        # Foolscap requires native strings:
917        if isinstance(port_or_endpoint, (bytes, str)):
918            port_or_endpoint = ensure_str(port_or_endpoint)
919        tub.listenOn(port_or_endpoint)
920    # This last step makes the Tub is ready for tub.registerReference()
921    tub.setLocation(location)
922
923
924def create_main_tub(config, tub_options,
925                    default_connection_handlers, foolscap_connection_handlers,
926                    i2p_provider, tor_provider,
927                    handler_overrides=None, cert_filename="node.pem"):
928    """
929    Creates a 'main' Foolscap Tub, typically for use as the top-level
930    access point for a running Node.
931
932    :param Config: a `_Config` instance
933
934    :param dict tub_options: any options to change in the tub
935
936    :param default_connection_handlers: default Foolscap connection
937        handlers
938
939    :param foolscap_connection_handlers: Foolscap connection
940        handlers for this tub
941
942    :param i2p_provider: None, or a _Provider instance if I2P is
943        installed.
944
945    :param tor_provider: None, or a _Provider instance if txtorcon +
946        Tor are installed.
947    """
948    if handler_overrides is None:
949        handler_overrides = {}
950    portlocation = _tub_portlocation(
951        config,
952        iputil.get_local_addresses_sync,
953        iputil.allocate_tcp_port,
954    )
955
956    # FIXME? "node.pem" was the CERTFILE option/thing
957    certfile = config.get_private_path("node.pem")
958    tub = create_tub(
959        tub_options,
960        default_connection_handlers,
961        foolscap_connection_handlers,
962        force_foolscap=config.get_config(
963            "storage", "force_foolscap", default=False, boolean=True
964        ),
965        handler_overrides=handler_overrides,
966        certFile=certfile,
967    )
968
969    if portlocation is None:
970        log.msg("Tub is not listening")
971    else:
972        tubport, location = portlocation
973        tub_listen_on(
974            i2p_provider,
975            tor_provider,
976            tub,
977            tubport,
978            location,
979        )
980        log.msg("Tub location set to %r" % (location,))
981    return tub
982
983
984class Node(service.MultiService):
985    """
986    This class implements common functionality of both Client nodes and Introducer nodes.
987    """
988    NODETYPE = "unknown NODETYPE"
989    CERTFILE = "node.pem"
990
991    def __init__(self, config, main_tub, i2p_provider, tor_provider):
992        """
993        Initialize the node with the given configuration. Its base directory
994        is the current directory by default.
995        """
996        service.MultiService.__init__(self)
997
998        self.config = config
999        self.get_config = config.get_config # XXX stopgap
1000        self.nickname = config.nickname # XXX stopgap
1001
1002        # this can go away once Client.init_client_storage_broker is moved into create_client()
1003        # (tests sometimes have None here)
1004        self._i2p_provider = i2p_provider
1005        self._tor_provider = tor_provider
1006
1007        self.create_log_tub()
1008        self.logSource = "Node"
1009        self.setup_logging()
1010
1011        self.tub = main_tub
1012        if self.tub is not None:
1013            self.nodeid = b32decode(self.tub.tubID.upper())  # binary format
1014            self.short_nodeid = b32encode(self.nodeid).lower()[:8]  # for printing
1015            self.config.write_config_file("my_nodeid", b32encode(self.nodeid).lower() + b"\n", mode="wb")
1016            self.tub.setServiceParent(self)
1017        else:
1018            self.nodeid = self.short_nodeid = None
1019
1020        self.log("Node constructed. " + __full_version__)
1021        iputil.increase_rlimits()
1022
1023    def _is_tub_listening(self):
1024        """
1025        :returns: True if the main tub is listening
1026        """
1027        return len(self.tub.getListeners()) > 0
1028
1029    # pull this outside of Node's __init__ too, see:
1030    # https://tahoe-lafs.org/trac/tahoe-lafs/ticket/2948
1031    def create_log_tub(self):
1032        # The logport uses a localhost-only ephemeral Tub, with no control
1033        # over the listening port or location. This might change if we
1034        # discover a compelling reason for it in the future (e.g. being able
1035        # to use "flogtool tail" against a remote server), but for now I
1036        # think we can live without it.
1037        self.log_tub = Tub()
1038        portnum = iputil.listenOnUnused(self.log_tub)
1039        self.log("Log Tub location set to 127.0.0.1:%s" % (portnum,))
1040        self.log_tub.setServiceParent(self)
1041
1042    def startService(self):
1043        # Note: this class can be started and stopped at most once.
1044        self.log("Node.startService")
1045        # Record the process id in the twisted log, after startService()
1046        # (__init__ is called before fork(), but startService is called
1047        # after). Note that Foolscap logs handle pid-logging by itself, no
1048        # need to send a pid to the foolscap log here.
1049        twlog.msg("My pid: %s" % os.getpid())
1050        try:
1051            os.chmod("twistd.pid", 0o644)
1052        except EnvironmentError:
1053            pass
1054
1055        service.MultiService.startService(self)
1056        self.log("%s running" % self.NODETYPE)
1057        twlog.msg("%s running" % self.NODETYPE)
1058
1059    def stopService(self):
1060        self.log("Node.stopService")
1061        return service.MultiService.stopService(self)
1062
1063    def shutdown(self):
1064        """Shut down the node. Returns a Deferred that fires (with None) when
1065        it finally stops kicking."""
1066        self.log("Node.shutdown")
1067        return self.stopService()
1068
1069    def setup_logging(self):
1070        # we replace the formatTime() method of the log observer that
1071        # twistd set up for us, with a method that uses our preferred
1072        # timestamp format.
1073        for o in twlog.theLogPublisher.observers:
1074            # o might be a FileLogObserver's .emit method
1075            if type(o) is type(self.setup_logging): # bound method
1076                ob = o.__self__
1077                if isinstance(ob, twlog.FileLogObserver):
1078                    newmeth = types.MethodType(formatTimeTahoeStyle, ob)
1079                    ob.formatTime = newmeth
1080        # TODO: twisted >2.5.0 offers maxRotatedFiles=50
1081
1082        lgfurl_file = self.config.get_private_path("logport.furl").encode(get_filesystem_encoding())
1083        if os.path.exists(lgfurl_file):
1084            os.remove(lgfurl_file)
1085        self.log_tub.setOption("logport-furlfile", lgfurl_file)
1086        lgfurl = self.config.get_config("node", "log_gatherer.furl", "")
1087        if lgfurl:
1088            # this is in addition to the contents of log-gatherer-furlfile
1089            lgfurl = lgfurl.encode("utf-8")
1090            self.log_tub.setOption("log-gatherer-furl", lgfurl)
1091        self.log_tub.setOption("log-gatherer-furlfile",
1092                               self.config.get_config_path("log_gatherer.furl"))
1093
1094        incident_dir = self.config.get_config_path("logs", "incidents")
1095        foolscap.logging.log.setLogDir(incident_dir)
1096        twlog.msg("Foolscap logging initialized")
1097        twlog.msg("Note to developers: twistd.log does not receive very much.")
1098        twlog.msg("Use 'flogtool tail -c NODEDIR/private/logport.furl' instead")
1099        twlog.msg("and read docs/logging.rst")
1100
1101    def log(self, *args, **kwargs):
1102        return log.msg(*args, **kwargs)
Note: See TracBrowser for help on using the repository browser.