Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
00d5ae0
refactor(clients): make some changes to how clients work
soehlert Nov 21, 2025
77ce3fe
feat(client-admin): show the UUID with the client object in the admin…
soehlert Nov 21, 2025
6182b89
feat(client-IP): grab the client IP for further authentication
soehlert Nov 21, 2025
16b0d23
feat(client-updates): change hostname to client name, grab the client…
soehlert Nov 21, 2025
17d78d9
style(formatting): fix some formatting
soehlert Nov 23, 2025
24c9d7f
style(ruff): updating some ruff stuff
soehlert Nov 23, 2025
464b8e0
fix(error): there was a timing issue getting this information into th…
soehlert Dec 10, 2025
3334e72
test(API-call): fix the API call that had the wrong arguments causing…
soehlert Dec 17, 2025
b81827c
Merge branch 'main' into topic/soehlert/client_improvements
samoehlert Dec 18, 2025
02fb391
style(ruff): fix ruff errors
soehlert Dec 18, 2025
b42ae97
ci(docker): make sure to clean old containers before we start buildin…
soehlert Dec 18, 2025
ccd0dec
Merge branch 'topic/soehlert/client_improvements' of github.com:esnet…
soehlert Dec 18, 2025
bd29206
Revert "ci(docker): make sure to clean old containers before we start…
soehlert Dec 18, 2025
63b6cf9
test(hostname-rename): fix the setup on TestIsActive to use client_na…
soehlert Dec 18, 2025
a7289fc
docs(update-docstring): use allow listed to match our nomenclature
soehlert Dec 18, 2025
12240f9
style(black): ran black
soehlert Dec 18, 2025
c3269d9
style(black): ran black but formatter this time vs check? sighhh
soehlert Dec 18, 2025
92ce954
docs(docstring): update docstring to remove extraneous bits
soehlert Dec 19, 2025
601fc23
Merge branch 'main' into topic/soehlert/client_improvements
samoehlert Dec 19, 2025
8fd2686
refactor(basenames): add basename parameter to all of the registered …
soehlert Dec 19, 2025
b203021
fix(UUID-registration): add the UUID field to the serializer
soehlert Dec 19, 2025
502de71
fix(UUID): dont require the UUID on registration
soehlert Dec 19, 2025
8f797e0
test(client-registration): make sure to test that we can create clien…
soehlert Dec 19, 2025
cba39df
fix(update-client-code): fix client creation to allow for both creati…
soehlert Dec 19, 2025
3cbe78d
fix(merge): had to bring in code from main to fix merge conflicts
soehlert Dec 19, 2025
e6d053a
style(ruff): ran ruff format and check
soehlert Dec 19, 2025
43053d1
refactor(basenames): add basename parameter to all of the registered …
samoehlert Dec 19, 2025
d704386
fix(UUID-registration): add the UUID field to the serializer
samoehlert Dec 19, 2025
920485f
fix(UUID): dont require the UUID on registration
samoehlert Dec 19, 2025
873d977
test(client-registration): make sure to test that we can create clien…
samoehlert Dec 19, 2025
e2ee4ac
fix(update-client-code): fix client creation to allow for both creati…
samoehlert Dec 19, 2025
d3d4160
fix(process_updates): Fix expirations, cleanup syncing, and add integ…
samoehlert Dec 19, 2025
0724e29
style(ruff): ran ruff format and check
samoehlert Dec 19, 2025
1e253db
fix(rebase): please god let it be over
samoehlert Dec 19, 2025
c36eae7
fix(hostname): change hostname to client_name
samoehlert Dec 19, 2025
7d0386b
fix(hostname): nope theres still one more
samoehlert Dec 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
exclude: 'docs|migrations|.git|.tox'
default_stages: [commit]
default_stages: [pre-commit]
fail_fast: true

repos:
Expand Down
14 changes: 8 additions & 6 deletions config/api_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@

router = DefaultRouter()

router.register("users", UserViewSet)
router.register("actiontypes", ActionTypeViewSet)
router.register("register_client", ClientViewSet)
router.register("entries", EntryViewSet)
router.register("ignore_entries", IgnoreEntryViewSet)
router.register("is_active", IsActiveViewSet, "is_active")
# The basename parameter is really only required for the IsActiveViewSet since that has a dynamic queryset.
# The others have a static queryset so they don't strictly require it. I added it for consistency.
router.register("users", UserViewSet, basename="user")
router.register("actiontypes", ActionTypeViewSet, basename="actiontype")
router.register("register_client", ClientViewSet, basename="client")
router.register("entries", EntryViewSet, basename="entry")
router.register("ignore_entries", IgnoreEntryViewSet, basename="ignoreentry")
router.register("is_active", IsActiveViewSet, basename="is_active")

