[IMP] New version

This commit is contained in:
2026-08-01 23:30:21 +02:00
parent 425080dfec
commit d8a3adb6cf
3 changed files with 388 additions and 42 deletions
+91 -35
View File
@@ -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
View File
@@ -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
View File
@@ -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