1# encoding: utf-8
2
3# Nagstamon - Nagios status monitor for your desktop
4# Copyright (C) 2008-2021 Henri Wahl <henri@nagstamon.de> et al.
5#
6# This program is free software; you can redistribute it and/or modify
7# it under the terms of the GNU General Public License as published by
8# the Free Software Foundation; either version 2 of the License, or
9# (at your option) any later version.
10#
11# This program is distributed in the hope that it will be useful,
12# but WITHOUT ANY WARRANTY; without even the implied warranty of
13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14# GNU General Public License for more details.
15#
16# You should have received a copy of the GNU General Public License
17# along with this program; if not, write to the Free Software
18# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
19
20from Nagstamon.Objects import Result
21from Nagstamon.Objects import GenericHost
22from Nagstamon.Objects import GenericService
23from Nagstamon.Servers.Generic import GenericServer
24from Nagstamon.Config import conf
25
26import logging
27log = logging.getLogger('Livestatus')
28
29import re
30import json
31import socket
32import time
33
34
35def format_timestamp(timestamp):
36    """format unix timestamp"""
37    ts_tuple = time.localtime(timestamp)
38    return time.strftime('%Y-%m-%d %H:%M:%S', ts_tuple)
39
40
41def duration(timestamp):
42    """human representation of a duration"""
43    factors = (60 * 60 * 24, 60 * 60, 60, 1)
44    result = []
45    diff = time.time() - timestamp
46    for f in factors:
47        x = int(diff / f)
48        result.append(x)
49        diff = diff - x * f
50    return '%02dd %02dh %02dm %02ds' % tuple(result)
51
52
53def service_to_host(data):
54    """create the host data blob from the implicit join data of a service"""
55    result = {}
56    for key in data.keys():
57        if key.startswith('host_'):
58            result[key[5:]] = data[key]
59    return result
60
61
62class LivestatusServer(GenericServer):
63    """A server running MK Livestatus plugin. Tested with icinga2"""
64
65    TYPE = 'Livestatus'
66
67    def init_config(self):
68        log.info(self.monitor_url)
69        # we abuse the monitor_url for the connection information
70        self.address = ('localhost', 6558)
71        m = re.match('.*?://([^:/]+?)(?::(\d+))?(?:/|$)', self.monitor_url)
72        if m:
73            host, port = m.groups()
74            if not port:
75                port = 6558
76            else:
77                port = int(port)
78            self.address = (host, port)
79        else:
80            log.error('unable to parse monitor_url %s', self.monitor_url)
81            self.enable = False
82
83    def init_HTTP(self):
84        pass
85
86    def communicate(self, data, response=True):
87        buffersize = 2**20
88        data.append('')
89        data.append('')
90        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
91        log.debug('connecting')
92        s.connect(self.address)
93        s.send('\n'.join(data).encode('utf8'))
94        if not response:
95            log.debug('no response required, disconnect')
96            s.close()
97            return ''
98        result = bytes()
99        line = s.recv(buffersize)
100        while len(line) > 0:
101            result += line
102            line = s.recv(buffersize)
103        log.debug('disconnect')
104        s.close()
105        log.debug('received %d bytes', len(result))
106        result = result.decode('utf8')
107        return result
108
109    def get(self, table, raw=[], headers={}):
110        """send data to livestatus socket, receive result, format as json"""
111        data = ['GET %s' % table, ]
112        headers['OutputFormat'] = 'json'
113        headers['ColumnHeaders'] = 'on'
114        for k, v in headers.items():
115            data.append('%s: %s' % (k, v))
116        for line in raw:
117            data.append(line)
118        result = self.communicate(data)
119        if result:
120            return json.loads(result)
121        return result
122
123    def command(self, *cmd):
124        """execute nagios command via livestatus socket.
125        For commands see
126        https://old.nagios.org/developerinfo/externalcommands/commandlist.php
127        """
128        data = []
129        ts = str(int(time.time()) + 5)  # current epoch timestamp + 5 seconds
130        for line in cmd:
131            line = 'COMMAND [TIMESTAMP] ' + line
132            data.append(line.replace('TIMESTAMP', ts))
133        self.communicate(data, response=False)
134
135    def table(self, data):
136        """take a livestatus answer and format it as a table,
137        list of dictionaries
138        [ {host: 'foo1', service: 'bar1'}, {host: 'foo2', service: 'bar2'} ]
139        """
140        try:
141            header = data[0]
142        except IndexError:
143            raise StopIteration
144        for line in data[1:]:
145            yield(dict(zip(header, line)))
146
147    def _get_status(self):
148        """fetch any host/service not in OK state
149        store the information in self.new_hosts
150        applies basic filtering. All additional
151        filtering and merging new_hosts to hosts
152        is left to nagstamon
153        """
154        log.debug('_get_status')
155        self.new_hosts = dict()
156        filters = []
157        filters.append('Filter: state != 0')  # ignore OK state
158        if conf.filter_acknowledged_hosts_services:
159            filters.append('Filter: acknowledged != 1')
160        # hosts
161        data = self.get("hosts", raw=filters)
162        for h in self.table(data):
163            host = self._create_host(h)
164            self.new_hosts[host.name] = host
165            log.info("host %s is %s", host.name, host.status)
166        # services
167        data = self.get("services", raw=filters)
168        for s in self.table(data):
169            # service are attached to host objects
170            if s['host_name'] in self.new_hosts:
171                host = self.new_hosts[s['host_name']]
172            else:
173                # need to create the host
174                # icinga2 adds all host information to the server
175                # prefixed with HOST_
176                xdata = service_to_host(s)  # any field starting with HOST_
177                host = self._create_host(xdata)
178                self.new_hosts[host.name] = host
179            service = self._create_service(s)
180            service.host = host.name
181            host.services[service.name] = service
182        return Result()
183
184    def _update_object(self, obj, data):
185        """populate the generic fields of obj (GenericHost or GenericService)
186        from data."""
187        result = obj
188        result.server = self.name
189        result.last_check = format_timestamp(data['last_check'])
190        result.duration = duration(data['last_state_change'])
191        result.attempt = data['current_attempt']
192        result.status_information = data['plugin_output']
193        result.passiveonly = False
194        result.notifications_disabled = data['notifications_enabled'] != 1
195        result.flapping = data['is_flapping'] == 1
196        result.acknowledged = data['acknowledged'] == 1
197        result.scheduled_downtime = data['scheduled_downtime_depth'] == 1
198        if data['state'] == data['last_hard_state']:
199            result.status_type = 'hard'
200        else:
201            result.status_type = 'soft'
202        return result
203
204    def _create_host(self, data):
205        """create GenericHost from json data"""
206        result = self._update_object(GenericHost(), data)
207        result.name = data['name']
208        host_states = {0: 'UP', 1: 'DOWN', 2: 'UNKNOWN'}
209        result.status = host_states[data['state']]
210        return result
211
212    def _create_service(self, data):
213        """create GenericService from json data"""
214        result = self._update_object(GenericService(), data)
215        result.name = data['display_name']
216        service_states = {0: 'OK', 1: 'WARNING', 2: 'CRITICAL', 3: 'UNKNOWN'}
217        result.status = service_states[data['state']]
218        return result
219
220    def set_recheck(self, info_dict):
221        """schedule a forced recheck of a service or host"""
222        service = info_dict['service']
223        host = info_dict['host']
224        if service:
225            if self.hosts[host].services[service].is_passive_only():
226                return
227            cmd = ['SCHEDULE_FORCED_SVC_CHECK', host, service, 'TIMESTAMP']
228        else:
229            cmd = ['SCHEDULE_FORCED_HOST_CHECK', host, 'TIMESTAMP']
230        self.command(';'.join(cmd))
231
232    def set_acknowledge(self, info_dict):
233        """acknowledge a service or host"""
234        host = info_dict['host']
235        service = info_dict['service']
236        if service:
237            cmd = ['ACKNOWLEDGE_SVC_PROBLEM', host, service]
238        else:
239            cmd = ['ACKNOWLEDGE_HOST_PROBLEM', host]
240        cmd.extend([
241            '2' if info_dict['sticky'] else '1',
242            '1' if info_dict['notify'] else '0',
243            '1' if info_dict['persistent'] else '0',
244            info_dict['author'],
245            info_dict['comment'],
246        ])
247        self.command(';'.join(cmd))
248
249    def set_downtime(self, info_dict):
250        log.info('set_downtime not implemented')
251
252    def set_submit_check_result(self, info_dict):
253        log.info('set_submit_check_result not implemented')
254
255    def get_start_end(self, host):
256        log.info('get_start_end not implemented')
257        return 'n/a', 'n/a'
258
259    def open_monitor(self, host, service=''):
260        log.info('open_monitor not implemented')
261        # TODO figure out how to add more config options like socket and weburl
262
263    def open_monitor_webpage(self):
264        log.info('open_monitor_webpage not implemented')
265
266    # TODO
267    # config dialog fields
268    # config
269