Issues (49)

glances/plugins/ports/__init__.py (1 issue)

1
#
2
# This file is part of Glances.
3
#
4
# SPDX-FileCopyrightText: 2022 Nicolas Hennion <[email protected]>
5
#
6
# SPDX-License-Identifier: LGPL-3.0-only
7
#
8
9
"""Ports scanner plugin."""
10
11
import numbers
12
import os
13
import socket
14
import subprocess
15
import threading
16
import time
17
18
from glances.globals import BSD, MACOS, WINDOWS, bool_type
19
from glances.logger import logger
20
from glances.plugins.plugin.model import GlancesPluginModel
21
from glances.ports_list import GlancesPortsList
22
from glances.timer import Counter
23
from glances.web_list import GlancesWebList
24
25
try:
26
    import requests
27
28
    requests_tag = True
29
except ImportError as e:
30
    requests_tag = False
31
    logger.warning(f"Missing Python Lib ({e}), Ports plugin is limited to port scanning")
32
33
# Fields description
34
# description: human readable description
35
# short_name: shortname to use un UI
36
# unit: unit type
37
# rate: is it a rate ? If yes, // by time_since_update when displayed,
38
# min_symbol: Auto unit should be used if value > than 1 'X' (K, M, G)...
39
fields_description = {
40
    'host': {
41
        'description': 'Measurement is be done on this host (or IP address)',
42
    },
43
    'port': {
44
        'description': 'Measurement is be done on this port (0 for ICMP)',
45
    },
46
    'description': {
47
        'description': 'Human readable description for the host/port',
48
    },
49
    'refresh': {
50
        'description': 'Refresh time (in seconds) for this host/port',
51
    },
52
    'timeout': {
53
        'description': 'Timeout (in seconds) for the measurement',
54
    },
55
    'status': {
56
        'description': 'Measurement result (in seconds)',
57
        'unit': 'second',
58
    },
59
    'rtt_warning': {
60
        'description': 'Warning threshold (in seconds) for the measurement',
61
        'unit': 'second',
62
    },
63
    'indice': {
64
        'description': 'Unique indice for the host/port',
65
    },
66
}
67
68
69
class PluginModel(GlancesPluginModel):
70
    """Glances ports scanner plugin."""
71
72
    def __init__(self, args=None, config=None):
73
        """Init the plugin."""
74
        super().__init__(args=args, config=config, stats_init_value=[], fields_description=fields_description)
75
        self.args = args
76
        self.config = config
77
78
        # We want to display the stat in the curse interface
79
        self.display_curse = True
80
81
        # Init stats
82
        self.stats = (
83
            GlancesPortsList(config=config, args=args).get_ports_list()
84
            + GlancesWebList(config=config, args=args).get_web_list()
85
        )
86
87
        # Global Thread running all the scans
88
        self._thread = None
89
90
    def exit(self):
91
        """Overwrite the exit method to close threads."""
92
        if self._thread is not None:
93
            self._thread.stop()
94
        # Call the father class
95
        super().exit()
96
97
    def get_key(self):
98
        """Return the key of the list."""
99
        return 'indice'
100
101
    @GlancesPluginModel._check_decorator
102
    @GlancesPluginModel._log_result_decorator
103
    def update(self):
104
        """Update the ports list."""
105
        if self.input_method == 'local':
106
            # Only refresh:
107
            # * if there is not other scanning thread
108
            # * every refresh seconds (define in the configuration file)
109
            if self._thread is None:
110
                thread_is_running = False
111
            else:
112
                thread_is_running = self._thread.is_alive()
113
            if not thread_is_running:
114
                # Run ports scanner
115
                self._thread = ThreadScanner(self.stats)
116
                self._thread.start()
117
        else:
118
            # Not available in SNMP mode
119
            pass
120
121
        return self.stats
122
123 View Code Duplication
    def get_ports_alert(self, port, header="", log=False):
124
        """Return the alert status relative to the port scan return value."""
125
        ret = 'OK'
126
        if port['status'] is None:
127
            ret = 'CAREFUL'
128
        elif port['status'] == 0:
129
            ret = 'CRITICAL'
130
        elif (
131
            isinstance(port['status'], (float, int))
132
            and port['rtt_warning'] is not None
133
            and port['status'] > port['rtt_warning']
134
        ):
135
            ret = 'WARNING'
136
137
        # Get stat name
138
        stat_name = self.get_stat_name(header=header)
