├── .gitignore
├── LICENSE
├── README.md
├── configuration.yml
├── probe.py
├── siphealthcheck.py
└── utils.py
/.gitignore:
--------------------------------------------------------------------------------
1 | *.swp
2 | env.sh
3 | *.pyc
4 | venv/
5 | *.retry
6 | .idea
7 | *.log
8 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2019 Nguyen Hoang Minh
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy
4 | of this software and associated documentation files (the "Software"), to deal
5 | in the Software without restriction, including without limitation the rights
6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | copies of the Software, and to permit persons to whom the Software is
8 | furnished to do so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in all
11 | copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # SIP Healthcheck [Python SIP OPTIONS Packet]
2 |
3 | SIP Healthcheck is a simple tool that allow to check and alert that refect on SIP endpoint by sending SIP OPTIONS methods
4 |
5 | ## Features:
6 | * Ping SIP endpoin by SIP OPTIONS
7 | * Collecting evaualuation metric such as latency, timeout or response code
8 | * Configuration inventory via yaml format
9 | * Support TCP, UDP transport
10 | * Notify via Slack, SMS, Email, or Voice.
11 | * Setting up sequent time send notification
12 | * Setting up threshold for alert
13 | * and many more .., pull requests are welcome
14 |
15 | ## REQUIREMENTS
16 | * requests
17 | * yaml
18 | * twilio
19 |
20 | ## USAGE
21 | * Setting your inventory in configuration.yml
22 |
23 | * Start Monitoring by run command:
24 | ```python siphealthcheck.py```
25 |
26 | ## Container:
27 | Docker will be added soon.
28 |
29 | ## Notification
30 | Current nofications are supported to use slack, mailgun, sms and voice call.
31 |
32 | Chat channel with Slack | Email with Mailgun | SMS & Call with Twilio
33 | :-------------------------:|:-------------------------:|:-------------------------:
34 |
|
|
35 |
36 |
37 | ## Slack Alert Example:
38 | 
39 |
40 | ## AUTHOR
41 |
42 | **Minh Minh**
43 |
44 | ---
45 |
--------------------------------------------------------------------------------
/configuration.yml:
--------------------------------------------------------------------------------
1 | application:
2 | version: 1.0.2
3 | # example
4 | domain: siphealthcheck.com
5 | name: siphealthcheck
6 |
7 | ping:
8 | # how often you will do health check sip server? (in second, default: 60)
9 | interval: 120
10 | # how long you will mark the server is unavailable (in second, default: 10)?
11 | timeout: 10
12 | # threshold of latency(RTT), how long you mark the server is in high latency (in millisecond, default: 150)?
13 | latency: 350
14 | notification:
15 | # sequence of time-point that will alert for timeout/death; when you want to receive alert for timeout?
16 | timeout_schedulers: [3, 9, 15, 20]
17 | # sequence of time-point that will alert for high latency; when you want to receive alert for latency?
18 | latency_schedulers: [5, 10, 20]
19 | # which channel will you receive notification?
20 | # support chat notification use Slack
21 | # support email notification use Mailgun
22 | # support sms notification use Twilio
23 | # support call notification use Twilio
24 | # please note that you will need register apikey to use above service.
25 | methods:
26 | - name: slack
27 | webhook: https://hooks.slack.com/services/your-slack-incoming-webhook
28 |
29 | inventory:
30 | - name: BorderSer
31 | host: 192.168.1.219
32 | port: 5070
33 | transport: udp
34 | ping:
35 | interval: 60
36 | timeout: 8
37 | latency: 600
38 | notification:
39 | timeout_schedulers: [2, 6, 11, 15]
40 | latency_schedulers: [3, 7, 12]
41 | methods:
42 | - name: slack
43 | webhook: https://hooks.slack.com/services/your-slack-incoming-webhook
44 | - name: email
45 | sender: noreply@siphealthcheck.com
46 | receivers:
47 | - minh@siphealthcheck.com
48 | - name: sms
49 | sender: +84123456789
50 | receivers:
51 | - +84987654321
52 | - name: call
53 | caller: +84123456789
54 | callee: +84987654321
55 |
56 | - name: FeatureSer
57 | host: 192.168.1.43
58 | port: 5060
59 | transport: tcp
60 |
--------------------------------------------------------------------------------
/probe.py:
--------------------------------------------------------------------------------
1 | import time
2 | import random
3 | import uuid
4 | from threading import Thread
5 | import sys
6 | import os
7 | sys.path.append(os.path.dirname(os.path.abspath(__file__)))
8 | from utils import socket_connection, load_config, logged, notify
9 |
10 | sample_number = 3
11 | configuration = load_config()
12 | version = configuration['application']['version']
13 | application = configuration['application']['name'].replace(' ', '').lower()
14 | domain = configuration['application']['domain']
15 |
16 |
17 | class SIPProbe(Thread):
18 |
19 | def __init__(self, name, destination_ip, destination_port, transport, ping_interval, ping_timeout,
20 | notification_timeout_schedulers, ping_latency, notification_latency_schedulers, notification_methods):
21 |
22 | Thread.__init__(self)
23 | self.logger = logged(__name__)
24 |
25 | self.name = name
26 |
27 | self.source_ip = ''
28 | self.source_port = random.randint(10240, 65535)
29 |
30 | self.destination_ip = destination_ip
31 | self.destination_port = destination_port
32 | self.transport = transport
33 |
34 | self.ping_interval = ping_interval
35 | self.timeout = ping_timeout
36 | self.notification_timeout_schedulers = notification_timeout_schedulers
37 | self.max_notification_timeout_schedulers = max(notification_timeout_schedulers)
38 | self.min_notification_timeout_schedulers = min(notification_timeout_schedulers)
39 |
40 | self.ping_latency = ping_latency
41 | self.notification_latency_schedulers = notification_latency_schedulers
42 | self.max_notification_latency_schedulers = max(notification_latency_schedulers)
43 | self.min_notification_latency_schedulers = min(notification_latency_schedulers)
44 |
45 | self.notification_methods = notification_methods
46 |
47 | self.timeout_counter = 0
48 | self.latency_counter = 0
49 |
50 | def options(self):
51 | from_tag = str(uuid.uuid4())[:12]
52 | branch = 'z9hG4bK' + str(uuid.uuid4())[:12] # https://www.ietf.org/rfc/rfc3261.txt
53 | call_id = str(uuid.uuid4())
54 | cseq = random.randint(1, 999999)
55 |
56 | sip_options = (
57 | 'OPTIONS sip:{app_name}@{sip_dest_addr}:{sip_dest_port} SIP/2.0\r\n'
58 | 'Via: SIP/2.0/{transport} {sip_src_addr}:{sip_src_port};branch={branch};rport;alias\r\n'
59 | 'From: ;tag={from_tag}\r\n'
60 | 'To: \r\n'
61 | 'Call-ID: {call_id}@{sip_src_addr}\r\n'
62 | 'CSeq: {cseq} OPTIONS\r\n'
63 | 'Contact: \r\n'
64 | 'Max-Forwards: 70\r\n'
65 | 'User-Agent: {app_name}_v{version}\r\n'
66 | 'Supported: path, replaces\r\n'
67 | 'Accept: text/plain\r\n'
68 | 'Allow: INVITE, ACK, BYE, CANCEL, OPTIONS, MESSAGE, INFO, UPDATE, NOTIFY\r\n'
69 | 'Content-Length: 0\r\n\r\n').format(
70 | app_name=application,
71 | sip_dest_addr=self.destination_ip,
72 | sip_dest_port=self.destination_port,
73 | transport=self.transport,
74 | sip_src_addr=domain,
75 | sip_src_port=self.source_port,
76 | branch=branch,
77 | from_tag=from_tag,
78 | call_id=call_id,
79 | cseq=cseq,
80 | version=version)
81 |
82 | start_time = time.time()
83 | resp, error = socket_connection(sip_options, self.destination_ip, self.destination_port, self.source_ip,
84 | self.source_port, self.transport, self.timeout)
85 | latency = round(time.time() - start_time, 3) * 1000
86 |
87 | if resp:
88 | status_code = resp.split()[1]
89 |
90 | return status_code, latency, resp
91 | else:
92 | return None, latency, error
93 |
94 | daemon = True
95 |
96 | def run(self):
97 | properties = {'name': self.name, 'transport': self.transport, 'destination': self.destination_ip, 'port': self.destination_port}
98 | states = {'counter': 0, 'matrix': []}
99 | self.logger.info(
100 | '[{}] {}://{}:{} interval: {}, timeout: ({} {}), latency: ({} {}) '.format(self.name, self.transport,
101 | self.destination_ip,
102 | self.destination_port,
103 | self.ping_interval, self.timeout,
104 | self.notification_timeout_schedulers,
105 | self.ping_latency,
106 | self.notification_latency_schedulers))
107 | status_code_list = []
108 | latency_list = []
109 | while True:
110 | start_loop = time.time()
111 | status_code, latency, message = self.options()
112 |
113 | # timeout
114 | if status_code == '200':
115 | self.timeout_counter = 0
116 | status_code_list = status_code_list[(-1) * self.min_notification_timeout_schedulers:]
117 |
118 | # latency
119 | if latency < self.ping_latency:
120 | self.latency_counter = 0
121 | latency_list = latency_list[(-1) * self.min_notification_latency_schedulers:]
122 | else:
123 | self.latency_counter += 1
124 |
125 | latency_list.append(latency)
126 | if len(latency_list) > sample_number * self.min_notification_latency_schedulers:
127 | latency_list = latency_list[(-1) * (sample_number - 1) * self.min_notification_latency_schedulers:]
128 |
129 |
130 | states['matrix'] = latency_list
131 | states['counter'] = self.latency_counter
132 |
133 | # latency notify
134 | if (self.latency_counter in self.notification_latency_schedulers) or (
135 | self.latency_counter > 0 and self.latency_counter % self.max_notification_latency_schedulers == 0):
136 |
137 | notify('Latency', self.notification_methods, properties, states)
138 | self.logger.info("{} Latency Notify {}".format(self.name, latency_list))
139 |
140 | else:
141 | self.timeout_counter += 1
142 |
143 | status_code_list.append(status_code)
144 | if len(status_code_list) > sample_number * self.min_notification_timeout_schedulers:
145 | status_code_list = status_code_list[
146 | (-1) * (sample_number - 1) * self.min_notification_timeout_schedulers:]
147 |
148 | states['matrix'] = status_code_list
149 | states['counter'] = self.timeout_counter
150 | # timeout notify
151 | if (self.timeout_counter in self.notification_timeout_schedulers) or (
152 | self.timeout_counter > 0 and self.timeout_counter % self.max_notification_timeout_schedulers == 0):
153 |
154 | notify('Timeout', self.notification_methods, properties, states)
155 | self.logger.info("{} Timeout Notify {}".format(self.name, status_code_list))
156 |
157 | # summary
158 | self.logger.info('{} is running... {}_{} {}_{}'.format(self.name, self.timeout_counter, status_code_list,
159 | self.latency_counter, latency_list))
160 |
161 | # idle, guarantee ping_interval is precise
162 | current_loop_ex_time = time.time() - start_loop
163 | if self.ping_interval > current_loop_ex_time:
164 | sleep_time = self.ping_interval - current_loop_ex_time
165 | else:
166 | sleep_time = self.ping_interval
167 |
168 | time.sleep(sleep_time)
169 |
--------------------------------------------------------------------------------
/siphealthcheck.py:
--------------------------------------------------------------------------------
1 | import time
2 | import sys
3 | import os
4 |
5 | sys.path.append(os.path.dirname(os.path.abspath(__file__)))
6 | from utils import load_config
7 | from probe import SIPProbe
8 |
9 | try:
10 | configuration = load_config()
11 | inventory = configuration['inventory']
12 | default_ping = configuration['ping']
13 | default_ping_interval = default_ping['interval']
14 | default_ping_timeout = default_ping['timeout']
15 | default_ping_latency = default_ping['latency']
16 | default_notification = configuration['notification']
17 | default_notification_timeout_schedulers = default_notification['timeout_schedulers']
18 | default_notification_latency_schedulers = default_notification['latency_schedulers']
19 | default_notification_methods = default_notification['methods']
20 |
21 | threads = []
22 | for server in inventory:
23 | name = server['name']
24 | host = server['host']
25 | port = server['port']
26 | transport = server['transport']
27 | ping = server.get('ping', default_ping)
28 | ping_interval = ping.get('interval', default_ping_interval)
29 | ping_timeout = ping.get('timeout', default_ping_timeout)
30 | ping_latency = ping.get('latency', default_ping_latency)
31 | notification = server.get('notification', default_notification)
32 | notification_timeout_schedulers = notification.get('timeout_schedulers', default_notification_timeout_schedulers)
33 | notification_latency_schedulers = notification.get('latency_schedulers', default_notification_latency_schedulers)
34 | notification_methods = notification.get('methods', default_notification_methods)
35 |
36 | instance = SIPProbe(name, host, port, transport, ping_interval, ping_timeout, notification_timeout_schedulers,
37 | ping_latency, notification_latency_schedulers, notification_methods)
38 |
39 | threads.append(instance)
40 |
41 | for thread in threads:
42 | thread.start()
43 |
44 | while True:
45 | time.sleep(1)
46 |
47 | except Exception as error:
48 | raise Exception(error)
49 |
--------------------------------------------------------------------------------
/utils.py:
--------------------------------------------------------------------------------
1 | import json
2 | import socket
3 | import yaml
4 | import requests
5 | import time
6 | import logging
7 | import os
8 | from twilio.rest import Client
9 |
10 | path = os.path.dirname(os.path.abspath(__file__))
11 |
12 | def logged(name):
13 | my_log = logging.getLogger(name)
14 | logging.config.dictConfig({
15 | "version": 1,
16 | "disable_existing_loggers": False,
17 | "formatters": {
18 | "simple": {
19 | "format": "[%(asctime)s] [%(name)s] - [%(levelname)s] - %(message)s"
20 | }
21 | },
22 | "handlers": {
23 | "console": {
24 | "class": "logging.StreamHandler",
25 | "level": "DEBUG",
26 | "formatter": "simple",
27 | "stream": "ext://sys.stdout"
28 | },
29 | "info_file_handler": {
30 | "class": "logging.handlers.RotatingFileHandler",
31 | "level": "INFO",
32 | "formatter": "simple",
33 | "filename": "{}/siphealthcheck.log".format(path),
34 | "maxBytes": 5242880,
35 | "backupCount": 10,
36 | "encoding": "utf8"
37 | },
38 | "error_file_handler": {
39 | "class": "logging.handlers.RotatingFileHandler",
40 | "level": "ERROR",
41 | "formatter": "simple",
42 | "filename": "{}/siphealthcheck.log".format(path),
43 | "maxBytes": 5242880,
44 | "backupCount": 10,
45 | "encoding": "utf8"
46 | }
47 | },
48 | "loggers": {
49 | "my_module": {
50 | "level": "INFO",
51 | "handlers": [
52 | "console"
53 | ],
54 | "propagate": "no"
55 | }
56 | },
57 | "root": {
58 | "level": "INFO",
59 | "handlers": [
60 | "info_file_handler"
61 | ]
62 | }
63 | })
64 |
65 | return my_log
66 |
67 |
68 | def load_config():
69 | with open(r'{}/configuration.yml'.format(path)) as file:
70 | config = yaml.safe_load(file)
71 |
72 | return config
73 |
74 |
75 | def socket_connection(data, destination_ip, destination_port, source_ip, source_port, transport, timeout):
76 | if transport.upper() == 'TCP':
77 | s = socket.socket(
78 | socket.AF_INET,
79 | socket.SOCK_STREAM,
80 | socket.IPPROTO_TCP
81 | )
82 | s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
83 | else:
84 | s = socket.socket(
85 | socket.AF_INET,
86 | socket.SOCK_DGRAM,
87 | socket.IPPROTO_UDP
88 | )
89 |
90 | # bind socket
91 | s.bind((source_ip, source_port))
92 | s.settimeout(timeout)
93 |
94 | response = None
95 | error = None
96 | try:
97 | s.connect((destination_ip, destination_port))
98 | s.send(str.encode(data))
99 | resp = s.recv(65535)
100 |
101 | if resp:
102 | response = resp.decode()
103 |
104 | # close socket connection
105 | s.shutdown(1)
106 | s.close()
107 |
108 | except Exception as e:
109 | error = e
110 | raise Exception(e)
111 |
112 | finally:
113 | return response, error
114 |
115 |
116 | def notify(category, methods, properties, states):
117 |
118 |
119 | name = properties['name']
120 | transport = properties['transport']
121 | destination = properties['destination']
122 | port = properties['port']
123 | matrix = states['matrix']
124 | counter = states['counter']
125 |
126 | subject = 'SIP HealthCheck {} on {}'.format(category, name)
127 | link = '{}://{}:{}'.format(transport, destination, port)
128 | message = 'Detect {} {} {}'.format(category, name, link)
129 | content = ('Counter: {} \nMatrix: {}'.format(counter, matrix))
130 |
131 |
132 | for method in methods:
133 | name = method['name']
134 | if name == 'slack':
135 | webhook = method['webhook']
136 | notify2slack(category, webhook, subject, message, content, link)
137 | if name == 'email':
138 | sender = method['sender']
139 | receivers = method['receivers']
140 | mailgun(sender, receivers, subject, message + "\n" + content, states)
141 | if name == 'sms':
142 | sender = method['sender']
143 | receivers = method['receivers']
144 | TwilioService().sms(sender, receivers, subject)
145 | if name == 'call':
146 | caller = method['caller']
147 | callee = method['callee']
148 | # just an example, can create the voice message via xml_content_url when receiving notify call,
149 | # or just a call is enough that you aware that is nofication
150 | xml_content_url = 'http://demo.twilio.com/docs/voice.xml'
151 | TwilioService().voice(caller, callee, xml_content_url)
152 |
153 | def notify2slack(category, webhook, subject, message, content, link):
154 | app_headers = {'User-Agent': 'ClientX', 'content-type': 'application/json'}
155 | response = None
156 | error = None
157 |
158 | try:
159 | data = {"text": message,
160 | "username": "SIPHealthCheck",
161 | "icon_emoji": ":icon:",
162 | "mrkdwn": True,
163 | "attachments": [
164 | {
165 | "color": "#eb0e0e",
166 | "title": subject,
167 | "title_link": link,
168 | "text": content,
169 | "footer": "SIPHealthcheck",
170 | "footer_icon": "https://",
171 | "ts": int(time.time())
172 | }
173 | ]
174 | }
175 | response = requests.post(url=webhook, data=json.dumps(data), headers=app_headers)
176 |
177 | except Exception as e:
178 | error = e
179 | raise Exception(e)
180 |
181 | finally:
182 | return response, error
183 |
184 | def mailgun(sender, recipients, subject, text_email, html_email):
185 | api_url = "mailgun_api"
186 | api_key = "api_key"
187 |
188 | response = None
189 | error = None
190 | try:
191 | response = requests.post(url=api_url,
192 | auth=("api", api_key),
193 | data={
194 | 'subject': subject,
195 | 'from': sender,
196 | 'to': recipients,
197 | 'text': text_email,
198 | 'html': html_email}
199 | )
200 |
201 | except Exception as e:
202 | error = e
203 | raise Exception(e)
204 |
205 | finally:
206 | return response, error
207 |
208 | class TwilioService:
209 | def __init__(self):
210 | account_sid = 'account_sid'
211 | auth_token = 'auth_token'
212 | self.client = Client(account_sid, auth_token)
213 | self.logger = logged('TwilioService')
214 |
215 | def sms(self, sender, receiver, content):
216 | try:
217 | message = self.client.messages.create(from_=sender, to=receiver, body=content)
218 | """
219 | resp = {
220 | 'account_sid': message.account_sid,
221 | 'api_version': message.api_version,
222 | 'body': message.body,
223 | 'date_created': message.date_created,
224 | 'date_updated': message.date_updated,
225 | 'date_sent': message.date_sent,
226 | 'direction': message.direction,
227 | 'error_code': message.error_code,
228 | 'error_message': message.error_message,
229 | 'from_': message.from_,
230 | 'messaging_service_sid': message.messaging_service_sid,
231 | 'num_media': message.num_media,
232 | 'num_segments': message.num_segments,
233 | 'price': message.price,
234 | 'price_unit': message.price_unit,
235 | 'sid': message.sid,
236 | 'status': message.status,
237 | 'subresource_uris': message.subresource_uris,
238 | 'to': message.to,
239 | 'uri': message.uri,
240 | }
241 | """
242 | self.logger.info('TWILIO: {} {}'.format(message.sid, message.status))
243 |
244 | except Exception as e:
245 | self.logger.info("{}".format(e))
246 |
247 | def voice(self, sender, receiver, url):
248 | # https://www.twilio.com/docs/voice/quickstart/python#make-an-outgoing-phone-call-with-python
249 | try:
250 | call = self.client.calls.create(from_=sender, to=receiver, method='GET', url=url)
251 |
252 | """
253 | resp = {
254 | 'account_sid': call.account_sid,
255 | 'annotation': call.annotation,
256 | 'answered_by': call.answered_by,
257 | 'api_version': call.api_version,
258 | 'caller_name': call.caller_name,
259 | 'date_created': call.date_created,
260 | 'date_updated': call.date_updated,
261 | 'direction': call.direction,
262 | 'duration': call.duration,
263 | 'end_time': call.end_time,
264 | 'forwarded_from': call.forwarded_from,
265 | 'from_': call.from_,
266 | 'from_formatted': call.from_formatted,
267 | 'group_sid': call.group_sid,
268 | 'parent_call_sid': call.parent_call_sid,
269 | 'phone_number_sid': call.phone_number_sid,
270 | 'price': call.price,
271 | 'price_unit': call.price_unit,
272 | 'sid': call.sid,
273 | 'start_time': call.start_time,
274 | 'status': call.status,
275 | 'subresource_uris': call.subresource_uris,
276 | 'to': call.to,
277 | 'to_formatted': call.to_formatted,
278 | 'uri': call.uri,
279 | }
280 | """
281 | self.logger.info('TWILIO: {} {}'.format(call.sid, call.status))
282 |
283 | except Exception as e:
284 | self.logger.info("{}".format(e))
285 |
--------------------------------------------------------------------------------