summaryrefslogtreecommitdiffstats
path: root/deluge/plugins/Blocklist/deluge_blocklist/core.py
blob: 53e36705d48b7296898ca6747387d14c23061992 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
# -*- coding: utf-8 -*-
#
# Copyright (C) 2008 Andrew Resch <andrewresch@gmail.com>
# Copyright (C) 2009-2010 John Garland <johnnybg+deluge@gmail.com>
#
# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with
# the additional special exception to link portions of this program with the OpenSSL library.
# See LICENSE for more details.
#

import logging
import os
import shutil
import time
from datetime import datetime, timedelta
from email.utils import formatdate
from urllib.parse import urljoin

from twisted.internet import defer, threads
from twisted.internet.task import LoopingCall
from twisted.web import error

import deluge.component as component
import deluge.configmanager
from deluge.common import is_url
from deluge.core.rpcserver import export
from deluge.httpdownloader import download_file
from deluge.plugins.pluginbase import CorePluginBase

from .common import IP, BadIP
from .detect import UnknownFormatError, create_reader, detect_compression, detect_format
from .readers import ReaderParseError

# TODO: check return values for deferred callbacks
# TODO: review class attributes for redundancy

log = logging.getLogger(__name__)

DEFAULT_PREFS = {
    'url': '',
    'load_on_start': False,
    'check_after_days': 4,
    'list_compression': '',
    'list_type': '',
    'last_update': 0.0,
    'list_size': 0,
    'timeout': 180,
    'try_times': 3,
    'whitelisted': [],
}

# Constants
ALLOW_RANGE = 0
BLOCK_RANGE = 1


