├── .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 | [](https://pypi.python.org/pypi/ecmcli)
7 | [](https://pypi.python.org/pypi/ecmcli)
8 | [](https://github.com/mayfield/ecmcli/blob/master/CHANGELOG.md)
9 | [](https://semaphoreci.com/mayfield/ecmcli)
10 | [](https://pypi.python.org/pypi/ecmcli)
11 | [](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 | [](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%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%%%s>)' % (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 = ['%s>' % 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%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%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 |
--------------------------------------------------------------------------------