app_name = "api"
urlpatterns = router.urls
19 changes: 17 additions & 2 deletions scram/route_manager/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,15 @@
from django.contrib import admin
from simple_history.admin import SimpleHistoryAdmin

from .models import ActionType, Client, Entry, IgnoreEntry, Route, WebSocketMessage, WebSocketSequenceElement
from .models import (
ActionType,
Client,
Entry,
IgnoreEntry,
Route,
WebSocketMessage,
WebSocketSequenceElement,
)


class WhoFilter(admin.SimpleListFilter):
Expand Down Expand Up @@ -52,8 +60,15 @@ class EntryAdmin(SimpleHistoryAdmin):
search_fields = ["route", "comment"]


@admin.register(Client)
class ClientAdmin(admin.ModelAdmin):
"""Configure the Client and how it shows up in the Admin site."""

list_display = ("client_name", "uuid", "registered_from_ip")
readonly_fields = ("uuid",)


admin.site.register(IgnoreEntry, SimpleHistoryAdmin)
admin.site.register(Route)
admin.site.register(Client)
admin.site.register(WebSocketMessage)
admin.site.register(WebSocketSequenceElement)
5 changes: 4 additions & 1 deletion scram/route_manager/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,14 @@ class Meta:
class ClientSerializer(serializers.ModelSerializer):
"""Map the serializer to the model via Meta."""

uuid = serializers.UUIDField(required=False)

class Meta:
"""Maps to the Client model, and specifies the fields exposed by the API."""

model = Client
fields = ["hostname", "uuid"]
fields = ["client_name", "uuid", "registered_from_ip"]
read_only_fields = ["registered_from_ip"]


class IsActiveSerializer(serializers.ModelSerializer):
Expand Down
144 changes: 124 additions & 20 deletions scram/route_manager/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.db.models import Q
from drf_spectacular.utils import extend_schema
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework import status, viewsets
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from simple_history.utils import update_change_reason

from scram.shared.shared_code import get_client_ip

