1#!/usr/bin/env python
2from __future__ import absolute_import
3
4import argparse
5import errno
6import logging
7import logging.handlers
8import os
9import os.path
10import sys
11
12import tornado.httpserver
13import tornado.ioloop
14import tornado.template
15import tornado.web
16from tornado.ioloop import IOLoop
17
18import auth
19import load_games
20import process_handler
21import userdb
22import config
23from config import *  # TODO: remove
24from game_data_handler import GameDataHandler
25from util import *
26from ws_handler import *
27
28
29class MainHandler(tornado.web.RequestHandler):
30    def get(self):
31        host = self.request.host
32        if self.request.protocol == "https" or self.request.headers.get("x-forwarded-proto") == "https":
33            protocol = "wss://"
34        else:
35            protocol = "ws://"
36
37        recovery_token = None
38        recovery_token_error = None
39
40        recovery_token = self.get_argument("ResetToken",None)
41        if recovery_token:
42            recovery_token_error = userdb.find_recovery_token(recovery_token)[2]
43            if recovery_token_error:
44                logging.warning("Recovery token error from %s", self.request.remote_ip)
45
46        self.render("client.html", socket_server = protocol + host + "/socket",
47                    username = None, config = config,
48                    reset_token = recovery_token, reset_token_error = recovery_token_error)
49
50class NoCacheHandler(tornado.web.StaticFileHandler):
51    def set_extra_headers(self, path):
52        self.set_header("Cache-Control", "no-cache, no-store, must-revalidate")
53        self.set_header("Pragma", "no-cache")
54        self.set_header("Expires", "0")
55
56def err_exit(errmsg):
57    logging.error(errmsg)
58    sys.exit(errmsg)
59
60def daemonize():
61    try:
62        pid = os.fork()
63        if pid > 0:
64            os._exit(0)
65    except OSError as e:
66        err_exit("Fork #1 failed! (%s)" % (e.strerror))
67
68    os.setsid()
69
70    try:
71        pid = os.fork()
72        if pid > 0:
73            os._exit(0)
74    except OSError as e:
75        err_exit("Fork #2 failed! (%s)" % e.strerror)
76
77    with open("/dev/null", "r") as f:
78        os.dup2(f.fileno(), sys.stdin.fileno())
79    with open("/dev/null", "w") as f:
80        os.dup2(f.fileno(), sys.stdout.fileno())
81        os.dup2(f.fileno(), sys.stderr.fileno())
82
83def write_pidfile():
84    if not getattr(config, 'pidfile', None):
85        return
86    if os.path.exists(config.pidfile):
87        try:
88            with open(config.pidfile) as f:
89                pid = int(f.read())
90        except ValueError:
91            err_exit("PIDfile %s contains non-numeric value" % config.pidfile)
92        try:
93            os.kill(pid, 0)
94        except OSError as why:
95            if why.errno == errno.ESRCH:
96                # The pid doesn't exist.
97                logging.warn("Removing stale pidfile %s" % config.pidfile)
98                os.remove(config.pidfile)
99            else:
100                err_exit("Can't check status of PID %s from pidfile %s: %s" %
101                         (pid, config.pidfile, why.strerror))
102        else:
103            err_exit("Another Webtiles server is running, PID %s\n" % pid)
104
105    with open(config.pidfile, "w") as f:
106        f.write(str(os.getpid()))
107
108def remove_pidfile():
109    if not getattr(config, 'pidfile', None):
110        return
111    try:
112        os.remove(config.pidfile)
113    except OSError as e:
114        if e.errno == errno.EACCES or e.errno == errno.EPERM:
115            logging.warn("No permission to delete pidfile!")
116        else:
117            logging.error("Failed to delete pidfile!")
118    except:
119        logging.error("Failed to delete pidfile!")
120
121def shed_privileges():
122    if getattr(config, 'gid', None) is not None:
123        os.setgid(config.gid)
124    if getattr(config, 'uid', None) is not None:
125        os.setuid(config.uid)
126
127def stop_everything():
128    for server in servers:
129        server.stop()
130    shutdown()
131    # TODO: shouldn't this actually wait for everything to close??
132    if len(sockets) == 0:
133        IOLoop.current().stop()
134    else:
135        IOLoop.current().add_timeout(time.time() + 2, IOLoop.current().stop)
136
137def signal_handler(signum, frame):
138    logging.info("Received signal %i, shutting down.", signum)
139    try:
140        IOLoop.current().add_callback_from_signal(stop_everything)
141    except AttributeError:
142        # This is for compatibility with ancient versions < Tornado 3. It
143        # probably won't shutdown correctly and is *definitely* incorrect for
144        # modern versions of Tornado; but this is how it was done on the
145        # original implementation of webtiles + Tornado 2.4 that was in use
146        # through about 2020.
147        stop_everything()
148
149def bind_server():
150    settings = {
151        "static_path": config.static_path,
152        "template_loader": DynamicTemplateLoader.get(config.template_path),
153        "debug": bool(getattr(config, 'development_mode', False)),
154        }
155
156    if hasattr(config, "no_cache") and config.no_cache:
157        settings["static_handler_class"] = NoCacheHandler
158
159    application = tornado.web.Application([
160            (r"/", MainHandler),
161            (r"/socket", CrawlWebSocket),
162            (r"/gamedata/([0-9a-f]*\/.*)", GameDataHandler)
163            ], gzip=getattr(config,"use_gzip",True), **settings)
164
165    kwargs = {}
166    if getattr(config, 'http_connection_timeout', None) is not None:
167        kwargs["idle_connection_timeout"] = config.http_connection_timeout
168
169    # TODO: the logic looks odd here, as it is set to None in the default
170    # config.py. But I'm not really sure how this is used so I don't want to
171    # mess with it...
172    if getattr(config, "http_xheaders", False):
173        kwargs["xheaders"] = config.http_xheaders
174
175    servers = []
176
177    def server_wrap(**kwargs):
178        try:
179            return tornado.httpserver.HTTPServer(application, **kwargs)
180        except TypeError:
181            # Ugly backwards-compatibility hack. Removable once Tornado 3
182            # is out of the picture (if ever)
183            del kwargs["idle_connection_timeout"]
184            server = tornado.httpserver.HTTPServer(application, **kwargs)
185            logging.error(
186                    "Server configuration sets `idle_connection_timeout` "
187                    "but this is not available in your version of "
188                    "Tornado. Please upgrade to at least Tornado 4 for "
189                    "this to work.""")
190            return server
191
192    if config.bind_nonsecure:
193        server = server_wrap(**kwargs)
194
195        try:
196            listens = config.bind_pairs
197        except AttributeError:
198            listens = ( (config.bind_address, config.bind_port), )
199        for (addr, port) in listens:
200            logging.info("Listening on %s:%d" % (addr, port))
201            server.listen(port, addr)
202        servers.append(server)
203
204    if config.ssl_options:
205        # TODO: allow different ssl_options per bind pair
206        server = server_wrap(ssl_options=config.ssl_options, **kwargs)
207
208        try:
209            listens = config.ssl_bind_pairs
210        except NameError:
211            listens = ( (config.ssl_address, config.ssl_port), )
212        for (addr, port) in listens:
213            logging.info("Listening on %s:%d" % (addr, port))
214            server.listen(port, addr)
215        servers.append(server)
216
217    return servers
218
219
220def init_logging(logging_config):
221    filename = logging_config.get("filename")
222    if filename:
223        max_bytes = logging_config.get("max_bytes", 10*1000*1000)
224        backup_count = logging_config.get("backup_count", 5)
225        hdlr = logging.handlers.RotatingFileHandler(
226            filename, maxBytes=max_bytes, backupCount=backup_count)
227    else:
228        hdlr = logging.StreamHandler(None)
229    fs = logging_config.get("format", "%(levelname)s:%(name)s:%(message)s")
230    dfs = logging_config.get("datefmt", None)
231    fmt = logging.Formatter(fs, dfs)
232    hdlr.setFormatter(fmt)
233    logging.getLogger().addHandler(hdlr)
234    level = logging_config.get("level")
235    if level is not None:
236        logging.getLogger().setLevel(level)
237    if tornado.version_info[0] >= 3:
238        # hide regular successful access messages, e.g. `200 GET` messages
239        # messages. 404s are still shown. TODO: would there be demand for
240        # sending this to its own file in a configurable way?
241        logging.getLogger("tornado.access").setLevel(logging.WARNING)
242    else:
243        # the tornado version is ancient enough that it doesn't have its own
244        # logging streams; this filter suppresses any logging done from the
245        # `web` module at level INFO.
246        logging.getLogger().addFilter(TornadoFilter())
247    logging.addLevelName(logging.DEBUG, "DEBG")
248    logging.addLevelName(logging.WARNING, "WARN")
249
250
251def check_config():
252    success = True
253    for (game_id, game_data) in config.games.items():
254        if not os.path.exists(game_data["crawl_binary"]):
255            logging.warning("Crawl executable for %s (%s) doesn't exist!",
256                            game_id, game_data["crawl_binary"])
257            success = False
258
259        if ("client_path" in game_data and
260            not os.path.exists(game_data["client_path"])):
261            logging.warning("Client data path %s doesn't exist!", game_data["client_path"])
262            success = False
263
264    load_games.collect_game_modes()
265
266    if (getattr(config, "allow_password_reset", False) or getattr(config, "admin_password_reset", False)) and not config.lobby_url:
267        logging.warning("Lobby URL needs to be defined!")
268        success = False
269    return success
270
271
272def monkeypatch_tornado24():
273    # extremely ugly compatibility hack, to ease transition for servers running
274    # the ancient patched tornado 2.4.
275    IOLoop.current = staticmethod(IOLoop.instance)
276
277
278def ensure_tornado_current():
279    try:
280        tornado.ioloop.IOLoop.current()
281    except AttributeError:
282        monkeypatch_tornado24()
283        tornado.ioloop.IOLoop.current()
284        logging.error(
285            "You are running a deprecated version of tornado; please update"
286            " to at least version 4.")
287
288
289def _do_load_games():
290    if getattr(config, "use_game_yaml", False):
291        config.games = load_games.load_games(config.games)
292
293
294def usr1_handler(signum, frame):
295    assert signum == signal.SIGUSR1
296    logging.info("Received USR1, reloading config.")
297    try:
298        IOLoop.current().add_callback_from_signal(_do_load_games)
299    except AttributeError:
300        # This is for compatibility with ancient versions < Tornado 3.
301        try:
302            _do_load_games()
303        except Exception:
304            logging.exception("Failed to update games after USR1 signal.")
305    except Exception:
306        logging.exception("Failed to update games after USR1 signal.")
307
308
309def parse_args():
310    parser = argparse.ArgumentParser(
311        description='Dungeon Crawl webtiles server',
312        epilog='Command line options will override config settings.')
313    parser.add_argument('-p', '--port', type=int, help='A port to bind; disables SSL.')
314    # TODO: --ssl-port or something?
315    parser.add_argument('--logfile',
316                        help='A logfile to write to; use "-" for stdout.')
317    parser.add_argument('--daemon', action='store_true', default=None,
318                        help='Daemonize after start.')
319    parser.add_argument('-n', '--no-daemon', action='store_false', default=None,
320                        dest='daemon',
321                        help='Do not daemonize after start.')
322    parser.add_argument('--no-pidfile', dest='pidfile', action='store_false',
323                        default=None, help='Do not use a PID-file.')
324
325    # live debug mode is intended to be able to run (more or less) safely with
326    # a concurrent real webtiles server. However, still be careful with this...
327    parser.add_argument('--live-debug', action='store_true',
328        help=('Debug mode for server admins. Will use a separate directory for sockets. '
329              'Entails --no-pidfile, --no-daemon, --logfile -, watch_socket_dirs=False. '
330              '(Further command line options can override these.)'))
331    parser.add_argument('--reset-password', type=str, help='A username to generate a password reset token for. Does not start the server.')
332    parser.add_argument('--clear-reset-password', type=str, help='A username to clear password reset tokens for. Does not start the server.')
333    result = parser.parse_args()
334    if result.live_debug or result.reset_password or result.clear_reset_password:
335        if not result.logfile:
336            result.logfile = '-'
337        if result.daemon is None:
338            result.daemon = False
339        result.pidfile = False
340        config.live_debug = True
341    return result
342
343
344# override config with any arguments supplied on the command line
345def export_args_to_config(args):
346    if args.port:
347        config.bind_nonsecure = True
348        config.bind_address = ""  # TODO: ??
349        config.bind_port = args.port
350        if getattr(config, 'bind_pairs', None) is not None:
351            del config.bind_pairs
352        logging.info("Using command-line supplied port: %d", args.port)
353        if getattr(config, 'ssl_options', None):
354            logging.info("    (Overrides config-specified SSL settings.)")
355            config.ssl_options = None
356    if args.daemon is not None:
357        logging.info("Command line override for daemonize: %r", args.daemon)
358        config.daemon = args.daemon
359    if args.pidfile is not None:
360        if not args.pidfile:
361            if getattr(config, 'pidfile', None):
362                logging.info("Command line overrides config-specified PID file!")
363            config.pidfile = None
364
365def reset_token_commands(args):
366    if args.clear_reset_password:
367        username = args.clear_reset_password
368    else:
369        username = args.reset_password
370
371    # duplicate some minimal setup needed for this to work
372    config.logging_config.pop('filename', None)
373    args.logfile = "<stdout>"  # make the log message easier to read
374
375    init_logging(config.logging_config)
376
377    if not check_config():
378        err_exit("Errors in config. Exiting.")
379    if config.dgl_mode:
380        userdb.ensure_user_db_exists()
381        userdb.upgrade_user_db()
382    userdb.ensure_settings_db_exists()
383    user_info = userdb.get_user_info(username)
384    if not user_info:
385        err_exit("Reset/clear password failed; invalid user: %s" % username)
386
387    # don't crash on the default config
388    if config.lobby_url is None:
389        config.lobby_url = "[insert lobby url here]"
390
391    if args.clear_reset_password:
392        ok, msg = userdb.clear_password_token(username)
393        if not ok:
394            err_exit("Error clearing password reset token for %s: %s" % (username, msg))
395        else:
396            print("Password reset token cleared for account '%s'." % username)
397    else:
398        ok, msg = userdb.generate_forgot_password(username)
399        if not ok:
400            err_exit("Error generating password reset token for %s: %s" % (username, msg))
401        else:
402            if not user_info[1]:
403                logging.warning("No email set for account '%s', use caution!" % username)
404            print("Setting a password reset token on account '%s'." % username)
405            print("Email: %s\nMessage body to send to user:\n%s\n" % (user_info[1], msg))
406
407
408def server_main():
409    args = parse_args()
410    if config.chroot:
411        os.chroot(config.chroot)
412
413    if args.reset_password or args.clear_reset_password:
414        reset_token_commands(args)
415        return
416
417    if getattr(config, 'live_debug', False):
418        logging.info("Starting in live-debug mode.")
419        config.watch_socket_dirs = False
420
421    # do this here so it can happen before logging init
422    if args.logfile:
423        if args.logfile == "-":
424            config.logging_config.pop('filename', None)
425            args.logfile = "<stdout>"  # make the log message easier to read
426        else:
427            config.logging_config['filename'] = args.logfile
428
429    init_logging(config.logging_config)
430
431    if args.logfile:
432        logging.info("Using command-line supplied logfile: '%s'", args.logfile)
433
434    _do_load_games()
435
436    export_args_to_config(args)
437
438    if not check_config():
439        err_exit("Errors in config. Exiting.")
440
441    if config.daemon:
442        daemonize()
443
444    signal.signal(signal.SIGTERM, signal_handler)
445    signal.signal(signal.SIGHUP, signal_handler)
446    signal.signal(signal.SIGINT, signal_handler)
447
448    if getattr(config, 'umask', None) is not None:
449        os.umask(config.umask)
450
451    write_pidfile()
452
453    global servers
454    servers = bind_server()
455    ensure_tornado_current()
456
457    shed_privileges()
458
459    if config.dgl_mode:
460        userdb.ensure_user_db_exists()
461        userdb.upgrade_user_db()
462    userdb.ensure_settings_db_exists()
463
464    signal.signal(signal.SIGUSR1, usr1_handler)
465
466    try:
467        IOLoop.current().set_blocking_log_threshold(0.5) # type: ignore
468        logging.info("Blocking call timeout: 500ms.")
469    except:
470        # this is the new normal; still not sure of a way to deal with this.
471        logging.info("Webserver running without a blocking call timeout.")
472
473    if config.dgl_mode:
474        status_file_timeout()
475        auth.purge_login_tokens_timeout()
476        start_reading_milestones()
477
478        if config.watch_socket_dirs:
479            process_handler.watch_socket_dirs()
480
481    logging.info("DCSS Webtiles server started with Tornado %s! (PID: %s)" %
482                                                (tornado.version, os.getpid()))
483
484    IOLoop.current().start()
485
486    logging.info("Bye!")
487    remove_pidfile()
488
489
490if __name__ == "__main__":
491    server_main()
492