From ee211e216a48cd1de1375e80dd299ae4a9ad49df Mon Sep 17 00:00:00 2001 From: Paul Russo Date: Tue, 3 Dec 2019 17:50:40 -0700 Subject: [PATCH 01/30] Added - `get_radius_users()` returns a list of RADIUS names, passwords, IDs, and site IDs - unifi-ls-radius example file --- pyunifi/controller.py | 6 ++++++ unifi-ls-radius | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100755 unifi-ls-radius diff --git a/pyunifi/controller.py b/pyunifi/controller.py index 2334312..a455e86 100644 --- a/pyunifi/controller.py +++ b/pyunifi/controller.py @@ -241,6 +241,12 @@ def _mac_cmd(self, target_mac, command, mgr='stamgr', params={}): log.debug('_mac_cmd(%s, %s)', target_mac, command) params['mac'] = target_mac return self._run_command(command, params, mgr) + + def get_radius_users(self): + """Return a list of all users, with their + name, password, id, and site id + """ + return self._api_read('rest/account') def get_device_stat(self, target_mac): """Gets the current state & configuration of diff --git a/unifi-ls-radius b/unifi-ls-radius new file mode 100755 index 0000000..08b4558 --- /dev/null +++ b/unifi-ls-radius @@ -0,0 +1,36 @@ +#!/usr/bin/env python + +import argparse + +from pyunifi.controller import Controller + +parser = argparse.ArgumentParser() +parser.add_argument('-c', '--controller', default='unifi', help='the controller address (default "unifi")') +parser.add_argument('-u', '--username', default='admin', help='the controller username (default("admin")') +parser.add_argument('-p', '--password', default='', help='the controller password') +parser.add_argument('-b', '--port', default='8443', help='the controller port (default "8443")') +parser.add_argument('-v', '--version', default='v5', help='the controller base version (default "v5")') +parser.add_argument('-s', '--siteid', default='default', help='the site ID, UniFi >=3.x only (default "default")') +parser.add_argument('-V', '--no-ssl-verify', default=False, action='store_true', help='Don\'t verify ssl certificates') +parser.add_argument('-C', '--certificate', default='', help='verify with ssl certificate pem file') +args = parser.parse_args() + +ssl_verify = (not args.no_ssl_verify) + +if ssl_verify and len(args.certificate) > 0: + ssl_verify = args.certificate + +c = Controller(args.controller, args.username, args.password, args.port, args.version, args.siteid, ssl_verify=ssl_verify) + +users = c.get_radius_users() +users.sort(key=lambda x: x['name']) + +FORMAT = '%-30s %-16s %-26s %-16s' +print(FORMAT % ('USERNAME', 'PASSWORD', 'ID', 'SITE ID')) +for user in users: + name = user["name"] + password = user["x_password"] + id = user["_id"] + site_id = user["site_id"] + + print(FORMAT % (name, password, id, site_id)) From b634cdd11274ccc3d0464228192a1c5662048587 Mon Sep 17 00:00:00 2001 From: Paul Russo Date: Tue, 3 Dec 2019 18:00:03 -0700 Subject: [PATCH 02/30] changed format to be more reasonable --- unifi-ls-radius | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unifi-ls-radius b/unifi-ls-radius index 08b4558..16df1a6 100755 --- a/unifi-ls-radius +++ b/unifi-ls-radius @@ -25,7 +25,7 @@ c = Controller(args.controller, args.username, args.password, args.port, args.ve users = c.get_radius_users() users.sort(key=lambda x: x['name']) -FORMAT = '%-30s %-16s %-26s %-16s' +FORMAT = '%-26s %-16s %-26s %-26s' print(FORMAT % ('USERNAME', 'PASSWORD', 'ID', 'SITE ID')) for user in users: name = user["name"] From c1a76d949c507c8449bff674a45f11828414214f Mon Sep 17 00:00:00 2001 From: Paul Russo Date: Tue, 3 Dec 2019 18:56:33 -0700 Subject: [PATCH 03/30] Moved position of get_radius_users() --- pyunifi/controller.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pyunifi/controller.py b/pyunifi/controller.py index a455e86..4dcc4d0 100644 --- a/pyunifi/controller.py +++ b/pyunifi/controller.py @@ -242,12 +242,6 @@ def _mac_cmd(self, target_mac, command, mgr='stamgr', params={}): params['mac'] = target_mac return self._run_command(command, params, mgr) - def get_radius_users(self): - """Return a list of all users, with their - name, password, id, and site id - """ - return self._api_read('rest/account') - def get_device_stat(self, target_mac): """Gets the current state & configuration of the given device based on its MAC Address. @@ -261,6 +255,12 @@ def get_device_stat(self, target_mac): params = {"macs": [target_mac]} return self._api_read('stat/device/' + target_mac, params)[0] + def get_radius_users(self): + """Return a list of all users, with their + name, password, id, and site id + """ + return self._api_read('rest/account') + def get_switch_port_overrides(self, target_mac): """Gets a list of port overrides, in dictionary format, for the given target MAC address. The From 308fd70866da5b4c770b6295d63af81ab591561b Mon Sep 17 00:00:00 2001 From: Paul Russo Date: Tue, 3 Dec 2019 19:59:01 -0700 Subject: [PATCH 04/30] added to README.md --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 770b772..bd30358 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,9 @@ Gets the current state & configuration of the given device based on its MAC Addr - `target_mac` -- MAC address of the device +### `get_radius_users(self)` +Returns a list of all RADIUS users, with their name, password, id, and site id. + ### `get_switch_port_overrides(self, target_mac)` Gets a list of port overrides, in dictionary format, for the given target MAC address. The dictionary contains the port_idx, portconf_id, poe_mode, & name. From 52efca96e3e1d3ba3130edafdad5031d1dd6b426 Mon Sep 17 00:00:00 2001 From: Paul Russo Date: Wed, 4 Dec 2019 16:51:47 -0700 Subject: [PATCH 05/30] Add unifi-save-radius to save RADIUS username, passwords, etc. to .csv --- unifi-save-radius | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100755 unifi-save-radius diff --git a/unifi-save-radius b/unifi-save-radius new file mode 100755 index 0000000..368e923 --- /dev/null +++ b/unifi-save-radius @@ -0,0 +1,46 @@ +#!/usr/bin/env python + +import argparse + +from pyunifi.controller import Controller + +parser = argparse.ArgumentParser() +parser.add_argument('-c', '--controller', default='unifi', help='the controller address (default "unifi")') +parser.add_argument('-u', '--username', default='admin', help='the controller username (default("admin")') +parser.add_argument('-p', '--password', default='', help='the controller password') +parser.add_argument('-b', '--port', default='8443', help='the controller port (default "8443")') +parser.add_argument('-v', '--version', default='v5', help='the controller base version (default "v5")') +parser.add_argument('-s', '--siteid', default='default', help='the site ID, UniFi >=3.x only (default "default")') +parser.add_argument('-V', '--no-ssl-verify', default=False, action='store_true', help='Don\'t verify ssl certificates') +parser.add_argument('-C', '--certificate', default='', help='verify with ssl certificate pem file') +parser.add_argument('-f', '--file', default='radius-unifi.csv', help='the filename of write statistics') +args = parser.parse_args() + +ssl_verify = (not args.no_ssl_verify) + +if ssl_verify and len(args.certificate) > 0: + ssl_verify = args.certificate + +c = Controller(args.controller, args.username, args.password, args.port, args.version, args.siteid, ssl_verify=ssl_verify) + +users = c.get_radius_users() +users.sort(key=lambda x: x['name']) + +#open file +fo = open(args.file, "wb") + +FORMAT_CSV = '%s, %s, %s, %s\n' +fo.write(FORMAT_CSV % ('USERNAME', 'PASSWORD', 'ID', 'SITE ID')) +for user in users: + name = user["name"] + password = user["x_password"] + id = user["_id"] + site_id = user["site_id"] + + fo.write(FORMAT_CSV % (name, password, id, site_id)) + +# Close file +fo.close() + +# Print result of file +print(open(args.file,"rb").read()) From f6ba82c8993581f38a036f38e9935627c0140719 Mon Sep 17 00:00:00 2001 From: Paul Russo Date: Wed, 4 Dec 2019 22:31:12 -0700 Subject: [PATCH 06/30] Added to controller.py - add_radius_user(), update_radius_user(), and delete_radius_user. - _delete() and _api_delete() to support deleting RADIUS users Updated the README.md file --- README.md | 22 +++++++++++++++++++++- pyunifi/controller.py | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bd30358..f35aa26 100644 --- a/README.md +++ b/README.md @@ -209,7 +209,27 @@ Gets the current state & configuration of the given device based on its MAC Addr - `target_mac` -- MAC address of the device ### `get_radius_users(self)` -Returns a list of all RADIUS users, with their name, password, id, and site id. +Returns a list of all RADIUS users, name, password, 24 digit user id, and 24 digit site id. + +### `add_radius_user(self, name, password)` +Add a new RADIUS user with this username and password. + +- `name` -- the new user's username +- `password` -- the new user's password + +### `update_radius_user(self, name, password, id)` +Update a RADIUS user to this new username and password. +Requires the user's 24 digit user id, which can be gotten from `get_radius_users(self)`. + +- `name` -- the user's new username +- `password` -- the user's new password +- `id` -- the user's 24 digit user id. + +### `delete_radius_user(self, id)` +Delete a RADIUS user. +Requires the user's 24 digit user id, which can be gotten from `get_radius_users(self)`. + +- `id` -- the user's 24 digit user id. ### `get_switch_port_overrides(self, target_mac)` Gets a list of port overrides, in dictionary format, for the given target MAC address. The dictionary contains the port_idx, portconf_id, poe_mode, & name. diff --git a/pyunifi/controller.py b/pyunifi/controller.py index 4dcc4d0..4274a6c 100644 --- a/pyunifi/controller.py +++ b/pyunifi/controller.py @@ -128,6 +128,14 @@ def _update(self, url, params=None): def _api_update(self, url, params=None): return self._update(self._api_url() + url, params) + @retry_login + def _delete(self, url, params=None): + r = self.session.delete(url, json=params) + return self._jsondec(r.text) + + def _api_delete(self, url, params=None): + return self._delete(self._api_url() + url, params) + def _login(self): log.debug('login() as %s', self.username) @@ -257,10 +265,33 @@ def get_device_stat(self, target_mac): def get_radius_users(self): """Return a list of all users, with their - name, password, id, and site id + name, password, 24 digit user id, and 24 digit site id. """ return self._api_read('rest/account') + def add_radius_user(self, name, password): + """Add a new user with this username and password. + :returns: user's name, password, 24 digit user id, and 24 digit site id. + """ + params = {'name': name, 'x_password': password} + return self._api_write('rest/account/', params) + + def update_radius_user(self, name, password, id): + """Update a user to this new username and password. + Requires the user's 24 digit user id. + :returns: user's name, password, 24 digit user id, and 24 digit site id. + :returns: [] if no change was made + """ + params = {'name': name, '_id': id, 'x_password': password} + return self._api_update('rest/account/' + id, params) + + def delete_radius_user(self, id): + """Delete user. + Requires the user's 24 digit user id. + :returns: [] if successful. + """ + return self._api_delete('rest/account/' + id) + def get_switch_port_overrides(self, target_mac): """Gets a list of port overrides, in dictionary format, for the given target MAC address. The From cfeb5037c9d2668ea26e8e03bea52937d8a8cb43 Mon Sep 17 00:00:00 2001 From: Paul Russo Date: Thu, 5 Dec 2019 02:22:53 -0700 Subject: [PATCH 07/30] First commit of unifi-copy-radius utility --- unifi-copy-radius | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100755 unifi-copy-radius diff --git a/unifi-copy-radius b/unifi-copy-radius new file mode 100755 index 0000000..16df1a6 --- /dev/null +++ b/unifi-copy-radius @@ -0,0 +1,36 @@ +#!/usr/bin/env python + +import argparse + +from pyunifi.controller import Controller + +parser = argparse.ArgumentParser() +parser.add_argument('-c', '--controller', default='unifi', help='the controller address (default "unifi")') +parser.add_argument('-u', '--username', default='admin', help='the controller username (default("admin")') +parser.add_argument('-p', '--password', default='', help='the controller password') +parser.add_argument('-b', '--port', default='8443', help='the controller port (default "8443")') +parser.add_argument('-v', '--version', default='v5', help='the controller base version (default "v5")') +parser.add_argument('-s', '--siteid', default='default', help='the site ID, UniFi >=3.x only (default "default")') +parser.add_argument('-V', '--no-ssl-verify', default=False, action='store_true', help='Don\'t verify ssl certificates') +parser.add_argument('-C', '--certificate', default='', help='verify with ssl certificate pem file') +args = parser.parse_args() + +ssl_verify = (not args.no_ssl_verify) + +if ssl_verify and len(args.certificate) > 0: + ssl_verify = args.certificate + +c = Controller(args.controller, args.username, args.password, args.port, args.version, args.siteid, ssl_verify=ssl_verify) + +users = c.get_radius_users() +users.sort(key=lambda x: x['name']) + +FORMAT = '%-26s %-16s %-26s %-26s' +print(FORMAT % ('USERNAME', 'PASSWORD', 'ID', 'SITE ID')) +for user in users: + name = user["name"] + password = user["x_password"] + id = user["_id"] + site_id = user["site_id"] + + print(FORMAT % (name, password, id, site_id)) From 0dbbbdb603d663fb60ff32b6c1f1be921cc557bf Mon Sep 17 00:00:00 2001 From: Paul Russo Date: Thu, 5 Dec 2019 02:28:27 -0700 Subject: [PATCH 08/30] Added arg for destination site --- unifi-copy-radius | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/unifi-copy-radius b/unifi-copy-radius index 16df1a6..3d320e7 100755 --- a/unifi-copy-radius +++ b/unifi-copy-radius @@ -10,7 +10,8 @@ parser.add_argument('-u', '--username', default='admin', help='the controller us parser.add_argument('-p', '--password', default='', help='the controller password') parser.add_argument('-b', '--port', default='8443', help='the controller port (default "8443")') parser.add_argument('-v', '--version', default='v5', help='the controller base version (default "v5")') -parser.add_argument('-s', '--siteid', default='default', help='the site ID, UniFi >=3.x only (default "default")') +parser.add_argument('-s', '--siteid', default='default', help='the source site ID, (default "default")') +parser.add_argument('-S', '--siteid2', default='', help='the destination site ID, to copy to') parser.add_argument('-V', '--no-ssl-verify', default=False, action='store_true', help='Don\'t verify ssl certificates') parser.add_argument('-C', '--certificate', default='', help='verify with ssl certificate pem file') args = parser.parse_args() From 7bf7e711929a493d83b4cdfb09ba909577574802 Mon Sep 17 00:00:00 2001 From: Paul Russo Date: Fri, 6 Dec 2019 14:32:13 -0700 Subject: [PATCH 09/30] copy-radius utility creates two sets, modified and changed users --- unifi-copy-radius | 47 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/unifi-copy-radius b/unifi-copy-radius index 3d320e7..61c0df1 100755 --- a/unifi-copy-radius +++ b/unifi-copy-radius @@ -1,6 +1,7 @@ #!/usr/bin/env python import argparse +import json from pyunifi.controller import Controller @@ -21,17 +22,41 @@ ssl_verify = (not args.no_ssl_verify) if ssl_verify and len(args.certificate) > 0: ssl_verify = args.certificate -c = Controller(args.controller, args.username, args.password, args.port, args.version, args.siteid, ssl_verify=ssl_verify) +controller_source = Controller(args.controller, args.username, args.password, args.port, args.version, args.siteid, ssl_verify=ssl_verify) +controller_dest = Controller(args.controller, args.username, args.password, args.port, args.version, args.siteid2, ssl_verify=ssl_verify) -users = c.get_radius_users() -users.sort(key=lambda x: x['name']) +source_users = controller_source.get_radius_users() +dest_users = controller_dest.get_radius_users() -FORMAT = '%-26s %-16s %-26s %-26s' -print(FORMAT % ('USERNAME', 'PASSWORD', 'ID', 'SITE ID')) -for user in users: - name = user["name"] - password = user["x_password"] - id = user["_id"] - site_id = user["site_id"] +# remove irrelevent fields +for user in source_users: + user.pop("site_id", None) + user.pop("vlan", None) + user.pop("tunnel_type", None) + user.pop("tunnel_medium_type", None) +for user in dest_users: + user.pop("site_id", None) + user.pop("vlan", None) + user.pop("tunnel_type", None) + user.pop("tunnel_medium_type", None) + + +unchanged_users = [] +modified_users = [] + +for source_user in source_users: + for dest_user in dest_users: + if source_user['name'] == dest_user['name']: + # usernames are the same + if source_user['x_password'] == dest_user['x_password']: + # password has not changed + unchanged_users.append (source_user) + else: + # password has changed + modified_users.append (source_user) + +print("source_users\n", json.dumps(source_users, indent=2, sort_keys=False), "\n") +print("dest_users\n", json.dumps(dest_users, indent=2, sort_keys=False), "\n") +print("unchanged_users\n", json.dumps(unchanged_users, indent=2, sort_keys=False), "\n") +print("modified_users\n", json.dumps(modified_users, indent=2, sort_keys=False), "\n") - print(FORMAT % (name, password, id, site_id)) From 5650c371a36afbff78be9b6f89d6a826997749b9 Mon Sep 17 00:00:00 2001 From: Paul Russo Date: Fri, 6 Dec 2019 16:07:22 -0700 Subject: [PATCH 10/30] unifi-copy-radius creates two more sets, added and deleted users --- unifi-copy-radius | 43 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/unifi-copy-radius b/unifi-copy-radius index 61c0df1..6e8b739 100755 --- a/unifi-copy-radius +++ b/unifi-copy-radius @@ -28,18 +28,29 @@ controller_dest = Controller(args.controller, args.username, args.password, ar source_users = controller_source.get_radius_users() dest_users = controller_dest.get_radius_users() -# remove irrelevent fields for user in source_users: + # remove irrelevent fields user.pop("site_id", None) user.pop("vlan", None) user.pop("tunnel_type", None) user.pop("tunnel_medium_type", None) + # add status field to keep track of which + # users should be added or deleted or modified + user["status"] = "None" for user in dest_users: + # remove irrelevent fields user.pop("site_id", None) user.pop("vlan", None) user.pop("tunnel_type", None) user.pop("tunnel_medium_type", None) + # add status field to keep track of which + # users should be added or deleted or modified + user["status"] = "None" +source_users.sort(key=lambda x: x['name']) +print("source_users\n", json.dumps(source_users, indent=2, sort_keys=False), "\n") +dest_users.sort(key=lambda x: x['name']) +print("dest_users\n", json.dumps(dest_users, indent=2, sort_keys=False), "\n") unchanged_users = [] modified_users = [] @@ -50,13 +61,39 @@ for source_user in source_users: # usernames are the same if source_user['x_password'] == dest_user['x_password']: # password has not changed + dest_user["status"] = "unchanged" + source_user["status"] = "unchanged" unchanged_users.append (source_user) else: # password has changed + dest_user["status"] = "modified" + source_user["status"] = "modified" modified_users.append (source_user) -print("source_users\n", json.dumps(source_users, indent=2, sort_keys=False), "\n") -print("dest_users\n", json.dumps(dest_users, indent=2, sort_keys=False), "\n") +unchanged_users.sort(key=lambda x: x['name']) print("unchanged_users\n", json.dumps(unchanged_users, indent=2, sort_keys=False), "\n") +modified_users.sort(key=lambda x: x['name']) print("modified_users\n", json.dumps(modified_users, indent=2, sort_keys=False), "\n") +added_users = [] +deleted_users = [] + +# Any users who are not unchanged or modified +# are unique to either the source or destination +# +# Added users are on the source +for source_user in source_users: + if source_user['status'] == 'None': + source_user['status'] == 'added' + added_users.append(source_user) +# +# Deleted users are on the destination +for dest_user in dest_users: + if dest_user['status'] == 'None': + dest_user['status'] == 'deleted' + deleted_users.append(dest_user) + +added_users.sort(key=lambda x: x['name']) +print("added_users\n", json.dumps(added_users, indent=2, sort_keys=False), "\n") +deleted_users.sort(key=lambda x: x['name']) +print("deleted_users\n", json.dumps(deleted_users, indent=2, sort_keys=False), "\n") From bfea61415b157a34b81763add1919d63c0c5047c Mon Sep 17 00:00:00 2001 From: Paul Russo Date: Fri, 6 Dec 2019 21:58:42 -0700 Subject: [PATCH 11/30] unifi-copy-radius added the calls to the RADIUS code --- unifi-copy-radius | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/unifi-copy-radius b/unifi-copy-radius index 6e8b739..498fb85 100755 --- a/unifi-copy-radius +++ b/unifi-copy-radius @@ -97,3 +97,19 @@ added_users.sort(key=lambda x: x['name']) print("added_users\n", json.dumps(added_users, indent=2, sort_keys=False), "\n") deleted_users.sort(key=lambda x: x['name']) print("deleted_users\n", json.dumps(deleted_users, indent=2, sort_keys=False), "\n") + +print () +if (len(added_users) == 0) and (len(modified_users) == 0) and (len(deleted_users) == 0): + print ("No users to add, modify, or delete") +else: + for user in added_users: + print ("adding user:", user['name']) + controller_dest.add_radius_user(user['name'], user['x_password']) + + for user in modified_users: + print ("updating user:", user['name']) + controller_dest.update_radius_user(user['name'], user['x_password'], user['_id']) + + for user in deleted_users: + print ("deleting user:", user['name']) + controller_dest.delete_radius_user(user['_id']) From 8af97b047661de08b5cdbd54d1e3ae4f0ffdbda0 Mon Sep 17 00:00:00 2001 From: Paul Russo Date: Fri, 6 Dec 2019 22:07:34 -0700 Subject: [PATCH 12/30] bug: turns out modifying the user requires data from both source and dest user --- unifi-copy-radius | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/unifi-copy-radius b/unifi-copy-radius index 498fb85..a63f13d 100755 --- a/unifi-copy-radius +++ b/unifi-copy-radius @@ -68,7 +68,13 @@ for source_user in source_users: # password has changed dest_user["status"] = "modified" source_user["status"] = "modified" - modified_users.append (source_user) + # Strange problem solved by temp_user. + # We need the username/password of source_user + temp_user['name'] = source_user['name'] + temp_user['x_password'] = source_user['x_password'] + # but: we need the id of the destination user to modify it + temp_user['_id'] = dest_user['_id'] + modified_users.append (temp_user) unchanged_users.sort(key=lambda x: x['name']) print("unchanged_users\n", json.dumps(unchanged_users, indent=2, sort_keys=False), "\n") From ebe9e50873ae1f140419c250e16863a01ec6baaa Mon Sep 17 00:00:00 2001 From: Paul Russo Date: Fri, 6 Dec 2019 22:09:10 -0700 Subject: [PATCH 13/30] Satisfies linter --- unifi-copy-radius | 1 + 1 file changed, 1 insertion(+) diff --git a/unifi-copy-radius b/unifi-copy-radius index a63f13d..b4c3327 100755 --- a/unifi-copy-radius +++ b/unifi-copy-radius @@ -54,6 +54,7 @@ print("dest_users\n", json.dumps(dest_users, indent=2, sort_keys=False), "\n") unchanged_users = [] modified_users = [] +temp_user = {} for source_user in source_users: for dest_user in dest_users: From cf0a93a6335f8e0ed3e39c5779dca6756d8212bd Mon Sep 17 00:00:00 2001 From: Paul Russo Date: Fri, 6 Dec 2019 22:10:18 -0700 Subject: [PATCH 14/30] New better comments --- unifi-copy-radius | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/unifi-copy-radius b/unifi-copy-radius index b4c3327..22a7f72 100755 --- a/unifi-copy-radius +++ b/unifi-copy-radius @@ -56,17 +56,20 @@ unchanged_users = [] modified_users = [] temp_user = {} +# Compare source and destination usernames and passwords +# to decide which users have been unchanged or modified +# for source_user in source_users: for dest_user in dest_users: if source_user['name'] == dest_user['name']: # usernames are the same if source_user['x_password'] == dest_user['x_password']: - # password has not changed + # username and password are the same dest_user["status"] = "unchanged" source_user["status"] = "unchanged" unchanged_users.append (source_user) else: - # password has changed + # username is the same but password has changed dest_user["status"] = "modified" source_user["status"] = "modified" # Strange problem solved by temp_user. @@ -88,13 +91,13 @@ deleted_users = [] # Any users who are not unchanged or modified # are unique to either the source or destination # -# Added users are on the source +# Unique users on the source will be added to the destination for source_user in source_users: if source_user['status'] == 'None': source_user['status'] == 'added' added_users.append(source_user) # -# Deleted users are on the destination +# Unique users on the destination will be deleted from the destination for dest_user in dest_users: if dest_user['status'] == 'None': dest_user['status'] == 'deleted' From 65d4e3591d2ec40ca85af21b317b4c2464a0c090 Mon Sep 17 00:00:00 2001 From: Paul Russo Date: Sat, 7 Dec 2019 15:12:38 -0700 Subject: [PATCH 15/30] Comment and white space cleanup --- pyunifi/controller.py | 30 +++++++++++++++++------------- unifi-ls-clients | 2 +- unifi-ls-radius | 4 ++-- unifi-save-radius | 6 +++--- 4 files changed, 23 insertions(+), 19 deletions(-) diff --git a/pyunifi/controller.py b/pyunifi/controller.py index 4274a6c..320fe04 100644 --- a/pyunifi/controller.py +++ b/pyunifi/controller.py @@ -249,7 +249,7 @@ def _mac_cmd(self, target_mac, command, mgr='stamgr', params={}): log.debug('_mac_cmd(%s, %s)', target_mac, command) params['mac'] = target_mac return self._run_command(command, params, mgr) - + def get_device_stat(self, target_mac): """Gets the current state & configuration of the given device based on its MAC Address. @@ -265,30 +265,34 @@ def get_device_stat(self, target_mac): def get_radius_users(self): """Return a list of all users, with their - name, password, 24 digit user id, and 24 digit site id. + name, password, 24 digit user id, and 24 digit site id """ return self._api_read('rest/account') def add_radius_user(self, name, password): - """Add a new user with this username and password. - :returns: user's name, password, 24 digit user id, and 24 digit site id. + """Add a new user with this username and password + :param name: new user's username + :param password: new user's password + :returns: user's name, password, 24 digit user id, and 24 digit site id """ params = {'name': name, 'x_password': password} - return self._api_write('rest/account/', params) + return self._api_write('rest/account/', params) def update_radius_user(self, name, password, id): - """Update a user to this new username and password. - Requires the user's 24 digit user id. - :returns: user's name, password, 24 digit user id, and 24 digit site id. + """Update a user to this new username and password + :param name: user's new username + :param password: user's new password + :param id: the user's 24 digit user id, from get_radius_users() or add_radius_user() + :returns: user's name, password, 24 digit user id, and 24 digit site id :returns: [] if no change was made """ params = {'name': name, '_id': id, 'x_password': password} - return self._api_update('rest/account/' + id, params) - + return self._api_update('rest/account/' + id, params) + def delete_radius_user(self, id): - """Delete user. - Requires the user's 24 digit user id. - :returns: [] if successful. + """Delete user + :param id: the user's 24 digit user id, from get_radius_users() or add_radius_user() + :returns: [] if successful """ return self._api_delete('rest/account/' + id) diff --git a/unifi-ls-clients b/unifi-ls-clients index 34cb118..66a675a 100755 --- a/unifi-ls-clients +++ b/unifi-ls-clients @@ -19,7 +19,7 @@ ssl_verify = (not args.no_ssl_verify) if ssl_verify and len(args.certificate) > 0: ssl_verify = args.certificate - + c = Controller(args.controller, args.username, args.password, args.port, args.version, args.siteid, ssl_verify=ssl_verify) aps = c.get_aps() diff --git a/unifi-ls-radius b/unifi-ls-radius index 16df1a6..a4bef8a 100755 --- a/unifi-ls-radius +++ b/unifi-ls-radius @@ -1,5 +1,5 @@ #!/usr/bin/env python - + import argparse from pyunifi.controller import Controller @@ -19,7 +19,7 @@ ssl_verify = (not args.no_ssl_verify) if ssl_verify and len(args.certificate) > 0: ssl_verify = args.certificate - + c = Controller(args.controller, args.username, args.password, args.port, args.version, args.siteid, ssl_verify=ssl_verify) users = c.get_radius_users() diff --git a/unifi-save-radius b/unifi-save-radius index 368e923..e3b2a66 100755 --- a/unifi-save-radius +++ b/unifi-save-radius @@ -1,5 +1,5 @@ #!/usr/bin/env python - + import argparse from pyunifi.controller import Controller @@ -20,7 +20,7 @@ ssl_verify = (not args.no_ssl_verify) if ssl_verify and len(args.certificate) > 0: ssl_verify = args.certificate - + c = Controller(args.controller, args.username, args.password, args.port, args.version, args.siteid, ssl_verify=ssl_verify) users = c.get_radius_users() @@ -38,7 +38,7 @@ for user in users: site_id = user["site_id"] fo.write(FORMAT_CSV % (name, password, id, site_id)) - + # Close file fo.close() From f62c32334e287cd09e1c0f5d4462bbb81268c187 Mon Sep 17 00:00:00 2001 From: Caleb Dunn Date: Mon, 30 Mar 2020 08:42:43 -0500 Subject: [PATCH 16/30] update to version 2.20 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index d6d259f..bd0fc66 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ from setuptools import setup setup(name='pyunifi', - version='2.19.0', + version='2.20.0', description='API for Ubiquity Networks UniFi controller', author='Caleb Dunn', author_email='finish.06@gmail.com', From 19422c69d9dd7b740993ed7b5849e244eb670823 Mon Sep 17 00:00:00 2001 From: Caleb Dunn Date: Mon, 30 Mar 2020 08:49:50 -0500 Subject: [PATCH 17/30] add 2.20.0 changes --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21f1eae..c71a359 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [2.20.0] - 2020-04-01 +### Added +- CHANGELOG +- Added support for UnifiOS: `version = 'unifiOS'` ## [2.19.0] - 2019-10-28 ### Added From a99a3fafd3c488f87ef2540fe5d9a6d40c899cbb Mon Sep 17 00:00:00 2001 From: Caleb Dunn Date: Mon, 30 Mar 2020 08:51:37 -0500 Subject: [PATCH 18/30] add support for UnifiOS --- pyunifi/controller.py | 57 +++++++++++++++++++++++++++++-------------- 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/pyunifi/controller.py b/pyunifi/controller.py index 2334312..13149b3 100644 --- a/pyunifi/controller.py +++ b/pyunifi/controller.py @@ -66,28 +66,49 @@ def __init__(self, host, username, password, port=8443, :param ssl_verify: Verify the controllers SSL certificate, can also be "path/to/custom_cert.pem" """ + self.log = logging.getLogger(__name__ + ".Controller") - if float(version[1:]) < 4: - raise APIError("%s controllers no longer supported" % version) - - self.host = host - self.port = port - self.version = version - self.username = username - self.password = password - self.site_id = site_id - self.url = 'https://' + host + ':' + str(port) + '/' - self.ssl_verify = ssl_verify - - if ssl_verify is False: - warnings.simplefilter("default", category=requests.packages. + + if version == "unifiOS": + self.host = host + self.username = username + self.password = password + self.site_id = site_id + self.ssl_verify = ssl_verify + self.url = 'https://' + host + '/proxy/network/' + + if ssl_verify is False: + warnings.simplefilter("default", category=requests.packages. urllib3.exceptions.InsecureRequestWarning) - self.session = requests.Session() - self.session.verify = ssl_verify + self.session = requests.Session() + self.session.verify = ssl_verify + + self.log.debug('Controller for %s', self.url) + self._login() + + if version[:1] == 'v': + if float(version[1:]) < 4: + raise APIError("%s controllers no longer supported" % version) + + self.host = host + self.port = port + self.version = version + self.username = username + self.password = password + self.site_id = site_id + self.url = 'https://' + host + ':' + str(port) + '/' + self.ssl_verify = ssl_verify + + if ssl_verify is False: + warnings.simplefilter("default", category=requests.packages. + urllib3.exceptions.InsecureRequestWarning) + + self.session = requests.Session() + self.session.verify = ssl_verify - self.log.debug('Controller for %s', self.url) - self._login() + self.log.debug('Controller for %s', self.url) + self._login() @staticmethod def _jsondec(data): From 87afa81e96200a3b02def868585042ee7a50e257 Mon Sep 17 00:00:00 2001 From: Caleb Dunn Date: Mon, 30 Mar 2020 08:56:13 -0500 Subject: [PATCH 19/30] fix formatting --- pyunifi/controller.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyunifi/controller.py b/pyunifi/controller.py index 13149b3..98d8598 100644 --- a/pyunifi/controller.py +++ b/pyunifi/controller.py @@ -79,7 +79,7 @@ def __init__(self, host, username, password, port=8443, if ssl_verify is False: warnings.simplefilter("default", category=requests.packages. - urllib3.exceptions.InsecureRequestWarning) + urllib3.exceptions.InsecureRequestWarning) self.session = requests.Session() self.session.verify = ssl_verify @@ -87,7 +87,7 @@ def __init__(self, host, username, password, port=8443, self.log.debug('Controller for %s', self.url) self._login() - if version[:1] == 'v': + if version[:1] == 'v': if float(version[1:]) < 4: raise APIError("%s controllers no longer supported" % version) @@ -102,7 +102,7 @@ def __init__(self, host, username, password, port=8443, if ssl_verify is False: warnings.simplefilter("default", category=requests.packages. - urllib3.exceptions.InsecureRequestWarning) + urllib3.exceptions.InsecureRequestWarning) self.session = requests.Session() self.session.verify = ssl_verify From 6d78528375e4dc380e620c50873a420ea1534c4b Mon Sep 17 00:00:00 2001 From: Caleb Dunn Date: Mon, 30 Mar 2020 08:57:47 -0500 Subject: [PATCH 20/30] formatting fix line length --- pyunifi/controller.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pyunifi/controller.py b/pyunifi/controller.py index 98d8598..91d920a 100644 --- a/pyunifi/controller.py +++ b/pyunifi/controller.py @@ -79,7 +79,8 @@ def __init__(self, host, username, password, port=8443, if ssl_verify is False: warnings.simplefilter("default", category=requests.packages. - urllib3.exceptions.InsecureRequestWarning) + urllib3.exceptions. + InsecureRequestWarning) self.session = requests.Session() self.session.verify = ssl_verify @@ -102,7 +103,8 @@ def __init__(self, host, username, password, port=8443, if ssl_verify is False: warnings.simplefilter("default", category=requests.packages. - urllib3.exceptions.InsecureRequestWarning) + urllib3.exceptions. + InsecureRequestWarning) self.session = requests.Session() self.session.verify = ssl_verify From 5664706047867846f389f8ba85ebdea3ca23f397 Mon Sep 17 00:00:00 2001 From: Caleb Dunn Date: Mon, 30 Mar 2020 09:03:58 -0500 Subject: [PATCH 21/30] version 2.20.1 --- CHANGELOG.md | 6 +++++- setup.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c71a359..622a0fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [2.20.0] - 2020-04-01 +## [2.20.1] - 2020-03-30 +### Fixed +- Lint failures in controller.py + +## [2.20.0] - 2020-03-30 ### Added - CHANGELOG - Added support for UnifiOS: `version = 'unifiOS'` diff --git a/setup.py b/setup.py index bd0fc66..3cd994b 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ from setuptools import setup setup(name='pyunifi', - version='2.20.0', + version='2.20.1', description='API for Ubiquity Networks UniFi controller', author='Caleb Dunn', author_email='finish.06@gmail.com', From 01b8eafeb49ad848c55f14ea17887a8ec461d356 Mon Sep 17 00:00:00 2001 From: Caleb Dunn Date: Mon, 30 Mar 2020 09:15:56 -0500 Subject: [PATCH 22/30] include `unifiOS` in version options --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 770b772..d2a78bd 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Create a Controller object. - `username` -- the username to log in with - `password` -- the password to log in with - `port` -- the port of the controller host - - `version` -- the base version of the controller API [v4|v5] + - `version` -- the base version of the controller API [v4|v5|unifiOS] - `site_id` -- the site ID to access - `ssl_verify` -- Verify the controllers SSL certificate, default=True, can also be False or "path/to/custom_cert.pem" From 6255fb7b466a47533fa5a33341719f006cea9b40 Mon Sep 17 00:00:00 2001 From: digitlength <62958838+digitlength@users.noreply.github.com> Date: Tue, 9 Jun 2020 16:26:18 +0100 Subject: [PATCH 23/30] Update README.md Add all options to unifi-ls-clients --- README.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d2a78bd..ab6f58a 100644 --- a/README.md +++ b/README.md @@ -232,8 +232,17 @@ The following small utilities are bundled with the API: ### unifi-ls-clients -Lists the currently active clients on the networks. Takes parameters for -controller, username, password, controller version and site ID (UniFi >= 3.x) +Lists the currently active clients on the networks. Can take the following parameters: +|Parameters |Description |Default | +| ------------- |---------------------------------------| -------| +| -c | controller address |unifi | +| -u | controller username |admin | +| -p | controller password | | +| -b | controller port |8443 | +| -v | controller base version |v5 | +| -s | site ID, UniFi >=3.x only |default | +| -V | ignore SSL certificates | | +| -C | verify with ssl certificate pem file | | ``` jb@unifi:~ % unifi-ls-clients -c localhost -u admin -p p4ssw0rd -v v3 -s default From 63b8d2b0224869be486ed9127e40cb0a11a0e01c Mon Sep 17 00:00:00 2001 From: ChrisMandich Date: Wed, 5 Aug 2020 23:29:24 -0700 Subject: [PATCH 24/30] Add UDM Support, support for CSRF token required for every POST. --- pyunifi/controller.py | 121 +++++++++++++++++++++++------------------- 1 file changed, 66 insertions(+), 55 deletions(-) diff --git a/pyunifi/controller.py b/pyunifi/controller.py index 91d920a..bc2c9bd 100644 --- a/pyunifi/controller.py +++ b/pyunifi/controller.py @@ -4,6 +4,7 @@ import shutil import time import warnings +from urllib3.exceptions import InsecureRequestWarning """For testing purposes: @@ -69,49 +70,34 @@ def __init__(self, host, username, password, port=8443, self.log = logging.getLogger(__name__ + ".Controller") + self.host = host + self.headers=None + self.version = version + self.port = port + self.username = username + self.password = password + self.site_id = site_id + self.ssl_verify = ssl_verify + if version == "unifiOS": - self.host = host - self.username = username - self.password = password - self.site_id = site_id - self.ssl_verify = ssl_verify self.url = 'https://' + host + '/proxy/network/' - - if ssl_verify is False: - warnings.simplefilter("default", category=requests.packages. - urllib3.exceptions. - InsecureRequestWarning) - - self.session = requests.Session() - self.session.verify = ssl_verify - - self.log.debug('Controller for %s', self.url) - self._login() - - if version[:1] == 'v': + self.auth_url = self.url + 'api/login' + elif version == "UDMP-unifiOS": + self.auth_url = 'https://' + host + '/api/auth/login' + self.url = 'https://' + host + '/proxy/network/' + elif version[:1] == 'v': if float(version[1:]) < 4: raise APIError("%s controllers no longer supported" % version) - - self.host = host - self.port = port - self.version = version - self.username = username - self.password = password - self.site_id = site_id self.url = 'https://' + host + ':' + str(port) + '/' - self.ssl_verify = ssl_verify - - if ssl_verify is False: - warnings.simplefilter("default", category=requests.packages. - urllib3.exceptions. - InsecureRequestWarning) - - self.session = requests.Session() - self.session.verify = ssl_verify - - self.log.debug('Controller for %s', self.url) - self._login() + self.auth_url = self.url + 'api/login' + else: + raise APIError("%s controllers no longer supported" % version) + if ssl_verify is False: + warnings.simplefilter("default", category=InsecureRequestWarning) + + self.log.debug('Controller for %s', self.url) + self._login() @staticmethod def _jsondec(data): obj = json.loads(data) @@ -129,7 +115,11 @@ def _api_url(self): @retry_login def _read(self, url, params=None): # Try block to handle the unifi server being offline. - r = self.session.get(url, params=params) + r = self.session.get(url, params=params, headers=self.headers) + + if r.headers.get('X-CSRF-Token'): + self.headers = {'X-CSRF-Token': r.headers['X-CSRF-Token']} + return self._jsondec(r.text) def _api_read(self, url, params=None): @@ -137,7 +127,11 @@ def _api_read(self, url, params=None): @retry_login def _write(self, url, params=None): - r = self.session.post(url, json=params) + r = self.session.post(url, json=params, headers=self.headers) + + if r.headers.get('X-CSRF-Token'): + self.headers = {'X-CSRF-Token': r.headers['X-CSRF-Token']} + return self._jsondec(r.text) def _api_write(self, url, params=None): @@ -145,7 +139,11 @@ def _api_write(self, url, params=None): @retry_login def _update(self, url, params=None): - r = self.session.put(url, json=params) + r = self.session.put(url, json=params, headers=self.headers) + + if r.headers.get('X-CSRF-Token'): + self.headers = {'X-CSRF-Token': r.headers['X-CSRF-Token']} + return self._jsondec(r.text) def _api_update(self, url, params=None): @@ -153,18 +151,24 @@ def _api_update(self, url, params=None): def _login(self): log.debug('login() as %s', self.username) + self.session = requests.Session() + self.session.verify = self.ssl_verify # XXX Why doesn't passing in the dict work? params = {'username': self.username, 'password': self.password} - login_url = self.url + 'api/login' + + r = self.session.post(self.auth_url, json=params, headers=self.headers) + + if r.headers.get('X-CSRF-Token'): + self.headers = {'X-CSRF-Token': r.headers['X-CSRF-Token']} - r = self.session.post(login_url, json=params) if r.status_code != 200: raise APIError("Login failed - status code: %i" % r.status_code) def _logout(self): log.debug('logout()') self._api_write('logout') + self.session.close() def switch_site(self, name): """ @@ -185,7 +189,8 @@ def get_alerts(self): def get_alerts_unarchived(self): """Return a list of Alerts unarchived.""" - return self._api_write('stat/alarm', params={'archived': False}) + params = {'archived': False} + return self._api_write('stat/alarm', params=params) def get_statistics_last_24h(self): """Returns statistical data of the last 24h""" @@ -193,12 +198,12 @@ def get_statistics_last_24h(self): def get_statistics_24h(self, endtime): """Return statistical data last 24h from time""" - params = { 'attrs': ["bytes", "num_sta", "time"], 'start': int(endtime - 86400) * 1000, - 'end': int(endtime - 3600) * 1000} - return self._write(self._api_url() + 'stat/report/hourly.site', params) + 'end': int(endtime - 3600) * 1000 + } + return self._api_write('stat/report/hourly.site', params) def get_events(self): """Return a list of all Events.""" @@ -258,7 +263,7 @@ def get_wlan_conf(self): def _run_command(self, command, params={}, mgr='stamgr'): log.debug('_run_command(%s)', command) params.update({'cmd': command}) - return self._write(self._api_url() + 'cmd/' + mgr, params=params) + return self._api_write('cmd/' + mgr, params=params) def _mac_cmd(self, target_mac, command, mgr='stamgr', params={}): log.debug('_mac_cmd(%s, %s)', target_mac, command) @@ -310,15 +315,16 @@ def _switch_port_power(self, target_mac, port_idx, mode): # different Class, Switch. log.debug('_switch_port_power(%s, %s, %s)', target_mac, port_idx, mode) device_stat = self.get_device_stat(target_mac) - device_id = device_stat['_id'] - overrides = device_stat['port_overrides'] + device_id = device_stat.get('_id') + overrides = device_stat.get('port_overrides') found = False - for i in range(0, len(overrides)): - if overrides[i]['port_idx'] == port_idx: - # Override already exists, update.. - overrides[i]['poe_mode'] = mode - found = True - break + if overrides: + for i in range(0, len(overrides)): + if overrides[i]['port_idx'] == port_idx: + # Override already exists, update.. + overrides[i]['poe_mode'] = mode + found = True + break if not found: # Retrieve portconf portconf_id = None @@ -378,6 +384,9 @@ def create_site(self, desc='desc'): :param desc: Name of the site to be created. """ + + # TODO: Not currently supported on UDM Pro as site support doesn't exist. + return self._run_command('add-site', params={"desc": desc}, mgr='sitemgr') @@ -427,6 +436,7 @@ def archive_all_alerts(self): """Archive all Alerts""" return self._run_command('archive-all-alarms', mgr='evtmgr') + # TODO: Not currently supported on UDM Pro as it now utilizes async-backups. def create_backup(self, days='0'): """Ask controller to create a backup archive file @@ -441,6 +451,7 @@ def create_backup(self, days='0'): res = self._run_command('backup', mgr='system', params={'days': days}) return res[0]['url'] + # TODO: Not currently supported on UDM Pro as it now utilizes async-backups. def get_backup(self, download_path=None, target_file='unifi-backup.unf'): """ :param download_path: path to backup; if None is given From fd857822ec4dc1e0387ac8ee9c1403a17a242172 Mon Sep 17 00:00:00 2001 From: ChrisMandich Date: Wed, 5 Aug 2020 23:34:53 -0700 Subject: [PATCH 25/30] Black formatting --- pyunifi/controller.py | 356 +++++++++++++++++++++++------------------- 1 file changed, 193 insertions(+), 163 deletions(-) diff --git a/pyunifi/controller.py b/pyunifi/controller.py index bc2c9bd..0e62d73 100644 --- a/pyunifi/controller.py +++ b/pyunifi/controller.py @@ -20,18 +20,19 @@ class APIError(Exception): def retry_login(func, *args, **kwargs): """To reattempt login if requests exception(s) occur at time of call""" + def wrapper(*args, **kwargs): try: try: return func(*args, **kwargs) - except (requests.exceptions.RequestException, - APIError) as err: + except (requests.exceptions.RequestException, APIError) as err: log.warning("Failed to perform %s due to %s" % (func, err)) controller = args[0] controller._login() return func(*args, **kwargs) except Exception as err: raise APIError(err) + return wrapper @@ -55,8 +56,16 @@ class Controller(object): """ - def __init__(self, host, username, password, port=8443, - version='v5', site_id='default', ssl_verify=True): + def __init__( + self, + host, + username, + password, + port=8443, + version="v5", + site_id="default", + ssl_verify=True, + ): """ :param host: the address of the controller host; IP or name :param username: the username to log in with @@ -71,7 +80,7 @@ def __init__(self, host, username, password, port=8443, self.log = logging.getLogger(__name__ + ".Controller") self.host = host - self.headers=None + self.headers = None self.version = version self.port = port self.username = username @@ -80,45 +89,46 @@ def __init__(self, host, username, password, port=8443, self.ssl_verify = ssl_verify if version == "unifiOS": - self.url = 'https://' + host + '/proxy/network/' - self.auth_url = self.url + 'api/login' + self.url = "https://" + host + "/proxy/network/" + self.auth_url = self.url + "api/login" elif version == "UDMP-unifiOS": - self.auth_url = 'https://' + host + '/api/auth/login' - self.url = 'https://' + host + '/proxy/network/' - elif version[:1] == 'v': + self.auth_url = "https://" + host + "/api/auth/login" + self.url = "https://" + host + "/proxy/network/" + elif version[:1] == "v": if float(version[1:]) < 4: raise APIError("%s controllers no longer supported" % version) - self.url = 'https://' + host + ':' + str(port) + '/' - self.auth_url = self.url + 'api/login' + self.url = "https://" + host + ":" + str(port) + "/" + self.auth_url = self.url + "api/login" else: raise APIError("%s controllers no longer supported" % version) if ssl_verify is False: warnings.simplefilter("default", category=InsecureRequestWarning) - - self.log.debug('Controller for %s', self.url) + + self.log.debug("Controller for %s", self.url) self._login() + @staticmethod def _jsondec(data): obj = json.loads(data) - if 'meta' in obj: - if obj['meta']['rc'] != 'ok': - raise APIError(obj['meta']['msg']) - if 'data' in obj: - return obj['data'] + if "meta" in obj: + if obj["meta"]["rc"] != "ok": + raise APIError(obj["meta"]["msg"]) + if "data" in obj: + return obj["data"] else: return obj def _api_url(self): - return self.url + 'api/s/' + self.site_id + '/' + return self.url + "api/s/" + self.site_id + "/" @retry_login def _read(self, url, params=None): # Try block to handle the unifi server being offline. r = self.session.get(url, params=params, headers=self.headers) - if r.headers.get('X-CSRF-Token'): - self.headers = {'X-CSRF-Token': r.headers['X-CSRF-Token']} + if r.headers.get("X-CSRF-Token"): + self.headers = {"X-CSRF-Token": r.headers["X-CSRF-Token"]} return self._jsondec(r.text) @@ -129,8 +139,8 @@ def _api_read(self, url, params=None): def _write(self, url, params=None): r = self.session.post(url, json=params, headers=self.headers) - if r.headers.get('X-CSRF-Token'): - self.headers = {'X-CSRF-Token': r.headers['X-CSRF-Token']} + if r.headers.get("X-CSRF-Token"): + self.headers = {"X-CSRF-Token": r.headers["X-CSRF-Token"]} return self._jsondec(r.text) @@ -141,8 +151,8 @@ def _api_write(self, url, params=None): def _update(self, url, params=None): r = self.session.put(url, json=params, headers=self.headers) - if r.headers.get('X-CSRF-Token'): - self.headers = {'X-CSRF-Token': r.headers['X-CSRF-Token']} + if r.headers.get("X-CSRF-Token"): + self.headers = {"X-CSRF-Token": r.headers["X-CSRF-Token"]} return self._jsondec(r.text) @@ -150,24 +160,24 @@ def _api_update(self, url, params=None): return self._update(self._api_url() + url, params) def _login(self): - log.debug('login() as %s', self.username) + log.debug("login() as %s", self.username) self.session = requests.Session() self.session.verify = self.ssl_verify # XXX Why doesn't passing in the dict work? - params = {'username': self.username, 'password': self.password} - + params = {"username": self.username, "password": self.password} + r = self.session.post(self.auth_url, json=params, headers=self.headers) - - if r.headers.get('X-CSRF-Token'): - self.headers = {'X-CSRF-Token': r.headers['X-CSRF-Token']} + + if r.headers.get("X-CSRF-Token"): + self.headers = {"X-CSRF-Token": r.headers["X-CSRF-Token"]} if r.status_code != 200: raise APIError("Login failed - status code: %i" % r.status_code) def _logout(self): - log.debug('logout()') - self._api_write('logout') + log.debug("logout()") + self._api_write("logout") self.session.close() def switch_site(self, name): @@ -178,19 +188,19 @@ def switch_site(self, name): :return: True or APIError """ for site in self.get_sites(): - if site['desc'] == name: - self.site_id = site['name'] + if site["desc"] == name: + self.site_id = site["name"] return True raise APIError("No site %s found" % name) def get_alerts(self): """Return a list of all Alerts.""" - return self._api_write('stat/alarm') + return self._api_write("stat/alarm") def get_alerts_unarchived(self): """Return a list of Alerts unarchived.""" - params = {'archived': False} - return self._api_write('stat/alarm', params=params) + params = {"archived": False} + return self._api_write("stat/alarm", params=params) def get_statistics_last_24h(self): """Returns statistical data of the last 24h""" @@ -199,23 +209,23 @@ def get_statistics_last_24h(self): def get_statistics_24h(self, endtime): """Return statistical data last 24h from time""" params = { - 'attrs': ["bytes", "num_sta", "time"], - 'start': int(endtime - 86400) * 1000, - 'end': int(endtime - 3600) * 1000 - } - return self._api_write('stat/report/hourly.site', params) + "attrs": ["bytes", "num_sta", "time"], + "start": int(endtime - 86400) * 1000, + "end": int(endtime - 3600) * 1000, + } + return self._api_write("stat/report/hourly.site", params) def get_events(self): """Return a list of all Events.""" - return self._api_read('stat/event') + return self._api_read("stat/event") def get_aps(self): """Return a list of all APs, with significant information about each. """ # Set test to 0 instead of NULL - params = {'_depth': 2, 'test': 0} - return self._api_read('stat/device', params) + params = {"_depth": 2, "test": 0} + return self._api_read("stat/device", params) def get_client(self, mac): """Get details about a specific client""" @@ -223,51 +233,51 @@ def get_client(self, mac): # stat/user/ works better than stat/sta/ # stat/sta seems to be only active clients # stat/user includes known but offline clients - return self._api_read('stat/user/' + mac)[0] + return self._api_read("stat/user/" + mac)[0] def get_clients(self): """Return a list of all active clients, with significant information about each. """ - return self._api_read('stat/sta') + return self._api_read("stat/sta") def get_users(self): """Return a list of all known clients, with significant information about each. """ - return self._api_read('list/user') + return self._api_read("list/user") def get_user_groups(self): """Return a list of user groups with its rate limiting settings.""" - return self._api_read('list/usergroup') + return self._api_read("list/usergroup") def get_sysinfo(self): """Return basic system informations.""" - return self._api_read('stat/sysinfo') + return self._api_read("stat/sysinfo") def get_healthinfo(self): """Return health information.""" - return self._api_read('stat/health') + return self._api_read("stat/health") def get_sites(self): """Return a list of all sites, with their UID and description""" - return self._read(self.url + 'api/self/sites') + return self._read(self.url + "api/self/sites") def get_wlan_conf(self): """Return a list of configured WLANs with their configuration parameters. """ - return self._api_read('list/wlanconf') + return self._api_read("list/wlanconf") - def _run_command(self, command, params={}, mgr='stamgr'): - log.debug('_run_command(%s)', command) - params.update({'cmd': command}) - return self._api_write('cmd/' + mgr, params=params) + def _run_command(self, command, params={}, mgr="stamgr"): + log.debug("_run_command(%s)", command) + params.update({"cmd": command}) + return self._api_write("cmd/" + mgr, params=params) - def _mac_cmd(self, target_mac, command, mgr='stamgr', params={}): - log.debug('_mac_cmd(%s, %s)', target_mac, command) - params['mac'] = target_mac + def _mac_cmd(self, target_mac, command, mgr="stamgr", params={}): + log.debug("_mac_cmd(%s, %s)", target_mac, command) + params["mac"] = target_mac return self._run_command(command, params, mgr) def get_device_stat(self, target_mac): @@ -279,9 +289,9 @@ def get_device_stat(self, target_mac): capabilities and configuration of the device :rtype: dict() """ - log.debug('get_device_stat(%s)', target_mac) + log.debug("get_device_stat(%s)", target_mac) params = {"macs": [target_mac]} - return self._api_read('stat/device/' + target_mac, params)[0] + return self._api_read("stat/device/" + target_mac, params)[0] def get_switch_port_overrides(self, target_mac): """Gets a list of port overrides, in dictionary @@ -295,8 +305,8 @@ def get_switch_port_overrides(self, target_mac): 'poe_mode': str, 'name': str } ] :rtype: list( dict() ) """ - log.debug('get_switch_port_overrides(%s)', target_mac) - return self.get_device_stat(target_mac)['port_overrides'] + log.debug("get_switch_port_overrides(%s)", target_mac) + return self.get_device_stat(target_mac)["port_overrides"] def _switch_port_power(self, target_mac, port_idx, mode): """Helper method to set the given PoE mode the port/switch. @@ -313,35 +323,31 @@ def _switch_port_power(self, target_mac, port_idx, mode): """ # TODO: Switch operations should most likely happen in a # different Class, Switch. - log.debug('_switch_port_power(%s, %s, %s)', target_mac, port_idx, mode) + log.debug("_switch_port_power(%s, %s, %s)", target_mac, port_idx, mode) device_stat = self.get_device_stat(target_mac) - device_id = device_stat.get('_id') - overrides = device_stat.get('port_overrides') + device_id = device_stat.get("_id") + overrides = device_stat.get("port_overrides") found = False if overrides: for i in range(0, len(overrides)): - if overrides[i]['port_idx'] == port_idx: + if overrides[i]["port_idx"] == port_idx: # Override already exists, update.. - overrides[i]['poe_mode'] = mode + overrides[i]["poe_mode"] = mode found = True break if not found: # Retrieve portconf portconf_id = None - for port in device_stat['port_table']: - if port['port_idx'] == port_idx: - portconf_id = port['portconf_id'] + for port in device_stat["port_table"]: + if port["port_idx"] == port_idx: + portconf_id = port["portconf_id"] break if portconf_id is None: log.error("Port ID %s could not be found in the port_table.") - raise APIError( - 'Port ID %s not found in port_table' % str(port_idx) - ) - overrides.append({ - "port_idx": port_idx, - "portconf_id": portconf_id, - "poe_mode": mode - }) + raise APIError("Port ID %s not found in port_table" % str(port_idx)) + overrides.append( + {"port_idx": port_idx, "portconf_id": portconf_id, "poe_mode": mode} + ) # We return the device_id as it's needed by the parent method return {"port_overrides": overrides, "device_id": device_id} @@ -356,11 +362,11 @@ def switch_port_power_off(self, target_mac, port_idx): :returns: API Response which is the resulting complete port overrides :rtype: list( dict() ) """ - log.debug('switch_port_power_off(%s, %s)', target_mac, port_idx) + log.debug("switch_port_power_off(%s, %s)", target_mac, port_idx) params = self._switch_port_power(target_mac, port_idx, "off") - device_id = params['device_id'] - del params['device_id'] - return self._api_update('rest/device/' + device_id, params) + device_id = params["device_id"] + del params["device_id"] + return self._api_update("rest/device/" + device_id, params) def switch_port_power_on(self, target_mac, port_idx): """Powers On the given port on the Switch identified @@ -373,13 +379,13 @@ def switch_port_power_on(self, target_mac, port_idx): :returns: API Response which is the resulting complete port overrides :rtype: list( dict() ) """ - log.debug('switch_port_power_on(%s, %s)', target_mac, port_idx) + log.debug("switch_port_power_on(%s, %s)", target_mac, port_idx) params = self._switch_port_power(target_mac, port_idx, "auto") - device_id = params['device_id'] - del params['device_id'] - return self._api_update('rest/device/' + device_id, params) + device_id = params["device_id"] + del params["device_id"] + return self._api_update("rest/device/" + device_id, params) - def create_site(self, desc='desc'): + def create_site(self, desc="desc"): """Create a new site. :param desc: Name of the site to be created. @@ -387,22 +393,21 @@ def create_site(self, desc='desc'): # TODO: Not currently supported on UDM Pro as site support doesn't exist. - return self._run_command('add-site', params={"desc": desc}, - mgr='sitemgr') + return self._run_command("add-site", params={"desc": desc}, mgr="sitemgr") def block_client(self, mac): """Add a client to the block list. :param mac: the MAC address of the client to block. """ - return self._mac_cmd(mac, 'block-sta') + return self._mac_cmd(mac, "block-sta") def unblock_client(self, mac): """Remove a client from the block list. :param mac: the MAC address of the client to unblock. """ - return self._mac_cmd(mac, 'unblock-sta') + return self._mac_cmd(mac, "unblock-sta") def disconnect_client(self, mac): """Disconnect a client. @@ -412,14 +417,14 @@ def disconnect_client(self, mac): :param mac: the MAC address of the client to disconnect. """ - return self._mac_cmd(mac, 'kick-sta') + return self._mac_cmd(mac, "kick-sta") def restart_ap(self, mac): """Restart an access point (by MAC). :param mac: the MAC address of the AP to restart. """ - return self._mac_cmd(mac, 'restart', 'devmgr') + return self._mac_cmd(mac, "restart", "devmgr") def restart_ap_name(self, name): """Restart an access point (by name). @@ -427,17 +432,17 @@ def restart_ap_name(self, name): :param name: the name address of the AP to restart. """ if not name: - raise APIError('%s is not a valid name' % str(name)) + raise APIError("%s is not a valid name" % str(name)) for ap in self.get_aps(): - if ap.get('state', 0) == 1 and ap.get('name', None) == name: - return self.restart_ap(ap['mac']) + if ap.get("state", 0) == 1 and ap.get("name", None) == name: + return self.restart_ap(ap["mac"]) def archive_all_alerts(self): """Archive all Alerts""" - return self._run_command('archive-all-alarms', mgr='evtmgr') + return self._run_command("archive-all-alarms", mgr="evtmgr") # TODO: Not currently supported on UDM Pro as it now utilizes async-backups. - def create_backup(self, days='0'): + def create_backup(self, days="0"): """Ask controller to create a backup archive file ..warning: @@ -448,11 +453,11 @@ def create_backup(self, days='0'): '-1' backup all metrics. '0' backup only the configuration. :return: URL path to backup file """ - res = self._run_command('backup', mgr='system', params={'days': days}) - return res[0]['url'] + res = self._run_command("backup", mgr="system", params={"days": days}) + return res[0]["url"] # TODO: Not currently supported on UDM Pro as it now utilizes async-backups. - def get_backup(self, download_path=None, target_file='unifi-backup.unf'): + def get_backup(self, download_path=None, target_file="unifi-backup.unf"): """ :param download_path: path to backup; if None is given one will be created @@ -463,11 +468,18 @@ def get_backup(self, download_path=None, target_file='unifi-backup.unf'): download_path = self.create_backup() r = self.session.get(self.url + download_path, stream=True) - with open(target_file, 'wb') as _backfh: + with open(target_file, "wb") as _backfh: return shutil.copyfileobj(r.raw, _backfh) - def authorize_guest(self, guest_mac, minutes, up_bandwidth=None, - down_bandwidth=None, byte_quota=None, ap_mac=None): + def authorize_guest( + self, + guest_mac, + minutes, + up_bandwidth=None, + down_bandwidth=None, + byte_quota=None, + ap_mac=None, + ): """ Authorize a guest based on his MAC address. @@ -478,17 +490,17 @@ def authorize_guest(self, guest_mac, minutes, up_bandwidth=None, :param byte_quota: quantity of bytes allowed in MB :param ap_mac: access point MAC address """ - cmd = 'authorize-guest' - params = {'mac': guest_mac, 'minutes': minutes} + cmd = "authorize-guest" + params = {"mac": guest_mac, "minutes": minutes} if up_bandwidth: - params['up'] = up_bandwidth + params["up"] = up_bandwidth if down_bandwidth: - params['down'] = down_bandwidth + params["down"] = down_bandwidth if byte_quota: - params['bytes'] = byte_quota + params["bytes"] = byte_quota if ap_mac: - params['ap_mac'] = ap_mac + params["ap_mac"] = ap_mac return self._run_command(cmd, params=params) def unauthorize_guest(self, guest_mac): @@ -497,12 +509,11 @@ def unauthorize_guest(self, guest_mac): :param guest_mac: the guest MAC address: 'aa:bb:cc:dd:ee:ff' """ - cmd = 'unauthorize-guest' - params = {'mac': guest_mac} + cmd = "unauthorize-guest" + params = {"mac": guest_mac} return self._run_command(cmd, params=params) - def get_firmware(self, cached=True, available=True, - known=False, site=False): + def get_firmware(self, cached=True, available=True, known=False, site=False): """ Return a list of available/cached firmware versions @@ -514,14 +525,14 @@ def get_firmware(self, cached=True, available=True, """ res = [] if cached: - res.extend(self._run_command('list-cached', mgr='firmware')) + res.extend(self._run_command("list-cached", mgr="firmware")) if available: - res.extend(self._run_command('list-available', mgr='firmware')) + res.extend(self._run_command("list-available", mgr="firmware")) if known: - res = [fw for fw in res if fw['knownDevice']] + res = [fw for fw in res if fw["knownDevice"]] if site: - res = [fw for fw in res if fw['siteDevice']] + res = [fw for fw in res if fw["siteDevice"]] return res def cache_firmware(self, version, device): @@ -536,8 +547,8 @@ def cache_firmware(self, version, device): :return: True/False """ return self._run_command( - 'download', mgr='firmware', - params={'device': device, 'version': version})[0]['result'] + "download", mgr="firmware", params={"device": device, "version": version} + )[0]["result"] def remove_firmware(self, version, device): """ @@ -551,12 +562,12 @@ def remove_firmware(self, version, device): :return: True/false """ return self._run_command( - 'remove', mgr='firmware', - params={'device': device, 'version': version})[0]['result'] + "remove", mgr="firmware", params={"device": device, "version": version} + )[0]["result"] def get_tag(self): """Get all tags and their member MACs""" - return self._api_read('rest/tag') + return self._api_read("rest/tag") def upgrade_device(self, mac, version): """ @@ -564,15 +575,16 @@ def upgrade_device(self, mac, version): :param mac: MAC of dev :param version: version to upgrade to """ - self._mac_cmd(mac, 'upgrade', mgr='devmgr', - params={'upgrade_to_firmware': version}) + self._mac_cmd( + mac, "upgrade", mgr="devmgr", params={"upgrade_to_firmware": version} + ) def provision(self, mac): """ Force provisioning of a device :param mac: MAC of device """ - self._mac_cmd(mac, 'force-provision', mgr='devmgr') + self._mac_cmd(mac, "force-provision", mgr="devmgr") def get_setting(self, section=None, super=False): """ @@ -583,17 +595,19 @@ def get_setting(self, section=None, super=False): :return: {section:settings} """ res = {} - settings = self._api_read('get/setting') + settings = self._api_read("get/setting") if section and not isinstance(section, (list, tuple)): section = [section] for s in settings: - s_sect = s['key'] - if (super and 'site_id' in s) or \ - (not super and 'site_id' not in s) or \ - (section and s_sect not in section): + s_sect = s["key"] + if ( + (super and "site_id" in s) + or (not super and "site_id" not in s) + or (section and s_sect not in section) + ): continue - for k in ('_id', 'site_id', 'key'): + for k in ("_id", "site_id", "key"): s.pop(k, None) res[s_sect] = s return res @@ -607,7 +621,7 @@ def update_setting(self, settings): """ res = [] for sect, setting in settings.items(): - res.extend(self._api_write('set/setting/' + sect, setting)) + res.extend(self._api_write("set/setting/" + sect, setting)) return res def update_user_group(self, group_id, down_kbps=-1, up_kbps=-1): @@ -625,13 +639,16 @@ def update_user_group(self, group_id, down_kbps=-1, up_kbps=-1): for group in groups: if group["_id"] == group_id: # Apply setting change - res = self._api_update("rest/usergroup/{0}".format(group_id), { - "qos_rate_max_down": down_kbps, - "qos_rate_max_up": up_kbps, - "name": group["name"], - "_id": group_id, - "site_id": self.site_id - }) + res = self._api_update( + "rest/usergroup/{0}".format(group_id), + { + "qos_rate_max_down": down_kbps, + "qos_rate_max_up": up_kbps, + "name": group["name"], + "_id": group_id, + "site_id": self.site_id, + }, + ) return res raise ValueError("Group ID {0} is not valid.".format(group_id)) @@ -642,11 +659,19 @@ def set_client_alias(self, mac, alias): :param mac: The MAC of the client to rename :param alias: The alias to set """ - client = self.get_client(mac)['_id'] - return self._api_update('rest/user/' + client, {'name': alias}) + client = self.get_client(mac)["_id"] + return self._api_update("rest/user/" + client, {"name": alias}) - def create_voucher(self, number, quota, expire, up_bandwidth=None, - down_bandwidth=None, byte_quota=None, note=None): + def create_voucher( + self, + number, + quota, + expire, + up_bandwidth=None, + down_bandwidth=None, + byte_quota=None, + note=None, + ): """ Create voucher for guests. @@ -658,20 +683,25 @@ def create_voucher(self, number, quota, expire, up_bandwidth=None, :param byte_quota: quantity of bytes allowed in MB :param note: description """ - cmd = 'create-voucher' - params = {'n': number, 'quota': quota, 'expire': 'custom', - 'expire_number': expire, 'expire_unit': 1} + cmd = "create-voucher" + params = { + "n": number, + "quota": quota, + "expire": "custom", + "expire_number": expire, + "expire_unit": 1, + } if up_bandwidth: - params['up'] = up_bandwidth + params["up"] = up_bandwidth if down_bandwidth: - params['down'] = down_bandwidth + params["down"] = down_bandwidth if byte_quota: - params['bytes'] = byte_quota + params["bytes"] = byte_quota if note: - params['note'] = note - res = self._run_command(cmd, mgr='hotspot', params=params) - return self.list_vouchers(create_time=res[0]['create_time']) + params["note"] = note + res = self._run_command(cmd, mgr="hotspot", params=params) + return self.list_vouchers(create_time=res[0]["create_time"]) def list_vouchers(self, **filter): """ @@ -681,11 +711,11 @@ def list_vouchers(self, **filter): used, note, status_expires, status, ... """ - if 'code' in filter: - filter['code'] = filter['code'].replace('-', '') + if "code" in filter: + filter["code"] = filter["code"].replace("-", "") vouchers = [] - for voucher in self._api_read('stat/voucher'): + for voucher in self._api_read("stat/voucher"): voucher_match = True for key, val in filter.items(): voucher_match &= voucher.get(key) == val @@ -699,6 +729,6 @@ def delete_voucher(self, id): :param id: id of voucher """ - cmd = 'delete-voucher' - params = {'_id': id} - self._run_command(cmd, mgr='hotspot', params=params) + cmd = "delete-voucher" + params = {"_id": id} + self._run_command(cmd, mgr="hotspot", params=params) From 688dd3af188a36e42e07dc9bfbb6d4b404565ab1 Mon Sep 17 00:00:00 2001 From: ChrisMandich Date: Thu, 6 Aug 2020 10:15:25 -0700 Subject: [PATCH 26/30] Resolve pylint --- pyunifi/controller.py | 215 ++++++++++++++++++++++++------------------ 1 file changed, 121 insertions(+), 94 deletions(-) diff --git a/pyunifi/controller.py b/pyunifi/controller.py index 0e62d73..6e2ee15 100644 --- a/pyunifi/controller.py +++ b/pyunifi/controller.py @@ -1,24 +1,28 @@ -import json -import logging -import requests +""" +Python package to interact with UniFi Controller +""" import shutil import time import warnings +import json +import logging + +import requests from urllib3.exceptions import InsecureRequestWarning """For testing purposes: logging.basicConfig(filename='pyunifi.log', level=logging.WARN, format='%(asctime)s %(message)s') -""" -log = logging.getLogger(__name__) +""" # pylint: disable=W0105 +CONS_LOG = logging.getLogger(__name__) class APIError(Exception): - pass + """API Error exceptions""" -def retry_login(func, *args, **kwargs): +def retry_login(func, *args, **kwargs): # pylint: disable=w0613 """To reattempt login if requests exception(s) occur at time of call""" def wrapper(*args, **kwargs): @@ -26,9 +30,9 @@ def wrapper(*args, **kwargs): try: return func(*args, **kwargs) except (requests.exceptions.RequestException, APIError) as err: - log.warning("Failed to perform %s due to %s" % (func, err)) + CONS_LOG.warning("Failed to perform %s due to %s", func, err) controller = args[0] - controller._login() + controller._login() # pylint: disable=w0212 return func(*args, **kwargs) except Exception as err: raise APIError(err) @@ -36,7 +40,7 @@ def wrapper(*args, **kwargs): return wrapper -class Controller(object): +class Controller: # pylint: disable=R0902,R0904 """Interact with a UniFi controller. @@ -56,15 +60,15 @@ class Controller(object): """ - def __init__( - self, - host, - username, - password, - port=8443, - version="v5", - site_id="default", - ssl_verify=True, + def __init__( # pylint: disable=r0913 + self, + host, + username, + password, + port=8443, + version="v5", + site_id="default", + ssl_verify=True, ): """ :param host: the address of the controller host; IP or name @@ -115,9 +119,11 @@ def _jsondec(data): if obj["meta"]["rc"] != "ok": raise APIError(obj["meta"]["msg"]) if "data" in obj: - return obj["data"] + result = obj["data"] else: - return obj + result = obj + + return result def _api_url(self): return self.url + "api/s/" + self.site_id + "/" @@ -125,58 +131,59 @@ def _api_url(self): @retry_login def _read(self, url, params=None): # Try block to handle the unifi server being offline. - r = self.session.get(url, params=params, headers=self.headers) + response = self.session.get(url, params=params, headers=self.headers) - if r.headers.get("X-CSRF-Token"): - self.headers = {"X-CSRF-Token": r.headers["X-CSRF-Token"]} + if response.headers.get("X-CSRF-Token"): + self.headers = {"X-CSRF-Token": response.headers["X-CSRF-Token"]} - return self._jsondec(r.text) + return self._jsondec(response.text) def _api_read(self, url, params=None): return self._read(self._api_url() + url, params) @retry_login def _write(self, url, params=None): - r = self.session.post(url, json=params, headers=self.headers) + response = self.session.post(url, json=params, headers=self.headers) - if r.headers.get("X-CSRF-Token"): - self.headers = {"X-CSRF-Token": r.headers["X-CSRF-Token"]} + if response.headers.get("X-CSRF-Token"): + self.headers = {"X-CSRF-Token": response.headers["X-CSRF-Token"]} - return self._jsondec(r.text) + return self._jsondec(response.text) def _api_write(self, url, params=None): return self._write(self._api_url() + url, params) @retry_login def _update(self, url, params=None): - r = self.session.put(url, json=params, headers=self.headers) + response = self.session.put(url, json=params, headers=self.headers) - if r.headers.get("X-CSRF-Token"): - self.headers = {"X-CSRF-Token": r.headers["X-CSRF-Token"]} + if response.headers.get("X-CSRF-Token"): + self.headers = {"X-CSRF-Token": response.headers["X-CSRF-Token"]} - return self._jsondec(r.text) + return self._jsondec(response.text) def _api_update(self, url, params=None): return self._update(self._api_url() + url, params) def _login(self): - log.debug("login() as %s", self.username) + self.log.debug("login() as %s", self.username) self.session = requests.Session() self.session.verify = self.ssl_verify - # XXX Why doesn't passing in the dict work? - params = {"username": self.username, "password": self.password} - - r = self.session.post(self.auth_url, json=params, headers=self.headers) + response = self.session.post( + self.auth_url, + json={"username": self.username, "password": self.password}, + headers=self.headers, + ) - if r.headers.get("X-CSRF-Token"): - self.headers = {"X-CSRF-Token": r.headers["X-CSRF-Token"]} + if response.headers.get("X-CSRF-Token"): + self.headers = {"X-CSRF-Token": response.headers["X-CSRF-Token"]} - if r.status_code != 200: - raise APIError("Login failed - status code: %i" % r.status_code) + if response.status_code != 200: + raise APIError("Login failed - status code: %i" % response.status_code) def _logout(self): - log.debug("logout()") + self.log.debug("logout()") self._api_write("logout") self.session.close() @@ -270,13 +277,17 @@ def get_wlan_conf(self): """ return self._api_read("list/wlanconf") - def _run_command(self, command, params={}, mgr="stamgr"): - log.debug("_run_command(%s)", command) + def _run_command(self, command, params=None, mgr="stamgr"): + if params is None: + params = {} + self.log.debug("_run_command(%s)", command) params.update({"cmd": command}) return self._api_write("cmd/" + mgr, params=params) - def _mac_cmd(self, target_mac, command, mgr="stamgr", params={}): - log.debug("_mac_cmd(%s, %s)", target_mac, command) + def _mac_cmd(self, target_mac, command, mgr="stamgr", params=None): + if params is None: + params = {} + self.log.debug("_mac_cmd(%s, %s)", target_mac, command) params["mac"] = target_mac return self._run_command(command, params, mgr) @@ -289,7 +300,7 @@ def get_device_stat(self, target_mac): capabilities and configuration of the device :rtype: dict() """ - log.debug("get_device_stat(%s)", target_mac) + self.log.debug("get_device_stat(%s)", target_mac) params = {"macs": [target_mac]} return self._api_read("stat/device/" + target_mac, params)[0] @@ -305,7 +316,7 @@ def get_switch_port_overrides(self, target_mac): 'poe_mode': str, 'name': str } ] :rtype: list( dict() ) """ - log.debug("get_switch_port_overrides(%s)", target_mac) + self.log.debug("get_switch_port_overrides(%s)", target_mac) return self.get_device_stat(target_mac)["port_overrides"] def _switch_port_power(self, target_mac, port_idx, mode): @@ -323,13 +334,13 @@ def _switch_port_power(self, target_mac, port_idx, mode): """ # TODO: Switch operations should most likely happen in a # different Class, Switch. - log.debug("_switch_port_power(%s, %s, %s)", target_mac, port_idx, mode) + self.log.debug("_switch_port_power(%s, %s, %s)", target_mac, port_idx, mode) device_stat = self.get_device_stat(target_mac) device_id = device_stat.get("_id") overrides = device_stat.get("port_overrides") found = False if overrides: - for i in range(0, len(overrides)): + for i in overrides: if overrides[i]["port_idx"] == port_idx: # Override already exists, update.. overrides[i]["poe_mode"] = mode @@ -343,7 +354,7 @@ def _switch_port_power(self, target_mac, port_idx, mode): portconf_id = port["portconf_id"] break if portconf_id is None: - log.error("Port ID %s could not be found in the port_table.") + self.log.error("Port ID %s could not be found in the port_table.") raise APIError("Port ID %s not found in port_table" % str(port_idx)) overrides.append( {"port_idx": port_idx, "portconf_id": portconf_id, "poe_mode": mode} @@ -362,7 +373,7 @@ def switch_port_power_off(self, target_mac, port_idx): :returns: API Response which is the resulting complete port overrides :rtype: list( dict() ) """ - log.debug("switch_port_power_off(%s, %s)", target_mac, port_idx) + self.log.debug("switch_port_power_off(%s, %s)", target_mac, port_idx) params = self._switch_port_power(target_mac, port_idx, "off") device_id = params["device_id"] del params["device_id"] @@ -379,7 +390,7 @@ def switch_port_power_on(self, target_mac, port_idx): :returns: API Response which is the resulting complete port overrides :rtype: list( dict() ) """ - log.debug("switch_port_power_on(%s, %s)", target_mac, port_idx) + self.log.debug("switch_port_power_on(%s, %s)", target_mac, port_idx) params = self._switch_port_power(target_mac, port_idx, "auto") device_id = params["device_id"] del params["device_id"] @@ -392,6 +403,8 @@ def create_site(self, desc="desc"): """ # TODO: Not currently supported on UDM Pro as site support doesn't exist. + if self.version == "UDMP-unifiOS": + raise APIError("Controller version not supported: %s" % self.version) return self._run_command("add-site", params={"desc": desc}, mgr="sitemgr") @@ -433,9 +446,13 @@ def restart_ap_name(self, name): """ if not name: raise APIError("%s is not a valid name" % str(name)) - for ap in self.get_aps(): - if ap.get("state", 0) == 1 and ap.get("name", None) == name: - return self.restart_ap(ap["mac"]) + for access_point in self.get_aps(): + if ( + access_point.get("state", 0) == 1 + and access_point.get("name", None) == name + ): + result = self.restart_ap(access_point["mac"]) + return result def archive_all_alerts(self): """Archive all Alerts""" @@ -453,6 +470,9 @@ def create_backup(self, days="0"): '-1' backup all metrics. '0' backup only the configuration. :return: URL path to backup file """ + if self.version == "UDMP-unifiOS": + raise APIError("Controller version not supported: %s" % self.version) + res = self._run_command("backup", mgr="system", params={"days": days}) return res[0]["url"] @@ -464,21 +484,28 @@ def get_backup(self, download_path=None, target_file="unifi-backup.unf"): :param target_file: Filename or full path to download the backup archive to, should have .unf extension for restore. """ + if self.version == "UDMP-unifiOS": + raise APIError("Controller version not supported: %s" % self.version) + if not download_path: download_path = self.create_backup() - r = self.session.get(self.url + download_path, stream=True) + response = self.session.get(self.url + download_path, stream=True) + + if response != 200: + raise APIError("API backup failed: %i" % response.status_code) + with open(target_file, "wb") as _backfh: - return shutil.copyfileobj(r.raw, _backfh) - - def authorize_guest( - self, - guest_mac, - minutes, - up_bandwidth=None, - down_bandwidth=None, - byte_quota=None, - ap_mac=None, + return shutil.copyfileobj(response.raw, _backfh) + + def authorize_guest( # pylint: disable=R0913 + self, + guest_mac, + minutes, + up_bandwidth=None, + down_bandwidth=None, + byte_quota=None, + ap_mac=None, ): """ Authorize a guest based on his MAC address. @@ -586,30 +613,30 @@ def provision(self, mac): """ self._mac_cmd(mac, "force-provision", mgr="devmgr") - def get_setting(self, section=None, super=False): + def get_setting(self, section=None, cs_settings=False): """ Return settings for this site or controller - :param super: Return only controller-wide settings + :param cs_settings: Return only controller-wide settings :param section: Only return this/these section(s) :return: {section:settings} """ res = {} - settings = self._api_read("get/setting") + all_settings = self._api_read("get/setting") if section and not isinstance(section, (list, tuple)): section = [section] - for s in settings: - s_sect = s["key"] + for setting in all_settings: + s_sect = setting["key"] if ( - (super and "site_id" in s) - or (not super and "site_id" not in s) - or (section and s_sect not in section) + (cs_settings and "site_id" in setting) # pylint: disable=R0916 + or (not cs_settings and "site_id" not in setting) + or (section and s_sect not in section) ): continue for k in ("_id", "site_id", "key"): - s.pop(k, None) - res[s_sect] = s + setting.pop(k, None) + res[s_sect] = setting return res def update_setting(self, settings): @@ -662,15 +689,15 @@ def set_client_alias(self, mac, alias): client = self.get_client(mac)["_id"] return self._api_update("rest/user/" + client, {"name": alias}) - def create_voucher( - self, - number, - quota, - expire, - up_bandwidth=None, - down_bandwidth=None, - byte_quota=None, - note=None, + def create_voucher( # pylint: disable=R0913 + self, + number, + quota, + expire, + up_bandwidth=None, + down_bandwidth=None, + byte_quota=None, + note=None, ): """ Create voucher for guests. @@ -703,32 +730,32 @@ def create_voucher( res = self._run_command(cmd, mgr="hotspot", params=params) return self.list_vouchers(create_time=res[0]["create_time"]) - def list_vouchers(self, **filter): + def list_vouchers(self, **filter_voucher): """ Get list of vouchers - :param filter: Filter vouchers by create_time, code, quota, + :param filter_voucher: Filter vouchers by create_time, code, quota, used, note, status_expires, status, ... """ - if "code" in filter: - filter["code"] = filter["code"].replace("-", "") + if "code" in filter_voucher: + filter_voucher["code"] = filter_voucher["code"].replace("-", "") vouchers = [] for voucher in self._api_read("stat/voucher"): voucher_match = True - for key, val in filter.items(): + for key, val in filter_voucher.items(): voucher_match &= voucher.get(key) == val if voucher_match: vouchers.append(voucher) return vouchers - def delete_voucher(self, id): + def delete_voucher(self, voucher_id): """ Delete / revoke voucher :param id: id of voucher """ cmd = "delete-voucher" - params = {"_id": id} + params = {"_id": voucher_id} self._run_command(cmd, mgr="hotspot", params=params) From f9666ca43b60cb3b5c23acfafcd1c762ca91deb3 Mon Sep 17 00:00:00 2001 From: ChrisMandich Date: Thu, 6 Aug 2020 10:21:19 -0700 Subject: [PATCH 27/30] Resolve errors in __init__ --- pyunifi/__init__.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pyunifi/__init__.py b/pyunifi/__init__.py index f026d48..23557e5 100644 --- a/pyunifi/__init__.py +++ b/pyunifi/__init__.py @@ -1,5 +1,9 @@ +""" +Python __init__ to interact with UniFi Controller +""" +import urllib3 + def http_debug_log_stderr(): """Dump requests urllib3 debug messages to stderr""" - import requests - requests.packages.urllib3.add_stderr_logger() + urllib3.add_stderr_logger() From 83996bbf336d5071dfa7cf70c296bf21a198381c Mon Sep 17 00:00:00 2001 From: ChrisMandich Date: Thu, 6 Aug 2020 10:27:10 -0700 Subject: [PATCH 28/30] Update changelog and readme with UDMP/CSRF info --- CHANGELOG.md | 6 ++++++ README.md | 2 +- pyunifi/controller.py | 5 +++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 622a0fa..c7fceff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +### Added +- Support for UDMP + +### Fixed +- Support for CSRF + ## [2.20.1] - 2020-03-30 ### Fixed - Lint failures in controller.py diff --git a/README.md b/README.md index ab6f58a..5d1cb88 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Create a Controller object. - `username` -- the username to log in with - `password` -- the password to log in with - `port` -- the port of the controller host - - `version` -- the base version of the controller API [v4|v5|unifiOS] + - `version` -- the base version of the controller API [v4|v5|unifiOS|UDMP-unifiOS] - `site_id` -- the site ID to access - `ssl_verify` -- Verify the controllers SSL certificate, default=True, can also be False or "path/to/custom_cert.pem" diff --git a/pyunifi/controller.py b/pyunifi/controller.py index 6e2ee15..a2897b0 100644 --- a/pyunifi/controller.py +++ b/pyunifi/controller.py @@ -194,6 +194,11 @@ def switch_site(self, name): :param name: Site Name :return: True or APIError """ + + # TODO: Not currently supported on UDM Pro as site support doesn't exist. + if self.version == "UDMP-unifiOS": + raise APIError("Controller version not supported: %s" % self.version) + for site in self.get_sites(): if site["desc"] == name: self.site_id = site["name"] From 7a63924ff73a7b37370af7c49de7acc37c04a3ec Mon Sep 17 00:00:00 2001 From: ChrisMandich Date: Thu, 6 Aug 2020 13:44:19 -0700 Subject: [PATCH 29/30] include flake --- pyunifi/controller.py | 94 +++++++++++++++++++++++++++++++++---------- 1 file changed, 73 insertions(+), 21 deletions(-) diff --git a/pyunifi/controller.py b/pyunifi/controller.py index a2897b0..b96d9b8 100644 --- a/pyunifi/controller.py +++ b/pyunifi/controller.py @@ -180,7 +180,9 @@ def _login(self): self.headers = {"X-CSRF-Token": response.headers["X-CSRF-Token"]} if response.status_code != 200: - raise APIError("Login failed - status code: %i" % response.status_code) + raise APIError( + "Login failed - status code: %i" % response.status_code + ) def _logout(self): self.log.debug("logout()") @@ -195,9 +197,11 @@ def switch_site(self, name): :return: True or APIError """ - # TODO: Not currently supported on UDM Pro as site support doesn't exist. + # TODO: Not currently supported on UDMP as site support doesn't exist. if self.version == "UDMP-unifiOS": - raise APIError("Controller version not supported: %s" % self.version) + raise APIError( + "Controller version not supported: %s" % self.version + ) for site in self.get_sites(): if site["desc"] == name: @@ -339,7 +343,9 @@ def _switch_port_power(self, target_mac, port_idx, mode): """ # TODO: Switch operations should most likely happen in a # different Class, Switch. - self.log.debug("_switch_port_power(%s, %s, %s)", target_mac, port_idx, mode) + self.log.debug( + "_switch_port_power(%s, %s, %s)", target_mac, port_idx, mode + ) device_stat = self.get_device_stat(target_mac) device_id = device_stat.get("_id") overrides = device_stat.get("port_overrides") @@ -359,10 +365,15 @@ def _switch_port_power(self, target_mac, port_idx, mode): portconf_id = port["portconf_id"] break if portconf_id is None: - self.log.error("Port ID %s could not be found in the port_table.") - raise APIError("Port ID %s not found in port_table" % str(port_idx)) + raise APIError( + "Port ID %s not found in port_table" % str(port_idx) + ) overrides.append( - {"port_idx": port_idx, "portconf_id": portconf_id, "poe_mode": mode} + { + "port_idx": port_idx, + "portconf_id": portconf_id, + "poe_mode": mode + } ) # We return the device_id as it's needed by the parent method return {"port_overrides": overrides, "device_id": device_id} @@ -407,11 +418,17 @@ def create_site(self, desc="desc"): :param desc: Name of the site to be created. """ - # TODO: Not currently supported on UDM Pro as site support doesn't exist. + # TODO: Not currently supported on UDMP as site support doesn't exist. if self.version == "UDMP-unifiOS": - raise APIError("Controller version not supported: %s" % self.version) + raise APIError( + "Controller version not supported: %s" % self.version + ) - return self._run_command("add-site", params={"desc": desc}, mgr="sitemgr") + return self._run_command( + "add-site", + params={"desc": desc}, + mgr="sitemgr" + ) def block_client(self, mac): """Add a client to the block list. @@ -463,7 +480,7 @@ def archive_all_alerts(self): """Archive all Alerts""" return self._run_command("archive-all-alarms", mgr="evtmgr") - # TODO: Not currently supported on UDM Pro as it now utilizes async-backups. + # TODO: Not currently supported on UDMP as it now utilizes async-backups. def create_backup(self, days="0"): """Ask controller to create a backup archive file @@ -476,12 +493,18 @@ def create_backup(self, days="0"): :return: URL path to backup file """ if self.version == "UDMP-unifiOS": - raise APIError("Controller version not supported: %s" % self.version) + raise APIError( + "Controller version not supported: %s" % self.version + ) - res = self._run_command("backup", mgr="system", params={"days": days}) + res = self._run_command( + "backup", + mgr="system", + params={"days": days} + ) return res[0]["url"] - # TODO: Not currently supported on UDM Pro as it now utilizes async-backups. + # TODO: Not currently supported on UDMP as it now utilizes async-backups. def get_backup(self, download_path=None, target_file="unifi-backup.unf"): """ :param download_path: path to backup; if None is given @@ -490,7 +513,9 @@ def get_backup(self, download_path=None, target_file="unifi-backup.unf"): backup archive to, should have .unf extension for restore. """ if self.version == "UDMP-unifiOS": - raise APIError("Controller version not supported: %s" % self.version) + raise APIError( + "Controller version not supported: %s" % self.version + ) if not download_path: download_path = self.create_backup() @@ -543,9 +568,19 @@ def unauthorize_guest(self, guest_mac): """ cmd = "unauthorize-guest" params = {"mac": guest_mac} - return self._run_command(cmd, params=params) + return self._run_command( + cmd, + params=params + ) + + def get_firmware( + self, + cached=True, + available=True, + known=False, + site=False + ): - def get_firmware(self, cached=True, available=True, known=False, site=False): """ Return a list of available/cached firmware versions @@ -579,7 +614,12 @@ def cache_firmware(self, version, device): :return: True/False """ return self._run_command( - "download", mgr="firmware", params={"device": device, "version": version} + "download", + mgr="firmware", + params={ + "device": device, + "version": version + } )[0]["result"] def remove_firmware(self, version, device): @@ -594,7 +634,12 @@ def remove_firmware(self, version, device): :return: True/false """ return self._run_command( - "remove", mgr="firmware", params={"device": device, "version": version} + "remove", + mgr="firmware", + params={ + "device": device, + "version": version + } )[0]["result"] def get_tag(self): @@ -608,7 +653,12 @@ def upgrade_device(self, mac, version): :param version: version to upgrade to """ self._mac_cmd( - mac, "upgrade", mgr="devmgr", params={"upgrade_to_firmware": version} + mac, + "upgrade", + mgr="devmgr", + params={ + "upgrade_to_firmware": version + } ) def provision(self, mac): @@ -634,7 +684,9 @@ def get_setting(self, section=None, cs_settings=False): for setting in all_settings: s_sect = setting["key"] if ( - (cs_settings and "site_id" in setting) # pylint: disable=R0916 + ( # pylint: disable=R0916 + cs_settings and "site_id" in setting + ) or (not cs_settings and "site_id" not in setting) or (section and s_sect not in section) ): From a509a535ceb16d6abe2570727bae03496b636ea2 Mon Sep 17 00:00:00 2001 From: ChrisMandich Date: Thu, 6 Aug 2020 14:06:28 -0700 Subject: [PATCH 30/30] Merge @mtnocean radius functions and utilities https://github.com/finish06/pyunifi/issues/52 --- pyunifi/controller.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/pyunifi/controller.py b/pyunifi/controller.py index 1523a2e..a56bbf2 100644 --- a/pyunifi/controller.py +++ b/pyunifi/controller.py @@ -167,8 +167,12 @@ def _api_update(self, url, params=None): @retry_login def _delete(self, url, params=None): - r = self.session.delete(url, json=params) - return self._jsondec(r.text) + response = self.session.delete(url, json=params, headers=self.headers) + + if response.headers.get("X-CSRF-Token"): + self.headers = {"X-CSRF-Token": response.headers["X-CSRF-Token"]} + + return self._jsondec(response.text) def _api_delete(self, url, params=None): return self._delete(self._api_url() + url, params) @@ -336,7 +340,7 @@ def add_radius_user(self, name, password): params = {'name': name, 'x_password': password} return self._api_write('rest/account/', params) - def update_radius_user(self, name, password, id): + def update_radius_user(self, name, password, user_id): """Update a user to this new username and password :param name: user's new username :param password: user's new password @@ -344,15 +348,15 @@ def update_radius_user(self, name, password, id): :returns: user's name, password, 24 digit user id, and 24 digit site id :returns: [] if no change was made """ - params = {'name': name, '_id': id, 'x_password': password} - return self._api_update('rest/account/' + id, params) + params = {'name': name, '_id': user_id, 'x_password': password} + return self._api_update('rest/account/' + user_id, params) - def delete_radius_user(self, id): + def delete_radius_user(self, user_id): """Delete user :param id: the user's 24 digit user id, from get_radius_users() or add_radius_user() :returns: [] if successful """ - return self._api_delete('rest/account/' + id) + return self._api_delete('rest/account/' + user_id) def get_switch_port_overrides(self, target_mac): """Gets a list of port overrides, in dictionary