Skip to content

Commit

Permalink
Add support for node tags (#60)
Browse files Browse the repository at this point in the history
* Add support for node tags

* Add basic constraints on node tags (max length per tag 100, not more than 100 tags per node)

* Strip tags

* Use unique tags

* Better input validator for names and tags

* More documentation

* more
  • Loading branch information
jschlyter authored Jan 13, 2025
1 parent 04c2602 commit 7281359
Show file tree
Hide file tree
Showing 7 changed files with 201 additions and 17 deletions.
12 changes: 11 additions & 1 deletion nodeman/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,17 @@ def command_create(args: argparse.Namespace) -> NodeBootstrapInformation:

client = get_admin_client(args)

payload = {
**({"name": args.name} if args.name else {}),
**(
{"tags": [tag.strip() for tag in args.tags.split(",") if tag.strip()]}
if hasattr(args, "tags") and args.tags
else {}
),
}

try:
response = client.post(urljoin(args.server, "/api/v1/node"))
response = client.post(urljoin(args.server, "/api/v1/node"), json=payload)
response.raise_for_status()
except httpx.HTTPError as exc:
logging.error("Failed to create node: %s", str(exc))
Expand Down Expand Up @@ -314,6 +323,7 @@ def main() -> None:
admin_create_parser.set_defaults(func=command_create)
add_admin_arguments(admin_create_parser)
admin_create_parser.add_argument("--name", metavar="name", help="Node name")
admin_create_parser.add_argument("--tags", metavar="tags", help="Node tags")

admin_get_parser = subparsers.add_parser("get", help="Get node")
admin_get_parser.set_defaults(func=command_get)
Expand Down
14 changes: 12 additions & 2 deletions nodeman/db_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from cryptography import x509
from cryptography.hazmat.primitives import serialization
from cryptography.x509.oid import ExtensionOID
from mongoengine import DateTimeField, DictField, Document, StringField, ValidationError
from mongoengine import DateTimeField, DictField, Document, SortedListField, StringField, ValidationError
from mongoengine.errors import NotUniqueError

from .names import get_deterministic_name, get_random_name
Expand All @@ -15,7 +15,12 @@


class TapirNode(Document):
meta = {"collection": "nodes"}
meta = {
"collection": "nodes",
"indexes": [
{"fields": ["tags"]},
],
}

name = StringField(unique=True)
domain = StringField()
Expand All @@ -24,6 +29,11 @@ class TapirNode(Document):
activated = DateTimeField()
deleted = DateTimeField()

tags = SortedListField(
StringField(regex=r"^[a-zA-Z0-9_/-]+$", min_length=1, max_length=100),
max_length=100,
)

@classmethod
def create_random_node(cls, domain: str) -> Self:
name = get_random_name() + "." + domain
Expand Down
26 changes: 24 additions & 2 deletions nodeman/models.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import re
from datetime import datetime, timezone
from enum import StrEnum
from typing import Self
from typing import Annotated, Self

from cryptography.x509 import load_pem_x509_certificates
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, StringConstraints, field_validator
from pydantic.types import AwareDatetime

from .db_models import TapirNode
Expand All @@ -12,6 +13,10 @@

MAX_REQUEST_AGE = 300

DOMAIN_NAME_RE = re.compile(r"^(?=.{1,255}$)(?!-)[A-Za-z0-9\-]{1,63}(\.[A-Za-z0-9\-]{1,63})*\.?(?<!-)$")

NodeTag = Annotated[str, StringConstraints(pattern=r"^[A-Za-z0-9/\-\.]{1,100}$")]


class PublicKeyFormat(StrEnum):
PEM = "application/x-pem-file"
Expand All @@ -26,6 +31,21 @@ def from_accept(cls, accept: str | None) -> Self:
raise ValueError(f"Unsupported format. Acceptable formats: {[f.value for f in cls]} or */*")


class NodeCreateRequest(BaseModel):
name: str | None = Field(
title="Node name",
description="Optional hostname for the node. Must be a valid domain name.",
json_schema_extra={"format": "hostname"},
default=None,
)
tags: list[NodeTag] | None = Field(
title="Node tags",
description="Optional list of tags for categorizing the node. Each tag must be alphanumeric with optional /, -, or . characters.",
max_length=100,
default=None,
)


class NodeRequest(BaseModel):
timestamp: AwareDatetime = Field(title="Timestamp")
x509_csr: str = Field(title="X.509 Client Certificate Bundle")
Expand All @@ -49,13 +69,15 @@ class NodeInformation(BaseModel):
name: str = Field(title="Node name")
public_key: PublicJwk | None = Field(title="Public key")
activated: AwareDatetime | None = Field(title="Activated")
tags: list[str] | None = Field(title="Node tags")

@classmethod
def from_db_model(cls, node: TapirNode):
return cls(
name=node.name,
public_key=public_key_factory(node.public_key) if node.public_key else None,
activated=node.activated,
tags=node.tags,
)


Expand Down
37 changes: 32 additions & 5 deletions nodeman/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@
from .db_models import TapirCertificate, TapirNode, TapirNodeEnrollment
from .jose import PublicEC, PublicOKP, PublicRSA
from .models import (
DOMAIN_NAME_RE,
EnrollmentRequest,
NodeBootstrapInformation,
NodeCertificate,
NodeCollection,
NodeConfiguration,
NodeCreateRequest,
NodeEnrollmentResult,
NodeInformation,
PublicKeyFormat,
Expand Down Expand Up @@ -128,22 +130,47 @@ def process_csr_request(request: Request, csr: x509.CertificateSigningRequest, n
response_model_exclude_none=True,
)
async def create_node(
request: Request, username: Annotated[str, Depends(get_current_username)], name: str | None = None
username: Annotated[str, Depends(get_current_username)],
request: Request,
create_request: NodeCreateRequest | None = None,
) -> NodeBootstrapInformation:
"""Create node"""
"""
Create a new node with optional name and tags.
Args:
username: The authenticated user creating the node.
request: The FastAPI request object.
create_request: Optional request containing:
- name: Optional hostname (must be a valid domain name)
- tags: Optional list of tags (alphanumeric with /, -, or .)
Maximum length: 100 characters per tag
Returns:
NodeBootstrapInformation: Information needed to bootstrap the node.
Raises:
HTTPException: If the node name is invalid.
"""

name = create_request.name if create_request and create_request.name else None
tags = list(set(create_request.tags)) if create_request and create_request.tags else None

node_enrollment_key = JWK.generate(kty="oct", size=256, alg="HS256")
domain = request.app.settings.nodes.domain

node_enrollment_key = JWK.generate(kty="oct", size=256, alg="HS256")

if name is None:
node = TapirNode.create_next_node(domain=request.app.settings.nodes.domain)
elif name.endswith(f".{domain}"):
node = TapirNode.create_next_node(domain=domain)
elif name.endswith(f".{domain}") and DOMAIN_NAME_RE.match(name):
logging.debug("Explicit node name %s requested", name, extra={"nodename": name})
node = TapirNode(name=name, domain=domain).save()
else:
logging.warning("Explicit node name %s not acceptable", name, extra={"nodename": name})
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Invalid node name")

node.tags = tags
node.save()

TapirNodeEnrollment(
name=node.name,
key=node_enrollment_key.export(as_dict=True, private_key=node_enrollment_key.kty == "oct"),
Expand Down
Loading

0 comments on commit 7281359

Please sign in to comment.