Added support for remote syslog servers (#442)

pull/447/head
Chris Caron 2021-09-18 14:45:17 -04:00 committed by GitHub
parent 109841d72e
commit 0c7e32390e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 289 additions and 26 deletions

View File

@ -80,7 +80,7 @@ The table below identifies the services this tool supports and some example serv
| [SMTP2Go](https://github.com/caronc/apprise/wiki/Notify_smtp2go) | smtp2go:// | (TCP) 443 | smtp2go://user@hostname/apikey<br />smtp2go://user@hostname/apikey/email<br />smtp2go://user@hostname/apikey/email1/email2/emailN<br />smtp2go://user@hostname/apikey/?name="From%20User" | [SMTP2Go](https://github.com/caronc/apprise/wiki/Notify_smtp2go) | smtp2go:// | (TCP) 443 | smtp2go://user@hostname/apikey<br />smtp2go://user@hostname/apikey/email<br />smtp2go://user@hostname/apikey/email1/email2/emailN<br />smtp2go://user@hostname/apikey/?name="From%20User"
| [SparkPost](https://github.com/caronc/apprise/wiki/Notify_sparkpost) | sparkpost:// | (TCP) 443 | sparkpost://user@hostname/apikey<br />sparkpost://user@hostname/apikey/email<br />sparkpost://user@hostname/apikey/email1/email2/emailN<br />sparkpost://user@hostname/apikey/?name="From%20User" | [SparkPost](https://github.com/caronc/apprise/wiki/Notify_sparkpost) | sparkpost:// | (TCP) 443 | sparkpost://user@hostname/apikey<br />sparkpost://user@hostname/apikey/email<br />sparkpost://user@hostname/apikey/email1/email2/emailN<br />sparkpost://user@hostname/apikey/?name="From%20User"
| [Spontit](https://github.com/caronc/apprise/wiki/Notify_spontit) | spontit:// | (TCP) 443 | spontit://UserID@APIKey/<br />spontit://UserID@APIKey/Channel<br />spontit://UserID@APIKey/Channel1/Channel2/ChannelN | [Spontit](https://github.com/caronc/apprise/wiki/Notify_spontit) | spontit:// | (TCP) 443 | spontit://UserID@APIKey/<br />spontit://UserID@APIKey/Channel<br />spontit://UserID@APIKey/Channel1/Channel2/ChannelN
| [Syslog](https://github.com/caronc/apprise/wiki/Notify_syslog) | syslog:// | n/a | syslog://<br />syslog://Facility | [Syslog](https://github.com/caronc/apprise/wiki/Notify_syslog) | syslog:// | (UDP) 514 (_if hostname specified_) | syslog://<br />syslog://Facility<br />syslog://hostname<br />syslog://hostname/Facility
| [Telegram](https://github.com/caronc/apprise/wiki/Notify_telegram) | tgram:// | (TCP) 443 | tgram://bottoken/ChatID<br />tgram://bottoken/ChatID1/ChatID2/ChatIDN | [Telegram](https://github.com/caronc/apprise/wiki/Notify_telegram) | tgram:// | (TCP) 443 | tgram://bottoken/ChatID<br />tgram://bottoken/ChatID1/ChatID2/ChatIDN
| [Twitter](https://github.com/caronc/apprise/wiki/Notify_twitter) | twitter:// | (TCP) 443 | twitter://CKey/CSecret/AKey/ASecret<br/>twitter://user@CKey/CSecret/AKey/ASecret<br/>twitter://CKey/CSecret/AKey/ASecret/User1/User2/User2<br/>twitter://CKey/CSecret/AKey/ASecret?mode=tweet | [Twitter](https://github.com/caronc/apprise/wiki/Notify_twitter) | twitter:// | (TCP) 443 | twitter://CKey/CSecret/AKey/ASecret<br/>twitter://user@CKey/CSecret/AKey/ASecret<br/>twitter://CKey/CSecret/AKey/ASecret/User1/User2/User2<br/>twitter://CKey/CSecret/AKey/ASecret?mode=tweet
| [Twist](https://github.com/caronc/apprise/wiki/Notify_twist) | twist:// | (TCP) 443 | twist://pasword:login<br/>twist://password:login/#channel<br/>twist://password:login/#team:channel<br/>twist://password:login/#team:channel1/channel2/#team3:channel | [Twist](https://github.com/caronc/apprise/wiki/Notify_twist) | twist:// | (TCP) 443 | twist://pasword:login<br/>twist://password:login/#channel<br/>twist://password:login/#team:channel<br/>twist://password:login/#team:channel1/channel2/#team3:channel

View File

@ -22,12 +22,15 @@
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE. # THE SOFTWARE.
import os
import six
import syslog import syslog
import socket
from .NotifyBase import NotifyBase from .NotifyBase import NotifyBase
from ..common import NotifyType from ..common import NotifyType
from ..utils import parse_bool from ..utils import parse_bool
from ..utils import is_hostname
from ..AppriseLocale import gettext_lazy as _ from ..AppriseLocale import gettext_lazy as _
@ -98,6 +101,21 @@ SYSLOG_FACILITY_RMAP = {
} }
class SyslogMode(object):
# A local query
LOCAL = "local"
# A remote query
REMOTE = "remote"
# webhook modes are placed ito this list for validation purposes
SYSLOG_MODES = (
SyslogMode.LOCAL,
SyslogMode.REMOTE,
)
class NotifySyslog(NotifyBase): class NotifySyslog(NotifyBase):
""" """
A wrapper for Syslog Notifications A wrapper for Syslog Notifications
@ -119,13 +137,14 @@ class NotifySyslog(NotifyBase):
# local anyway # local anyway
request_rate_per_sec = 0 request_rate_per_sec = 0
# Title to be added to body if present
title_maxlen = 0
# Define object templates # Define object templates
templates = ( templates = (
'{schema}://', '{schema}://',
'{schema}://{facility}', '{schema}://{facility}',
'{schema}://{host}',
'{schema}://{host}:{port}',
'{schema}://{host}/{facility}',
'{schema}://{host}:{port}/{facility}',
) )
# Define our template tokens # Define our template tokens
@ -136,6 +155,18 @@ class NotifySyslog(NotifyBase):
'values': [k for k in SYSLOG_FACILITY_MAP.keys()], 'values': [k for k in SYSLOG_FACILITY_MAP.keys()],
'default': SyslogFacility.USER, 'default': SyslogFacility.USER,
}, },
'host': {
'name': _('Hostname'),
'type': 'string',
'required': True,
},
'port': {
'name': _('Port'),
'type': 'int',
'min': 1,
'max': 65535,
'default': 514,
},
}) })
# Define our template arguments # Define our template arguments
@ -144,6 +175,12 @@ class NotifySyslog(NotifyBase):
# We map back to the same element defined in template_tokens # We map back to the same element defined in template_tokens
'alias_of': 'facility', 'alias_of': 'facility',
}, },
'mode': {
'name': _('Syslog Mode'),
'type': 'choice:string',
'values': SYSLOG_MODES,
'default': SyslogMode.LOCAL,
},
'logpid': { 'logpid': {
'name': _('Log PID'), 'name': _('Log PID'),
'type': 'bool', 'type': 'bool',
@ -158,8 +195,8 @@ class NotifySyslog(NotifyBase):
}, },
}) })
def __init__(self, facility=None, log_pid=True, log_perror=False, def __init__(self, facility=None, mode=None, log_pid=True,
**kwargs): log_perror=False, **kwargs):
""" """
Initialize Syslog Object Initialize Syslog Object
""" """
@ -179,6 +216,14 @@ class NotifySyslog(NotifyBase):
SYSLOG_FACILITY_MAP[ SYSLOG_FACILITY_MAP[
self.template_tokens['facility']['default']] self.template_tokens['facility']['default']]
self.mode = self.template_args['mode']['default'] \
if not isinstance(mode, six.string_types) else mode.lower()
if self.mode not in SYSLOG_MODES:
msg = 'The mode specified ({}) is invalid.'.format(mode)
self.logger.warning(msg)
raise TypeError(msg)
# Logging Options # Logging Options
self.logoptions = 0 self.logoptions = 0
@ -214,8 +259,13 @@ class NotifySyslog(NotifyBase):
NotifyType.WARNING: syslog.LOG_WARNING, NotifyType.WARNING: syslog.LOG_WARNING,
} }
if title:
# Format title
body = '{}: {}'.format(title, body)
# Always call throttle before any remote server i/o is made # Always call throttle before any remote server i/o is made
self.throttle() self.throttle()
if self.mode == SyslogMode.LOCAL:
try: try:
syslog.syslog(_pmap[notify_type], body) syslog.syslog(_pmap[notify_type], body)
@ -226,6 +276,60 @@ class NotifySyslog(NotifyBase):
'({}) was specified.'.format(notify_type)) '({}) was specified.'.format(notify_type))
return False return False
else: # SyslogMode.REMOTE
host = self.host
port = self.port if self.port \
else self.template_tokens['port']['default']
if self.log_pid:
payload = '<%d>- %d - %s' % (
_pmap[notify_type] + self.facility * 8, os.getpid(), body)
else:
payload = '<%d>- %s' % (
_pmap[notify_type] + self.facility * 8, body)
# send UDP packet to upstream server
self.logger.debug(
'Syslog Host: %s:%d/%s',
host, port, SYSLOG_FACILITY_RMAP[self.facility])
self.logger.debug('Syslog Payload: %s' % str(payload))
# our sent bytes
sent = 0
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(self.socket_connect_timeout)
sent = sock.sendto(payload.encode('utf-8'), (host, port))
sock.close()
except socket.gaierror as e:
self.logger.warning(
'A connection error occurred sending Syslog '
'notification to %s:%d/%s', host, port,
SYSLOG_FACILITY_RMAP[self.facility]
)
self.logger.debug('Socket Exception: %s' % str(e))
return False
except socket.timeout as e:
self.logger.warning(
'A connection timeout occurred sending Syslog '
'notification to %s:%d/%s', host, port,
SYSLOG_FACILITY_RMAP[self.facility]
)
self.logger.debug('Socket Exception: %s' % str(e))
return False
if sent < len(payload):
self.logger.warning(
'Syslog sent %d byte(s) but intended to send %d byte(s)',
sent, len(payload))
return False
self.logger.info('Sent Syslog (%s) notification.', self.mode)
return True return True
def url(self, privacy=False, *args, **kwargs): def url(self, privacy=False, *args, **kwargs):
@ -237,11 +341,13 @@ class NotifySyslog(NotifyBase):
params = { params = {
'logperror': 'yes' if self.log_perror else 'no', 'logperror': 'yes' if self.log_perror else 'no',
'logpid': 'yes' if self.log_pid else 'no', 'logpid': 'yes' if self.log_pid else 'no',
'mode': self.mode,
} }
# Extend our parameters # Extend our parameters
params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
if self.mode == SyslogMode.LOCAL:
return '{schema}://{facility}/?{params}'.format( return '{schema}://{facility}/?{params}'.format(
facility=self.template_tokens['facility']['default'] facility=self.template_tokens['facility']['default']
if self.facility not in SYSLOG_FACILITY_RMAP if self.facility not in SYSLOG_FACILITY_RMAP
@ -250,6 +356,19 @@ class NotifySyslog(NotifyBase):
params=NotifySyslog.urlencode(params), params=NotifySyslog.urlencode(params),
) )
# Remote mode:
return '{schema}://{hostname}{port}/{facility}/?{params}'.format(
schema=self.secure_protocol,
hostname=NotifySyslog.quote(self.host, safe=''),
port='' if self.port is None
or self.port == self.template_tokens['port']['default']
else ':{}'.format(self.port),
facility=self.template_tokens['facility']['default']
if self.facility not in SYSLOG_FACILITY_RMAP
else SYSLOG_FACILITY_RMAP[self.facility],
params=NotifySyslog.urlencode(params),
)
@staticmethod @staticmethod
def parse_url(url): def parse_url(url):
""" """
@ -262,9 +381,28 @@ class NotifySyslog(NotifyBase):
# We're done early as we couldn't load the results # We're done early as we couldn't load the results
return results return results
# if specified; save hostname into facility tokens = []
facility = None if not results['host'] \ if results['host']:
else NotifySyslog.unquote(results['host']) tokens.append(NotifySyslog.unquote(results['host']))
# Get our path values
tokens.extend(NotifySyslog.split_path(results['fullpath']))
facility = None
if len(tokens) > 1 and is_hostname(tokens[0]):
# syslog://hostname/facility
results['mode'] = SyslogMode.REMOTE
# Store our facility as the first path entry
facility = tokens[-1]
elif tokens:
# This is a bit ambigious... it could be either:
# syslog://facility -or- syslog://hostname
# First lets test it as a facility; we'll correct this
# later on if nessisary
facility = tokens[-1]
# However if specified on the URL, that will over-ride what was # However if specified on the URL, that will over-ride what was
# identified # identified
@ -280,15 +418,34 @@ class NotifySyslog(NotifyBase):
facility = next((f for f in SYSLOG_FACILITY_MAP.keys() facility = next((f for f in SYSLOG_FACILITY_MAP.keys()
if f.startswith(facility)), facility) if f.startswith(facility)), facility)
# Save facility # Attempt to solve our ambiguity
if len(tokens) == 1 and is_hostname(tokens[0]) and (
results['port'] or facility not in SYSLOG_FACILITY_MAP):
# facility is likely hostname; update our guessed mode
results['mode'] = SyslogMode.REMOTE
# Reset our facility value
facility = None
# Set mode if not otherwise set
if 'mode' in results['qsd'] and len(results['qsd']['mode']):
results['mode'] = NotifySyslog.unquote(results['qsd']['mode'])
# Save facility if set
if facility:
results['facility'] = facility results['facility'] = facility
# Include PID as part of the message logged # Include PID as part of the message logged
results['log_pid'] = \ results['log_pid'] = parse_bool(
parse_bool(results['qsd'].get('logpid', True)) results['qsd'].get(
'logpid',
NotifySyslog.template_args['logpid']['default']))
# Print to stderr as well. # Print to stderr as well.
results['log_perror'] = \ results['log_perror'] = parse_bool(
parse_bool(results['qsd'].get('logperror', False)) results['qsd'].get(
'logperror',
NotifySyslog.template_args['logperror']['default']))
return results return results

