1# -*- coding: utf-8 -*- 2 3# This file is part of Tautulli. 4# 5# Tautulli is free software: you can redistribute it and/or modify 6# it under the terms of the GNU General Public License as published by 7# the Free Software Foundation, either version 3 of the License, or 8# (at your option) any later version. 9# 10# Tautulli is distributed in the hope that it will be useful, 11# but WITHOUT ANY WARRANTY; without even the implied warranty of 12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13# GNU General Public License for more details. 14# 15# You should have received a copy of the GNU General Public License 16# along with Tautulli. If not, see <http://www.gnu.org/licenses/>. 17 18from __future__ import unicode_literals 19from future.builtins import str 20 21from logging import handlers 22 23import cherrypy 24import logging 25import os 26import re 27import sys 28import threading 29import traceback 30 31import plexpy 32if plexpy.PYTHON2: 33 import helpers 34 from config import _BLACKLIST_KEYS, _WHITELIST_KEYS 35else: 36 from plexpy import helpers 37 from plexpy.config import _BLACKLIST_KEYS, _WHITELIST_KEYS 38 39 40# These settings are for file logging only 41FILENAME = "tautulli.log" 42FILENAME_API = "tautulli_api.log" 43FILENAME_PLEX_WEBSOCKET = "plex_websocket.log" 44MAX_SIZE = 5000000 # 5 MB 45MAX_FILES = 5 46 47_BLACKLIST_WORDS = set() 48 49# Tautulli logger 50logger = logging.getLogger("tautulli") 51# Tautulli API logger 52logger_api = logging.getLogger("tautulli_api") 53# Tautulli websocket logger 54logger_plex_websocket = logging.getLogger("plex_websocket") 55 56# Global queue for multiprocessing logging 57queue = None 58 59 60def blacklist_config(config): 61 blacklist = set() 62 blacklist_keys = ['HOOK', 'APIKEY', 'KEY', 'PASSWORD', 'TOKEN'] 63 64 for key, value in config.items(): 65 if isinstance(value, str) and len(value.strip()) > 5 and \ 66 key.upper() not in _WHITELIST_KEYS and (key.upper() in blacklist_keys or 67 any(bk in key.upper() for bk in _BLACKLIST_KEYS)): 68 blacklist.add(value.strip()) 69 70 _BLACKLIST_WORDS.update(blacklist) 71 72 73class NoThreadFilter(logging.Filter): 74 """ 75 Log filter for the current thread 76 """ 77 def __init__(self, threadName): 78 super(NoThreadFilter, self).__init__() 79 80 self.threadName = threadName 81 82 def filter(self, record): 83 return not record.threadName == self.threadName 84 85 86# Taken from Hellowlol/HTPC-Manager 87class BlacklistFilter(logging.Filter): 88 """ 89 Log filter for blacklisted tokens and passwords 90 """ 91 def __init__(self): 92 super(BlacklistFilter, self).__init__() 93 94 def filter(self, record): 95 if not plexpy.CONFIG.LOG_BLACKLIST: 96 return True 97 98 for item in _BLACKLIST_WORDS: 99 try: 100 if item in record.msg: 101 record.msg = record.msg.replace(item, 16 * '*') 102 103 args = [] 104 for arg in record.args: 105 try: 106 arg_str = str(arg) 107 if item in arg_str: 108 arg_str = arg_str.replace(item, 16 * '*') 109 arg = arg_str 110 except: 111 pass 112 args.append(arg) 113 record.args = tuple(args) 114 except: 115 pass 116 117 return True 118 119 120class RegexFilter(logging.Filter): 121 """ 122 Base class for regex log filter 123 """ 124 def __init__(self): 125 super(RegexFilter, self).__init__() 126 127 self.regex = re.compile(r'') 128 129 def filter(self, record): 130 if not plexpy.CONFIG.LOG_BLACKLIST: 131 return True 132 133 try: 134 matches = self.regex.findall(record.msg) 135 for match in matches: 136 record.msg = self.replace(record.msg, match) 137 138 args = [] 139 for arg in record.args: 140 try: 141 arg_str = str(arg) 142 matches = self.regex.findall(arg_str) 143 if matches: 144 for match in matches: 145 arg_str = self.replace(arg_str, match) 146 arg = arg_str 147 except: 148 pass 149 args.append(arg) 150 record.args = tuple(args) 151 except: 152 pass 153 154 return True 155 156 def replace(self, text, match): 157 return text 158 159 160class PublicIPFilter(RegexFilter): 161 """ 162 Log filter for public IP addresses 163 """ 164 def __init__(self): 165 super(PublicIPFilter, self).__init__() 166 167 # Currently only checking for ipv4 addresses 168 self.regex = re.compile(r'[0-9]+(?:[.-][0-9]+){3}(?!\d*-[a-z0-9]{6})') 169 170 def replace(self, text, ip): 171 if helpers.is_public_ip(ip.replace('-', '.')): 172 partition = '-' if '-' in ip else '.' 173 return text.replace(ip, partition.join(['***'] * 4)) 174 return text 175 176 177class EmailFilter(RegexFilter): 178 """ 179 Log filter for email addresses 180 """ 181 def __init__(self): 182 super(EmailFilter, self).__init__() 183 184 self.regex = re.compile(r'([a-z0-9!#$%&\'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&\'*+/=?^_`{|}~-]+)*@' 185 r'(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)', 186 re.IGNORECASE) 187 188 def replace(self, text, email): 189 email_parts = email.partition('@') 190 return text.replace(email, 16 * '*' + email_parts[1] + 8 * '*') 191 192 193class PlexTokenFilter(RegexFilter): 194 """ 195 Log filter for X-Plex-Token 196 """ 197 def __init__(self): 198 super(PlexTokenFilter, self).__init__() 199 200 self.regex = re.compile(r'X-Plex-Token(?:=|%3D)([a-zA-Z0-9]+)') 201 202 def replace(self, text, token): 203 return text.replace(token, 16 * '*') 204 205 206def initLogger(console=False, log_dir=False, verbose=False): 207 """ 208 Setup logging for Tautulli. It uses the logger instance with the name 209 'tautulli'. Three log handlers are added: 210 211 * RotatingFileHandler: for the file tautulli.log 212 * LogListHandler: for Web UI 213 * StreamHandler: for console (if console) 214 215 Console logging is only enabled if console is set to True. This method can 216 be invoked multiple times, during different stages of Tautulli. 217 """ 218 219 # Close and remove old handlers. This is required to reinit the loggers 220 # at runtime 221 log_handlers = logger.handlers[:] + \ 222 logger_api.handlers[:] + \ 223 logger_plex_websocket.handlers[:] + \ 224 cherrypy.log.error_log.handlers[:] 225 for handler in log_handlers: 226 # Just make sure it is cleaned up. 227 if isinstance(handler, handlers.RotatingFileHandler): 228 handler.close() 229 elif isinstance(handler, logging.StreamHandler): 230 handler.flush() 231 232 logger.removeHandler(handler) 233 logger_api.removeHandler(handler) 234 logger_plex_websocket.removeHandler(handler) 235 cherrypy.log.error_log.removeHandler(handler) 236 237 # Configure the logger to accept all messages 238 logger.propagate = False 239 logger.setLevel(logging.DEBUG if verbose else logging.INFO) 240 logger_api.propagate = False 241 logger_api.setLevel(logging.DEBUG if verbose else logging.INFO) 242 logger_plex_websocket.propagate = False 243 logger_plex_websocket.setLevel(logging.DEBUG if verbose else logging.INFO) 244 cherrypy.log.error_log.propagate = False 245 246 # Setup file logger 247 if log_dir: 248 file_formatter = logging.Formatter('%(asctime)s - %(levelname)-7s :: %(threadName)s : %(message)s', '%Y-%m-%d %H:%M:%S') 249 250 # Main Tautulli logger 251 filename = os.path.join(log_dir, FILENAME) 252 file_handler = handlers.RotatingFileHandler(filename, maxBytes=MAX_SIZE, backupCount=MAX_FILES, encoding='utf-8') 253 file_handler.setLevel(logging.DEBUG) 254 file_handler.setFormatter(file_formatter) 255 256 logger.addHandler(file_handler) 257 cherrypy.log.error_log.addHandler(file_handler) 258 259 # Tautulli API logger 260 filename = os.path.join(log_dir, FILENAME_API) 261 file_handler = handlers.RotatingFileHandler(filename, maxBytes=MAX_SIZE, backupCount=MAX_FILES, encoding='utf-8') 262 file_handler.setLevel(logging.DEBUG) 263 file_handler.setFormatter(file_formatter) 264 265 logger_api.addHandler(file_handler) 266 267 # Tautulli websocket logger 268 filename = os.path.join(log_dir, FILENAME_PLEX_WEBSOCKET) 269 file_handler = handlers.RotatingFileHandler(filename, maxBytes=MAX_SIZE, backupCount=MAX_FILES, encoding='utf-8') 270 file_handler.setLevel(logging.DEBUG) 271 file_handler.setFormatter(file_formatter) 272 273 logger_plex_websocket.addHandler(file_handler) 274 275 # Setup console logger 276 if console: 277 console_formatter = logging.Formatter('%(asctime)s - %(levelname)s :: %(threadName)s : %(message)s', '%Y-%m-%d %H:%M:%S') 278 console_handler = logging.StreamHandler() 279 console_handler.setFormatter(console_formatter) 280 console_handler.setLevel(logging.DEBUG) 281 282 logger.addHandler(console_handler) 283 cherrypy.log.error_log.addHandler(console_handler) 284 285 # Add filters to log handlers 286 # Only add filters after the config file has been initialized 287 # Nothing prior to initialization should contain sensitive information 288 if not plexpy.DEV and plexpy.CONFIG: 289 log_handlers = logger.handlers + \ 290 logger_api.handlers + \ 291 logger_plex_websocket.handlers + \ 292 cherrypy.log.error_log.handlers 293 for handler in log_handlers: 294 handler.addFilter(BlacklistFilter()) 295 handler.addFilter(PublicIPFilter()) 296 handler.addFilter(EmailFilter()) 297 handler.addFilter(PlexTokenFilter()) 298 299 # Install exception hooks 300 initHooks() 301 302 303def initHooks(global_exceptions=True, thread_exceptions=True, pass_original=True): 304 """ 305 This method installs exception catching mechanisms. Any exception caught 306 will pass through the exception hook, and will be logged to the logger as 307 an error. Additionally, a traceback is provided. 308 309 This is very useful for crashing threads and any other bugs, that may not 310 be exposed when running as daemon. 311 312 The default exception hook is still considered, if pass_original is True. 313 """ 314 315 def excepthook(*exception_info): 316 # We should always catch this to prevent loops! 317 try: 318 message = "".join(traceback.format_exception(*exception_info)) 319 logger.error("Uncaught exception: %s", message) 320 except: 321 pass 322 323 # Original excepthook 324 if pass_original: 325 sys.__excepthook__(*exception_info) 326 327 # Global exception hook 328 if global_exceptions: 329 sys.excepthook = excepthook 330 331 # Thread exception hook 332 if thread_exceptions: 333 old_init = threading.Thread.__init__ 334 335 def new_init(self, *args, **kwargs): 336 old_init(self, *args, **kwargs) 337 old_run = self.run 338 339 def new_run(*args, **kwargs): 340 try: 341 old_run(*args, **kwargs) 342 except (KeyboardInterrupt, SystemExit): 343 raise 344 except: 345 excepthook(*sys.exc_info()) 346 self.run = new_run 347 348 # Monkey patch the run() by monkey patching the __init__ method 349 threading.Thread.__init__ = new_init 350 351 352def shutdown(): 353 logging.shutdown() 354 355 356# Expose logger methods 357# Main Tautulli logger 358info = logger.info 359warn = logger.warning 360error = logger.error 361debug = logger.debug 362warning = logger.warning 363exception = logger.exception 364 365# Tautulli API logger 366api_info = logger_api.info 367api_warn = logger_api.warning 368api_error = logger_api.error 369api_debug = logger_api.debug 370api_warning = logger_api.warning 371api_exception = logger_api.exception 372 373# Tautulli websocket logger 374websocket_info = logger_plex_websocket.info 375websocket_warn = logger_plex_websocket.warning 376websocket_error = logger_plex_websocket.error 377websocket_debug = logger_plex_websocket.debug 378websocket_warning = logger_plex_websocket.warning 379websocket_exception = logger_plex_websocket.exception 380