139
140
        # Manage threshold
141
        self.manage_threshold(stat_name, ret)
142
143
        # Manage action
144
        self.manage_action(stat_name, ret.lower(), header, port[self.get_key()])
145
146
        return ret
147
148 View Code Duplication
    def get_web_alert(self, web, header="", log=False):
149
        """Return the alert status relative to the web/url scan return value."""
150
        ret = 'OK'
151
        if web['status'] is None:
152
            ret = 'CAREFUL'
153
        elif web['status'] not in [200, 301, 302]:
154
            ret = 'CRITICAL'
155
        elif web['rtt_warning'] is not None and web['elapsed'] > web['rtt_warning']:
156
            ret = 'WARNING'
157
158
        # Get stat name
159
        stat_name = self.get_stat_name(header=header)
160
161
        # Manage threshold
162
        self.manage_threshold(stat_name, ret)
163
164
        # Manage action
165
        self.manage_action(stat_name, ret.lower(), header, web[self.get_key()])
166
167
        return ret
168
169
    def msg_curse(self, args=None, max_width=None):
170
        """Return the dict to display in the curse interface."""
171
        # Init the return message
172
        # Only process if stats exist and display plugin enable...
173
        ret = []
174
175
        if not self.stats or args.disable_ports:
176
            return ret
177
178
        # Max size for the interface name
179
        if max_width:
180
            name_max_width = max_width - 7
181
        else:
182
            # No max_width defined, return an emptu curse message
183
            logger.debug(f"No max_width defined for the {self.plugin_name} plugin, it will not be displayed.")
184
            return ret
185
186
        # Build the string message
187
        for p in self.stats:
188
            if 'host' in p:
189
                if p['host'] is None:
190
                    status = 'None'
191
                elif p['status'] is None:
192
                    status = 'Scanning'
193
                elif isinstance(p['status'], bool_type) and p['status'] is True:
194
                    status = 'Open'
195
                elif p['status'] == 0:
196
                    status = 'Timeout'
197
                else:
198
                    # Convert second to ms
199
                    status = '{:.0f}ms'.format(p['status'] * 1000.0)
200
201
                msg = '{:{width}}'.format(p['description'][0:name_max_width], width=name_max_width)
202
                ret.append(self.curse_add_line(msg))
203
                msg = f'{status:>9}'
204
                ret.append(self.curse_add_line(msg, self.get_ports_alert(p, header=p['indice'] + '_rtt')))
205
                ret.append(self.curse_new_line())
206
            elif 'url' in p:
207
                msg = '{:{width}}'.format(p['description'][0:name_max_width], width=name_max_width)
208
                ret.append(self.curse_add_line(msg))
209
                if isinstance(p['status'], numbers.Number):
210
                    status = 'Code {}'.format(p['status'])
211
                elif p['status'] is None:
212
                    status = 'Scanning'
213
                else:
214
                    status = p['status']
215
                msg = f'{status:>9}'
216
                ret.append(self.curse_add_line(msg, self.get_web_alert(p, header=p['indice'] + '_rtt')))
217
                ret.append(self.curse_new_line())
218
219
        # Delete the last empty line
220
        try:
221
            ret.pop()
222
        except IndexError:
223
            pass
224
225
        return ret
226
227
228
class ThreadScanner(threading.Thread):
229
    """
230
    Specific thread for the port/web scanner.
231
232
    stats is a list of dict
233
    """
234
235
    def __init__(self, stats):
236
        """Init the class."""
237
        logger.debug(f"ports plugin - Create thread for scan list {stats}")
238
        super().__init__()
239
        # Event needed to stop properly the thread
240
        self._stopper = threading.Event()
241
        # The class return the stats as a list of dict
242
        self._stats = stats
243
        # Is part of Ports plugin
244
        self.plugin_name = "ports"
245
246
    def get_key(self):
247
        """Return the key of the list."""
248
        return 'indice'
249
250
    def run(self):
251
        """Grab the stats.
252
253
        Infinite loop, should be stopped by calling the stop() method.
254
        """
255
        for p in self._stats:
256
            # End of the thread has been asked
257
            if self.stopped():
258
                break
259
            # Scan a port (ICMP or TCP)
260
            if 'port' in p:
261
                self._port_scan(p)
262
                # Had to wait between two scans
263
                # If not, result are not ok
264
                time.sleep(1)
265
            # Scan an URL
