sydent.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590
  1. # Copyright 2014 OpenMarket Ltd
  2. # Copyright 2018 New Vector Ltd
  3. # Copyright 2019 The Matrix.org Foundation C.I.C.
  4. #
  5. # Licensed under the Apache License, Version 2.0 (the "License");
  6. # you may not use this file except in compliance with the License.
  7. # You may obtain a copy of the License at
  8. #
  9. # http://www.apache.org/licenses/LICENSE-2.0
  10. #
  11. # Unless required by applicable law or agreed to in writing, software
  12. # distributed under the License is distributed on an "AS IS" BASIS,
  13. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  14. # See the License for the specific language governing permissions and
  15. # limitations under the License.
  16. import configparser
  17. import copy
  18. import gc
  19. import logging
  20. import logging.handlers
  21. import os
  22. from typing import Set
  23. import twisted.internet.reactor
  24. from jinja2 import Environment, FileSystemLoader
  25. from twisted.internet import address, task
  26. from twisted.python import log
  27. from sydent.config import SydentConfig
  28. from sydent.db.hashing_metadata import HashingMetadataStore
  29. from sydent.db.sqlitedb import SqliteDatabase
  30. from sydent.db.valsession import ThreePidValSessionStore
  31. from sydent.hs_federation.verifier import Verifier
  32. from sydent.http.httpcommon import SslComponents
  33. from sydent.http.httpsclient import ReplicationHttpsClient
  34. from sydent.http.httpserver import (
  35. ClientApiHttpServer,
  36. InternalApiHttpServer,
  37. ReplicationHttpsServer,
  38. )
  39. from sydent.http.servlets.accountservlet import AccountServlet
  40. from sydent.http.servlets.blindlysignstuffservlet import BlindlySignStuffServlet
  41. from sydent.http.servlets.bulklookupservlet import BulkLookupServlet
  42. from sydent.http.servlets.emailservlet import (
  43. EmailRequestCodeServlet,
  44. EmailValidateCodeServlet,
  45. )
  46. from sydent.http.servlets.getvalidated3pidservlet import GetValidated3pidServlet
  47. from sydent.http.servlets.hashdetailsservlet import HashDetailsServlet
  48. from sydent.http.servlets.logoutservlet import LogoutServlet
  49. from sydent.http.servlets.lookupservlet import LookupServlet
  50. from sydent.http.servlets.lookupv2servlet import LookupV2Servlet
  51. from sydent.http.servlets.msisdnservlet import (
  52. MsisdnRequestCodeServlet,
  53. MsisdnValidateCodeServlet,
  54. )
  55. from sydent.http.servlets.pubkeyservlets import (
  56. Ed25519Servlet,
  57. EphemeralPubkeyIsValidServlet,
  58. PubkeyIsValidServlet,
  59. )
  60. from sydent.http.servlets.registerservlet import RegisterServlet
  61. from sydent.http.servlets.replication import ReplicationPushServlet
  62. from sydent.http.servlets.store_invite_servlet import StoreInviteServlet
  63. from sydent.http.servlets.termsservlet import TermsServlet
  64. from sydent.http.servlets.threepidbindservlet import ThreePidBindServlet
  65. from sydent.http.servlets.threepidunbindservlet import ThreePidUnbindServlet
  66. from sydent.http.servlets.v1_servlet import V1Servlet
  67. from sydent.http.servlets.v2_servlet import V2Servlet
  68. from sydent.replication.pusher import Pusher
  69. from sydent.threepid.bind import ThreepidBinder
  70. from sydent.util.hash import sha256_and_url_safe_base64
  71. from sydent.util.ip_range import DEFAULT_IP_RANGE_BLACKLIST, generate_ip_set
  72. from sydent.util.tokenutils import generateAlphanumericTokenOfLength
  73. from sydent.validators.emailvalidator import EmailValidator
  74. from sydent.validators.msisdnvalidator import MsisdnValidator
  75. logger = logging.getLogger(__name__)
  76. CONFIG_DEFAULTS = {
  77. "general": {
  78. "server.name": os.environ.get("SYDENT_SERVER_NAME", ""),
  79. "log.path": "",
  80. "log.level": "INFO",
  81. "pidfile.path": os.environ.get("SYDENT_PID_FILE", "sydent.pid"),
  82. "terms.path": "",
  83. "address_lookup_limit": "10000", # Maximum amount of addresses in a single /lookup request
  84. # The root path to use for load templates. This should contain branded
  85. # directories. Each directory should contain the following templates:
  86. #
  87. # * invite_template.eml
  88. # * verification_template.eml
  89. # * verify_response_template.html
  90. "templates.path": "res",
  91. # The brand directory to use if no brand hint (or an invalid brand hint)
  92. # is provided by the request.
  93. "brand.default": "matrix-org",
  94. # The following can be added to your local config file to enable prometheus
  95. # support.
  96. # 'prometheus_port': '8080', # The port to serve metrics on
  97. # 'prometheus_addr': '', # The address to bind to. Empty string means bind to all.
  98. # The following can be added to your local config file to enable sentry support.
  99. # 'sentry_dsn': 'https://...' # The DSN has configured in the sentry instance project.
  100. # Whether clients and homeservers can register an association using v1 endpoints.
  101. "enable_v1_associations": "true",
  102. "delete_tokens_on_bind": "true",
  103. # Prevent outgoing requests from being sent to the following blacklisted
  104. # IP address CIDR ranges. If this option is not specified or empty then
  105. # it defaults to private IP address ranges.
  106. #
  107. # The blacklist applies to all outbound requests except replication
  108. # requests.
  109. #
  110. # (0.0.0.0 and :: are always blacklisted, whether or not they are
  111. # explicitly listed here, since they correspond to unroutable
  112. # addresses.)
  113. "ip.blacklist": "",
  114. # List of IP address CIDR ranges that should be allowed for outbound
  115. # requests. This is useful for specifying exceptions to wide-ranging
  116. # blacklisted target IP ranges.
  117. #
  118. # This whitelist overrides `ip.blacklist` and defaults to an empty
  119. # list.
  120. "ip.whitelist": "",
  121. },
  122. "db": {
  123. "db.file": os.environ.get("SYDENT_DB_PATH", "sydent.db"),
  124. },
  125. "http": {
  126. "clientapi.http.bind_address": "::",
  127. "clientapi.http.port": "8090",
  128. "internalapi.http.bind_address": "::1",
  129. "internalapi.http.port": "",
  130. "replication.https.certfile": "",
  131. "replication.https.cacert": "", # This should only be used for testing
  132. "replication.https.bind_address": "::",
  133. "replication.https.port": "4434",
  134. "obey_x_forwarded_for": "False",
  135. "federation.verifycerts": "True",
  136. # verify_response_template is deprecated, but still used if defined Define
  137. # templates.path and brand.default under general instead.
  138. #
  139. # 'verify_response_template': 'res/verify_response_page_template',
  140. "client_http_base": "",
  141. },
  142. "email": {
  143. # email.template and email.invite_template are deprecated, but still used
  144. # if defined. Define templates.path and brand.default under general instead.
  145. #
  146. # 'email.template': 'res/verification_template.eml',
  147. # 'email.invite_template': 'res/invite_template.eml',
  148. "email.from": "Sydent Validation <noreply@{hostname}>",
  149. "email.subject": "Your Validation Token",
  150. "email.invite.subject": "%(sender_display_name)s has invited you to chat",
  151. "email.invite.subject_space": "%(sender_display_name)s has invited you to a space",
  152. "email.smtphost": "localhost",
  153. "email.smtpport": "25",
  154. "email.smtpusername": "",
  155. "email.smtppassword": "",
  156. "email.hostname": "",
  157. "email.tlsmode": "0",
  158. # The web client location which will be used if it is not provided by
  159. # the homeserver.
  160. #
  161. # This should be the scheme and hostname only, see res/invite_template.eml
  162. # for the full URL that gets generated.
  163. "email.default_web_client_location": "https://app.element.io",
  164. # When a user is invited to a room via their email address, that invite is
  165. # displayed in the room list using an obfuscated version of the user's email
  166. # address. These config options determine how much of the email address to
  167. # obfuscate. Note that the '@' sign is always included.
  168. #
  169. # If the string is longer than a configured limit below, it is truncated to that limit
  170. # with '...' added. Otherwise:
  171. #
  172. # * If the string is longer than 5 characters, it is truncated to 3 characters + '...'
  173. # * If the string is longer than 1 character, it is truncated to 1 character + '...'
  174. # * If the string is 1 character long, it is converted to '...'
  175. #
  176. # This ensures that a full email address is never shown, even if it is extremely
  177. # short.
  178. #
  179. # The number of characters from the beginning to reveal of the email's username
  180. # portion (left of the '@' sign)
  181. "email.third_party_invite_username_obfuscate_characters": "3",
  182. # The number of characters from the beginning to reveal of the email's domain
  183. # portion (right of the '@' sign)
  184. "email.third_party_invite_domain_obfuscate_characters": "3",
  185. },
  186. "sms": {
  187. "bodyTemplate": "Your code is {token}",
  188. "username": "",
  189. "password": "",
  190. },
  191. "crypto": {
  192. "ed25519.signingkey": "",
  193. },
  194. }
  195. class Sydent:
  196. def __init__(
  197. self,
  198. cfg,
  199. sydent_config: SydentConfig,
  200. reactor=twisted.internet.reactor,
  201. use_tls_for_federation=True,
  202. ):
  203. self.cfg = cfg
  204. self.config = sydent_config
  205. self.reactor = reactor
  206. self.config_file = get_config_file_path()
  207. self.use_tls_for_federation = use_tls_for_federation
  208. logger.info("Starting Sydent server")
  209. self.pidfile = self.cfg.get("general", "pidfile.path")
  210. self.db = SqliteDatabase(self).db
  211. if self.cfg.has_option("general", "sentry_dsn"):
  212. # Only import and start sentry SDK if configured.
  213. import sentry_sdk
  214. sentry_sdk.init(
  215. dsn=self.cfg.get("general", "sentry_dsn"),
  216. )
  217. with sentry_sdk.configure_scope() as scope:
  218. scope.set_tag("sydent_server_name", self.config.general.server_name)
  219. if self.cfg.has_option("general", "prometheus_port"):
  220. import prometheus_client
  221. prometheus_client.start_http_server(
  222. port=self.cfg.getint("general", "prometheus_port"),
  223. addr=self.cfg.get("general", "prometheus_addr"),
  224. )
  225. if self.cfg.has_option("general", "templates.path"):
  226. # Get the possible brands by looking at directories under the
  227. # templates.path directory.
  228. root_template_path = self.cfg.get("general", "templates.path")
  229. if os.path.exists(root_template_path):
  230. self.valid_brands = {
  231. p
  232. for p in os.listdir(root_template_path)
  233. if os.path.isdir(os.path.join(root_template_path, p))
  234. }
  235. else:
  236. # This is a legacy code-path and assumes that verify_response_template,
  237. # email.template, and email.invite_template are defined.
  238. self.valid_brands = set()
  239. self.enable_v1_associations = parse_cfg_bool(
  240. self.cfg.get("general", "enable_v1_associations")
  241. )
  242. self.delete_tokens_on_bind = parse_cfg_bool(
  243. self.cfg.get("general", "delete_tokens_on_bind")
  244. )
  245. ip_blacklist = set_from_comma_sep_string(
  246. self.cfg.get("general", "ip.blacklist")
  247. )
  248. if not ip_blacklist:
  249. ip_blacklist = DEFAULT_IP_RANGE_BLACKLIST
  250. ip_whitelist = set_from_comma_sep_string(
  251. self.cfg.get("general", "ip.whitelist")
  252. )
  253. self.ip_blacklist = generate_ip_set(ip_blacklist)
  254. self.ip_whitelist = generate_ip_set(ip_whitelist)
  255. self.template_environment = Environment(
  256. loader=FileSystemLoader(self.cfg.get("general", "templates.path")),
  257. autoescape=True,
  258. )
  259. # See if a pepper already exists in the database
  260. # Note: This MUST be run before we start serving requests, otherwise lookups for
  261. # 3PID hashes may come in before we've completed generating them
  262. hashing_metadata_store = HashingMetadataStore(self)
  263. lookup_pepper = hashing_metadata_store.get_lookup_pepper()
  264. if not lookup_pepper:
  265. # No pepper defined in the database, generate one
  266. lookup_pepper = generateAlphanumericTokenOfLength(5)
  267. # Store it in the database and rehash 3PIDs
  268. hashing_metadata_store.store_lookup_pepper(
  269. sha256_and_url_safe_base64, lookup_pepper
  270. )
  271. self.validators = Validators()
  272. self.validators.email = EmailValidator(self)
  273. self.validators.msisdn = MsisdnValidator(self)
  274. self.keyring = Keyring()
  275. self.keyring.ed25519 = self.config.crypto.signing_key
  276. self.keyring.ed25519.alg = "ed25519"
  277. self.sig_verifier = Verifier(self)
  278. self.servlets = Servlets()
  279. self.servlets.v1 = V1Servlet(self)
  280. self.servlets.v2 = V2Servlet(self)
  281. self.servlets.emailRequestCode = EmailRequestCodeServlet(self)
  282. self.servlets.emailRequestCodeV2 = EmailRequestCodeServlet(
  283. self, require_auth=True
  284. )
  285. self.servlets.emailValidate = EmailValidateCodeServlet(self)
  286. self.servlets.emailValidateV2 = EmailValidateCodeServlet(
  287. self, require_auth=True
  288. )
  289. self.servlets.msisdnRequestCode = MsisdnRequestCodeServlet(self)
  290. self.servlets.msisdnRequestCodeV2 = MsisdnRequestCodeServlet(
  291. self, require_auth=True
  292. )
  293. self.servlets.msisdnValidate = MsisdnValidateCodeServlet(self)
  294. self.servlets.msisdnValidateV2 = MsisdnValidateCodeServlet(
  295. self, require_auth=True
  296. )
  297. self.servlets.lookup = LookupServlet(self)
  298. self.servlets.bulk_lookup = BulkLookupServlet(self)
  299. self.servlets.hash_details = HashDetailsServlet(self, lookup_pepper)
  300. self.servlets.lookup_v2 = LookupV2Servlet(self, lookup_pepper)
  301. self.servlets.pubkey_ed25519 = Ed25519Servlet(self)
  302. self.servlets.pubkeyIsValid = PubkeyIsValidServlet(self)
  303. self.servlets.ephemeralPubkeyIsValid = EphemeralPubkeyIsValidServlet(self)
  304. self.servlets.threepidBind = ThreePidBindServlet(self)
  305. self.servlets.threepidBindV2 = ThreePidBindServlet(self, require_auth=True)
  306. self.servlets.threepidUnbind = ThreePidUnbindServlet(self)
  307. self.servlets.replicationPush = ReplicationPushServlet(self)
  308. self.servlets.getValidated3pid = GetValidated3pidServlet(self)
  309. self.servlets.getValidated3pidV2 = GetValidated3pidServlet(
  310. self, require_auth=True
  311. )
  312. self.servlets.storeInviteServlet = StoreInviteServlet(self)
  313. self.servlets.storeInviteServletV2 = StoreInviteServlet(self, require_auth=True)
  314. self.servlets.blindlySignStuffServlet = BlindlySignStuffServlet(self)
  315. self.servlets.blindlySignStuffServletV2 = BlindlySignStuffServlet(
  316. self, require_auth=True
  317. )
  318. self.servlets.termsServlet = TermsServlet(self)
  319. self.servlets.accountServlet = AccountServlet(self)
  320. self.servlets.registerServlet = RegisterServlet(self)
  321. self.servlets.logoutServlet = LogoutServlet(self)
  322. self.threepidBinder = ThreepidBinder(self)
  323. self.sslComponents = SslComponents(self)
  324. self.clientApiHttpServer = ClientApiHttpServer(self)
  325. self.replicationHttpsServer = ReplicationHttpsServer(self)
  326. self.replicationHttpsClient = ReplicationHttpsClient(self)
  327. self.pusher = Pusher(self)
  328. # A dedicated validation session store just to clean up old sessions every N minutes
  329. self.cleanupValSession = ThreePidValSessionStore(self)
  330. cb = task.LoopingCall(self.cleanupValSession.deleteOldSessions)
  331. cb.clock = self.reactor
  332. cb.start(10 * 60.0)
  333. # workaround for https://github.com/getsentry/sentry-python/issues/803: we
  334. # disable automatic GC and run it periodically instead.
  335. gc.disable()
  336. cb = task.LoopingCall(run_gc)
  337. cb.clock = self.reactor
  338. cb.start(1.0)
  339. def save_config(self):
  340. fp = open(self.config_file, "w")
  341. self.cfg.write(fp)
  342. fp.close()
  343. def run(self):
  344. self.clientApiHttpServer.setup()
  345. self.replicationHttpsServer.setup()
  346. self.pusher.setup()
  347. if self.config.http.internal_api_enabled:
  348. internalport = self.config.http.internal_port
  349. interface = self.config.http.internal_bind_address
  350. self.internalApiHttpServer = InternalApiHttpServer(self)
  351. self.internalApiHttpServer.setup(interface, internalport)
  352. if self.pidfile:
  353. with open(self.pidfile, "w") as pidfile:
  354. pidfile.write(str(os.getpid()) + "\n")
  355. self.reactor.run()
  356. def ip_from_request(self, request):
  357. if self.config.http.obey_x_forwarded_for and request.requestHeaders.hasHeader(
  358. "X-Forwarded-For"
  359. ):
  360. return request.requestHeaders.getRawHeaders("X-Forwarded-For")[0]
  361. client = request.getClientAddress()
  362. if isinstance(client, (address.IPv4Address, address.IPv6Address)):
  363. return client.host
  364. else:
  365. return None
  366. def brand_from_request(self, request):
  367. """
  368. If the brand GET parameter is passed, returns that as a string, otherwise returns None.
  369. :param request: The incoming request.
  370. :type request: twisted.web.http.Request
  371. :return: The brand to use or None if no hint is found.
  372. :rtype: str or None
  373. """
  374. if b"brand" in request.args:
  375. return request.args[b"brand"][0].decode("utf-8")
  376. return None
  377. def get_branded_template(self, brand, template_name, deprecated_template_name):
  378. """
  379. Calculate a (maybe) branded template filename to use.
  380. If the deprecated email.template setting is defined, always use it.
  381. Otherwise, attempt to use the hinted brand from the request if the brand
  382. is valid. Otherwise, fallback to the default brand.
  383. :param brand: The hint of which brand to use.
  384. :type brand: str or None
  385. :param template_name: The name of the template file to load.
  386. :type template_name: str
  387. :param deprecated_template_name: The deprecated setting to use, if provided.
  388. :type deprecated_template_name: Tuple[str]
  389. :return: The template filename to use.
  390. :rtype: str
  391. """
  392. # If the deprecated setting is defined, return it.
  393. try:
  394. return self.cfg.get(*deprecated_template_name)
  395. except configparser.NoOptionError:
  396. pass
  397. # If a brand hint is provided, attempt to use it if it is valid.
  398. if brand:
  399. if brand not in self.valid_brands:
  400. brand = None
  401. # If the brand hint is not valid, or not provided, fallback to the default brand.
  402. if not brand:
  403. brand = self.cfg.get("general", "brand.default")
  404. root_template_path = self.cfg.get("general", "templates.path")
  405. # Grab jinja template if it exists
  406. if os.path.exists(
  407. os.path.join(root_template_path, brand, template_name + ".j2")
  408. ):
  409. return os.path.join(brand, template_name + ".j2")
  410. else:
  411. return os.path.join(root_template_path, brand, template_name)
  412. class Validators:
  413. pass
  414. class Servlets:
  415. pass
  416. class Keyring:
  417. pass
  418. def parse_config_dict(config_dict):
  419. """Parse the given config from a dictionary, populating missing items and sections
  420. Args:
  421. config_dict (dict): the configuration dictionary to be parsed
  422. """
  423. # Build a config dictionary from the defaults merged with the given dictionary
  424. config = copy.deepcopy(CONFIG_DEFAULTS)
  425. for section, section_dict in config_dict.items():
  426. if section not in config:
  427. config[section] = {}
  428. for option in section_dict.keys():
  429. config[section][option] = config_dict[section][option]
  430. # Build a ConfigParser from the merged dictionary
  431. cfg = configparser.ConfigParser()
  432. for section, section_dict in config.items():
  433. cfg.add_section(section)
  434. for option, value in section_dict.items():
  435. cfg.set(section, option, value)
  436. return cfg
  437. def parse_config_file(config_file):
  438. """Parse the given config from a filepath, populating missing items and
  439. sections
  440. Args:
  441. config_file (str): the file to be parsed
  442. """
  443. # if the config file doesn't exist, prepopulate the config object
  444. # with the defaults, in the right section.
  445. #
  446. # otherwise, we have to put the defaults in the DEFAULT section,
  447. # to ensure that they don't override anyone's settings which are
  448. # in their config file in the default section (which is likely,
  449. # because sydent used to be braindead).
  450. use_defaults = not os.path.exists(config_file)
  451. cfg = configparser.ConfigParser()
  452. for sect, entries in CONFIG_DEFAULTS.items():
  453. cfg.add_section(sect)
  454. for k, v in entries.items():
  455. cfg.set(configparser.DEFAULTSECT if use_defaults else sect, k, v)
  456. cfg.read(config_file)
  457. return cfg
  458. def setup_logging(cfg):
  459. log_format = "%(asctime)s - %(name)s - %(lineno)d - %(levelname)s" " - %(message)s"
  460. formatter = logging.Formatter(log_format)
  461. logPath = cfg.get("general", "log.path")
  462. if logPath != "":
  463. handler = logging.handlers.TimedRotatingFileHandler(
  464. logPath, when="midnight", backupCount=365
  465. )
  466. handler.setFormatter(formatter)
  467. def sighup(signum, stack):
  468. logger.info("Closing log file due to SIGHUP")
  469. handler.doRollover()
  470. logger.info("Opened new log file due to SIGHUP")
  471. else:
  472. handler = logging.StreamHandler()
  473. handler.setFormatter(formatter)
  474. rootLogger = logging.getLogger("")
  475. rootLogger.setLevel(cfg.get("general", "log.level"))
  476. rootLogger.addHandler(handler)
  477. observer = log.PythonLoggingObserver()
  478. observer.start()
  479. def get_config_file_path():
  480. return os.environ.get("SYDENT_CONF", "sydent.conf")
  481. def parse_cfg_bool(value):
  482. return value.lower() == "true"
  483. def set_from_comma_sep_string(rawstr: str) -> Set[str]:
  484. if rawstr == "":
  485. return set()
  486. return {x.strip() for x in rawstr.split(",")}
  487. def run_gc():
  488. threshold = gc.get_threshold()
  489. counts = gc.get_count()
  490. for i in reversed(range(len(threshold))):
  491. if threshold[i] < counts[i]:
  492. gc.collect(i)
  493. if __name__ == "__main__":
  494. cfg = parse_config_file(get_config_file_path())
  495. setup_logging(cfg)
  496. sydent_config = SydentConfig()
  497. cfg_needs_saving = sydent_config.parse_from_config_parser(cfg)
  498. syd = Sydent(cfg, sydent_config=sydent_config)
  499. if cfg_needs_saving:
  500. syd.save_config()
  501. syd.run()