1# -*- coding: utf-8 -*-
2# (c) 2015, Logentries.com, Jimmy Tang <jimmy.tang@logentries.com>
3# (c) 2017 Ansible Project
4# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
5from __future__ import (absolute_import, division, print_function)
6__metaclass__ = type
7
8DOCUMENTATION = '''
9    author: Unknown (!UNKNOWN)
10    name: logentries
11    type: notification
12    short_description: Sends events to Logentries
13    description:
14      - This callback plugin will generate JSON objects and send them to Logentries via TCP for auditing/debugging purposes.
15      - Before 2.4, if you wanted to use an ini configuration, the file must be placed in the same directory as this plugin and named logentries.ini
16      - In 2.4 and above you can just put it in the main Ansible configuration file.
17    requirements:
18      - whitelisting in configuration
19      - certifi (python library)
20      - flatdict (python library), if you want to use the 'flatten' option
21    options:
22      api:
23        description: URI to the Logentries API
24        env:
25          - name: LOGENTRIES_API
26        default: data.logentries.com
27        ini:
28          - section: callback_logentries
29            key: api
30      port:
31        description: HTTP port to use when connecting to the API
32        env:
33            - name: LOGENTRIES_PORT
34        default: 80
35        ini:
36          - section: callback_logentries
37            key: port
38      tls_port:
39        description: Port to use when connecting to the API when TLS is enabled
40        env:
41            - name: LOGENTRIES_TLS_PORT
42        default: 443
43        ini:
44          - section: callback_logentries
45            key: tls_port
46      token:
47        description: The logentries "TCP token"
48        env:
49          - name: LOGENTRIES_ANSIBLE_TOKEN
50        required: True
51        ini:
52          - section: callback_logentries
53            key: token
54      use_tls:
55        description:
56          - Toggle to decide whether to use TLS to encrypt the communications with the API server
57        env:
58          - name: LOGENTRIES_USE_TLS
59        default: False
60        type: boolean
61        ini:
62          - section: callback_logentries
63            key: use_tls
64      flatten:
65        description: flatten complex data structures into a single dictionary with complex keys
66        type: boolean
67        default: False
68        env:
69          - name: LOGENTRIES_FLATTEN
70        ini:
71          - section: callback_logentries
72            key: flatten
73'''
74
75EXAMPLES = '''
76examples: >
77  To enable, add this to your ansible.cfg file in the defaults block
78
79    [defaults]
80    callback_whitelist = community.general.logentries
81
82  Either set the environment variables
83    export LOGENTRIES_API=data.logentries.com
84    export LOGENTRIES_PORT=10000
85    export LOGENTRIES_ANSIBLE_TOKEN=dd21fc88-f00a-43ff-b977-e3a4233c53af
86
87  Or in the main Ansible config file
88    [callback_logentries]
89    api = data.logentries.com
90    port = 10000
91    tls_port = 20000
92    use_tls = no
93    token = dd21fc88-f00a-43ff-b977-e3a4233c53af
94    flatten = False
95'''
96
97import os
98import socket
99import random
100import time
101import uuid
102
103try:
104    import certifi
105    HAS_CERTIFI = True
106except ImportError:
107    HAS_CERTIFI = False
108
109try:
110    import flatdict
111    HAS_FLATDICT = True
112except ImportError:
113    HAS_FLATDICT = False
114
115from ansible.module_utils.common.text.converters import to_bytes, to_text
116from ansible.plugins.callback import CallbackBase
117
118# Todo:
119#  * Better formatting of output before sending out to logentries data/api nodes.
120
121
122class PlainTextSocketAppender(object):
123    def __init__(self, display, LE_API='data.logentries.com', LE_PORT=80, LE_TLS_PORT=443):
124
125        self.LE_API = LE_API
126        self.LE_PORT = LE_PORT
127        self.LE_TLS_PORT = LE_TLS_PORT
128        self.MIN_DELAY = 0.1
129        self.MAX_DELAY = 10
130        # Error message displayed when an incorrect Token has been detected
131        self.INVALID_TOKEN = "\n\nIt appears the LOGENTRIES_TOKEN parameter you entered is incorrect!\n\n"
132        # Unicode Line separator character   \u2028
133        self.LINE_SEP = u'\u2028'
134
135        self._display = display
136        self._conn = None
137
138    def open_connection(self):
139        self._conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
140        self._conn.connect((self.LE_API, self.LE_PORT))
141
142    def reopen_connection(self):
143        self.close_connection()
144
145        root_delay = self.MIN_DELAY
146        while True:
147            try:
148                self.open_connection()
149                return
150            except Exception as e:
151                self._display.vvvv(u"Unable to connect to Logentries: %s" % to_text(e))
152
153            root_delay *= 2
154            if root_delay > self.MAX_DELAY:
155                root_delay = self.MAX_DELAY
156
157            wait_for = root_delay + random.uniform(0, root_delay)
158
159            try:
160                self._display.vvvv("sleeping %s before retry" % wait_for)
161                time.sleep(wait_for)
162            except KeyboardInterrupt:
163                raise
164
165    def close_connection(self):
166        if self._conn is not None:
167            self._conn.close()
168
169    def put(self, data):
170        # Replace newlines with Unicode line separator
171        # for multi-line events
172        data = to_text(data, errors='surrogate_or_strict')
173        multiline = data.replace(u'\n', self.LINE_SEP)
174        multiline += u"\n"
175        # Send data, reconnect if needed
176        while True:
177            try:
178                self._conn.send(to_bytes(multiline, errors='surrogate_or_strict'))
179            except socket.error:
180                self.reopen_connection()
181                continue
182            break
183
184        self.close_connection()
185
186
187try:
188    import ssl
189    HAS_SSL = True
190except ImportError:  # for systems without TLS support.
191    SocketAppender = PlainTextSocketAppender
192    HAS_SSL = False
193else:
194
195    class TLSSocketAppender(PlainTextSocketAppender):
196        def open_connection(self):
197            sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
198            sock = ssl.wrap_socket(
199                sock=sock,
200                keyfile=None,
201                certfile=None,
202                server_side=False,
203                cert_reqs=ssl.CERT_REQUIRED,
204                ssl_version=getattr(
205                    ssl, 'PROTOCOL_TLSv1_2', ssl.PROTOCOL_TLSv1),
206                ca_certs=certifi.where(),
207                do_handshake_on_connect=True,
208                suppress_ragged_eofs=True, )
209            sock.connect((self.LE_API, self.LE_TLS_PORT))
210            self._conn = sock
211
212    SocketAppender = TLSSocketAppender
213
214
215class CallbackModule(CallbackBase):
216    CALLBACK_VERSION = 2.0
217    CALLBACK_TYPE = 'notification'
218    CALLBACK_NAME = 'community.general.logentries'
219    CALLBACK_NEEDS_WHITELIST = True
220
221    def __init__(self):
222
223        # TODO: allow for alternate posting methods (REST/UDP/agent/etc)
224        super(CallbackModule, self).__init__()
225
226        # verify dependencies
227        if not HAS_SSL:
228            self._display.warning("Unable to import ssl module. Will send over port 80.")
229
230        if not HAS_CERTIFI:
231            self.disabled = True
232            self._display.warning('The `certifi` python module is not installed.\nDisabling the Logentries callback plugin.')
233
234        self.le_jobid = str(uuid.uuid4())
235
236        # FIXME: make configurable, move to options
237        self.timeout = 10
238
239    def set_options(self, task_keys=None, var_options=None, direct=None):
240
241        super(CallbackModule, self).set_options(task_keys=task_keys, var_options=var_options, direct=direct)
242
243        # get options
244        try:
245            self.api_url = self.get_option('api')
246            self.api_port = self.get_option('port')
247            self.api_tls_port = self.get_option('tls_port')
248            self.use_tls = self.get_option('use_tls')
249            self.flatten = self.get_option('flatten')
250        except KeyError as e:
251            self._display.warning(u"Missing option for Logentries callback plugin: %s" % to_text(e))
252            self.disabled = True
253
254        try:
255            self.token = self.get_option('token')
256        except KeyError as e:
257            self._display.warning('Logentries token was not provided, this is required for this callback to operate, disabling')
258            self.disabled = True
259
260        if self.flatten and not HAS_FLATDICT:
261            self.disabled = True
262            self._display.warning('You have chosen to flatten and the `flatdict` python module is not installed.\nDisabling the Logentries callback plugin.')
263
264        self._initialize_connections()
265
266    def _initialize_connections(self):
267
268        if not self.disabled:
269            if self.use_tls:
270                self._display.vvvv("Connecting to %s:%s with TLS" % (self.api_url, self.api_tls_port))
271                self._appender = TLSSocketAppender(display=self._display, LE_API=self.api_url, LE_TLS_PORT=self.api_tls_port)
272            else:
273                self._display.vvvv("Connecting to %s:%s" % (self.api_url, self.api_port))
274                self._appender = PlainTextSocketAppender(display=self._display, LE_API=self.api_url, LE_PORT=self.api_port)
275            self._appender.reopen_connection()
276
277    def emit_formatted(self, record):
278        if self.flatten:
279            results = flatdict.FlatDict(record)
280            self.emit(self._dump_results(results))
281        else:
282            self.emit(self._dump_results(record))
283
284    def emit(self, record):
285        msg = record.rstrip('\n')
286        msg = "{0} {1}".format(self.token, msg)
287        self._appender.put(msg)
288        self._display.vvvv("Sent event to logentries")
289
290    def _set_info(self, host, res):
291        return {'le_jobid': self.le_jobid, 'hostname': host, 'results': res}
292
293    def runner_on_ok(self, host, res):
294        results = self._set_info(host, res)
295        results['status'] = 'OK'
296        self.emit_formatted(results)
297
298    def runner_on_failed(self, host, res, ignore_errors=False):
299        results = self._set_info(host, res)
300        results['status'] = 'FAILED'
301        self.emit_formatted(results)
302
303    def runner_on_skipped(self, host, item=None):
304        results = self._set_info(host, item)
305        del results['results']
306        results['status'] = 'SKIPPED'
307        self.emit_formatted(results)
308
309    def runner_on_unreachable(self, host, res):
310        results = self._set_info(host, res)
311        results['status'] = 'UNREACHABLE'
312        self.emit_formatted(results)
313
314    def runner_on_async_failed(self, host, res, jid):
315        results = self._set_info(host, res)
316        results['jid'] = jid
317        results['status'] = 'ASYNC_FAILED'
318        self.emit_formatted(results)
319
320    def v2_playbook_on_play_start(self, play):
321        results = {}
322        results['le_jobid'] = self.le_jobid
323        results['started_by'] = os.getlogin()
324        if play.name:
325            results['play'] = play.name
326        results['hosts'] = play.hosts
327        self.emit_formatted(results)
328
329    def playbook_on_stats(self, stats):
330        """ close connection """
331        self._appender.close_connection()
332