266
            elif 'url' in p and requests_tag:
267
                self._web_scan(p)
268
            # Add the key for every element
269
            p['key'] = self.get_key()
270
271
    @property
272
    def stats(self):
273
        """Stats getter."""
274
        return self._stats
275
276
    @stats.setter
277
    def stats(self, value):
278
        """Stats setter."""
279
        self._stats = value
280
281
    def stop(self, timeout=None):
282
        """Stop the thread."""
283
        logger.debug(f"ports plugin - Close thread for scan list {self._stats}")
284
        self._stopper.set()
285
286
    def stopped(self):
287
        """Return True is the thread is stopped."""
288
        return self._stopper.is_set()
289
290
    def _web_scan(self, web):
291
        """Scan the  Web/URL (dict) and update the status key."""
292
        try:
293
            req = requests.head(
294
                web['url'],
295
                allow_redirects=True,
296
                verify=web['ssl_verify'],
297
                proxies=web['proxies'],
298
                timeout=web['timeout'],
299
            )
300
        except Exception as e:
301
            logger.debug(e)
302
            web['status'] = 'Error'
303
            web['elapsed'] = 0
304
        else:
305
            web['status'] = req.status_code
306
            web['elapsed'] = req.elapsed.total_seconds()
307
        return web
308
309
    def _port_scan(self, port):
310
        """Scan the port structure (dict) and update the status key."""
311
        if int(port['port']) == 0:
312
            return self._port_scan_icmp(port)
313
        return self._port_scan_tcp(port)
314
315
    def _resolv_name(self, hostname):
316
        """Convert hostname to IP address."""
317
        ip = hostname
318
        try:
319
            ip = socket.gethostbyname(hostname)
320
        except Exception as e:
321
            logger.debug(f"{self.plugin_name}: Cannot convert {hostname} to IP address ({e})")
322
        return ip
323
324
    def _port_scan_icmp(self, port):
325
        """Scan the (ICMP) port structure (dict) and update the status key."""
326
        ret = None
327
328
        # Create the ping command
329
        # Use the system ping command because it already have the sticky bit set
330
        # Python can not create ICMP packet with non root right
331
        if WINDOWS:
332
            timeout_opt = '-w'
333
            count_opt = '-n'
334
        elif MACOS or BSD:
335
            timeout_opt = '-t'
336
            count_opt = '-c'
337
        else:
338
            # Linux and co...
339
            timeout_opt = '-W'
340
            count_opt = '-c'
341
        # Build the command line
342
        # Note: Only string are allowed
343
        cmd = [
344
            'ping',
345
            count_opt,
346
            '1',
347
            timeout_opt,
348
            str(self._resolv_name(port['timeout'])),
349
            self._resolv_name(port['host']),
350
        ]
351
        fnull = open(os.devnull, 'w')
352
353
        try:
354
            counter = Counter()
355
            ret = subprocess.check_call(cmd, stdout=fnull, stderr=fnull, close_fds=True)
356
            if ret == 0:
357
                port['status'] = counter.get()
358
            else:
359
                port['status'] = False
360
        except subprocess.CalledProcessError:
361
            # Correct issue #1084: No Offline status for timed-out ports
362
            port['status'] = False
363
        except Exception as e:
364
            logger.debug("{}: Error while pinging host {} ({})".format(self.plugin_name, port['host'], e))
365
366
        fnull.close()
367
368
        return ret
369
370
    def _port_scan_tcp(self, port):
371
        """Scan the (TCP) port structure (dict) and update the status key."""
372
        ret = None
373
374
        # Create and configure the scanning socket
375
        try:
376
            socket.setdefaulttimeout(port['timeout'])
377
            _socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
378
        except Exception as e:
379
            logger.debug(f"{self.plugin_name}: Error while creating scanning socket ({e})")
380
381
        # Scan port
382
        ip = self._resolv_name(port['host'])
383
        counter = Counter()
384
        try:
385
            ret = _socket.connect_ex((ip, int(port['port'])))
0 ignored issues
show
The variable _socket does not seem to be defined for all execution paths.
Loading history...
386
        except Exception as e:
387
            logger.debug(f"{self.plugin_name}: Error while scanning port {port} ({e})")
388
        else:
389
            if ret == 0:
390
                port['status'] = counter.get()
391
            else:
392
                port['status'] = False
393
        finally:
394
            _socket.close()
395
396
        return ret
397