From 834ce90d35087518a6021ad27f2f9269e3bf992f Mon Sep 17 00:00:00 2001 From: Jakob Schlyter Date: Thu, 19 Dec 2024 22:03:42 +0100 Subject: [PATCH 1/6] Implement GET /api/v1/node/{name}/configuration --- nodeman/models.py | 14 ++++++++++++-- nodeman/nodes.py | 36 +++++++++++++++++++++++++++++++++--- tests/test_api.py | 16 +++++++++++++++- 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/nodeman/models.py b/nodeman/models.py index 0e03040..34452b4 100644 --- a/nodeman/models.py +++ b/nodeman/models.py @@ -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 @@ -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( @@ -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 diff --git a/nodeman/nodes.py b/nodeman/nodes.py index 1b60b23..c68ad4d 100644 --- a/nodeman/nodes.py +++ b/nodeman/nodes.py @@ -19,6 +19,7 @@ NodeCertificate, NodeCollection, NodeConfiguration, + NodeEnrollmentResult, NodeInformation, PublicKeyFormat, RenewalRequest, @@ -199,7 +200,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"], @@ -208,7 +209,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) @@ -269,7 +270,7 @@ async def enroll_node( nodes_enrolled.add(1) - return NodeConfiguration( + return NodeEnrollmentResult( name=name, mqtt_broker=request.app.settings.nodes.mqtt_broker, mqtt_topics=request.app.settings.nodes.mqtt_topics, @@ -332,3 +333,32 @@ 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, +) -> NodeConfiguration: + """Enroll new node""" + + 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") + + 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, + ) diff --git a/tests/test_api.py b/tests/test_api.py index b363cb4..f625cd3 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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") @@ -140,6 +145,15 @@ 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 + ##################### # Get node public key From d34c0f80bf4705785d8064acfba12358f0a4d14e Mon Sep 17 00:00:00 2001 From: Jakob Schlyter Date: Fri, 20 Dec 2024 08:17:03 +0100 Subject: [PATCH 2/6] Split create node configuration --- nodeman/nodes.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/nodeman/nodes.py b/nodeman/nodes.py index c68ad4d..5e45e4e 100644 --- a/nodeman/nodes.py +++ b/nodeman/nodes.py @@ -49,6 +49,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, @@ -271,10 +280,7 @@ async def enroll_node( nodes_enrolled.add(1) return NodeEnrollmentResult( - name=name, - mqtt_broker=request.app.settings.nodes.mqtt_broker, - mqtt_topics=request.app.settings.nodes.mqtt_topics, - trusted_jwks=request.app.trusted_jwks, + **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, @@ -356,9 +362,4 @@ async def get_node_configuration( logging.debug("Node %s not activated", name, extra={"nodename": name}) raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Node not activated") - 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 create_node_configuration(name=name, request=request) From e5a747f19b347b0df10060161d0a4c3fed73fbdd Mon Sep 17 00:00:00 2001 From: Jakob Schlyter Date: Fri, 20 Dec 2024 08:22:54 +0100 Subject: [PATCH 3/6] fix bad doc string --- nodeman/nodes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nodeman/nodes.py b/nodeman/nodes.py index 5e45e4e..cca7e6c 100644 --- a/nodeman/nodes.py +++ b/nodeman/nodes.py @@ -354,7 +354,7 @@ async def get_node_configuration( name: str, request: Request, ) -> NodeConfiguration: - """Enroll new node""" + """Get node configuration""" node = find_node(name) From 2c431dfe67a7608e608d978c6a161626f927fd04 Mon Sep 17 00:00:00 2001 From: Jakob Schlyter Date: Fri, 20 Dec 2024 08:27:26 +0100 Subject: [PATCH 4/6] Add telemetry for get configurations --- nodeman/nodes.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/nodeman/nodes.py b/nodeman/nodes.py index cca7e6c..bd3ae96 100644 --- a/nodeman/nodes.py +++ b/nodeman/nodes.py @@ -37,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() @@ -362,4 +365,8 @@ async def get_node_configuration( logging.debug("Node %s not activated", name, extra={"nodename": name}) raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Node not activated") - return create_node_configuration(name=name, request=request) + res = create_node_configuration(name=name, request=request) + + node_configurations_requested.add(1) + + return res From 01016aee3efcfdab0c921c1370ede8bf2d235cce Mon Sep 17 00:00:00 2001 From: Jakob Schlyter Date: Sun, 22 Dec 2024 12:18:09 +0100 Subject: [PATCH 5/6] Add Cache-Control header to get configuration --- nodeman/nodes.py | 5 +++++ nodeman/settings.py | 1 + tests/test_api.py | 1 + 3 files changed, 7 insertions(+) diff --git a/nodeman/nodes.py b/nodeman/nodes.py index bd3ae96..e43c896 100644 --- a/nodeman/nodes.py +++ b/nodeman/nodes.py @@ -356,6 +356,7 @@ async def renew_node( async def get_node_configuration( name: str, request: Request, + response: Response, ) -> NodeConfiguration: """Get node configuration""" @@ -369,4 +370,8 @@ async def get_node_configuration( 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 diff --git a/nodeman/settings.py b/nodeman/settings.py index ffcfa52..270f51a 100644 --- a/nodeman/settings.py +++ b/nodeman/settings.py @@ -45,6 +45,7 @@ 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) class User(BaseModel): diff --git a/tests/test_api.py b/tests/test_api.py index f625cd3..0293ef7 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -153,6 +153,7 @@ def _test_enroll(data_key: JWK, x509_key: PrivateKey, requested_name: str | None 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 From bac0983e9c01b22e45d1da966270283d5f136d12 Mon Sep 17 00:00:00 2001 From: Jakob Schlyter Date: Sun, 22 Dec 2024 12:23:25 +0100 Subject: [PATCH 6/6] ttl --- nodeman/settings.py | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/nodeman/settings.py b/nodeman/settings.py index 270f51a..2b95501 100644 --- a/nodeman/settings.py +++ b/nodeman/settings.py @@ -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, @@ -45,7 +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) + configuration_ttl: int = Field( + default=300, + gt=0, + le=86400, + description="Configuration cache TTL in seconds", + ) class User(BaseModel):