├── .gitignore ├── LICENSE ├── README.md ├── bin └── tmo-monitor.py ├── example.env ├── requirements.txt ├── setup.py ├── tmo-monitor-logo.png └── tmo_monitor ├── __init__.py ├── configuration.py ├── gateway ├── arcadyan.py ├── base.py ├── model.py └── nokia.py └── status.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 highvolt-dev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | tmo-monitor logo 3 |

4 | 5 | # tmo-monitor 6 | 7 | A lightweight, cross-platform Python 3 script that can monitor the T-Mobile Home Internet Nokia, Arcadyan, and Sagecom 5G Gateways for 4G/5G bands, cellular site (tower), and internet connectivity and reboots as needed or on-demand. 8 | 9 | By default, checks for n41 5G signal and connectivity to google.com via ping. 10 | 11 | ## Getting Started 12 | 13 | ### Install dependencies 14 | 15 | `pip3 install .` 16 | 17 | The command will then be available anywhere as `tmo-monitor.py`. 18 | 19 | #### Windows 20 | 21 | 1. On Windows, open the folder where you downloaded the project. 22 | 2. Click `File` > `Open Windows PowerShell` 23 | 3. Run the above `pip3 install .` command. 24 | 4. To use, either: 25 | - Run `cmd.exe` within PowerShell first 26 | - Open `cmd.exe` (Command Prompt) instead of PowerShell 27 | - Run `python bin/tmo-monitor.py` in PowerShell from inside the project directory 28 | 29 | When in doubt, consult this document or run `tmo-monitor.py --help`. 30 | 31 | ## Usage 32 | 33 | ### Command line usage 34 | ``` 35 | usage: tmo-monitor.py [-h] [--connectivity-check {ping,http}] 36 | [-I INTERFACE] 37 | [--http-target HTTP_TARGET] [--status-code {[100,600)}] 38 | [-H PING_HOST] [--ping-count PING_COUNT] [--ping-interval PING_INTERVAL] [-6] 39 | [-R] [-r] 40 | [--skip-bands] [--skip-5g-bands] [--skip-connectivity-check] [--skip-enbid] 41 | [--uptime UPTIME] 42 | [-4 {B2,B4,B5,B12,B13,B25,B26,B41,B46,B48,B66,B71}] [-5 {n41,n71}] 43 | [--enbid ENBID] 44 | [--print-config] 45 | [--logfile LOGFILE] [--log-all] [--log-delta] [--syslog] 46 | [--model {NOK5G21,ARCKVD21,FAST5688W}] 47 | [username] [password] 48 | 49 | Check T-Mobile Home Internet cellular band(s) and connectivity and reboot if necessary 50 | 51 | positional arguments: 52 | username the username (most likely "admin") 53 | password the administrative password (will be requested at runtime if not passed as argument) 54 | 55 | optional arguments: 56 | -h, --help show this help message and exit 57 | --connectivity-check {ping,http} 58 | type of connectivity check to perform (defaults to ping) 59 | -I INTERFACE, --interface INTERFACE 60 | the network interface to use for ping. pass the source IP on Windows 61 | --http-target HTTP_TARGET 62 | the URL to perform a http check against (defaults to https://google.com/generate_204) 63 | --status-code {[100,600)} 64 | expected HTTP status code for http connectivity check (defaults to 204) 65 | -H PING_HOST, --ping-host PING_HOST 66 | the host to ping (defaults to google.com) 67 | --ping-count PING_COUNT 68 | how many ping health checks to perform (defaults to 1) 69 | --ping-interval PING_INTERVAL 70 | how long in seconds to wait between ping health checks (defaults to 10) 71 | -6, --ping-6 use IPv6 ping 72 | -R, --reboot skip health checks and immediately reboot gateway 73 | -r, --skip-reboot skip rebooting gateway 74 | --skip-bands skip check for connected 4g band 75 | --skip-5g-bands skip check for connected 5g band 76 | --skip-connectivity-check, --skip-ping 77 | skip connectivity check 78 | --skip-enbid skip check for connected eNB ID 79 | --uptime UPTIME how long the gateway must be up before considering a reboot (defaults to 90 seconds) 80 | -4 {B2,B4,B5,B12,B13,B25,B26,B41,B46,B48,B66,B71}, --4g-band {B2,B4,B5,B12,B13,B25,B26,B41,B46,B48,B66,B71} 81 | the 4g band(s) to check 82 | -5 {n41,n71}, --5g-band {n41,n71} 83 | the 5g band(s) to check (defaults to n41) 84 | --enbid ENBID check for a connection to a given eNB ID 85 | --print-config output configuration settings 86 | --logfile LOGFILE output file for logging 87 | --log-all always write connection details to logfile 88 | --log-delta write connection details to logfile on change 89 | --syslog log to syslog 90 | --model {NOK5G21,ARCKVD21,FAST5688W} 91 | the gateway model (defaults to NOK5G21) 92 | ``` 93 | 94 | ## Options 95 | 96 | ### Gateway Model 97 | **Gateway Model:** `--model` 98 | 99 | By default, the script will assume the silver-colored Nokia NOK5G21 gateway is being used. 100 | 101 | Valid values are `NOK5G21` for the Nokia gateway, `ARCKVD21` for the square, black-colored Arcadyan gateway without top vent holes, or `FAST5688W` for the square, black-colored Sagecom gateway with top vent holes. 102 | 103 | ### Connectivity check 104 | **Mode:** `--connectivity-check` 105 | Defaults to `ping`. Can instead use a HTTP(S) based health check with the `http` value. The `http` health check defaults to checking `https://google.com/generate_204` and checking its status code. 106 | 107 | **Interface:** `-I --interface` 108 | Can be used to specify the network interface used by the ping command. Useful if T-Mobile Home Internet is not your default network interface: e.g., this is running on a dual WAN router. On Windows, pass the source IP address to use. `http` connectivity checks will be dictated by system routing rules. 109 | 110 | ### HTTP check 111 | **Target:** `--http-target` 112 | Defaults to `https://google.com/generate_204` - both `http` and `https` targets are supported by the `http` value of the `--connectivity-check` flag. 113 | 114 | **Status Code:** `--status-code` 115 | Defaults to `204` for use with `https://google.com/generate_204` - in most common use cases, a `200` status code is expected instead. Expects a numeric value between 100-599 (inclusive). 116 | 117 | ### Ping options 118 | 119 | `ping` checks are the default connectivity check in `tmo-monitor`. It's possible to use HTTP(S)-based checks instead. Refer to the `--connectivity-check` flag. 120 | 121 | **Ping Host:** `-H --ping-host` 122 | Defaults to `google.com` - override if you'd like to ping an alternate host to determine internet connectivity. Must specify a host if flag is provided - you can simply omit the flag if you'd like to use the default google.com ping check. 123 | 124 | **Ping Count:** `--ping-count` 125 | Defaults to `1` - override if you'd like to perform multiple ping checks before rebooting. Short-circuits if a successful ping is encountered. Will reboot if all fail. 126 | 127 | **Ping Interval:** `--ping-interval` 128 | Defaults to `10` seconds - override if you'd like to use a different interval. 129 | 130 | **Ping v6:** `-6 --ping-6` 131 | Use IPv6 ping. 132 | 133 | ### Reboot options 134 | **Reboot:** `-R --reboot` 135 | Skip health checks and immediately reboot gateway. 136 | 137 | **Skip Reboot:** `-r --skip-reboot` 138 | Skip rebooting gateway. 139 | 140 | **Skip Bands:** `--skip-bands` 141 | Skip check for connected 4g band. 142 | 143 | **Skip 5g Bands:** `--skip-5g-bands` 144 | Skip check for connected 5g band. 145 | 146 | **Skip Ping:** `--skip-connectivity-check --skip-ping` 147 | Skip check for successful connectivity check. 148 | 149 | **Uptime Threshold:** `--uptime` 150 | Defaults to 90 seconds - Specify a required uptime for an implicit reboot to occur. Intended to allow sufficient time to establish a connection and stabilize band selection. Setting is used to avoid boot looping, but is not respected when the `--reboot` flag is used. 151 | 152 | ### Connection configuration 153 | **4G Band Checking:** `-4 --4g-band` 154 | Specify a 4G band you expect the gateway to be connected to. Repeat the flag to allow multiple acceptable bands. Case-sensitive. 155 | 156 | **5G Band Checking:** `-5 --5g-band` 157 | Defaults to n41 - Specify a 5G band you expect the gateway to be connected to. Repeat the flag to allow multiple acceptable bands. Case-sensitive. 158 | 159 | **eNB ID:** `--enbid` 160 | Specify the desired cell site you expect the gateway to be connected to. Expects a numeric eNB ID to be provided. [cellmapper.net](https://www.cellmapper.net) is a helpful resource for finding eNB ID values for nearby cell sites. 161 | 162 | ### General settings 163 | 164 | **Logfile:** `--logfile LOGFILE` 165 | Output file for logging. Defaults to `tmo-monitor.log` 166 | 167 | **Log all:** `--log-all` 168 | Always write connection details to logfile. Checks all configuration settings. 169 | 170 | **Log delta:** `--log-delta` 171 | Write connection details to logfile on change of any configuration setting or long ping time. 172 | 173 | ### Default settings 174 | - Username == admin 175 | - Password -> interactive prompt 176 | - 5G band == n41 177 | - Reboot on failure to ping google.com 178 | 179 | ### Environment (`.env`) options 180 | 181 | The script is normally run in batch mode, such as scheduled through a `cron` job. Interactive command-line options are meant to be used as overrides to defaults or environment settings. 182 | 183 | A common usage pattern would be to configure the script using a `.env` file to reboot on 5G band and wifi check. When messing with the settings, a user might want to specify `--skip-reboot`. When a user knows that the reboot is needed they might specify `--reboot` for an immediate reboot. 184 | 185 | - Default settings have the lowest precendence. 186 | - Environment settings--whether in the shell environment or a `.env` file--override the defaults 187 | - Command line options have the highest precedence and override both default settings and environment settings 188 | 189 | 190 | Environment settings are meant to be declarative. They fall into four categories: 191 | 192 | - Login settings (username, password) 193 | - Configuration settings 194 | - Ping settings (target host/interface, number of pings, interval) 195 | - Connection settings (preferred band, eNB ID, etc.) 196 | - Reboot settings: request reboot on any number of failed checks. 197 | - Skip reboot overrides all reboot requests 198 | - Reboot interval overrides all reboot requests 199 | - There is no "reboot immediately" option 200 | - General settings: 201 | - Default output/silent mode _(not yet implemented)_ 202 | - Logging settings 203 | 204 | 205 | ## Exit Status 206 | 207 | tmo-monitor uses the following exit status codes: 208 | 209 | - Clean execution: 0 210 | - `GENERAL_ERROR`: 1 211 | - `CONFIGURATION_ERROR`: 2 212 | - `API_ERROR`: 3 213 | - `REBOOT_PERFORMED`: 4 214 | 215 | 216 | ## Roadmap 217 | 218 | (Not yet implemented): 219 | - Alternate connectivity checks 220 | - systemd service configuration 221 | 222 | ## Tip 223 | Run this script with either a cronjob or as a systemd service to implement periodic recurring T-Mobile Home Internet health checks with automatic rebooting. 224 | -------------------------------------------------------------------------------- /bin/tmo-monitor.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Start by getting signal metrics 3 | import logging 4 | import logging.handlers 5 | import os 6 | import platform 7 | import sys 8 | import tailer 9 | from parse import * 10 | from tmo_monitor.gateway.model import GatewayModel 11 | from tmo_monitor.configuration import Configuration 12 | from tmo_monitor.gateway.arcadyan import CubeController 13 | from tmo_monitor.gateway.nokia import TrashCanController 14 | from tmo_monitor.status import ExitStatus 15 | 16 | # __main__ 17 | if __name__ == "__main__": 18 | 19 | config = Configuration() 20 | if config.general['print_config']: 21 | config.print_config() 22 | # Set up logging for console 23 | root_logger = logging.getLogger() 24 | root_logger.setLevel(logging.DEBUG) 25 | formatter = logging.Formatter('%(asctime)s [%(levelname)s] %(message)s', '%Y/%m/%d %H:%M:%S') 26 | console_logger = logging.StreamHandler() 27 | console_logger.setFormatter(formatter) 28 | root_logger.addHandler(console_logger) 29 | if config.general['logfile']: 30 | file_logger = logging.FileHandler(config.general['logfile']) 31 | file_logger.setLevel(logging.INFO) 32 | file_logger.setFormatter(formatter) 33 | root_logger.addHandler(file_logger) 34 | logging.debug('Enabled file logging to {}'.format(config.general['logfile'])) 35 | if config.general['syslog']: 36 | syslog_handler_opts = {} 37 | syslog_logging_details = '' 38 | if platform.system() != 'Windows': 39 | for syslog_socket in ['/dev/log', '/var/run/syslog']: 40 | if os.path.exists(syslog_socket): 41 | syslog_handler_opts['address'] = syslog_socket 42 | syslog_logging_details = ' via {}'.format(syslog_socket) 43 | break 44 | syslog_logger = logging.handlers.SysLogHandler(**syslog_handler_opts) 45 | syslog_formatter = logging.Formatter('[%(levelname)s] %(message)s') 46 | syslog_logger.setFormatter(syslog_formatter) 47 | syslog_logger.setLevel(logging.INFO) 48 | syslog_logger.ident = 'tmo-monitor[{}]: '.format(os.getpid()) 49 | root_logger.addHandler(syslog_logger) 50 | logging.debug('Enabled syslog logging{}'.format(syslog_logging_details)) 51 | if config.reboot_now: 52 | logging.info('Immediate reboot requested.') 53 | reboot_requested = True 54 | else: 55 | reboot_requested = False 56 | 57 | log_all = False 58 | connection = dict([('4G', ''), ('5G', ''), ('enbid', ''), ('ping', '')]) 59 | if config.general['log_all'] or config.general['log_delta']: 60 | log_all = True 61 | 62 | if config.model == GatewayModel.NOKIA: 63 | gw_control = TrashCanController(config.login['username'], config.login['password']) 64 | # The Arcadyan and Sagecom gateways appear to conform to the same API 65 | elif config.model in [GatewayModel.ARCADYAN, GatewayModel.SAGECOM]: 66 | gw_control = CubeController(config.login['username'], config.login['password']) 67 | else: 68 | raise Exception('Unsupported Gateway Model') 69 | 70 | if not reboot_requested: 71 | 72 | # Check for eNB ID if an eNB ID was supplied & reboot on eNB ID wasn't False in the .env 73 | if config.connection['enbid'] and config.reboot['enbid'] or log_all: 74 | site_meta = gw_control.get_site_info() 75 | connection['enbid'] = site_meta['eNBID'] 76 | if (site_meta['eNBID'] != config.connection['enbid']) and config.reboot['enbid']: 77 | logging.info('Not on eNB ID ' + str(config.connection['enbid']) + ', on ' + str(site_meta['eNBID']) + '.') 78 | reboot_requested = True 79 | else: 80 | print('eNB ID check passed, on ' + str(site_meta['eNBID']) + '.') 81 | 82 | # Check for preferred bands regardless of reboot on band mismatch 83 | if config.reboot['4G_band'] or config.reboot['5G_band'] or log_all: 84 | signal_info = gw_control.get_signal_info() 85 | 86 | if config.connection['primary_band'] or log_all: 87 | primary_band = config.connection['primary_band'] 88 | band_4g = signal_info['4G'] 89 | connection['4G'] = band_4g 90 | if (primary_band and band_4g not in primary_band) and config.reboot['4G_band']: 91 | logging.info('Not on ' + ('one of ' if len(primary_band) > 1 else '') + ', '.join(primary_band) + '.') 92 | if config.reboot['4G_band']: 93 | reboot_requested = True 94 | else: 95 | print('Camping on ' + band_4g + '.') 96 | 97 | # 5G has a default value set (n41) 98 | secondary_band = config.connection['secondary_band'] 99 | band_5g = signal_info['5G'] 100 | connection['5G'] = band_5g 101 | if band_5g not in secondary_band and config.reboot['5G_band']: 102 | logging.info('Not on ' + ('one of ' if len(secondary_band) > 1 else '') + ', '.join(secondary_band) + '.') 103 | if config.reboot['5G_band']: 104 | reboot_requested = True 105 | else: 106 | print('Camping on ' + band_5g + '.') 107 | 108 | # Check for successful ping 109 | ping_ms = gw_control.ping(config.ping['ping_host'], config.ping['ping_count'], config.ping['ping_interval'], config.connectivity['interface'], config.ping['ping_6']) 110 | if log_all: 111 | connection['ping'] = ping_ms 112 | if ping_ms < 0 and config.connectivity['connectivity_check'] == 'ping': 113 | logging.error('Could not ping ' + config.ping['ping_host'] + '.') 114 | if config.reboot['ping']: 115 | reboot_requested = True 116 | 117 | # Check for successful http check 118 | if config.connectivity['connectivity_check'] == 'http': 119 | status_code = gw_control.http_check(config.http['http_target']) 120 | if status_code != config.http['status_code']: 121 | logging.error('Status code failed check for ' + config.http['http_target'] + ' - received status code ' + str(status_code)) 122 | if config.reboot['http']: 123 | reboot_requested = True 124 | 125 | # Reboot if needed 126 | reboot_performed = False 127 | if (reboot_requested or log_all): 128 | connection['uptime'] = gw_control.get_uptime() 129 | if reboot_requested: 130 | if config.skip_reboot: 131 | logging.info('Not rebooting.') 132 | else: 133 | logging.info('Reboot requested.') 134 | 135 | if config.reboot_now or (connection['uptime'] >= config.reboot['uptime']): 136 | logging.info('Rebooting.') 137 | gw_control.reboot() 138 | reboot_performed = True 139 | else: 140 | logging.info('Uptime threshold not met for reboot.') 141 | else: 142 | print('No reboot necessary.') 143 | 144 | if log_all and config.general['log_delta'] and config.general['logfile']: 145 | # Tail the last 10 lines of the file (to account for logged errors) and reverse to detect the newest logline 146 | logline = tailer.tail(open(config.general['logfile']), 10) 147 | logline.reverse() 148 | for line in logline: 149 | if line.__contains__('|'): 150 | print(line) 151 | data = parse("{0} [INFO] 4G: {1} | 5G: {2} | eNB ID: {3} | Avg Ping: {4} ms | Uptime: {5} sec", line) 152 | if data[1] != connection['4G']: 153 | logging.info("4G connection is {0}, was {1}".format(connection['4G'], data[1])) 154 | config.general['log_all'] = True 155 | if data[2] != connection['5G']: 156 | logging.info("5G connection is {0}, was {1}".format(connection['5G'], data[2])) 157 | config.general['log_all'] = True 158 | if int(data[3]) != connection['enbid']: 159 | logging.info("eNB ID is {0}, was {1}".format(connection['enbid'], data[3])) 160 | config.general['log_all'] = True 161 | if int(data[4]) * 3 < connection['ping']: 162 | logging.info("Ping ms {0}, over 3x {1} ms".format(connection['ping'], data[4])) 163 | config.general['log_all'] = True 164 | if int(data[5]) > connection['uptime']: 165 | logging.info("Uptime {0} sec, less than {1} sec".format(connection['uptime'], data[5])) 166 | config.general['log_all'] = True 167 | break 168 | 169 | if log_all and config.general['log_all']: 170 | if config.general['logfile'] == '' and not config.general['syslog']: 171 | logging.error("Logging requested but file or syslog not specified") 172 | else: 173 | msg = "4G: {0} | 5G: {1} | eNB ID: {2} | Avg Ping: {3} ms | Uptime: {4} sec".format( 174 | connection['4G'], connection['5G'], connection['enbid'], connection['ping'], connection['uptime']) 175 | logging.info(msg) 176 | 177 | if reboot_performed: 178 | sys.exit(ExitStatus.REBOOT_PERFORMED.value) 179 | -------------------------------------------------------------------------------- /example.env: -------------------------------------------------------------------------------- 1 | # Rename file to `.env` to use. 2 | # The .env file will be loaded from the current or (recursive) parent directories. 3 | # All settings are prefixed with `tmo_` to allow for common `.env` files at the root. 4 | 5 | # Example: 6 | tmo_username=admin 7 | tmo_password=P4ssw0rd1! 8 | 9 | tmo_ping_count=3 10 | tmo_ping_interval=1 11 | tmo_primary_band=B2,B4 12 | 13 | tmo_5G_band_reboot=False 14 | tmo_ping_reboot=True 15 | 16 | tmo_print_config=True 17 | 18 | # Gateway model 19 | # tmo_model # {'NOK5G21' | 'ARCKVD21' | 'FAST5688W' } The gateway model (defaults to NOK5G21) 20 | 21 | # Trashcan login settings 22 | # tmo_username # defaults to 'admin' 23 | # tmo_password # if not supplied, will be prompted interactively 24 | 25 | # Connectivity check 26 | # tmo_connectivity_check # {'ping' | 'http' } Type of connectivity check to perform (defaults to ping) 27 | # tmo_interface # The network interface to use for ping. Pass the source IP on Windows. http checks use system routing rules. 28 | 29 | # HTTP checks 30 | # tmo_http_target # HTTP connectivity test target (defaults to https://google.com/generate_204) 31 | # tmo_status_code # The expected HTTP status code for HTTP connectivity tests (defaults to 204) 32 | 33 | # Ping configuration 34 | # tmo_ping_host # The host to ping (defaults to 'google.com') 35 | # tmo_ping_count # How many pings to perform before rebooting (defaults to 1) 36 | # tmo_ping_interval # The interval between pings (defaults to 10) 37 | # tmo_ping_6 # {True | False } Use IPv6 ping 38 | 39 | # Connection configuration 40 | # tmo_primary_band # 4G band: comma-separated list from {B2,B4,B5,B12,B13,B25,B26,B41,B46,B48,B66,B71} 41 | # tmo_secondary_band # 5G band: comma-separated list from {n41,n71} 42 | # tmo_enbid # eNB ID 43 | 44 | # Reboot settings: minimum uptime (seconds) & reboot on failed check {True, False} 45 | # Note that these semantics differ from command line arguments! 46 | # tmo_skip_reboot # overrides all other reboot options 47 | # tmo_min_uptime # Minimum uptime to reboot, defaults to 90 seconds 48 | # tmo_ping_reboot # {True | False } Reboot on failed ping 49 | # tmo_http_reboot # {True | False } Reboot on failed http connectivity test. 50 | # tmo_4G_band_reboot # {True | False } Reboot on failed 4G band check 51 | # tmo_5G_band_reboot # {True | False } Reboot on failed 5G band check 52 | # tmo_enbid_reboot # {True | False } Reboot on failed eNB ID check 53 | 54 | # General settings 55 | # tmo_print_config # {True | False } Output configuration to console 56 | # tmo_logfile # Filename for logging output (default: 'tmo-monitor.log') 57 | # tmo_log_all # {True | False } Log all connection statistics 58 | # tmo_log_delta # {True | False } Log any change in connection statistics 59 | # syslog # {True | False } Log to syslog 60 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | certifi==2021.5.30 2 | charset-normalizer==2.0.6 3 | idna==3.2 4 | requests==2.26.0 5 | urllib3==1.26.7 6 | python-dotenv==0.19.2 7 | parse==1.19.0 8 | tailer==0.4.1 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name='tmo_monitor', 5 | version='2.0.0-beta4', 6 | description='A script to monitor the T-Mobile Home Internet 5G Gateways', 7 | long_description='A lightweight, cross-platform Python 3 script that can monitor the T-Mobile Home Internet Arcadyan and Nokia 5G Gateways for 4G/5G bands, cellular site (tower), and internet connectivity and reboots as needed or on-demand.', 8 | url='https://github.com/highvolt-dev/tmo-monitor', 9 | author='highvolt-dev', 10 | license='MIT', 11 | packages=[ 12 | 'tmo_monitor', 13 | 'tmo_monitor.gateway' 14 | ], 15 | scripts=['bin/tmo-monitor.py'], 16 | install_requires=[ 17 | 'parse', 18 | 'python-dotenv', 19 | 'requests', 20 | 'tailer' 21 | ] 22 | ) 23 | -------------------------------------------------------------------------------- /tmo-monitor-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/highvolt-dev/tmo-monitor/dfc0d1aeb5431b9f7f8f84a0728816755cde52a4/tmo-monitor-logo.png -------------------------------------------------------------------------------- /tmo_monitor/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/highvolt-dev/tmo-monitor/dfc0d1aeb5431b9f7f8f84a0728816755cde52a4/tmo_monitor/__init__.py -------------------------------------------------------------------------------- /tmo_monitor/configuration.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import getpass 3 | import logging 4 | import os 5 | import sys 6 | from dotenv import load_dotenv, find_dotenv 7 | from .gateway.model import GatewayModel 8 | from .status import ExitStatus 9 | 10 | class Configuration: 11 | def __init__(self): 12 | # Set default values 13 | self.reboot_now = False 14 | self.skip_reboot = False 15 | self.login = dict([('username', 'admin'), ('password', '')]) 16 | self.connectivity = dict([('connectivity_check', 'ping'), ('interface', '')]) 17 | self.http = dict([('http_target', 'https://google.com/generate_204'), ('status_code', 204)]) 18 | self.ping = dict([('ping_host', 'google.com'), ('ping_count', 1), ('ping_interval', 10), ('ping_6', False)]) 19 | self.connection = dict([('primary_band', None), ('secondary_band', ['n41']), ('enbid', None), ('uptime', '')]) 20 | self.reboot = dict([('uptime', 90), ('ping', True), ('http', True), ('4G_band', True), ('5G_band', True), ('enbid', True)]) 21 | self.general = dict([('print_config', False), ('logfile', ''), ('log_all', False), ('log_delta', False), ('syslog', False)]) 22 | self.model = GatewayModel.NOKIA 23 | 24 | # Command line arguments override defaults & .env file 25 | self.read_environment() 26 | args = self.parse_commandline() 27 | self.parse_arguments(args) 28 | 29 | if self.skip_reboot and self.reboot_now: 30 | logging.error('Incompatible options: --reboot and --skip-reboot') 31 | if sys.stdin and sys.stdin.isatty(): 32 | self.parser.print_help(sys.stderr) 33 | sys.exit(ExitStatus.CONFIGURATION_ERROR.value) 34 | if self.skip_reboot: 35 | for var in {'ping', 'http', '4G_band', '5G_band', 'enbid'}: 36 | self.reboot[var] = False 37 | if not self.login['password']: 38 | self.login['password'] = getpass.getpass('Password: ') 39 | 40 | 41 | def read_environment(self): 42 | try: 43 | envfile=find_dotenv() 44 | load_dotenv(envfile) 45 | except: 46 | logging.debug("No .env file found") 47 | return 48 | for var in {'username', 'password'}: 49 | tmp = os.environ.get('tmo_' + var) 50 | if tmp != None: 51 | self.login[var] = tmp 52 | for var in {'connectivity_check', 'interface'}: 53 | tmp = os.environ.get('tmo_' + var) 54 | if tmp != None: 55 | self.connectivity[var] = tmp 56 | for var in {'http_target', 'status_code'}: 57 | tmp = os.environ.get('tmo_' + var) 58 | if tmp != None: 59 | self.http[var] = tmp 60 | for var in {'ping_host', 'ping_count', 'ping_interval'}: 61 | tmp = os.environ.get('tmo_' + var) 62 | if tmp != None: 63 | self.ping[var] = tmp 64 | tmp = os.environ.get('tmo_ping_6') 65 | if tmp != None: 66 | if tmp.lower() == 'false': 67 | self.ping['ping_6'] = False 68 | else: 69 | self.ping['ping_6'] = True 70 | for var in {'primary_band', 'secondary_band'}: 71 | tmp = os.environ.get('tmo_' + var) 72 | if tmp != None: 73 | splits = tmp.split(',') 74 | self.connection[var] = splits 75 | tmp = os.environ.get('tmo_enbid') 76 | if tmp != None: 77 | self.connection['enbid'] = tmp 78 | tmp = os.environ.get('tmo_min_uptime') 79 | if tmp != None: 80 | self.reboot['uptime'] = tmp 81 | 82 | # Default all reboot options to true, .env file can override to false 83 | for var in {'ping', 'http', '4G_band', '5G_band', 'enbid'}: 84 | tmp = os.environ.get('tmo_' + var + '_reboot') 85 | if tmp != None: 86 | if tmp.lower() == 'false': 87 | self.reboot[var] = False 88 | else: 89 | self.reboot[var] = True 90 | 91 | tmp = os.environ.get('tmo_skip_reboot') 92 | if tmp != None: 93 | if tmp.lower() == 'true': 94 | self.skip_reboot = True 95 | else: 96 | self.skip_reboot = False 97 | tmp = os.environ.get('tmo_logfile') 98 | if tmp != None: 99 | self.general['logfile'] = tmp 100 | for var in {'print_config', 'log_all', 'log_delta', 'syslog'}: 101 | tmp = os.environ.get('tmo_' + var) 102 | if tmp != None: 103 | if tmp.lower() == 'true': 104 | self.general[var] = True 105 | else: 106 | self.general[var] = False 107 | tmp = os.environ.get('tmo_model') 108 | if tmp != None: 109 | self.model = GatewayModel(tmp) 110 | 111 | def parse_commandline(self): 112 | self.parser = argparse.ArgumentParser(description='Check T-Mobile Home Internet cellular band(s) and connectivity and reboot if necessary') 113 | # login settings 114 | self.parser.add_argument('username', type=str, help='the username (most likely "admin")', nargs='?') 115 | self.parser.add_argument('password', type=str, help='the administrative password (will be requested at runtime if not passed as argument)', nargs='?') 116 | # connectivity check 117 | self.parser.add_argument('--connectivity-check', type=str, default=self.connectivity['connectivity_check'], choices=['ping', 'http'], help='type of connectivity check to perform (defaults to ping)') 118 | self.parser.add_argument('-I', '--interface', type=str, help='the network interface to use for connectivity checks. pass the source IP on Windows or http connectivity checks') 119 | # http check 120 | self.parser.add_argument('--http-target', type=str, default=self.http['http_target'], help='the URL to perform a http check against (defaults to https://google.com/generate_204)') 121 | self.parser.add_argument('--status-code', type=int, default=self.http['status_code'], choices=range(100,600), metavar='{[100,600)}', help='expected HTTP status code for http connectivity check (defaults to 204)') 122 | # ping configuration 123 | self.parser.add_argument('-H', '--ping-host', type=str, default=self.ping['ping_host'], help='the host to ping (defaults to google.com)') 124 | self.parser.add_argument('--ping-count', type=int, default=self.ping['ping_count'], help='how many ping health checks to perform (defaults to 1)') 125 | self.parser.add_argument('--ping-interval', type=int, default=self.ping['ping_interval'], help='how long in seconds to wait between ping health checks (defaults to 10)') 126 | self.parser.add_argument('-6', '--ping-6', action='store_true', default=self.ping['ping_6'], help='use IPv6 ping') 127 | # reboot settings 128 | self.parser.add_argument('-R', '--reboot', action='store_true', help='skip health checks and immediately reboot gateway') 129 | self.parser.add_argument('-r', '--skip-reboot', action='store_true', help='skip rebooting gateway') 130 | self.parser.add_argument('--skip-bands', action='store_true', help='skip check for connected 4g band') 131 | self.parser.add_argument('--skip-5g-bands', action='store_true', help='skip check for connected 5g band') 132 | self.parser.add_argument('--skip-connectivity-check', '--skip-ping', action='store_true', help='skip connectivity check') 133 | self.parser.add_argument('--skip-enbid', action='store_true', help='skip check for connected eNB ID') 134 | self.parser.add_argument('--uptime', type=int, default=self.reboot['uptime'], help='how long the gateway must be up before considering a reboot (defaults to 90 seconds)') 135 | # connection configuration 136 | self.parser.add_argument('-4', '--4g-band', type=str, action='append', dest='primary_band', default=None, choices=['B2', 'B4', 'B5', 'B12', 'B13', 'B25', 'B26', 'B41', 'B46', 'B48', 'B66', 'B71'], help='the 4g band(s) to check') 137 | self.parser.add_argument('-5', '--5g-band', type=str, action='append', dest='secondary_band', default=None, choices=['n41', 'n71'], help='the 5g band(s) to check (defaults to n41)') 138 | self.parser.add_argument('--enbid', type=int, default=self.connection['enbid'], help='check for a connection to a given eNB ID') 139 | # general configuration 140 | self.parser.add_argument('--print-config', action='store_true', default=self.general['print_config'], help='output configuration settings') 141 | self.parser.add_argument('--logfile', type=str, default=self.general['logfile'], help='output file for logging') 142 | self.parser.add_argument('--log-all', action='store_true', default=self.general['log_all'], help='always write connection details to logfile') 143 | self.parser.add_argument('--log-delta', action='store_true', default=self.general['log_delta'], help='write connection details to logfile on change') 144 | self.parser.add_argument('--syslog', action='store_true', default=self.general['syslog'], help='log to syslog') 145 | self.parser.add_argument('--model', type=str, default=self.model, choices=[model.value for model in GatewayModel], help='the gateway model (defaults to NOK5G21)') 146 | return self.parser.parse_args() 147 | 148 | def parse_arguments(self, args): 149 | for var in {'username', 'password'}: 150 | tmp = getattr(args, var) 151 | if tmp != None: 152 | self.login[var] = tmp 153 | for var in {'connectivity_check', 'interface'}: 154 | tmp = getattr(args, var) 155 | if tmp != None: 156 | self.connectivity[var] = tmp 157 | for var in {'http_target', 'status_code'}: 158 | tmp = getattr(args, var) 159 | if tmp != None: 160 | self.http[var] = tmp 161 | for var in {'ping_host', 'ping_count', 'ping_interval'}: 162 | tmp = getattr(args, var) 163 | if tmp != None: 164 | self.ping[var] = tmp 165 | if args.ping_6 == True: 166 | self.ping['ping_6'] = True 167 | for var in {'primary_band', 'secondary_band', 'enbid'}: 168 | tmp = getattr(args, var) 169 | if tmp != None: 170 | self.connection[var] = tmp 171 | self.general['logfile'] = args.logfile 172 | for var in {'print_config', 'log_all', 'log_delta', 'syslog'}: 173 | tmp = getattr(args, var) 174 | self.general[var] = tmp 175 | 176 | if args.uptime != None: 177 | self.reboot['uptime'] = args.uptime 178 | 179 | # At this point in the script self.reboot[*] defaults to True unless overridden in .env file 180 | 181 | # Reboot on ping and http check by default, override for args.skip_connectivity_check 182 | if args.skip_connectivity_check == True: 183 | self.reboot['ping'] = False 184 | self.reboot['http'] = False 185 | 186 | # Reboot on primary (4G) band only if one is specified & no overrides 187 | if self.connection['primary_band'] == None or args.skip_bands == True: 188 | self.reboot['4G_band'] = False 189 | 190 | # Secondary band has default (n41). Reboot only if skipped on command line 191 | if args.skip_5g_bands == True: 192 | self.reboot['5G_band'] = False 193 | 194 | # Reboot on enbid only if one is specified & no overrides 195 | if self.connection['enbid'] == None or args.skip_enbid == True: 196 | self.reboot['enbid'] = False 197 | 198 | if args.skip_reboot == True: 199 | self.skip_reboot = True 200 | if args.reboot == True: 201 | self.reboot_now = True 202 | 203 | if args.model is not None: 204 | self.model = GatewayModel(args.model) 205 | 206 | def print_config(self): 207 | print("Script configuration:") 208 | print(" Gateway model: " + self.model.value) 209 | if sys.stdin and sys.stdin.isatty(): 210 | print(" Login info:") 211 | print(" Username: " + self.login.get('username') if self.login.get('username') else '') 212 | print(" Password: " + self.login.get('password') if self.login.get('password') else '') 213 | print(" Connectivity check:") 214 | print(" Mode: " + self.connectivity.get('connectivity_check')) 215 | (print(" Interface: " + self.connectivity.get('interface')) if self.connectivity.get('interface') else '') 216 | print(" Http check:") 217 | (print(" Target: " + self.http.get('http_target')) if self.http.get('http_target') else '') 218 | (print(" Status Code: " + str(self.http.get('status_code'))) if self.http.get('status_code') else '') 219 | print(" Ping configuration:") 220 | (print(" Host: " + self.ping.get('ping_host')) if self.ping.get('ping_host') else '') 221 | (print(" Count: " + str(self.ping.get('ping_count'))) if self.ping.get('ping_count') else '') 222 | (print(" Interval: " + str(self.ping.get('ping_interval'))) if self.ping.get('ping_interval') else '') 223 | print(" Protocol: " + ('IPv6' if self.ping.get('ping_6') else 'IPv4')) 224 | print(" Connection configuration:") 225 | (print(" Primary band: " + str(self.connection.get('primary_band'))) if self.connection.get('primary_band') else '') 226 | (print(" Secondary band: " + str(self.connection.get('secondary_band'))) if self.connection.get('secondary_band') else '') 227 | (print(" eNB ID: " + str(self.connection.get('enbid'))) if self.connection.get('enbid') else '') 228 | print(" Reboot settings:") 229 | print(" Reboot now: " + str(self.reboot_now)) 230 | print(" Skip reboot: " + str(self.skip_reboot)) 231 | (print(" Min uptime: " + str(self.reboot.get('uptime'))) if self.reboot.get('uptime') else '') 232 | print(" Reboot on: " + ("ping " if self.reboot['ping'] else '') + ("4G_band " if self.reboot['4G_band'] else '') 233 | + ("5G_band " if self.reboot['5G_band'] else '') + ("eNB_ID" if self.reboot['enbid'] else '')) 234 | print(" General settings:") 235 | print(" Log file: " + str(self.general['logfile'])) 236 | print(" Log all: " + str(self.general['log_all'])) 237 | print(" Log delta: " + str(self.general['log_delta'])) 238 | print(" Log to syslog: " + str(self.general['syslog'])) 239 | print('') 240 | -------------------------------------------------------------------------------- /tmo_monitor/gateway/arcadyan.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import sys 3 | import logging 4 | import math 5 | from .base import ControllerBase 6 | from ..status import ExitStatus 7 | 8 | class CubeController(ControllerBase): 9 | def __init__(self, username, password): 10 | self.username = username 11 | self.password = password 12 | self.info_web = None 13 | self.app_token = None 14 | # functions using authenticated app API endpoints 15 | def login_app(self): 16 | try: 17 | login_request = requests.post('http://192.168.12.1/TMI/v1/auth/login', json={'username': self.username, 'password': self.password}) 18 | except: 19 | logging.critical("Could not post login request, exiting.") 20 | sys.exit(ExitStatus.API_ERROR.value) 21 | login_request.raise_for_status() 22 | self.app_token = login_request.json()['auth']['token'] 23 | 24 | def get_site_info(self): 25 | try: 26 | if not self.app_token: 27 | self.login_app() 28 | stat_request = requests.get('http://192.168.12.1/TMI/v1/network/telemetry?get=all', headers={'Authorization': 'Bearer ' + self.app_token}) 29 | except: 30 | logging.critical("Could not query site info, exiting.") 31 | sys.exit(ExitStatus.API_ERROR.value) 32 | 33 | stat_request.raise_for_status() 34 | meta = stat_request.json()['cell']['4g'] 35 | 36 | return { 37 | 'eNBID': math.floor(int(meta['ecgi'][6:])/256), 38 | 'PLMN': meta['mcc'] + '-' + meta['mnc'] 39 | } 40 | def reboot(self): 41 | try: 42 | if not self.app_token: 43 | self.login_app() 44 | reboot_request = requests.post('http://192.168.12.1/TMI/v1/gateway/reset?set=reboot', headers={'Authorization': 'Bearer ' + self.app_token}) 45 | except: 46 | logging.critical("Could not post reboot request, exiting.") 47 | sys.exit(ExitStatus.API_ERROR.value) 48 | reboot_request.raise_for_status() 49 | # functions using authenticated web API endpoints 50 | def login_web(self): 51 | raise Exception('Not implemented') 52 | # functions using unauthenticated API endpoints 53 | def get_all_info_web(self): 54 | if self.info_web is not None: 55 | return self.info_web 56 | try: 57 | signal_request = requests.get('http://192.168.12.1/TMI/v1/gateway?get=all') 58 | except: 59 | logging.critical("Could not query signal status, exiting.") 60 | sys.exit(ExitStatus.API_ERROR.value) 61 | signal_request.raise_for_status() 62 | self.info_web = signal_request.json() 63 | return self.info_web 64 | def get_uptime(self): 65 | return self.get_all_info_web()['time']['upTime'] 66 | def get_signal_info(self): 67 | info = self.get_all_info_web() 68 | lte_info = info['signal']['4g']['bands'] 69 | if '5g' in info['signal']: 70 | nr_info = info['signal']['5g']['bands'] 71 | else: 72 | nr_info = [] 73 | 74 | return { 75 | '4G': None if len(lte_info) == 0 else lte_info[0].upper(), 76 | '5G': None if len(nr_info) == 0 else nr_info[0] 77 | } -------------------------------------------------------------------------------- /tmo_monitor/gateway/base.py: -------------------------------------------------------------------------------- 1 | import platform 2 | import requests 3 | import re 4 | import shutil 5 | import subprocess 6 | import time 7 | import sys 8 | 9 | class ControllerBase: 10 | # functions that don't touch the API 11 | def ping(self, ping_host, ping_count, ping_interval, interface = None, ping_6 = False): 12 | is_win = platform.system() == 'Windows' 13 | is_mac = platform.system() == 'Darwin' 14 | 15 | ping_cmd = [] 16 | extra_flags = [] 17 | 18 | # Handle IPv6 support - use ping6 binary or ping -6 flag 19 | ping_bin = 'ping' 20 | if ping_6: 21 | if shutil.which('ping6') is not None: 22 | ping_bin = 'ping6' 23 | else: 24 | extra_flags.append('-6') 25 | # Explicitly use -4 flag for IPv4 except for Mac OS X 26 | elif not is_mac: 27 | extra_flags.append('-4') 28 | 29 | # Add optional interface flag 30 | if interface: 31 | extra_flags.append('-S' if is_win else '-I') 32 | extra_flags.append(interface) 33 | 34 | # Combine base command with extra flags 35 | ping_cmd.append(ping_bin) 36 | ping_cmd = ping_cmd + extra_flags 37 | 38 | # Specify ping count 39 | ping_cmd.append('-n' if is_win else '-c') 40 | ping_cmd.append('1') 41 | ping_cmd.append(ping_host) 42 | 43 | def ping_time(ping_index): 44 | if ping_index > 0: 45 | time.sleep(ping_interval) 46 | ping_exec = subprocess.run(ping_cmd, capture_output=True) 47 | print(ping_exec.stdout.decode('utf-8')) 48 | if ping_exec.returncode != 0: 49 | return -1 50 | if is_win and 'Destination host unreachable' in str(ping_exec.stdout): 51 | return -1 52 | pattern = b'(?:rtt|round-trip) min/avg/max(?:/(?:mdev|stddev))? = \d+.\d+/(\d+.\d+)/\d+.\d+(?:/\d+.\d+)? ms' 53 | if is_win: 54 | pattern = b'Minimum = \d+ms, Maximum = \d+ms, Average = (\d+)ms' 55 | ping_ms = re.search(pattern, ping_exec.stdout) 56 | return round(float(ping_ms.group(1))) 57 | 58 | for i in range (ping_count): 59 | result = ping_time(i) 60 | if result > 0: 61 | return result 62 | return -1 63 | 64 | def http_check(self, target, interface = None): 65 | r = requests.get(target) 66 | return r.status_code -------------------------------------------------------------------------------- /tmo_monitor/gateway/model.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | class GatewayModel(Enum): 4 | NOKIA = 'NOK5G21' 5 | ARCADYAN = 'ARCKVD21' 6 | SAGECOM = 'FAST5688W' -------------------------------------------------------------------------------- /tmo_monitor/gateway/nokia.py: -------------------------------------------------------------------------------- 1 | from .base import ControllerBase 2 | from base64 import b64encode 3 | import hashlib 4 | import logging 5 | import requests 6 | import secrets 7 | import sys 8 | from ..status import ExitStatus 9 | 10 | class TrashCanController(ControllerBase): 11 | def __init__(self, username, password): 12 | self.username = username 13 | self.password = password 14 | self.nonce = None 15 | self.csrf_token = None 16 | self.app_jar = None 17 | self.web_jar = None 18 | self.device_info = None 19 | 20 | # functions using authenticated app API endpoints 21 | def login_app(self): 22 | try: 23 | login_request = requests.post('http://192.168.12.1/login_app.cgi', data={'name': self.username, 'pswd': self.password}) 24 | except: 25 | logging.critical("Could not post login request, exiting.") 26 | sys.exit(ExitStatus.API_ERROR.value) 27 | login_request.raise_for_status() 28 | 29 | self.app_jar = requests.cookies.RequestsCookieJar() 30 | self.app_jar.set('sid', login_request.cookies['sid'], domain='192.168.12.1', path='/') 31 | self.app_jar.set('lsid', login_request.cookies['lsid'], domain='192.168.12.1', path='/') 32 | 33 | def get_site_info(self): 34 | try: 35 | if not self.app_jar: 36 | self.login_app() 37 | stat_request = requests.get('http://192.168.12.1/cell_status_app.cgi', cookies=self.app_jar) 38 | except: 39 | logging.critical("Could not query site info, exiting.") 40 | sys.exit(ExitStatus.API_ERROR.value) 41 | 42 | stat_request.raise_for_status() 43 | meta = stat_request.json()['cell_stat_lte'][0] 44 | 45 | return { 46 | 'eNBID': int(meta['eNBID']), 47 | 'PLMN': meta['MCC'] + '-' + meta['MNC'] 48 | } 49 | 50 | # functions using authenticated web API endpoints 51 | def login_web(self): 52 | try: 53 | nonce_request = requests.get('http://192.168.12.1/login_web_app.cgi?nonce') 54 | except: 55 | logging.critical("Could not query nonce, exiting.") 56 | sys.exit(ExitStatus.API_ERROR.value) 57 | 58 | nonce_request.raise_for_status() 59 | nonce_response = nonce_request.json() 60 | self.nonce = nonce_response['nonce'] 61 | 62 | if self.get_firmware_version() < '1.2103.00.0338': 63 | pass_hash_input = self.password 64 | else: 65 | if nonce_response['iterations'] >= 1: 66 | raise Exception('Password strategy not implemented') 67 | else: 68 | r = self.password 69 | 70 | pass_hash_input = r.lower() 71 | 72 | user_pass_hash = self.sha256(self.username, pass_hash_input) 73 | user_pass_nonce_hash = self.sha256url(user_pass_hash, self.nonce) 74 | login_request_body = { 75 | 'userhash': self.sha256url(self.username, self.nonce), 76 | 'RandomKeyhash': self.sha256url(nonce_response['randomKey'], self.nonce), 77 | 'response': user_pass_nonce_hash, 78 | 'nonce': self.base64url_escape(self.nonce), 79 | 'enckey': self.base64url_escape(b64encode(secrets.token_bytes(16)).decode('utf-8')), 80 | 'enciv': self.base64url_escape(b64encode(secrets.token_bytes(16)).decode('utf-8')) 81 | } 82 | 83 | try: 84 | login_request = requests.post('http://192.168.12.1/login_web_app.cgi', data=login_request_body) 85 | except: 86 | logging.critical("Could not post login request, exiting.") 87 | sys.exit(ExitStatus.API_ERROR.value) 88 | login_request.raise_for_status() 89 | self.web_jar = requests.cookies.RequestsCookieJar() 90 | self.web_jar.set('sid', login_request.cookies['sid'], domain='192.168.12.1', path='/') 91 | if 'lsid' in login_request.cookies: 92 | self.web_jar.set('lsid', login_request.cookies['lsid'], domain='192.168.12.1', path='/') 93 | login_response = login_request.json() 94 | self.csrf_token = login_response['token'] 95 | 96 | def reboot(self): 97 | try: 98 | if not (self.csrf_token or self.web_jar): 99 | self.login_web() 100 | reboot_request = requests.post('http://192.168.12.1/reboot_web_app.cgi', data={'csrf_token': self.csrf_token}, cookies=self.web_jar) 101 | except: 102 | logging.critical("Could not post reboot request, exiting.") 103 | sys.exit(ExitStatus.API_ERROR.value) 104 | reboot_request.raise_for_status() 105 | 106 | # functions using unauthenticated API endpoints 107 | def get_device_info(self): 108 | try: 109 | device_info_req = requests.get('http://192.168.12.1/dashboard_device_info_status_web_app.cgi') 110 | except: 111 | logging.critical("Could not query device info, exiting.") 112 | sys.exit(ExitStatus.API_ERROR.value) 113 | device_info_req.raise_for_status() 114 | return device_info_req.json()['device_app_status'][0] 115 | 116 | def get_firmware_version(self): 117 | try: 118 | if not self.device_info: 119 | self.device_info = self.get_device_info() 120 | except: 121 | logging.critical("Could not query firmware version, exiting.") 122 | sys.exit(ExitStatus.API_ERROR.value) 123 | return self.device_info['SoftwareVersion'] 124 | 125 | def get_uptime(self): 126 | try: 127 | if not self.device_info: 128 | self.device_info = self.get_device_info() 129 | except: 130 | logging.critical("Could not query modem uptime, exiting.") 131 | sys.exit(ExitStatus.API_ERROR.value) 132 | return self.device_info['UpTime'] 133 | 134 | def get_signal_info(self): 135 | try: 136 | signal_request = requests.get('http://192.168.12.1/fastmile_radio_status_web_app.cgi') 137 | except: 138 | logging.critical("Could not query signal status, exiting.") 139 | sys.exit(ExitStatus.API_ERROR.value) 140 | signal_request.raise_for_status() 141 | info = signal_request.json() 142 | 143 | return { 144 | '4G': info['cell_LTE_stats_cfg'][0]['stat']['Band'], 145 | '5G': info['cell_5G_stats_cfg'][0]['stat']['Band'] 146 | } 147 | 148 | # helper functions - maybe move these into their own class and import it later? 149 | def base64url_escape(self, b64): 150 | out = '' 151 | for c in b64: 152 | if c == '+': 153 | out += '-' 154 | elif c == '/': 155 | out += '_' 156 | elif c == '=': 157 | out += '.' 158 | else: 159 | out += c 160 | return out 161 | 162 | def sha256(self, val1, val2): 163 | hash = hashlib.sha256() 164 | hash.update((val1 + ':' + val2).encode()) 165 | return b64encode(hash.digest()).decode('utf-8') 166 | 167 | def sha256url(self, val1, val2): 168 | return self.base64url_escape(self.sha256(val1, val2)) -------------------------------------------------------------------------------- /tmo_monitor/status.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | class ExitStatus(Enum): 4 | GENERAL_ERROR = 1 5 | CONFIGURATION_ERROR = 2 6 | API_ERROR = 3 7 | REBOOT_PERFORMED = 4 8 | --------------------------------------------------------------------------------