from ..models import ActionType, Client, Entry, IgnoreEntry, Route, WebSocketSequenceElement
from .exceptions import ActiontypeNotAllowed, IgnoredRoute, NoActiveEntryFound, PrefixTooLarge
from .serializers import (
Expand All @@ -30,11 +32,15 @@


@extend_schema(
description="API endpoint for actiontypes",
responses={200: ActionTypeSerializer},
description="API endpoint for actiontypes.",
responses={
200: OpenApiResponse(response=ActionTypeSerializer, description="Successful retrieval of actiontype(s)."),
403: OpenApiResponse(description="Authentication credentials were not provided."),
404: OpenApiResponse(description="The requested actiontype does not exist."),
},
)
class ActionTypeViewSet(viewsets.ReadOnlyModelViewSet):
"""Lookup ActionTypes by name when authenticated, and bind to the serializer."""
"""Lookup ActionTypes by name, and bind to the serializer."""

queryset = ActionType.objects.all()
permission_classes = (IsAuthenticated,)
Expand All @@ -43,8 +49,17 @@ class ActionTypeViewSet(viewsets.ReadOnlyModelViewSet):


@extend_schema(
description="API endpoint for ignore entries",
responses={200: IgnoreEntrySerializer},
description="API endpoint for ignore entries.",
responses={
200: OpenApiResponse(
response=IgnoreEntrySerializer, description="Successful retrieval or update of an ignore entry."
),
201: OpenApiResponse(response=IgnoreEntrySerializer, description="Ignore entry successfully created."),
204: OpenApiResponse(description="Ignore entry successfully deleted."),
400: OpenApiResponse(description="Invalid data provided."),
403: OpenApiResponse(description="Authentication credentials were not provided."),
404: OpenApiResponse(description="The requested ignore entry does not exist."),
},
)
class IgnoreEntryViewSet(viewsets.ModelViewSet):
"""Lookup IgnoreEntries by route when authenticated, and bind to the serializer."""
Expand All @@ -56,20 +71,80 @@ class IgnoreEntryViewSet(viewsets.ModelViewSet):


@extend_schema(
description="API endpoint for clients",
responses={200: ClientSerializer},
description="API endpoint for clients.",
responses={
200: OpenApiResponse(
response=ClientSerializer,
description="Client already existed and was retrieved successfully.",
),
201: OpenApiResponse(response=ClientSerializer, description="Client successfully created."),
400: OpenApiResponse(
description="Client with this name already exists with a different UUID, or client_name was not provided."
),
},
)
class ClientViewSet(viewsets.ModelViewSet):
"""Lookup Client by hostname on POSTs regardless of authentication, and bind to the serializer."""
"""Lookup Client by client_name on POSTs regardless of authentication, and bind to the serializer."""

queryset = Client.objects.all()
# We want to allow a client to be registered from anywhere
permission_classes = (AllowAny,)
serializer_class = ClientSerializer
lookup_field = "hostname"
lookup_field = "client_name"
http_method_names = ["post"]

def perform_create(self, serializer):
"""Create a new Client, capturing the IP address it was created from."""
ip = get_client_ip(self.request)
serializer.save(registered_from_ip=ip)

def create(self, request, *args, **kwargs):
"""Create a new Client while avoiding information leaks hopefully."""
client_name = request.data.get("client_name")
request_uuid = request.data.get("uuid")

if not client_name:
return Response({"detail": "client_name is required."}, status=status.HTTP_400_BAD_REQUEST)

existing_client = self.queryset.filter(client_name=client_name).first()

if existing_client:
if request_uuid and str(existing_client.uuid) == request_uuid:
# Idempotent success
serializer = self.get_serializer(existing_client)
return Response(serializer.data, status=status.HTTP_200_OK)

# Log the conflict without leaking client_names to the user
logger.warning(
"Client named %s already exists with a different UUID",
client_name,
)
return Response(
{"detail": "Invalid client registration request."},
status=status.HTTP_400_BAD_REQUEST,
)

return super().create(request, *args, **kwargs)


@extend_schema(
description="API endpoint to check if a route is active.",
parameters=[
{
"name": "cidr",
"required": True,
"type": "string",
"description": "The CIDR network to check (e.g., 192.0.2.0/24).",
"in": "query",
}
],
responses={
200: OpenApiResponse(
response=IsActiveSerializer, description="The 'is_active' field indicates the status of the route."
),
400: OpenApiResponse(description="The 'cidr' parameter is missing or invalid."),
},
)
class IsActiveViewSet(viewsets.ReadOnlyModelViewSet):
"""Look up a route to see if SCRAM considers it active or deactivated."""

Expand Down Expand Up @@ -116,8 +191,15 @@ def list(self, request):


@extend_schema(
description="API endpoint for entries",
responses={200: EntrySerializer},
description="API endpoint for entries.",
responses={
200: OpenApiResponse(response=EntrySerializer, description="Successful retrieval of an entry/entries."),
201: OpenApiResponse(response=EntrySerializer, description="Entry successfully created."),
204: OpenApiResponse(description="Entry successfully deleted."),
400: OpenApiResponse(description="The route is likely on the ignore list or the prefix is too large."),
403: OpenApiResponse(description="The client is not authorized for this action."),
404: OpenApiResponse(description="The requested entry does not exist."),
},
)
class EntryViewSet(viewsets.ModelViewSet):
"""Lookup Entry when authenticated, and bind to the serializer."""
Expand All @@ -139,18 +221,40 @@ def get_permissions(self):
return super().get_permissions()

def check_client_authorization(self, actiontype):
"""Ensure that a given client is authorized to use a given actiontype."""
"""Ensure that a given client is authorized to use a given actiontype and IP address."""
uuid = self.request.data.get("uuid")
if uuid:
authorized_actiontypes = Client.objects.filter(uuid=uuid).values_list(
"authorized_actiontypes__name",
flat=True,
)
authorized_client = Client.objects.filter(uuid=uuid).values("is_authorized")
if not authorized_client or actiontype not in authorized_actiontypes:
logger.debug("Client: %s, actiontypes: %s", uuid, authorized_actiontypes)
try:
client = Client.objects.get(uuid=uuid)
except Client.DoesNotExist as client_dne:
msg = "Client does not exist"
raise PermissionDenied(msg) from client_dne

# Check if client is authorized for the action type
if not client.is_authorized or actiontype not in client.authorized_actiontypes.values_list(
"name", flat=True
):
logger.debug(
"Client: %s, actiontypes: %s",
uuid,
list(client.authorized_actiontypes.values_list("name", flat=True)),
)
logger.info("%s is not allowed to add an entry to the %s list.", uuid, actiontype)
raise ActiontypeNotAllowed

# Check if the client's IP address is allow listed
if client.registered_from_ip:
request_ip = self.get_client_ip()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure how tests are passing with this, but when I try running a POST to the entries API, I get a 500:

django-1                |   File "/app/scram/route_manager/api/views.py", line 247, in check_client_authorization
django-1                |     request_ip = self.get_client_ip()
django-1                |                  ^^^^^^^^^^^^^^^^^^
django-1                | AttributeError: 'EntryViewSet' object has no attribute 'get_client_ip'

I think this needs to be request_ip = get_client_ip(self.request)?

if client.registered_from_ip != request_ip:
logger.warning(
"Client %s attempted to authorize from unauthorized IP %s (expected %s)",
uuid,
request_ip,
client.registered_from_ip,
)
msg = "Request from unauthorized IP address %s"
raise PermissionDenied(msg)
Comment on lines +245 to +256
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's our plan for existing clients that have already been registered?

Right now, our existing clients will get denied with a null IP in the database so I think we should consider:

  1. null IP entries are allowed to auth for existing clients but then we populate the IP address after it tries to do something (seems like a potential avenue for someone adding their own IP.
  2. we manually update our databases to have the IP of our existing clients.
  3. we write a one time use management script to go and find the A/AAAA records for a host and add them during that one time use.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another thought... Say I'm a bad person and I'm trying to enumerate what's allowed from an IP standpoint or maybe trying to get information about what's behind a load balancer. Could I then maybe use this to grab the load-balancer's IP address and use that for something nefarious?


elif not self.request.user.has_perm("route_manager.can_add_entry"):
raise PermissionDenied

Expand Down
19 changes: 19 additions & 0 deletions scram/route_manager/migrations/0035_alter_client_uuid.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Generated by Django 4.2.26 on 2025-11-21 05:52

from django.db import migrations, models
import uuid


class Migration(migrations.Migration):

dependencies = [
("route_manager", "0034_alter_entry_originating_scram_instance_and_more"),
]

operations = [
migrations.AlterField(
model_name="client",
name="uuid",
field=models.UUIDField(default=uuid.uuid4, editable=False),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.26 on 2025-11-21 06:03

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
("route_manager", "0035_alter_client_uuid"),
]

operations = [
migrations.RenameField(
model_name="client",
old_name="hostname",
new_name="client_name",
),
]
18 changes: 18 additions & 0 deletions scram/route_manager/migrations/0037_client_registered_from_ip.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.26 on 2025-11-21 06:08

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("route_manager", "0036_rename_hostname_client_client_name"),
]

operations = [
migrations.AddField(
model_name="client",
name="registered_from_ip",
field=models.GenericIPAddressField(blank=True, null=True),
),
]
19 changes: 19 additions & 0 deletions scram/route_manager/migrations/0038_unique_client_uuid.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Generated by Django 4.2.26 on 2025-11-21 06:17

from django.db import migrations, models
import uuid


class Migration(migrations.Migration):

dependencies = [
("route_manager", "0037_client_registered_from_ip"),
]

operations = [
migrations.AlterField(
model_name="client",
name="uuid",
field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
),
]
9 changes: 5 additions & 4 deletions scram/route_manager/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,15 +169,16 @@ def __str__(self):
class Client(models.Model):
"""Any client that would like to hit the API to add entries (e.g. Zeek)."""

hostname = models.CharField(max_length=50, unique=True)
uuid = models.UUIDField()
client_name = models.CharField(max_length=50, unique=True)
uuid = models.UUIDField(default=uuid_lib.uuid4, editable=False, unique=True)
registered_from_ip = models.GenericIPAddressField(null=True, blank=True)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this work for dual-stacked hosts? Sometimes they might connect from IPv4, sometimes from IPv6. Additionally, do we need to come up with a method for a client to change its IP automagically? If we don't, how will we handle hosts getting renumbered (or even how will we handle multiple v6 addresses on a host (privacy, ula, etc).


is_authorized = models.BooleanField(null=True, blank=True, default=False)
authorized_actiontypes = models.ManyToManyField(ActionType)

def __str__(self):
"""Only display the hostname."""
return str(self.hostname)
"""Only display the client_name."""
return str(self.client_name)


channel_layer = get_channel_layer()
Loading
Loading