├── .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 | ![sip-healthcheck-alert](https://user-images.githubusercontent.com/58973699/71613706-e56dc100-2be2-11ea-9770-d44e69d4cc39.jpg) 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 | --------------------------------------------------------------------------------