class Core(CorePluginBase):
    def enable(self):
        log.debug('Blocklist: Plugin enabled...')

        self.is_url = True
        self.is_downloading = False
        self.is_importing = False
        self.has_imported = False
        self.up_to_date = False
        self.need_to_resume_session = False
        self.num_whited = 0
        self.num_blocked = 0
        self.file_progress = 0.0

        self.core = component.get('Core')
        self.config = deluge.configmanager.ConfigManager(
            'blocklist.conf', DEFAULT_PREFS
        )
        if 'whitelisted' not in self.config:
            self.config['whitelisted'] = []

        self.reader = create_reader(
            self.config['list_type'], self.config['list_compression']
        )

        if not isinstance(self.config['last_update'], float):
            self.config.config['last_update'] = 0.0

        update_now = False
        if self.config['load_on_start']:
            self.pause_session()
            if self.config['last_update']:
                last_update = datetime.fromtimestamp(self.config['last_update'])
                check_period = timedelta(days=self.config['check_after_days'])
            if (
                not self.config['last_update']
                or last_update + check_period < datetime.now()
            ):
                update_now = True
            else:
                d = self.import_list(
                    deluge.configmanager.get_config_dir('blocklist.cache')
                )
                d.addCallbacks(self.on_import_complete, self.on_import_error)
                if self.need_to_resume_session:
                    d.addBoth(self.resume_session)

        # This function is called every 'check_after_days' days, to download
        # and import a new list if needed.
        self.update_timer = LoopingCall(self.check_import)
        if self.config['check_after_days'] > 0:
            self.update_timer.start(
                self.config['check_after_days'] * 24 * 60 * 60, update_now
            )

    def disable(self):
        self.config.save()
        log.debug('Reset IP filter')
        self.core.session.get_ip_filter().add_rule(
            '0.0.0.0', '255.255.255.255', ALLOW_RANGE
        )
        log.debug('Blocklist: Plugin disabled')

    def update(self):
        pass

    # Exported RPC methods #
    @export
    def check_import(self, force=False):
        """Imports latest blocklist specified by blocklist url.

        Args:
            force (bool, optional): Force the download/import, default is False.

        Returns:
            Deferred: A Deferred which fires when the blocklist has been imported.

        """
        if not self.config['url']:
            return

        # Reset variables
        self.filename = None
        self.force_download = force
        self.failed_attempts = 0
        self.auto_detected = False
        self.up_to_date = False
        if force:
            self.reader = None
        self.is_url = is_url(self.config['url'])

        # Start callback chain
        if self.is_url:
            d = self.download_list()
            d.addCallbacks(self.on_download_complete, self.on_download_error)
            d.addCallback(self.import_list)
        else:
            d = self.import_list(self.config['url'])
        d.addCallbacks(self.on_import_complete, self.on_import_error)
        if self.need_to_resume_session:
            d.addBoth(self.resume_session)

        return d

    @export
    def get_config(self):
        """Gets the blocklist config dictionary.

        Returns:
            dict: The config dictionary.

        """
        return self.config.config

    @export
    def set_config(self, config):
        """Sets the blocklist config.

        Args:
            config (dict): config to set.

        """
        needs_blocklist_import = False
        for key in config:
            if key == 'whitelisted':
                saved = set(self.config[key])
                update = set(config[key])
                diff = saved.symmetric_difference(update)
                if diff:
                    log.debug('Whitelist changed. Updating...')
                    added = update.intersection(diff)
                    removed = saved.intersection(diff)
                    if added:
                        for ip in added:
                            try:
                                ip = IP.parse(ip)
                                self.blocklist.add_rule(
                                    ip.address, ip.address, ALLOW_RANGE
                                )
                                saved.add(ip.address)
                                log.debug('Added %s to whitelisted', ip)
                                self.num_whited += 1
                            except BadIP as ex:
                                log.error('Bad IP: %s', ex)
                                continue
                    if removed:
                        needs_blocklist_import = True
                        for ip in removed:
                            try:
                                ip = IP.parse(ip)
                                saved.remove(ip.address)
                                log.debug('Removed %s from whitelisted', ip)
                            except BadIP as ex:
                                log.error('Bad IP: %s', ex)
                                continue

                self.config[key] = list(saved)
                continue
            elif key == 'check_after_days':
                if self.config[key] != config[key]:
                    self.config[key] = config[key]
                    update_now = False
                    if self.config['last_update']:
                        last_update = datetime.fromtimestamp(self.config['last_update'])
                        check_period = timedelta(days=self.config['check_after_days'])
                    if (
                        not self.config['last_update']
                        or last_update + check_period < datetime.now()
                    ):
                        update_now = True
                    if self.update_timer.running:
                        self.update_timer.stop()
                    if self.config['check_after_days'] > 0:
                        self.update_timer.start(
                            self.config['check_after_days'] * 24 * 60 * 60, update_now
                        )
                continue
            self.config[key] = config[key]

        if needs_blocklist_import:
            log.debug(
                'IP addresses were removed from the whitelist. Since we '
                'do not know if they were blocked before. Re-import '
                'current blocklist and re-add whitelisted.'
            )
            self.has_imported = False
            d = self.import_list(deluge.configmanager.get_config_dir('blocklist.cache'))
            d.addCallbacks(self.on_import_complete, self.on_import_error)

    @export
    def get_status(self):
        """Get the status of the plugin.

        Returns:
            dict: The status dict of the plugin.

        """
        status = {}
        if self.is_downloading:
            status['state'] = 'Downloading'
        elif self.is_importing:
            status['state'] = 'Importing'
        else:
            status['state'] = 'Idle'

        status['up_to_date'] = self.up_to_date
        status['num_whited'] = self.num_whited
        status['num_blocked'] = self.num_blocked
        status['file_progress'] = self.file_progress
        status['file_url'] = self.config['url']
        status['file_size'] = self.config['list_size']
        status['file_date'] = self.config['last_update']
        status['file_type'] = self.config['list_type']
        status['whitelisted'] = self.config['whitelisted']
        if self.config['list_compression']:
            status['file_type'] += ' (%s)' % self.config['list_compression']
        return status

    ####

    def update_info(self, blocklist):
        """Updates blocklist info.

        Args:
            blocklist (str): Path of blocklist.

        Returns:
            str: Path of blocklist.

        """
        log.debug('Updating blocklist info: %s', blocklist)
        self.config['last_update'] = time.time()
        self.config['list_size'] = os.path.getsize(blocklist)
        self.filename = blocklist
        return blocklist

    def download_list(self, url=None):
        """Downloads the blocklist specified by 'url' in the config.

        Args:
            url (str, optional): url to download from, defaults to config value.

        Returns:
            Deferred: a Deferred which fires once the blocklist has been downloaded.

        """

        def on_retrieve_data(data, current_length, total_length):
            if total_length:
                fp = current_length / total_length
                if fp > 1.0:
                    fp = 1.0
            else:
                fp = 0.0

            self.file_progress = fp

        import socket

        socket.setdefaulttimeout(self.config['timeout'])

        if not url:
            url = self.config['url']

        headers = {}
        if self.config['last_update'] and not self.force_download:
            headers['If-Modified-Since'] = formatdate(
                self.config['last_update'], usegmt=True
            )

        log.debug('Attempting to download blocklist %s', url)
        log.debug('Sending headers: %s', headers)
        self.is_downloading = True
        return download_file(
            url,
            deluge.configmanager.get_config_dir('blocklist.download'),
            on_retrieve_data,
            headers,
        )

    def on_download_complete(self, blocklist):
        """Runs any download clean up functions.

        Args:
            blocklist (str): Path of blocklist.

        Returns:
            Deferred: a Deferred which fires when clean up is done.

        """
        log.debug('Blocklist download complete: %s', blocklist)
        self.is_downloading = False
        return threads.deferToThread(self.update_info, blocklist)

    def on_download_error(self, f):
        """Recovers from download error.

        Args:
            f (Failure): Failure that occurred.

        Returns:
            Deferred or Failure: A Deferred if recovery was possible else original Failure.

        """
        self.is_downloading = False
        error_msg = f.getErrorMessage()
        d = f
        if f.check(error.PageRedirect):
            # Handle redirect errors
            location = urljoin(self.config['url'], error_msg.split(' to ')[1])
            if 'Moved Permanently' in error_msg:
                log.debug('Setting blocklist url to %s', location)
                self.config['url'] = location
            d = self.download_list(location)
            d.addCallbacks(self.on_download_complete, self.on_download_error)
        else:
            if 'Not Modified' in error_msg:
                log.debug('Blocklist is up-to-date!')
                self.up_to_date = True
                blocklist = deluge.configmanager.get_config_dir('blocklist.cache')
                d = threads.deferToThread(self.update_info, blocklist)
            else:
                log.warning('Blocklist download failed: %s', error_msg)
                if self.failed_attempts < self.config['try_times']:
                    log.debug(
                        'Try downloading blocklist again... (%s/%s)',
                        self.failed_attempts,
                        self.config['try_times'],
                    )
                    self.failed_attempts += 1
                    d = self.download_list()
                    d.addCallbacks(self.on_download_complete, self.on_download_error)
        return d

    def import_list(self, blocklist):
        """Imports the downloaded blocklist into the session.

        Args:
            blocklist (str): path of blocklist.

        Returns:
            Deferred: A Deferred that fires when the blocklist has been imported.

        """
        log.trace('on import_list')

        def on_read_ip_range(start, end):
            """Add ip range to blocklist"""
            # log.trace('Adding ip range %s - %s to ipfilter as blocked', start, end)
            self.blocklist.add_rule(start.address, end.address, BLOCK_RANGE)
            self.num_blocked += 1

        def on_finish_read(result):
            """Add any whitelisted IP's and add the blocklist to session"""
            # White listing happens last because the last rules added have
            # priority
            log.info('Added %d ranges to ipfilter as blocked', self.num_blocked)
            for ip in self.config['whitelisted']:
                ip = IP.parse(ip)
                self.blocklist.add_rule(ip.address, ip.address, ALLOW_RANGE)
                self.num_whited += 1
                log.trace('Added %s to the ipfiler as white-listed', ip.address)
            log.info('Added %d ranges to ipfilter as white-listed', self.num_whited)
            self.core.session.set_ip_filter(self.blocklist)
            return result

        # TODO: double check logic
        if self.up_to_date and self.has_imported:
            log.debug('Latest blocklist is already imported')
            return defer.succeed(blocklist)

        self.is_importing = True
        self.num_blocked = 0
        self.num_whited = 0
        self.blocklist = self.core.session.get_ip_filter()

        if not blocklist:
            blocklist = self.filename

        if not self.reader:
            self.auto_detect(blocklist)
            self.auto_detected = True

        def on_reader_failure(failure):
            log.error('Failed to read!!!!!!')
            log.exception(failure)

        log.debug('Importing using reader: %s', self.reader)
        log.debug(
            'Reader type: %s compression: %s',
            self.config['list_type'],
            self.config['list_compression'],
        )
        log.debug('Clearing current ip filtering')
        # self.blocklist.add_rule('0.0.0.0', '255.255.255.255', ALLOW_RANGE)
        d = threads.deferToThread(self.reader(blocklist).read, on_read_ip_range)
        d.addCallback(on_finish_read).addErrback(on_reader_failure)

        return d

    def on_import_complete(self, blocklist):
        """Runs any import clean up functions.

        Args:
            blocklist (str): Path of blocklist.

        Returns:
            Deferred: A Deferred that fires when clean up is done.

        """
        log.trace('on_import_list_complete')
        d = blocklist
        self.is_importing = False
        self.has_imported = True
        log.debug('Blocklist import complete!')
        cache = deluge.configmanager.get_config_dir('blocklist.cache')
        if blocklist != cache:
            if self.is_url:
                log.debug('Moving %s to %s', blocklist, cache)
                d = threads.deferToThread(shutil.move, blocklist, cache)
            else:
                log.debug('Copying %s to %s', blocklist, cache)
                d = threads.deferToThread(shutil.copy, blocklist, cache)
        return d

    def on_import_error(self, f):
        """Recovers from import error.

        Args:
            f (Failure): Failure that occurred.

        Returns:
            Deferred or Failure: A Deferred if recovery was possible else original Failure.

        """
        log.trace('on_import_error: %s', f)
        d = f
        self.is_importing = False
        try_again = False
        cache = deluge.configmanager.get_config_dir('blocklist.cache')

        if f.check(ReaderParseError) and not self.auto_detected:
            # Invalid / corrupt list, let's detect it
            log.warning('Invalid / corrupt blocklist')
            self.reader = None
            blocklist = None
            try_again = True
        elif self.filename != cache and os.path.exists(cache):
            # If we have a backup and we haven't already used it
            log.warning('Error reading blocklist: %s', f.getErrorMessage())
            blocklist = cache
            try_again = True

        if try_again:
            d = self.import_list(blocklist)
            d.addCallbacks(self.on_import_complete, self.on_import_error)

        return d

    def auto_detect(self, blocklist):
        """Attempts to auto-detect the blocklist type.

        Args:
            blocklist (str): Path of blocklist.

        Raises:
            UnknownFormatError: If the format cannot be detected.

        """
        self.config['list_compression'] = detect_compression(blocklist)
        self.config['list_type'] = detect_format(
            blocklist, self.config['list_compression']
        )
        log.debug(
            'Auto-detected type: %s compression: %s',
            self.config['list_type'],
            self.config['list_compression'],
        )
        if not self.config['list_type']:
            self.config['list_compression'] = ''
            raise UnknownFormatError
        else:
            self.reader = create_reader(
                self.config['list_type'], self.config['list_compression']
            )

    def pause_session(self):
        self.need_to_resume_session = not self.core.session.is_paused()
        self.core.pause_session()

    def resume_session(self, result):
        self.core.resume_session()
        self.need_to_resume_session = False
        return result