Skip to content

Commit

Permalink
Implement GET /api/v1/node/{name}/configuration (#46)
Browse files Browse the repository at this point in the history
* Implement GET /api/v1/node/{name}/configuration

* Split create node configuration

* fix bad doc string

* Add telemetry for get configurations

* Add Cache-Control header to get configuration

* ttl
  • Loading branch information
jschlyter authored Dec 22, 2024
1 parent 2e8b85d commit 6d51236
Show file tree
Hide file tree
Showing 4 changed files with 100 additions and 14 deletions.
14 changes: 12 additions & 2 deletions nodeman/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@
from pydantic.types import AwareDatetime

from .db_models import TapirNode
from .jose import PrivateJwk, PrivateSymmetric, PublicJwk, PublicJwks, public_key_factory
from .jose import (
PrivateJwk,
PrivateSymmetric,
PublicJwk,
PublicJwks,
public_key_factory,
)
from .settings import MqttUrl

MAX_REQUEST_AGE = 300
Expand Down Expand Up @@ -81,7 +87,7 @@ def validate_pem_bundle(cls, v: str):
return v


class NodeConfiguration(NodeCertificate):
class NodeConfiguration(BaseModel):
name: str = Field(title="Node name", examples=["node.example.com"])
mqtt_broker: MqttUrl = Field(title="MQTT Broker", examples=["mqtts://broker.example.com"])
mqtt_topics: dict[str, str] = Field(
Expand All @@ -90,3 +96,7 @@ class NodeConfiguration(NodeCertificate):
examples=[{"edm": "configuration/node.example.com/edm", "pop": "configuration/node.example.com/pop"}],
)
trusted_jwks: PublicJwks = Field(title="Trusted JWKS")


class NodeEnrollmentResult(NodeConfiguration, NodeCertificate):
pass
57 changes: 50 additions & 7 deletions nodeman/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
NodeCertificate,
NodeCollection,
NodeConfiguration,
NodeEnrollmentResult,
NodeInformation,
PublicKeyFormat,
RenewalRequest,
Expand All @@ -36,6 +37,9 @@
nodes_public_key_queries = meter.create_counter(
"nodes.public_key_queries", description="The number of node public keys queried"
)
node_configurations_requested = meter.create_counter(
"nodes.configurations", description="The number of node configurations requested"
)

router = APIRouter()

Expand All @@ -48,6 +52,15 @@ def find_node(name: str) -> TapirNode:
raise HTTPException(status.HTTP_404_NOT_FOUND)


def create_node_configuration(name: str, request: Request) -> NodeConfiguration:
return NodeConfiguration(
name=name,
mqtt_broker=request.app.settings.nodes.mqtt_broker,
mqtt_topics=request.app.settings.nodes.mqtt_topics,
trusted_jwks=request.app.trusted_jwks,
)


@router.post(
"/api/v1/node",
status_code=status.HTTP_201_CREATED,
Expand Down Expand Up @@ -199,7 +212,7 @@ def delete_node(name: str, username: Annotated[str, Depends(get_current_username
@router.post(
"/api/v1/node/{name}/enroll",
responses={
status.HTTP_200_OK: {"model": NodeConfiguration},
status.HTTP_200_OK: {"model": NodeEnrollmentResult},
status.HTTP_404_NOT_FOUND: {},
},
tags=["client"],
Expand All @@ -208,7 +221,7 @@ def delete_node(name: str, username: Annotated[str, Depends(get_current_username
async def enroll_node(
name: str,
request: Request,
) -> NodeConfiguration:
) -> NodeEnrollmentResult:
"""Enroll new node"""

node = find_node(name)
Expand Down Expand Up @@ -269,11 +282,8 @@ async def enroll_node(

nodes_enrolled.add(1)

return NodeConfiguration(
name=name,
mqtt_broker=request.app.settings.nodes.mqtt_broker,
mqtt_topics=request.app.settings.nodes.mqtt_topics,
trusted_jwks=request.app.trusted_jwks,
return NodeEnrollmentResult(
**create_node_configuration(name=name, request=request).model_dump(),
x509_certificate=node_certificate.x509_certificate,
x509_ca_certificate=node_certificate.x509_ca_certificate,
x509_certificate_serial_number=node_certificate.x509_certificate_serial_number,
Expand Down Expand Up @@ -332,3 +342,36 @@ async def renew_node(
nodes_renewed.add(1)

return res


@router.get(
"/api/v1/node/{name}/configuration",
responses={
status.HTTP_200_OK: {"model": NodeConfiguration},
status.HTTP_404_NOT_FOUND: {},
},
tags=["client"],
response_model_exclude_none=True,
)
async def get_node_configuration(
name: str,
request: Request,
response: Response,
) -> NodeConfiguration:
"""Get node configuration"""

node = find_node(name)

if not node.activated:
logging.debug("Node %s not activated", name, extra={"nodename": name})
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Node not activated")

res = create_node_configuration(name=name, request=request)

node_configurations_requested.add(1)

# Cache response for 5 minutes
max_age = request.app.settings.nodes.configuration_ttl
response.headers["Cache-Control"] = f"public, max-age={max_age}"

return res
26 changes: 22 additions & 4 deletions nodeman/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,23 @@
from typing import Annotated, Self

from argon2 import PasswordHasher
from pydantic import AnyHttpUrl, BaseModel, Field, FilePath, StringConstraints, UrlConstraints, model_validator
from pydantic_core import Url
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict, TomlConfigSettingsSource

from dnstapir.opentelemetry import OtlpSettings
from pydantic import (
AnyHttpUrl,
BaseModel,
Field,
FilePath,
StringConstraints,
UrlConstraints,
model_validator,
)
from pydantic_core import Url
from pydantic_settings import (
BaseSettings,
PydanticBaseSettingsSource,
SettingsConfigDict,
TomlConfigSettingsSource,
)

MqttUrl = Annotated[
Url,
Expand Down Expand Up @@ -45,6 +57,12 @@ class NodesSettings(BaseModel):
trusted_jwks: FilePath | None = Field(default=None)
mqtt_broker: MqttUrl = Field(default="mqtt://localhost")
mqtt_topics: dict[str, str] = Field(default={})
configuration_ttl: int = Field(
default=300,
gt=0,
le=86400,
description="Configuration cache TTL in seconds",
)


class User(BaseModel):
Expand Down
17 changes: 16 additions & 1 deletion tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@
from nodeman.models import PublicKeyFormat
from nodeman.server import NodemanServer
from nodeman.settings import Settings
from nodeman.x509 import RSA_EXPONENT, CertificateAuthorityClient, generate_ca_certificate, generate_x509_csr
from nodeman.x509 import (
RSA_EXPONENT,
CertificateAuthorityClient,
generate_ca_certificate,
generate_x509_csr,
)

ADMIN_TEST_NODE_COUNT = 100
BACKEND_CREDENTIALS = ("username", "password")
Expand Down Expand Up @@ -140,6 +145,16 @@ def _test_enroll(data_key: JWK, x509_key: PrivateKey, requested_name: str | None
assert node_information["name"] == name
assert node_information["activated"] is not None

#########################
# Get node configuration

response = client.get(f"{node_url}/configuration")
assert response.status_code == status.HTTP_200_OK
node_information = response.json()
print(json.dumps(node_information, indent=4))
assert node_information["name"] == name
assert response.headers.get("Cache-Control") is not None

#####################
# Get node public key

Expand Down

0 comments on commit 6d51236

Please sign in to comment.