View File

@ -27,6 +27,7 @@ import re
import pytest import pytest
import mock import mock
import apprise import apprise
import socket
# Disable logging for a cleaner testing output # Disable logging for a cleaner testing output
import logging import logging
@ -49,6 +50,7 @@ def test_notify_syslog_by_url(openlog, syslog):
assert obj.url().startswith('syslog://user') is True assert obj.url().startswith('syslog://user') is True
assert re.search(r'logpid=yes', obj.url()) is not None assert re.search(r'logpid=yes', obj.url()) is not None
assert re.search(r'logperror=no', obj.url()) is not None assert re.search(r'logperror=no', obj.url()) is not None
assert re.search(r'syslog://.*mode=local', obj.url())
assert isinstance( assert isinstance(
apprise.Apprise.instantiate( apprise.Apprise.instantiate(
@ -59,9 +61,11 @@ def test_notify_syslog_by_url(openlog, syslog):
assert obj.url().startswith('syslog://user') is True assert obj.url().startswith('syslog://user') is True
assert re.search(r'logpid=no', obj.url()) is not None assert re.search(r'logpid=no', obj.url()) is not None
assert re.search(r'logperror=yes', obj.url()) is not None assert re.search(r'logperror=yes', obj.url()) is not None
assert re.search(r'syslog://.*mode=local', obj.url())
# Test sending a notification # Test sending a notification
assert obj.notify("body") is True assert obj.notify("body") is True
assert obj.notify(title="title", body="body") is True
# Invalid Notification Type # Invalid Notification Type
assert obj.notify("body", notify_type='invalid') is False assert obj.notify("body", notify_type='invalid') is False
@ -71,6 +75,7 @@ def test_notify_syslog_by_url(openlog, syslog):
assert obj.url().startswith('syslog://local5') is True assert obj.url().startswith('syslog://local5') is True
assert re.search(r'logpid=yes', obj.url()) is not None assert re.search(r'logpid=yes', obj.url()) is not None
assert re.search(r'logperror=no', obj.url()) is not None assert re.search(r'logperror=no', obj.url()) is not None
assert re.search(r'syslog://.*mode=local', obj.url())
# Invalid instantiation # Invalid instantiation
assert apprise.Apprise.instantiate('syslog://_/?facility=invalid') is None assert apprise.Apprise.instantiate('syslog://_/?facility=invalid') is None
@ -81,6 +86,7 @@ def test_notify_syslog_by_url(openlog, syslog):
assert obj.url().startswith('syslog://daemon') is True assert obj.url().startswith('syslog://daemon') is True
assert re.search(r'logpid=yes', obj.url()) is not None assert re.search(r'logpid=yes', obj.url()) is not None
assert re.search(r'logperror=no', obj.url()) is not None assert re.search(r'logperror=no', obj.url()) is not None
assert re.search(r'syslog://.*mode=local', obj.url())
# Facility can also be specified on the url as a hostname # Facility can also be specified on the url as a hostname
obj = apprise.Apprise.instantiate('syslog://kern?logpid=no&logperror=y') obj = apprise.Apprise.instantiate('syslog://kern?logpid=no&logperror=y')
@ -88,11 +94,13 @@ def test_notify_syslog_by_url(openlog, syslog):
assert obj.url().startswith('syslog://kern') is True assert obj.url().startswith('syslog://kern') is True
assert re.search(r'logpid=no', obj.url()) is not None assert re.search(r'logpid=no', obj.url()) is not None
assert re.search(r'logperror=yes', obj.url()) is not None assert re.search(r'logperror=yes', obj.url()) is not None
assert re.search(r'syslog://.*mode=local', obj.url())
# Facilities specified as an argument always over-ride host # Facilities specified as an argument always over-ride host
obj = apprise.Apprise.instantiate('syslog://kern?facility=d') obj = apprise.Apprise.instantiate('syslog://kern?facility=d')
assert isinstance(obj, apprise.plugins.NotifySyslog) assert isinstance(obj, apprise.plugins.NotifySyslog)
assert obj.url().startswith('syslog://daemon') is True assert obj.url().startswith('syslog://daemon') is True
assert re.search(r'syslog://.*mode=local', obj.url())
@mock.patch('syslog.syslog') @mock.patch('syslog.syslog')
@ -109,6 +117,7 @@ def test_notify_syslog_by_class(openlog, syslog):
assert obj.url().startswith('syslog://user') is True assert obj.url().startswith('syslog://user') is True
assert re.search(r'logpid=yes', obj.url()) is not None assert re.search(r'logpid=yes', obj.url()) is not None
assert re.search(r'logperror=no', obj.url()) is not None assert re.search(r'logperror=no', obj.url()) is not None
assert re.search(r'syslog://.*mode=local', obj.url())
# Exception should be thrown about the fact no bot token was specified # Exception should be thrown about the fact no bot token was specified
with pytest.raises(TypeError): with pytest.raises(TypeError):
@ -116,3 +125,100 @@ def test_notify_syslog_by_class(openlog, syslog):
with pytest.raises(TypeError): with pytest.raises(TypeError):
apprise.plugins.NotifySyslog(facility=object) apprise.plugins.NotifySyslog(facility=object)
@mock.patch('syslog.syslog')
@mock.patch('syslog.openlog')
@mock.patch('socket.socket')
@mock.patch('os.getpid')
def test_notify_syslog_remote(
mock_getpid, mock_socket, mock_openlog, mock_syslog):
"""
API: Syslog Remote Testing
"""
payload = "test"
mock_connection = mock.Mock()
# Fix pid response since it can vary in length and this impacts the
# sendto() payload response
mock_getpid.return_value = 123
# our payload length
mock_connection.sendto.return_value = 16
mock_socket.return_value = mock_connection
# localhost does not lookup to any of the facility codes so this
# gets interpreted as a host
obj = apprise.Apprise.instantiate('syslog://localhost')
assert isinstance(obj, apprise.plugins.NotifySyslog)
assert obj.url().startswith('syslog://localhost') is True
assert re.search(r'syslog://.*mode=remote', obj.url())
assert re.search(r'logpid=yes', obj.url()) is not None
assert obj.notify(body=payload) is True
# Test with port
obj = apprise.Apprise.instantiate('syslog://localhost:518')
assert isinstance(obj, apprise.plugins.NotifySyslog)
assert obj.url().startswith('syslog://localhost:518') is True
assert re.search(r'syslog://.*mode=remote', obj.url())
assert re.search(r'logpid=yes', obj.url()) is not None
assert obj.notify(body=payload) is True
# Test with default port
obj = apprise.Apprise.instantiate('syslog://localhost:514')
assert isinstance(obj, apprise.plugins.NotifySyslog)
assert obj.url().startswith('syslog://localhost') is True
assert re.search(r'syslog://.*mode=remote', obj.url())
assert re.search(r'logpid=yes', obj.url()) is not None
assert obj.notify(body=payload) is True
# Specify a facility
obj = apprise.Apprise.instantiate('syslog://localhost/kern')
assert isinstance(obj, apprise.plugins.NotifySyslog)
assert obj.url().startswith('syslog://localhost/kern') is True
assert re.search(r'syslog://.*mode=remote', obj.url())
assert re.search(r'logpid=yes', obj.url()) is not None
assert obj.notify(body=payload) is True
# Specify a facility requiring a lookup and having the port identified
# resolves any ambiguity
obj = apprise.Apprise.instantiate('syslog://kern:514/d')
assert isinstance(obj, apprise.plugins.NotifySyslog)
assert obj.url().startswith('syslog://kern/daemon') is True
assert re.search(r'syslog://.*mode=remote', obj.url())
assert re.search(r'logpid=yes', obj.url()) is not None
mock_connection.sendto.return_value = 17 # daemon is one more byte in size
assert obj.notify(body=payload) is True
# We can attempt to exclusively set the mode as well without a port
# to also remove ambiguity; this falls back to sending as the 'user'
obj = apprise.Apprise.instantiate('syslog://kern/d?mode=remote&logpid=no')
assert isinstance(obj, apprise.plugins.NotifySyslog)
assert obj.url().startswith('syslog://kern/daemon') is True
assert re.search(r'syslog://.*mode=remote', obj.url())
assert re.search(r'logpid=no', obj.url()) is not None
assert re.search(r'logperror=no', obj.url()) is not None
# Test notifications
# + 1 byte in size due to user
# + length of pid returned
mock_connection.sendto.return_value = len(payload) + 5 \
+ len(str(mock_getpid.return_value))
assert obj.notify(body=payload) is True
# This only fails because the underlining sendto() will return a
# length different then what was expected
assert obj.notify(body="a different payload size") is False
# Test timeouts and errors that can occur
mock_connection.sendto.return_value = None
mock_connection.sendto.side_effect = socket.gaierror
assert obj.notify(body=payload) is False
mock_connection.sendto.side_effect = socket.timeout
assert obj.notify(body=payload) is False
with pytest.raises(TypeError):
# Handle an invalid mode
obj = apprise.Apprise.instantiate(
'syslog://user/?mode=invalid', suppress_exceptions=False)