diff --git a/.gitignore b/.gitignore index 1d3ed4c..2fa7ce7 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1 @@ -config.yml +config.ini diff --git a/README.md b/README.md index 7ebb094..6c8c1e8 100644 --- a/README.md +++ b/README.md @@ -13,17 +13,12 @@ This is a script that makes an API connection to OPNsense and checks if there is any pending updates and if there are, it sends a message with details. -Based on the script by Bart J. Smit, 'ObecalpEffect' and Franco Fichtner, forked from https://github.com/bartsmit/opnsense-update-email. - -Based on the script from Bryce Torcello, forked from https://github.com/losuler/opnsense-update-notify. +Idea is based on the script from Bryce Torcello, forked from https://github.com/losuler/opnsense-update-notify. ## TODO -- [ ] Add SMTP AUTH -- [ ] Add SMTP SSL -- [ ] Add SMTP Port - +- TBD ## Setup @@ -47,54 +42,51 @@ It's recommended to create a user with access restricted to the API endpoints re ## Config -The configuration file `config.yml` has three main sections (see `config.yml.example`). The already filled in values in the example config are the defaults. +The configuration file `config.ini` has three main sections (see `config.ini.example`). The already filled in values in the example config are the defaults. ### OPNsense -```yaml -opnsense: - host: - self_signed: true - api_key: - api_secret: +```ini +[opnsense] +url: +self_signed: true +api_key: +api_secret: ``` -`host` is either the ip address or hostname of the OPNsense web interface. +** REQUIRED ** `url` is the full url (https:///api/core/firmware/status) to the OPNsense web api interface. -`self_signed` refers to whether the TLS certificate is self signed or not, it maybe be either `true` or `false`. Since OPNsense creates it's own self signed cert by default, the default for this value is `true`. +** REQUIRED ** `self_signed` refers to whether the TLS certificate is self signed or not, it maybe be either `true` or `false`. Since OPNsense creates it's own self signed cert by default, the default for this value is `true`. -`api_key` and `api_secret` refers to the values provided in step 5 of the [Setup](#setup) section above. +** REQUIRED ** `api_key` and `api_secret` refers to the values provided in step 5 of the [Setup](#setup) section above. ### Emitters -```yaml -emitter: telegram -emitter: email +```ini +[emitter] +emitter: [pushover or telegram] ``` -The `emitter` refers to one of the message services listed in the subsections below (only Telegram and Email for now). +** REQUIRED ** The `emitter` refers to one of the message services listed in the subsections below (only Telegram or Pushover for now, request more via issues). To use more than one supported emitter just create a new line after `emitter:` and indent for each desired emitter to be used. -#### Email +#### Pushover -```yaml -email: - from: - to: - host: +```ini +[pushover] +app_token: +user_token: ``` -`from` is the Email address you want to tag as FROM when notifications are sent. - -`to` is the Email address you want to tag as TO when notifications are sent to be received. +`app_token` is the custom application created for Pushover. -`host` is the SMTP host (TBD add auth and port). +`user_token` is the user token for your Pushover account. #### Telegram -```yaml -telegram: - token: - chatid: +```ini +[telegram] +token: +chatid: ``` `token` is the token for the Telegram bot, which is provided by creating a bot by following the steps provided in the [Telegram bot API documentation](https://core.telegram.org/bots#3-how-do-i-create-a-bot). diff --git a/config.yml.example b/config.yml.example deleted file mode 100644 index ddc3e34..0000000 --- a/config.yml.example +++ /dev/null @@ -1,21 +0,0 @@ ---- - -emitter: email - -logging: - file: /var/log/opnsense-update.log - -email: - from: "firewall@domain.com" - to: "user@domain.com" - host: localhost - -telegram: - token: example - chatid: 1234 - -opnsense: - host: firewall.local - self_signed: true - api_key: insert api key here - api_secret: insert api key here diff --git a/main.py b/main.py index 863838c..01ac301 100755 --- a/main.py +++ b/main.py @@ -1,63 +1,62 @@ #!/usr/bin/env python3 +# vim: set ts=4 sts=4 et sw=4 ft=python: -import json -import sys - -import requests -from requests.packages.urllib3.exceptions import InsecureRequestWarning -import yaml -import yamale - -# SMTP -import smtplib -from email.message import EmailMessage - -# Logging -import logging +# Python Modules from pprint import pprint -from datetime import datetime -# Argument parsing -import argparse - -parser = argparse.ArgumentParser(description="OPNsense firmware notification utility") -parser.add_argument("directory", help="Directory containing the yaml files used by the program") -args = parser.parse_args() - -def valid_conf(schema_file, config_file): - schema_yamale = yamale.make_schema(schema_file) - config_yamale = yamale.make_data(config_file) - - try: - yamale.validate(schema_yamale, config_yamale) - except ValueError as e: - for r in e.results: - for err in r.errors: - logging.error('%s', err) - sys.exit(1) - +import json # Parse JSON from OPNsense +import sys # Arguments and exit calls +import argparse # Options +from configparser import ConfigParser, ExtendedInterpolation # Config Parsing +import requests # Communication +from urllib3 import disable_warnings +from urllib3.exceptions import InsecureRequestWarning +disable_warnings(InsecureRequestWarning) + +# Pretty printing for debugging + +# End of Python Modules + +# Functions +## Function: parse_res +## Description: This will parse the API response from OPNsense def parse_res(resp): - if int(resp['updates']) > 0: - message = 'OPNsense Updates Available\n\n' - message += f"Packages to download: {resp['updates']}\n" - message += f"Download size: {resp['download_size']}\n\n" - + if resp['status'] != "none": + if args.verbose and args.config: + print(f"[INFO] Current Product Version: {resp['product']['product_version']}") + print(f"[INFO] New Product Version: {resp['product']['product_latest']}") + # If current version and new version don't match we must have an upgrade! + if resp['product']['product_version'] != resp['product']['product_latest']: + message = 'OPNsense upgrade available!\n' + message += "Current version: " + resp['product']['product_version'] + " New version: " + resp['product']['product_latest'] + "\n" + # Determine if reboot is required + if resp['needs_reboot'] != "0": + message += "Reboot required for this upgrade\n" + message += resp['status_msg'] + "\n" + # They match, just package updates maybe? + if resp['product']['product_version'] == resp['product']['product_latest']: + message = 'OPNsense updates available!\n' + # Check if there are any new packages to install as part of this update new_pkgs = resp['new_packages'] - + if args.verbose and args.config and len(new_pkgs) > 0: + print(f"[INFO] Number of new packages: {len(new_pkgs)}") if len(new_pkgs) > 0: - message += 'New:\n\n' - + # Looks like we have new packages, let's provide them in the notification + message += 'New:\n' + # Check if we have many or just one, then provide a package name and versioning if type(new_pkgs) == dict: for pkg in new_pkgs: message += f"{new_pkgs[pkg]['name']} {new_pkgs[pkg]['version']}\n" else: for pkg in new_pkgs: message += f"{pkg['name']} {pkg['version']}\n" - + # Check if there are any updated packages to install as part of this update upg_pkgs = resp['upgrade_packages'] - + if args.verbose and args.config and len(upg_pkgs) > 0: + print(f"[INFO] Number of upgraded packages: {len(upg_pkgs)}") if len(upg_pkgs) > 0: - message += 'Upgrade:\n\n' - + # Looks like we have upgraded packages, let's provide them in the notification + message += 'Upgrade:\n' + # Check if we have many or just one, then provide a package name, new version and old version if type(upg_pkgs) == dict: for pkg in upg_pkgs: message += f"{new_pkgs[pkg]['name']} from {new_pkgs[pkg]['current_version']}" + \ @@ -66,99 +65,93 @@ def parse_res(resp): for pkg in upg_pkgs: message += f"{pkg['name']} from {pkg['current_version']}" + \ f" to {pkg['new_version']}\n" - + # Check if there are any packages to reinstall as part of this update reinst_pkgs = resp['reinstall_packages'] - + if args.verbose and args.config and len(reinst_pkgs) > 0: + print(f"[INFO] Number of packages to reinstall: {len(reinst_pkgs)}") if len(reinst_pkgs) > 0: - message += 'Reinstall:\n\n' - + # Looks like we have packages to reinstall, let's provide them in the notification + message += 'Reinstall:\n' + # Check if we have many or just one, then provide a package name and old version if type(reinst_pkgs) == dict: for pkg in reinst_pkgs: message += f"{new_pkgs[pkg]['name']} {new_pkgs[pkg]['version']}\n" else: for pkg in reinst_pkgs: message += f"{pkg['name']} {pkg['version']}\n" - - if resp['upgrade_needs_reboot'] == '1': - message += '\nThis requires a reboot\n' - - if resp['upgrade_major_version'] != '': - try: - message - except NameError: - message = 'OPNsense Major Upgrade Available\n' - else: - message += 'OPNsense Major Upgrade Available\n' - message += f"{resp['upgrade_major_version']} from {resp['product_version']}" - - try: - message - except NameError: - message = None - + # All done here so let's return the notification message return message +## Function: send_telegram +## Description: This will send a notification via Telegram def send_telegram(msg, chatid, token): url = f'https://api.telegram.org/bot{token}/sendMessage?text={msg}&chat_id={chatid}' r = requests.get(url) return r +## Function: send_pushover +## Description: This will send a notification via Pushover +def send_pushover(msg, token, user): + payload = {"message": msg, "user": user, "token": token } + r = requests.post('https://api.pushover.net/1/messages.json', data=payload, headers={'User-Agent': 'Python'}) + return r -requests.packages.urllib3.disable_warnings(InsecureRequestWarning) - -schema_filename = "/schema.yml" -config_filename = "/config.yml" -schema_file = args.directory + schema_filename -config_file = args.directory + config_filename -valid_conf(schema_file, config_file) -with open(config_file) as f: - conf = yaml.safe_load(f) - -# Logging -logging.basicConfig(filename=conf['logging']['logfile'], filemode='a',format='%(asctime)s: %(levelname)s - %(message)s',datefmt='%m/%d/%Y %H:%M:%S',level=logging.INFO) -logging.info('Script execution started') -logging.info('Reading configuration from %s',args.directory) - -host = conf['opnsense']['host'] -# verify is false if self signed -verify = not conf['opnsense']['self_signed'] -api_key = conf['opnsense']['api_key'] -api_secret = conf['opnsense']['api_secret'] - -t_chatid = conf['telegram']['chatid'] -t_token = conf['telegram']['token'] - -smtp_from = conf['email']['from'] -smtp_to = conf['email']['to'] -smtp_host = conf['email']['host'] - -url = 'https://' + host + '/api/core/firmware/status' - -r = requests.get(url,verify=verify,auth=(api_key, api_secret)) +# End of Functions + +# Main +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("-v", "--verbose", action="store_true", help="Increase output verbosity / debug") + parser.add_argument( + "-c", + "--config", + help="Full path location to config.ini configuration file", + type=str, + required=True, + ) + args = parser.parse_args() + if args.verbose and args.config: + print(f"[INFO] Received configuration file path as {args.config}") + requests.packages.urllib3.disable_warnings(InsecureRequestWarning) + # Parse Configuration files + config = ConfigParser(interpolation=ExtendedInterpolation()) + config.read(args.config) + # Setup OPNsense variables based on the configuration + url = config["opnsense"]["url"] + # verify is false if self signed + verify = not config["opnsense"]["self_signed"] + api_key = config["opnsense"]["api_key"] + api_secret = config["opnsense"]["api_secret"] + if args.verbose and args.config: + print(f"[INFO] URL: {config['opnsense']['url']}") + print(f"[INFO] Verify: {config['opnsense']['self_signed']}") + print(f"[INFO] API Key: {config['opnsense']['api_key']}") + print(f"[INFO] API Secret: {config['opnsense']['api_secret']}") + # Request the Update status via API from OPNsense + r = requests.get(url, verify=verify, auth=(api_key, api_secret)) + if r.status_code == 200: + res = json.loads(r.text) + message = parse_res(res) + if args.verbose and args.config: + # Since we craft the message as a long string with new lines, break them for verbose mode + for msg in message.splitlines(): + print(f"[INFO] Message: {msg}") + # Parse the emitters, this supports more than one emitter! + for emit in config['emitter']['emitter'].strip().splitlines(): + if args.verbose and args.config: + print(f"[INFO] Detected Emitter: {emit}") + if emit == "telegram": + if args.verbose and args.config: + print(f"[INFO] Sending Telegram notification") + response = send_telegram(message,config['telegram']['chatid'],config['telegram']['token']) + if args.verbose and args.config: + print(f"[INFO] Telegram response: {response}") + if emit == "pushover": + if args.verbose and args.config: + print(f"[INFO] Sending Pushover notification") + response = send_pushover(message,config['pushover']['app_token'],config['pushover']['user_token']) + if args.verbose and args.config: + print(f"[INFO] Pushover response: {response}") -if r.status_code == 200: - res = json.loads(r.text) - message = parse_res(res) - if message != None: - if conf['emitter'] == "email": - msg = EmailMessage() - msg.set_content(message) - msg['Subject'] = f'OPNsense Updater Notification' - msg['From'] = smtp_from - msg['To'] = smtp_to - s = smtplib.SMTP(smtp_host) - s.send_message(msg) - s.quit() - logging.info('Detected an update or major upgrade available, notification sent via email') - elif conf['emitter'] == "telegram": - send_telegram(message, t_chatid, t_token) - logging.info('Detected an update or major upgrade available, notification sent via telegram') - else: - logging.error('Unknown emitter %s!',conf['emitter']) - else: - logging.info('There are no updates or major upgrades available') - -else: - logging.error('Unknown status code %s', {res.text}) - -logging.info('Script execution finished') + print("[INFO] There are no updates or major upgrades available") +# End of Main diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index 4716834..0000000 --- a/poetry.lock +++ /dev/null @@ -1,77 +0,0 @@ -[[package]] -category = "main" -description = "Python package for providing Mozilla's CA Bundle." -name = "certifi" -optional = false -python-versions = "*" -version = "2020.4.5.1" - -[[package]] -category = "main" -description = "Universal encoding detector for Python 2 and 3" -name = "chardet" -optional = false -python-versions = "*" -version = "3.0.4" - -[[package]] -category = "main" -description = "Internationalized Domain Names in Applications (IDNA)" -name = "idna" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.9" - -[[package]] -category = "main" -description = "YAML parser and emitter for Python" -name = "pyyaml" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "5.3.1" - -[[package]] -category = "main" -description = "Python HTTP for Humans." -name = "requests" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "2.23.0" - -[package.dependencies] -certifi = ">=2017.4.17" -chardet = ">=3.0.2,<4" -idna = ">=2.5,<3" -urllib3 = ">=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26" - -[[package]] -category = "main" -description = "HTTP library with thread-safe connection pooling, file post, and more." -name = "urllib3" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" -version = "1.25.9" - -[[package]] -category = "main" -description = "A schema and validator for YAML." -name = "yamale" -optional = false -python-versions = "*" -version = "2.1.0" - -[package.dependencies] -pyyaml = "*" - -[metadata] -content-hash = "48df29b8b910e5db4d27a2fc08c3058e2d38b83e1a133d7a70f34da9db1a2ad4" -python-versions = "^3.7" - -[metadata.hashes] -certifi = ["1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304", "51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519"] -chardet = ["84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", "fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"] -idna = ["7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", "a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"] -pyyaml = ["06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97", "240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76", "4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2", "69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648", "73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf", "74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f", "7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2", "95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee", "b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d", "cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c", "d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a"] -requests = ["43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee", "b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"] -urllib3 = ["3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527", "88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115"] -yamale = ["11758648080820a7cc3ab1b679cc88e5a37ab1a73c55202341c316eeb5e63274"] diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 9cf1484..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,17 +0,0 @@ -[tool.poetry] -name = "opnsense-update-notify" -version = "1.2.0" -description = "" -authors = ["losuler "] - -[tool.poetry.dependencies] -python = "^3.7" -requests = "^2.23" -pyyaml = "^5.3" -yamale = "^2.1" - -[tool.poetry.dev-dependencies] - -[build-system] -requires = ["poetry>=0.12"] -build-backend = "poetry.masonry.api" diff --git a/schema.yml b/schema.yml deleted file mode 100644 index b551435..0000000 --- a/schema.yml +++ /dev/null @@ -1,21 +0,0 @@ ---- - -emitter: enum('telegram', 'email') - -logging: - logfile: str() - -email: - from: str() - to: str() - host: str() - -telegram: - token: str() - chatid: int() - -opnsense: - host: any(ip(), str()) - self_signed: bool() - api_key: str() - api_secret: str()