├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── README.md ├── circle.yml ├── ecmcli ├── __init__.py ├── api.py ├── commands │ ├── __init__.py │ ├── accounts.py │ ├── activity_log.py │ ├── alerts.py │ ├── apps.py │ ├── authorizations.py │ ├── base.py │ ├── clients.py │ ├── features.py │ ├── firmware.py │ ├── groups.py │ ├── login.py │ ├── logs.py │ ├── messages.py │ ├── netflow.py │ ├── remote.py │ ├── routers.py │ ├── shell.py │ ├── shtools.py │ ├── tos.py │ ├── trace.py │ ├── users.py │ ├── wanrate.py │ └── wifi.py ├── mac.db ├── main.py └── ui.py ├── requirements.txt ├── setup.cfg ├── setup.py └── test ├── __init__.py ├── api_glob.py ├── reboot.py └── remote_config.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # PyInstaller 26 | # Usually these files are written by a python script from a template 27 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 28 | *.manifest 29 | *.spec 30 | 31 | # Installer logs 32 | pip-log.txt 33 | pip-delete-this-directory.txt 34 | 35 | # Unit test / coverage reports 36 | htmlcov/ 37 | .tox/ 38 | .coverage 39 | .cache 40 | nosetests.xml 41 | coverage.xml 42 | 43 | # Translations 44 | *.mo 45 | *.pot 46 | 47 | # Django stuff: 48 | *.log 49 | 50 | # Sphinx documentation 51 | docs/_build/ 52 | 53 | # PyBuilder 54 | target/ 55 | 56 | # Local files 57 | *.swp 58 | .DS_Store 59 | .cscope_db 60 | .eggs 61 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | 4 | ## [9.4] - 2018-01-28 5 | ### Fixed 6 | - KeyError with SSO failed login attempt. 7 | - Python 3.7 support 8 | - Logs command handling of "WARN" and "WARNING" level. 9 | 10 | 11 | ## [9.2] - 2017-04-10 12 | ### Fixed 13 | - Fix `remote` and `logs -f` regression from SSO support. 14 | - Avoid use of aiohttp 2 until we support it. 15 | 16 | 17 | ## [9.1] - 2017-02-25 18 | ### Fixed 19 | - Docstring correction for firmwares command. 20 | 21 | ### Changed 22 | - Use shellish version with case-insensitive search. 23 | - Add **Total Routers** footer to `routers ls`. 24 | 25 | 26 | ## [9] - 2017-02-25 27 | ### Added 28 | - Beta support for SSO login 29 | 30 | 31 | ## [8.1] - 2017-02-23 32 | ### Fixed 33 | - Release fixes 34 | 35 | 36 | ## [8] - 2017-02-23 37 | ### Added 38 | - Activity log 39 | 40 | 41 | ## [7.1] - 2016-11-05 42 | ### Fixed 43 | - Update syndicate requirement for mandatory fix with new aiohttp. 44 | 45 | 46 | ## [7] - 2016-11-03 47 | ### Added 48 | - Router `apps` command. 49 | - `wifi` command for access point survey viewing and instigation. 50 | 51 | ### Fixed 52 | - Honor router args to `logs -f ROUTER...`. 53 | - Updated syndicate lib with aiohttp fixes. 54 | 55 | 56 | ## [6] - 2016-04-29 57 | ### Changed 58 | - Moved `routers-clients` to its own command, `clients`. 59 | 60 | ### Added 61 | - Docker support via jmayfield/ecmcli 62 | - Log follow mode `logs -f` for doing live tail of logs. 63 | - Feature (binding) command. 64 | 65 | 66 | ## [5] - 2015-11-15 67 | ### Changed 68 | - Using syndicate 2 and cellulario for async basis. No more tornado. 69 | 70 | ### Fixed 71 | - Remotely closed HTTP connections due to server infrastructure timeouts no 72 | longer break calls. 73 | 74 | ### Added 75 | - Set support for glob patterns. This follows the bash style braces syntax. 76 | The `users-ls` command for example: 77 | users ls '{Mr,Mrs} Mayfield' 78 | Wildcards in the set patterns are also valid, such as `{foo*,H?m[aA]mmm}`. 79 | 80 | 81 | ## [4] - 2015-11-04 82 | ### Added 83 | - Terms of service command: `tos`. 84 | 85 | ### Changed 86 | - Auth failures now break a command and require you to use the `login` 87 | command to overcome them. This is because shellish 2 uses pagers for 88 | most commands. 89 | - Pagers everywhere. 90 | 91 | 92 | ## [3] - 2015-10-24 93 | ### Added 94 | - Multiple output formats for remote-get command (XML, JSON, CSV, Table) 95 | - Firmware command for status, update check and quick upgrade. 96 | - Configuration subcommands for groups. 97 | - accounts-delete takes N+1 arguments now. 98 | - Messages command for user and system message viewing. 99 | - Authorizations command. Supports viewing and deleting authorizations. 100 | 101 | ### Changed 102 | - Improved `debug_api` command to be more pretty; Also renamed to `trace` 103 | 104 | ### Fixed 105 | - Command groups-edit with `--firmware` uses correct firmware product variant. 106 | - Fix for `BlockingIOError` after using `shell` command. 107 | 108 | 109 | ## [2.4.9] - 2015-10-08 110 | ### Added 111 | - Beta testing `remote` command to serve as replacement for `config` and `gpio`. 112 | 113 | 114 | ## [2.4.0] - 2015-10-02 115 | ### Added 116 | - GPIO Command provided by @zvickery. 117 | 118 | ### Changed 119 | - API connection is now encased in the ctrl-c interrupt verbosity guard. 120 | 121 | ### Fixed 122 | - Router identity argument for reboot command. 123 | 124 | 125 | ## [2.3.0] - 2015-09-23 126 | ### Added 127 | - 'routers clients' command will lookup MAC address hw provider. 128 | - WiFi stats for verbose mode of 'routers clients' command. 129 | - System wide tab completion via 'ecm completion >> ~/.bashrc' 130 | 131 | ### Changed 132 | - Cleaner output for wanrate bps values. 133 | 134 | ### Fixed 135 | - 'timeout' api option used flashleds command is not supported anymore. 136 | 137 | 138 | ## [2.1.0] - 2015-09-09 139 | 140 | ### Changed 141 | - Accounts show command renamed to tree 142 | 143 | ### Added 144 | - Accounts show command is flat table output like others 145 | 146 | 147 | ## [2.0.0] - 2015-09-04 148 | ### Added 149 | - Major refactor to use shellish 150 | - Much improved tab completion 151 | - Much improved layout via shellish.Table 152 | 153 | 154 | ## [0.5.0] - 2015-07-26 155 | ### Changed 156 | - First alpha release 157 | 158 | 159 | [9.4]: https://github.com/mayfield/ecmcli/compare/v9.2...v9.4 160 | [9.2]: https://github.com/mayfield/ecmcli/compare/v9.1...v9.2 161 | [9.1]: https://github.com/mayfield/ecmcli/compare/v9...v9.1 162 | [9]: https://github.com/mayfield/ecmcli/compare/v8.1...v9 163 | [8.1]: https://github.com/mayfield/ecmcli/compare/v8...v8.1 164 | [8]: https://github.com/mayfield/ecmcli/compare/v7.1...v8 165 | [7.1]: https://github.com/mayfield/ecmcli/compare/v7...v7.1 166 | [7]: https://github.com/mayfield/ecmcli/compare/v6...v7 167 | [6]: https://github.com/mayfield/ecmcli/compare/v5...v6 168 | [5]: https://github.com/mayfield/ecmcli/compare/v4...v5 169 | [4]: https://github.com/mayfield/ecmcli/compare/v3...v4 170 | [3]: https://github.com/mayfield/ecmcli/compare/v2.4.9...v3 171 | [2.4.9]: https://github.com/mayfield/ecmcli/compare/v2.4.0...v2.4.9 172 | [2.4.0]: https://github.com/mayfield/ecmcli/compare/v2.3.0...v2.4.0 173 | [2.3.0]: https://github.com/mayfield/ecmcli/compare/v2.1.0...v2.3.0 174 | [2.1.0]: https://github.com/mayfield/ecmcli/compare/v2.0.0...v2.1.0 175 | [2.0.0]: https://github.com/mayfield/ecmcli/compare/v0.5.0...v2.0.0 176 | [0.5.0]: https://github.com/mayfield/ecmcli/compare/eb0a415fae7344860404f92e4264c8c23f4d5cb4...v0.5.0 177 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM jmayfield/shellish 2 | 3 | COPY requirements.txt / 4 | RUN pip install -r /requirements.txt 5 | COPY . /package 6 | RUN cd /package && python ./setup.py install 7 | 8 | ENTRYPOINT ["ecm"] 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Justin Mayfield 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 | 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include CHANGELOG.md 3 | include LICENSE 4 | include ecmcli/mac.db 5 | include requirements.txt 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ecmcli 2 | =========== 3 | 4 | _*CLI for Cradlepoint ECM*_ 5 | 6 | [![Maturity](https://img.shields.io/pypi/status/ecmcli.svg)](https://pypi.python.org/pypi/ecmcli) 7 | [![License](https://img.shields.io/pypi/l/ecmcli.svg)](https://pypi.python.org/pypi/ecmcli) 8 | [![Change Log](https://img.shields.io/badge/change-log-blue.svg)](https://github.com/mayfield/ecmcli/blob/master/CHANGELOG.md) 9 | [![Build Status](https://semaphoreci.com/api/v1/mayfield/ecmcli/branches/master/shields_badge.svg)](https://semaphoreci.com/mayfield/ecmcli) 10 | [![Version](https://img.shields.io/pypi/v/ecmcli.svg)](https://pypi.python.org/pypi/ecmcli) 11 | [![Chat](https://img.shields.io/badge/gitter-chat-FF3399.svg)](https://gitter.im/mayfield/ecmcli?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 12 | 13 | About 14 | -------- 15 | 16 | Installation provides a command line utility (ecm) which can be used to 17 | interact with Cradlepoint's ECM service. Commands are subtasks of the 18 | ECM utility. The full list of subtasks are visible by running 'ecm --help'. 19 | 20 | 21 | Walkthrough Video 22 | -------- 23 | [![Walkthrough Video](http://share.gifyoutube.com/y7nLaZ.gif)](http://www.youtube.com/watch?v=fv4dWL03zPk) 24 | 25 | 26 | Installation 27 | -------- 28 | 29 | python3 ./setup.py build 30 | python3 ./setup.py install 31 | 32 | 33 | Compatibility 34 | -------- 35 | 36 | * Python 3.5+ 37 | 38 | 39 | Example Usage 40 | -------- 41 | 42 | **Viewing Device Logs** 43 | 44 | ```shell 45 | $ ecm logs 46 | ``` 47 | 48 | 49 | **Monitoring WAN Rates** 50 | 51 | ```shell 52 | $ ecm wanrate 53 | Home 2100(24400): [device is offline], Home Router(138927): 68.1 KiB, Home 1400(669): 0 Bytes 54 | Home 2100(24400): [device is offline], Home Router(138927): 43.6 KiB, Home 1400(669): 0 Bytes 55 | Home 2100(24400): [device is offline], Home Router(138927): 40.6 KiB, Home 1400(669): 0 Bytes 56 | Home 2100(24400): [device is offline], Home Router(138927): 49.7 KiB, Home 1400(669): 0 Bytes 57 | ``` 58 | 59 | 60 | **Rebooting a specific router** 61 | 62 | ```shell 63 | $ ecm reboot --routers 669 64 | Rebooting: 65 | Home 1400 (669) 66 | ``` 67 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | services: 3 | - docker 4 | 5 | dependencies: 6 | cache_directories: 7 | - "~/docker" 8 | override: 9 | - | 10 | if [ -d ~/docker ] ; then 11 | echo Using Docker Cache 12 | sudo du -sk ~/docker 13 | sudo mv /var/lib/docker /var/lib/docker.old 14 | else 15 | echo WARNING: No Docker Cache Found 16 | sudo mv /var/lib/docker ~/docker 17 | fi 18 | sudo ln -s ~/docker /var/lib/docker 19 | sudo service docker restart 20 | pre: 21 | - echo NOPE 22 | 23 | test: 24 | override: 25 | - docker build -t ecmcli . 26 | post: 27 | - sudo rm /var/lib/docker 28 | 29 | deployment: 30 | stage: 31 | branch: master 32 | commands: 33 | - > 34 | FOO=$BAR echo Hello $FOO World $BAR 35 | -------------------------------------------------------------------------------- /ecmcli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayfield/ecmcli/1fea2c536108342cf6b6649c0e5ccd13d78417ec/ecmcli/__init__.py -------------------------------------------------------------------------------- /ecmcli/api.py: -------------------------------------------------------------------------------- 1 | """ 2 | Some API handling code. Predominantly this is to centralize common 3 | alterations we make to API calls, such as filtering by router ids. 4 | """ 5 | 6 | import asyncio 7 | import cellulario 8 | import collections 9 | import collections.abc 10 | import fnmatch 11 | import html 12 | import html.parser 13 | import itertools 14 | import logging 15 | import os 16 | import re 17 | import requests 18 | import shellish 19 | import shelve 20 | import shutil 21 | import syndicate 22 | import syndicate.client 23 | import syndicate.data 24 | import warnings 25 | from syndicate.adapters.requests import RequestsPager 26 | 27 | logger = logging.getLogger('ecm.api') 28 | JWT_AUTH_COOKIE = 'cpAuthJwt' 29 | JWT_ACCOUNT_COOKIE = 'cpAccountsJwt' 30 | SESSION_COOKIE = 'accounts_sessionid' 31 | 32 | 33 | class HTMLJSONDecoder(syndicate.data.NormalJSONDecoder): 34 | 35 | def parse_object(self, data): 36 | data = super().parse_object(data) 37 | for key, value in data.items(): 38 | if isinstance(value, str): 39 | data[key] = html.unescape(value) 40 | return data 41 | 42 | 43 | syndicate.data.serializers['htmljson'] = syndicate.data.Serializer( 44 | 'application/json', 45 | syndicate.data.serializers['json'].encode, 46 | HTMLJSONDecoder().decode 47 | ) 48 | 49 | 50 | class AuthFailure(SystemExit): 51 | pass 52 | 53 | 54 | class Unauthorized(AuthFailure): 55 | """ Either the login is bad or the session is expired. """ 56 | pass 57 | 58 | 59 | class TOSRequired(AuthFailure): 60 | """ The terms of service have not been accepted yet. """ 61 | pass 62 | 63 | 64 | class ECMLogin(object): 65 | 66 | login_url = 'https://accounts.cradlepointecm.com/api/internal/v1/users/login' 67 | auth_url = 'https://accounts.cradlepointecm.com/api/internal/v1/users/oidc_authorize' + \ 68 | '?redirect_url=https%3A%2F%2Faccounts.cradlepointecm.com' 69 | 70 | def __init__(self, api): 71 | self._site = api.site 72 | self._session = api.adapter.session 73 | self._login_attempted = None 74 | self.sso = None 75 | 76 | def set_creds(self, username, password): 77 | self.session_mode = False 78 | self._login_attempted = False 79 | self._username = username 80 | self._password = password 81 | 82 | def set_session(self, legacy_id, jwt): 83 | self.session_mode = True 84 | self.initial_legacy_id = legacy_id 85 | self.initial_jwt = jwt 86 | 87 | def reset(self, request): 88 | try: 89 | del request.headers['Cookie'] 90 | except KeyError: 91 | pass 92 | 93 | def __call__(self, request): 94 | if self.session_mode: 95 | if self.initial_jwt: 96 | self.reset(request) 97 | elif not self._login_attempted: 98 | self._login_attempted = True 99 | self.reset(request) 100 | logger.info("Attempting to login with credentials...") 101 | creds = { 102 | "username": self._username, 103 | "password": self._password, 104 | } 105 | auth_req = requests.session() 106 | resp = auth_req.post(self.login_url, json= 107 | { 108 | "data": { 109 | "type": "login", 110 | "attributes": { 111 | "email": creds['username'], 112 | "password": creds['password'], 113 | } 114 | } 115 | }, 116 | headers={"content-type": "application/vnd.api+json"}, 117 | allow_redirects=False) 118 | if not resp.ok: 119 | raise Unauthorized('Invalid Login') 120 | auth_attrs = resp.json()['data']['attributes'] 121 | auth_resp = auth_req.get(self.auth_url + f'&state={auth_attrs["state"]}') 122 | if not auth_resp.ok or JWT_ACCOUNT_COOKIE not in auth_req.cookies: 123 | raise Unauthorized('Invalid Login') 124 | self._session.cookies[JWT_ACCOUNT_COOKIE] = auth_req.cookies[JWT_ACCOUNT_COOKIE] 125 | request.prepare_cookies(self._session.cookies) 126 | return request 127 | 128 | 129 | class AberrantPager(RequestsPager): 130 | """ The time-series resources in ECM have broken paging. limit and offset 131 | mean different things, next is erroneous and total_count is a lie. """ 132 | 133 | def __init__(self, getter, path, kwargs): 134 | self._limit = kwargs.pop('limit') 135 | self._offset = kwargs.pop('offset', 0) 136 | self._done = False 137 | super().__init__(getter, path, kwargs) 138 | 139 | def __len__(self): 140 | """ Count is not supported but we'd like to support truthy tests 141 | still. """ 142 | return 0 if self._done else 1 143 | 144 | def _get_next_page(self): 145 | assert not self._done, 'iterator exhausted' 146 | page = self.getter(*self.path, limit=self._limit, 147 | offset=self._offset, **self.kwargs) 148 | size = len(page) 149 | if not size: 150 | self._done = True 151 | raise StopIteration() 152 | self._offset += size 153 | self._limit += size 154 | return page 155 | 156 | def __next__(self): 157 | if self._done: 158 | raise StopIteration() 159 | if not self.page: 160 | self.page = self._get_next_page() 161 | return self.page.pop(0) 162 | 163 | 164 | class ECMService(shellish.Eventer, syndicate.Service): 165 | 166 | site = 'https://www.cradlepointecm.com' 167 | api_prefix = '/api/v1' 168 | session_file = os.path.expanduser('~/.ecm_session') 169 | globs = { 170 | 'seq': r'\[.*\]', 171 | 'wild': r'[*?]', 172 | 'set': r'\{.*\}' 173 | } 174 | re_glob_matches = re.compile('|'.join('(?P<%s>%s)' % x 175 | for x in globs.items())) 176 | re_glob_sep = re.compile('(%s)' % '|'.join(globs.values())) 177 | default_remote_concurrency = 20 178 | # Resources that don't page correctly. 179 | aberrant_pager_resources = { 180 | 'router_alerts', 181 | 'activity_logs', 182 | } 183 | 184 | def __init__(self, **kwargs): 185 | super().__init__(uri='nope', urn=self.api_prefix, 186 | serializer='htmljson', **kwargs) 187 | if not self.aio: 188 | a = requests.adapters.HTTPAdapter(max_retries=3) 189 | self.adapter.session.mount('https://', a) 190 | self.adapter.session.mount('http://', a) 191 | self.username = None 192 | self.legacy_id = None 193 | self.jwt = None 194 | self.add_events([ 195 | 'start_request', 196 | 'finish_request', 197 | 'reset_auth' 198 | ]) 199 | self.call_count = itertools.count() 200 | 201 | def clone(self, **varations): 202 | """ Produce a cloned instance of ourselves, including state. """ 203 | clone = type(self)(**varations) 204 | copy = ('parent_account', 'legacy_id', 'jwt', 'username', 205 | 'ident', 'uri', '_events', 'call_count') 206 | for x in copy: 207 | value = getattr(self, x) 208 | if hasattr(value, 'copy'): # containers 209 | value = value.copy() 210 | setattr(clone, x, value) 211 | if clone.jwt is not None: 212 | clone.adapter.set_cookie(JWT_ACCOUNT_COOKIE, clone.jwt) 213 | #else: 214 | #clone.adapter.set_cookie(LEGACY_COOKIE, clone.legacy_id) 215 | return clone 216 | 217 | @property 218 | def default_page_size(self): 219 | """ Dynamically change the page size to the screen height. """ 220 | # Underflow the term height by a few rows to give a bit of context 221 | # for each page. For simple cases the output will pause on each 222 | # page and this gives them a bit of old data or header data to look 223 | # at while the next page is being loaded. 224 | page_size = shutil.get_terminal_size()[1] - 4 225 | return max(20, min(100, page_size)) 226 | 227 | def connect(self, site=None, username=None, password=None): 228 | if site: 229 | self.site = site 230 | self.parent_account = None 231 | self.uri = self.site 232 | self.adapter.auth = ECMLogin(self) 233 | if username: 234 | self.login(username, password) 235 | elif not self.load_session(try_last=True): 236 | raise Unauthorized('No valid sessions found') 237 | 238 | def reset_auth(self): 239 | self.fire_event('reset_auth') 240 | #self.adapter.set_cookie(LEGACY_COOKIE, None) 241 | self.adapter.set_cookie(JWT_ACCOUNT_COOKIE, None) 242 | self.save_session(None) 243 | self.ident = None 244 | 245 | def login(self, username=None, password=None): 246 | if not self.load_session(username): 247 | self.set_auth(username, password) 248 | 249 | def set_auth(self, username, password=None, legacy_id=None, jwt=None): 250 | if password is not None: 251 | self.adapter.auth.set_creds(username, password) 252 | elif legacy_id or jwt: 253 | self.adapter.auth.set_session(legacy_id, jwt) 254 | else: 255 | raise TypeError("password or legacy_id required") 256 | self.save_last_username(username) 257 | self.username = username 258 | self.ident = self.get('login') 259 | if not self.ident: 260 | raise Unauthorized('No valid sessions found') 261 | if self.adapter.auth.sso: 262 | self.ident['user']['username'] = self.ident['user']['email'] 263 | 264 | def get_session(self, username=None, use_last=False): 265 | if use_last: 266 | if username is not None: 267 | raise RuntimeError("use_last and username are exclusive") 268 | elif username is None: 269 | raise TypeError("username required unless use_last=True") 270 | with shelve.open(self.session_file) as s: 271 | try: 272 | site = s[self.uri] 273 | if not username: 274 | username = site['last_username'] 275 | return username, site['sessions'][username] 276 | except KeyError: 277 | return None, None 278 | 279 | def save_last_username(self, username): 280 | with shelve.open(self.session_file) as s: 281 | site = s.get(self.uri, {}) 282 | site['last_username'] = username 283 | s[self.uri] = site # Required to persist; see shelve docs. 284 | 285 | def save_session(self, session): 286 | with shelve.open(self.session_file) as s: 287 | site = s.get(self.uri, {}) 288 | sessions = site.setdefault('sessions', {}) 289 | sessions[self.username] = session 290 | s[self.uri] = site # Required to persist; see shelve docs. 291 | 292 | def load_session(self, username=None, try_last=False): 293 | username, session = self.get_session(username, use_last=try_last) 294 | self.legacy_id = session.get('id') if session else None 295 | self.jwt = session.get('jwt') if session else None 296 | if self.legacy_id or self.jwt: 297 | self.set_auth(username, legacy_id=self.legacy_id, jwt=self.jwt) 298 | return True 299 | else: 300 | self.username = None 301 | return False 302 | 303 | def check_session(self): 304 | """ ECM sometimes updates the session token. We make sure we are in 305 | sync. """ 306 | #try: 307 | # legacy_id = self.adapter.get_cookie(LEGACY_COOKIE) 308 | #except KeyError: 309 | # legacy_id = self.legacy_id 310 | try: 311 | jwt = self.adapter.get_cookie(JWT_ACCOUNT_COOKIE) 312 | except KeyError: 313 | jwt = self.jwt 314 | legacy_id = self.legacy_id # XXX 315 | if legacy_id != self.legacy_id or jwt != self.jwt: 316 | logger.info("Updating Session: ID:%s JWT:%s" % (legacy_id, jwt)) 317 | self.save_session({ 318 | "id": legacy_id, 319 | "jwt": jwt 320 | }) 321 | self.legacy_id = legacy_id 322 | self.jwt = jwt 323 | 324 | def finish_do(self, callid, result_func, *args, reraise=True, **kwargs): 325 | try: 326 | result = result_func(*args, **kwargs) 327 | except BaseException as e: 328 | self.fire_event('finish_request', callid, exc=e) 329 | if reraise: 330 | raise e 331 | else: 332 | return 333 | else: 334 | self.fire_event('finish_request', callid, result=result) 335 | return result 336 | 337 | def do(self, *args, **kwargs): 338 | """ Wrap some session and error handling around all API actions. """ 339 | callid = next(self.call_count) 340 | self.fire_event('start_request', callid, args=args, kwargs=kwargs) 341 | if self.aio: 342 | on_fin = lambda f: self.finish_do(callid, f.result, reraise=False) 343 | future = asyncio.ensure_future(self._do(*args, **kwargs)) 344 | future.add_done_callback(on_fin) 345 | return future 346 | else: 347 | return self.finish_do(callid, self._do, *args, **kwargs) 348 | 349 | def _do(self, *args, **kwargs): 350 | if self.parent_account is not None: 351 | kwargs['parentAccount'] = self.parent_account 352 | try: 353 | result = super().do(*args, **kwargs) 354 | except syndicate.client.ResponseError as e: 355 | self.handle_error(e) 356 | result = super().do(*args, **kwargs) 357 | except Unauthorized as e: 358 | self.reset_auth() 359 | raise e 360 | self.check_session() 361 | return result 362 | 363 | def handle_error(self, error): 364 | """ Pretty print error messages and exit. """ 365 | resp = error.response 366 | if resp.get('exception') == 'precondition_failed' and \ 367 | resp['message'] == 'must_accept_tos': 368 | raise TOSRequired('Must accept TOS') 369 | err = resp.get('exception') or resp.get('error_code') 370 | if err in ('login_failure', 'unauthorized'): 371 | self.reset_auth() 372 | raise Unauthorized(err) 373 | if resp.get('message'): 374 | err += '\n%s' % resp['message'].strip() 375 | raise SystemExit("Error: %s" % err) 376 | 377 | def glob_match(self, string, pattern): 378 | """ Add bash style {a,b?,c*c} set matching to fnmatch. """ 379 | sets = [] 380 | for x in self.re_glob_matches.finditer(pattern): 381 | match = x.group('set') 382 | if match is not None: 383 | prefix = pattern[:x.start()] 384 | suffix = pattern[x.end():] 385 | for s in match[1:-1].split(','): 386 | sets.append(prefix + s + suffix) 387 | if not sets: 388 | sets = [pattern] 389 | return any(fnmatch.fnmatchcase(string, x) for x in sets) 390 | 391 | def glob_field(self, field, criteria): 392 | """ Convert the criteria into an API filter and test function to 393 | further refine the fetched results. That is, the glob pattern will 394 | often require client side filtering after doing a more open ended 395 | server filter. The client side test function will only be truthy 396 | when a value is in full compliance. The server filters are simply 397 | to reduce high latency overhead. """ 398 | filters = {} 399 | try: 400 | start, *globs, end = self.re_glob_sep.split(criteria) 401 | except ValueError: 402 | filters['%s__exact' % field] = criteria 403 | else: 404 | if start: 405 | filters['%s__startswith' % field] = start 406 | if end: 407 | filters['%s__endswith' % field] = end 408 | return filters, lambda x: self.glob_match(x.get(field), criteria) 409 | 410 | def get_by(self, selectors, resource, criteria, required=True, **options): 411 | if isinstance(selectors, str): 412 | selectors = [selectors] 413 | for field in selectors: 414 | sfilters, test = self.glob_field(field, criteria) 415 | filters = options.copy() 416 | filters.update(sfilters) 417 | for x in self.get_pager(resource, **filters): 418 | if test is None or test(x): 419 | return x 420 | if required: 421 | raise SystemExit("%s not found: %s" % (resource[:-1].capitalize(), 422 | criteria)) 423 | 424 | def get_by_id_or_name(self, resource, id_or_name, **kwargs): 425 | selectors = ['name'] 426 | if id_or_name.isnumeric(): 427 | selectors.insert(0, 'id') 428 | return self.get_by(selectors, resource, id_or_name, **kwargs) 429 | 430 | def glob_pager(self, *args, **kwargs): 431 | """ Similar to get_pager but use glob filter patterns. If arrays are 432 | given to a filter arg it is converted to the appropriate disjunction 433 | filters. That is, if you ask for field=['foo*', 'bar*'] it will return 434 | entries that start with `foo` OR `bar`. The normal behavior would 435 | produce a paradoxical query saying it had to start with both. """ 436 | exclude = {"expand", "limit", "timeout", "_or", "page_size", "urn", 437 | "data", "callback"} 438 | iterable = lambda x: isinstance(x, collections.abc.Iterable) and \ 439 | not isinstance(x, str) 440 | glob_tests = [] 441 | glob_filters = collections.defaultdict(list) 442 | for fkey, fval in list(kwargs.items()): 443 | if fkey in exclude or '__' in fkey or '.' in fkey: 444 | continue 445 | kwargs.pop(fkey) 446 | fvals = [fval] if not iterable(fval) else fval 447 | gcount = 0 448 | for gval in fvals: 449 | gcount += 1 450 | filters, test = self.glob_field(fkey, gval) 451 | for query, term in filters.items(): 452 | glob_filters[query].append(term) 453 | if test: 454 | glob_tests.append(test) 455 | # Scrub out any exclusive queries that will prevent certain client 456 | # side matches from working. Namely if one pattern can match by 457 | # `startswith`, for example, but others can't we must forgo 458 | # inclusion of this server side filter to prevent stripping out 459 | # potentially valid responses for the other more open-ended globs. 460 | for gkey, gvals in list(glob_filters.items()): 461 | if len(gvals) != gcount: 462 | del glob_filters[gkey] 463 | disjunctions = [] 464 | disjunct = kwargs.pop('_or', None) 465 | if disjunct is not None: 466 | if isinstance(disjunct, collections.abc.Iterable) and \ 467 | not isinstance(disjunct, str): 468 | disjunctions.extend(disjunct) 469 | else: 470 | disjunctions.append(disjunct) 471 | disjunctions.extend('|'.join('%s=%s' % (query, x) for x in terms) 472 | for query, terms in glob_filters.items()) 473 | if disjunctions: 474 | kwargs['_or'] = disjunctions 475 | stream = self.get_pager(*args, **kwargs) 476 | if not glob_tests: 477 | return stream 478 | else: 479 | 480 | def glob_scrub(): 481 | for x in stream: 482 | if any(t(x) for t in glob_tests): 483 | yield x 484 | return glob_scrub() 485 | 486 | def _routers_slice(self, routers, size): 487 | """ Pull a slice of s3 routers out of a generator. """ 488 | while True: 489 | page = list(itertools.islice(routers, size)) 490 | if not page: 491 | return {} 492 | idmap = dict((x['id'], x) for x in page 493 | if x['product']['series'] == 3) 494 | if idmap: 495 | return idmap 496 | 497 | def remote(self, path, **kwargs): 498 | """ Generator for remote data with globing support and smart 499 | paging. """ 500 | if '/' in path: 501 | warnings.warn("Use '.' instead of '/' for path argument.") 502 | path_parts = path.split('.') 503 | server_path = [] 504 | globs = [] 505 | for i, x in enumerate(path_parts): 506 | if self.re_glob_sep.search(x): 507 | globs.extend(path_parts[i:]) 508 | break 509 | else: 510 | server_path.append(x) 511 | 512 | def expand_globs(base, tests, context=server_path): 513 | if not tests: 514 | yield '.'.join(context), base 515 | return 516 | if isinstance(base, dict): 517 | items = base.items() 518 | elif isinstance(base, list): 519 | items = [(str(i), x) for i, x in enumerate(base)] 520 | else: 521 | return 522 | test = tests[0] 523 | for key, val in items: 524 | if self.glob_match(key, test): 525 | if len(tests) == 1: 526 | yield '.'.join(context + [key]), val 527 | else: 528 | yield from expand_globs(val, tests[1:], 529 | context + [key]) 530 | for x in self.fetch_remote(server_path, **kwargs): 531 | if 'data' in x: 532 | x['results'] = [{"path": k, "data": v} 533 | for k, v in expand_globs(x['data'], globs)] 534 | x['_data'] = x['data'] 535 | del x['data'] 536 | else: 537 | x['results'] = [] 538 | yield x 539 | 540 | def fetch_remote(self, path, concurrency=None, timeout=None, **query): 541 | cell = cellulario.IOCell(coord='pool') 542 | if concurrency is None: 543 | concurrency = self.default_remote_concurrency 544 | elif concurrency < 1: 545 | raise ValueError("Concurrency less than 1") 546 | page_concurrency = min(4, concurrency) 547 | page_slice = max(10, round((concurrency / page_concurrency) * 1.20)) 548 | api = self.clone(aio=True, loop=cell.loop, request_timeout=timeout, 549 | connect_timeout=timeout) 550 | 551 | @cell.tier() 552 | async def start(route): 553 | probe = await api.get('routers', limit=1, fields='id', 554 | **query) 555 | for i in range(0, probe.meta['total_count'], page_slice): 556 | await route.emit(i, page_slice) 557 | 558 | @cell.tier(pool_size=page_concurrency) 559 | async def get_page(route, offset, limit): 560 | page = await api.get('routers', expand='product', 561 | offset=offset, limit=limit, **query) 562 | for router in page: 563 | if router['product']['series'] != 3: 564 | continue 565 | await route.emit(router) 566 | 567 | @cell.tier(pool_size=concurrency) 568 | async def get_remote(route, router): 569 | try: 570 | res = (await api.get('remote', *path, id=router['id']))[0] 571 | except Exception as e: 572 | res = { 573 | "success": False, 574 | "exception": type(e).__name__, 575 | "message": str(e), 576 | "id": int(router['id']) 577 | } 578 | res['router'] = router 579 | await route.emit(res) 580 | 581 | @cell.cleaner 582 | async def close(): 583 | await api.close() 584 | return cell 585 | 586 | def get_pager(self, *path, **kwargs): 587 | resource = path[0].split('/', 1)[0] if path else None 588 | if resource in self.aberrant_pager_resources: 589 | assert not self.aio, 'Only sync mode supported for: %s' % \ 590 | resource 591 | page_arg = kwargs.pop('page_size', None) 592 | limit_arg = kwargs.pop('limit', None) 593 | kwargs['limit'] = page_arg or limit_arg or self.default_page_size 594 | return AberrantPager(self.get, path, kwargs) 595 | else: 596 | return super().get_pager(*path, **kwargs) 597 | -------------------------------------------------------------------------------- /ecmcli/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayfield/ecmcli/1fea2c536108342cf6b6649c0e5ccd13d78417ec/ecmcli/commands/__init__.py -------------------------------------------------------------------------------- /ecmcli/commands/accounts.py: -------------------------------------------------------------------------------- 1 | """ 2 | Manage ECM Accounts. 3 | """ 4 | 5 | import collections 6 | import shellish 7 | from . import base 8 | 9 | 10 | class Formatter(object): 11 | 12 | terse_table_fields = ( 13 | (lambda x: x['name'], 'Name'), 14 | (lambda x: x['id'], 'ID'), 15 | (lambda x: len(x['groups']), 'Groups'), 16 | (lambda x: x['customer']['customer_name'], 'Customer'), 17 | (lambda x: x['customer']['contact_name'], 'Contact') 18 | ) 19 | 20 | verbose_table_fields = ( 21 | (lambda x: x['name'], 'Name'), 22 | (lambda x: x['id'], 'ID'), 23 | (lambda x: len(x['groups']), 'Groups'), 24 | (lambda x: x['routers_count'], 'Routers'), 25 | (lambda x: x['user_profiles_count'], 'Users'), 26 | (lambda x: x['subaccounts_count'], 'Subaccounts'), 27 | (lambda x: x['customer']['customer_name'], 'Customer'), 28 | (lambda x: x['customer']['contact_name'], 'Contact') 29 | ) 30 | 31 | expands = [ 32 | 'groups', 33 | 'customer', 34 | ] 35 | 36 | def setup_args(self, parser): 37 | self.add_argument('-v', '--verbose', action='store_true') 38 | self.inject_table_factory() 39 | super().setup_args(parser) 40 | 41 | def prerun(self, args): 42 | self.verbose = args.verbose 43 | if args.verbose: 44 | self.formatter = self.verbose_formatter 45 | self.table_fields = self.verbose_table_fields 46 | else: 47 | self.formatter = self.terse_formatter 48 | self.table_fields = self.terse_table_fields 49 | self.table = self.make_table(headers=[x[1] for x in self.table_fields], 50 | accessors=[self.safe_get(x[0], '') 51 | for x in self.table_fields]) 52 | super().prerun(args) 53 | 54 | def safe_get(self, func, default=None): 55 | def fn(x): 56 | try: 57 | return func(x) 58 | except: 59 | return default 60 | return fn 61 | 62 | def bundle(self, account): 63 | if self.verbose: 64 | counts = ['routers', 'user_profiles', 'subaccounts'] 65 | for x in counts: 66 | n = self.api.get(urn=account[x], count='id')[0]['id_count'] 67 | account['%s_count' % x] = n 68 | account['groups_count'] = len(account['groups']) 69 | return account 70 | 71 | def terse_formatter(self, account): 72 | return '%(name)s (id:%(id)s)' % account 73 | 74 | def verbose_formatter(self, account): 75 | return '%(name)s (id:%(id)s, routers:%(routers_count)d ' \ 76 | 'groups:%(groups_count)d, users:%(user_profiles_count)d, ' \ 77 | 'subaccounts:%(subaccounts_count)d)' % account 78 | 79 | 80 | class Tree(Formatter, base.ECMCommand): 81 | """ Show account Tree """ 82 | 83 | name = 'tree' 84 | 85 | def setup_args(self, parser): 86 | self.add_account_argument(nargs='?') 87 | super().setup_args(parser) 88 | 89 | def run(self, args): 90 | if args.ident: 91 | root_id = self.api.get_by_id_or_name('accounts', args.ident)['id'] 92 | else: 93 | root_id = None 94 | self.show_tree(root_id) 95 | 96 | def show_tree(self, root_id): 97 | """ Huge page size for accounts costs nearly nothing, but api calls 98 | are extremely expensive. The fastest and best way to get accounts and 99 | their descendants is to get massive pages from the root level, which 100 | already include descendants; Build our own tree and do account level 101 | filtering client-side. This theory is proven as of ECM 7-18-2015. """ 102 | expands = ','.join(self.expands) 103 | accounts_pager = self.api.get_pager('accounts', expand=expands, 104 | page_size=10000) 105 | accounts = dict((x['resource_uri'], x) for x in accounts_pager) 106 | root_ref = root = {"node": shellish.TreeNode('root')} 107 | for uri, x in accounts.items(): 108 | parent = accounts.get(x['account'], root) 109 | if 'node' not in parent: 110 | parent['node'] = shellish.TreeNode(parent) 111 | if 'node' not in x: 112 | x['node'] = shellish.TreeNode(x) 113 | parent['node'].children.append(x['node']) 114 | if root_id is not None and x['id'] == root_id: 115 | root_ref = x 116 | if root_ref == root: 117 | root_ref = root['node'].children 118 | else: 119 | root_ref = [root_ref['node']] 120 | formatter = lambda x: self.formatter(self.bundle(x.value)) 121 | t = shellish.Tree(formatter=formatter, 122 | sort_key=lambda x: x.value['id']) 123 | for x in t.render(root_ref): 124 | print(x) 125 | 126 | 127 | class List(Formatter, base.ECMCommand): 128 | """ List accounts. """ 129 | 130 | name = 'ls' 131 | 132 | def setup_args(self, parser): 133 | self.add_account_argument('idents', nargs='*') 134 | super().setup_args(parser) 135 | 136 | def run(self, args): 137 | expands = ','.join(self.expands) 138 | if args.idents: 139 | accounts = [self.api.get_by_id_or_name('accounts', x, 140 | expand=expands) 141 | for x in args.idents] 142 | else: 143 | accounts = self.api.get_pager('accounts', expand=expands) 144 | with self.table as t: 145 | t.print(map(self.bundle, accounts)) 146 | 147 | 148 | class Create(base.ECMCommand): 149 | """ Create account """ 150 | 151 | name = 'create' 152 | 153 | def setup_args(self, parser): 154 | self.add_account_argument('-p', '--parent', 155 | metavar="PARENT_ACCOUNT_ID_OR_NAME") 156 | self.add_argument('name', metavar='NAME') 157 | 158 | def run(self, args): 159 | new_account = { 160 | "name": args.name 161 | } 162 | if args.parent: 163 | account = self.api.get_by_id_or_name('accounts', args.parent) 164 | if not account: 165 | raise SystemExit("Account not found: %s" % args.parent) 166 | new_account['account'] = account['resource_uri'] 167 | self.api.post('accounts', new_account) 168 | 169 | 170 | class Remove(base.ECMCommand): 171 | """ Remove an account """ 172 | 173 | name = 'rm' 174 | use_pager = False 175 | 176 | def setup_args(self, parser): 177 | self.add_account_argument('idents', nargs='+') 178 | self.add_argument('-f', '--force', action='store_true', 179 | help='Do not prompt for confirmation') 180 | self.add_argument('-r', '--recursive', action='store_true', 181 | help='Remove all subordinate resources too.') 182 | 183 | def run(self, args): 184 | for x in args.idents: 185 | account = self.api.get_by_id_or_name('accounts', x) 186 | if args.recursive: 187 | resources = self.get_subordinates(account) 188 | else: 189 | resources = {} 190 | if not args.force: 191 | if resources: 192 | r = resources 193 | self.confirm('Confirm removal of "%s" along with %d ' 194 | 'subaccounts, %d groups, %d routers and %d ' 195 | 'users' % 196 | (account['name'], len(r['subaccounts']), 197 | len(r['groups']), len(r['routers']), 198 | len(r['users']))) 199 | else: 200 | self.confirm('Confirm account removal: %s (%s)' % ( 201 | account['name'], account['id'])) 202 | if resources: 203 | for res in ('users', 'routers', 'groups', 'subaccounts'): 204 | for x in resources[res]: 205 | self.api.delete(urn=x) 206 | self.api.delete('accounts', account['id']) 207 | 208 | def get_subordinates(self, account): 209 | """ Recursively look for resources underneath this account. """ 210 | resources = collections.defaultdict(list) 211 | for x in self.api.get_pager(urn=account['subaccounts']): 212 | for res, items in self.get_subordinates(x).items(): 213 | resources[res].extend(items) 214 | resources['subaccounts'].append(x['resource_uri']) 215 | for x in self.api.get_pager(urn=account['groups']): 216 | resources['groups'].append(x['resource_uri']) 217 | for x in self.api.get_pager(urn=account['routers']): 218 | resources['routers'].append(x['resource_uri']) 219 | for x in self.api.get_pager(urn=account['user_profiles']): 220 | resources['users'].append(x['user']) 221 | return resources 222 | 223 | 224 | class Move(base.ECMCommand): 225 | """ Move account to new parent account """ 226 | 227 | name = 'mv' 228 | 229 | def setup_args(self, parser): 230 | self.add_account_argument() 231 | self.add_account_argument('new_parent', 232 | metavar='NEW_PARENT_ID_OR_NAME') 233 | 234 | def run(self, args): 235 | account = self.api.get_by_id_or_name('accounts', args.ident) 236 | new_parent = self.api.get_by_id_or_name('accounts', args.new_parent) 237 | self.api.put('accounts', account['id'], 238 | {"account": new_parent['resource_uri']}) 239 | 240 | 241 | class Rename(base.ECMCommand): 242 | """ Rename an account """ 243 | 244 | name = 'rename' 245 | 246 | def setup_args(self, parser): 247 | self.add_account_argument() 248 | self.add_argument('new_name', metavar='NEW_NAME') 249 | 250 | def run(self, args): 251 | account = self.api.get_by_id_or_name('accounts', args.ident) 252 | self.api.put('accounts', account['id'], {"name": args.new_name}) 253 | 254 | 255 | class Search(Formatter, base.ECMCommand): 256 | """ Search for account(s) """ 257 | 258 | name = 'search' 259 | 260 | def setup_args(self, parser): 261 | expands = ','.join(self.expands) 262 | searcher = self.make_searcher('accounts', ['name'], expand=expands) 263 | self.lookup = searcher.lookup 264 | self.add_search_argument(searcher) 265 | super().setup_args(parser) 266 | 267 | def run(self, args): 268 | results = self.lookup(args.search) 269 | if not results: 270 | raise SystemExit("No results for: %s" % ' '.join(args.search)) 271 | with self.table as t: 272 | t.print(map(self.bundle, results)) 273 | 274 | 275 | class Accounts(base.ECMCommand): 276 | """ Manage ECM Accounts. """ 277 | 278 | name = 'accounts' 279 | 280 | def __init__(self, *args, **kwargs): 281 | super().__init__(*args, **kwargs) 282 | self.add_subcommand(List, default=True) 283 | self.add_subcommand(Tree) 284 | self.add_subcommand(Create) 285 | self.add_subcommand(Remove) 286 | self.add_subcommand(Move) 287 | self.add_subcommand(Rename) 288 | self.add_subcommand(Search) 289 | 290 | command_classes = [Accounts] 291 | -------------------------------------------------------------------------------- /ecmcli/commands/activity_log.py: -------------------------------------------------------------------------------- 1 | """ 2 | Activity Log 3 | """ 4 | 5 | import collections 6 | import shellish 7 | from . import base 8 | from .. import ui 9 | 10 | 11 | actor_types = { 12 | 1: 'system', 13 | 2: 'user', 14 | 3: 'api_key', 15 | 4: 'router', 16 | } 17 | 18 | 19 | activity_types = { 20 | 1: "created", 21 | 2: "deleted", 22 | 3: "updated", 23 | 4: "requested", 24 | 5: "reported", 25 | 6: "logged in", 26 | 7: "logged out", 27 | 8: "registered", 28 | 9: "unregistered", 29 | 10: "activated", 30 | } 31 | 32 | 33 | object_types = { 34 | 1: "account", 35 | 2: "user", 36 | 3: "group", 37 | 4: "router", 38 | 5: "schedule", 39 | # 6 deprecated 40 | 7: "task", 41 | 8: "api_key", 42 | 9: "net_device", 43 | 10: "notifier", 44 | 11: "feature_binding", 45 | 12: "authorization", 46 | } 47 | 48 | 49 | class ActivityLog(base.ECMCommand): 50 | """ Activity log commands. """ 51 | 52 | name = 'activity-log' 53 | 54 | def setup_args(self, parser): 55 | self.add_subcommand(List, default=True) 56 | self.add_subcommand(Webhook) 57 | 58 | 59 | class List(base.ECMCommand): 60 | """ Tabulate activity log. """ 61 | 62 | name = 'ls' 63 | 64 | def setup_args(self, parser): 65 | self.inject_table_factory() 66 | super().setup_args(parser) 67 | 68 | @shellish.ttl_cache(300) 69 | def get_actor(self, itype, id): 70 | kind = actor_types[itype] 71 | if kind == 'user': 72 | user = self.api.get('users', str(id)) 73 | return '(user) %s %s (%d)' % (user['first_name'], 74 | user['last_name'], id) 75 | elif kind == 'router': 76 | router = self.api.get('routers', str(id)) 77 | return '(router) %s (%d)' % (router['name'], id) 78 | else: 79 | raise TypeError("unsupported actor: %s" % kind) 80 | 81 | @shellish.ttl_cache(300) 82 | def get_object(self, itype, id): 83 | kind = object_types[itype] 84 | if kind == 'user': 85 | user = self.api.get('users', str(id)) 86 | return 'user: %s %s (%d)' % (user['first_name'], 87 | user['last_name'], id) 88 | elif kind == 'router': 89 | router = self.api.get('routers', str(id)) 90 | return 'router: %s (%d)' % (router['name'], id) 91 | else: 92 | raise TypeError("unsupported actor: %s" % kind) 93 | 94 | def unhandled(self, kind): 95 | def fn(row): 96 | return 'Unsupported [%s]: %s' % (kind, row) 97 | return fn 98 | 99 | def handle_request(self, row): 100 | return '{actor[username]} ({actor[id]}) {operation[name]} of ' \ 101 | '{object[name]} ({object[id]})' \ 102 | .format(**row['attributes']) 103 | 104 | def handle_update_details(self, row): 105 | attrs = row['attributes'] 106 | if attrs['actor'] == attrs['object']: 107 | src = 'local-device' 108 | else: 109 | src = self.get_actor(row['actor_type'], row['actor_id']) 110 | dst = '%s (%s)' % (attrs['object']['name'], attrs['object']['id']) 111 | updates = list(base.totuples(attrs['diff']['target_config'][0])) 112 | updates.extend(('.'.join(map(str, x)), 'DELETED') 113 | for x in attrs['diff']['target_config'][1]) 114 | pretty_updates = ', '.join('%s=%s' % x for x in updates) 115 | return 'Config changed by %s on %s: %s' % ( 116 | src, dst, pretty_updates) 117 | 118 | def handle_update_diff(self, row): 119 | attrs = row['attributes'] 120 | if attrs['actor'] == attrs['object']: 121 | src = 'local-device' 122 | else: 123 | src = self.get_actor(row['actor_type'], row['actor_id']) 124 | dst = '%s (%s)' % (attrs['object']['name'], attrs['object']['id']) 125 | updates = len(list(base.totuples(attrs['diff']['target_config'][0]))) 126 | removals = len(attrs['diff']['target_config'][1]) 127 | stat = [] 128 | if updates: 129 | stat.append('+%d' % updates) 130 | if removals: 131 | stat.append('-%d' % removals) 132 | return 'Config changed by %s on %s: %s differences' % ( 133 | src, dst, '/'.join(stat)) 134 | 135 | def handle_login(self, row): 136 | return '{actor[username]} ({actor[id]}) logged into ECM' \ 137 | .format(**row['attributes']) 138 | 139 | def handle_logout(self, row): 140 | return '{actor[username]} ({actor[id]}) logged out of ECM' \ 141 | .format(**row['attributes']) 142 | 143 | def handle_fw_report(self, row): 144 | fw = row['attributes']['after']['actual_firmware'] 145 | return '{actor[name]} ({actor[id]}) firmware upgraded to ' \ 146 | '{fw[version]}' \ 147 | .format(fw=fw, **row['attributes']) 148 | 149 | def handle_register(self, row): 150 | attrs = row['attributes'] 151 | if attrs['actor'] == attrs['object']: 152 | src = 'local-device' 153 | else: 154 | # XXX Never seen before, but is probably some sort of insecure 155 | # activation thing and not a router or user. 156 | raise NotImplementedError(str(attrs['actor'])) 157 | router = '%s (%s)' % (attrs['object']['name'], attrs['object']['id']) 158 | return 'Router registered by %s: %s' % (src, 159 | router) 160 | 161 | def parse_activity(self, row): 162 | handlers = { 163 | 1: self.unhandled("create"), 164 | 2: self.unhandled("delete"), 165 | 3: self.handle_update_diff, 166 | 4: self.handle_request, 167 | 5: self.handle_fw_report, 168 | 6: self.handle_login, 169 | 7: self.handle_logout, 170 | 8: self.handle_register, 171 | 9: self.unhandled("unregister"), 172 | 10: self.unhandled("activate"), 173 | } 174 | return handlers[row['activity_type']](row) 175 | 176 | def run(self, args): 177 | fields = collections.OrderedDict(( 178 | ('Activity', self.parse_activity), 179 | ('Time', lambda x: ui.formatdatetime(ui.localize_dt(x['created_at']))) 180 | )) 181 | with self.make_table(headers=fields.keys(), 182 | accessors=fields.values()) as t: 183 | t.print(self.api.get_pager('activity_logs', 184 | order_by='-created_at_timeuuid')) 185 | 186 | 187 | class Webhook(base.ECMCommand): 188 | """ Monitor for new events and post them to a webhook. """ 189 | 190 | name = 'webhook' 191 | 192 | def run(self, args): 193 | raise NotImplementedError("") 194 | 195 | 196 | command_classes = [ActivityLog] 197 | -------------------------------------------------------------------------------- /ecmcli/commands/alerts.py: -------------------------------------------------------------------------------- 1 | """ 2 | Analyze and Report ECM Alerts. 3 | """ 4 | 5 | import collections 6 | import humanize 7 | import shellish 8 | import sys 9 | from . import base 10 | from .. import ui 11 | 12 | 13 | def since(dt): 14 | """ Return humanized time since for an absolute datetime. """ 15 | since = dt.now(tz=dt.tzinfo) - dt 16 | return humanize.naturaltime(since)[:-4] 17 | 18 | 19 | class Alerts(base.ECMCommand): 20 | """ Analyze and Report ECM Alerts """ 21 | 22 | name = 'alerts' 23 | 24 | def setup_args(self, parser): 25 | self.add_subcommand(List, default=True) 26 | self.add_subcommand(Summary) 27 | self.add_subcommand(Webhook) 28 | 29 | 30 | class List(base.ECMCommand): 31 | """ Analyze and Report ECM Alerts """ 32 | 33 | name = 'ls' 34 | 35 | def setup_args(self, parser): 36 | self.inject_table_factory() 37 | super().setup_args(parser) 38 | 39 | def run(self, args): 40 | fields = collections.OrderedDict(( 41 | ('Type', 'type'), 42 | ('Info', 'friendly_info'), 43 | ('Time', lambda x: ui.formatdatetime(ui.localize_dt( 44 | x['detected_at']))) 45 | )) 46 | with self.make_table(headers=fields.keys(), 47 | accessors=fields.values()) as t: 48 | t.print(self.api.get_pager('router_alerts', 49 | order_by='-created_at_timeuuid')) 50 | 51 | 52 | class Summary(base.ECMCommand): 53 | """ Analyze and Report ECM Alerts """ 54 | 55 | name = 'summary' 56 | 57 | def setup_args(self, parser): 58 | self.inject_table_factory() 59 | super().setup_args(parser) 60 | 61 | def run(self, args): 62 | by_type = collections.OrderedDict() 63 | alerts = self.api.get_pager('router_alerts', 64 | order_by='-created_at_timeuuid') 65 | if sys.stdout.isatty(): 66 | msg = 'Collecting alerts: ' 67 | alerts = shellish.progressbar(alerts, prefix=msg, clear=True) 68 | for i, x in enumerate(alerts, 1): 69 | try: 70 | ent = by_type[x['alert_type']] 71 | except KeyError: 72 | ent = by_type[x['alert_type']] = { 73 | "records": [x], 74 | "newest": x['created_ts'], 75 | "oldest": x['created_ts'], 76 | } 77 | else: 78 | ent['records'].append(x), 79 | ent['oldest'] = x['created_ts'] 80 | headers = ['Alert Type', 'Count', 'Most Recent', 'Oldest'] 81 | with self.make_table(headers=headers) as t: 82 | t.print((name, len(x['records']), since(x['newest']), 83 | since(x['oldest'])) for name, x in by_type.items()) 84 | 85 | 86 | class Webhook(base.ECMCommand): 87 | """ Monitor for new events and post them to a webhook. """ 88 | 89 | name = 'webhook' 90 | 91 | def setup_args(self, parser): 92 | super().setup_args(parser) 93 | 94 | def run(self, args): 95 | by_type = collections.OrderedDict() 96 | alerts = self.api.get_pager('router_alerts', 97 | order_by='-created_at_timeuuid') 98 | if sys.stdout.isatty(): 99 | msg = 'Collecting alerts: ' 100 | alerts = shellish.progressbar(alerts, prefix=msg, clear=True) 101 | for i, x in enumerate(alerts, 1): 102 | try: 103 | ent = by_type[x['alert_type']] 104 | except KeyError: 105 | ent = by_type[x['alert_type']] = { 106 | "records": [x], 107 | "newest": x['created_ts'], 108 | "oldest": x['created_ts'], 109 | } 110 | else: 111 | ent['records'].append(x), 112 | ent['oldest'] = x['created_ts'] 113 | headers = ['Alert Type', 'Count', 'Most Recent', 'Oldest'] 114 | with self.make_table(headers=headers) as t: 115 | t.print((name, len(x['records']), since(x['newest']), 116 | since(x['oldest'])) for name, x in by_type.items()) 117 | 118 | 119 | command_classes = [Alerts] 120 | -------------------------------------------------------------------------------- /ecmcli/commands/apps.py: -------------------------------------------------------------------------------- 1 | """ 2 | Work with the ECM Router Apps toolset. 3 | """ 4 | 5 | import collections 6 | import shellish 7 | import time 8 | from . import base 9 | from .. import ui 10 | 11 | versions_res = 'router_sdk_app_versions' 12 | deploy_res = 'router_sdk_group_bindings' 13 | 14 | 15 | class CommonMixin(object): 16 | 17 | def get_app(self, name_ver, **kwargs): 18 | """ Get the app version object for a `name:version` identifier. """ 19 | name, version = name_ver.split(':', 1) 20 | major, minor = version.split('.', 1) 21 | app = self.api.get(versions_res, app__name=name, major_version=major, 22 | minor_version=minor, **kwargs) 23 | if not app: 24 | raise SystemExit("Application %s not found" % name_ver) 25 | return app[0] 26 | 27 | def add_app_argument(self, *keys, **options): 28 | options.setdefault('metavar', 'APP:VERSION') 29 | options.setdefault('help', 'The name:version identifier of an app ' 30 | 'version') 31 | return self.add_argument(*keys, **options) 32 | 33 | def app_ident(self, app_version): 34 | name = app_version['app'] and app_version['app']['name'] 35 | return '%s:%d.%d' % (name, app_version['major_version'], 36 | app_version['minor_version']) 37 | 38 | 39 | class List(CommonMixin, base.ECMCommand): 40 | """ List uploaded router apps. """ 41 | 42 | name = 'ls' 43 | expands = ( 44 | 'account', 45 | 'app' 46 | ) 47 | 48 | def setup_args(self, parser): 49 | self.inject_table_factory() 50 | super().setup_args(parser) 51 | 52 | def run(self, args): 53 | versions = self.api.get_pager(versions_res, 54 | expand=','.join(self.expands)) 55 | fields = collections.OrderedDict(( 56 | ('Version ID', 'id'), 57 | ('App ID', lambda x: x['app'] and x['app']['id']), 58 | ('App Ident', self.app_ident), 59 | ('Created', lambda x: ui.time_since(x['created_at']) + ' ago'), 60 | ('State', 'state'), 61 | )) 62 | with self.make_table(headers=fields.keys(), 63 | accessors=fields.values()) as t: 64 | t.print(versions) 65 | 66 | 67 | class Examine(CommonMixin, base.ECMCommand): 68 | """ Examine a specific router app. """ 69 | 70 | name = 'examine' 71 | expands = ( 72 | 'account', 73 | 'app', 74 | 'groups' 75 | ) 76 | 77 | def setup_args(self, parser): 78 | self.add_app_argument('appident') 79 | self.inject_table_factory() 80 | super().setup_args(parser) 81 | 82 | def run(self, args): 83 | app = self.get_app(args.appident, expand=','.join(self.expands)) 84 | routers = self.api.get_pager(urn=app['routers']) 85 | appfields = app['app'] or {} 86 | with self.make_table(columns=[20, None], column_padding=1) as t: 87 | t.print([ 88 | ('Version ID', app['id']), 89 | ('App ID', appfields.get('id')), 90 | ('Name', appfields.get('name')), 91 | ('UUID', appfields.get('uuid')), 92 | ('Description', appfields.get('description')), 93 | ('Version', '%d.%d' % (app['major_version'], 94 | app['minor_version'])), 95 | ('Account', app['account']['name']), 96 | ('Created', app['created_at']), 97 | ('Updated', app['updated_at']), 98 | ('State', '%s %s' % (app['state'], 99 | app.get('state_details') or '')), 100 | ('Groups', ', '.join(x['name'] for x in app['groups'])), 101 | ('Routers', ', '.join(x['name'] for x in routers)), 102 | ]) 103 | 104 | 105 | class Upload(base.ECMCommand): 106 | """ Upload a new application package. """ 107 | 108 | name = 'upload' 109 | use_pager = False 110 | 111 | def setup_args(self, parser): 112 | self.add_file_argument('package', mode='rb') 113 | super().setup_args(parser) 114 | 115 | def run(self, args): 116 | with args.package as f: 117 | url = self.api.uri + self.api.urn 118 | session = self.api.adapter.session 119 | del session.headers['content-type'] 120 | resp = session.post('%s/%s/' % (url, versions_res), files={ 121 | "archive": f 122 | }).json() 123 | if not resp['success']: 124 | raise SystemExit(resp['message']) 125 | appid = resp['data']['id'] 126 | print("Checking upload: ", end='', flush=True) 127 | for i in range(10): 128 | app = self.api.get_by('id', versions_res, appid) 129 | if app['state'] != 'uploading': 130 | break 131 | time.sleep(0.200) 132 | else: 133 | raise SystemExit("Timeout waiting for upload to complete") 134 | if app['state'] == 'error': 135 | shellish.vtmlprint("ERROR") 136 | self.api.delete(versions_res, app['id']) 137 | raise SystemExit(app['state_details']) 138 | elif app['state'] == 'ready': 139 | shellish.vtmlprint("READY") 140 | else: 141 | shellish.vtmlprint("%s" % app['state'].upper()) 142 | if app['state_details']: 143 | print(app.get('state_details')) 144 | 145 | 146 | class Remove(CommonMixin, base.ECMCommand): 147 | """ Remove an application version. """ 148 | 149 | name = 'rm' 150 | use_pager = False 151 | 152 | def setup_args(self, parser): 153 | self.add_app_argument('appident') 154 | self.add_argument('-f', '--force', action='store_true', 155 | help='Do not prompt for confirmation.') 156 | super().setup_args(parser) 157 | 158 | def run(self, args): 159 | app = self.get_app(args.appident) 160 | if not args.force: 161 | self.confirm("Delete %s (%s)?" % (args.appident, app['id'])) 162 | self.api.delete(versions_res, app['id']) 163 | 164 | 165 | class Deploy(CommonMixin, base.ECMCommand): 166 | """ Show and manage application deployments. """ 167 | 168 | name = 'deploys' 169 | expands = ( 170 | 'app_version.app', 171 | 'group' 172 | ) 173 | 174 | def setup_args(self, parser): 175 | self.add_subcommand(DeployInstall) 176 | self.add_subcommand(DeployRemove) 177 | self.inject_table_factory() 178 | super().setup_args(parser) 179 | 180 | def run(self, args): 181 | deploys = self.api.get_pager(deploy_res, 182 | expand=','.join(self.expands)) 183 | fields = collections.OrderedDict(( 184 | ('ID', 'id'), 185 | ('Created', lambda x: ui.time_since(x['created_at']) + ' ago'), 186 | ('Group', lambda x: x['group']['name']), 187 | ('App Ident', lambda x: self.app_ident(x['app_version'])) 188 | )) 189 | with self.make_table(headers=fields.keys(), 190 | accessors=fields.values()) as t: 191 | t.print(deploys) 192 | 193 | 194 | class DeployInstall(CommonMixin, base.ECMCommand): 195 | """ Install an application to a group of routers. """ 196 | 197 | name = 'install' 198 | use_pager = False 199 | 200 | def setup_args(self, parser): 201 | self.add_app_argument('appident') 202 | self.add_group_argument('group', metavar='GROUP_ID_OR_NAME') 203 | super().setup_args(parser) 204 | 205 | def run(self, args): 206 | app = self.get_app(args.appident) 207 | group = self.api.get_by_id_or_name('groups', args.group) 208 | self.api.post(deploy_res, { 209 | "app_version": app['resource_uri'], 210 | "group": group['resource_uri'], 211 | "account": group['account'] 212 | }) 213 | 214 | 215 | class DeployRemove(CommonMixin, base.ECMCommand): 216 | """ Remove an application from a group of routers. """ 217 | 218 | name = 'rm' 219 | use_pager = False 220 | 221 | def setup_args(self, parser): 222 | crit = parser.add_mutually_exclusive_group(required=True) 223 | self.add_argument('--deploy-id', parser=crit, metavar='DEPLOY_ID', 224 | complete=self.make_completer(deploy_res, 'id')) 225 | self.add_app_argument('--app-ident', parser=crit) 226 | self.add_group_argument('--group', metavar='GROUP_NAME_OR_ID', 227 | help='Only remove an app from this group') 228 | self.add_argument('-f', '--force', action='store_true', 229 | help='Do not prompt for confirmation.') 230 | super().setup_args(parser) 231 | 232 | def run(self, args): 233 | if args.deploy_id: 234 | if not args.force: 235 | self.confirm("Remove %s?" % args.deploy_id) 236 | self.api.delete(deploy_res, args.deploy_id) 237 | else: 238 | app = self.get_app(args.app_ident) 239 | filters = { 240 | "app_version": app['id'] 241 | } 242 | if args.group: 243 | group = self.api.get_by_id_or_name('groups', args.group) 244 | filters['group'] = group['id'] 245 | deploys = list(self.api.get_pager(deploy_res, **filters)) 246 | if not deploys: 247 | raise SystemExit("No deploys to remove") 248 | if not args.force: 249 | self.confirm("Remove %s?" % ', '.join(x['id'] 250 | for x in deploys)) 251 | for x in deploys: 252 | self.api.delete(deploy_res, x['id']) 253 | 254 | 255 | class Apps(base.ECMCommand): 256 | """ View and edit Router Applications (router-sdk). """ 257 | 258 | name = 'apps' 259 | 260 | def __init__(self, *args, **kwargs): 261 | super().__init__(*args, **kwargs) 262 | self.add_subcommand(List, default=True) 263 | self.add_subcommand(Examine) 264 | self.add_subcommand(Upload) 265 | self.add_subcommand(Remove) 266 | self.add_subcommand(Deploy) 267 | 268 | command_classes = [Apps] 269 | -------------------------------------------------------------------------------- /ecmcli/commands/authorizations.py: -------------------------------------------------------------------------------- 1 | """ 2 | View and edit authorizations along with managing collaborations. 3 | """ 4 | 5 | import collections 6 | import shellish 7 | from . import base 8 | 9 | 10 | class Common(object): 11 | """ Mixin of common stuff. """ 12 | 13 | def add_auth_argument(self): 14 | self.add_argument('authid', metavar='ID', 15 | complete=self.make_completer('authorizations', 'id')) 16 | 17 | 18 | class List(Common, base.ECMCommand): 19 | """ List authorizations. """ 20 | 21 | name = 'ls' 22 | expands = ( 23 | 'account', 24 | 'role', 25 | 'user.profile.account', 26 | 'securitytoken.account' 27 | ) 28 | 29 | def __init__(self, *args, **kwargs): 30 | super().__init__(*args, **kwargs) 31 | check = '%s' % shellish.beststr('✓', '*') 32 | self.verbose_fields = collections.OrderedDict(( 33 | ('id', 'ID'), 34 | (self.trustee_acc, 'Beneficiary (user/token)'), 35 | (self.orig_account_acc, 'Originating Account'), 36 | ('role.name', 'Role'), 37 | ('account.name', 'Rights on (account)'), 38 | (lambda x: check if x['cascade'] else '', 'Cascades'), 39 | (lambda x: check if not x['active'] else '', 'Inactive'), 40 | )) 41 | self.terse_fields = collections.OrderedDict(( 42 | ('id', 'ID'), 43 | (self.trustee_acc, 'Beneficiary (user/token)'), 44 | ('role.name', 'Role'), 45 | ('account.name', 'Rights on (account)'), 46 | (lambda x: check if not x['active'] else '', 'Inactive'), 47 | )) 48 | 49 | def trustee_acc(self, row): 50 | """ Show the username@account or securitytoken@account. """ 51 | try: 52 | return row['user.username'] 53 | except KeyError: 54 | try: 55 | return row['securitytoken.label'] 56 | except KeyError: 57 | return '' 58 | 59 | def orig_account_acc(self, row): 60 | """ Show the originating account. This is where the user/token hails 61 | from. """ 62 | if 'user.username' in row: 63 | try: 64 | return row['user.profile.account.name'] 65 | except KeyError: 66 | return '(%s)' % row['user.profile.account'].split('/')[-2] 67 | else: 68 | try: 69 | return row['securitytoken.account.name'] 70 | except KeyError: 71 | try: 72 | return '(%s)' % row['securitytoken.account'].split('/')[-2] 73 | except KeyError: 74 | return '' 75 | 76 | def setup_args(self, parser): 77 | self.add_user_argument('--beneficiary', help='Limit display to this ' 78 | 'beneficiary.') 79 | self.add_role_argument('--role', help='Limit display to this role.') 80 | self.add_account_argument('--rights-on', help='Limit display to ' 81 | 'records affecting this account.') 82 | self.add_argument('--inactive', action='store_true', 83 | help='Limit display to inactive records.') 84 | self.add_argument('--verbose', '-v', action='store_true') 85 | self.inject_table_factory() 86 | super().setup_args(parser) 87 | 88 | def run(self, args): 89 | filters = {} 90 | if args.beneficiary: 91 | beni = args.beneficiary 92 | filters['_or'] = 'user__username=%s|securitytoken.label=%s' % ( 93 | beni, beni) 94 | if args.role: 95 | filters['role__name'] = args.role 96 | if args.rights_on: 97 | filters['account__name'] = args.rights_on 98 | if args.inactive: 99 | filters['active'] = False 100 | auths = self.api.get_pager('authorizations', 101 | expand=','.join(self.expands), **filters) 102 | fields = self.terse_fields if not args.verbose else self.verbose_fields 103 | with self.make_table(headers=fields.values(), 104 | accessors=fields.keys()) as t: 105 | t.print(map(dict, map(base.totuples, auths))) 106 | 107 | 108 | class Delete(Common, base.ECMCommand): 109 | """ Delete an authorization. """ 110 | 111 | name = 'rm' 112 | use_pager = False 113 | 114 | def setup_args(self, parser): 115 | self.add_auth_argument() 116 | self.add_argument('-f', '--force', action="store_true") 117 | super().setup_args(parser) 118 | 119 | def format_auth(self, auth): 120 | if auth['user']: 121 | try: 122 | beni = auth['user']['username'] 123 | except TypeError: 124 | beni = '(%s)' % auth['user'].split('/')[-2] 125 | elif auth['securitytoken']: 126 | try: 127 | beni = auth['securitytoken']['label'] 128 | except TypeError: 129 | beni = '(%s)' % auth['securitytoken'].split('/')[-2] 130 | else: 131 | beni = '' 132 | try: 133 | rights_on = auth['account']['name'] 134 | except TypeError: 135 | rights_on = '(%s)' % auth['account'].split('/')[-2] 136 | return '%s [%s -> %s]' % (auth['id'], beni, rights_on) 137 | 138 | def run(self, args): 139 | auth = self.api.get_by('id', 'authorizations', args.authid, 140 | expand='user,securitytoken,account') 141 | if not args.force: 142 | self.confirm('Delete authorization: %s' % self.format_auth(auth)) 143 | self.api.delete('authorizations', auth['id']) 144 | 145 | 146 | class Create(Common, base.ECMCommand): 147 | """ Create a new authorization. """ 148 | 149 | name = 'create' 150 | use_pager = False 151 | 152 | def setup_args(self, parser): 153 | beni = parser.add_mutually_exclusive_group() 154 | self.add_user_argument('--beneficiary-user', parser=beni, 155 | help='Username to benefit.') 156 | self.add_argument('--beneficiary-token', parser=beni, type=int, 157 | help='Security token ID to benefit.') 158 | self.add_role_argument('--role', help='Role for the beneficiary.') 159 | self.add_account_argument('--rights-on', help='Account bestowing ' 160 | 'rights to.') 161 | self.add_argument('--no-cascade', action='store_true') 162 | self.add_argument('--foreign', action='store_true', help='Create an ' 163 | 'authorization for a foreign user. Sometimes ' 164 | 'referred to as a collaborator request.') 165 | super().setup_args(parser) 166 | 167 | def run(self, args): 168 | new = { 169 | "cascade": not args.no_cascade 170 | } 171 | foreign = args.foreign 172 | user = args.beneficiary_user 173 | token = args.beneficiary_token 174 | if foreign and token: 175 | raise SystemExit("Foreign authorizations only work with users.") 176 | if not user and not token: 177 | if foreign: 178 | user = input("Enter collaborator beneficiary username: ") 179 | if not user: 180 | raise SystemExit("User Required") 181 | else: 182 | user = input("Enter beneficiary username ( to skip): ") 183 | if not user: 184 | token = input("Enter beneficiary token ID: ") 185 | if not token: 186 | raise SystemExit("User or Token Required") 187 | if user: 188 | if foreign: 189 | new['username'] = user 190 | else: 191 | user = self.api.get_by('username', 'users', user) 192 | new['user'] = user['resource_uri'] 193 | elif token: 194 | token = self.api.get_by('id', 'securitytokens', token) 195 | new['securitytoken'] = token['resource_uri'] 196 | role = args.role or input('Role: ') 197 | new['role'] = self.api.get_by_id_or_name('roles', role)['resource_uri'] 198 | rights_on = args.rights_on or input('Account (rights on): ') 199 | new['account'] = self.api.get_by_id_or_name('accounts', 200 | rights_on)['resource_uri'] 201 | resource = 'authorizations' if not foreign else \ 202 | 'foreign_authorizations' 203 | self.api.post(resource, new) 204 | 205 | 206 | class Edit(Common, base.ECMCommand): 207 | """ Edit authorization attributes. """ 208 | 209 | name = 'edit' 210 | 211 | def setup_args(self, parser): 212 | self.add_auth_argument() 213 | self.add_role_argument('--role') 214 | self.add_account_argument('--account') 215 | cascade = parser.add_mutually_exclusive_group() 216 | self.add_argument('--cascade', action='store_true', parser=cascade, 217 | help="Permit beneficiary rights to subaccounts.") 218 | self.add_argument('--no-cascade', action='store_true', parser=cascade) 219 | active = parser.add_mutually_exclusive_group() 220 | self.add_argument('--activate', action='store_true', parser=active, 221 | help="Activate the authorization.") 222 | self.add_argument('--deactivate', action='store_true', parser=active, 223 | help="Deactivate the authorization.") 224 | super().setup_args(parser) 225 | 226 | def run(self, args): 227 | auth = self.api.get_by('id', 'authorizations', args.authid) 228 | updates = {} 229 | if args.role: 230 | role = self.api.get_by_id_or_name('roles', args.role) 231 | updates['role'] = role['resource_uri'] 232 | if args.cascade: 233 | updates['cascade'] = True 234 | elif args.no_cascade: 235 | updates['cascade'] = False 236 | if args.activate: 237 | updates['active'] = True 238 | elif args.deactivate: 239 | updates['active'] = False 240 | if args.account: 241 | role = self.api.get_by_id_or_name('accounts', args.role) 242 | updates['account'] = role['resource_uri'] 243 | if updates: 244 | self.api.put('authorizations', auth['id'], updates) 245 | 246 | 247 | class RoleExamine(base.ECMCommand): 248 | """ Examine the permissions of a role. """ 249 | 250 | name = 'examine' 251 | method_colors = { 252 | 'get': 'green', 253 | 'put': 'magenta', 254 | 'post': 'magenta', 255 | 'patch': 'magenta', 256 | 'delete': 'red' 257 | } 258 | 259 | def setup_args(self, parser): 260 | self.add_role_argument() 261 | self.add_argument('--resources', nargs='+', help='Limit display to ' 262 | 'these resource(s).') 263 | self.add_argument('--methods', metavar="METHOD", nargs='+', 264 | help='Limit display to resources permitted to use ' 265 | 'these method(s).') 266 | self.inject_table_factory() 267 | super().setup_args(parser) 268 | 269 | def color_code(self, method): 270 | color = self.method_colors.get(method.lower(), 'yellow') 271 | return '<%s>%s' % (color, method, color) 272 | 273 | def run(self, args): 274 | """ Unroll perms for a role. """ 275 | role = self.api.get_by_id_or_name('roles', args.ident, 276 | expand='permissions') 277 | if args.methods: 278 | methods = set(x.lower() for x in args.methods) 279 | rights = collections.defaultdict(dict) 280 | operations = set() 281 | for x in role['permissions']: 282 | res = x['subject'] 283 | op = x['operation'] 284 | if args.resources and res not in args.resources: 285 | continue 286 | if args.methods and op not in methods: 287 | continue 288 | operations |= {op} 289 | rights[res]['name'] = res 290 | rights[res][op] = self.color_code(op.upper()) 291 | title = 'Permissions for: %s (%s)' % (role['name'], role['id']) 292 | headers = ['API Resource'] + ([''] * len(operations)) 293 | accessors = ['name'] + sorted(operations) 294 | with self.make_table(title=title, headers=headers, 295 | accessors=accessors) as t: 296 | t.print(sorted(rights.values(), key=lambda x: x['name'])) 297 | 298 | 299 | class Roles(base.ECMCommand): 300 | """ View authorization roles. 301 | 302 | List the available roles in the system or examine the exact permissions 303 | provided by a given role. """ 304 | 305 | name = 'roles' 306 | 307 | def __init__(self, *args, **kwargs): 308 | super().__init__(*args, **kwargs) 309 | self.add_subcommand(RoleExamine) 310 | self.fields = collections.OrderedDict(( 311 | ('id', 'ID'), 312 | ('name', 'Name'), 313 | ('get', 'GETs'), 314 | ('put', 'PUTs'), 315 | ('post', 'POSTs'), 316 | ('delete', 'DELETEs'), 317 | ('patch', 'PATCHes'), 318 | )) 319 | 320 | def setup_args(self, parser): 321 | self.inject_table_factory() 322 | super().setup_args(parser) 323 | 324 | def run(self, args): 325 | operations = set() 326 | roles = list(self.api.get_pager('roles', expand='permissions')) 327 | for x in roles: 328 | counts = collections.Counter(xx['operation'] 329 | for xx in x['permissions']) 330 | operations |= set(counts) 331 | x.update(counts) 332 | ops = sorted(operations) 333 | headers = ['ID', 'Name'] + ['%ss' % x.upper() for x in ops] 334 | accessors = ['id', 'name'] + ops 335 | with self.make_table(headers=headers, accessors=accessors) as t: 336 | t.print(roles) 337 | 338 | 339 | class Authorizations(base.ECMCommand): 340 | """ View and edit authorizations. 341 | 342 | Authorizations control who has access to an account(s) along with the 343 | role/permissions for that access. A `collaborator` authorization may also 344 | be granted to external users to provide access to local resources from 345 | users outside your own purview. This may be used for handling support or 346 | other cases where you want to temporarily grant access to a 3rd party. """ 347 | 348 | name = 'authorizations' 349 | 350 | def __init__(self, *args, **kwargs): 351 | super().__init__(*args, **kwargs) 352 | self.add_subcommand(List, default=True) 353 | self.add_subcommand(Delete) 354 | self.add_subcommand(Create) 355 | self.add_subcommand(Edit) 356 | self.add_subcommand(Roles) 357 | 358 | command_classes = [Authorizations] 359 | -------------------------------------------------------------------------------- /ecmcli/commands/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | Foundation components for commands. 3 | """ 4 | 5 | import collections 6 | import functools 7 | import itertools 8 | import shellish 9 | from xml.dom import minidom 10 | 11 | 12 | def toxml(data, root_tag='ecmcli'): 13 | """ Convert python container tree to xml. """ 14 | dom = minidom.getDOMImplementation() 15 | document = dom.createDocument(None, root_tag, None) 16 | root = document.documentElement 17 | 18 | def crawl(obj, parent): 19 | try: 20 | for key, value in sorted(obj.items()): 21 | el = document.createElement(key) 22 | parent.appendChild(el) 23 | crawl(value, el) 24 | except AttributeError: 25 | if not isinstance(obj, str) and hasattr(obj, '__iter__'): 26 | obj = list(obj) 27 | for i, value in enumerate(obj, 1): 28 | try: 29 | array_id = value.pop('_id_') 30 | except (TypeError, KeyError, AttributeError): 31 | pass 32 | else: 33 | parent.setAttribute('id', array_id) 34 | crawl(value, parent) 35 | if i < len(obj): 36 | newparent = document.createElement(parent.tagName) 37 | parent.parentNode.appendChild(newparent) 38 | parent = newparent 39 | elif obj is not None: 40 | parent.setAttribute('type', type(obj).__name__) 41 | parent.appendChild(document.createTextNode(str(obj))) 42 | crawl(data, root) 43 | return root 44 | 45 | 46 | def totuples(data): 47 | """ Convert python container tree to key/value tuples. """ 48 | 49 | def crawl(obj, path): 50 | try: 51 | for key, value in sorted(obj.items()): 52 | yield from crawl(value, path + (key,)) 53 | except AttributeError: 54 | if not isinstance(obj, str) and hasattr(obj, '__iter__'): 55 | for i, value in enumerate(obj): 56 | yield from crawl(value, path + (i,)) 57 | else: 58 | yield '.'.join(map(str, path)), obj 59 | return crawl(data, ()) 60 | 61 | 62 | def todict(obj, str_array_keys=False): 63 | """ On a tree of list and dict types convert the lists to dict types. """ 64 | if isinstance(obj, list): 65 | key_conv = str if str_array_keys else lambda x: x 66 | return dict((key_conv(k), todict(v, str_array_keys)) 67 | for k, v in zip(itertools.count(), obj)) 68 | elif isinstance(obj, dict): 69 | obj = dict((k, todict(v, str_array_keys)) for k, v in obj.items()) 70 | return obj 71 | 72 | 73 | class ECMCommand(shellish.Command): 74 | """ Extensions for dealing with ECM's APIs. """ 75 | 76 | use_pager = True 77 | Searcher = collections.namedtuple('Searcher', 'lookup, completer, help') 78 | 79 | def api_complete(self, resource, field, startswith): 80 | options = {} 81 | if '.' in field: 82 | options['expand'] = field.rsplit('.', 1)[0] 83 | if startswith: 84 | options['%s__startswith' % field] = startswith 85 | resources = self.api.get_pager(resource, fields=field, **options) 86 | return set(self.res_flatten(x, {field: field})[field] 87 | for x in resources) 88 | 89 | def make_completer(self, resource, field): 90 | """ Return a function that completes for the API .resource and 91 | returns a list of .field values. The function returned takes 92 | one argument to filter by an optional 'startswith' criteria. """ 93 | 94 | @shellish.hone_cache(maxage=300) 95 | def cached(startswith): 96 | return self.api_complete(resource, field, startswith) 97 | 98 | def wrap(startswith, *args): 99 | return cached(startswith) 100 | 101 | wrap.__name__ = '' % (resource, field) 102 | return wrap 103 | 104 | def api_search(self, resource, fields, terms, match='icontains', 105 | **options): 106 | fields = fields.copy() 107 | or_terms = [] 108 | for term in terms: 109 | if ':' in term: 110 | field, value = term.split(':', 1) 111 | if field in fields: 112 | options['%s__%s' % (fields[field], match)] = value 113 | fields.pop(field) 114 | continue 115 | query = [('%s__%s' % (x, match), term) 116 | for x in fields.values()] 117 | or_terms.extend('='.join(x) for x in query) 118 | if or_terms: 119 | options['_or'] = '|'.join(or_terms) 120 | return self.api.get_pager(resource, **options) 121 | 122 | def res_flatten(self, resource, fields): 123 | """ Flat version of resource based on field_desc. """ 124 | resp = {} 125 | for friendly, dotpath in fields.items(): 126 | offt = resource 127 | for x in dotpath.split('.'): 128 | try: 129 | offt = offt[x] 130 | except (ValueError, TypeError, KeyError): 131 | offt = None 132 | break 133 | resp[friendly] = offt 134 | return resp 135 | 136 | def confirm(self, msg, exit=True): 137 | assert not self.use_pager 138 | if input('%s (type "yes" to confirm)? ' % msg) != 'yes': 139 | if not exit: 140 | return False 141 | raise SystemExit('Aborted') 142 | return True 143 | 144 | def make_searcher(self, resource, field_desc, **search_options): 145 | """ Return a Searcher instance for doing API based lookups. This 146 | is primarily designed to meet needs of argparse arguments and tab 147 | completion. """ 148 | field_completers = {} 149 | fields = {} 150 | for x in field_desc: 151 | label, field = x if isinstance(x, tuple) else (x, x) 152 | fields[label] = field 153 | field_completers[label] = self.make_completer(resource, field) 154 | 155 | def lookup(terms, **options): 156 | merged_options = search_options.copy() 157 | merged_options.update(options) 158 | return self.api_search(resource, fields, terms, 159 | **merged_options) 160 | 161 | def complete(startswith, args): 162 | if ':' in startswith: 163 | field, value = startswith.split(':', 1) 164 | if field in fields: 165 | results = field_completers[field](value) 166 | return set('%s:%s' % (field, x) for x in results 167 | if x is not None) 168 | if not startswith: 169 | terms = set('%s:' % x for x in fields) 170 | return terms | {''} 171 | else: 172 | expands = [x.rsplit('.', 1)[0] for x in fields.values() 173 | if '.' in x] 174 | options = {"expand": ','.join(expands)} if expands else {} 175 | 176 | results = self.api_search(resource, fields, [startswith], 177 | match='startswith', **options) 178 | return set(val for res in results 179 | for val in self.res_flatten(res, fields).values() 180 | if val and str(val).startswith(startswith)) 181 | 182 | help = 'Search "%s" on fields: %s' % (resource, ', '.join(fields)) 183 | return self.Searcher(lookup, complete, help) 184 | 185 | def add_completer_argument(self, *keys, resource=None, res_field=None, 186 | **options): 187 | if not keys: 188 | keys = ('ident',) 189 | nargs = options.get('nargs') 190 | if (isinstance(nargs, int) and nargs > 1) or \ 191 | nargs and nargs in '+*': 192 | keys = ('idents',) 193 | options["complete"] = self.make_completer(resource, res_field) 194 | return self.add_argument(*keys, **options) 195 | 196 | def add_router_argument(self, *keys, **options): 197 | options.setdefault('metavar', 'ROUTER_ID_OR_NAME') 198 | options.setdefault('help', 'The ID or name of a router.') 199 | return self.add_completer_argument(*keys, resource='routers', 200 | res_field='name', **options) 201 | 202 | def add_group_argument(self, *keys, **options): 203 | options.setdefault('metavar', 'GROUP_ID_OR_NAME') 204 | options.setdefault('help', 'The ID or name of a group.') 205 | return self.add_completer_argument(*keys, resource='groups', 206 | res_field='name', **options) 207 | 208 | def add_account_argument(self, *keys, **options): 209 | options.setdefault('metavar', 'ACCOUNT_ID_OR_NAME') 210 | options.setdefault('help', 'The ID or name of an account.') 211 | return self.add_completer_argument(*keys, resource='accounts', 212 | res_field='name', **options) 213 | 214 | def add_product_argument(self, *keys, **options): 215 | options.setdefault('metavar', 'PRODUCT_ID_OR_NAME') 216 | options.setdefault('help', 'Product name, Eg. "MBR1400".') 217 | return self.add_completer_argument(*keys, resource='products', 218 | res_field='name', **options) 219 | 220 | def add_firmware_argument(self, *keys, **options): 221 | options.setdefault('metavar', 'FIRMWARE_VERSION') 222 | options.setdefault('help', 'Version identifier, Eg. "5.4.1".') 223 | return self.add_completer_argument(*keys, resource='firmwares', 224 | res_field='version', **options) 225 | 226 | def add_role_argument(self, *keys, **options): 227 | options.setdefault('metavar', 'ROLE') 228 | options.setdefault('help', 'Authorization role, Eg. "admin".') 229 | return self.add_completer_argument(*keys, resource='roles', 230 | res_field='name', **options) 231 | 232 | def add_user_argument(self, *keys, **options): 233 | options.setdefault('metavar', 'USERNAME') 234 | options.setdefault('help', 'Login user.') 235 | return self.add_completer_argument(*keys, resource='users', 236 | res_field='username', **options) 237 | 238 | def add_search_argument(self, searcher, *keys, **options): 239 | if not keys: 240 | keys = ('search',) 241 | options.setdefault('metavar', 'SEARCH_CRITERIA') 242 | options.setdefault('nargs', '+') 243 | return self.add_argument(*keys, help=searcher.help, 244 | complete=searcher.completer, **options) 245 | 246 | def inject_table_factory(self, *args, **kwargs): 247 | """ Use this in setup_args to produce a shellish.Table factory. The 248 | resultant factory will have defaults provided from the command line 249 | args added in this method. It can be called with any standard Table 250 | arguments too. """ 251 | make_table_options = self.add_table_arguments(*args, **kwargs) 252 | 253 | def setup_make_table(argument_ns): 254 | options = make_table_options(argument_ns) 255 | self.make_table = functools.partial(shellish.Table, **options) 256 | self.add_listener('prerun', setup_make_table) 257 | -------------------------------------------------------------------------------- /ecmcli/commands/clients.py: -------------------------------------------------------------------------------- 1 | """ 2 | Harvest a detailed list of clients seen by online routers. 3 | """ 4 | 5 | import itertools 6 | import pickle 7 | import pkg_resources 8 | from . import base 9 | 10 | 11 | class List(base.ECMCommand): 12 | """ Show the currently connected clients on a router. The router must be 13 | connected to ECM for this to work. """ 14 | # XXX Broken when len(clients) > page_size 15 | 16 | name = 'ls' 17 | wifi_bw_modes = { 18 | 0: "20", 19 | 1: "40", 20 | 2: "80" 21 | } 22 | wifi_modes = { 23 | 0: "802.11b", 24 | 1: "802.11g", 25 | 2: "802.11n", 26 | 3: "802.11n-only", 27 | 4: "802.11ac" 28 | } 29 | wifi_bands = { 30 | 0: "2.4", 31 | 1: "5" 32 | } 33 | 34 | def setup_args(self, parser): 35 | self.add_router_argument('idents', nargs='*') 36 | self.add_argument('-v', '--verbose', action="store_true") 37 | self.inject_table_factory() 38 | 39 | @property 40 | def mac_db(self): 41 | try: 42 | return self._mac_db 43 | except AttributeError: 44 | mac_db = pkg_resources.resource_stream('ecmcli', 'mac.db') 45 | self._mac_db = pickle.load(mac_db) 46 | return self._mac_db 47 | 48 | def mac_lookup_short(self, info): 49 | return self.mac_lookup(info, 0) 50 | 51 | def mac_lookup_long(self, info): 52 | return self.mac_lookup(info, 1) 53 | 54 | def mac_lookup(self, info, idx): 55 | mac = int(''.join(info['mac'].split(':')[:3]), 16) 56 | localadmin = mac & 0x20000 57 | # This really only pertains to cradlepoint devices. 58 | if localadmin and mac not in self.mac_db: 59 | mac &= 0xffff 60 | return self.mac_db.get(mac, [None, None])[idx] 61 | 62 | def make_dns_getter(self, ids): 63 | dns = {} 64 | for leases in self.api.get_pager('remote', 'status/dhcpd/leases', 65 | id__in=','.join(ids)): 66 | if not leases['success'] or not leases['data']: 67 | continue 68 | dns.update(dict((x['mac'], x['hostname']) 69 | for x in leases['data'])) 70 | return lambda x: dns.get(x['mac'], '') 71 | 72 | def make_wifi_getter(self, ids): 73 | wifi = {} 74 | radios = {} 75 | for x in self.api.get_pager('remote', 'config/wlan/radio', 76 | id__in=','.join(ids)): 77 | if x['success']: 78 | radios[x['id']] = x['data'] 79 | for x in self.api.get_pager('remote', 'status/wlan/clients', 80 | id__in=','.join(ids)): 81 | if not x['success'] or not x['data']: 82 | continue 83 | for client in x['data']: 84 | client['radio_info'] = radios[x['id']][client['radio']] 85 | wifi[client['mac']] = client 86 | return lambda x: wifi.get(x['mac'], {}) 87 | 88 | def wifi_status_acc(self, client, default): 89 | """ Accessor for WiFi RSSI, txrate and mode. """ 90 | if not client: 91 | return default 92 | status = [ 93 | self.get_wifi_rssi(client), 94 | '%d Mbps' % client['txrate'], 95 | self.wifi_modes[client['mode']], 96 | ] 97 | return ', '.join(status) 98 | 99 | def get_wifi_rssi(self, wifi_info): 100 | rssi_vals = [] 101 | for i in itertools.count(0): 102 | try: 103 | rssi_vals.append(wifi_info['rssi%d' % i]) 104 | except KeyError: 105 | break 106 | rssi = sum(rssi_vals) / len(rssi_vals) 107 | if rssi > -40: 108 | fmt = '%.0f' 109 | elif rssi > -55: 110 | fmt = '%.0f' 111 | elif rssi > -65: 112 | fmt = '%.0f' 113 | elif rssi > -80: 114 | fmt = '%.0f' 115 | else: 116 | fmt = '%.0f' 117 | return fmt % rssi + ' dBm' 118 | 119 | def wifi_bss_acc(self, client, default): 120 | """ Accessor for WiFi access point. """ 121 | if not client: 122 | return default 123 | radio = client['radio_info'] 124 | bss = radio['bss'][client['bss']] 125 | band = self.wifi_bands[client['radio_info']['wifi_band']] 126 | return '%s (%s Ghz)' % (bss['ssid'], band) 127 | 128 | def run(self, args): 129 | if args.idents: 130 | routers = [self.api.get_by_id_or_name('routers', x) 131 | for x in args.idents] 132 | else: 133 | routers = self.api.get_pager('routers', state='online', 134 | product__series=3) 135 | ids = dict((x['id'], x['name']) for x in routers) 136 | if not ids: 137 | raise SystemExit("No online routers found") 138 | data = [] 139 | for clients in self.api.get_pager('remote', 'status/lan/clients', 140 | id__in=','.join(ids)): 141 | if not clients['success']: 142 | continue 143 | by_mac = {} 144 | for x in clients['data']: 145 | x['router'] = ids[str(clients['id'])] 146 | if x['mac'] in by_mac: 147 | by_mac[x['mac']]['ip_addresses'].append(x['ip_address']) 148 | else: 149 | x['ip_addresses'] = [x['ip_address']] 150 | by_mac[x['mac']] = x 151 | data.extend(by_mac.values()) 152 | dns_getter = self.make_dns_getter(ids) 153 | ip_getter = lambda x: ', '.join(sorted(x['ip_addresses'], key=len)) 154 | headers = ['Router', 'IP Addresses', 'Hostname', 'MAC', 'Hardware'] 155 | accessors = ['router', ip_getter, dns_getter, 'mac'] 156 | if not args.verbose: 157 | accessors.append(self.mac_lookup_short) 158 | else: 159 | wifi_getter = self.make_wifi_getter(ids) 160 | headers.extend(['WiFi Status', 'WiFi AP']) 161 | na = '' 162 | accessors.extend([ 163 | self.mac_lookup_long, 164 | lambda x: self.wifi_status_acc(wifi_getter(x), na), 165 | lambda x: self.wifi_bss_acc(wifi_getter(x), na) 166 | ]) 167 | with self.make_table(headers=headers, accessors=accessors) as t: 168 | t.print(data) 169 | 170 | 171 | class Clients(base.ECMCommand): 172 | 173 | name = 'clients' 174 | 175 | def __init__(self, *args, **kwargs): 176 | super().__init__(*args, **kwargs) 177 | self.add_subcommand(List, default=True) 178 | 179 | command_classes = [Clients] 180 | -------------------------------------------------------------------------------- /ecmcli/commands/features.py: -------------------------------------------------------------------------------- 1 | """ 2 | View and edit features (bindings) along with managing collaborations. 3 | """ 4 | 5 | import collections 6 | import shellish 7 | from . import base 8 | from .. import ui 9 | 10 | 11 | class Common(object): 12 | """ Mixin of common stuff. """ 13 | 14 | def add_ident_argument(self): 15 | self.add_argument('featureid', metavar='ID', 16 | complete=self.make_completer('featurebindings', 17 | 'id')) 18 | 19 | 20 | class List(Common, base.ECMCommand): 21 | """ List features. """ 22 | 23 | name = 'ls' 24 | expands = ( 25 | 'account', 26 | 'feature', 27 | ) 28 | 29 | def __init__(self, *args, **kwargs): 30 | super().__init__(*args, **kwargs) 31 | check = '%s' % shellish.beststr('✓', '*') 32 | self.verbose_fields = collections.OrderedDict(( 33 | ('id', 'ID'), 34 | (self.feature_title_acc, 'Name'), 35 | ('feature.version', 'Version'), 36 | (lambda x: ui.time_since(x['created']), 'Age'), 37 | ('account.name', 'Account'), 38 | ('feature.category', 'Category'), 39 | (lambda x: check if x['enabled'] else '', 'Enabled'), 40 | (lambda x: check if x['locked'] else '', 'Locked'), 41 | (lambda x: check if x['tos_accepted'] else '', 'TOS Accepted'), 42 | 43 | )) 44 | self.terse_fields = collections.OrderedDict(( 45 | ('id', 'ID'), 46 | (self.feature_title_acc, 'Name'), 47 | (lambda x: ui.time_since(x['created']), 'Age'), 48 | ('feature.category', 'Category'), 49 | (lambda x: check if x['enabled'] else '', 'Enabled'), 50 | (lambda x: check if x['tos_accepted'] else '', 'TOS Accepted'), 51 | )) 52 | 53 | def setup_args(self, parser): 54 | self.add_argument('-a', '--all', action='store_true', help='Show ' 55 | 'internal features too.') 56 | self.add_argument('--verbose', '-v', action='store_true') 57 | self.inject_table_factory() 58 | super().setup_args(parser) 59 | 60 | def run(self, args): 61 | filters = {} 62 | if not args.all: 63 | filters['feature.category__nin'] = 'internal' 64 | features = self.api.get_pager('featurebindings', 65 | expand=','.join(self.expands), **filters) 66 | fields = self.terse_fields if not args.verbose else self.verbose_fields 67 | with self.make_table(headers=fields.values(), 68 | accessors=fields.keys()) as t: 69 | t.print(map(dict, map(base.totuples, features))) 70 | 71 | def feature_title_acc(self, row): 72 | return row['feature.title'] or row['feature.name'] 73 | 74 | 75 | class Delete(Common, base.ECMCommand): 76 | """ Delete a feature """ 77 | 78 | name = 'rm' 79 | use_pager = False 80 | 81 | def setup_args(self, parser): 82 | self.add_ident_argument() 83 | self.add_argument('-f', '--force', action="store_true") 84 | super().setup_args(parser) 85 | 86 | def run(self, args): 87 | binding = self.api.get_by('id', 'featurebindings', args.featureid, 88 | expand='feature') 89 | if not args.force: 90 | self.confirm('Delete feature (binding): %s (%s)' % ( 91 | binding['feature']['name'], binding['id'])) 92 | self.api.delete('featurebindings', binding['id']) 93 | 94 | 95 | class Routers(Common, base.ECMCommand): 96 | """ List routers bound to a feature. """ 97 | 98 | name = 'routers' 99 | 100 | def setup_args(self, parser): 101 | self.add_ident_argument() 102 | self.inject_table_factory() 103 | super().setup_args(parser) 104 | 105 | def run(self, args): 106 | feature = self.api.get_by('id', 'featurebindings', args.featureid) 107 | routers = self.api.get(urn=feature['routers']) 108 | fields = collections.OrderedDict(( 109 | ('id', 'ID'), 110 | ('name', 'Name'), 111 | )) 112 | with self.make_table(headers=fields.values(), 113 | accessors=fields.keys()) as t: 114 | t.print(routers) 115 | 116 | 117 | class AddRouter(Common, base.ECMCommand): 118 | """ Add router to a feature. """ 119 | 120 | name = 'addrouter' 121 | 122 | def setup_args(self, parser): 123 | self.add_ident_argument() 124 | self.add_router_argument('router') 125 | super().setup_args(parser) 126 | 127 | def run(self, args): 128 | router = self.api.get_by_id_or_name('routers', args.router) 129 | self.api.post('featurebindings', args.featureid, 'routers', 130 | [router['resource_uri']]) 131 | 132 | 133 | class RemoveRouter(Common, base.ECMCommand): 134 | """ Remove router from a feature. """ 135 | 136 | name = 'removerouter' 137 | 138 | def setup_args(self, parser): 139 | self.add_ident_argument() 140 | self.add_router_argument('router') 141 | super().setup_args(parser) 142 | 143 | def run(self, args): 144 | router = self.api.get_by_id_or_name('routers', args.router) 145 | self.api.delete('featurebindings', args.featureid, 'routers', 146 | data=[router['resource_uri']]) 147 | 148 | 149 | class Features(base.ECMCommand): 150 | """ View and edit features (bindings). 151 | 152 | For features that have router bindings or other settings those can be 153 | managed with these commands too. """ 154 | 155 | name = 'features' 156 | 157 | def __init__(self, *args, **kwargs): 158 | super().__init__(*args, **kwargs) 159 | self.add_subcommand(List, default=True) 160 | self.add_subcommand(Delete) 161 | self.add_subcommand(Routers) 162 | self.add_subcommand(AddRouter) 163 | self.add_subcommand(RemoveRouter) 164 | 165 | command_classes = [Features] 166 | -------------------------------------------------------------------------------- /ecmcli/commands/firmware.py: -------------------------------------------------------------------------------- 1 | """ 2 | Commands for managing firmware versions of routers. 3 | """ 4 | 5 | import json 6 | import shellish 7 | import sys 8 | from . import base 9 | 10 | 11 | class AvailMixin(object): 12 | 13 | _fw_avail_cache = {} 14 | 15 | def available_firmware(self, product_urn=None, product_name=None, 16 | version=None): 17 | """ Lookup available firmware versions by product/version tuple. """ 18 | key = product_urn or product_name 19 | try: 20 | avail = self._fw_avail_cache[key] 21 | except KeyError: 22 | if product_urn: 23 | query = dict(product=product_urn.split('/')[-2]) 24 | elif product_name: 25 | query = dict(product__name=product_name) 26 | else: 27 | raise TypeError('product_urn or product_name required') 28 | avail = list(self.api.get_pager('firmwares', expand='product', 29 | order_by='release_date', **query)) 30 | if not avail: 31 | raise ValueError("Invalid product query: %s" % (query)) 32 | product = avail[0]['product'] 33 | self._fw_avail_cache[product['resource_uri']] = avail 34 | self._fw_avail_cache[product['name']] = avail 35 | return [x for x in avail if x['version'] > (version or "")] 36 | 37 | 38 | class Active(base.ECMCommand): 39 | """ Show the firmware versions being actively used. """ 40 | 41 | name = 'active' 42 | 43 | def run(self, args): 44 | data = [['Firmware Version', 'Routers', 'Standalone']] 45 | fw_field = 'actual_firmware__version' 46 | data.extend(self.api.get('routers', group_by=fw_field, 47 | count='id,group')) 48 | standalone = lambda x: x['id_count'] - x['group_count'] 49 | shellish.tabulate(data, title='Active Firmware Stats', 50 | accessors=[fw_field, 'id_count', standalone]) 51 | 52 | 53 | class Updates(AvailMixin, base.ECMCommand): 54 | """ Scan routers to see if a firmware update is available. 55 | If no updates are found show nothing, otherwise a report of the available 56 | updates is printed and the command produces a truthy return value. """ 57 | 58 | name = 'updates' 59 | 60 | def run(self, args): 61 | fw_field = 'actual_firmware__version' 62 | no_updates = True 63 | for x in self.api.get('routers', group_by=fw_field + 64 | ',product__name', count='id'): 65 | if x[fw_field] is None: 66 | continue # unsupported/dev fw 67 | avail = self.available_firmware(product_name=x['product__name'], 68 | version=x[fw_field]) 69 | name = '%s v%s (%s devices)' % (x['product__name'], x[fw_field], 70 | x['id_count']) 71 | if avail: 72 | no_updates = False 73 | shellish.vtmlprint("Updates available for: %s" % name) 74 | for xx in avail: 75 | print("\tv%s (Release Date: %s)" % (xx['version'], 76 | xx['release_date'].date())) 77 | if not no_updates: 78 | raise SystemExit(1) 79 | 80 | 81 | class Upgrade(AvailMixin, base.ECMCommand): 82 | """ Upgrade firmware for one routers or a group of routers. 83 | For ungrouped routers we can start a firmware upgrade directly. For 84 | grouped devices the target firmware version for the group can be altered. 85 | """ 86 | 87 | name = 'upgrade' 88 | use_pager = False 89 | 90 | def setup_args(self, parser): 91 | or_group = parser.add_mutually_exclusive_group(required=True) 92 | self.add_router_argument('--router', parser=or_group) 93 | self.add_group_argument('--group', parser=or_group) 94 | self.add_firmware_argument('--upgrade-to') 95 | self.add_argument('--force', '-f', action='store_true', 96 | help='Do not confirm upgrade.') 97 | 98 | def run(self, args): 99 | router = group = None 100 | if args.router: 101 | router = self.api.get_by_id_or_name('routers', args.router) 102 | if router['group']: 103 | raise SystemExit('Cannot upgrade router in group') 104 | product = router['product'] 105 | firmware = router['actual_firmware'] 106 | ent = router 107 | type_ = 'router' 108 | elif args.group: 109 | group = self.api.get_by_id_or_name('groups', args.group) 110 | product = group['product'] 111 | firmware = group['target_firmware'] 112 | ent = group 113 | type_ = 'group' 114 | else: 115 | raise RuntimeError("Arguments misconfigured") 116 | avail = self.available_firmware(product_urn=product) 117 | for fromfw in avail: 118 | if firmware == fromfw['resource_uri']: 119 | break 120 | else: 121 | raise RuntimeError("Originating firmware not found in system") 122 | if not args.upgrade_to: 123 | tofw = avail[-1] 124 | else: 125 | for tofw in avail: 126 | if args.upgrade_to == tofw['version']: 127 | break 128 | else: 129 | raise SystemExit("Invalid firmware for product") 130 | if tofw == fromfw: 131 | raise SystemExit("Target version matches current version") 132 | direction = 'down' if tofw['version'] < fromfw['version'] else 'up' 133 | if not args.force: 134 | self.confirm('Confirm %sgrade of %s "%s" from %s to %s' % ( 135 | direction, type_, ent['name'], fromfw['version'], 136 | tofw['version'])) 137 | self.api.put(dict(target_firmware=tofw['resource_uri']), 138 | urn=ent['resource_uri']) 139 | 140 | 141 | class DTD(base.ECMCommand): 142 | """ Show the DTD for a firmware version. 143 | Each firmware has a configuration data-type-definition which describes and 144 | regulates the structure and types that go into a router's config. """ 145 | 146 | name = 'dtd' 147 | 148 | def setup_args(self, parser): 149 | self.add_firmware_argument('--firmware', help='Limit display to this ' 150 | 'firmware version.') 151 | self.add_product_argument('--product', help='Limit display to this ' 152 | 'product type.') 153 | self.add_argument('path', nargs='?', help='Dot notation offset into ' 154 | 'the DTD tree.') 155 | self.add_argument('--shallow', '-s', action='store_true', 156 | help='Only show one level of the DTD.') 157 | output = parser.add_argument_group('output formats', 'Change the ' 158 | 'DTD output format.') 159 | for x in ('json', 'tree'): 160 | self.add_argument('--%s' % x, dest='format', action='store_const', 161 | const=x, parser=output) 162 | self.add_file_argument('--output-file', '-o', mode='w', default='-', 163 | metavar="OUTPUT_FILE", parser=output) 164 | super().setup_args(parser) 165 | 166 | def walk_dtd(self, dtd, path=None): 167 | """ Walk into a DTD tree. The path argument is the path as it relates 168 | to a rendered config and not the actual dtd structure which is double 169 | nested to contain more information about the structure. """ 170 | offt = {'nodes': dtd} 171 | if path: 172 | for x in path.split('.'): 173 | try: 174 | offt = offt['nodes'][x] 175 | except KeyError: 176 | raise SystemExit('DTD path not found: %s' % path) 177 | else: 178 | path = '' 179 | return {path: offt} 180 | 181 | def run(self, args): 182 | filters = {} 183 | if args.firmware: 184 | filters['version'] = args.firmware 185 | if args.product: 186 | filters['product__name'] = args.product 187 | firmwares = self.api.get('firmwares', expand='dtd', limit=1, **filters) 188 | if not firmwares: 189 | raise SystemExit("No firmware DTD matches this specification.") 190 | if firmwares.meta['total_count'] > 1: 191 | shellish.vtmlprint('WARNING: More than one ' 192 | 'firmware DTD found for this specification.', 193 | file=sys.stderr) 194 | dtd = firmwares[0]['dtd']['value'] 195 | with args.output_file as f: 196 | dtd = self.walk_dtd(dtd, args.path) 197 | if args.shallow: 198 | if 'nodes' in dtd: 199 | dtd['nodes'] = list(dtd['nodes']) 200 | if args.format == 'json': 201 | print(json.dumps(dtd, indent=4, sort_keys=True), file=f) 202 | else: 203 | shellish.treeprint(dtd, file=f) 204 | 205 | 206 | class Firmware(base.ECMCommand): 207 | """ Manage ECM Firmware. """ 208 | 209 | name = 'firmware' 210 | 211 | def __init__(self, *args, **kwargs): 212 | super().__init__(*args, **kwargs) 213 | self.add_subcommand(Active, default=True) 214 | self.add_subcommand(Updates) 215 | self.add_subcommand(Upgrade) 216 | self.add_subcommand(DTD) 217 | 218 | command_classes = [Firmware] 219 | -------------------------------------------------------------------------------- /ecmcli/commands/groups.py: -------------------------------------------------------------------------------- 1 | """ 2 | Manage ECM Groups. 3 | """ 4 | 5 | import collections 6 | import copy 7 | import difflib 8 | import json 9 | import shellish 10 | import sys 11 | from . import base 12 | 13 | 14 | PatchStat = collections.namedtuple('PatchStat', 'adds, removes') 15 | 16 | 17 | def patch_stats(patch): 18 | adds, removes = patch 19 | adds = list(base.totuples(adds)) 20 | return PatchStat(len(adds), len(removes)) 21 | 22 | 23 | def patch_validate(patch): 24 | if not isinstance(patch, (list, tuple)) or len(patch) != 2: 25 | raise TypeError('Patch must be a 2 item sequence') 26 | for x in patch[1]: 27 | if not isinstance(x, (list, tuple)): 28 | raise TypeError('Removals must be sequences') 29 | if not isinstance(patch[0], dict): 30 | raise TypeError('Additions must be an dict tree') 31 | 32 | 33 | class Printer(object): 34 | """ Mixin for printing commands. """ 35 | 36 | expands = ','.join([ 37 | 'statistics', 38 | 'product', 39 | 'account', 40 | 'target_firmware', 41 | 'configuration' 42 | ]) 43 | 44 | def setup_args(self, parser): 45 | self.inject_table_factory() 46 | super().setup_args(parser) 47 | 48 | def bundle_group(self, group): 49 | group['product'] = group['product']['name'] 50 | group['firmware'] = group['target_firmware']['version'] 51 | stats = group['statistics'] 52 | group['online'] = stats['online_count'] 53 | group['offline'] = stats['offline_count'] 54 | group['total'] = stats['device_count'] 55 | group['account_name'] = group['account']['name'] 56 | group['suspended'] = stats['suspended_count'] 57 | group['synchronized'] = stats['synched_count'] 58 | return group 59 | 60 | def ratio_format(self, actual, possible, high=0.99, med=0.90, low=0.90): 61 | """ Color format output for a ratio. Bigger is better. """ 62 | if not possible: 63 | return '' 64 | pct = round((actual / possible) * 100) 65 | if pct > .99: 66 | color = 'green' 67 | elif pct > .90: 68 | color = 'yellow' 69 | else: 70 | color = 'red' 71 | return '%s/%s (<%s>%d%%)' % (actual, possible, color, pct, color) 72 | 73 | def online_accessor(self, group): 74 | """ Table accessor for online column. """ 75 | return self.ratio_format(group['online'], group['total']) 76 | 77 | def sync_accessor(self, group): 78 | """ Table accessor for config sync stats. """ 79 | suspended = group['suspended'] 80 | sync = group['synchronized'] 81 | total = group['total'] 82 | ret = self.ratio_format(sync, total) 83 | if suspended: 84 | ret += ', %d suspended' % suspended 85 | return ret 86 | 87 | def patch_accessor(self, group): 88 | """ Table accessor for patch stats. """ 89 | stat = patch_stats(group['configuration']) 90 | output = [] 91 | if stat.adds: 92 | output.append('+%d' % stat.adds) 93 | if stat.removes: 94 | output.append('-%d' % stat.removes) 95 | return '/'.join(output) 96 | 97 | def printer(self, groups): 98 | fields = ( 99 | ("id", 'ID'), 100 | ("name", 'Name'), 101 | ("account_name", 'Account'), 102 | ("product", 'Product'), 103 | ("firmware", 'Firmware'), 104 | (self.online_accessor, 'Online'), 105 | (self.sync_accessor, 'Config Sync'), 106 | (self.patch_accessor, 'Config Patch'), 107 | ) 108 | with self.make_table(headers=[x[1] for x in fields], 109 | accessors=[x[0] for x in fields]) as t: 110 | t.print(map(self.bundle_group, groups)) 111 | 112 | 113 | class List(Printer, base.ECMCommand): 114 | """ List group(s). """ 115 | 116 | name = 'ls' 117 | 118 | def setup_args(self, parser): 119 | self.add_group_argument(nargs='?') 120 | super().setup_args(parser) 121 | 122 | def run(self, args): 123 | if args.ident: 124 | groups = [self.api.get_by_id_or_name('groups', args.ident, 125 | expand=self.expands)] 126 | else: 127 | groups = self.api.get_pager('groups', expand=self.expands) 128 | self.printer(groups) 129 | 130 | 131 | class Create(base.ECMCommand): 132 | """ Create a new group. 133 | A group mostly represents configuration for more than one device, but 134 | also manages settings such as alerts and log acquisition. """ 135 | 136 | name = 'create' 137 | use_pager = False 138 | 139 | def setup_args(self, parser): 140 | self.add_argument('--name') 141 | self.add_product_argument('--product') 142 | self.add_firmware_argument('--firmware') 143 | self.add_account_argument('--account') 144 | 145 | def run(self, args): 146 | name = args.name or input('Name: ') 147 | if not name: 148 | raise SystemExit("Name required") 149 | 150 | product = args.product or input('Product: ') 151 | products = dict((x['name'], x) 152 | for x in self.api.get_pager('products')) 153 | if product not in products: 154 | if not product: 155 | print("Product required") 156 | else: 157 | print("Invalid product:", product) 158 | print("\nValid products...") 159 | for x in sorted(products): 160 | print("\t", x) 161 | raise SystemExit(1) 162 | 163 | fw = args.firmware or input('Firmware: ') 164 | firmwares = dict((x['version'], x) 165 | for x in self.api.get_pager('firmwares', 166 | product=products[product]['id'])) 167 | if fw not in firmwares: 168 | if not fw: 169 | print("Firmware required") 170 | else: 171 | print("Invalid firmware:", fw) 172 | print("\nValid firmares...") 173 | for x in sorted(firmwares): 174 | print("\t", x) 175 | raise SystemExit(1) 176 | 177 | group = { 178 | "name": name, 179 | "product": products[product]['resource_uri'], 180 | "target_firmware": firmwares[fw]['resource_uri'] 181 | } 182 | if args.account: 183 | account = self.api.get_by_id_or_name('accounts', args.account) 184 | group['account'] = account['resource_uri'] 185 | self.api.post('groups', group) 186 | 187 | 188 | class Config(base.ECMCommand): 189 | """ Show or alter the group configuration. 190 | The configuration stored in a group consists of a patch tuple; 191 | (additions, removals) respectively. The patch format is a JSON array with 192 | exactly 2 entries to match the aforementioned tuple. 193 | 194 | Additions are a JSON object that holds a subset of a routers configuration 195 | tree. When paths, or nodes, are absent they are simply ignored. The 196 | config system will crawl the object looking for "leaf" nodes, which are 197 | applied as an overlay on the device. 198 | 199 | The actual algo for managing this layering is rather complex, so it is 200 | advised that you experiment with this feature in a test environment before 201 | attempting action on a production environment. 202 | 203 | The deletions section contains lists of paths to be removed from the 204 | router's config. This is used in cases where you want to explicitly take 205 | out a configuration that is on the router by default. A common case is 206 | removing one of the default LAN networks, such as the guest network. 207 | 208 | Example patch that updates config.system.desc and has no deletions. 209 | 210 | [{"system": {"desc": "New Value Here"}}, []] 211 | """ 212 | 213 | name = 'config' 214 | 215 | def __init__(self, *args, **kwargs): 216 | super().__init__(*args, **kwargs) 217 | self.add_subcommand(ConfigShow, default=True) 218 | self.add_subcommand(ConfigSet) 219 | self.add_subcommand(ConfigClear) 220 | 221 | 222 | class ConfigShow(base.ECMCommand): 223 | """ Show the config patch used for a group. """ 224 | 225 | name = 'show' 226 | 227 | def setup_args(self, parser): 228 | self.add_group_argument() 229 | self.add_argument('--json', action='store_true') 230 | self.add_file_argument('--output-file', '-o', default='-', mode='w') 231 | 232 | def run(self, args): 233 | group = self.api.get_by_id_or_name('groups', args.ident, 234 | expand='configuration') 235 | adds, removes = group['configuration'] 236 | with args.output_file as f: 237 | if f is not sys.stdout: 238 | args.json = True 239 | if args.json: 240 | print(json.dumps([adds, removes], indent=4), file=f) 241 | else: 242 | treelines = shellish.treeprint({ 243 | "": adds, 244 | "": removes 245 | }, render_only=True) 246 | for x in treelines: 247 | print(x, file=f) 248 | 249 | 250 | class ConfigSet(base.ECMCommand): 251 | """ Update the config of a group. """ 252 | 253 | name = 'set' 254 | use_pager = False 255 | 256 | def setup_args(self, parser): 257 | self.add_group_argument() 258 | ex = parser.add_mutually_exclusive_group(required=True) 259 | self.add_file_argument('--replace', metavar='PATCH_FILE', parser=ex, 260 | help='Replace entire group config.') 261 | self.add_file_argument('--merge', metavar='PATCH_FILE', parser=ex, 262 | help='Merge new patch into existing group ' 263 | 'config.') 264 | self.add_argument('--set', metavar='KEY=JSON_VALUE', parser=ex, 265 | help='Set a single value in the group config.') 266 | self.add_argument('--force', '-f', action='store_true', help='Do not ' 267 | 'prompt for confirmation.') 268 | self.add_argument('--json-diff', action='store_true', help='Show diff ' 269 | 'of JSON values instead of key=value tuples.') 270 | 271 | def inplace_merge(self, dst, src): 272 | """ Copy bits from src into dst. """ 273 | for key, val in src.items(): 274 | if not isinstance(val, dict) or key not in dst: 275 | dst[key] = val 276 | else: 277 | self.inplace_merge(dst[key], src[key]) 278 | 279 | def merge(self, orig, overlay): 280 | updates = copy.deepcopy(orig[0]) 281 | self.inplace_merge(updates, overlay[0]) 282 | removes = list(set(map(tuple, orig[1])) | set(map(tuple, overlay[1]))) 283 | return updates, removes 284 | 285 | def run(self, args): 286 | group = self.api.get_by_id_or_name('groups', args.ident, 287 | expand='configuration') 288 | cur_patch = group['configuration'] 289 | if args.replace: 290 | with args.replace as f: 291 | patch = json.load(f) 292 | patch_validate(patch) 293 | elif args.merge: 294 | with args.merge as f: 295 | overlay = json.load(f) 296 | patch_validate(overlay) 297 | patch = self.merge(cur_patch, overlay) 298 | elif args.set: 299 | path, value = args.set.split('=', 1) 300 | path = path.strip().split('.') 301 | value = json.loads(value) 302 | updates = copy.deepcopy(cur_patch[0]) 303 | offt = updates 304 | for x in path[:-1]: 305 | if x in offt: 306 | offt = offt[x] 307 | else: 308 | offt[x] = {} 309 | offt[path[-1]] = value 310 | patch = [updates, cur_patch[1]] 311 | patch_validate(patch) 312 | if args.json_diff: 313 | oldjson = json.dumps(cur_patch, indent=4, sort_keys=True) 314 | newjson = json.dumps(patch, indent=4, sort_keys=True) 315 | printed = False 316 | for line in difflib.unified_diff(oldjson.splitlines(True), 317 | newjson.splitlines(True), 318 | fromfile='current config', 319 | tofile='proposed config', n=10): 320 | if line.startswith('---') or line.startswith('+++'): 321 | line = '%s' % line 322 | elif line.startswith('@@'): 323 | line = '%s' % line 324 | elif line[0] == '-': 325 | line = '%s' % line 326 | elif line[0] == '+': 327 | line = '%s' % line 328 | shellish.vtmlprint(line, end='') 329 | printed = True 330 | if printed: 331 | print() 332 | else: 333 | old_adds = dict(base.totuples(cur_patch[0])) 334 | new_adds = dict(base.totuples(patch[0])) 335 | old_add_keys = set(old_adds) 336 | new_add_keys = set(new_adds) 337 | for x in old_add_keys - new_add_keys: 338 | shellish.vtmlprint('Unsetting: %s=%s' % ( 339 | x, old_adds[x])) 340 | for x in new_add_keys - old_add_keys: 341 | shellish.vtmlprint('Adding: %s=%s' % ( 342 | x, new_adds[x])) 343 | for x in new_add_keys & old_add_keys: 344 | if old_adds[x] != new_adds[x]: 345 | shellish.vtmlprint('Changing: %s (%s' 346 | ' -> %s)' % 347 | (x, old_adds[x], new_adds[x])) 348 | old_removes = set(map(tuple, cur_patch[1])) 349 | new_removes = set(map(tuple, patch[1])) 350 | for x in old_removes - new_removes: 351 | shellish.vtmlprint('Unsetting removal: %s' % ( 352 | '.'.join(map(str, x)))) 353 | for x in new_removes - old_removes: 354 | shellish.vtmlprint('Adding removal: %s' % ( 355 | '.'.join(map(str, x)))) 356 | self.confirm('Confirm update of config of: %s' % group['name']) 357 | self.api.put('groups', group['id'], {"configuration": patch}) 358 | 359 | 360 | class ConfigClear(base.ECMCommand): 361 | """ Clear the config of a group. """ 362 | 363 | name = 'clear' 364 | use_pager = False 365 | 366 | def setup_args(self, parser): 367 | self.add_group_argument() 368 | self.add_argument('--force', '-f', action='store_true', 369 | help='Do not prompt for confirmation.') 370 | 371 | def run(self, args): 372 | group = self.api.get_by_id_or_name('groups', args.ident, 373 | expand='configuration') 374 | if not args.force: 375 | self.confirm('Confirm config clear of: %s' % group['name']) 376 | self.api.put('groups', group['id'], {"configuration": [{}, []]}) 377 | 378 | 379 | class Edit(base.ECMCommand): 380 | """ Edit group attributes. """ 381 | 382 | name = 'edit' 383 | 384 | def setup_args(self, parser): 385 | self.add_group_argument() 386 | self.add_argument('--name') 387 | self.add_firmware_argument('--firmware') 388 | 389 | def run(self, args): 390 | group = self.api.get_by_id_or_name('groups', args.ident, 391 | expand='product') 392 | updates = {} 393 | if args.name: 394 | updates['name'] = args.name 395 | if args.firmware: 396 | fw = self.api.get_by(['version'], 'firmwares', args.firmware, 397 | product=group['product']['id']) 398 | updates['target_firmware'] = fw['resource_uri'] 399 | self.api.put('groups', group['id'], updates) 400 | 401 | 402 | class Delete(base.ECMCommand): 403 | """ Delete one or more groups. """ 404 | 405 | name = 'rm' 406 | use_pager = False 407 | 408 | def setup_args(self, parser): 409 | self.add_group_argument('idents', nargs='+') 410 | self.add_argument('-f', '--force', action="store_true") 411 | 412 | def run(self, args): 413 | for ident in args.idents: 414 | group = self.api.get_by_id_or_name('groups', ident) 415 | if not args.force and \ 416 | not self.confirm('Delete group: %s' % group['name'], 417 | exit=False): 418 | continue 419 | self.api.delete('groups', group['id']) 420 | 421 | 422 | class Move(base.ECMCommand): 423 | """ Move group to a different account. """ 424 | 425 | name = 'mv' 426 | 427 | def setup_args(self, parser): 428 | self.add_group_argument() 429 | self.add_account_argument('new_account', 430 | metavar='NEW_ACCOUNT_ID_OR_NAME') 431 | self.add_argument('-f', '--force', action="store_true") 432 | 433 | def run(self, args): 434 | group = self.api.get_by_id_or_name('groups', args.ident) 435 | account = self.api.get_by_id_or_name('accounts', args.new_account) 436 | self.api.put('groups', group['id'], 437 | {"account": account['resource_uri']}) 438 | 439 | 440 | class Search(Printer, base.ECMCommand): 441 | """ Search for groups. """ 442 | 443 | name = 'search' 444 | fields = ['name', ('firmware', 'target_firmware.version'), 445 | ('product', 'product.name'), ('account', 'account.name')] 446 | 447 | def setup_args(self, parser): 448 | searcher = self.make_searcher('groups', self.fields) 449 | self.lookup = searcher.lookup 450 | self.add_search_argument(searcher) 451 | super().setup_args(parser) 452 | 453 | def run(self, args): 454 | results = self.lookup(args.search, expand=self.expands) 455 | if not results: 456 | raise SystemExit("No Results For: %s" % ' '.join(args.search)) 457 | self.printer(results) 458 | 459 | 460 | class Groups(base.ECMCommand): 461 | """ Manage ECM Groups. """ 462 | 463 | name = 'groups' 464 | 465 | def __init__(self, *args, **kwargs): 466 | super().__init__(*args, **kwargs) 467 | self.add_subcommand(List, default=True) 468 | self.add_subcommand(Create) 469 | self.add_subcommand(Edit) 470 | self.add_subcommand(Delete) 471 | self.add_subcommand(Move) 472 | self.add_subcommand(Search) 473 | self.add_subcommand(Config) 474 | 475 | command_classes = [Groups] 476 | -------------------------------------------------------------------------------- /ecmcli/commands/login.py: -------------------------------------------------------------------------------- 1 | """ 2 | Login to ECM. 3 | """ 4 | 5 | import getpass 6 | from . import base 7 | 8 | 9 | class Login(base.ECMCommand): 10 | """ Login to ECM. """ 11 | 12 | name = 'login' 13 | use_pager = False 14 | 15 | def setup_args(self, parser): 16 | self.add_argument('username', nargs='?') 17 | 18 | def run(self, args): 19 | last_username = self.api.get_session(use_last=True)[0] 20 | prompt = 'Username' 21 | if last_username: 22 | prompt += ' [%s]: ' % last_username 23 | else: 24 | prompt += ': ' 25 | username = args.username or input(prompt) 26 | if not username: 27 | if last_username: 28 | username = last_username 29 | else: 30 | raise SystemExit("Username required") 31 | if not self.api.load_session(username): 32 | password = getpass.getpass() 33 | self.api.set_auth(username, password) 34 | 35 | 36 | class Logout(base.ECMCommand): 37 | """ Logout from ECM. """ 38 | 39 | name = 'logout' 40 | use_pager = False 41 | 42 | def run(self, args): 43 | self.api.reset_auth() 44 | 45 | command_classes = [Login, Logout] 46 | -------------------------------------------------------------------------------- /ecmcli/commands/logs.py: -------------------------------------------------------------------------------- 1 | """ 2 | Download router logs from ECM. 3 | """ 4 | 5 | import datetime 6 | import html 7 | import time 8 | from . import base 9 | 10 | 11 | class Logs(base.ECMCommand): 12 | """ Show or clear router logs. """ 13 | 14 | use_pager = False 15 | max_follow_sleep = 30 16 | name = 'logs' 17 | levels = ['debug', 'info', 'warning', 'error', 'critical'] 18 | level_colors = { 19 | "debug": 'dim', 20 | "info": 'blue', 21 | "warning": 'yellow', 22 | "warn": 'yellow', # Yes, we see both warning and warn now. 23 | "error": 'red', 24 | "critical": 'red' 25 | } 26 | 27 | def setup_args(self, parser): 28 | self.add_router_argument('idents', nargs='*') 29 | self.add_argument('--clear', action='store_true', help="Clear logs") 30 | self.add_argument('-l', '--level', choices=self.levels) 31 | self.add_argument('-f', '--follow', action='store_true', 32 | help="Follow live logs (online routers only)") 33 | self.add_argument('-n', '--numlines', type=int, default=20, 34 | help="Number of lines to display") 35 | self.inject_table_factory() 36 | 37 | def table_timestamp_acc(self, record): 38 | """ Table accessor for log timestamp. """ 39 | dt = datetime.datetime.fromtimestamp(record['timestamp']) 40 | return '%d:%s' % (dt.hour % 12, dt.strftime('%M:%S %p')) 41 | 42 | def table_levelname_acc(self, record): 43 | """ Table accessor for colorized log level name. """ 44 | color = self.level_colors[record['levelname'].lower()] 45 | opentags = ['<%s>' % color] 46 | closetags = ['' % color] 47 | if record['levelname'] == 'CRITICAL': 48 | opentags.append('') 49 | closetags.insert(0, '') 50 | return ''.join(opentags) + record['levelname'] + ''.join(closetags) 51 | 52 | def run(self, args): 53 | filters = {} 54 | if args.follow: 55 | filters['state'] = 'online' 56 | filters['product__series'] = 3 57 | if args.idents: 58 | routers = [self.api.get_by_id_or_name('routers', x, **filters) 59 | for x in args.idents] 60 | else: 61 | routers = self.api.get_pager('routers', **filters) 62 | if args.clear: 63 | self.clear(args, routers) 64 | else: 65 | with self.make_table(accessors=( 66 | self.table_timestamp_acc, 67 | lambda x: x['router']['name'], 68 | self.table_levelname_acc, 69 | 'source', 70 | 'message', 71 | 'exc' 72 | ), columns=( 73 | { 74 | "width": 11, 75 | "padding": 1, 76 | "align": 'right' 77 | }, 78 | { 79 | "minwidth": 12 80 | }, 81 | { 82 | "minwidth": 5, 83 | "padding": 1 84 | }, 85 | { 86 | "minwidth": 6 87 | }, 88 | None, 89 | None, 90 | )) as table: 91 | if args.follow: 92 | self.follow(args, routers, table) 93 | else: 94 | self.view(args, routers, table) 95 | 96 | def clear(self, args, routers): 97 | for rinfo in routers: 98 | print("Clearing logs for: %s (%s)" % (rinfo['name'], rinfo['id'])) 99 | self.api.delete('logs', rinfo['id']) 100 | 101 | def view(self, args, routers, table): 102 | filters = {} 103 | if args.level: 104 | filters['levelname'] = args.level.upper() 105 | for rinfo in routers: 106 | print("Logs for: %s (%s)" % (rinfo['name'], rinfo['id'])) 107 | for x in self.api.get_pager('logs', rinfo['id'], **filters): 108 | x['mac'] = rinfo['mac'] 109 | print('%(timestamp)s [%(mac)s] [%(levelname)8s] ' 110 | '[%(source)18s] %(message)s' % x) 111 | 112 | def follow(self, args, routers, table): 113 | lastseen = {} 114 | router_ids = ','.join(x['id'] for x in routers) 115 | sleep = 0 116 | while True: 117 | logs = [] 118 | for router in self.api.remote('status.log', id__in=router_ids): 119 | if not router['results']: 120 | continue 121 | logdata = router['results'][0]['data'] 122 | logdata.sort(key=lambda x: x[0]) 123 | lastshown = lastseen.get(router['id']) 124 | offt = -args.numlines 125 | if lastshown is not None: 126 | for offt, x in enumerate(logdata): 127 | if x == lastshown: 128 | offt += 1 129 | break 130 | else: 131 | raise RuntimeError("Did not find tailing edge") 132 | updates = logdata[offt:] 133 | if updates: 134 | logs.extend({ 135 | "router": router['router'], 136 | "timestamp": x[0], 137 | "levelname": x[1], 138 | "source": x[2], 139 | "message": x[3] and html.unescape(x[3]) 140 | } for x in updates) 141 | lastseen[router['id']] = logdata[-1] 142 | if logs: 143 | logs.sort(key=lambda x: x['timestamp']) 144 | table.print(logs) 145 | sleep = 0 146 | else: 147 | sleep += 0.200 148 | time.sleep(min(self.max_follow_sleep, sleep)) 149 | 150 | command_classes = [Logs] 151 | -------------------------------------------------------------------------------- /ecmcli/commands/messages.py: -------------------------------------------------------------------------------- 1 | """ 2 | A mail command for user/system messages. 3 | """ 4 | 5 | import functools 6 | import humanize 7 | import shellish 8 | import textwrap 9 | from . import base 10 | 11 | 12 | def ack_wrap(fn): 13 | """ Add emphasis to unread cells. """ 14 | 15 | @functools.wraps(fn) 16 | def wrap(*args): 17 | res = fn(*args) 18 | row = args[-1] 19 | if not row.get('is_read') and not row.get('confirmed'): 20 | return '%s' % res 21 | else: 22 | return res 23 | return wrap 24 | 25 | 26 | class Common(object): 27 | 28 | def get_messages(self): 29 | """ Combine system and user message streams. """ 30 | messages = list(self.api.get_pager('system_message', 31 | type__nexact='tos')) 32 | messages.extend(self.api.get_pager('user_messages')) 33 | messages.sort(key=lambda x: x['created'], reverse=True) 34 | return messages 35 | 36 | @functools.lru_cache() 37 | def get_user(self, user_urn): 38 | return self.api.get(urn=user_urn) 39 | 40 | def humantime(self, dt): 41 | if dt is None: 42 | return '' 43 | since = dt.now(tz=dt.tzinfo) - dt 44 | return humanize.naturaltime(since) 45 | 46 | 47 | class List(Common, base.ECMCommand): 48 | """ List messages. """ 49 | 50 | name = 'ls' 51 | 52 | def setup_args(self, parser): 53 | self.inject_table_factory() 54 | self.fields = ( 55 | (self.id_acc, 'ID'), 56 | (self.created_acc, 'Created'), 57 | (self.user_acc, 'From'), 58 | (self.title_acc, 'Title'), 59 | (self.expires_acc, 'Expires'), 60 | ) 61 | super().setup_args(parser) 62 | 63 | @ack_wrap 64 | def id_acc(self, x): 65 | return '%s-%s' % (x['type'], x['id']) 66 | 67 | @ack_wrap 68 | def created_acc(self, x): 69 | return self.humantime(x['created']) 70 | 71 | @ack_wrap 72 | def user_acc(self, x): 73 | if x['type'] == 'sys': 74 | return '[ECM]' 75 | elif x['type'] == 'usr': 76 | return self.get_user(x['user'])['username'] 77 | else: 78 | return '[UNSUPPORTED]' 79 | 80 | @ack_wrap 81 | def title_acc(self, x): 82 | return x['title'] 83 | 84 | @ack_wrap 85 | def expires_acc(self, x): 86 | return self.humantime(x['expires']) 87 | 88 | def run(self, args): 89 | with self.make_table(headers=[x[1] for x in self.fields], 90 | accessors=[x[0] for x in self.fields]) as t: 91 | t.print(self.get_messages()) 92 | 93 | 94 | class Read(Common, base.ECMCommand): 95 | """ Read/Acknowledge a message. """ 96 | 97 | name = 'read' 98 | 99 | def setup_args(self, parser): 100 | self.add_argument('ident', metavar='MESSAGE_ID', 101 | complete=self.complete_id) 102 | super().setup_args(parser) 103 | 104 | def format_msg(self, msg): 105 | return shellish.htmlrender(msg) 106 | 107 | @shellish.ttl_cache(60) 108 | def cached_messages(self): 109 | return frozenset('%s-%s' % (x['type'], x['id']) 110 | for x in self.get_messages()) 111 | 112 | def complete_id(self, prefix, args): 113 | return set(x for x in self.cached_messages() 114 | if x.startswith(prefix)) 115 | 116 | def run(self, args): 117 | ident = args.ident.split('-') 118 | if len(ident) != 2: 119 | raise SystemExit("Invalid message identity: %s" % args.ident) 120 | res = { 121 | "sys": 'system_message', 122 | "usr": 'user_messages' 123 | }.get(ident[0]) 124 | if res is None: 125 | raise SystemExit("Invalid message type: %s" % args.ident[0]) 126 | # NOTE: system_message does not support detail get. 127 | msg = self.api.get_by(['id'], res, ident[1]) 128 | shellish.vtmlprint('Created: %s' % msg['created']) 129 | shellish.vtmlprint('Subject: %s' % msg['title']) 130 | if 'message' in msg: 131 | output = shellish.htmlrender(msg['message']) 132 | for x in str(output).split('\n'): 133 | print(textwrap.fill(x.strip(), break_long_words=False, 134 | replace_whitespace=False, 135 | break_on_hyphens=False)) 136 | if ident[0] == 'usr': 137 | if not msg['is_read']: 138 | self.api.put(res, ident[1], {"is_read": True}) 139 | elif not msg['confirmed']: 140 | self.api.post('system_message_confirm', 141 | {"message": msg['resource_uri']}) 142 | 143 | 144 | class Messages(base.ECMCommand): 145 | """ Read/Acknowledge any messages from the system. """ 146 | 147 | name = 'messages' 148 | 149 | def __init__(self, *args, **kwargs): 150 | super().__init__(*args, **kwargs) 151 | self.add_subcommand(List, default=True) 152 | self.add_subcommand(Read) 153 | 154 | command_classes = [Messages] 155 | -------------------------------------------------------------------------------- /ecmcli/commands/netflow.py: -------------------------------------------------------------------------------- 1 | """ 2 | Monitor live network flow of one or more devices. 3 | """ 4 | 5 | import collections 6 | from . import base 7 | 8 | 9 | class Monitor(base.ECMCommand): 10 | """ Monitor live network flows. """ 11 | 12 | name = 'monitor' 13 | use_pager = False 14 | 15 | def setup_args(self, parser): 16 | self.add_router_argument('idents', nargs='*') 17 | self.inject_table_factory() 18 | 19 | def run(self, args): 20 | filters = { 21 | "state": 'online', 22 | "product__series": 3 23 | } 24 | if args.idents: 25 | routers = [self.api.get_by_id_or_name('routers', x) 26 | for x in args.idents] 27 | filters["id__in"] = ','.join(x['id'] for x in routers) 28 | fields = collections.OrderedDict(( 29 | ("flow.start", self.flow_start_acc), 30 | ("flow.end", self.flow_end_acc), 31 | ("ip.daddr", 'orig.ip.daddr'), 32 | ("ip.protocol", 'orig.ip.protocol'), 33 | ("ip.saddr", 'orig.ip.saddr'), 34 | ("ip.dport", 'orig.ip.dport'), 35 | ("ip.sport", 'orig.ip.sport'), 36 | ("raw.pktcount", 'orig.raw.pktcount'), 37 | ("raw.pktlen", 'orig.raw.pktlen'), 38 | )) 39 | flows = collections.OrderedDict() 40 | 41 | res = self.api.put('remote', 'control/netflow/ulog/enable', True, 42 | **filters) 43 | print('resd', res) 44 | print('resd', res) 45 | print('resd', res) 46 | while True: 47 | for x in self.api.remote('control.netflow.ulog.data', **filters): 48 | print('sx', x) 49 | 50 | with self.make_table(headers=fields.keys(), 51 | accessors=fields.values()) as t: 52 | t.print(flows) 53 | 54 | def flow_start_acc(self, record): 55 | return float('%s.%s' % (record['flow.start.sec'], 56 | record['flow.start.usec'])) 57 | 58 | def flow_end_acc(self, record): 59 | return float('%s.%s' % (record['flow.end.sec'], 60 | record['flow.end.usec'])) 61 | 62 | 63 | class Netflow(base.ECMCommand): 64 | 65 | name = 'netflow' 66 | 67 | def __init__(self, *args, **kwargs): 68 | super().__init__(*args, **kwargs) 69 | self.add_subcommand(Monitor, default=True) 70 | 71 | command_classes = [Netflow] 72 | -------------------------------------------------------------------------------- /ecmcli/commands/remote.py: -------------------------------------------------------------------------------- 1 | """ 2 | Get and set remote values on series 3 routers. 3 | """ 4 | 5 | import collections 6 | import csv 7 | import datetime 8 | import itertools 9 | import shellish 10 | import syndicate.data 11 | import time 12 | from . import base 13 | 14 | 15 | class DeviceSelectorsMixin(object): 16 | """ Add arguments used for selecting devices. """ 17 | 18 | search_fields = ['name', 'desc', 'mac', ('account', 'account.name'), 19 | 'asset_id', 'custom1', 'custom2', 20 | ('group', 'group.name'), 21 | ('firmware', 'actual_firmware.version'), 'ip_address', 22 | ('product', 'product.name'), 'serial_number', 'state'] 23 | 24 | def setup_args(self, parser): 25 | sg = parser.add_argument_group('selection filters') 26 | self.add_argument('--disjunction', '--or', action='store_true', 27 | help="Logical OR the selection arguments.", 28 | parser=sg) 29 | self.add_argument('--skip-offline', action='store_true', 30 | help='Ignore devices that are offline.', 31 | parser=sg) 32 | self.add_group_argument('--group', parser=sg) 33 | self.add_router_argument('--router', parser=sg) 34 | self.add_account_argument('--account', parser=sg) 35 | self.add_product_argument('--product', parser=sg) 36 | self.add_firmware_argument('--firmware', parser=sg) 37 | searcher = self.make_searcher('routers', self.search_fields) 38 | self.search_lookup = searcher.lookup 39 | self.add_search_argument(searcher, '--search', nargs=1, parser=sg) 40 | super().setup_args(parser) 41 | 42 | @shellish.ttl_cache(300) 43 | def api_res_lookup(self, *args, **kwargs): 44 | """ Cached wrapper around get_by_id_or_name. """ 45 | return self.api.get_by_id_or_name(*args, required=False, **kwargs) 46 | 47 | def gen_selection_filters(self, args_namespace): 48 | """ Return the api filters for the selection criteria. Note that 49 | the group selection is only used to get a list of devices. """ 50 | args = vars(args_namespace) 51 | filters = {} 52 | if args.get('group'): 53 | hit = self.api_res_lookup('groups', args['group']) 54 | if hit: 55 | filters['group'] = hit['id'] 56 | if args.get('account'): 57 | hit = self.api_res_lookup('accounts', args['account']) 58 | if hit: 59 | filters['account'] = hit['id'] 60 | if args.get('product'): 61 | hit = self.api_res_lookup('products', args['product'], series=3) 62 | if hit: 63 | filters['product'] = hit['id'] 64 | if args.get('firmware'): 65 | filters['actual_firmware.version'] = args['firmware'] 66 | rids = [] 67 | if args.get('router'): 68 | hit = self.api_res_lookup('routers', args['router']) 69 | if hit: 70 | rids.append(hit['id']) 71 | if args.get('search'): 72 | sids = self.search_lookup(args['search']) 73 | if not sids: 74 | rids.append('-1') # Ensure no match is possible softly. 75 | else: 76 | rids.extend(x['id'] for x in sids) 77 | if rids: 78 | filters['id__in'] = ','.join(rids) 79 | if args.get('disjunction'): 80 | filters = dict(_or='|'.join('%s=%s' % x for x in filters.items())) 81 | if args.get('skip_offline'): 82 | filters['state'] = 'online' 83 | return filters 84 | 85 | @shellish.ttl_cache(300) 86 | def completion_router_elect(self, **filters): 87 | """ Cached lookup of a router meeting the filters criteria to be used 88 | for completion lookups. """ 89 | for x in self.api.get_pager('routers', page_size=1, state='online', 90 | expand='product', 91 | fields='id,product.series', **filters): 92 | if x['product']['series'] == 3: 93 | return x['id'] 94 | 95 | def try_complete_path(self, prefix, args=None): 96 | filters = self.gen_selection_filters(args) 97 | rid = self.completion_router_elect(**filters) 98 | if not rid: 99 | return set(('[NO ONLINE MATCHING ROUTERS FOUND]', ' ')) 100 | parts = prefix.split('.') 101 | if len(parts) > 1: 102 | path = parts[:-1] 103 | prefix = parts[-1] 104 | else: 105 | path = [] 106 | # Cheat for root paths to avoid huge lookup cost on naked tab. 107 | if not path: 108 | cs = dict.fromkeys(('config', 'status', 'control', 'state'), {}) 109 | else: 110 | cs = self.remote_lookup((rid,) + tuple(path)) 111 | if not cs: 112 | return set() 113 | options = dict((str(k), v) for k, v in cs.items() 114 | if str(k).startswith(prefix)) 115 | if len(options) == 1: 116 | key, value = list(options.items())[0] 117 | if isinstance(value, dict): 118 | options[key + '.'] = None # Prevent trailing space. 119 | return set('.'.join(path + [x]) for x in options) 120 | 121 | @shellish.hone_cache(maxage=3600, refineby='container') 122 | def remote_lookup(self, rid_and_path): 123 | rid = rid_and_path[0] 124 | path = rid_and_path[1:] 125 | resp = self.api.get('remote', *path, id=rid) 126 | if resp and resp[0]['success'] and 'data' in resp[0]: 127 | return base.todict(resp[0]['data'], str_array_keys=True) 128 | 129 | 130 | class Get(DeviceSelectorsMixin, base.ECMCommand): 131 | """ Get configs for a selection of routers. """ 132 | 133 | name = 'get' 134 | 135 | def setup_args(self, parser): 136 | super().setup_args(parser) 137 | output_options = parser.add_argument_group('output options') 138 | or_group = output_options.add_mutually_exclusive_group() 139 | self.inject_table_factory(skip_formats=True) 140 | for x in ('json', 'csv', 'xml', 'table', 'tree'): 141 | self.add_argument('--%s' % x, dest='output', action='store_const', 142 | const=x, parser=or_group) 143 | self.add_argument('path', metavar='REMOTE_PATH', nargs='?', 144 | complete=self.try_complete_path, default='', 145 | help='Dot notation path to config value; Eg. ' 146 | 'status.wan.rules.0.enabled') 147 | self.add_file_argument('--output-file', '-o', mode='w', default='-', 148 | metavar="OUTPUT_FILE", parser=output_options) 149 | self.add_argument('--repeat', type=float, metavar="SECONDS", 150 | help="Repeat the request every N seconds. Only " 151 | "appropriate for table format.") 152 | 153 | advanced = parser.add_argument_group('advanced options') 154 | self.add_argument('--concurrency', type=int, parser=advanced, 155 | help='Maximum number of concurrent connections.') 156 | self.add_argument('--timeout', type=float, default=300, 157 | parser=advanced, help='Maximum time in seconds for ' 158 | 'each connection.') 159 | 160 | def run(self, args): 161 | filters = self.gen_selection_filters(args) 162 | outformat = args.output 163 | fallback_format = self.tree_format if not args.repeat else \ 164 | self.table_format 165 | with args.output_file as f: 166 | if not outformat and hasattr(f.name, 'rsplit'): 167 | outformat = f.name.rsplit('.', 1)[-1] 168 | formatter = { 169 | 'json': self.json_format, 170 | 'xml': self.xml_format, 171 | 'csv': self.csv_format, 172 | 'table': self.table_format, 173 | 'tree': self.tree_format, 174 | }.get(outformat) or fallback_format 175 | feed = lambda: self.api.remote(args.path, timeout=args.timeout, 176 | concurrency=args.concurrency, 177 | **filters) 178 | formatter(args, feed, file=f) 179 | 180 | def data_flatten(self, args, datafeed): 181 | """ Flatten out the results a bit for a consistent data format. """ 182 | 183 | def responses(): 184 | for cres in datafeed: 185 | resmap = collections.OrderedDict((x['path'], x['data']) 186 | for x in cres['results']) 187 | emit = {"results": resmap} 188 | for x in ('desc', 'custom1', 'custom2', 'asset_id', 189 | 'ip_address', 'mac', 'name', 'serial_number', 190 | 'state'): 191 | emit[x] = cres['router'].get(x) 192 | yield emit 193 | args = vars(args).copy() 194 | for key, val in list(args.items()): 195 | if key.startswith('api_') or \ 196 | key.startswith(self.arg_label_fmt.split('%', 1)[0]): 197 | del args[key] 198 | else: 199 | args[key] = repr(val) 200 | return { 201 | "time": datetime.datetime.utcnow().isoformat(), 202 | "args": args, 203 | "responses": responses() 204 | } 205 | 206 | def make_response_tree(self, resp): 207 | """ Render a tree of the response data if it was successful otherwise 208 | return a formatted error response. The return type is iterable. """ 209 | if resp['success']: 210 | resmap = collections.OrderedDict((x['path'], x['data']) 211 | for x in resp['results']) 212 | return shellish.treeprint(resmap, render_only=True) 213 | else: 214 | error = resp.get('message', resp.get('reason', 215 | resp.get('exception'))) 216 | return ['%s' % error] 217 | 218 | def tree_format(self, args, results_feed, file): 219 | if args.repeat: 220 | raise SystemExit('Repeat mode not supported for tree format.') 221 | worked = failed = 0 222 | 223 | def cook(): 224 | nonlocal worked, failed 225 | for x in results_feed(): 226 | if x['success']: 227 | worked += 1 228 | status = 'yes' 229 | else: 230 | failed += 1 231 | status = 'no' 232 | feeds = [ 233 | [x['router']['name']], 234 | [x['router']['id']], 235 | [status], 236 | self.make_response_tree(x) 237 | ] 238 | for row in itertools.zip_longest(*feeds, fillvalue=''): 239 | yield row 240 | 241 | headers = ['Name', 'ID', 'Success', 'Response'] 242 | with self.make_table(headers=headers, file=file) as t: 243 | t.print(cook()) 244 | if worked: 245 | t.print_footer('Succeeded: %d' % worked) 246 | if failed: 247 | t.print_footer('Failed: %d' % failed) 248 | 249 | def table_format(self, args, results_feed, file): 250 | table = None 251 | if not args.repeat: 252 | status = lambda x: ' - %s' % ('PASS' if x['success'] else 'FAIL') 253 | else: 254 | status = lambda x: '' 255 | while True: 256 | start = time.monotonic() 257 | results = list(results_feed()) 258 | if table is None: 259 | headers = ['%s (%s)%s' % (x['router']['name'], x['id'], 260 | status(x)) for x in results] 261 | order = [x['id'] for x in results] 262 | table = self.make_table(headers=headers, file=file) 263 | else: 264 | # Align columns with the first requests ordering. 265 | results.sort(key=lambda x: order.index(x['id'])) 266 | trees = map(self.make_response_tree, results) 267 | table.print(itertools.zip_longest(*trees, fillvalue='')) 268 | if not args.repeat: 269 | break 270 | else: 271 | tillnext = args.repeat - (time.monotonic() - start) 272 | time.sleep(max(tillnext, 0)) 273 | 274 | def json_format(self, args, results_feed, file): 275 | if args.repeat: 276 | raise SystemExit('Repeat mode not supported for json format.') 277 | jenc = syndicate.data.NormalJSONEncoder(indent=4, sort_keys=True) 278 | data = self.data_flatten(args, results_feed()) 279 | data['responses'] = list(data['responses']) 280 | print(jenc.encode(data), file=file) 281 | 282 | def xml_format(self, args, results_feed, file): 283 | if args.repeat: 284 | raise SystemExit('Repeat mode not supported for xml format.') 285 | doc = base.toxml(self.data_flatten(args, results_feed())) 286 | print("", file=file) 287 | print(doc.toprettyxml(indent=' ' * 4), end='', file=file) 288 | 289 | def csv_format(self, args, results_feed, file): 290 | if args.repeat: 291 | raise SystemExit('Repeat mode not supported for csv format.') 292 | data = list(self.data_flatten(args, results_feed())['responses']) 293 | tuples = [[(('DATA:%s' % xx[0]).strip('.'), xx[1]) 294 | for xx in base.totuples(x.get('results', []))] 295 | for x in data] 296 | keys = sorted(set(xx[0] for x in tuples for xx in x)) 297 | static_fields = ( 298 | ('id', 'ROUTER_ID'), 299 | ('mac', 'ROUTER_MAC'), 300 | ('name', 'ROUTER_NAME'), 301 | ('success', 'SUCCESS'), 302 | ('exception', 'ERROR') 303 | ) 304 | fields = [x[0] for x in static_fields] + keys 305 | header = [x[1] for x in static_fields] + keys 306 | writer = csv.DictWriter(file, fieldnames=fields, 307 | extrasaction='ignore') 308 | writer.writerow(dict(zip(fields, header))) 309 | for xtuple, x in zip(tuples, data): 310 | x.update(dict(xtuple)) 311 | writer.writerow(x) 312 | 313 | 314 | class Set(DeviceSelectorsMixin, base.ECMCommand): 315 | """ Set a config value on a selection of devices and/or groups. """ 316 | 317 | name = 'set' 318 | 319 | def setup_args(self, parser): 320 | super().setup_args(parser) 321 | in_group = parser.add_mutually_exclusive_group(required=True) 322 | self.add_argument('--input-data', '-d', metavar="INPUT_DATA", 323 | help="JSON formated input data.", parser=in_group) 324 | self.add_file_argument('--input-file', '-i', metavar="INPUT_FILE", 325 | parser=in_group) 326 | self.add_argument('--dry-run', '--manifest', help="Do not set a " 327 | "config. Generate a manifest of what would be " 328 | "done and the potential peril if executed.") 329 | 330 | def run(self, args): 331 | raise NotImplementedError() 332 | 333 | 334 | class Diff(base.ECMCommand): 335 | """ Produce an N-way diff of a particular config-store path between any 336 | selection of routers. """ 337 | 338 | name = 'diff' 339 | 340 | def setup_args(self, parser): 341 | super().setup_args(parser) 342 | self.add_product_argument('--product', default="MBR1400") 343 | self.add_firmware_argument('--firmware', default="5.4.1") 344 | self.add_argument('path', complete=self.complete_dtd_path) 345 | 346 | def complete_dtd_path(self, prefix, args=None): 347 | return set('wan', 'lan', 'system', 'firewall') 348 | 349 | def run(self, args): 350 | filters = { 351 | "product.name": args.product, 352 | "firmware.version": args.firmware 353 | } 354 | firmwares = self.api.get('firmwares', **filters) 355 | if not firmwares: 356 | raise SystemExit("Firmware not found: %s v%s" % (args.product, 357 | args.firmware)) 358 | dtd = self.api.get(urn=firmwares[0]['dtd']) 359 | print(dtd) 360 | 361 | 362 | class Remote(base.ECMCommand): 363 | """ Remote control live routers. 364 | 365 | The remote interface gives direct access to online routers. Generally 366 | this provides the ability to view status and configuration as well as set 367 | configuration and activate certain router control features. All the 368 | commands operate under the pretense of a router being online and connected 369 | to ECM. 370 | 371 | The endpoint of these remote calls is one or more Cradlepoint series 3 372 | routers. More specifically it is to the config-store of these routers. 373 | The config-store is the information hub for everything the router exposes 374 | publicly. It provides access to real-time status, logs, and current state 375 | as well as read/write access to the non-volatile configuration. There is 376 | also a control tree to instruct the router to perform various actions such 377 | as rebooting, doing a traceroute or even pulling GPIO pins up and down. 378 | 379 | DISCLAIMER: All the actions performed with this command are done 380 | optimistically and without regard to firmware versions. It is an 381 | exercise for the reader to understand the affects of these commands as 382 | well as the applicability of any input values provided. 383 | """ 384 | 385 | name = 'remote' 386 | 387 | def __init__(self, *args, **kwargs): 388 | super().__init__(*args, **kwargs) 389 | self.add_subcommand(Get, default=True) 390 | self.add_subcommand(Diff) 391 | 392 | command_classes = [Remote] 393 | -------------------------------------------------------------------------------- /ecmcli/commands/routers.py: -------------------------------------------------------------------------------- 1 | """ 2 | Manage ECM Routers. 3 | """ 4 | 5 | import time 6 | from . import base 7 | from .. import ui 8 | 9 | 10 | class Printer(object): 11 | """ Mixin for printer commands. """ 12 | 13 | terse_expands = ','.join([ 14 | 'account', 15 | 'group', 16 | 'product', 17 | 'actual_firmware' 18 | ]) 19 | verbose_expands = ','.join([ 20 | 'account', 21 | 'group', 22 | 'product', 23 | 'actual_firmware', 24 | 'last_known_location', 25 | 'featurebindings' 26 | ]) 27 | 28 | def setup_args(self, parser): 29 | self.add_argument('-v', '--verbose', action='store_true') 30 | self.inject_table_factory() 31 | super().setup_args(parser) 32 | 33 | def prerun(self, args): 34 | if args.verbose: 35 | self.expands = self.verbose_expands 36 | self.printer = self.verbose_printer 37 | else: 38 | self.expands = self.terse_expands 39 | self.printer = self.terse_printer 40 | super().prerun(args) 41 | 42 | def verbose_printer(self, routers): 43 | fields = { 44 | 'account_info': 'Account', 45 | 'asset_id': 'Asset ID', 46 | 'config_status': 'Config Status', 47 | 'custom1': 'Custom 1', 48 | 'custom2': 'Custom 2', 49 | 'dashboard_url': 'Dashboard URL', 50 | 'desc': 'Description', 51 | 'entitlements': 'Entitlements', 52 | 'firmware_info': 'Firmware', 53 | 'group_name': 'Group', 54 | 'id': 'ID', 55 | 'ip_address': 'IP Address', 56 | 'joined': 'Joined', 57 | 'locality': 'Locality', 58 | 'location_info': 'Location', 59 | 'mac': 'MAC', 60 | 'product_info': 'Product', 61 | 'quarantined': 'Quarantined', 62 | 'serial_number': 'Serial Number', 63 | 'since': 'Connection Time', 64 | 'state': 'Connection', 65 | } 66 | location_url = 'https://maps.google.com/maps?' \ 67 | 'q=loc:%(latitude)f+%(longitude)f' 68 | key_col_width = max(map(len, fields.values())) 69 | first = True 70 | for x in routers: 71 | if first: 72 | first = False 73 | else: 74 | print() 75 | x = self.bundle_router(x) 76 | t = self.make_table(columns=[key_col_width, None], 77 | headers=['Router Name', x['name']]) 78 | x['since'] = ui.time_since(x['state_ts']) 79 | x['joined'] = ui.time_since(x['create_ts']) + ' ago' 80 | x['account_info'] = '%s (%s)' % (x['account']['name'], 81 | x['account']['id']) 82 | loc = x.get('last_known_location') 83 | x['location_info'] = location_url % loc if loc else '' 84 | ents = x['featurebindings'] 85 | 86 | def acc(x): 87 | try: 88 | return x['settings']['entitlement']['sf_entitlements'][0]['name'] 89 | except: 90 | # ECM bug where expands dont work on some accounts 91 | return '' 92 | x['entitlements'] = ', '.join(filter(None, map(acc, ents))) if ents else '' 93 | x['dashboard_url'] = 'https://cradlepointecm.com/ecm.html' \ 94 | '#devices/dashboard?id=%s' % x['id'] 95 | for key, label in sorted(fields.items(), key=lambda x: x[1]): 96 | t.print_row([label, x[key]]) 97 | t.close() 98 | 99 | def group_name(self, group): 100 | """ Sometimes the group is empty or a URN if the user is not 101 | authorized to see it. Return the best extrapolation of the 102 | group name. """ 103 | if not group: 104 | return '' 105 | elif isinstance(group, str): 106 | return '' % group.rsplit('/')[-2] 107 | else: 108 | return group['name'] 109 | 110 | def terse_printer(self, routers): 111 | 112 | fields = ( 113 | ("id", "ID"), 114 | ("name", "Name"), 115 | ("product_info", "Product"), 116 | ("firmware_info", "Firmware"), 117 | ("account_name", "Account"), 118 | ("group_name", "Group"), 119 | ("ip_address", "IP Address"), 120 | (lambda x: self.colorize_conn_state(x['state']), "Conn") 121 | ) 122 | with self.make_table(headers=[x[1] for x in fields], 123 | accessors=[x[0]for x in fields]) as t: 124 | t.print(map(self.bundle_router, routers)) 125 | t.print_footer('Total Routers: %d' % len(routers)) 126 | 127 | def colorize_conn_state(self, state): 128 | colormap = { 129 | "online": 'green', 130 | "offline": 'red' 131 | } 132 | color = colormap.get(state, 'yellow') 133 | return '<%s>%s' % (color, state, color) 134 | 135 | def bundle_router(self, router): 136 | router['account_name'] = router['account']['name'] 137 | router['group_name'] = self.group_name(router['group']) 138 | fw = router['actual_firmware'] 139 | router['firmware_info'] = fw['version'] if fw else '' 140 | router['product_info'] = router['product']['name'] 141 | return router 142 | 143 | 144 | class List(Printer, base.ECMCommand): 145 | """ List routers registered with ECM. """ 146 | 147 | name = 'ls' 148 | 149 | def setup_args(self, parser): 150 | self.add_router_argument(nargs='?') 151 | super().setup_args(parser) 152 | 153 | def run(self, args): 154 | if args.ident: 155 | routers = [self.api.get_by_id_or_name('routers', args.ident, 156 | expand=self.expands)] 157 | else: 158 | routers = self.api.get_pager('routers', expand=self.expands) 159 | self.printer(routers) 160 | 161 | 162 | class Search(Printer, base.ECMCommand): 163 | """ Search for routers. """ 164 | 165 | name = 'search' 166 | fields = ['name', 'desc', 'mac', ('account', 'account.name'), 'asset_id', 167 | 'custom1', 'custom2', ('group', 'group.name'), 168 | ('firmware', 'actual_firmware.version'), 'ip_address', 169 | ('product', 'product.name'), 'serial_number', 'state'] 170 | 171 | def setup_args(self, parser): 172 | searcher = self.make_searcher('routers', self.fields) 173 | self.lookup = searcher.lookup 174 | self.add_search_argument(searcher) 175 | super().setup_args(parser) 176 | 177 | def run(self, args): 178 | results = self.lookup(args.search, expand=self.expands) 179 | if not results: 180 | raise SystemExit("No results for: %s" % ' '.join(args.search)) 181 | self.printer(results) 182 | 183 | 184 | class GroupAssign(base.ECMCommand): 185 | """ Assign a router to a [new] group. """ 186 | 187 | name = 'groupassign' 188 | use_pager = False 189 | 190 | def setup_args(self, parser): 191 | self.add_router_argument() 192 | self.add_group_argument('new_group', metavar='NEW_GROUP_ID_OR_NAME') 193 | self.add_argument('-f', '--force', action='store_true') 194 | 195 | def run(self, args): 196 | router = self.api.get_by_id_or_name('routers', args.ident, 197 | expand='group') 198 | group = self.api.get_by_id_or_name('groups', args.new_group) 199 | if router['group'] and not args.force: 200 | self.confirm('Replace router group: %s => %s' % ( 201 | router['group']['name'], group['name'])) 202 | self.api.put('routers', router['id'], 203 | {"group": group['resource_uri']}) 204 | 205 | 206 | class GroupUnassign(base.ECMCommand): 207 | """ Remove a router from its group. """ 208 | 209 | name = 'groupunassign' 210 | 211 | def setup_args(self, parser): 212 | self.add_router_argument() 213 | 214 | def run(self, args): 215 | router = self.api.get_by_id_or_name('routers', args.ident) 216 | self.api.put('routers', router['id'], {"group": None}) 217 | 218 | 219 | class Edit(base.ECMCommand): 220 | """ Edit a group's attributes. """ 221 | 222 | name = 'edit' 223 | 224 | def setup_args(self, parser): 225 | self.add_router_argument() 226 | self.add_argument('--name') 227 | self.add_argument('--desc') 228 | self.add_argument('--asset_id') 229 | self.add_argument('--custom1') 230 | self.add_argument('--custom2') 231 | 232 | def run(self, args): 233 | router = self.api.get_by_id_or_name('routers', args.ident) 234 | value = {} 235 | fields = ['name', 'desc', 'asset_id', 'custom1', 'custom2'] 236 | for x in fields: 237 | v = getattr(args, x) 238 | if v is not None: 239 | value[x] = v 240 | self.api.put('routers', router['id'], value) 241 | 242 | 243 | class Move(base.ECMCommand): 244 | """ Move a router to different account """ 245 | 246 | name = 'mv' 247 | 248 | def setup_args(self, parser): 249 | self.add_router_argument() 250 | self.add_account_argument('new_account', 251 | metavar='NEW_ACCOUNT_ID_OR_NAME') 252 | 253 | def run(self, args): 254 | router = self.api.get_by_id_or_name('routers', args.ident) 255 | account = self.api.get_by_id_or_name('accounts', args.new_account) 256 | self.api.put('routers', router['id'], 257 | {"account": account['resource_uri']}) 258 | 259 | 260 | class Delete(base.ECMCommand): 261 | """ Delete (unregister) a router from ECM """ 262 | 263 | name = 'rm' 264 | use_pager = False 265 | 266 | def setup_args(self, parser): 267 | self.add_router_argument('idents', nargs='+') 268 | self.add_argument('-f', '--force', action='store_true') 269 | 270 | def run(self, args): 271 | for id_or_name in args.idents: 272 | router = self.api.get_by_id_or_name('routers', id_or_name) 273 | if not args.force and \ 274 | not self.confirm('Delete router: %s, id:%s' % (router['name'], 275 | router['id']), exit=False): 276 | continue 277 | self.api.delete('routers', router['id']) 278 | 279 | 280 | class Reboot(base.ECMCommand): 281 | """ Reboot connected router(s). """ 282 | 283 | name = 'reboot' 284 | use_pager = False 285 | 286 | def setup_args(self, parser): 287 | self.add_router_argument('idents', nargs='*') 288 | self.add_argument('-f', '--force', action='store_true') 289 | 290 | def run(self, args): 291 | if args.idents: 292 | routers = [self.api.get_by_id_or_name('routers', r) 293 | for r in args.idents] 294 | else: 295 | routers = self.api.get_pager('routers') 296 | for x in routers: 297 | if not args.force and \ 298 | not self.confirm("Reboot %s (%s)" % (x['name'], x['id']), 299 | exit=False): 300 | continue 301 | print("Rebooting: %s (%s)" % (x['name'], x['id'])) 302 | self.api.put('remote', '/control/system/reboot', 1, id=x['id']) 303 | 304 | 305 | class FlashLEDS(base.ECMCommand): 306 | """ Flash the LEDs of online routers. """ 307 | 308 | name = 'flashleds' 309 | min_flash_delay = 0.200 310 | use_pager = False 311 | 312 | def setup_args(self, parser): 313 | self.add_router_argument('idents', nargs='*') 314 | self.add_argument('--duration', '-d', type=float, default=60, 315 | help='Duration in seconds for LED flashing.') 316 | 317 | def run(self, args): 318 | if args.idents: 319 | routers = [self.api.get_by_id_or_name('routers', r) 320 | for r in args.idents] 321 | else: 322 | routers = self.api.get_pager('routers') 323 | ids = [] 324 | print("Flashing LEDS for:") 325 | for rinfo in routers: 326 | print(" %s (%s)" % (rinfo['name'], rinfo['id'])) 327 | ids.append(rinfo['id']) 328 | rfilter = { 329 | "id__in": ','.join(ids) 330 | } 331 | leds = dict.fromkeys(( 332 | "LED_ATTENTION", 333 | "LED_SS_1", 334 | "LED_SS_2", 335 | "LED_SS_3", 336 | "LED_SS_4" 337 | ), 0) 338 | print() 339 | start = time.time() 340 | while time.time() - start < args.duration: 341 | for k, v in leds.items(): 342 | leds[k] = state = not v 343 | step = time.time() 344 | self.api.put('remote', '/control/gpio', leds, **rfilter) 345 | print("\rLEDS State: %s" % ('ON ' if state else 'OFF'), end='', 346 | flush=True) 347 | time.sleep(max(0, self.min_flash_delay - (time.time() - step))) 348 | 349 | 350 | class Routers(base.ECMCommand): 351 | """ Manage ECM Routers. """ 352 | 353 | name = 'routers' 354 | 355 | def __init__(self, *args, **kwargs): 356 | super().__init__(*args, **kwargs) 357 | self.add_subcommand(List, default=True) 358 | self.add_subcommand(Edit) 359 | self.add_subcommand(Search) 360 | self.add_subcommand(Move) 361 | self.add_subcommand(GroupAssign) 362 | self.add_subcommand(GroupUnassign) 363 | self.add_subcommand(Delete) 364 | self.add_subcommand(Reboot) 365 | self.add_subcommand(FlashLEDS) 366 | 367 | command_classes = [Routers] 368 | -------------------------------------------------------------------------------- /ecmcli/commands/shell.py: -------------------------------------------------------------------------------- 1 | """ 2 | Interact with the shell of ECM clients. 3 | """ 4 | 5 | import contextlib 6 | import fcntl 7 | import os 8 | import select 9 | import shutil 10 | import sys 11 | import termios 12 | import time 13 | import tty 14 | from . import base 15 | 16 | 17 | class Shell(base.ECMCommand): 18 | """ Emulate an interactive shell to a remote router. """ 19 | 20 | name = 'shell' 21 | use_pager = False 22 | poll_max_retry = 300 # Max secs for polling when no activity is detected. 23 | # How long we wait for additional keystrokes after one or more keystrokes 24 | # have been detected. 25 | key_idle_timeout = 0.150 26 | raw_in = sys.stdin.buffer.raw 27 | try: 28 | raw_out = sys.stdout.buffer.raw 29 | except AttributeError: 30 | raw_out = None 31 | 32 | def setup_args(self, parser): 33 | self.add_router_argument() 34 | self.add_argument('-n', '--new', action='store_true', 35 | help='Start a new session') 36 | 37 | def run(self, args): 38 | if not self.raw_out: 39 | print("No raw stdout device available") 40 | return 41 | router = self.api.get_by_id_or_name('routers', args.ident) 42 | print("Connecting to: %s (%s)" % (router['name'], router['id'])) 43 | print("Type ~~ rapidly to close session") 44 | sessionid = int(time.time() * 10000) if args.new else \ 45 | self.api.legacy_id 46 | with self.setup_tty(): 47 | self.rsh(router, sessionid) 48 | 49 | @contextlib.contextmanager 50 | def setup_tty(self): 51 | stdin = sys.stdin.fileno() 52 | ttysave = termios.tcgetattr(stdin) 53 | tty.setraw(stdin) 54 | attrs = termios.tcgetattr(stdin) 55 | attrs[tty.IFLAG] = (attrs[tty.IFLAG] | termios.ICRNL) 56 | attrs[tty.OFLAG] = (attrs[tty.OFLAG] | termios.ONLCR | termios.OPOST) 57 | attrs[tty.LFLAG] = (attrs[tty.LFLAG] | termios.IEXTEN) 58 | termios.tcsetattr(stdin, termios.TCSANOW, attrs) 59 | fl = fcntl.fcntl(stdin, fcntl.F_GETFL) 60 | fcntl.fcntl(stdin, fcntl.F_SETFL, fl | os.O_NONBLOCK) 61 | try: 62 | yield 63 | finally: 64 | termios.tcsetattr(stdin, termios.TCSADRAIN, ttysave) 65 | fcntl.fcntl(stdin, fcntl.F_SETFL, fl) 66 | 67 | def buffered_read(self, idle_timeout=key_idle_timeout, max_timeout=None): 68 | buf = [] 69 | timeout = max_timeout 70 | while True: 71 | if select.select([self.raw_in.fileno()], [], [], timeout)[0]: 72 | buf.append(self.raw_in.read()) 73 | timeout = self.key_idle_timeout 74 | else: 75 | break 76 | sbuf = b''.join(buf).decode() 77 | if '~~' in sbuf: 78 | sys.exit('Session Closed') 79 | return sbuf 80 | 81 | def full_write(self, dstfile, srcdata): 82 | """ Write into dstfile until it's done, accounting for short write() 83 | calls. """ 84 | srcview = memoryview(srcdata) 85 | size = len(srcview) 86 | written = 0 87 | while written < size: 88 | select.select([], [dstfile.fileno()], []) # block until writable 89 | written += dstfile.write(srcview[written:]) 90 | 91 | def rsh(self, router, sessionid): 92 | rid = router['id'] 93 | w_save, h_save = None, None 94 | res = 'remote/control/csterm/ecmcli-%s/' % sessionid 95 | in_data = '\n' 96 | poll_timeout = self.key_idle_timeout # somewhat arbitrary 97 | while True: 98 | w, h = shutil.get_terminal_size() 99 | if (w, h) != (w_save, h_save): 100 | out = self.api.put(res, { 101 | "w": w, 102 | "h": h, 103 | "k": in_data 104 | }, id=rid)[0] 105 | w_save, h_save = w, h 106 | data = out['data']['k'] if out['success'] else None 107 | else: 108 | out = self.api.put('%sk' % res, in_data, id=rid)[0] 109 | data = out['data'] if out['success'] else None 110 | if out['success']: 111 | if data: 112 | self.full_write(self.raw_out, data.encode()) 113 | poll_timeout = 0 # Quickly look for more data 114 | else: 115 | poll_timeout += 0.050 116 | else: 117 | raise Exception('%s (%s)' % (out['exception'], out['reason'])) 118 | poll_timeout = min(self.poll_max_retry, poll_timeout) 119 | in_data = self.buffered_read(max_timeout=poll_timeout) 120 | 121 | command_classes = [Shell] 122 | -------------------------------------------------------------------------------- /ecmcli/commands/shtools.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tools for the interactive shell. 3 | """ 4 | 5 | import code 6 | from . import base 7 | 8 | 9 | class Debug(base.ECMCommand): 10 | """ Run an interactive python interpretor. """ 11 | 12 | name = 'debug' 13 | use_pager = False 14 | 15 | def run(self, args): 16 | code.interact(None, None, self.__dict__) 17 | 18 | command_classes = [Debug] 19 | -------------------------------------------------------------------------------- /ecmcli/commands/tos.py: -------------------------------------------------------------------------------- 1 | """ 2 | Terms of service viewing and acceptance. 3 | """ 4 | 5 | import shellish 6 | import shutil 7 | import textwrap 8 | from . import base 9 | 10 | 11 | class Review(base.ECMCommand): 12 | """ Review the ECM Terms of Service (TOS). """ 13 | 14 | name = 'review' 15 | 16 | def setup_args(self, parser): 17 | self.add_file_argument('--download', mode='w') 18 | 19 | def print_tos(self, tos): 20 | """ Groom the TOS to fit the screen. """ 21 | width = shutil.get_terminal_size()[0] 22 | data = str(shellish.htmlrender(tos['message'])).splitlines() 23 | for section in data: 24 | lines = textwrap.wrap(section, width - 4) 25 | if not lines: 26 | print() 27 | for x in lines: 28 | print(x) 29 | 30 | def get_tos(self): 31 | tos = self.api.get('system_message', type='tos') 32 | assert tos.meta['total_count'] == 1 33 | return tos[0] 34 | 35 | def run(self, args): 36 | tos = self.get_tos() 37 | if args.download: 38 | with args.download as f: 39 | f.write(tos['message']) 40 | else: 41 | self.print_tos(tos) 42 | 43 | 44 | class Accept(Review): 45 | """ Confirm acceptance of ECM Terms of Service (TOS). 46 | After reviewing the terms you will be asked to confirm your acceptance 47 | thereby giving you an opportunity to accept or reject the legal 48 | requirements for ECM usage. """ 49 | 50 | name = 'accept' 51 | accept_arg = 'i accept the ecm terms of service' 52 | 53 | def setup_args(self, parser): 54 | self.add_argument('--%s' % self.accept_arg.replace(' ', '-'), 55 | action='store_true') 56 | 57 | def prerun(self, args): 58 | self.accept = getattr(args, self.accept_arg.replace(' ', '_'), False) 59 | self.use_pager = not self.accept 60 | 61 | def run(self, args): 62 | self.tos = self.get_tos() 63 | self.print_tos(self.tos) 64 | 65 | def postrun(self, args, result=None, exc=None): 66 | if exc is not None: 67 | return 68 | print() 69 | if self.accept: 70 | shellish.vtmlprint('I, %s %s (%s), do hereby accept the ECM ' 71 | 'terms of service: X ' % 72 | (self.api.ident['user']['first_name'], 73 | self.api.ident['user']['last_name'], 74 | self.api.ident['user']['username'])) 75 | else: 76 | accept = input('Type "accept" to comply with the TOS: ') 77 | if accept != 'accept': 78 | raise SystemExit("Aborted") 79 | tos_uri = self.tos['resource_uri'] 80 | try: 81 | self.api.post('system_message_confirm', {"message": tos_uri}) 82 | except SystemExit as e: 83 | try: 84 | if 'already exists' in e.__context__.response['message']: 85 | raise SystemExit("TOS was already accepted.") 86 | except AttributeError: 87 | pass 88 | raise e 89 | 90 | 91 | class TOS(base.ECMCommand): 92 | """ Review and accept ECM Terms of Service (TOS). """ 93 | 94 | name = 'tos' 95 | 96 | def __init__(self, *args, **kwargs): 97 | super().__init__(*args, **kwargs) 98 | self.add_subcommand(Review, default=True) 99 | self.add_subcommand(Accept) 100 | 101 | command_classes = [TOS] 102 | -------------------------------------------------------------------------------- /ecmcli/commands/trace.py: -------------------------------------------------------------------------------- 1 | """ 2 | Trace API activity. 3 | """ 4 | 5 | import cellulario 6 | import functools 7 | import itertools 8 | import shellish 9 | import sys 10 | import time 11 | from . import base 12 | 13 | vprint = functools.partial(shellish.vtmlprint, file=sys.stderr) 14 | 15 | 16 | class Enable(base.ECMCommand): 17 | """ Enable API Tracing. """ 18 | 19 | name = 'enable' 20 | use_pager = False 21 | 22 | def run(self, *args): 23 | p = self.parent 24 | if p.enabled: 25 | raise SystemExit("Tracer already enabled") 26 | else: 27 | p.enabled = True 28 | cellulario.iocell.DEBUG = True 29 | p.session_verbosity_save = self.session.command_error_verbosity 30 | self.session.command_error_verbosity = 'traceback' 31 | self.api.add_listener('start_request', p.on_request_start) 32 | self.api.add_listener('finish_request', p.on_request_finish) 33 | self.session.add_listener('precmd', p.on_command_start) 34 | self.session.add_listener('postcmd', p.on_command_finish) 35 | 36 | 37 | class Disable(base.ECMCommand): 38 | """ Disable API Tracing. """ 39 | 40 | name = 'disable' 41 | use_pager = False 42 | 43 | def run(self, *args): 44 | p = self.parent 45 | if not p.enabled: 46 | raise SystemExit("No tracer to disable") 47 | else: 48 | p.enabled = False 49 | self.session.remove_listener('postcmd', p.on_command_finish) 50 | self.session.remove_listener('precmd', p.on_command_start) 51 | self.api.remove_listener('finish_request', p.on_request_finish) 52 | self.api.remove_listener('start_request', p.on_request_start) 53 | self.session.command_error_verbosity = p.session_verbosity_save 54 | cellulario.iocell.DEBUG = True 55 | 56 | 57 | class Trace(base.ECMCommand): 58 | """ Trace API calls. """ 59 | 60 | name = 'trace' 61 | use_pager = False 62 | 63 | def __init__(self, *args, **kwargs): 64 | super().__init__(*args, **kwargs) 65 | self.enabled = False 66 | self.tracking = {} 67 | self.add_subcommand(Enable) 68 | self.add_subcommand(Disable) 69 | 70 | def run(self, args): 71 | if self.enabled: 72 | self['disable'](argv='') 73 | print("Trace Disabled") 74 | else: 75 | self['enable'](argv='') 76 | print("Trace Enabled") 77 | 78 | def tprint(self, ident, category, message, code='blue'): 79 | t = time.perf_counter() 80 | vprint('%.3f[%s] - %s: <%s>%s' % (t, ident, 81 | category, code, message, code)) 82 | 83 | def on_request_start(self, callid, args=None, kwargs=None): 84 | t = time.perf_counter() 85 | method, path = args 86 | query = kwargs.copy() 87 | urn = query.pop('urn', self.api.urn) 88 | filters = ["%s=%s" % x for x in query.items()] 89 | sig = '%s /%s' % (method.upper(), urn.strip('/')) 90 | if path: 91 | sig += '/%s' % '/'.join(path).strip('/') 92 | if filters: 93 | sep = '&' if '?' in sig else '?' 94 | sig += '%s%s' % (sep, '&'.join(filters)) 95 | self.tracking[callid] = t, sig 96 | self.tprint(callid, 'API START', sig) 97 | 98 | def on_request_finish(self, callid, result=None, exc=None): 99 | t = time.perf_counter() 100 | start, sig = self.tracking.pop(callid) 101 | ms = round((t - start) * 1000) 102 | if self.tracking: 103 | addendum = ' [%d call(s) outstanding]' % \ 104 | len(self.tracking) 105 | else: 106 | addendum = '' 107 | if exc is not None: 108 | self.tprint(callid, 'API FINISH (%dms)' % ms, '%s ERROR (%s)' 109 | '%s' % (sig, exc, addendum), code='red') 110 | else: 111 | rlen = len(result) if result is not None else 'empty' 112 | self.tprint(callid, 'API FINISH (%dms)' % ms, '%s OK ' 113 | '(len: %s)%s' % (sig, rlen, addendum), code='green') 114 | 115 | def on_command_start(self, command, args): 116 | command.__trace_ts = time.perf_counter() 117 | args = vars(args).copy() 118 | for i in itertools.count(0): 119 | if self.arg_label_fmt % i in args: 120 | del args[self.arg_label_fmt % i] 121 | else: 122 | break 123 | simple = ', '.join('%s=%s' % x 124 | for x in args.items()) 125 | self.tprint(command.prog, 'COMMAND RUN', simple) 126 | 127 | def on_command_finish(self, command, args, result=None, exc=None): 128 | try: 129 | ms = round((time.perf_counter() - command.__trace_ts) * 1000) 130 | except AttributeError: 131 | ms = 0 132 | if exc: 133 | self.tprint(command.prog, 'COMMAND FINISH (%dms)' % ms, exc, 134 | code='red') 135 | else: 136 | self.tprint(command.prog, 'COMMAND FINISH (%dms)' % ms, result, 137 | code='green') 138 | 139 | command_classes = [Trace] 140 | -------------------------------------------------------------------------------- /ecmcli/commands/users.py: -------------------------------------------------------------------------------- 1 | """ 2 | List/Edit/Manage ECM Users. 3 | """ 4 | 5 | import datetime 6 | import getpass 7 | import shellish 8 | from . import base 9 | 10 | 11 | class Common(object): 12 | 13 | expands = ','.join([ 14 | 'authorizations.role', 15 | 'profile.account' 16 | ]) 17 | 18 | def get_users(self, usernames): 19 | return self.api.glob_pager('users', username=usernames, 20 | expand=self.expands) 21 | 22 | def get_user(self, username): 23 | return self.api.get_by('username', 'users', username, 24 | expand=self.expands) 25 | 26 | def splitname(self, fullname): 27 | name = fullname.rsplit(' ', 1) 28 | last_name = name.pop() if len(name) > 1 else '' 29 | return name[0], last_name 30 | 31 | def bundle_user(self, user): 32 | account = user['profile']['account'] 33 | user['name'] = '%(first_name)s %(last_name)s' % user 34 | roles = (x['role']['name'] for x in user['authorizations'] 35 | if not isinstance(x, str) and x['role']['id'] != '4') 36 | user['roles'] = ', '.join(roles) 37 | if isinstance(account, str): 38 | user['account_desc'] = '(%s)' % account.split('/')[-2] 39 | else: 40 | user['account_desc'] = '%s (%s)' % (account['name'], 41 | account['id']) 42 | slen = user['profile']['session_length'] 43 | user['session'] = datetime.timedelta(seconds=slen) 44 | return user 45 | 46 | def add_username_argument(self, *keys, **options): 47 | if not keys: 48 | keys = ('username',) 49 | options.setdefault("metavar", 'USERNAME') 50 | return self.add_completer_argument(*keys, resource='users', 51 | res_field='username', **options) 52 | 53 | 54 | class Printer(object): 55 | 56 | def setup_args(self, parser): 57 | self.inject_table_factory() 58 | super().setup_args(parser) 59 | 60 | def prerun(self, args): 61 | self.verbose = args.verbose 62 | self.printer = self.verbose_printer if self.verbose else \ 63 | self.terse_printer 64 | super().prerun(args) 65 | 66 | def verbose_printer(self, users): 67 | fields = [ 68 | ('id', 'ID'), 69 | ('username', 'Username'), 70 | ('name', 'Full Name'), 71 | ('account_desc', 'Account'), 72 | ('roles', 'Role(s)'), 73 | ('email', 'EMail'), 74 | ('date_joined', 'Joined'), 75 | ('last_login', 'Last Login'), 76 | ('session', 'Max Session') 77 | ] 78 | self.print_table(fields, users) 79 | 80 | def terse_printer(self, users): 81 | fields = [ 82 | ('id', 'ID'), 83 | ('username', 'Username'), 84 | ('name', 'Full Name'), 85 | ('account_desc', 'Account'), 86 | ('roles', 'Role(s)'), 87 | ('email', 'EMail') 88 | ] 89 | self.print_table(fields, users) 90 | 91 | def print_table(self, fields, users): 92 | with self.make_table(headers=[x[1] for x in fields], 93 | accessors=[x[0] for x in fields]) as t: 94 | t.print(map(self.bundle_user, users)) 95 | 96 | 97 | class List(Common, Printer, base.ECMCommand): 98 | """ List users. """ 99 | 100 | name = 'ls' 101 | 102 | def setup_args(self, parser): 103 | self.add_username_argument('usernames', nargs='*') 104 | self.add_argument('-v', '--verbose', action='store_true') 105 | super().setup_args(parser) 106 | 107 | def run(self, args): 108 | self.printer(self.get_users(args.usernames)) 109 | 110 | 111 | class Create(Common, base.ECMCommand): 112 | """ Create a new user. """ 113 | 114 | name = 'create' 115 | use_pager = False 116 | 117 | def setup_args(self, parser): 118 | self.add_argument('--username') 119 | self.add_argument('--email') 120 | self.add_argument('--password') 121 | self.add_argument('--fullname') 122 | self.add_argument('--role', 123 | complete=self.make_completer('roles', 'name')) 124 | self.add_argument('--account', 125 | complete=self.make_completer('accounts', 'name')) 126 | super().setup_args(parser) 127 | 128 | def username_available(self, username): 129 | return self.api.get('check_username', username=username)[0]['is_valid'] 130 | 131 | def run(self, args): 132 | while True: 133 | username = args.username or input('Username: ') 134 | if not self.username_available(username): 135 | shellish.vtmlprint("Username unavailable.") 136 | if args.username: 137 | raise SystemExit(1) 138 | else: 139 | break 140 | email = args.email or input('Email: ') 141 | password = args.password 142 | if not password: 143 | password = getpass.getpass('Password (or empty to send email): ') 144 | if password: 145 | password2 = getpass.getpass('Confirm Password: ') 146 | if password != password2: 147 | raise SystemExit("Aborted: passwords do not match") 148 | name = self.splitname(args.fullname or input('Full Name: ')) 149 | role = args.role or input('Role: ') 150 | role_id = self.api.get_by_id_or_name('roles', role)['id'] 151 | user_data = { 152 | "username": username, 153 | "email": email, 154 | "first_name": name[0], 155 | "last_name": name[1], 156 | "password": password, 157 | } 158 | if args.account: 159 | a = self.api.get_by_id_or_name('accounts', args.account) 160 | user_data['account'] = a['resource_uri'] 161 | user = self.api.post('users', user_data, expand='profile') 162 | self.api.put('profiles', user['profile']['id'], { 163 | "require_password_change": not password 164 | }) 165 | self.api.post('authorizations', { 166 | "account": user['profile']['account'], 167 | "cascade": True, 168 | "role": '/api/v1/roles/%s/' % role_id, 169 | "user": user['resource_uri'] 170 | }) 171 | 172 | 173 | class Edit(Common, base.ECMCommand): 174 | """ Edit user attributes. """ 175 | 176 | name = 'edit' 177 | 178 | def setup_args(self, parser): 179 | self.add_username_argument() 180 | self.add_argument('--email') 181 | self.add_argument('--fullname') 182 | self.add_argument('--session_length', type=int) 183 | super().setup_args(parser) 184 | 185 | def run(self, args): 186 | user = self.get_user(args.username) 187 | updates = {} 188 | if args.fullname: 189 | first, last = self.splitname(args.fullname) 190 | updates['first_name'] = first 191 | updates['last_name'] = last 192 | if args.email: 193 | updates['email'] = args.email 194 | if updates: 195 | self.api.put('users', user['id'], updates) 196 | if args.session_length: 197 | self.api.put('profiles', user['profile']['id'], 198 | {"session_length": args.session_length}) 199 | 200 | 201 | class Remove(Common, base.ECMCommand): 202 | """ Remove a user. """ 203 | 204 | name = 'rm' 205 | use_pager = False 206 | 207 | def setup_args(self, parser): 208 | self.add_username_argument('usernames', nargs='+') 209 | self.add_argument('-f', '--force', action="store_true") 210 | super().setup_args(parser) 211 | 212 | def run(self, args): 213 | for user in self.get_users(args.usernames): 214 | if not args.force and \ 215 | not self.confirm('Remove user: %s' % user['username'], 216 | exit=False): 217 | continue 218 | self.api.delete('users', user['id']) 219 | 220 | 221 | class Move(Common, base.ECMCommand): 222 | """ Move a user to a different account. """ 223 | 224 | name = 'mv' 225 | 226 | def setup_args(self, parser): 227 | self.add_username_argument('usernames', nargs='+') 228 | self.add_account_argument('new_account', 229 | metavar='NEW_ACCOUNT_ID_OR_NAME') 230 | super().setup_args(parser) 231 | 232 | def run(self, args): 233 | account = self.api.get_by_id_or_name('accounts', args.new_account) 234 | for user in self.get_users(args.usernames): 235 | self.api.put('profiles', user['profile']['id'], 236 | {"account": account['resource_uri']}) 237 | 238 | 239 | class Passwd(base.ECMCommand): 240 | """ Change your password. """ 241 | 242 | name = 'passwd' 243 | use_pager = False 244 | 245 | def run(self, args): 246 | user = self.api.ident['user'] 247 | update = { 248 | "current_password": getpass.getpass('Current Password: '), 249 | "password": getpass.getpass('New Password: '), 250 | "password2": getpass.getpass('New Password (confirm): ') 251 | } 252 | if update['password'] != update.pop('password2'): 253 | raise SystemExit("Aborted: passwords do not match") 254 | self.api.put('users', user['id'], update) 255 | 256 | 257 | class Search(Common, Printer, base.ECMCommand): 258 | """ Search for users. """ 259 | 260 | name = 'search' 261 | fields = ['username', 'first_name', 'last_name', 'email', 262 | ('account', 'profile.account.name')] 263 | 264 | def setup_args(self, parser): 265 | searcher = self.make_searcher('users', self.fields) 266 | self.lookup = searcher.lookup 267 | self.add_argument('-v', '--verbose', action='store_true') 268 | self.add_search_argument(searcher) 269 | super().setup_args(parser) 270 | 271 | def run(self, args): 272 | results = self.lookup(args.search, expand=self.expands) 273 | if not results: 274 | raise SystemExit("No results for: %s" % ' '.join(args.search)) 275 | self.printer(results) 276 | 277 | 278 | class Users(base.ECMCommand): 279 | """ Manage ECM Users. """ 280 | 281 | name = 'users' 282 | 283 | def __init__(self, *args, **kwargs): 284 | super().__init__(*args, **kwargs) 285 | self.add_subcommand(List, default=True) 286 | self.add_subcommand(Create) 287 | self.add_subcommand(Remove) 288 | self.add_subcommand(Edit) 289 | self.add_subcommand(Move) 290 | self.add_subcommand(Passwd) 291 | self.add_subcommand(Search) 292 | 293 | command_classes = [Users] 294 | -------------------------------------------------------------------------------- /ecmcli/commands/wanrate.py: -------------------------------------------------------------------------------- 1 | """ 2 | Collect two samples of wan usage to calculate the bit/sec rate. 3 | """ 4 | 5 | import humanize 6 | import time 7 | from . import base 8 | 9 | 10 | class WanRate(base.ECMCommand): 11 | """ Show the current WAN bitrate of connected routers. """ 12 | 13 | name = 'wanrate' 14 | sample_delay = 1 15 | use_pager = False 16 | 17 | def setup_args(self, parser): 18 | self.add_router_argument('idents', nargs='*') 19 | self.add_argument('-s', '--sampletime', 20 | help='How long to wait between sample captures ' 21 | 'in seconds', type=float, 22 | default=self.sample_delay) 23 | self.inject_table_factory(format_excludes={'json'}) 24 | super().setup_args(parser) 25 | 26 | def run(self, args): 27 | if args.idents: 28 | routers = [self.api.get_by_id_or_name('routers', x) 29 | for x in args.idents] 30 | else: 31 | routers = list(self.api.get_pager('routers', state='online', 32 | product__series=3)) 33 | routers_by_id = dict((x['id'], x) for x in routers) 34 | if not routers_by_id: 35 | raise SystemExit("No valid routers to monitor") 36 | headers = ['%s (%s)' % (x['name'], x['id']) for x in routers] 37 | table = self.make_table(headers=headers, flex=False) 38 | while True: 39 | start = time.time() 40 | # XXX: We should calculate our own bps instead of using 'bps' to 41 | # ensure the resolution of our rate correlates with our 42 | # sampletime. 43 | data = self.api.get('remote', 'status/wan/stats/bps', 44 | id__in=','.join(routers_by_id)) 45 | time.sleep(max(0, args.sampletime - (time.time() - start))) 46 | for x in data: 47 | if x['success']: 48 | if x['data'] > 1024: 49 | value = humanize.naturalsize(x['data'], gnu=True, 50 | format='%.1f ') + 'bps' 51 | else: 52 | value = '%s bps' % x['data'] 53 | value = value.lower() 54 | else: 55 | value = '[%s]' % x['reason'] 56 | routers_by_id[str(x['id'])]['bps'] = value 57 | table.print_row([x['bps'] for x in routers]) 58 | table.close() 59 | 60 | command_classes = [WanRate] 61 | -------------------------------------------------------------------------------- /ecmcli/commands/wifi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WiFi commands. 3 | """ 4 | 5 | import collections 6 | import shellish 7 | from . import base 8 | from .. import ui 9 | 10 | 11 | class AccessPoints(base.ECMCommand): 12 | """ List access points seen by site surveys. """ 13 | 14 | name = 'aps' 15 | 16 | def setup_args(self, parser): 17 | self.add_router_argument('idents', nargs='*') 18 | self.add_argument('-v', '--verbose', action='store_true', help='More ' 19 | 'verbose display.') 20 | self.inject_table_factory() 21 | super().setup_args(parser) 22 | 23 | def run(self, args): 24 | if args.idents: 25 | ids = ','.join(self.api.get_by_id_or_name('routers', x)['id'] 26 | for x in args.idents) 27 | filters = {"survey__router__in": ids} 28 | else: 29 | filters = {} 30 | check = '%s' % shellish.beststr('✓', '*') 31 | if args.verbose: 32 | fields = collections.OrderedDict(( 33 | ('SSID', 'wireless_ap.ssid'), 34 | ('BSSID', 'wireless_ap.bssid'), 35 | ('Manufacturer', 'wireless_ap.manufacturer'), 36 | ('Band', 'survey.band'), 37 | ('Mode', 'wireless_ap.mode'), 38 | ('Auth', 'wireless_ap.authmode'), 39 | ('Channel', 'survey.channel'), 40 | ('RSSI', 'survey.rssi'), 41 | ('First Seen', lambda x: ui.time_since(x['survey.created'])), 42 | ('Last Seen', lambda x: ui.time_since(x['survey.updated'])), 43 | ('Seen By', 'survey.router.name'), 44 | ('Trusted', lambda x: check if x['trust.trusted'] else ''), 45 | )) 46 | else: 47 | fields = collections.OrderedDict(( 48 | ('SSID', 'wireless_ap.ssid'), 49 | ('Manufacturer', 'wireless_ap.manufacturer'), 50 | ('Band', 'survey.band'), 51 | ('Auth', 'wireless_ap.authmode'), 52 | ('Last Seen', lambda x: ui.time_since(x['survey.updated'])), 53 | ('Seen By', 'survey.router.name'), 54 | ('Trusted', lambda x: check if x['trust.trusted'] else ''), 55 | )) 56 | 57 | survey = self.api.get_pager('wireless_ap_survey_view', 58 | expand='survey.router,trust,wireless_ap', 59 | **filters) 60 | with self.make_table(headers=fields.keys(), 61 | accessors=fields.values()) as t: 62 | t.print(map(dict, map(base.totuples, survey))) 63 | 64 | 65 | class Survey(base.ECMCommand): 66 | """ Start a WiFi site survey on connected router(s). """ 67 | 68 | name = 'survey' 69 | use_pager = False 70 | 71 | def setup_args(self, parser): 72 | self.add_router_argument('idents', nargs='*') 73 | 74 | def run(self, args): 75 | if args.idents: 76 | ids = [self.api.get_by_id_or_name('routers', x)['id'] 77 | for x in args.idents] 78 | else: 79 | ids = [x['id'] for x in self.api.get_pager('routers')] 80 | self.api.post('wireless_site_survey', ids) 81 | 82 | 83 | class WiFi(base.ECMCommand): 84 | """ WiFi access points info and surveys. """ 85 | 86 | name = 'wifi' 87 | 88 | def __init__(self, *args, **kwargs): 89 | super().__init__(*args, **kwargs) 90 | self.add_subcommand(AccessPoints, default=True) 91 | self.add_subcommand(Survey) 92 | 93 | command_classes = [WiFi] 94 | -------------------------------------------------------------------------------- /ecmcli/mac.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayfield/ecmcli/1fea2c536108342cf6b6649c0e5ccd13d78417ec/ecmcli/mac.db -------------------------------------------------------------------------------- /ecmcli/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | Bootstrap the shell and commands and then run either one. 3 | """ 4 | 5 | import importlib 6 | import logging 7 | import pkg_resources 8 | import shellish 9 | import shellish.logging 10 | import sys 11 | from . import api 12 | from .commands import base, shtools 13 | from shellish.command import contrib 14 | 15 | command_modules = [ 16 | 'accounts', 17 | 'activity_log', 18 | 'alerts', 19 | 'apps', 20 | 'authorizations', 21 | 'clients', 22 | 'features', 23 | 'firmware', 24 | 'groups', 25 | 'login', 26 | 'logs', 27 | 'messages', 28 | 'netflow', 29 | 'remote', 30 | 'routers', 31 | 'shell', 32 | 'tos', 33 | 'trace', 34 | 'users', 35 | 'wanrate', 36 | 'wifi', 37 | ] 38 | 39 | 40 | class ECMSession(shellish.Session): 41 | 42 | command_error_verbosity = 'pretty' 43 | default_prompt_format = r': \033[7m{user}\033[0m / ECM ;' 44 | intro = '\n'.join([ 45 | 'Welcome to the ECM shell.', 46 | 'Type "help" or "?" to list commands and "exit" to quit.' 47 | ]) 48 | 49 | def verror(self, *args, **kwargs): 50 | msg = ' '.join(map(str, args)) 51 | shellish.vtmlprint(msg, file=sys.stderr, **kwargs) 52 | 53 | def prompt_info(self): 54 | info = super().prompt_info() 55 | ident = self.root_command.api.ident 56 | username = ident['user']['username'] if ident else '*LOGGED_OUT*' 57 | info.update({ 58 | "user": username, 59 | "site": self.root_command.api.site.split('//', 1)[1] 60 | }) 61 | return info 62 | 63 | def execute(self, *args, **kwargs): 64 | try: 65 | return super().execute(*args, **kwargs) 66 | except api.TOSRequired: 67 | self.verror('TOS Acceptance Required') 68 | input('Press to review TOS') 69 | return self.root_command['tos']['accept'](argv='') 70 | except api.AuthFailure as e: 71 | self.verror('Auth error:' % e) 72 | return self.root_command['login'](argv='') 73 | 74 | 75 | class ECMRoot(base.ECMCommand): 76 | """ ECM Command Line Interface 77 | 78 | This utility represents a collection of sub-commands to perform against 79 | the Cradlepoint ECM service. You must already have a valid ECM 80 | username/password to use this tool. For more info go to 81 | https://cradlepointecm.com/. """ 82 | 83 | name = 'ecm' 84 | use_pager = False 85 | Session = ECMSession 86 | 87 | def setup_args(self, parser): 88 | distro = pkg_resources.get_distribution('ecmcli') 89 | self.add_argument('--api-username') 90 | self.add_argument('--api-password') 91 | self.add_argument('--api-site', 92 | help='E.g. https://cradlepointecm.com') 93 | self.add_argument('--debug', action='store_true') 94 | self.add_argument('--trace', action='store_true') 95 | self.add_argument('--no-pager', action='store_true') 96 | self.add_argument('--version', action='version', 97 | version=distro.version) 98 | self.add_subcommand(contrib.Commands) 99 | self.add_subcommand(contrib.SystemCompletion) 100 | self.add_subcommand(contrib.Help) 101 | 102 | def prerun(self, args): 103 | """ Add the interactive commands just before it goes to the prompt so 104 | they don't show up in the --help from the commands line. """ 105 | for x in shtools.command_classes: 106 | self.add_subcommand(x) 107 | self.add_subcommand(contrib.Exit) 108 | self.add_subcommand(contrib.INI) 109 | self.add_subcommand(contrib.Reset) 110 | self.add_subcommand(contrib.Pager) 111 | self.remove_subcommand(contrib.SystemCompletion) 112 | 113 | def run(self, args): 114 | self.session.run_loop() 115 | 116 | 117 | def main(): 118 | try: 119 | _main() 120 | except KeyboardInterrupt: 121 | sys.exit(1) 122 | 123 | 124 | def _main(): 125 | root = ECMRoot(api=api.ECMService()) 126 | for modname in command_modules: 127 | module = importlib.import_module('.%s' % modname, 'ecmcli.commands') 128 | for Command in module.command_classes: 129 | root.add_subcommand(Command) 130 | args = root.parse_args() 131 | if args.no_pager: 132 | root.session.allow_pager = False 133 | if args.trace: 134 | root['trace']['enable'](argv='') 135 | if args.debug: 136 | if args.debug: 137 | logger = logging.getLogger() 138 | logger.setLevel('DEBUG') 139 | logger.addHandler(shellish.logging.VTMLHandler()) 140 | try: 141 | root.api.connect(args.api_site, username=args.api_username, 142 | password=args.api_password) 143 | except api.Unauthorized: 144 | root['login'](argv='') 145 | root(args) 146 | -------------------------------------------------------------------------------- /ecmcli/ui.py: -------------------------------------------------------------------------------- 1 | """ 2 | User interface collection. 3 | """ 4 | 5 | import datetime 6 | import dateutil 7 | import humanize 8 | 9 | localtz = dateutil.tz.tzlocal() 10 | 11 | 12 | def time_since(dt): 13 | """ Return a human string indicating how much time as passed since this 14 | datetime. """ 15 | if dt is None: 16 | return '' 17 | since = dt.now(tz=dt.tzinfo) - dt 18 | return humanize.naturaltime(since)[:-4] 19 | 20 | 21 | def parse_ts(ts, **kwargs): 22 | dt = dateutil.parser.parse(ts) 23 | return localize_dt(dt, **kwargs) 24 | 25 | 26 | def localize_dt(dt, tz=localtz): 27 | return dt.astimezone(tz) 28 | 29 | 30 | def localnow(tz=localtz): 31 | return datetime.datetime.now(tz=tz) 32 | 33 | 34 | def formatdate(dt, format='%b %d, %Y'): 35 | return dt.strftime(format) 36 | 37 | 38 | def formattime(dt, format='%I:%M %p %Z'): 39 | return dt.strftime(format) 40 | 41 | 42 | def formatdatetime(dt, timeformat=None, dateformat=None): 43 | d = formatdate(dt, format=dateformat) if dateformat else formatdate(dt) 44 | t = formattime(dt, format=timeformat) if timeformat else formattime(dt) 45 | return '%s, %s' % (d, t) 46 | 47 | 48 | def naturaldelta(td): 49 | return humanize.naturaldelta(td) 50 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | syndicate>=3 2 | aiohttp<4 3 | shellish>=5 4 | humanize 5 | cellulario>=3 6 | python_dateutil 7 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [pep8] 2 | ignore = E731 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup, find_packages 4 | 5 | README = 'README.md' 6 | 7 | with open('requirements.txt') as f: 8 | requirements = f.readlines() 9 | 10 | 11 | def long_desc(): 12 | try: 13 | import pypandoc 14 | except ImportError: 15 | with open(README) as f: 16 | return f.read() 17 | else: 18 | return pypandoc.convert(README, 'rst') 19 | 20 | setup( 21 | name='ecmcli', 22 | version='10', 23 | description='Command Line Interface for Cradlepoint ECM (e.g. NCM)', 24 | author='Justin Mayfield', 25 | author_email='tooker@gmail.com', 26 | url='https://github.com/mayfield/ecmcli/', 27 | license='MIT', 28 | long_description=long_desc(), 29 | packages=find_packages(), 30 | test_suite='test', 31 | install_requires=requirements, 32 | entry_points={ 33 | 'console_scripts': ['ecm=ecmcli.main:main', 'ncm=ecmcli.main:main'], 34 | }, 35 | include_package_data=True, 36 | classifiers=[ 37 | 'Development Status :: 4 - Beta', 38 | 'Intended Audience :: Developers', 39 | 'License :: OSI Approved :: MIT License', 40 | 'Operating System :: OS Independent', 41 | 'Programming Language :: Python', 42 | 'Programming Language :: Python :: 3.4', 43 | 'Programming Language :: Python :: 3.5', 44 | 'Programming Language :: Python :: 3.6', 45 | 'Programming Language :: Python :: 3.7', 46 | ] 47 | ) 48 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayfield/ecmcli/1fea2c536108342cf6b6649c0e5ccd13d78417ec/test/__init__.py -------------------------------------------------------------------------------- /test/api_glob.py: -------------------------------------------------------------------------------- 1 | 2 | import unittest 3 | from ecmcli import api 4 | 5 | 6 | class GlobFilters(unittest.TestCase): 7 | 8 | def setUp(self): 9 | self.api = api.ECMService() 10 | self.glob = self.api.glob_field 11 | 12 | def test_exact(self): 13 | filters, test = self.glob('foo', 'bar') 14 | self.assertEqual(filters, {"foo__exact": 'bar'}) 15 | self.assertTrue(test(dict(foo='bar'))) 16 | self.assertFalse(test(dict(foo='baz'))) 17 | 18 | def test_just_star(self): 19 | filters, test = self.glob('foo', '*') 20 | self.assertFalse(filters) 21 | self.assertTrue(test(dict(foo='bar'))) 22 | self.assertTrue(test(dict(foo=''))) 23 | 24 | def test_just_qmark(self): 25 | filters, test = self.glob('foo', '?') 26 | self.assertFalse(filters) 27 | self.assertFalse(test(dict(foo='no'))) 28 | self.assertTrue(test(dict(foo='y'))) 29 | 30 | def test_prefix_with_star_tail(self): 31 | filters, test = self.glob('foo', 'bar*') 32 | self.assertEqual(filters, {"foo__startswith": 'bar'}) 33 | self.assertFalse(test(dict(foo='foobar'))) 34 | self.assertTrue(test(dict(foo='barfoo'))) 35 | self.assertTrue(test(dict(foo='bar'))) 36 | 37 | def test_prefix_with_star_head(self): 38 | filters, test = self.glob('foo', '*bar') 39 | self.assertEqual(filters, {"foo__endswith": 'bar'}) 40 | self.assertTrue(test(dict(foo='foobar'))) 41 | self.assertFalse(test(dict(foo='barfoo'))) 42 | self.assertTrue(test(dict(foo='bar'))) 43 | 44 | def test_prefix_with_star_border(self): 45 | filters, test = self.glob('foo', '*bar*') 46 | self.assertFalse(filters) 47 | self.assertFalse(test(dict(foo='ba'))) 48 | self.assertFalse(test(dict(foo='baz'))) 49 | self.assertTrue(test(dict(foo='bar'))) 50 | self.assertTrue(test(dict(foo='foobar'))) 51 | self.assertTrue(test(dict(foo='barfoo'))) 52 | self.assertTrue(test(dict(foo='foobarbaz'))) 53 | 54 | def test_simple_set(self): 55 | filters, test = self.glob('foo', '{a,bb,ccc}') 56 | self.assertFalse(filters) 57 | self.assertTrue(test(dict(foo='a'))) 58 | self.assertTrue(test(dict(foo='bb'))) 59 | self.assertTrue(test(dict(foo='ccc'))) 60 | self.assertFalse(test(dict(foo='accc'))) 61 | self.assertFalse(test(dict(foo='ccca'))) 62 | self.assertFalse(test(dict(foo='accca'))) 63 | self.assertFalse(test(dict(foo='zccc'))) 64 | self.assertFalse(test(dict(foo='zcccz'))) 65 | self.assertFalse(test(dict(foo='aa'))) 66 | self.assertFalse(test(dict(foo='aaa'))) 67 | self.assertFalse(test(dict(foo='b'))) 68 | self.assertFalse(test(dict(foo='bbb'))) 69 | self.assertFalse(test(dict(foo='bbbb'))) 70 | self.assertFalse(test(dict(foo='c'))) 71 | self.assertFalse(test(dict(foo='cc'))) 72 | self.assertFalse(test(dict(foo='cccc'))) 73 | self.assertFalse(test(dict(foo='ccccc'))) 74 | self.assertFalse(test(dict(foo='cccccc'))) 75 | self.assertFalse(test(dict(foo='ccccccc'))) 76 | 77 | def test_wildprefix_set_suffix(self): 78 | filters, test = self.glob('foo', '*{a,bb}') 79 | self.assertFalse(filters) 80 | self.assertTrue(test(dict(foo='a'))) 81 | self.assertTrue(test(dict(foo='bb'))) 82 | self.assertTrue(test(dict(foo='aa'))) 83 | self.assertTrue(test(dict(foo='bbb'))) 84 | self.assertTrue(test(dict(foo='abb'))) 85 | self.assertTrue(test(dict(foo='ba'))) 86 | self.assertTrue(test(dict(foo='Za'))) 87 | self.assertFalse(test(dict(foo='accc'))) 88 | self.assertFalse(test(dict(foo='b'))) 89 | self.assertFalse(test(dict(foo='c'))) 90 | 91 | def test_wildsuffix_set_prefix(self): 92 | filters, test = self.glob('foo', '{a,bb}*') 93 | self.assertFalse(filters) 94 | self.assertTrue(test(dict(foo='a'))) 95 | self.assertTrue(test(dict(foo='bb'))) 96 | self.assertTrue(test(dict(foo='aa'))) 97 | self.assertTrue(test(dict(foo='bbb'))) 98 | self.assertTrue(test(dict(foo='abb'))) 99 | self.assertTrue(test(dict(foo='bba'))) 100 | self.assertTrue(test(dict(foo='aZ'))) 101 | self.assertFalse(test(dict(foo='ccca'))) 102 | self.assertFalse(test(dict(foo='b'))) 103 | self.assertFalse(test(dict(foo='c'))) 104 | 105 | def test_nested_expr_with_set(self): 106 | filters, test = self.glob('foo', '{a,b?b}') 107 | self.assertFalse(filters) 108 | self.assertTrue(test(dict(foo='a'))) 109 | self.assertTrue(test(dict(foo='bbb'))) 110 | self.assertTrue(test(dict(foo='bab'))) 111 | self.assertTrue(test(dict(foo='bZb'))) 112 | self.assertTrue(test(dict(foo='b?b'))) 113 | self.assertFalse(test(dict(foo='ab'))) 114 | self.assertFalse(test(dict(foo='abbb'))) 115 | self.assertFalse(test(dict(foo='bbba'))) 116 | -------------------------------------------------------------------------------- /test/reboot.py: -------------------------------------------------------------------------------- 1 | import unittest.mock 2 | from ecmcli.commands import routers 3 | 4 | 5 | class ArgSanity(unittest.TestCase): 6 | 7 | def setUp(self): 8 | api = unittest.mock.Mock() 9 | fake = dict(name='foo', id='1') 10 | api.get_by_id_or_name.return_value = fake 11 | api.get_pager.return_value = [fake] 12 | self.cmd = routers.Reboot(api=api) 13 | 14 | def runcmd(self, args): 15 | args = self.cmd.argparser.parse_args(args.split()) 16 | self.cmd.run(args) 17 | 18 | def test_router_single_ident_arg(self): 19 | self.runcmd('reboot foo -f') 20 | self.cmd.api.get_by_id_or_name.assert_called_with('routers', 'foo') 21 | self.assertEqual(self.cmd.api.put.call_args[1]['id'], '1') 22 | 23 | def test_router_multi_ident_arg(self): 24 | self.runcmd('reboot foo bar -f') 25 | self.cmd.api.get_by_id_or_name.assert_any_call('routers', 'foo') 26 | self.cmd.api.get_by_id_or_name.assert_any_call('routers', 'bar') 27 | self.assertEqual(self.cmd.api.put.call_args[1]['id'], '1') 28 | 29 | def test_router_no_ident_arg(self): 30 | self.runcmd('reboot -f') 31 | self.assertEqual(self.cmd.api.put.call_args[1]['id'], '1') 32 | -------------------------------------------------------------------------------- /test/remote_config.py: -------------------------------------------------------------------------------- 1 | 2 | import copy 3 | import unittest 4 | from ecmcli.commands import base, remote 5 | 6 | 7 | class ConfigData(unittest.TestCase): 8 | 9 | def test_todict_noconv(self): 10 | for x in ({}, 0, None, 1, "", True, False, 0.0, -1, -1.1, 1.1, {1: 1}): 11 | self.assertEqual(base.todict(x), copy.deepcopy(x)) 12 | 13 | def test_todict_nesting(self): 14 | case = {1: {2: 2}} 15 | self.assertEqual(base.todict(case), copy.deepcopy(case)) 16 | 17 | def test_todict_listconv(self): 18 | case = {1: ['aaa', 'bbb']} 19 | result = {1: {0: 'aaa', 1: 'bbb'}} 20 | self.assertEqual(base.todict(case), result) 21 | 22 | def test_todict_listconv_nested(self): 23 | case = {1: [{11: ['l2a', 'l2b']}, 'bbb']} 24 | result = {1: {0: {11: {0: 'l2a', 1: 'l2b'}}, 1: 'bbb'}} 25 | self.assertEqual(base.todict(case), result) 26 | 27 | def test_todict_liststart(self): 28 | case = ["aaa", "bbb"] 29 | result = {0: 'aaa', 1: 'bbb'} 30 | self.assertEqual(base.todict(case), result) 31 | 32 | def test_todict_multi_dim_list(self): 33 | case = [["aaa", "bbb"]] 34 | result = {0: {0: 'aaa', 1: 'bbb'}} 35 | self.assertEqual(base.todict(case), result) 36 | case = [["aaa", "bbb"], 1] 37 | result = {0: {0: 'aaa', 1: 'bbb'}, 1: 1} 38 | self.assertEqual(base.todict(case), result) 39 | case = [["aaa", "bbb"], []] 40 | result = {0: {0: 'aaa', 1: 'bbb'}, 1: {}} 41 | self.assertEqual(base.todict(case), result) 42 | case = [[['a']]] 43 | result = {0: {0: {0: 'a'}}} 44 | self.assertEqual(base.todict(case), result) 45 | case = [[[]]] 46 | result = {0: {0: {}}} 47 | self.assertEqual(base.todict(case), result) 48 | --------------------------------------------------------------------------------