Skip to content

Commit

Permalink
Merge pull request #34 from akquinet/config-changes
Browse files Browse the repository at this point in the history
feat: metrics authentication & disable metrics, index, api docs
  • Loading branch information
rwxd authored Feb 9, 2024
2 parents e1db43d + 96ebc5b commit 8be0074
Show file tree
Hide file tree
Showing 5 changed files with 153 additions and 25 deletions.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ integration: ## run integration tests
python -m pytest -vvl --setup-show -vvl tests/integration/ --showlocals

run: ## run project
python -m $(PROJECT_NAME)
uvicorn --host 0.0.0.0 --port 8000 --reload powerdns_api_proxy.proxy:app

clean: ## clean cache and temp dirs
rm -rf ./.mypy_cache ./.pytest_cache
Expand Down
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ pdns_api_token: "blablub"
pdns_api_verify_ssl: True
environments:
...

```
### Environment
Expand Down Expand Up @@ -205,6 +206,57 @@ environments:
global_search: true
```

### Metrics of the proxy

The proxy exposes metrics on the `/metrics` endpoint.
With the `metrics_enabled` option set to `false`, the metrics can be disabled.

The `metrics_require_auth` option can be used to disable the need for authentication for the `/metrics` endpoint.

```yaml
...
metrics_enabled: false # default is true
metrics_require_auth: false # default is true
```

#### Give an environment access to the metrics

When the `metrics_proxy` option is set to `true`, the environment has access to the `/metrics` endpoint of the proxy.

That is needed, when the `metrics_require_auth` option is set to `true` (default).

```yaml
...
environments:
- name: "Test1"
metrics_proxy: true
```

#### Metrics

