salt/beacons/wtmp.py
# -*- coding: utf-8 -*-
'''
Beacon to fire events at login of users as registered in the wtmp file
.. versionadded:: 2015.5.0
Example Configuration
=====================
.. code-block:: yaml
# Fire events on all logins
beacons:
wtmp: []
# Matching on user name, using a default time range
beacons:
wtmp:
- users:
gareth:
- defaults:
time_range:
start: '8am'
end: '4pm'
# Matching on user name, overriding the default time range
beacons:
wtmp:
- users:
gareth:
time_range:
start: '7am'
end: '3pm'
- defaults:
time_range:
start: '8am'
end: '4pm'
# Matching on group name, overriding the default time range
beacons:
wtmp:
- groups:
users:
time_range:
start: '7am'
end: '3pm'
- defaults:
time_range:
start: '8am'
end: '4pm'
How to Tell What An Event Means
===============================
In the events that this beacon fires, a type of ``7`` denotes a login, while a
type of ``8`` denotes a logout. These values correspond to the ``ut_type``
value from a wtmp/utmp event (see the ``wtmp`` manpage for more information).
In the extremely unlikely case that your platform uses different values, they
can be overridden using a ``ut_type`` key in the beacon configuration:
.. code-block:: yaml
beacons:
wtmp:
- ut_type:
login: 9
logout: 10
This beacon's events include an ``action`` key which will be either ``login``
or ``logout`` depending on the event type.
.. versionchanged:: 2019.2.0
``action`` key added to beacon event, and ``ut_type`` config parameter
added.
Use Case: Posting Login/Logout Events to Slack
==============================================
This can be done using the following reactor SLS:
.. code-block:: jinja
report-wtmp:
runner.salt.cmd:
- args:
- fun: slack.post_message
- channel: mychannel # Slack channel
- from_name: someuser # Slack user
- message: "{{ data.get('action', 'Unknown event') | capitalize }} from `{{ data.get('user', '') or 'unknown user' }}` on `{{ data['id'] }}`"
Match the event like so in the master config file:
.. code-block:: yaml
reactor:
- 'salt/beacon/*/wtmp/':
- salt://reactor/wtmp.sls
.. note::
This approach uses the :py:mod:`slack execution module
<salt.modules.slack_notify>` directly on the master, and therefore requires
that the master has a slack API key in its configuration:
.. code-block:: yaml
slack:
api_key: xoxb-XXXXXXXXXXXX-XXXXXXXXXXXX-XXXXXXXXXXXXXXXXXXXXXXXX
See the :py:mod:`slack execution module <salt.modules.slack_notify>`
documentation for more information. While you can use an individual user's
API key to post to Slack, a bot user is likely better suited for this. The
:py:mod:`slack engine <salt.engines.slack>` documentation has information
on how to set up a bot user.
'''
# Import Python libs
from __future__ import absolute_import, unicode_literals
import datetime
import logging
import os
import struct
# Import salt libs
import salt.utils.stringutils
import salt.utils.files
# Import 3rd-party libs
from salt.ext import six
# pylint: disable=import-error
from salt.ext.six.moves import map
# pylint: enable=import-error
__virtualname__ = 'wtmp'
WTMP = '/var/log/wtmp'
FMT = b'hi32s4s32s256shhiii4i20x'
FIELDS = [
'type',
'PID',
'line',
'inittab',
'user',
'hostname',
'exit_status',
'session',
'time',
'addr'
]
SIZE = struct.calcsize(FMT)
LOC_KEY = 'wtmp.loc'
TTY_KEY_PREFIX = 'wtmp.tty.'
LOGIN_TYPE = 7
LOGOUT_TYPE = 8
log = logging.getLogger(__name__)
# pylint: disable=import-error
try:
import dateutil.parser as dateutil_parser
_TIME_SUPPORTED = True
except ImportError:
_TIME_SUPPORTED = False
def __virtual__():
if os.path.isfile(WTMP):
return __virtualname__
return False
def _validate_time_range(trange, status, msg):
'''
Check time range
'''
# If trange is empty, just return the current status & msg
if not trange:
return status, msg
if not isinstance(trange, dict):
status = False
msg = ('The time_range parameter for '
'wtmp beacon must '
'be a dictionary.')
if not all(k in trange for k in ('start', 'end')):
status = False
msg = ('The time_range parameter for '
'wtmp beacon must contain '
'start & end options.')
return status, msg
def _gather_group_members(group, groups, users):
'''
Gather group members
'''
_group = __salt__['group.info'](group)
if not _group:
log.warning('Group %s does not exist, ignoring.', group)
return
for member in _group['members']:
if member not in users:
users[member] = groups[group]
def _check_time_range(time_range, now):
'''
Check time range
'''
if _TIME_SUPPORTED:
_start = dateutil_parser.parse(time_range['start'])
_end = dateutil_parser.parse(time_range['end'])
return bool(_start <= now <= _end)
else:
log.error('Dateutil is required.')
return False
def _get_loc():
'''
return the active file location
'''
if LOC_KEY in __context__:
return __context__[LOC_KEY]
def validate(config):
'''
Validate the beacon configuration
'''
vstatus = True
vmsg = 'Valid beacon configuration'
# Configuration for wtmp beacon should be a list of dicts
if not isinstance(config, list):
vstatus = False
vmsg = ('Configuration for wtmp beacon must be a list.')
else:
_config = {}
list(map(_config.update, config))
if 'users' in _config:
if not isinstance(_config['users'], dict):
vstatus = False
vmsg = ('User configuration for wtmp beacon must '
'be a dictionary.')
else:
for user in _config['users']:
_time_range = _config['users'][user].get('time_range', {})
vstatus, vmsg = _validate_time_range(_time_range,
vstatus,
vmsg)
if not vstatus:
return vstatus, vmsg
if 'groups' in _config:
if not isinstance(_config['groups'], dict):
vstatus = False
vmsg = ('Group configuration for wtmp beacon must '
'be a dictionary.')
else:
for group in _config['groups']:
_time_range = _config['groups'][group].get('time_range', {})
vstatus, vmsg = _validate_time_range(_time_range,
vstatus,
vmsg)
if not vstatus:
return vstatus, vmsg
if 'defaults' in _config:
if not isinstance(_config['defaults'], dict):
vstatus = False
vmsg = ('Defaults configuration for wtmp beacon must '
'be a dictionary.')
else:
_time_range = _config['defaults'].get('time_range', {})
vstatus, vmsg = _validate_time_range(_time_range,
vstatus,
vmsg)
if not vstatus:
return vstatus, vmsg
return vstatus, vmsg
def beacon(config):
'''
Read the last wtmp file and return information on the logins
'''
ret = []
users = {}
groups = {}
defaults = None
login_type = LOGIN_TYPE
logout_type = LOGOUT_TYPE
for config_item in config:
if 'users' in config_item:
users = config_item['users']
if 'groups' in config_item:
groups = config_item['groups']
if 'defaults' in config_item:
defaults = config_item['defaults']
if config_item == 'ut_type':
try:
login_type = config_item['ut_type']['login']
except KeyError:
pass
try:
logout_type = config_item['ut_type']['logout']
except KeyError:
pass
with salt.utils.files.fopen(WTMP, 'rb') as fp_:
loc = __context__.get(LOC_KEY, 0)
if loc == 0:
fp_.seek(0, 2)
__context__[LOC_KEY] = fp_.tell()
return ret
else:
fp_.seek(loc)
while True:
now = datetime.datetime.now()
raw = fp_.read(SIZE)
if len(raw) != SIZE:
return ret
__context__[LOC_KEY] = fp_.tell()
pack = struct.unpack(FMT, raw)
event = {}
for ind, field in enumerate(FIELDS):
event[field] = pack[ind]
if isinstance(event[field], six.string_types):
if isinstance(event[field], bytes):
event[field] = salt.utils.stringutils.to_unicode(event[field])
event[field] = event[field].strip('\x00')
if event['type'] == login_type:
event['action'] = 'login'
# Store the tty to identify the logout event
__context__['{0}{1}'.format(TTY_KEY_PREFIX, event['line'])] = event['user']
elif event['type'] == logout_type:
event['action'] = 'logout'
try:
event['user'] = __context__.pop('{0}{1}'.format(TTY_KEY_PREFIX, event['line']))
except KeyError:
pass
for group in groups:
_gather_group_members(group, groups, users)
if users:
if event['user'] in users:
_user = users[event['user']]
if isinstance(_user, dict) and 'time_range' in _user:
if _check_time_range(_user['time_range'], now):
ret.append(event)
else:
if defaults and 'time_range' in defaults:
if _check_time_range(defaults['time_range'],
now):
ret.append(event)
else:
ret.append(event)
else:
if defaults and 'time_range' in defaults:
if _check_time_range(defaults['time_range'], now):
ret.append(event)
else:
ret.append(event)
return ret