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