diff --git a/.gitignore b/.gitignore index 3dde377..45bd2b8 100644 --- a/.gitignore +++ b/.gitignore @@ -163,6 +163,9 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +# VSCode +.vscode/ + ### Python Patch ### # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration poetry.toml diff --git a/README.rst b/README.rst index 765b85f..5470bf6 100644 --- a/README.rst +++ b/README.rst @@ -22,6 +22,21 @@ then, add the app to `settings.py`: # ... other apps ... ] +configure aetos in `settings.py`: + +> ℹ️ **Important**: When using `django-aetos` in a project behind a reverse proxy, include [`django-xff`](https://pypi.org/project/django-xff/) in your project, so that a request's `REMOTE_ADDR` header gets rewritten to the correct client ip. + +.. code-block:: python + + # on enabled ip allowlist with empty list, requests are denied + AETOS_ENABLE_IP_ALLOWLIST = True + AETOS_IP_ALLOWLIST = ["127.0.0.1"] + + # enables authentication via bearer token + # if enabled with empty list, requests are denied + AETOS_ENABLE_AUTH = True + AETOS_AUTH_TOKENS = ["ooy9Evuth0zahka"] + and send requests to `/metrics` to Aetos in your `urls.py`: .. code-block:: python diff --git a/example_project/example_project/settings.py b/example_project/example_project/settings.py index 0b8b5af..0e7ac32 100644 --- a/example_project/example_project/settings.py +++ b/example_project/example_project/settings.py @@ -124,3 +124,5 @@ # https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# WARNING: Do not set AETOS_* settings, otherwise tests fail. diff --git a/example_project/tests.py b/example_project/tests.py index efcfac5..9f052ce 100644 --- a/example_project/tests.py +++ b/example_project/tests.py @@ -1,16 +1,162 @@ import pytest +from django.test import override_settings - -@pytest.mark.django_db -def test_e2e(client): - resp = client.get("/metrics") - assert ( - resp.content.decode() - == """# HELP books_count Total number of books +expected_output = """# HELP books_count Total number of books # TYPE books_count counter books_count 0 # HELP universes_count Total number of universes # TYPE universes_count counter universes_count 1 """ - ) + + +@pytest.mark.django_db +def test_e2e(client): + resp = client.get("/metrics") + assert resp.content.decode() == expected_output + + +@override_settings( + AETOS_ENABLE_IP_ALLOWLIST=True, + AETOS_IP_ALLOWLIST=["127.0.0.1"], + AETOS_ENABLE_AUTH=True, + AETOS_AUTH_TOKENLIST=["AhGei6ohghooDae"], +) +def test_settings(): + from django_aetos import app_settings + + assert app_settings.ENABLE_IP_ALLOWLIST + assert app_settings.IP_ALLOWLIST == ["127.0.0.1"] + assert app_settings.ENABLE_AUTH + assert app_settings.AUTH_TOKENLIST == ["AhGei6ohghooDae"] + + +def test_settings_defaults(): + from django_aetos import app_settings + + assert not app_settings.ENABLE_IP_ALLOWLIST + assert app_settings.IP_ALLOWLIST == [] + assert not app_settings.ENABLE_AUTH + assert app_settings.AUTH_TOKENLIST == [] + + +@pytest.mark.django_db +@override_settings(AETOS_ENABLE_IP_ALLOWLIST=True, AETOS_IP_ALLOWLIST=["127.0.0.1"]) +def test_enable_allowed_ips(client): + resp = client.get("/metrics") + assert resp.content.decode() == expected_output + + +@pytest.mark.django_db +@override_settings(AETOS_ENABLE_IP_ALLOWLIST=True, AETOS_IP_ALLOWLIST=["255.0.0.1"]) +def test_enable_allowed_ips_not_allowed(client): + resp = client.get("/metrics") + assert resp.content.decode() == "IP not allowed" + assert resp.status_code == 401 + + +@pytest.mark.django_db +@override_settings(AETOS_ENABLE_AUTH=True, AETOS_AUTH_TOKENLIST=["aquee4ro4Theeth"]) +def test_enable_auth(client): + resp = client.get("/metrics", headers={"Authorization": "Bearer aquee4ro4Theeth"}) + assert resp.content.decode() == expected_output + + +@pytest.mark.django_db +@override_settings(AETOS_ENABLE_AUTH=True, AETOS_AUTH_TOKENLIST=["aquee4ro4Theeth"]) +def test_enable_auth_token_not_allowed(client): + resp = client.get("/metrics", headers={"Authorization": "Bearer wr0ngt0kenf"}) + assert resp.content.decode() == "Invalid auth token" + assert resp.status_code == 401 + + +@pytest.mark.django_db +@override_settings( + AETOS_ENABLE_IP_ALLOWLIST=True, + AETOS_IP_ALLOWLIST=["127.0.0.1"], + AETOS_ENABLE_AUTH=True, + AETOS_AUTH_TOKENLIST=["aquee4ro4Theeth"], +) +def test_enable_all(client): + resp = client.get("/metrics", headers={"Authorization": "Bearer aquee4ro4Theeth"}) + assert resp.content.decode() == expected_output + + +@pytest.mark.django_db +@override_settings( + AETOS_ENABLE_IP_ALLOWLIST=True, + AETOS_IP_ALLOWLIST=["255.0.0.1"], + AETOS_ENABLE_AUTH=True, + AETOS_AUTH_TOKENLIST=["aquee4ro4Theeth"], +) +def test_enable_all_wrong_ip(client): + resp = client.get("/metrics", headers={"Authorization": "Bearer aquee4ro4Theeth"}) + assert resp.content.decode() == "IP not allowed" + assert resp.status_code == 401 + + +@pytest.mark.django_db +@override_settings( + AETOS_ENABLE_IP_ALLOWLIST=True, + AETOS_IP_ALLOWLIST=["127.0.0.1"], + AETOS_ENABLE_AUTH=True, + AETOS_AUTH_TOKENLIST=["aquee4ro4Theeth"], +) +def test_enable_all_wrong_token(client): + resp = client.get("/metrics", headers={"Authorization": "Bearer wr0ngt0ken"}) + assert resp.content.decode() == "Invalid auth token" + assert resp.status_code == 401 + + +@pytest.mark.django_db +@override_settings( + AETOS_ENABLE_IP_ALLOWLIST=True, + AETOS_IP_ALLOWLIST=["255.0.0.1"], + AETOS_ENABLE_AUTH=True, + AETOS_AUTH_TOKENLIST=["aquee4ro4Theeth"], +) +def test_enable_all_wrong_token_ip(client): + resp = client.get("/metrics", headers={"Authorization": "Bearer wr0ngt0ken"}) + assert resp.content.decode() == "Invalid auth token and IP not allowed" + assert resp.status_code == 401 + + +@pytest.mark.django_db +@override_settings( + AETOS_ENABLE_IP_ALLOWLIST=True, AETOS_ENABLE_AUTH=True, AETOS_AUTH_TOKENLIST=["aquee4ro4Theeth"] +) +def test_enable_all_empty_ip(client): + resp = client.get("/metrics", headers={"Authorization": "Bearer aquee4ro4Theeth"}) + assert resp.content.decode() == "IP not allowed" + assert resp.status_code == 401 + + +@pytest.mark.django_db +@override_settings( + AETOS_ENABLE_IP_ALLOWLIST=True, AETOS_IP_ALLOWLIST=["127.0.0.1"], AETOS_ENABLE_AUTH=True +) +def test_enable_all_empty_token(client): + resp = client.get("/metrics", headers={"Authorization": "Bearer aquee4ro4Theeth"}) + assert resp.content.decode() == "Invalid auth token" + assert resp.status_code == 401 + + +@pytest.mark.django_db +@override_settings(AETOS_ENABLE_IP_ALLOWLIST=True, AETOS_ENABLE_AUTH=True) +def test_enable_all_empty_token_ip(client): + resp = client.get("/metrics", headers={"Authorization": "Bearer aquee4ro4Theeth"}) + assert resp.content.decode() == "Invalid auth token and IP not allowed" + assert resp.status_code == 401 + + +@pytest.mark.django_db +@override_settings( + AETOS_ENABLE_IP_ALLOWLIST=True, + AETOS_IP_ALLOWLIST=["127.0.0.1"], + AETOS_ENABLE_AUTH=True, + AETOS_AUTH_TOKENLIST=["aquee4ro4Theeth"], +) +def test_enable_all_wrong_auth_header(client): + resp = client.get("/metrics", headers={"Authorization": "Basic aquee4ro4Theeth"}) + assert resp.content.decode() == "Invalid auth token" + assert resp.status_code == 401 diff --git a/src/django_aetos/app_settings.py b/src/django_aetos/app_settings.py new file mode 100644 index 0000000..003756f --- /dev/null +++ b/src/django_aetos/app_settings.py @@ -0,0 +1,32 @@ +class AppSettings: + def __init__(self, prefix): + self.prefix = prefix + + def _setting(self, name, dflt): + from django.conf import settings + + return getattr(settings, self.prefix + name, dflt) + + @property + def ENABLE_IP_ALLOWLIST(self): + return self._setting("ENABLE_IP_ALLOWLIST", False) + + @property + def IP_ALLOWLIST(self): + return self._setting("IP_ALLOWLIST", []) + + @property + def ENABLE_AUTH(self): + return self._setting("ENABLE_AUTH", False) + + @property + def AUTH_TOKENLIST(self): + return self._setting("AUTH_TOKENLIST", []) + + +_app_settings = AppSettings("AETOS_") + + +def __getattr__(name): + # See https://peps.python.org/pep-0562/ + return getattr(_app_settings, name) diff --git a/src/django_aetos/views.py b/src/django_aetos/views.py index acb71fc..4f240d2 100644 --- a/src/django_aetos/views.py +++ b/src/django_aetos/views.py @@ -1,11 +1,13 @@ import re +from django.http import HttpResponse from django.shortcuts import render +from django_aetos import app_settings from .signals import collect_metrics -def export_metrics(request): +def collect_response(): metrics = [] known_metrics = set() @@ -19,11 +21,53 @@ def export_metrics(request): known_metrics.add(metric["name_without_labels"]) metrics.append(metric) - response = render( - request, - "metrics/export.txt", - context={"metrics": metrics}, - content_type="text/plain", - ) - response.content = re.sub(b"\n+", b"\n", response.content) - return response + return metrics + + +def check_ip(request): + ip_address = request.META["REMOTE_ADDR"] + allowed_ips = app_settings.IP_ALLOWLIST + + if app_settings.ENABLE_IP_ALLOWLIST is True and ip_address in allowed_ips: + return True + elif app_settings.ENABLE_IP_ALLOWLIST is False: + return True + else: + return False + + +def check_auth(request): + try: + authorization_header = request.META["HTTP_AUTHORIZATION"] + except KeyError: + authorization_header = "" + type, sep, token = authorization_header.partition(" ") + allowed_tokens = app_settings.AUTH_TOKENLIST + + if app_settings.ENABLE_AUTH is True and type == "Bearer" and token in allowed_tokens: + return True + elif app_settings.ENABLE_AUTH is False: + return True + else: + return False + + +def export_metrics(request): + validated_ip = check_ip(request) + validated_auth = check_auth(request) + if validated_auth and validated_ip: + response = render( + request, + "metrics/export.txt", + context={"metrics": collect_response()}, + content_type="text/plain", + ) + response.content = re.sub(b"\n+", b"\n", response.content) + + return response + elif not validated_auth and validated_ip: + return HttpResponse("Invalid auth token", status=401) + elif validated_auth and not validated_ip: + return HttpResponse("IP not allowed", status=401) + else: + return HttpResponse("Invalid auth token and IP not allowed", status=401)