The [prometheus-fastapi-instrumentator](https://github.com/trallnag/prometheus-fastapi-instrumentator) is used for the default metrics.

Additionally http requests per environment are counted.

### API Docs

The API documentation can be viewed at `<url>/docs`.

They can be deactivated with the `api_docs_enabled` option.

```yaml
api_docs_enabled: false # default is true
```

### Index

The index page can be deactivated with the `index_enabled` option and customized with `index_html`.

```yaml
index_enabled: false # default is true
index_html: "<html><body><h1>PowerDNS API Proxy</h1></body></html>"
```

## Development

### Install requirements
Expand Down
12 changes: 12 additions & 0 deletions powerdns_api_proxy/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from powerdns_api_proxy.logging import logger
from powerdns_api_proxy.models import (
RRSET,
MetricsNotAllowedException,
NotAuthorizedException,
ProxyConfig,
ProxyConfigEnvironment,
Expand Down Expand Up @@ -57,6 +58,17 @@ def dependency_check_token_defined(
check_token_defined(load_config(), X_API_Key)


def dependency_metrics_proxy_enabled(
X_API_Key: str = Header(description='API Key for the proxy.'),
):
try:
environment = get_environment_for_token(load_config(), X_API_Key)
if not environment.metrics_proxy:
raise MetricsNotAllowedException()
except ValueError:
raise MetricsNotAllowedException()


def get_environment_for_token(
config: ProxyConfig, token: str
) -> ProxyConfigEnvironment:
Expand Down
45 changes: 45 additions & 0 deletions powerdns_api_proxy/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ class ProxyConfigEnvironment(BaseModel):
global_read_only: bool = False
global_search: bool = False
_zones_lookup: dict[str, ProxyConfigZone] = {}
metrics_proxy: bool = False

@field_validator('name')
@classmethod
Expand Down Expand Up @@ -114,11 +115,49 @@ def get_zone_if_allowed(self, zone: str) -> ProxyConfigZone:


class ProxyConfig(BaseModel):
"""
Configuration for the PowerDNS API Proxy.
Args:
pdns_api_url: The URL of the PowerDNS API.
pdns_api_token: The token for the PowerDNS API.
environments: A list of environments.
pdns_api_verify_ssl: Verify SSL certificate of the PowerDNS API.
metrics_enabled: Enable metrics.
metrics_require_auth: Require authentication for metrics.
api_docs_enabled: Enable API documentation.
index_enabled: Enable default web page
index_html: Custom html for the homepage
"""

pdns_api_url: str
pdns_api_token: str
environments: list[ProxyConfigEnvironment]
pdns_api_verify_ssl: bool = True

metrics_enabled: bool = True
metrics_require_auth: bool = True

api_docs_enabled: bool = True

index_enabled: bool = True
index_html: str = '''
<html>
<head>
<title>PowerDNS API Proxy</title>
</head>
<body>
<center>
<h1>PowerDNS API Proxy</h1>
<p><a href="/docs">Swagger Docs</a></p>
<q>The Domain Name Server (DNS) is the Achilles heel of the Web.<br>
The important thing is that it's managed responsibly.</q>
</center>
</body>
</html>
'''

@field_validator('pdns_api_url')
@classmethod
def api_url_defined(cls, v):
Expand Down Expand Up @@ -180,6 +219,12 @@ def __init__(self):
self.detail = 'Search not allowed'


class MetricsNotAllowedException(HTTPException):
def __init__(self):
self.status_code = 403
self.detail = 'Metrics not allowed'


class RRSETRecord(TypedDict):
content: str
disabled: bool
Expand Down
67 changes: 43 additions & 24 deletions powerdns_api_proxy/proxy.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
from contextlib import asynccontextmanager
from http import HTTPStatus
from typing import Literal

Expand All @@ -15,6 +16,7 @@
check_pdns_zone_admin,
check_pdns_zone_allowed,
dependency_check_token_defined,
dependency_metrics_proxy_enabled,
ensure_rrsets_request_allowed,
get_environment_for_token,
get_only_pdns_zones_allowed,
Expand Down Expand Up @@ -47,18 +49,46 @@
config.pdns_api_url, config.pdns_api_token, config.pdns_api_verify_ssl
)

app = FastAPI(title='PowerDNS API Proxy', version='0.1.0')
instrumentator = Instrumentator(
should_group_status_codes=False,
)
instrumentator.add(metrics.default())
instrumentator.instrument(app)

@asynccontextmanager
async def _startup(app: FastAPI):
yield


app = FastAPI(title='PowerDNS API Proxy', version='0.1.0', lifespan=_startup)

if not config.api_docs_enabled:
logger.info('Disabling API docs')
app = FastAPI(
title=app.title,
version=app.version,
lifespan=_startup,
docs_url=None,
redoc_url=None,
openapi_url=None,
)

@app.on_event('startup')
async def _startup():
if config.metrics_enabled:
instrumentator = Instrumentator(
should_group_status_codes=False,
)
logger.info('Enabling metrics')
instrumentator.add(metrics.default())
instrumentator.add(http_requests_total_environment())
instrumentator.expose(app)

if config.metrics_require_auth:
logger.info('Enabling metrics authentication')
instrumentator.expose(
app,
dependencies=[
Depends(dependency_check_token_defined),
Depends(dependency_metrics_proxy_enabled),
],
)
else:
instrumentator.expose(app)
else:
logger.info('Metrics are disabled')


# Patching HTTPException to be compatible with PowerDNS API errors
Expand Down Expand Up @@ -87,21 +117,10 @@ async def http_exception_handler(request, exc):
@app.head('/', include_in_schema=False)
@app.get('/', response_class=HTMLResponse, include_in_schema=False)
async def hello():
return '''
<html>
<head>
<title>PowerDNS API Proxy</title>
</head>
<body>
<center>
<h1>PowerDNS API Proxy</h1>
<p>| <a href="/docs">Swagger Docs</a></p>
<q>The Domain Name Server (DNS) is the Achilles heel of the Web.<br>
The important thing is that it's managed responsibly.</q>
</center>
</body>
</html>
'''
if config.index_enabled:
return config.index_html
else:
return HTMLResponse(status_code=404)


@router_health.get('/pdns', status_code=HTTPStatus.OK)
Expand Down

0 comments on commit 8be0074

Please sign in to comment.