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..e43c896 100644 --- a/nodeman/nodes.py +++ b/nodeman/nodes.py @@ -19,6 +19,7 @@ NodeCertificate, NodeCollection, NodeConfiguration, + NodeEnrollmentResult, NodeInformation, PublicKeyFormat, RenewalRequest, @@ -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() @@ -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, @@ -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"], @@ -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) @@ -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, @@ -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 diff --git a/nodeman/settings.py b/nodeman/settings.py index ffcfa52..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,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): diff --git a/tests/test_api.py b/tests/test_api.py index b363cb4..0293ef7 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,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