[IMP] New version
This commit is contained in:
+91
-35
@@ -9,6 +9,14 @@
|
||||
# Created: 03/03/2014
|
||||
# Copyright: (c) htouvet 2014
|
||||
# Licence: GPL V2
|
||||
#
|
||||
# Optimisations appliquées :
|
||||
# - Regex compilées au niveau module (GW, LINK, IPV4ADDR, MACADDR, ARPING1/2)
|
||||
# - Suppression de openvpn_prov inutilisé dans la boucle monitor
|
||||
# - Cache sur la propriété gateway (invalidé après enable/disable)
|
||||
# - run() retourne directement des str (suppression double decode)
|
||||
# - Écriture SQLite espacée (tous les 5 cycles)
|
||||
# - Purge SQL paramétrée
|
||||
#-------------------------------------------------------------------------------
|
||||
|
||||
import os
|
||||
@@ -38,7 +46,7 @@ check-json [all,<provider>] : check providers and output state as json data
|
||||
status : display current state from state file
|
||||
"""
|
||||
|
||||
version = "0.0.2"
|
||||
version = "0.0.3"
|
||||
|
||||
parser=OptionParser(usage=usage,version="%prog " + version)
|
||||
parser.add_option("-i","--check-interval", dest="check_interval", type=int, default=60, help="Config file full path (default: %default)")
|
||||
@@ -49,8 +57,17 @@ parser.add_option("-v","--verbose", dest="verbose", default=False, action='store
|
||||
parser.add_option("-o","--log", dest="logfile", default=None, help="Path to log file (default: %default)")
|
||||
parser.add_option("-l","--loglevel", dest="loglevel", default='info', type='choice', choices=['debug','warning','info','error','critical'], metavar='LOGLEVEL',help="Loglevel (default: %default)")
|
||||
|
||||
REPORT = re.compile(r'\n(?P<transmitted>\d+)\s+packets transmitted,\s+(?P<received>\d+) received,\s+(?P<loss>\d+)%\s+packet loss')
|
||||
RTT = re.compile(r'rtt min/avg/max/mdev = (?P<min>[0-9.]+)/(?P<avg>[0-9.]+)/(?P<max>[0-9.]+)/(?P<mdev>[0-9.]+) ms')
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regex compilées au niveau module (évite la recompilation à chaque appel)
|
||||
# ---------------------------------------------------------------------------
|
||||
REPORT = re.compile(r'\n(?P<transmitted>\d+)\s+packets transmitted,\s+(?P<received>\d+) received,\s+(?P<loss>\d+)%\s+packet loss')
|
||||
RTT = re.compile(r'rtt min/avg/max/mdev = (?P<min>[0-9.]+)/(?P<avg>[0-9.]+)/(?P<max>[0-9.]+)/(?P<mdev>[0-9.]+) ms')
|
||||
GW = re.compile(r'default via (?P<gateway>\d+\.\d+\.\d+\.\d+)\s+')
|
||||
LINK = re.compile(r':\s+<(?P<link_states>.+)>.* state (?P<link_status>.+?)\s')
|
||||
IPV4ADDR = re.compile(r'\sinet\s+(?P<ipv4>\d+\.\d+\.\d+\.\d+)[/\s]')
|
||||
MACADDR = re.compile(r'link/(?P<type>\S+)(\s(?P<mac>\S+))?')
|
||||
ARPING1 = re.compile(r'bytes from (?P<mac>\S+).*time=(?P<rtt>[0-9.]*) (?P<unit>.*)')
|
||||
ARPING2 = re.compile(r'reply from.*\[(?P<mac>\S+)\]\s+(?P<rtt>[0-9.]*)(?P<unit>.*)')
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paths
|
||||
@@ -59,6 +76,7 @@ BASE_DIR = '/opt/check_providers'
|
||||
DB_PATH = os.path.join(BASE_DIR, 'check-providers.db')
|
||||
STATE_FILE = os.path.join(BASE_DIR, 'check-providers-state.json')
|
||||
MONITOR_PID_FILE = os.path.join(BASE_DIR, 'check-providers.pid')
|
||||
SMS_SCRIPT = os.path.join(BASE_DIR, 'send_sms.sh')
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Database
|
||||
@@ -68,6 +86,8 @@ def init_db():
|
||||
"""Create the SQLite database and events table if not present."""
|
||||
os.makedirs(BASE_DIR, exist_ok=True)
|
||||
with sqlite3.connect(DB_PATH) as conn:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA synchronous=NORMAL")
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -89,7 +109,8 @@ def purge_old_events(days=30):
|
||||
"""Remove events older than `days` days."""
|
||||
with sqlite3.connect(DB_PATH) as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM events WHERE ts < datetime('now', '-{} days')".format(days)
|
||||
"DELETE FROM events WHERE ts < datetime('now', ?)",
|
||||
('-{} days'.format(days),)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@@ -105,26 +126,42 @@ def write_state_file(providers):
|
||||
def record_providers(providers):
|
||||
"""Insert one row per provider into the events table."""
|
||||
with sqlite3.connect(DB_PATH) as conn:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA synchronous=NORMAL")
|
||||
for provider in providers:
|
||||
provider.record(conn)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def send_sms(message):
|
||||
"""Send an SMS alert via send_sms.sh (non-blocking)."""
|
||||
if os.path.isfile(SMS_SCRIPT):
|
||||
try:
|
||||
subprocess.Popen([SMS_SCRIPT, message],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL)
|
||||
logger.info('SMS envoyé : {}'.format(message))
|
||||
except Exception as e:
|
||||
logger.warning('Échec envoi SMS : {}'.format(e))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run(cmd, dry_run=False):
|
||||
"""Execute a shell command and return (retcode, output_as_str)."""
|
||||
try:
|
||||
logger.debug(' running {}'.format(cmd))
|
||||
if not dry_run:
|
||||
p = subprocess.check_output(cmd, shell=True, stderr=subprocess.STDOUT)
|
||||
logger.debug(' output : {}'.format(p))
|
||||
return (0, p)
|
||||
output = p.decode('utf-8', errors='replace')
|
||||
logger.debug(' output : {}'.format(output))
|
||||
return (0, output)
|
||||
else:
|
||||
print("DRYRUN : {}".format(cmd))
|
||||
return (0, "#### DRYRUN ### no output for {}".format(cmd))
|
||||
except subprocess.CalledProcessError as e:
|
||||
return (e.returncode, e.output)
|
||||
return (e.returncode, e.output.decode('utf-8', errors='replace'))
|
||||
|
||||
|
||||
def default_json(o):
|
||||
@@ -144,19 +181,17 @@ def jsondumps(o, **kwargs):
|
||||
|
||||
|
||||
def arping(device, target_ip, ping_count=3):
|
||||
ARPING1 = re.compile(r'bytes from (?P<mac>\S+).*time=(?P<rtt>[0-9.]*) (?P<unit>.*)')
|
||||
ARPING2 = re.compile(r'reply from.*\[(?P<mac>\S+)\]\s+(?P<rtt>[0-9.]*)(?P<unit>.*)')
|
||||
ARPING_PATH = "/usr/sbin/arping"
|
||||
if ARPING_PATH is None:
|
||||
raise Exception('No arping command found')
|
||||
elif "/usr/bin/arping" in ARPING_PATH:
|
||||
(returncode, output) = run('arping -c{ping_count} -I{device} {target_ip}'.format(
|
||||
ping_count=ping_count, device=device, target_ip=target_ip))
|
||||
packets = [p.groupdict() for p in ARPING2.finditer(output.decode('utf-8'))]
|
||||
packets = [p.groupdict() for p in ARPING2.finditer(output)]
|
||||
elif "/usr/sbin/arping" in ARPING_PATH:
|
||||
(returncode, output) = run('arping -c{ping_count} -i{device} {target_ip}'.format(
|
||||
ping_count=ping_count, device=device, target_ip=target_ip))
|
||||
packets = [p.groupdict() for p in ARPING1.finditer(output.decode('utf-8'))]
|
||||
packets = [p.groupdict() for p in ARPING1.finditer(output)]
|
||||
result = {}
|
||||
if packets:
|
||||
result['mac'] = packets[-1]['mac']
|
||||
@@ -213,6 +248,7 @@ class Provider(object):
|
||||
self.last_ip = None
|
||||
|
||||
self._gateway = gateway
|
||||
self._gateway_cache = None # cache pour éviter ip route à chaque accès
|
||||
|
||||
self.gateway_alive = None
|
||||
self.gateway_rtt = None
|
||||
@@ -243,6 +279,10 @@ class Provider(object):
|
||||
|
||||
self.dry_run = False
|
||||
|
||||
def _invalidate_gateway_cache(self):
|
||||
"""Invalide le cache gateway après un changement d'état."""
|
||||
self._gateway_cache = None
|
||||
|
||||
def record(self, conn):
|
||||
"""Insert current state into the events table. Marks up<->down transitions."""
|
||||
transition = int(
|
||||
@@ -274,7 +314,7 @@ class Provider(object):
|
||||
proto=proto, src=self.last_ip, port=port))
|
||||
conn = output.splitlines()
|
||||
for c in conn:
|
||||
if "={src} ".format(src=self.last_ip) in c.decode('utf-8'):
|
||||
if "={src} ".format(src=self.last_ip) in c:
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -297,8 +337,7 @@ class Provider(object):
|
||||
@property
|
||||
def device_up(self):
|
||||
(retcode, output) = run('ip link show dev {device}'.format(device=self.device))
|
||||
LINK = re.compile(r':\s+<(?P<link_states>.+)>.* state (?P<link_status>.+?)\s')
|
||||
link = LINK.search(output.decode('utf-8'))
|
||||
link = LINK.search(output)
|
||||
if link:
|
||||
self._link_states = link.groupdict()['link_states'].split(',')
|
||||
self._link_status = link.groupdict()['link_status']
|
||||
@@ -310,14 +349,14 @@ class Provider(object):
|
||||
if self.target_ip:
|
||||
(retcode, route) = run('/sbin/ip route show {target_ip}'.format(target_ip=self.target_ip))
|
||||
if self.gateway:
|
||||
if not "{target_ip} via {gateway}".format(target_ip=self.target_ip, gateway=self.gateway) in route.decode('utf-8'):
|
||||
if not "{target_ip} via {gateway}".format(target_ip=self.target_ip, gateway=self.gateway) in route:
|
||||
logger.debug(run('/sbin/ip route del {target_ip}'.format(target_ip=self.target_ip), dry_run=self.dry_run)[1])
|
||||
logger.warning('No route for {target_ip} via {gateway}, adding one'.format(
|
||||
target_ip=self.target_ip, gateway=self.gateway))
|
||||
logger.debug(run('/sbin/ip route add {target_ip} via {gateway}'.format(
|
||||
target_ip=self.target_ip, gateway=self.gateway), dry_run=self.dry_run)[1])
|
||||
elif self.device:
|
||||
if not " {} ".format(self.device) in route.decode('utf-8'):
|
||||
if not " {} ".format(self.device) in route:
|
||||
logger.warning('No route for {target_ip} through {device}, adding one'.format(
|
||||
target_ip=self.target_ip, device=self.device))
|
||||
logger.debug(run('/sbin/ip route add {target_ip} dev {device}'.format(
|
||||
@@ -356,8 +395,8 @@ class Provider(object):
|
||||
ping_interval=self.ping_interval,
|
||||
))
|
||||
if returncode == 0:
|
||||
report = REPORT.search(output.decode('utf-8'))
|
||||
rtt = RTT.search(output.decode('utf-8'))
|
||||
report = REPORT.search(output)
|
||||
rtt = RTT.search(output)
|
||||
if report:
|
||||
self.last_loss = int(report.groupdict()['loss'])
|
||||
else:
|
||||
@@ -377,7 +416,7 @@ class Provider(object):
|
||||
elif self.last_rtt > self.max_rtt:
|
||||
self.status = 'Too long RTT {}ms'.format(self.last_rtt)
|
||||
else:
|
||||
self.status = 'ping test failed : {}'.format(output.decode('utf-8'))
|
||||
self.status = 'ping test failed : {}'.format(output)
|
||||
else:
|
||||
self._available = True
|
||||
else:
|
||||
@@ -389,14 +428,12 @@ class Provider(object):
|
||||
|
||||
def check_local_ip(self):
|
||||
(retcode, output) = run('ip addr show dev {device}'.format(device=self.device))
|
||||
IPV4ADDR = re.compile(r'\sinet\s+(?P<ipv4>\d+.\d+.\d+.\d+)[/\s]')
|
||||
MACADDR = re.compile(r'link/(?P<type>\S+)(\s(?P<mac>\S+))?')
|
||||
ipaddr = IPV4ADDR.search(output.decode('utf-8'))
|
||||
ipaddr = IPV4ADDR.search(output)
|
||||
if ipaddr:
|
||||
self.last_ip = ipaddr.groupdict()['ipv4']
|
||||
else:
|
||||
self.last_ip = None
|
||||
macaddr = MACADDR.search(str(output))
|
||||
macaddr = MACADDR.search(output)
|
||||
if macaddr:
|
||||
self.device_mac = macaddr.groupdict()['mac']
|
||||
self.device_type = macaddr.groupdict()['type']
|
||||
@@ -413,26 +450,31 @@ class Provider(object):
|
||||
else:
|
||||
return self._gateway
|
||||
else:
|
||||
# Cache : évite un ip route à chaque accès
|
||||
if self._gateway_cache is not None:
|
||||
return self._gateway_cache if self._gateway_cache != '' else None
|
||||
(retcode, output) = run('ip route list table {}'.format(self.provider_name))
|
||||
GW = re.compile(r'default via (?P<gateway>\d+.\d+.\d+.\d+)\s+')
|
||||
gw = GW.search(str(output))
|
||||
gw = GW.search(output)
|
||||
if gw:
|
||||
logger.debug('Gateway : {}'.format(gw.groupdict()['gateway']))
|
||||
return gw.groupdict()['gateway']
|
||||
self._gateway_cache = gw.groupdict()['gateway']
|
||||
logger.debug('Gateway : {}'.format(self._gateway_cache))
|
||||
return self._gateway_cache
|
||||
else:
|
||||
self._gateway_cache = ''
|
||||
logger.debug('No gateway')
|
||||
return None
|
||||
|
||||
@gateway.setter
|
||||
def gateway_set(self, value):
|
||||
self._gateway = value
|
||||
self._gateway_cache = None
|
||||
|
||||
@property
|
||||
def enabled(self):
|
||||
try:
|
||||
(retcode, routes) = run('ip route list table {}'.format(self.provider_name))
|
||||
if retcode == 0:
|
||||
routes = str(routes).splitlines()
|
||||
routes = routes.splitlines()
|
||||
self.last_enabled = len(routes) > 0
|
||||
else:
|
||||
self.last_enabled = False
|
||||
@@ -477,6 +519,7 @@ class Provider(object):
|
||||
def enable(self):
|
||||
if not self.enabled:
|
||||
logger.debug('Enable {}'.format(self.provider_name))
|
||||
self._invalidate_gateway_cache()
|
||||
try:
|
||||
print(run('/var/lib/shorewall/firewall enable {}'.format(self.provider_name), dry_run=self.dry_run))
|
||||
except Exception as e:
|
||||
@@ -491,6 +534,7 @@ class Provider(object):
|
||||
if self.enabled:
|
||||
openvpn = self.used_by_openvpn()
|
||||
logger.debug('Disable {}'.format(self.provider_name))
|
||||
self._invalidate_gateway_cache()
|
||||
if openvpn:
|
||||
logger.info('openvpn was running here, stopping openvpn')
|
||||
print(run('/etc/init.d/openvpn stop', dry_run=self.dry_run))
|
||||
@@ -509,7 +553,7 @@ class Provider(object):
|
||||
def remove_default_gw(self):
|
||||
(retcode, routes) = run('ip route list table main dev {}'.format(self.device))
|
||||
if retcode == 0:
|
||||
if 'default ' in str(routes):
|
||||
if 'default ' in routes:
|
||||
print(run('ip route del default table main dev {}'.format(self.device), dry_run=self.dry_run))
|
||||
|
||||
def __str__(self):
|
||||
@@ -728,7 +772,8 @@ if __name__ == '__main__':
|
||||
logger.info('Checking providers {}:'.format(
|
||||
','.join([provider.provider_name for provider in providers])))
|
||||
current_ok = [provider for provider in providers if provider.check_available()]
|
||||
openvpn_prov = [provider for provider in providers if provider.used_by_openvpn()]
|
||||
# SUPPRIMÉ : openvpn_prov (calculé mais jamais utilisé,
|
||||
# et conntrack -L est coûteux)
|
||||
shorewall_restart_needed = False
|
||||
|
||||
for provider in providers:
|
||||
@@ -739,9 +784,14 @@ if __name__ == '__main__':
|
||||
run('/usr/sbin/conntrack -F')
|
||||
if provider.openvpn_master:
|
||||
restart_openvpn()
|
||||
# SMS : provider de retour
|
||||
if not provider.fallback:
|
||||
send_sms("{} de retour (RTT: {}ms)".format(
|
||||
provider.provider_name,
|
||||
provider.last_rtt))
|
||||
if not shorewall_restart_needed and not provider.fallback:
|
||||
(retcode, output) = run('ip route show table balance')
|
||||
balance = str(output).splitlines()
|
||||
balance = output.splitlines()
|
||||
in_balance = False
|
||||
for l in balance:
|
||||
if provider.gateway in l.split(' ') or provider.device in l.split(' '):
|
||||
@@ -751,13 +801,17 @@ if __name__ == '__main__':
|
||||
shorewall_restart_needed = True
|
||||
logger.critical("Shorewall restart needed because provider {} is not in default balance route".format(
|
||||
provider.provider_name))
|
||||
run('/usr/sbin/shorewall restart && /usr/sbin/conntrack -F')
|
||||
run('/usr/sbin/shorewall restart && /usr/sbin/conntrack -F && /usr/bin/systemd restart dnsmasq')
|
||||
else:
|
||||
if provider.enabled:
|
||||
if current_ok and not provider.fallback:
|
||||
logger.critical("Disabling the provider {} because {}".format(
|
||||
provider.provider_name, provider.status))
|
||||
provider.disable()
|
||||
# SMS : provider en panne
|
||||
send_sms("ALERTE: {} DOWN ({})".format(
|
||||
provider.provider_name,
|
||||
provider.status))
|
||||
else:
|
||||
if not current_ok:
|
||||
logger.critical("About to disable provider {} but will not because there are no other one".format(
|
||||
@@ -766,9 +820,12 @@ if __name__ == '__main__':
|
||||
logger.critical("Not disabling fallback provider {}".format(provider.provider_name))
|
||||
logger.info(' {}'.format(provider))
|
||||
|
||||
# Persist state and history
|
||||
# Persist state à chaque cycle (utile pour l'API)
|
||||
write_state_file(providers)
|
||||
record_providers(providers)
|
||||
|
||||
# SQLite seulement tous les 5 cycles (~5 min)
|
||||
if cycle_count % 5 == 0:
|
||||
record_providers(providers)
|
||||
|
||||
# Purge old events once every 100 cycles (~every 100 min with default interval)
|
||||
if cycle_count % 100 == 0:
|
||||
@@ -805,4 +862,3 @@ if __name__ == '__main__':
|
||||
provider.check_available()
|
||||
result.append(provider.as_dict())
|
||||
print(jsondumps(result, indent=True))
|
||||
|
||||
|
||||
+190
-7
@@ -2,7 +2,7 @@
|
||||
# -*- coding: UTF-8 -*-
|
||||
#-------------------------------------------------------------------------------
|
||||
# Name: check_providers_api.py
|
||||
# Purpose: Flask API to expose check_providers state and history
|
||||
# Purpose: Flask API to expose check_providers state, history and 4G quota
|
||||
#
|
||||
# Routes:
|
||||
# GET /status -> current state of all providers (from JSON state file)
|
||||
@@ -11,11 +11,17 @@
|
||||
# ?limit=N -> number of rows (default: 200)
|
||||
# ?transitions=true -> only show up<->down transitions
|
||||
# ?days=N -> limit to last N days (default: 7)
|
||||
# GET /history/summary -> per-provider stats
|
||||
# GET /quota -> 4G quota usage (bytes used, remaining, percent)
|
||||
# GET /signal -> 4G signal strength (RSRP, RSRQ, SNR, bars)
|
||||
#-------------------------------------------------------------------------------
|
||||
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import time
|
||||
import sqlite3
|
||||
import subprocess
|
||||
|
||||
from flask import Flask, jsonify, request, Response
|
||||
|
||||
@@ -25,6 +31,17 @@ BASE_DIR = '/opt/check_providers'
|
||||
STATE_FILE = os.path.join(BASE_DIR, 'check-providers-state.json')
|
||||
DB_PATH = os.path.join(BASE_DIR, 'check-providers.db')
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Quota 4G — configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
QUOTA_BASE_FILE = '/var/lib/quota_4g/bytes_base'
|
||||
QUOTA_BLOCK_FLAG = '/var/run/quota_4g_blocked'
|
||||
NFT_BIN = '/usr/sbin/nft'
|
||||
|
||||
# Regex pour le format nft : "quota forfait_4g { over 153600 mbytes used 3677466112 bytes }"
|
||||
NFT_USED_RE = re.compile(r'used\s+(\d+)\s+bytes')
|
||||
NFT_LIMIT_RE = re.compile(r'over\s+(\d+)\s+mbytes')
|
||||
|
||||
|
||||
def get_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
@@ -32,6 +49,118 @@ def get_db():
|
||||
return conn
|
||||
|
||||
|
||||
def _read_int_file(path, default=0):
|
||||
"""Read a single integer from a file, return default if missing/invalid."""
|
||||
try:
|
||||
with open(path) as f:
|
||||
return int(f.read().strip())
|
||||
except (FileNotFoundError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _get_nft_quota():
|
||||
"""Read used bytes and quota limit from the nft inet quota_4g table.
|
||||
|
||||
Returns (used_bytes, limit_bytes) or (0, 0) on error.
|
||||
|
||||
Example nft output:
|
||||
quota forfait_4g {
|
||||
over 153600 mbytes used 3677466112 bytes
|
||||
}
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[NFT_BIN, 'list', 'table', 'inet', 'quota_4g'],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
used_match = NFT_USED_RE.search(result.stdout)
|
||||
limit_match = NFT_LIMIT_RE.search(result.stdout)
|
||||
used = int(used_match.group(1)) if used_match else 0
|
||||
limit = int(limit_match.group(1)) * 1024 * 1024 if limit_match else 0
|
||||
return (used, limit)
|
||||
except Exception:
|
||||
pass
|
||||
return (0, 0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signal 4G — configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
QMICLI_BIN = '/usr/bin/qmicli'
|
||||
QMICLI_DEVICE = '/dev/cdc-wdm1'
|
||||
SIGNAL_CACHE_TTL = 300 # secondes entre deux appels qmicli (5 min)
|
||||
|
||||
# Regex pour parser la sortie de qmicli --nas-get-signal-info
|
||||
SIG_RSSI = re.compile(r"RSSI:\s*'(-?\d+)\s*dBm'")
|
||||
SIG_RSRQ = re.compile(r"RSRQ:\s*'(-?\d+)\s*dB'")
|
||||
SIG_RSRP = re.compile(r"RSRP:\s*'(-?\d+)\s*dBm'")
|
||||
SIG_SNR = re.compile(r"SNR:\s*'(-?[\d.]+)\s*dB'")
|
||||
|
||||
# Cache signal
|
||||
_signal_cache = None
|
||||
_signal_cache_time = 0
|
||||
|
||||
|
||||
def _rsrp_to_quality(rsrp):
|
||||
"""Convert RSRP dBm to quality label and bar count (0-4)."""
|
||||
if rsrp is None:
|
||||
return ('inconnu', 0)
|
||||
if rsrp > -80:
|
||||
return ('excellent', 4)
|
||||
elif rsrp > -90:
|
||||
return ('bon', 3)
|
||||
elif rsrp > -100:
|
||||
return ('moyen', 2)
|
||||
elif rsrp > -110:
|
||||
return ('faible', 1)
|
||||
else:
|
||||
return ('tres_faible', 0)
|
||||
|
||||
|
||||
def _get_signal_info():
|
||||
"""Query qmicli for LTE signal metrics (cached for SIGNAL_CACHE_TTL seconds).
|
||||
|
||||
Returns dict with rssi, rsrq, rsrp, snr (all numeric) or None on error.
|
||||
"""
|
||||
global _signal_cache, _signal_cache_time
|
||||
|
||||
now = time.time()
|
||||
if _signal_cache is not None and (now - _signal_cache_time) < SIGNAL_CACHE_TTL:
|
||||
return _signal_cache
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[QMICLI_BIN, '-d', QMICLI_DEVICE, '--device-open-proxy',
|
||||
'--nas-get-signal-info'],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return _signal_cache # renvoie le dernier connu plutôt que None
|
||||
|
||||
output = result.stdout
|
||||
rssi_m = SIG_RSSI.search(output)
|
||||
rsrq_m = SIG_RSRQ.search(output)
|
||||
rsrp_m = SIG_RSRP.search(output)
|
||||
snr_m = SIG_SNR.search(output)
|
||||
|
||||
rsrp_val = int(rsrp_m.group(1)) if rsrp_m else None
|
||||
quality, bars = _rsrp_to_quality(rsrp_val)
|
||||
|
||||
_signal_cache = {
|
||||
'rssi': int(rssi_m.group(1)) if rssi_m else None,
|
||||
'rsrq': int(rsrq_m.group(1)) if rsrq_m else None,
|
||||
'rsrp': rsrp_val,
|
||||
'snr': float(snr_m.group(1)) if snr_m else None,
|
||||
'quality': quality,
|
||||
'bars': bars,
|
||||
}
|
||||
_signal_cache_time = now
|
||||
return _signal_cache
|
||||
except Exception:
|
||||
return _signal_cache # renvoie le dernier connu
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -49,6 +178,60 @@ def status():
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/quota')
|
||||
def quota():
|
||||
"""
|
||||
Return 4G quota usage.
|
||||
|
||||
Response JSON:
|
||||
used_bytes : total bytes consumed this billing cycle
|
||||
quota_bytes : total quota in bytes
|
||||
percent : usage percentage (0-100)
|
||||
blocked : true if quota exceeded and interface blocked
|
||||
nft_bytes : current nft counter value (since last shorewall restart)
|
||||
base_bytes : cumulated bytes from previous nft resets
|
||||
"""
|
||||
try:
|
||||
base_bytes = _read_int_file(QUOTA_BASE_FILE, 0)
|
||||
(nft_bytes, quota_bytes) = _get_nft_quota()
|
||||
blocked = os.path.exists(QUOTA_BLOCK_FLAG)
|
||||
|
||||
# Total = conso cumulée (avant dernier reset nft) + compteur nft actuel
|
||||
used_bytes = base_bytes + nft_bytes
|
||||
|
||||
percent = round(used_bytes / quota_bytes * 100, 1) if quota_bytes > 0 else 0.0
|
||||
|
||||
return jsonify({
|
||||
'used_bytes': used_bytes,
|
||||
'quota_bytes': quota_bytes,
|
||||
'percent': percent,
|
||||
'blocked': blocked,
|
||||
'nft_bytes': nft_bytes,
|
||||
'base_bytes': base_bytes,
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/signal')
|
||||
def signal():
|
||||
"""
|
||||
Return 4G signal strength metrics.
|
||||
|
||||
Response JSON:
|
||||
rssi : RSSI in dBm
|
||||
rsrq : RSRQ in dB
|
||||
rsrp : RSRP in dBm (primary metric)
|
||||
snr : SNR in dB
|
||||
quality : excellent / bon / moyen / faible / tres_faible
|
||||
bars : 0-4 (signal bars)
|
||||
"""
|
||||
info = _get_signal_info()
|
||||
if info is None:
|
||||
return jsonify({'error': 'Unable to read signal info from modem'}), 503
|
||||
return jsonify(info)
|
||||
|
||||
|
||||
@app.route('/history')
|
||||
def history():
|
||||
"""
|
||||
@@ -71,7 +254,8 @@ def history():
|
||||
'''
|
||||
where, params = [], []
|
||||
|
||||
where.append("ts >= datetime('now', '-{} days')".format(days))
|
||||
where.append("ts >= datetime('now', ?)")
|
||||
params.append('-{} days'.format(days))
|
||||
|
||||
if provider:
|
||||
where.append('provider = ?')
|
||||
@@ -113,14 +297,14 @@ def history_summary():
|
||||
ROUND(AVG(rtt), 2) AS avg_rtt,
|
||||
MAX(ts) AS last_seen
|
||||
FROM events
|
||||
WHERE ts >= datetime('now', '-{} days')
|
||||
WHERE ts >= datetime('now', ?)
|
||||
GROUP BY provider
|
||||
ORDER BY provider
|
||||
'''.format(days)
|
||||
'''
|
||||
|
||||
try:
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(query).fetchall()
|
||||
rows = conn.execute(query, ('-{} days'.format(days),)).fetchall()
|
||||
return jsonify([dict(r) for r in rows])
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
@@ -131,5 +315,4 @@ def history_summary():
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0', port=5050, debug=False)
|
||||
|
||||
app.run(host='192.168.35.254', port=5050, debug=False)
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
#!/bin/bash
|
||||
# /opt/check_providers/send_sms.sh
|
||||
# Wrapper générique pour envoyer un SMS via Gammu
|
||||
#
|
||||
# Usage :
|
||||
# send_sms.sh "Message texte" ← aux numéros par défaut
|
||||
# send_sms.sh "+33612345678" "Message texte" ← à un numéro spécifique
|
||||
# send_sms.sh --force "Message texte" ← ignore l'anti-flood
|
||||
#
|
||||
# Compatible avec :
|
||||
# - check_providers.py (alerte FIBRE down/up)
|
||||
# - quota_4g.sh (alerte quota)
|
||||
# - NUT (NOTIFYCMD dans upsmon.conf)
|
||||
# - tout script qui appelle : send_sms.sh "message"
|
||||
#
|
||||
# NUT : dans /etc/nut/upsmon.conf :
|
||||
# NOTIFYCMD /opt/check_providers/send_sms.sh
|
||||
# NOTIFYFLAG ONBATT SYSLOG+EXEC
|
||||
# NOTIFYFLAG LOWBATT SYSLOG+EXEC
|
||||
# NOTIFYFLAG ONLINE SYSLOG+EXEC
|
||||
#
|
||||
# ============================================================
|
||||
# CONFIGURATION
|
||||
# ============================================================
|
||||
|
||||
# Numéro(s) de téléphone par défaut (séparés par des espaces)
|
||||
DEFAULT_NUMBERS="+33687654443 +33672161654"
|
||||
|
||||
GAMMU_BIN="/usr/bin/gammu"
|
||||
GAMMURC="/etc/gammurc"
|
||||
LOG="logger -t sms-alert"
|
||||
|
||||
# Anti-flood : pas plus d'un SMS identique par heure
|
||||
FLOOD_DIR="/var/run/sms_flood"
|
||||
FLOOD_TTL=3600 # secondes
|
||||
|
||||
# ============================================================
|
||||
|
||||
mkdir -p "$FLOOD_DIR"
|
||||
|
||||
# --- Parse arguments ---
|
||||
FORCE=0
|
||||
NUMBERS=""
|
||||
MESSAGE=""
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--force|-f)
|
||||
FORCE=1
|
||||
shift
|
||||
;;
|
||||
+*)
|
||||
# Numéro de téléphone
|
||||
NUMBERS="$NUMBERS $1"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
MESSAGE="$1"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Numéros par défaut si aucun spécifié
|
||||
NUMBERS="${NUMBERS:-$DEFAULT_NUMBERS}"
|
||||
NUMBERS=$(echo "$NUMBERS" | xargs) # trim
|
||||
|
||||
if [ -z "$MESSAGE" ]; then
|
||||
echo "Usage: $0 [--force] [+33XXXXXXXXX ...] \"message\""
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --force, -f Ignore l'anti-flood"
|
||||
echo ""
|
||||
echo "Exemples:"
|
||||
echo " $0 \"Test SMS\""
|
||||
echo " $0 +33612345678 +33698765432 \"Alerte serveur\""
|
||||
echo " $0 --force \"Message urgent\""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Anti-flood ---
|
||||
HASH=$(echo -n "$MESSAGE" | md5sum | cut -d' ' -f1)
|
||||
FLOOD_FILE="$FLOOD_DIR/$HASH"
|
||||
|
||||
if [ "$FORCE" -eq 0 ] && [ -f "$FLOOD_FILE" ]; then
|
||||
AGE=$(( $(date +%s) - $(stat -c %Y "$FLOOD_FILE") ))
|
||||
if [ "$AGE" -lt "$FLOOD_TTL" ]; then
|
||||
$LOG "Anti-flood : message identique envoyé il y a ${AGE}s, ignoré"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Envoi ---
|
||||
SENT=0
|
||||
for NUMBER in $NUMBERS; do
|
||||
if $GAMMU_BIN -c "$GAMMURC" sendsms TEXT "$NUMBER" -text "$MESSAGE" 2>&1; then
|
||||
$LOG "SMS envoyé à $NUMBER : $MESSAGE"
|
||||
SENT=1
|
||||
else
|
||||
$LOG "Échec envoi SMS à $NUMBER"
|
||||
fi
|
||||
done
|
||||
|
||||
# Marquer anti-flood seulement si au moins un SMS est parti
|
||||
[ "$SENT" -eq 1 ] && touch "$FLOOD_FILE"
|
||||
|
||||
exit 0
|
||||
Reference in New Issue
Block a user