diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..41e3548 --- /dev/null +++ b/.flake8 @@ -0,0 +1,4 @@ +[flake8] +max-complexity = 18 + +ignore = E203, E266, E501, W503, E722 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..60eafe3 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,39 @@ +name: Lint & Test + +on: + - push + - pull_request + +jobs: + test: + + runs-on: ubuntu-16.04 + + steps: + - uses: actions/checkout@v2 + - name: Install dependencies + run: | + sudo apt-get update && DEBIAN_FRONTEND=noninteractive sudo apt-get install --yes --quiet pdns-server pdns-backend-sqlite3 python3 python3-pip + pip3 install setuptools + pip3 install . -r requirements-dev.txt + - name: Test with pytest + run: | + python3 -m pytest -v + lint: + + runs-on: ubuntu-18.04 + + steps: + - uses: actions/checkout@v2 + - name: Install linters + run: | + pip3 install setuptools flake8 black isort + - name: Lint with flake8 + run: | + python3 -m flake8 . + - name: Lint with isort + run: | + python3 -m isort --check-only -rc . + - name: Lint with black + run: | + python3 -m black --check --diff . diff --git a/.gitignore b/.gitignore index 3481c3c..691d8cc 100644 --- a/.gitignore +++ b/.gitignore @@ -101,3 +101,5 @@ ENV/ # mypy .mypy_cache/ + +.vscode/ diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 0000000..c779f72 --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,10 @@ + +[settings] +multi_line_output=3 +include_trailing_comma=true +force_grid_wrap=0 +use_parentheses=true +line_length=88 +default_section=THIRDPARTY +known_first_party=powerdns_auth_proxy +no_lines_before=LOCALFOLDER diff --git a/powerdns_auth_proxy/__init__.py b/powerdns_auth_proxy/__init__.py index 669a045..b469ab7 100644 --- a/powerdns_auth_proxy/__init__.py +++ b/powerdns_auth_proxy/__init__.py @@ -22,29 +22,30 @@ Michael Fincham """ +import configparser + from flask import Flask -import configparser def split_config_values(config, section_pattern): """ This turns: - + [user:foo] key=bar baz=qux thud - + In to: - + {'foo': {'key': 'bar', 'baz': ['qux', 'thud']}} """ return { - section[len(section_pattern):] : { - key.lower(): (value.split() if " " in value else value) + section[len(section_pattern) :]: { + key.lower(): (value.split() if " " in value else value) for key, value in config.items(section) - } - for section in config.sections() + } + for section in config.sections() if section.startswith(section_pattern) } @@ -59,15 +60,14 @@ def create_app(configuration=None): else: config.read("proxy.ini") - users = split_config_values(config, 'user:') - pdns = split_config_values(config, 'pdns')[''] + users = split_config_values(config, "user:") + pdns = split_config_values(config, "pdns")[""] app.config.from_mapping( - PDNS=pdns, - USERS=users, + PDNS=pdns, USERS=users, ) - + from . import proxy + app.register_blueprint(proxy.bp) return app - diff --git a/powerdns_auth_proxy/proxy.py b/powerdns_auth_proxy/proxy.py index afa0208..0c10e63 100644 --- a/powerdns_auth_proxy/proxy.py +++ b/powerdns_auth_proxy/proxy.py @@ -1,125 +1,150 @@ -from flask import Blueprint, current_app, Response, g, request, stream_with_context - -from werkzeug.exceptions import Forbidden, BadRequest, NotFound +import hmac +import json +from functools import wraps +from flask import Blueprint, Response, current_app, g, request from requests import Request, Session from requests.structures import CaseInsensitiveDict +from werkzeug.exceptions import Forbidden, NotFound -from functools import wraps -import configparser -import hmac -import json - -bp = Blueprint('proxy', __name__, url_prefix='/api') +bp = Blueprint("proxy", __name__, url_prefix="/api") servers = [ { - 'zones_url': '/api/v1/servers/localhost/zones{/zone}', - 'config_url': '/api/v1/servers/localhost/config{/config_setting}', - 'url': '/api/v1/servers/localhost', - 'daemon_type': 'authoritative', - 'version': 'PowerDNS auth proxy', - 'type': 'Server', - 'id': 'localhost' + "zones_url": "/api/v1/servers/localhost/zones{/zone}", + "config_url": "/api/v1/servers/localhost/config{/config_setting}", + "url": "/api/v1/servers/localhost", + "daemon_type": "authoritative", + "version": "PowerDNS auth proxy", + "type": "Server", + "id": "localhost", } ] ## Decorators for views + def json_request(f): """ If the request contains valid JSON then store that in "g" to be used later. For compatbility with various things (like traefik), don't require the JSON content type. """ + @wraps(f) def decorated_function(*args, **kwargs): g.json = CaseInsensitiveDict(request.get_json(silent=True, force=True)) if g.json is None: g.json = CaseInsensitiveDict() return f(*args, **kwargs) + return decorated_function + def json_response(f): """ JSON serialize the object returned from the view and send it to the browser. Detects if the view returns a requests response object and copies its status accordingly. """ + @wraps(f) def decorated_function(*args, **kwargs): response = f(*args, **kwargs) - if isinstance(response, Response): # pre-prepared responses get passed on whole + if isinstance(response, Response): # pre-prepared responses get passed on whole return response - if hasattr(response, 'json'): # this is a proxied response from the backend + if hasattr(response, "json"): # this is a proxied response from the backend status_code = response.status_code response = json.dumps(json_or_none(response)) - else: # or just a regular object to serialise + else: # or just a regular object to serialise status_code = 200 response = json.dumps(response) - return Response(response, status=status_code, content_type='application/json') + return Response(response, status=status_code, content_type="application/json") + return decorated_function + def authenticate(f): """ Authenticate all requests for this view. """ + @wraps(f) def decorated_function(*args, **kwargs): auth = request.authorization - - authentication_method = '' - if 'X-API-Key' in request.headers: + authentication_method = "" + + if "X-API-Key" in request.headers: try: - username, password = request.headers['X-API-Key'].split(':', 1) - authentication_method = 'key' + username, password = request.headers["X-API-Key"].split(":", 1) + authentication_method = "key" except: return Response( - 'Access denied', 401, - {'WWW-Authenticate': 'Basic realm="PowerDNS API"'} + "Access denied", + 401, + {"WWW-Authenticate": 'Basic realm="PowerDNS API"'}, ) elif auth: username = auth.username password = auth.password - authentication_method = 'basic' - - if authentication_method not in ('key', 'basic') or username not in current_app.config['USERS'] or not hmac.compare_digest(current_app.config['USERS'][username]['key'], password): + authentication_method = "basic" + + if ( + authentication_method not in ("key", "basic") + or username not in current_app.config["USERS"] + or not hmac.compare_digest( + current_app.config["USERS"][username]["key"], password + ) + ): return Response( - 'Access denied', 401, - {'WWW-Authenticate': 'Basic realm="PowerDNS API"'} + "Access denied", 401, {"WWW-Authenticate": 'Basic realm="PowerDNS API"'} ) - g.user = current_app.config['USERS'][username] + g.user = current_app.config["USERS"][username] g.username = username return f(*args, **kwargs) + return decorated_function + ## Proxy helper methods + def sanitise_metadata_updates(json, config): """ Ensure that the given json contains only keys that the user is allowed to update. """ # override any keys specified in the configuration - for key, value in {key[9:]:value for key, value in config.items() if key.lower().startswith('override-')}.items(): + for key, value in { + key[9:]: value + for key, value in config.items() + if key.lower().startswith("override-") + }.items(): json[key] = value - + # always override the account name with the right one for the logged in user - json['account'] = g.username - + json["account"] = g.username + return json + def proxy_to_backend(method, path, form=None): """ Dispatch a particular request to the PowerDNS API. """ s = Session() - req = Request(method, "%s/%s" % (current_app.config['PDNS'].get('api-url', 'http://localhost:8081'), path), data=form) + req = Request( + method, + "%s/%s" + % (current_app.config["PDNS"].get("api-url", "http://localhost:8081"), path), + data=form, + ) req = req.prepare() - req.headers['X-API-Key'] = current_app.config['PDNS'].get('api-key', '') - req.headers['Content-Type'] = 'application/json' + req.headers["X-API-Key"] = current_app.config["PDNS"].get("api-key", "") + req.headers["Content-Type"] = "application/json" return s.send(req) + def json_or_none(response): """ If possible, decode the JSON in a requests response object. Otherwise return None. @@ -129,9 +154,11 @@ def json_or_none(response): except: return None + ## Proxy views -@bp.route('/', methods=['GET']) + +@bp.route("/", methods=["GET"]) @json_response def api(): """ @@ -141,11 +168,12 @@ def api(): { "url": "/api/v1", "version": 1, - "compatibility": "PowerDNS auth proxy, PowerDNS API v1" + "compatibility": "PowerDNS auth proxy, PowerDNS API v1", } ] -@bp.route('/v1/servers', methods=['GET']) + +@bp.route("/v1/servers", methods=["GET"]) @authenticate @json_response def server_list(): @@ -154,7 +182,8 @@ def server_list(): """ return servers -@bp.route('/v1/servers/', methods=['GET']) + +@bp.route("/v1/servers/", methods=["GET"]) @authenticate @json_response def server_object(server_id): @@ -162,11 +191,12 @@ def server_object(server_id): GET: Retrieve a specific server. """ try: - return next(server for server in servers if server['id'] == server_id) + return next(server for server in servers if server["id"] == server_id) except StopIteration: raise NotFound -@bp.route('/v1/servers/localhost/config', methods=['GET']) + +@bp.route("/v1/servers/localhost/config", methods=["GET"]) @authenticate @json_response def configuration(): @@ -175,7 +205,8 @@ def configuration(): """ return [] -@bp.route('/v1/servers/localhost/statistics', methods=['GET']) + +@bp.route("/v1/servers/localhost/statistics", methods=["GET"]) @authenticate @json_response def statistics(): @@ -184,7 +215,8 @@ def statistics(): """ return [] -@bp.route('/v1/servers/localhost/zones', methods=['GET', 'POST']) + +@bp.route("/v1/servers/localhost/zones", methods=["GET", "POST"]) @authenticate @json_request @json_response @@ -193,29 +225,46 @@ def zone_list(): GET: Retrieve a list of zones that exist and belong to this account. POST: Create a new zone for this account. """ - if request.method == 'GET': + if request.method == "GET": try: - zones = [zone for zone in json_or_none(proxy_to_backend('GET', 'zones')) if zone['account'] == g.username] + zones = [ + zone + for zone in json_or_none(proxy_to_backend("GET", "zones")) + if zone["account"] == g.username + ] except TypeError: zones = [] return zones - elif request.method == 'POST': - requested_name = g.json.get('name', None) - if 'allow-suffix-creation' in g.user: - allowed_suffixes = g.user['allow-suffix-creation'] if isinstance(g.user['allow-suffix-creation'], list) else [g.user['allow-suffix-creation']] + elif request.method == "POST": + requested_name = g.json.get("name", None) + if "allow-suffix-creation" in g.user: + allowed_suffixes = ( + g.user["allow-suffix-creation"] + if isinstance(g.user["allow-suffix-creation"], list) + else [g.user["allow-suffix-creation"]] + ) allowed = False for suffix in allowed_suffixes: - if suffix.startswith('.') and requested_name.lower().endswith(suffix.lower()): + if suffix.startswith(".") and requested_name.lower().endswith( + suffix.lower() + ): allowed = True - elif not suffix.startswith('.') and requested_name.lower() == suffix.lower(): + elif ( + not suffix.startswith(".") + and requested_name.lower() == suffix.lower() + ): allowed = True - if allowed != True: + if allowed is not True: raise Forbidden - - g.json = sanitise_metadata_updates(g.json, current_app.config['PDNS']) - return proxy_to_backend('POST', 'zones', json.dumps(dict(g.json))) -@bp.route('/v1/servers/localhost/zones/', methods=['GET', 'PUT', 'PATCH', 'DELETE']) + g.json = sanitise_metadata_updates(g.json, current_app.config["PDNS"]) + return proxy_to_backend("POST", "zones", json.dumps(dict(g.json))) + + +@bp.route( + "/v1/servers/localhost/zones/", + methods=["GET", "PUT", "PATCH", "DELETE"], +) @authenticate @json_request @json_response @@ -226,29 +275,36 @@ def zone_detail(requested_zone): PATCH: Update the RRsets for a zone. DELETE: Delete a zone immediately. """ - zone = json_or_none(proxy_to_backend('GET', 'zones/%s' % requested_zone)) - if zone and zone.get('account', None) != g.username: + zone = json_or_none(proxy_to_backend("GET", "zones/%s" % requested_zone)) + if zone and zone.get("account", None) != g.username: raise Forbidden - if request.method == 'GET': # get metadata + if request.method == "GET": # get metadata return zone - elif request.method == 'PATCH': # update rrsets - return proxy_to_backend('PATCH', 'zones/%s' % requested_zone, json.dumps(dict(g.json))) - elif request.method == 'PUT': # update metadata - g.json = sanitise_metadata_updates(g.json, current_app.config['PDNS']) - return proxy_to_backend('PUT', 'zones/%s' % requested_zone, json.dumps(dict(g.json))) - elif request.method == 'DELETE': # delete zone - return proxy_to_backend('DELETE', 'zones/%s' % requested_zone, json.dumps(dict(g.json))) - -@bp.route('/v1/servers/localhost/zones//notify', methods=['PUT']) + elif request.method == "PATCH": # update rrsets + return proxy_to_backend( + "PATCH", "zones/%s" % requested_zone, json.dumps(dict(g.json)) + ) + elif request.method == "PUT": # update metadata + g.json = sanitise_metadata_updates(g.json, current_app.config["PDNS"]) + return proxy_to_backend( + "PUT", "zones/%s" % requested_zone, json.dumps(dict(g.json)) + ) + elif request.method == "DELETE": # delete zone + return proxy_to_backend( + "DELETE", "zones/%s" % requested_zone, json.dumps(dict(g.json)) + ) + + +@bp.route("/v1/servers/localhost/zones//notify", methods=["PUT"]) @authenticate @json_response def zone_notify(requested_zone): """ PUT: Queue a zone for notification to replicas. """ - zone = json_or_none(proxy_to_backend('GET', 'zones/%s' % requested_zone)) - if zone and zone.get('account', None) != g.username: + zone = json_or_none(proxy_to_backend("GET", "zones/%s" % requested_zone)) + if zone and zone.get("account", None) != g.username: raise Forbidden - return proxy_to_backend('PUT', 'zones/%s/notify' % requested_zone, None) + return proxy_to_backend("PUT", "zones/%s/notify" % requested_zone, None) diff --git a/powerdns_auth_proxy/tests/test_proxy.py b/powerdns_auth_proxy/tests/test_proxy.py index 1c51357..2ecfb64 100644 --- a/powerdns_auth_proxy/tests/test_proxy.py +++ b/powerdns_auth_proxy/tests/test_proxy.py @@ -1,35 +1,35 @@ -from contextlib import closing import base64 -import json import os import os.path +import sqlite3 import subprocess import tempfile import time - -import sqlite3 +from contextlib import closing import pytest - import requests from powerdns_auth_proxy import create_app + def api_key_header(client): """ Return a valid header for the X-API-Key authentication method. """ - user, user_data = sorted(list(client.application.config['USERS'].items()))[-1] - return {'X-API-Key': "%s:%s" % (user, user_data['key'])} + user, user_data = sorted(list(client.application.config["USERS"].items()))[-1] + return {"X-API-Key": "%s:%s" % (user, user_data["key"])} + def basic_auth_header(client): """ Return a valid header for the Basic authentication method. """ - user, user_data = sorted(list(client.application.config['USERS'].items()))[-1] - key = user_data['key'] - encoded = base64.b64encode(("%s:%s" % (user, key)).encode('ascii')).decode('ascii') - return {'Authorization': "Basic %s" % (encoded, )} + user, user_data = sorted(list(client.application.config["USERS"].items()))[-1] + key = user_data["key"] + encoded = base64.b64encode(("%s:%s" % (user, key)).encode("ascii")).decode("ascii") + return {"Authorization": "Basic %s" % (encoded,)} + @pytest.fixture def client(): @@ -44,7 +44,7 @@ def client(): [user:demo-example-org] key = dd70d1b0eccd79a0cf5d79ddf6672dce allow-suffix-creation = example.org. .example.test. - + [user:demo-example-net] key = a70f4f5fe78ea2e89b53c8b3ee133fdf allow-suffix-creation = example.net. @@ -64,29 +64,34 @@ def client(): "--gsqlite3-database=%s" % pdns_db_path, "--default-soa-name=ns1.example.org", "--default-soa-mail=dns.example.org", + "--webserver=yes", "--webserver-port=18081", "--api=yes", "--api-key=7128ae9eb680a14390ee22a988a9d01a", ] app = create_app(test_config) - app.config['TESTING'] = True - + app.config["TESTING"] = True ALL_SCHEMA_PATHS = [ - '/usr/share/doc/pdns-backend-sqlite3/schema.sqlite3.sql', - '/usr/share/doc/powerdns/schema.sqlite3.sql', + "/usr/share/doc/pdns-backend-sqlite3/schema.sqlite3.sql", + "/usr/share/doc/powerdns/schema.sqlite3.sql", ] schema_paths = list(filter(os.path.exists, ALL_SCHEMA_PATHS)) if not schema_paths: - raise Exception('Unsupported OS. Cannot find example sqlite schema. Looked in: ' + ':'.join(ALL_SCHEMA_PATHS)) + raise Exception( + "Unsupported OS. Cannot find example sqlite schema. Looked in: " + + ":".join(ALL_SCHEMA_PATHS) + ) # create an empty database from the supplied schema with closing(sqlite3.connect(pdns_db_path)) as db: - with app.open_resource(schema_paths[0], mode='r') as f: + with app.open_resource(schema_paths[0], mode="r") as f: db.cursor().executescript(f.read()) - db.execute("INSERT INTO domains (name, type, account) VALUES ('example.net', 'MASTER', 'nobody');") # create a domain that the demo user can't read later + db.execute( + "INSERT INTO domains (name, type, account) VALUES ('example.net', 'MASTER', 'nobody');" + ) # create a domain that the demo user can't read later db.commit() pdns = subprocess.Popen(pdns_config) @@ -94,7 +99,14 @@ def client(): # wait for powerdns to come up, in a really ugly way for m in range(1, 60): try: - if requests.get("http://127.0.0.1:18081/api", timeout=1, headers={'X-API-Key': '7128ae9eb680a14390ee22a988a9d01a'}).status_code == 200: + if ( + requests.get( + "http://127.0.0.1:18081/api", + timeout=1, + headers={"X-API-Key": "7128ae9eb680a14390ee22a988a9d01a"}, + ).status_code + == 200 + ): break except: pass @@ -107,18 +119,20 @@ def client(): pdns_empty_dir.cleanup() os.unlink(pdns_db_path) + def test_api_root(client): - json = client.get('/api/').get_json()[0] - assert 'version' in json - assert 'url' in json - assert 'compatibility' in json + json = client.get("/api/").get_json()[0] + assert "version" in json + assert "url" in json + assert "compatibility" in json + def test_api_auth(client): routes_requiring_auth = [ - '/api/v1/servers', - '/api/v1/servers/localhost/config', - '/api/v1/servers/localhost/statistics', - '/api/v1/servers/localhost/zones', + "/api/v1/servers", + "/api/v1/servers/localhost/config", + "/api/v1/servers/localhost/statistics", + "/api/v1/servers/localhost/zones", ] for route in routes_requiring_auth: @@ -129,211 +143,387 @@ def test_api_auth(client): assert response.status_code < 400 # invalid user cannot - response = client.get(route, headers={'X-API-Key': ':'}) + response = client.get(route, headers={"X-API-Key": ":"}) assert response.status_code > 400 - response = client.get(route, headers={'X-API-Key': '*:*'}) + response = client.get(route, headers={"X-API-Key": "*:*"}) assert response.status_code > 400 - response = client.get(route, headers={'X-API-Key': '*'}) + response = client.get(route, headers={"X-API-Key": "*"}) assert response.status_code > 400 - response = client.get(route, headers={'X-API-Key': ''}) + response = client.get(route, headers={"X-API-Key": ""}) assert response.status_code > 400 - response = client.get(route, headers={'Authorization': 'Basic *'}) + response = client.get(route, headers={"Authorization": "Basic *"}) assert response.status_code > 400 - response = client.get(route, headers={'Authorization': 'Basic'}) + response = client.get(route, headers={"Authorization": "Basic"}) assert response.status_code > 400 - response = client.get(route, headers={'Authorization': ''}) + response = client.get(route, headers={"Authorization": ""}) assert response.status_code > 400 - + # blank user cannot response = client.get(route) assert response.status_code > 400 response = client.get(route) assert response.status_code > 400 + def test_api_zone_create(client): # zone that the user is not allowed to create because it is not listed at all - response = client.post('/api/v1/servers/localhost/zones', headers=api_key_header(client), json={"masters": [], "name": "example.com.", "nameservers": ["ns1.example.org."], "kind": "MASTER", "soa_edit_api": "INCEPTION-INCREMENT"}) + response = client.post( + "/api/v1/servers/localhost/zones", + headers=api_key_header(client), + json={ + "masters": [], + "name": "example.com.", + "nameservers": ["ns1.example.org."], + "kind": "MASTER", + "soa_edit_api": "INCEPTION-INCREMENT", + }, + ) assert response.status_code > 400 # zone that the user is not allowed to create but which does share a common prefix with one they can create - response = client.post('/api/v1/servers/localhost/zones', headers=api_key_header(client), json={"masters": [], "name": "fooexample.org.", "nameservers": ["ns1.example.org."], "kind": "MASTER", "soa_edit_api": "INCEPTION-INCREMENT"}) + response = client.post( + "/api/v1/servers/localhost/zones", + headers=api_key_header(client), + json={ + "masters": [], + "name": "fooexample.org.", + "nameservers": ["ns1.example.org."], + "kind": "MASTER", + "soa_edit_api": "INCEPTION-INCREMENT", + }, + ) assert response.status_code > 400 # zone belonging to another user - response = client.post('/api/v1/servers/localhost/zones', headers=api_key_header(client), json={"masters": [], "name": "example.net.", "nameservers": ["ns1.example.org."], "kind": "MASTER", "soa_edit_api": "INCEPTION-INCREMENT"}) + response = client.post( + "/api/v1/servers/localhost/zones", + headers=api_key_header(client), + json={ + "masters": [], + "name": "example.net.", + "nameservers": ["ns1.example.org."], + "kind": "MASTER", + "soa_edit_api": "INCEPTION-INCREMENT", + }, + ) assert response.status_code > 400 # regular zone creation - response = client.post('/api/v1/servers/localhost/zones', headers=api_key_header(client), json={"masters": [], "name": "example.org.", "nameservers": ["ns1.example.org."], "kind": "MASTER", "soa_edit_api": "INCEPTION-INCREMENT"}) + response = client.post( + "/api/v1/servers/localhost/zones", + headers=api_key_header(client), + json={ + "masters": [], + "name": "example.org.", + "nameservers": ["ns1.example.org."], + "kind": "MASTER", + "soa_edit_api": "INCEPTION-INCREMENT", + }, + ) assert response.status_code < 400 - + # zone already exists, expected to fail - response = client.post('/api/v1/servers/localhost/zones', headers=api_key_header(client), json={"masters": [], "name": "example.org.", "nameservers": ["ns1.example.org."], "kind": "MASTER", "soa_edit_api": "INCEPTION-INCREMENT"}) + response = client.post( + "/api/v1/servers/localhost/zones", + headers=api_key_header(client), + json={ + "masters": [], + "name": "example.org.", + "nameservers": ["ns1.example.org."], + "kind": "MASTER", + "soa_edit_api": "INCEPTION-INCREMENT", + }, + ) assert response.status_code > 400 # suffix matching a wildcard domain - response = client.post('/api/v1/servers/localhost/zones', headers=api_key_header(client), json={"masters": [], "name": "bar.example.test.", "nameservers": ["ns1.example.org."], "kind": "MASTER", "soa_edit_api": "INCEPTION-INCREMENT"}) + response = client.post( + "/api/v1/servers/localhost/zones", + headers=api_key_header(client), + json={ + "masters": [], + "name": "bar.example.test.", + "nameservers": ["ns1.example.org."], + "kind": "MASTER", + "soa_edit_api": "INCEPTION-INCREMENT", + }, + ) assert response.status_code < 400 # disallow suffix on non-wildcard domain - response = client.post('/api/v1/servers/localhost/zones', headers=api_key_header(client), json={"masters": [], "name": "bar.example.org.", "nameservers": ["ns1.example.org."], "kind": "MASTER", "soa_edit_api": "INCEPTION-INCREMENT"}) + response = client.post( + "/api/v1/servers/localhost/zones", + headers=api_key_header(client), + json={ + "masters": [], + "name": "bar.example.org.", + "nameservers": ["ns1.example.org."], + "kind": "MASTER", + "soa_edit_api": "INCEPTION-INCREMENT", + }, + ) assert response.status_code > 400 + def test_api_zone_list(client): # create a zone to use for testing - response = client.post('/api/v1/servers/localhost/zones', headers=api_key_header(client), json={"masters": [], "name": "example.org.", "nameservers": ["ns1.example.org."], "kind": "MASTER", "soa_edit_api": "INCEPTION-INCREMENT"}) + response = client.post( + "/api/v1/servers/localhost/zones", + headers=api_key_header(client), + json={ + "masters": [], + "name": "example.org.", + "nameservers": ["ns1.example.org."], + "kind": "MASTER", + "soa_edit_api": "INCEPTION-INCREMENT", + }, + ) assert response.status_code < 400 # get list of zones in account - response = client.get('/api/v1/servers/localhost/zones', headers=api_key_header(client)) + response = client.get( + "/api/v1/servers/localhost/zones", headers=api_key_header(client) + ) assert response.status_code < 400 json = response.get_json() assert json is not None assert len(json) == 1 - assert json[0]['name'] == 'example.org.' - assert json[0]['account'] == 'demo-example-org' + assert json[0]["name"] == "example.org." + assert json[0]["account"] == "demo-example-org" + def test_api_zone_delete(client): # create a zone to use for testing - response = client.post('/api/v1/servers/localhost/zones', headers=api_key_header(client), json={"masters": [], "name": "example.org.", "nameservers": ["ns1.example.org."], "kind": "MASTER", "soa_edit_api": "INCEPTION-INCREMENT"}) + response = client.post( + "/api/v1/servers/localhost/zones", + headers=api_key_header(client), + json={ + "masters": [], + "name": "example.org.", + "nameservers": ["ns1.example.org."], + "kind": "MASTER", + "soa_edit_api": "INCEPTION-INCREMENT", + }, + ) assert response.status_code < 400 # delete created zone - response = client.delete('/api/v1/servers/localhost/zones/example.org.', headers=api_key_header(client)) + response = client.delete( + "/api/v1/servers/localhost/zones/example.org.", headers=api_key_header(client) + ) assert response.status_code < 400 - + # delete a zone belonging to another user, should return exactly 403 to prevent enumeration - response = client.delete('/api/v1/servers/localhost/zones/example.net.', headers=api_key_header(client)) + response = client.delete( + "/api/v1/servers/localhost/zones/example.net.", headers=api_key_header(client) + ) assert response.status_code == 403 - + # delete a zone that doesn't exist, should return exactly 403 to prevent enumeration - response = client.delete('/api/v1/servers/localhost/zones/example.com.', headers=api_key_header(client)) + response = client.delete( + "/api/v1/servers/localhost/zones/example.com.", headers=api_key_header(client) + ) assert response.status_code == 403 + def test_api_zone_get(client): # create a zone to use for testing - response = client.post('/api/v1/servers/localhost/zones', headers=api_key_header(client), json={"masters": [], "name": "example.org.", "nameservers": ["ns1.example.org."], "kind": "MASTER", "soa_edit_api": "INCEPTION-INCREMENT"}) + response = client.post( + "/api/v1/servers/localhost/zones", + headers=api_key_header(client), + json={ + "masters": [], + "name": "example.org.", + "nameservers": ["ns1.example.org."], + "kind": "MASTER", + "soa_edit_api": "INCEPTION-INCREMENT", + }, + ) assert response.status_code < 400 # retrieve zone that was created - response = client.get('/api/v1/servers/localhost/zones/example.org.', headers=api_key_header(client)) + response = client.get( + "/api/v1/servers/localhost/zones/example.org.", headers=api_key_header(client) + ) assert response.status_code < 400 json = response.get_json() - assert json['name'] == 'example.org.' - assert json['account'] == 'demo-example-org' + assert json["name"] == "example.org." + assert json["account"] == "demo-example-org" # retrieve zone that belongs to another user, should return exactly 403 to prevent enumeration - response = client.get('/api/v1/servers/localhost/zones/example.net.', headers=api_key_header(client)) + response = client.get( + "/api/v1/servers/localhost/zones/example.net.", headers=api_key_header(client) + ) assert response.status_code == 403 - + # retrieve zone that doesn't exist, should return exactly 403 to prevent enumeration - response = client.get('/api/v1/servers/localhost/zones/example.com.', headers=api_key_header(client)) + response = client.get( + "/api/v1/servers/localhost/zones/example.com.", headers=api_key_header(client) + ) assert response.status_code == 403 + def test_api_zone_create_override(client): # try and specify an option that is overriden - response = client.post('/api/v1/servers/localhost/zones', headers=api_key_header(client), json={"account": "nobody", "masters": [], "name": "example.org.", "nameservers": ["ns1.example.org."], "kind": "MASTER", "soa_edit_api": "Invalid"}) + response = client.post( + "/api/v1/servers/localhost/zones", + headers=api_key_header(client), + json={ + "account": "nobody", + "masters": [], + "name": "example.org.", + "nameservers": ["ns1.example.org."], + "kind": "MASTER", + "soa_edit_api": "Invalid", + }, + ) assert response.status_code < 400 - + # retrieve zone that was created - response = client.get('/api/v1/servers/localhost/zones/example.org.', headers=api_key_header(client)) + response = client.get( + "/api/v1/servers/localhost/zones/example.org.", headers=api_key_header(client) + ) assert response.status_code < 400 json = response.get_json() - assert json['name'] == 'example.org.' - assert json['account'] == 'demo-example-org' - assert json['soa_edit_api'] == 'INCEPTION-INCREMENT' - + assert json["name"] == "example.org." + assert json["account"] == "demo-example-org" + assert json["soa_edit_api"] == "INCEPTION-INCREMENT" + # check that the override was properly applied to the nameserver records - assert 'rrsets' in json + assert "rrsets" in json found_ns = False - for m in json['rrsets']: - if m['type'] == 'NS': - assert len(m['records']) == 4 # four override NS records + for m in json["rrsets"]: + if m["type"] == "NS": + assert len(m["records"]) == 4 # four override NS records found_ns = True assert found_ns is True + def test_api_zone_put(client): # create a zone to use for testing - response = client.post('/api/v1/servers/localhost/zones', headers=api_key_header(client), json={"masters": [], "name": "example.org.", "nameservers": ["ns1.example.org."], "kind": "MASTER", "soa_edit_api": "INCEPTION-INCREMENT"}) + response = client.post( + "/api/v1/servers/localhost/zones", + headers=api_key_header(client), + json={ + "masters": [], + "name": "example.org.", + "nameservers": ["ns1.example.org."], + "kind": "MASTER", + "soa_edit_api": "INCEPTION-INCREMENT", + }, + ) assert response.status_code < 400 # try and update a zone that belongs to another user, should return exactly 403 to prevent enumeration json = { - "kind": "NATIVE", + "kind": "NATIVE", "account": "someone-else", } - response = client.put('/api/v1/servers/localhost/zones/example.net.', headers=api_key_header(client), json=json) + response = client.put( + "/api/v1/servers/localhost/zones/example.net.", + headers=api_key_header(client), + json=json, + ) assert response.status_code == 403 # try an update which will be overriden json = { - "kind": "NATIVE", + "kind": "NATIVE", "account": "someone-else", } - response = client.put('/api/v1/servers/localhost/zones/example.org.', headers=api_key_header(client), json=json) + response = client.put( + "/api/v1/servers/localhost/zones/example.org.", + headers=api_key_header(client), + json=json, + ) assert response.status_code < 400 - + # retrieve zone that was updated - response = client.get('/api/v1/servers/localhost/zones/example.org.', headers=api_key_header(client)) + response = client.get( + "/api/v1/servers/localhost/zones/example.org.", headers=api_key_header(client) + ) assert response.status_code < 400 json = response.get_json() - assert json['kind'] == 'Master' - assert json['account'] == 'demo-example-org' - + assert json["kind"] == "Master" + assert json["account"] == "demo-example-org" + # try and evade the overriding of update parameters json = { - " kind ": "NATIVE", + " kind ": "NATIVE", "aCcOuNt": "someone-else", } - response = client.put('/api/v1/servers/localhost/zones/example.org.', headers=api_key_header(client), json=json) + response = client.put( + "/api/v1/servers/localhost/zones/example.org.", + headers=api_key_header(client), + json=json, + ) assert response.status_code < 400 - + # retrieve zone that was updated - response = client.get('/api/v1/servers/localhost/zones/example.org.', headers=api_key_header(client)) + response = client.get( + "/api/v1/servers/localhost/zones/example.org.", headers=api_key_header(client) + ) assert response.status_code < 400 json = response.get_json() - assert json['kind'] == 'Master' - assert json['account'] == 'demo-example-org' + assert json["kind"] == "Master" + assert json["account"] == "demo-example-org" # temporarily disable overrides for kind and make sure it is possible to update - del client.application.config['PDNS']['override-kind'] - + del client.application.config["PDNS"]["override-kind"] + # try an update which should now succeed json = { - "kind": "NATIVE", + "kind": "NATIVE", "account": "someone-else", } - response = client.put('/api/v1/servers/localhost/zones/example.org.', headers=api_key_header(client), json=json) + response = client.put( + "/api/v1/servers/localhost/zones/example.org.", + headers=api_key_header(client), + json=json, + ) assert response.status_code < 400 - + # retrieve zone that was updated - response = client.get('/api/v1/servers/localhost/zones/example.org.', headers=api_key_header(client)) + response = client.get( + "/api/v1/servers/localhost/zones/example.org.", headers=api_key_header(client) + ) assert response.status_code < 400 json = response.get_json() - assert json['kind'] == 'Native' - assert json['account'] == 'demo-example-org' + assert json["kind"] == "Native" + assert json["account"] == "demo-example-org" + def test_api_zone_patch(client): # create a zone to use for testing - response = client.post('/api/v1/servers/localhost/zones', headers=api_key_header(client), json={"masters": [], "name": "example.org.", "nameservers": ["ns1.example.org."], "kind": "MASTER", "soa_edit_api": "INCEPTION-INCREMENT"}) + response = client.post( + "/api/v1/servers/localhost/zones", + headers=api_key_header(client), + json={ + "masters": [], + "name": "example.org.", + "nameservers": ["ns1.example.org."], + "kind": "MASTER", + "soa_edit_api": "INCEPTION-INCREMENT", + }, + ) assert response.status_code < 400 - + # data to be added to the zone payload = { "rrsets": [ { - "type": "TXT", + "type": "TXT", "changetype": "REPLACE", "name": "test.example.org.", - "ttl": 3600, + "ttl": 3600, "records": [ { "priority": 0, "type": "TXT", - "content": "\"This is a test!\"", + "content": '"This is a test!"', "disabled": False, "set-ptr": False, - "name": "test.example.org." + "name": "test.example.org.", } ], } @@ -341,46 +531,77 @@ def test_api_zone_patch(client): } # try and patch a zone that belongs to another user, should return exactly 403 to prevent enumeration - response = client.patch('/api/v1/servers/localhost/zones/example.net.', headers=api_key_header(client), json=payload) + response = client.patch( + "/api/v1/servers/localhost/zones/example.net.", + headers=api_key_header(client), + json=payload, + ) assert response.status_code == 403 # patch a zone that in our account - response = client.patch('/api/v1/servers/localhost/zones/example.org.', headers=api_key_header(client), json=payload) + response = client.patch( + "/api/v1/servers/localhost/zones/example.org.", + headers=api_key_header(client), + json=payload, + ) assert response.status_code < 400 - + # retrieve zone that was updated - response = client.get('/api/v1/servers/localhost/zones/example.org.', headers=api_key_header(client)) + response = client.get( + "/api/v1/servers/localhost/zones/example.org.", headers=api_key_header(client) + ) assert response.status_code < 400 json = response.get_json() - + # check that the TXT record was properly added - assert 'rrsets' in json + assert "rrsets" in json found_txt = False - for m in json['rrsets']: - if m['type'] == 'TXT': - assert m['records'][0]['content'] == "\"This is a test!\"" + for m in json["rrsets"]: + if m["type"] == "TXT": + assert m["records"][0]["content"] == '"This is a test!"' found_txt = True assert found_txt is True # make the patch invalid (the TXT payload needs to have quotes around it for this to work) - payload['rrsets'][0]['records'][0]['content'] = 'Invalid' + payload["rrsets"][0]["records"][0]["content"] = "Invalid" # try and apply the invalid patch - response = client.patch('/api/v1/servers/localhost/zones/example.org.', headers=api_key_header(client), json=payload) + response = client.patch( + "/api/v1/servers/localhost/zones/example.org.", + headers=api_key_header(client), + json=payload, + ) assert response.status_code > 400 json = response.get_json() # ensure that errors from the backend are properly passed through - assert "not in expected format" in json['error'].lower() + assert "not in expected format" in json["error"].lower() + def test_api_zone_notify(client): # create a zone to use for testing - response = client.post('/api/v1/servers/localhost/zones', headers=api_key_header(client), json={"masters": [], "name": "example.org.", "nameservers": ["ns1.example.org."], "kind": "MASTER", "soa_edit_api": "INCEPTION-INCREMENT"}) + response = client.post( + "/api/v1/servers/localhost/zones", + headers=api_key_header(client), + json={ + "masters": [], + "name": "example.org.", + "nameservers": ["ns1.example.org."], + "kind": "MASTER", + "soa_edit_api": "INCEPTION-INCREMENT", + }, + ) assert response.status_code < 400 # try and notify a zone that belongs to another user, should return exactly 403 to prevent enumeration - response = client.put('/api/v1/servers/localhost/zones/example.net./notify', headers=api_key_header(client)) + response = client.put( + "/api/v1/servers/localhost/zones/example.net./notify", + headers=api_key_header(client), + ) assert response.status_code == 403 - + # this notification should work - response = client.put('/api/v1/servers/localhost/zones/example.org./notify', headers=api_key_header(client)) + response = client.put( + "/api/v1/servers/localhost/zones/example.org./notify", + headers=api_key_header(client), + ) assert response.status_code < 400 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 77eb14e..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -Flask~=1.0 -requests~=2.0 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..31a6f43 --- /dev/null +++ b/setup.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""The setup script.""" + +import setuptools + +requirements = [ + "Flask~=1.0", + "requests~=2.0", +] + +packages = setuptools.find_packages(where="./", include=["powerdns_auth_proxy"]) +if not packages: + raise ValueError("No packages detected.") + +setuptools.setup( + name="powerdns-auth-proxy", + version=0.1, + description="Authenticating proxy for PowerDNS's HTTP API", + long_description="", + author="Catalyst OpsDev", + author_email="opsdev@catalyst.net.nz", + url="https://github.com/catalyst/powerdns-auth-proxy", + packages=packages, + install_requires=requirements, + zip_safe=False, + classifiers=[ + "Development Status :: 2 - Pre-Alpha", + "Natural Language :: English", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + ], +)